mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 17:41:38 +00:00
* refactor(config): consolidate media model lists * refactor(config): unify memory configuration * refactor(config): consolidate TTS ownership * refactor(config): move typing policy to agents * refactor(config): retire product-level config surfaces * refactor(config): share scoped tool policy type * chore(config): refresh generated baselines * fix(config): honor agent typing overrides * fix(config): migrate sibling config consumers * refactor(infra): keep base64url decoder private * fix(config): strip invalid legacy TTS values * chore(config): refresh rebased baseline hash * fix(doctor): route legacy messages.tts.realtime voice to talk during tts move * refactor(config): polish final layout names * refactor(config): freeze retired tuning defaults * feat(config): add fast mode default symmetry * refactor(config): key agent entries by id * docs(config): update final layout reference * test(config): cover final layout migrations * chore(config): refresh final layout baselines * fix(config): align final layout runtime readers * fix(config): align remaining readers * fix(config): stabilize final layout migrations * fix(config): finalize config projection proof * fix(config): address final layout review * docs(release): preserve historical config names * fix(config): complete keyed agent migration * fix(config): close final migration gaps * fix(config): finish full-branch review * fix(config): complete runtime secret detection * fix(config): close final review findings * fix(config): finish canonical docs and heartbeat migration * fix(config): integrate latest main after rebase * refactor(env): isolate test-only controls * refactor(env): isolate build and development controls * refactor(env): collapse process identity indirection * refactor(env): remove duplicate config and temp aliases * docs(env): define the operator-facing allowlist * ci(env): ratchet production variable count * fix(env): remove stale provider helper import * fix(env): make ratchet sorting explicit * test(env): keep test seam in dead-code audit * test(env): cover ratchet growth and boundary; document surface budgets * docs(config): document tier-eval consolidations * docs(config): clarify speech preference ownership * test(memory): align retired tuning fixtures * refactor(memory): freeze engine heuristics * refactor(config): apply tier-eval tranche * refactor(tts): move persona shaping to providers * refactor(compaction): move prompt policy to providers * test(config): align hookified prompt fixtures * chore(deadcode): classify test-only exports * chore(github): remove unused spawn helper * chore(deadcode): classify queue diagnostics * chore(deadcode): remove unused lane snapshot export * chore(plugin-sdk): ratchet consolidated surface * fix(config): integrate latest main after rebase
850 lines
27 KiB
TypeScript
850 lines
27 KiB
TypeScript
/** Tests SecretRef provider resolution for env, file, and exec sources. */
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
|
|
import {
|
|
killPidIfAlive,
|
|
readPidFile,
|
|
waitForPidToExit,
|
|
writeForkingNoOutputScript,
|
|
} from "../test-utils/process-tree.js";
|
|
import { INVALID_EXEC_SECRET_REF_IDS } from "../test-utils/secret-ref-test-vectors.js";
|
|
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
|
|
import {
|
|
isMissingSecretRefResolutionError,
|
|
resolveSecretRefString,
|
|
resolveSecretRefValue,
|
|
resolveSecretRefValues,
|
|
} from "./resolve.js";
|
|
|
|
async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise<void> {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
|
.toString(16)
|
|
.slice(2)}`;
|
|
try {
|
|
await fs.writeFile(tempPath, content, "utf8");
|
|
await fs.chmod(tempPath, mode);
|
|
await fs.rename(tempPath, filePath);
|
|
} catch (err) {
|
|
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
describe("secret ref resolver", () => {
|
|
const isWindows = process.platform === "win32";
|
|
function itPosix(name: string, fn: () => Promise<void> | void) {
|
|
it.skipIf(isWindows)(name, fn);
|
|
}
|
|
let fixtureRoot = "";
|
|
let caseId = 0;
|
|
let execProtocolV1ScriptPath = "";
|
|
let execPlainScriptPath = "";
|
|
let execProtocolV2ScriptPath = "";
|
|
let execMissingIdScriptPath = "";
|
|
let execInheritedErrorScriptPath = "";
|
|
let execProviderErrorScriptPath = "";
|
|
let execUnsafeProviderErrorScriptPath = "";
|
|
let execInvalidJsonScriptPath = "";
|
|
let execFastExitScriptPath = "";
|
|
|
|
const createCaseDir = async (label: string): Promise<string> => {
|
|
const dir = path.join(fixtureRoot, `${label}-${caseId++}`);
|
|
await fs.mkdir(dir);
|
|
return dir;
|
|
};
|
|
|
|
type ExecProviderConfig = {
|
|
source: "exec";
|
|
command: string;
|
|
passEnv?: string[];
|
|
jsonOnly?: boolean;
|
|
allowSymlinkCommand?: boolean;
|
|
trustedDirs?: string[];
|
|
env?: Record<string, string>;
|
|
args?: string[];
|
|
timeoutMs?: number;
|
|
noOutputTimeoutMs?: number;
|
|
};
|
|
type FileProviderConfig = {
|
|
source: "file";
|
|
path: string;
|
|
mode: "json" | "singleValue";
|
|
timeoutMs?: number;
|
|
allowInsecurePath?: boolean;
|
|
};
|
|
|
|
function createExecProviderConfig(
|
|
command: string,
|
|
overrides: Partial<ExecProviderConfig> = {},
|
|
): ExecProviderConfig {
|
|
return {
|
|
source: "exec",
|
|
command,
|
|
passEnv: ["PATH"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function resolveExecSecret(
|
|
command: string,
|
|
overrides: Partial<ExecProviderConfig> = {},
|
|
): Promise<string> {
|
|
return resolveSecretRefString(
|
|
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
execmain: createExecProviderConfig(command, overrides),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function createFileProviderConfig(
|
|
filePath: string,
|
|
overrides: Partial<FileProviderConfig> = {},
|
|
): FileProviderConfig {
|
|
return {
|
|
source: "file",
|
|
path: filePath,
|
|
mode: "json",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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 });
|
|
|
|
execProtocolV1ScriptPath = path.join(sharedExecDir, "resolver-v1.sh");
|
|
await writeSecureFile(
|
|
execProtocolV1ScriptPath,
|
|
[
|
|
"#!/bin/sh",
|
|
'printf \'{"protocolVersion":1,"values":{"openai/api-key":"value:openai/api-key"}}\'',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
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,
|
|
);
|
|
|
|
execInheritedErrorScriptPath = path.join(sharedExecDir, "resolver-inherited-error.sh");
|
|
await writeSecureFile(
|
|
execInheritedErrorScriptPath,
|
|
[
|
|
"#!/bin/sh",
|
|
'printf \'{"protocolVersion":1,"values":{"toString":"resolved"},"errors":{}}\'',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
execProviderErrorScriptPath = path.join(sharedExecDir, "resolver-error.sh");
|
|
await writeSecureFile(
|
|
execProviderErrorScriptPath,
|
|
[
|
|
"#!/bin/sh",
|
|
'printf \'{"protocolVersion":1,"values":{},"errors":{"openai/api-key":{"code":"NOT_FOUND","message":"provider-private-detail-7f3c"}}}\'',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
execUnsafeProviderErrorScriptPath = path.join(sharedExecDir, "resolver-unsafe-error.sh");
|
|
await writeSecureFile(
|
|
execUnsafeProviderErrorScriptPath,
|
|
[
|
|
"#!/bin/sh",
|
|
'printf \'{"protocolVersion":1,"values":{},"errors":{"openai/api-key":{"code":"PROVIDERPRIVATEDETAIL9C2E"}}}\'',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
execInvalidJsonScriptPath = path.join(sharedExecDir, "resolver-invalid-json.sh");
|
|
await writeSecureFile(
|
|
execInvalidJsonScriptPath,
|
|
["#!/bin/sh", "printf 'not-json'"].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
execFastExitScriptPath = path.join(sharedExecDir, "resolver-fast-exit.sh");
|
|
await writeSecureFile(execFastExitScriptPath, ["#!/bin/sh", "exit 0"].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 () => {
|
|
const config: OpenClawConfig = {};
|
|
const value = await resolveSecretRefString(
|
|
{ source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
{
|
|
config,
|
|
env: { OPENAI_API_KEY: "sk-env-value" }, // pragma: allowlist secret
|
|
},
|
|
);
|
|
expect(value).toBe("sk-env-value");
|
|
});
|
|
|
|
it("classifies only matching absent env refs as missing", async () => {
|
|
const ref = { source: "env", provider: "default", id: "MISSING_API_KEY" } as const;
|
|
const missingError = await resolveSecretRefValue(ref, { config: {}, env: {} }).catch(
|
|
(error: unknown) => error,
|
|
);
|
|
|
|
expect(isMissingSecretRefResolutionError({ ref, error: missingError })).toBe(true);
|
|
expect(
|
|
isMissingSecretRefResolutionError({
|
|
ref: { ...ref, id: "OTHER_API_KEY" },
|
|
error: missingError,
|
|
}),
|
|
).toBe(false);
|
|
|
|
const policyError = await resolveSecretRefValue(ref, {
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
default: { source: "env", allowlist: ["OTHER_API_KEY"] },
|
|
},
|
|
},
|
|
},
|
|
env: { MISSING_API_KEY: "test-missing-api-key" },
|
|
}).catch((error: unknown) => error);
|
|
expect(isMissingSecretRefResolutionError({ ref, error: policyError })).toBe(false);
|
|
});
|
|
|
|
it("classifies missing refs under a configured default provider alias", async () => {
|
|
const ref = { source: "env", provider: "primary", id: "MISSING_API_KEY" } as const;
|
|
const error = await resolveSecretRefValue(ref, {
|
|
config: {
|
|
secrets: {
|
|
defaults: { env: "primary" },
|
|
providers: { primary: { source: "env" } },
|
|
},
|
|
},
|
|
env: {},
|
|
}).catch((caught: unknown) => caught);
|
|
|
|
expect(isMissingSecretRefResolutionError({ ref, error })).toBe(true);
|
|
});
|
|
|
|
it("does not rewrite an explicit default provider to a configured alias", async () => {
|
|
const ref = { source: "env", provider: "default", id: "MISSING_API_KEY" } as const;
|
|
const error = await resolveSecretRefValue(ref, {
|
|
config: {
|
|
secrets: {
|
|
defaults: { env: "primary" },
|
|
providers: { primary: { source: "env" } },
|
|
},
|
|
},
|
|
env: {},
|
|
}).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain('Secret provider "default" is not configured');
|
|
expect(isMissingSecretRefResolutionError({ ref, error })).toBe(false);
|
|
});
|
|
|
|
itPosix("resolves file refs in json mode", async () => {
|
|
const root = await createCaseDir("file");
|
|
const filePath = path.join(root, "secrets.json");
|
|
await writeSecureFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
providers: {
|
|
openai: {
|
|
apiKey: "sk-file-value", // pragma: allowlist secret
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const value = await resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(value).toBe("sk-file-value");
|
|
});
|
|
|
|
itPosix("classifies an out-of-bounds file pointer as a missing ref", async () => {
|
|
const root = await createCaseDir("file-missing-index");
|
|
const filePath = path.join(root, "secrets.json");
|
|
await writeSecureFile(filePath, JSON.stringify({ providers: [] }));
|
|
const ref = { source: "file", provider: "filemain", id: "/providers/0" } as const;
|
|
const error = await resolveSecretRefValue(ref, {
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath),
|
|
},
|
|
},
|
|
},
|
|
}).catch((caught: unknown) => caught);
|
|
|
|
expect(isMissingSecretRefResolutionError({ ref, error })).toBe(true);
|
|
});
|
|
|
|
itPosix("resolves exec refs with protocolVersion 1 response", async () => {
|
|
const value = await resolveExecSecret(execProtocolV1ScriptPath);
|
|
expect(value).toBe("value:openai/api-key");
|
|
});
|
|
|
|
itPosix("surfaces bounded exec error codes without provider-supplied detail", async () => {
|
|
const error = await resolveExecSecret(execProviderErrorScriptPath).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toBe(
|
|
'Exec provider "execmain" failed for id "openai/api-key" (NOT_FOUND).',
|
|
);
|
|
expect((error as Error).message).not.toContain("provider-private-detail-7f3c");
|
|
});
|
|
|
|
itPosix(
|
|
"classifies omitted and NOT_FOUND exec ids as missing but keeps other errors fail-closed",
|
|
async () => {
|
|
const ref = { source: "exec", provider: "execmain", id: "openai/api-key" } as const;
|
|
const configFor = (command: string): OpenClawConfig => ({
|
|
secrets: {
|
|
providers: {
|
|
execmain: createExecProviderConfig(command),
|
|
},
|
|
},
|
|
});
|
|
const omittedError = await resolveSecretRefValue(ref, {
|
|
config: configFor(execMissingIdScriptPath),
|
|
}).catch((error: unknown) => error);
|
|
const missingError = await resolveSecretRefValue(ref, {
|
|
config: configFor(execProviderErrorScriptPath),
|
|
}).catch((error: unknown) => error);
|
|
const providerError = await resolveSecretRefValue(ref, {
|
|
config: configFor(execUnsafeProviderErrorScriptPath),
|
|
}).catch((error: unknown) => error);
|
|
|
|
expect(isMissingSecretRefResolutionError({ ref, error: omittedError })).toBe(true);
|
|
expect(isMissingSecretRefResolutionError({ ref, error: missingError })).toBe(true);
|
|
expect(isMissingSecretRefResolutionError({ ref, error: providerError })).toBe(false);
|
|
},
|
|
);
|
|
|
|
itPosix("suppresses exec error codes outside the bounded format", async () => {
|
|
const error = await resolveExecSecret(execUnsafeProviderErrorScriptPath).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toBe(
|
|
'Exec provider "execmain" failed for id "openai/api-key".',
|
|
);
|
|
expect((error as Error).message).not.toContain("PROVIDERPRIVATEDETAIL9C2E");
|
|
});
|
|
|
|
itPosix("clamps oversized exec provider timeouts", async () => {
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
|
|
const value = await resolveExecSecret(execProtocolV1ScriptPath, {
|
|
timeoutMs: Number.MAX_SAFE_INTEGER,
|
|
noOutputTimeoutMs: Number.MAX_SAFE_INTEGER,
|
|
});
|
|
|
|
expect(value).toBe("value:openai/api-key");
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
|
});
|
|
|
|
itPosix("uses timeoutMs as the default no-output timeout for exec providers", async () => {
|
|
const root = await createCaseDir("exec-delay");
|
|
const scriptPath = path.join(root, "resolver-delay.sh");
|
|
// Keep the fixture cheap to start so this stays deterministic under a busy test run.
|
|
await writeSecureFile(
|
|
scriptPath,
|
|
[
|
|
"#!/bin/sh",
|
|
"sleep 0.03",
|
|
'printf \'{"protocolVersion":1,"values":{"delayed":"ok"}}\'',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
|
|
const value = await resolveSecretRefString(
|
|
{ source: "exec", provider: "execmain", id: "delayed" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
execmain: {
|
|
source: "exec",
|
|
command: scriptPath,
|
|
passEnv: ["PATH"],
|
|
timeoutMs: 1500,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(value).toBe("ok");
|
|
});
|
|
|
|
itPosix("kills forked exec provider children on no-output timeout", async () => {
|
|
const root = await createCaseDir("exec-fork-timeout");
|
|
const scriptPath = await writeForkingNoOutputScript(root);
|
|
const pidPath = path.join(root, "forked.pid");
|
|
let childPid: number | undefined;
|
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
const noOutputTimeouts: Array<() => void> = [];
|
|
const setTimeoutSpy = vi
|
|
.spyOn(globalThis, "setTimeout")
|
|
.mockImplementation((callback, delay, ...args) => {
|
|
if (delay === 1_000) {
|
|
noOutputTimeouts.push(() => callback(...args));
|
|
return nativeSetTimeout(() => undefined, 60_000);
|
|
}
|
|
return nativeSetTimeout(callback, delay, ...args);
|
|
});
|
|
|
|
try {
|
|
const resultPromise = resolveExecSecret(scriptPath, {
|
|
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
|
// Preserve production-like startup headroom; the test fires the
|
|
// re-armed timer only after the readiness byte arrives.
|
|
noOutputTimeoutMs: 1_000,
|
|
timeoutMs: 10_000,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
childPid = await readPidFile(pidPath);
|
|
noOutputTimeouts.at(-1)?.();
|
|
await expect(resultPromise).rejects.toThrow('Exec provider "execmain" produced no output');
|
|
expect(await waitForPidToExit(childPid, 5_000)).toBe(true);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
killPidIfAlive(childPid);
|
|
}
|
|
});
|
|
|
|
itPosix("supports non-JSON single-value exec output when jsonOnly is false", async () => {
|
|
const value = await resolveExecSecret(execPlainScriptPath, { jsonOnly: false });
|
|
expect(value).toBe("plain-secret");
|
|
});
|
|
|
|
itPosix(
|
|
"tolerates stdin write errors when exec provider exits before consuming a large request",
|
|
async () => {
|
|
const refs = Array.from({ length: 256 }, (_, index) => ({
|
|
source: "exec" as const,
|
|
provider: "execmain",
|
|
id: `openai/${String(index).padStart(3, "0")}/${"x".repeat(240)}`,
|
|
}));
|
|
await expect(
|
|
resolveSecretRefValues(refs, {
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
execmain: {
|
|
source: "exec",
|
|
command: execFastExitScriptPath,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow('Exec provider "execmain" returned empty stdout.');
|
|
},
|
|
);
|
|
|
|
it("enforces the built-in per-provider reference limit", async () => {
|
|
const refs = Array.from({ length: 513 }, (_, index) => ({
|
|
source: "env" as const,
|
|
provider: "default",
|
|
id: `SECRET_${index}`,
|
|
}));
|
|
|
|
await expect(resolveSecretRefValues(refs, { config: {} })).rejects.toThrow(
|
|
'Secret provider "default" exceeded maxRefsPerProvider (512).',
|
|
);
|
|
});
|
|
|
|
itPosix("rejects symlink command paths", async () => {
|
|
const root = await createCaseDir("exec-link-reject");
|
|
const symlinkPath = path.join(root, "resolver-link.mjs");
|
|
await fs.symlink(execPlainScriptPath, symlinkPath);
|
|
|
|
await expect(resolveExecSecret(symlinkPath, { jsonOnly: false })).rejects.toThrow(
|
|
"must not be a symlink",
|
|
);
|
|
});
|
|
|
|
itPosix("stays fail-closed when the retired symlink opt-out is present", async () => {
|
|
const root = await createCaseDir("exec-link-allow");
|
|
const symlinkPath = path.join(root, "resolver-link.mjs");
|
|
await fs.symlink(execPlainScriptPath, symlinkPath);
|
|
await expect(
|
|
resolveExecSecret(symlinkPath, {
|
|
jsonOnly: false,
|
|
allowSymlinkCommand: true,
|
|
}),
|
|
).rejects.toThrow("must not be a symlink");
|
|
});
|
|
|
|
itPosix(
|
|
"rejects Homebrew-style symlinked exec commands even with the retired opt-out",
|
|
async () => {
|
|
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 });
|
|
await fs.mkdir(cellarDir, { recursive: true });
|
|
|
|
const targetCommand = path.join(cellarDir, "node");
|
|
const symlinkCommand = path.join(binDir, "node");
|
|
await writeSecureFile(
|
|
targetCommand,
|
|
[
|
|
"#!/bin/sh",
|
|
'suffix="${1:-missing}"',
|
|
'printf \'{"protocolVersion":1,"values":{"openai/api-key":"%s:openai/api-key"}}\' "$suffix"',
|
|
].join("\n"),
|
|
0o700,
|
|
);
|
|
await fs.symlink(targetCommand, symlinkCommand);
|
|
await expect(
|
|
resolveExecSecret(symlinkCommand, {
|
|
args: ["brew"],
|
|
allowSymlinkCommand: true,
|
|
}),
|
|
).rejects.toThrow("must not be a symlink");
|
|
},
|
|
);
|
|
|
|
itPosix("rejects symlinks before trusted-directory evaluation", async () => {
|
|
const root = await createCaseDir("exec-link-trusted");
|
|
const symlinkPath = path.join(root, "resolver-link.mjs");
|
|
await fs.symlink(execPlainScriptPath, symlinkPath);
|
|
|
|
await expect(
|
|
resolveExecSecret(symlinkPath, {
|
|
jsonOnly: false,
|
|
allowSymlinkCommand: true,
|
|
trustedDirs: [root],
|
|
}),
|
|
).rejects.toThrow("must not be a symlink");
|
|
});
|
|
|
|
itPosix("rejects exec refs when protocolVersion is not 1", async () => {
|
|
await expect(resolveExecSecret(execProtocolV2ScriptPath)).rejects.toThrow(
|
|
"protocolVersion must be 1",
|
|
);
|
|
});
|
|
|
|
itPosix("rejects exec refs when response omits requested id", async () => {
|
|
await expect(resolveExecSecret(execMissingIdScriptPath)).rejects.toThrow(
|
|
'response missing id "openai/api-key"',
|
|
);
|
|
});
|
|
|
|
itPosix("rejects exec refs when missing response id is inherited", async () => {
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "exec", provider: "execmain", id: "toString" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
execmain: createExecProviderConfig(execMissingIdScriptPath),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow('response missing id "toString"');
|
|
});
|
|
|
|
itPosix("ignores inherited exec response errors", async () => {
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "exec", provider: "execmain", id: "toString" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
execmain: createExecProviderConfig(execInheritedErrorScriptPath),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).resolves.toBe("resolved");
|
|
});
|
|
|
|
itPosix("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
|
|
await expect(resolveExecSecret(execInvalidJsonScriptPath, { jsonOnly: true })).rejects.toThrow(
|
|
"returned invalid JSON",
|
|
);
|
|
});
|
|
|
|
itPosix("supports file singleValue mode with id=value", async () => {
|
|
const root = await createCaseDir("file-single-value");
|
|
const filePath = path.join(root, "token.txt");
|
|
await writeSecureFile(filePath, "raw-token-value\n");
|
|
|
|
const value = await resolveSecretRefString(
|
|
{ source: "file", provider: "rawfile", id: "value" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
rawfile: createFileProviderConfig(filePath, {
|
|
mode: "singleValue",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(value).toBe("raw-token-value");
|
|
});
|
|
|
|
itPosix("times out file provider reads when timeoutMs elapses", async () => {
|
|
const root = await createCaseDir("file-timeout");
|
|
const filePath = path.join(root, "secrets.json");
|
|
await writeSecureFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
providers: {
|
|
openai: {
|
|
apiKey: "sk-file-value", // pragma: allowlist secret
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const sampleHandle = await fs.open(filePath, "r");
|
|
const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as {
|
|
read: typeof sampleHandle.read;
|
|
};
|
|
await sampleHandle.close();
|
|
const readSpy = vi
|
|
.spyOn(fileHandlePrototype, "read")
|
|
.mockImplementation(() => new Promise(() => {}) as never);
|
|
|
|
try {
|
|
await expect(
|
|
resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath, {
|
|
timeoutMs: 5,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow('File provider "filemain" timed out');
|
|
} finally {
|
|
readSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects misconfigured provider source mismatches", async () => {
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "exec", provider: "default", id: "abc" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
default: {
|
|
source: "env",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow('has source "env" but ref requests "exec"');
|
|
});
|
|
|
|
it("rejects invalid exec ids before provider resolution", async () => {
|
|
for (const id of INVALID_EXEC_SECRET_REF_IDS) {
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "exec", provider: "vault", id },
|
|
{
|
|
config: {},
|
|
},
|
|
),
|
|
).rejects.toThrow(/Exec secret reference id must match|Secret reference id is empty/);
|
|
}
|
|
});
|
|
|
|
it("rejects invalid env, file, and provider refs before provider resolution", async () => {
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "env", provider: "default", id: "bad id" },
|
|
{
|
|
config: {},
|
|
},
|
|
),
|
|
).rejects.toThrow("Env secret reference id must match");
|
|
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "file", provider: "default", id: "providers/openai/apiKey" },
|
|
{
|
|
config: {},
|
|
},
|
|
),
|
|
).rejects.toThrow("File secret reference id must be an absolute JSON pointer");
|
|
|
|
await expect(
|
|
resolveSecretRefValue(
|
|
{ source: "env", provider: "Default", id: "OPENAI_API_KEY" },
|
|
{
|
|
config: {},
|
|
},
|
|
),
|
|
).rejects.toThrow("Secret reference provider must match");
|
|
});
|
|
|
|
it("strips UTF-8 BOM from file provider payload before JSON parse", async () => {
|
|
const dir = await createCaseDir("bom-file");
|
|
const filePath = path.join(dir, "secrets-with-bom.json");
|
|
// Write JSON with UTF-8 BOM prefix (EF BB BF)
|
|
const bom = "\uFEFF";
|
|
await writeSecureFile(filePath, `${bom}{"apiKey":"sk-test-123"}`);
|
|
|
|
const value = await resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "/apiKey" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(value).toBe("sk-test-123");
|
|
});
|
|
|
|
it("strips UTF-8 BOM from file provider singleValue mode", async () => {
|
|
const dir = await createCaseDir("bom-single");
|
|
const filePath = path.join(dir, "secret-with-bom.txt");
|
|
const bom = "\uFEFF";
|
|
await writeSecureFile(filePath, `${bom}my-secret-value\n`);
|
|
|
|
const value = await resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "value" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath, { mode: "singleValue" }),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(value).toBe("my-secret-value");
|
|
});
|
|
|
|
it("fails closed on Windows when file provider ACL source is unknown", async () => {
|
|
await withMockedWindowsPlatform(async () => {
|
|
const dir = await createCaseDir("win-acl");
|
|
const filePath = path.join(dir, "secrets.json");
|
|
await writeSecureFile(filePath, '{"token":"abc123"}');
|
|
|
|
await expect(
|
|
resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "/token" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow(/ACL verification unavailable on Windows/);
|
|
});
|
|
});
|
|
|
|
it("stays fail-closed when the retired Windows ACL opt-out is present", async () => {
|
|
await withMockedWindowsPlatform(async () => {
|
|
const dir = await createCaseDir("win-acl-opt-out");
|
|
const filePath = path.join(dir, "secrets.json");
|
|
await writeSecureFile(filePath, '{"token":"abc123"}');
|
|
|
|
await expect(
|
|
resolveSecretRefString(
|
|
{ source: "file", provider: "filemain", id: "/token" },
|
|
{
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: createFileProviderConfig(filePath, { allowInsecurePath: true }),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow(/ACL verification unavailable on Windows/);
|
|
});
|
|
});
|
|
|
|
it("fails closed on Windows when exec provider ACL source is unknown", async () => {
|
|
await withMockedWindowsPlatform(async () => {
|
|
await expect(resolveExecSecret(execProtocolV1ScriptPath)).rejects.toThrow(
|
|
/ACL verification unavailable on Windows/,
|
|
);
|
|
});
|
|
});
|
|
});
|