mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 15:14:10 +00:00
1622 lines
50 KiB
JavaScript
1622 lines
50 KiB
JavaScript
#!/usr/bin/env node
|
|
import childProcess from "node:child_process";
|
|
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const PLUGIN_ID = "secret-provider-proof";
|
|
const INTEGRATION_ID = "vault";
|
|
const PROVIDER_ALIAS = "team-secrets";
|
|
const TOKEN_V1 = "proof-gateway-token-v1";
|
|
const TOKEN_V2 = "proof-gateway-token-v2";
|
|
const ENV_TOKEN = "proof-env-token";
|
|
const FILE_TOKEN = "proof-file-token";
|
|
const MANUAL_EXEC_TOKEN = "proof-manual-exec-token";
|
|
const PLUGIN_EXEC_TOKEN = "proof-plugin-exec-token";
|
|
const OPENAI_PROFILE = "openai:secretref-proof";
|
|
const OPENAI_LIVE_PROOF_MODEL = "openai/gpt-5.5";
|
|
const COMMAND_TIMEOUT_MS = readPositiveInt(process.env.OPENCLAW_SECRET_PROOF_COMMAND_MS, 120000);
|
|
const READY_TIMEOUT_MS = readPositiveInt(process.env.OPENCLAW_SECRET_PROOF_READY_MS, 120000);
|
|
const RPC_TIMEOUT_MS = readPositiveInt(process.env.OPENCLAW_SECRET_PROOF_RPC_MS, 15000);
|
|
const TEARDOWN_GRACE_MS = 5000;
|
|
const RESULTS_PATH =
|
|
process.env.OPENCLAW_SECRET_PROOF_RESULTS_PATH?.trim() ||
|
|
path.join(os.tmpdir(), `openclaw-secret-provider-e2e-results-${process.pid}.json`);
|
|
|
|
const results = [];
|
|
let gatewayClientStateCounter = 0;
|
|
|
|
function requireFullMatrix() {
|
|
return process.env.OPENCLAW_SECRET_PROOF_FULL === "1";
|
|
}
|
|
|
|
function readPositiveInt(raw, fallback) {
|
|
const text = String(raw ?? "").trim();
|
|
if (!/^\d+$/u.test(text)) {
|
|
return fallback;
|
|
}
|
|
const parsed = Number(text);
|
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function remainingDeadlineMs(started, timeoutMs) {
|
|
return Math.max(1, timeoutMs - (Date.now() - started));
|
|
}
|
|
|
|
function formatErrorMessage(error) {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
if (typeof error === "string") {
|
|
return error;
|
|
}
|
|
if (error && typeof error === "object") {
|
|
try {
|
|
return JSON.stringify(error);
|
|
} catch {
|
|
return Object.prototype.toString.call(error);
|
|
}
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function writeJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function readJson(file) {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
function scrub(text) {
|
|
return String(text)
|
|
.replaceAll(TOKEN_V1, "<gateway-token-v1>")
|
|
.replaceAll(TOKEN_V2, "<gateway-token-v2>")
|
|
.replaceAll(ENV_TOKEN, "<env-token>")
|
|
.replaceAll(FILE_TOKEN, "<file-token>")
|
|
.replaceAll(MANUAL_EXEC_TOKEN, "<manual-exec-token>")
|
|
.replaceAll(PLUGIN_EXEC_TOKEN, "<plugin-exec-token>")
|
|
.replace(/sk-[A-Za-z0-9_-]{20,}/gu, "<openai-key>");
|
|
}
|
|
|
|
function parseJsonOutput(stdout) {
|
|
const text = stdout.trim();
|
|
if (!text) {
|
|
throw new Error("expected JSON output, got empty stdout");
|
|
}
|
|
const first = text.indexOf("{");
|
|
const last = text.lastIndexOf("}");
|
|
if (first < 0 || last < first) {
|
|
throw new Error(`expected JSON object output, got: ${scrub(text.slice(0, 500))}`);
|
|
}
|
|
return JSON.parse(text.slice(first, last + 1));
|
|
}
|
|
|
|
function resolveOpenClawRunner() {
|
|
if (process.env.OPENCLAW_ENTRY) {
|
|
return {
|
|
command: "node",
|
|
baseArgs: [process.env.OPENCLAW_ENTRY],
|
|
label: process.env.OPENCLAW_ENTRY,
|
|
};
|
|
}
|
|
if (process.env.OPENCLAW_SECRET_PROOF_USE_DIST === "1") {
|
|
for (const candidate of ["dist/index.mjs", "dist/index.js"]) {
|
|
const resolved = path.join(process.cwd(), candidate);
|
|
if (fs.existsSync(resolved)) {
|
|
return { command: "node", baseArgs: [resolved], label: candidate };
|
|
}
|
|
}
|
|
}
|
|
return { pnpm: true, baseArgs: ["openclaw"], label: "pnpm openclaw" };
|
|
}
|
|
|
|
function makeEnv(name) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-secret-proof-${name}-`));
|
|
const home = path.join(root, "home");
|
|
const stateDir = path.join(home, ".openclaw");
|
|
const agentDir = path.join(stateDir, "agents", "main", "agent");
|
|
const hostHome = os.homedir();
|
|
const serviceProfile = `secret-proof-${process.pid}-${name.replace(/[^a-z0-9-]/giu, "-")}`;
|
|
fs.mkdirSync(stateDir, { recursive: true, mode: 0o755 });
|
|
const env = {
|
|
...process.env,
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
OPENCLAW_HOME: home,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
|
|
OPENCLAW_AGENT_DIR: agentDir,
|
|
PI_CODING_AGENT_DIR: "",
|
|
OPENCLAW_NO_ONBOARD: "1",
|
|
OPENCLAW_SKIP_PROVIDERS: "0",
|
|
OPENCLAW_LOG_COLOR: "0",
|
|
OPENCLAW_PROFILE: serviceProfile,
|
|
OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.${serviceProfile}`,
|
|
OPENCLAW_SYSTEMD_UNIT: `openclaw-gateway-${serviceProfile}.service`,
|
|
OPENCLAW_WINDOWS_TASK_NAME: `OpenClaw Gateway (${serviceProfile})`,
|
|
NO_COLOR: "1",
|
|
PNPM_HOME:
|
|
process.env.PNPM_HOME ??
|
|
(process.platform === "darwin"
|
|
? path.join(hostHome, "Library", "pnpm")
|
|
: path.join(hostHome, ".local", "share", "pnpm")),
|
|
COREPACK_HOME:
|
|
process.env.COREPACK_HOME ??
|
|
(process.platform === "darwin"
|
|
? path.join(hostHome, "Library", "Caches", "node", "corepack")
|
|
: path.join(hostHome, ".cache", "node", "corepack")),
|
|
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME ?? path.join(hostHome, ".cache"),
|
|
};
|
|
delete env.OPENCLAW_GATEWAY_TOKEN;
|
|
delete env.OPENCLAW_GATEWAY_PASSWORD;
|
|
return { root, home, stateDir, env };
|
|
}
|
|
|
|
async function cleanupEnv(root) {
|
|
if (process.env.OPENCLAW_SECRET_PROOF_KEEP_TMP === "1") {
|
|
console.log(`[keep] ${root}`);
|
|
return;
|
|
}
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
return;
|
|
} catch {
|
|
await delay(250);
|
|
}
|
|
}
|
|
}
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
const timeoutMs = options.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
return new Promise((resolve, reject) => {
|
|
const child = childProcess.spawn(command, args, {
|
|
cwd: options.cwd ?? process.cwd(),
|
|
env: options.env ?? process.env,
|
|
shell: options.shell,
|
|
stdio: options.stdio ?? ["pipe", "pipe", "pipe"],
|
|
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => child.kill("SIGKILL"), 1000).unref();
|
|
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`)));
|
|
}, timeoutMs);
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString("utf8");
|
|
});
|
|
child.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.on("close", (code, signal) => {
|
|
clearTimeout(timer);
|
|
const result = { code: code ?? 0, signal, stdout, stderr };
|
|
if (result.code !== 0 && options.allowFailure !== true) {
|
|
reject(
|
|
new Error(
|
|
scrub(
|
|
`command failed (${result.code}): ${command} ${args.join(" ")}\n${stderr || stdout}`,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve(result);
|
|
});
|
|
if (options.input !== undefined) {
|
|
child.stdin.end(options.input);
|
|
} else {
|
|
child.stdin.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function runOpenClaw(args, env, options = {}) {
|
|
const command = await resolveOpenClawCommand(args, env, options);
|
|
return await runCommand(command.command, command.args, {
|
|
...options,
|
|
...command.options,
|
|
});
|
|
}
|
|
|
|
export async function resolveOpenClawCommand(args, env, options = {}) {
|
|
const runner = options.runner ?? resolveOpenClawRunner();
|
|
const stdio = options.stdio ?? ["pipe", "pipe", "pipe"];
|
|
if (runner.pnpm) {
|
|
const { createPnpmRunnerSpawnSpec } = await import("../pnpm-runner.mjs");
|
|
return createPnpmRunnerSpawnSpec({
|
|
comSpec: options.comSpec,
|
|
cwd: options.cwd ?? process.cwd(),
|
|
detached: options.detached,
|
|
env,
|
|
nodeExecPath: options.nodeExecPath,
|
|
npmExecPath: options.npmExecPath,
|
|
platform: options.platform,
|
|
pnpmArgs: [...runner.baseArgs, ...args],
|
|
stdio,
|
|
});
|
|
}
|
|
return {
|
|
command: runner.command,
|
|
args: [...runner.baseArgs, ...args],
|
|
options: {
|
|
cwd: options.cwd ?? process.cwd(),
|
|
detached: options.detached,
|
|
env,
|
|
shell: options.shell,
|
|
stdio,
|
|
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function allocatePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
await new Promise((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
if (!port) {
|
|
throw new Error("failed to allocate a local port");
|
|
}
|
|
return port;
|
|
}
|
|
|
|
function proofProviderConfig() {
|
|
return {
|
|
source: "exec",
|
|
pluginIntegration: {
|
|
pluginId: PLUGIN_ID,
|
|
integrationId: INTEGRATION_ID,
|
|
},
|
|
};
|
|
}
|
|
|
|
function proofSecretRef(id) {
|
|
return { source: "exec", provider: PROVIDER_ALIAS, id };
|
|
}
|
|
|
|
function baseConfig(port, overrides = {}) {
|
|
return {
|
|
gateway: {
|
|
mode: "local",
|
|
port,
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: proofSecretRef("gateway/token") },
|
|
controlUi: { enabled: false },
|
|
...overrides.gateway,
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
entries: {
|
|
[PLUGIN_ID]: { enabled: true },
|
|
},
|
|
...overrides.plugins,
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
[PROVIDER_ALIAS]: proofProviderConfig(),
|
|
},
|
|
...overrides.secrets,
|
|
},
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-5.4-nano",
|
|
},
|
|
...overrides.agents,
|
|
},
|
|
...overrides.root,
|
|
};
|
|
}
|
|
|
|
function writeProofPlugin(envCtx, options = {}) {
|
|
const pluginRoot = path.join(envCtx.stateDir, "extensions", PLUGIN_ID);
|
|
fs.mkdirSync(pluginRoot, { recursive: true, mode: 0o755 });
|
|
writeJson(path.join(pluginRoot, "openclaw.plugin.json"), {
|
|
id: PLUGIN_ID,
|
|
name: "Secret Provider Proof",
|
|
enabledByDefault: true,
|
|
activation: { onStartup: true },
|
|
secretProviderIntegrations: {
|
|
[INTEGRATION_ID]: {
|
|
source: "exec",
|
|
command: "${node}",
|
|
args: ["./resolver.mjs"],
|
|
providerAlias: PROVIDER_ALIAS,
|
|
displayName: "Secret Provider Proof",
|
|
description: "Local E2E proof resolver for plugin-managed SecretRef providers.",
|
|
timeoutMs: 1200,
|
|
noOutputTimeoutMs: 800,
|
|
maxOutputBytes: 8192,
|
|
passEnv: [
|
|
"PROOF_SECRET_STORE_PATH",
|
|
...(options.includeOpenAiPassEnv ? ["OPENAI_API_KEY"] : []),
|
|
],
|
|
env: { PROOF_PLUGIN_ENV: "manifest" },
|
|
},
|
|
},
|
|
configSchema: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {},
|
|
},
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "index.js"),
|
|
`import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const OPENAI_PROFILE = ${JSON.stringify(OPENAI_PROFILE)};
|
|
const EXPECTED_ID = "plugin-exec/token";
|
|
const EXPECTED_VALUE = ${JSON.stringify(PLUGIN_EXEC_TOKEN)};
|
|
const REPO_ROOT = ${JSON.stringify(process.cwd())};
|
|
|
|
function resolveAuthProfilesPath() {
|
|
const agentDir = process.env.OPENCLAW_AGENT_DIR;
|
|
if (agentDir) {
|
|
return path.join(agentDir, "auth-profiles.json");
|
|
}
|
|
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
|
if (stateDir) {
|
|
return path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
}
|
|
throw new Error("missing agent profile directory environment");
|
|
}
|
|
|
|
function readConfig() {
|
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
if (!configPath) {
|
|
throw new Error("missing OPENCLAW_CONFIG_PATH");
|
|
}
|
|
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
}
|
|
|
|
function readPersistedProfile() {
|
|
const store = JSON.parse(fs.readFileSync(resolveAuthProfilesPath(), "utf8"));
|
|
const profile = store.profiles?.[OPENAI_PROFILE];
|
|
const ref = profile?.keyRef;
|
|
if (
|
|
!ref ||
|
|
ref.source !== "exec" ||
|
|
ref.provider !== "${PROVIDER_ALIAS}" ||
|
|
ref.id !== EXPECTED_ID
|
|
) {
|
|
throw new Error("expected auth-profile SecretRef is not persisted");
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
async function loadSecretRuntime() {
|
|
const requireFromRepo = createRequire(path.join(REPO_ROOT, "package.json"));
|
|
const resolved = requireFromRepo.resolve("openclaw/plugin-sdk/secret-ref-runtime");
|
|
return await import(pathToFileURL(resolved).href);
|
|
}
|
|
|
|
async function resolveProfileSecretRef(ref) {
|
|
const { resolveSecretRefValues } = await loadSecretRuntime();
|
|
const resolved = await resolveSecretRefValues([ref], {
|
|
config: readConfig(),
|
|
env: process.env,
|
|
});
|
|
const values = Array.from(resolved.values());
|
|
if (values[0] !== EXPECTED_VALUE) {
|
|
throw new Error("SecretRef resolver did not return expected persisted-profile secret");
|
|
}
|
|
}
|
|
|
|
export default {
|
|
register(api) {
|
|
api.registerGatewayMethod("secret-provider-proof.serviceProbe", async ({ respond }) => {
|
|
try {
|
|
const ref = readPersistedProfile();
|
|
await resolveProfileSecretRef(ref);
|
|
respond(true, { ok: true, profileId: OPENAI_PROFILE, id: ref.id }, undefined);
|
|
} catch (error) {
|
|
respond(false, undefined, {
|
|
code: "UNAVAILABLE",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
});
|
|
},
|
|
};
|
|
`,
|
|
{ mode: 0o644 },
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "resolver.mjs"),
|
|
`#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
|
|
const storePath = process.env.PROOF_SECRET_STORE_PATH;
|
|
if (!storePath) {
|
|
console.error("missing PROOF_SECRET_STORE_PATH");
|
|
process.exit(4);
|
|
}
|
|
|
|
function readStdin() {
|
|
return new Promise((resolve) => {
|
|
let body = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
process.stdin.on("end", () => resolve(body));
|
|
});
|
|
}
|
|
|
|
function readStore() {
|
|
const store = JSON.parse(fs.readFileSync(storePath, "utf8"));
|
|
store.calls = Number(store.calls || 0) + 1;
|
|
fs.writeFileSync(storePath, JSON.stringify(store, null, 2) + "\\n", "utf8");
|
|
return store;
|
|
}
|
|
|
|
const request = JSON.parse(await readStdin());
|
|
const store = readStore();
|
|
if (Number(store.sleepMs || 0) > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, Number(store.sleepMs)));
|
|
}
|
|
if (store.mode === "fail") {
|
|
process.stdout.write(JSON.stringify({
|
|
protocolVersion: 1,
|
|
errors: Object.fromEntries((request.ids || []).map((id) => [id, "proof resolver forced failure"])),
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
const values = {};
|
|
const errors = {};
|
|
for (const id of request.ids || []) {
|
|
const entry = store.values?.[id];
|
|
if (entry && typeof entry === "object" && typeof entry.env === "string") {
|
|
const value = process.env[entry.env];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
values[id] = value;
|
|
} else {
|
|
errors[id] = "required environment variable is missing";
|
|
}
|
|
} else if (entry !== undefined) {
|
|
values[id] = entry;
|
|
} else {
|
|
errors[id] = "missing proof secret";
|
|
}
|
|
}
|
|
process.stdout.write(JSON.stringify({
|
|
protocolVersion: 1,
|
|
...(Object.keys(values).length ? { values } : {}),
|
|
...(Object.keys(errors).length ? { errors } : {}),
|
|
}));
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
return { pluginRoot, resolverPath: path.join(pluginRoot, "resolver.mjs") };
|
|
}
|
|
|
|
function writeSecretStore(envCtx, values = {}) {
|
|
const storePath = path.join(envCtx.stateDir, "proof-secret-store.json");
|
|
writeJson(storePath, {
|
|
mode: "ok",
|
|
calls: 0,
|
|
sleepMs: 0,
|
|
values: {
|
|
"gateway/token": TOKEN_V1,
|
|
"command/value": "proof-command-value",
|
|
"plugin-exec/token": PLUGIN_EXEC_TOKEN,
|
|
"openai/apiKey": { env: "OPENAI_API_KEY" },
|
|
...values,
|
|
},
|
|
});
|
|
envCtx.env.PROOF_SECRET_STORE_PATH = storePath;
|
|
return storePath;
|
|
}
|
|
|
|
function mutateStore(storePath, update) {
|
|
const current = readJson(storePath);
|
|
const next = update(current);
|
|
writeJson(storePath, next ?? current);
|
|
}
|
|
|
|
function envWithout(source, keys) {
|
|
const next = { ...source };
|
|
for (const key of keys) {
|
|
delete next[key];
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function serviceManagerEnv(source) {
|
|
const hostHome = os.homedir();
|
|
return {
|
|
...source,
|
|
// systemd/launchd discover user service definitions from the real account
|
|
// home, while OpenClaw state/config below remain pinned to the proof root.
|
|
HOME: hostHome,
|
|
USERPROFILE: hostHome,
|
|
};
|
|
}
|
|
|
|
async function startGateway(envCtx, port, token = TOKEN_V1) {
|
|
const command = await resolveOpenClawCommand(
|
|
[
|
|
"gateway",
|
|
"run",
|
|
"--port",
|
|
String(port),
|
|
"--bind",
|
|
"loopback",
|
|
"--allow-unconfigured",
|
|
],
|
|
envCtx.env,
|
|
{
|
|
detached: process.platform !== "win32",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
const child = childProcess.spawn(command.command, command.args, {
|
|
...command.options,
|
|
detached: process.platform !== "win32",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString("utf8");
|
|
});
|
|
const started = Date.now();
|
|
let lastHealthResult;
|
|
let lastHealthError;
|
|
while (Date.now() - started < READY_TIMEOUT_MS) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(
|
|
scrub(`gateway exited during startup (${child.exitCode})\n${stderr || stdout}`),
|
|
);
|
|
}
|
|
const remainingMs = remainingDeadlineMs(started, READY_TIMEOUT_MS);
|
|
try {
|
|
const health = await gatewayCall(
|
|
envCtx.env,
|
|
port,
|
|
token,
|
|
"health",
|
|
{},
|
|
{
|
|
allowFailure: true,
|
|
timeoutMs: Math.min(RPC_TIMEOUT_MS + 10000, remainingMs),
|
|
},
|
|
);
|
|
lastHealthResult = health;
|
|
if (health.code === 0) {
|
|
return {
|
|
child,
|
|
output: () => ({ stdout, stderr }),
|
|
stop: async () => {
|
|
await stopGateway(child);
|
|
},
|
|
};
|
|
}
|
|
} catch (error) {
|
|
lastHealthError = error;
|
|
}
|
|
await delay(Math.min(500, remainingDeadlineMs(started, READY_TIMEOUT_MS)));
|
|
}
|
|
terminateProcessTree(child, "SIGTERM");
|
|
const lastHealthOutput =
|
|
lastHealthError instanceof Error
|
|
? lastHealthError.message
|
|
: lastHealthError
|
|
? formatErrorMessage(lastHealthError)
|
|
: lastHealthResult
|
|
? lastHealthResult.stderr || lastHealthResult.stdout
|
|
: "";
|
|
throw new Error(scrub(`gateway did not become ready\n${lastHealthOutput}\n${stderr || stdout}`));
|
|
}
|
|
|
|
async function stopGateway(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
terminateProcessTree(child, "SIGTERM");
|
|
const started = Date.now();
|
|
while (Date.now() - started < TEARDOWN_GRACE_MS) {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
await delay(100);
|
|
}
|
|
terminateProcessTree(child, "SIGKILL");
|
|
}
|
|
|
|
function terminateProcessTree(child, signal) {
|
|
if (process.platform === "win32") {
|
|
try {
|
|
childProcess.spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
stdio: "ignore",
|
|
});
|
|
return;
|
|
} catch {
|
|
child.kill(signal);
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
} catch {
|
|
child.kill(signal);
|
|
}
|
|
}
|
|
|
|
async function gatewayCall(env, port, token, method, params = {}, options = {}) {
|
|
const clientStateDir = path.join(
|
|
path.dirname(env.OPENCLAW_CONFIG_PATH),
|
|
"gateway-call-clients",
|
|
`${Date.now()}-${gatewayClientStateCounter++}`,
|
|
);
|
|
fs.mkdirSync(clientStateDir, { recursive: true });
|
|
return await runOpenClaw(
|
|
[
|
|
"gateway",
|
|
"call",
|
|
method,
|
|
"--url",
|
|
`ws://127.0.0.1:${port}`,
|
|
"--token",
|
|
token,
|
|
"--timeout",
|
|
String(RPC_TIMEOUT_MS),
|
|
"--json",
|
|
"--params",
|
|
JSON.stringify(params),
|
|
],
|
|
{
|
|
...env,
|
|
OPENCLAW_STATE_DIR: clientStateDir,
|
|
OPENCLAW_HOME: clientStateDir,
|
|
},
|
|
{ timeoutMs: options.timeoutMs ?? RPC_TIMEOUT_MS + 10000, allowFailure: options.allowFailure },
|
|
);
|
|
}
|
|
|
|
async function expectGatewayCallOk(env, port, token, method = "health", params = {}) {
|
|
const result = await gatewayCall(env, port, token, method, params);
|
|
return parseJsonOutput(result.stdout);
|
|
}
|
|
|
|
async function expectGatewayCallFails(env, port, token, method = "health", params = {}) {
|
|
const result = await gatewayCall(env, port, token, method, params, { allowFailure: true });
|
|
if (result.code === 0) {
|
|
throw new Error(`expected gateway ${method} call to fail`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function expectReloadMayCloseForAuthChange(env, port, token) {
|
|
const result = await gatewayCall(env, port, token, "secrets.reload", {}, { allowFailure: true });
|
|
if (result.code === 0) {
|
|
return parseJsonOutput(result.stdout);
|
|
}
|
|
const output = scrub(`${result.stdout}\n${result.stderr}`);
|
|
if (!/gateway auth changed/iu.test(output)) {
|
|
throw new Error(`secrets.reload failed without auth-change close: ${output}`);
|
|
}
|
|
return { ok: true, connectionClosedForAuthChange: true };
|
|
}
|
|
|
|
async function expectGatewayStartupFails(envCtx, port, reason) {
|
|
const command = await resolveOpenClawCommand(
|
|
[
|
|
"gateway",
|
|
"run",
|
|
"--port",
|
|
String(port),
|
|
"--bind",
|
|
"loopback",
|
|
"--allow-unconfigured",
|
|
],
|
|
envCtx.env,
|
|
{
|
|
detached: process.platform !== "win32",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
const child = childProcess.spawn(command.command, command.args, {
|
|
...command.options,
|
|
detached: process.platform !== "win32",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString("utf8");
|
|
});
|
|
const code = await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
terminateProcessTree(child, "SIGTERM");
|
|
reject(new Error(`gateway did not fail closed for ${reason}`));
|
|
}, 20000);
|
|
child.on("close", (exitCode) => {
|
|
clearTimeout(timer);
|
|
resolve(exitCode);
|
|
});
|
|
child.on("error", reject);
|
|
});
|
|
if (code === 0) {
|
|
throw new Error(`gateway unexpectedly started for ${reason}`);
|
|
}
|
|
const rawCombined = `${stdout}\n${stderr}`;
|
|
for (const forbidden of [TOKEN_V1, TOKEN_V2, PLUGIN_EXEC_TOKEN]) {
|
|
if (rawCombined.includes(forbidden)) {
|
|
throw new Error(`startup failure for ${reason} leaked a secret value`);
|
|
}
|
|
}
|
|
const combined = scrub(rawCombined);
|
|
return combined;
|
|
}
|
|
|
|
async function uninstallManagedGateway(env) {
|
|
let lastResult;
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
lastResult = await runOpenClaw(["gateway", "uninstall", "--json"], env, {
|
|
timeoutMs: 60000,
|
|
allowFailure: true,
|
|
});
|
|
if (lastResult.code === 0) {
|
|
return;
|
|
}
|
|
if (attempt < 2) {
|
|
await delay(1000);
|
|
}
|
|
}
|
|
throw new Error(
|
|
scrub(
|
|
`managed gateway uninstall failed after service proof (${lastResult?.code ?? "unknown"}): ${
|
|
lastResult?.stderr || lastResult?.stdout || "<no output>"
|
|
}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
async function waitForManagedGatewayStatus(env, token) {
|
|
const started = Date.now();
|
|
let lastResult;
|
|
let lastError;
|
|
while (Date.now() - started < READY_TIMEOUT_MS) {
|
|
try {
|
|
lastResult = await runOpenClaw(
|
|
[
|
|
"gateway",
|
|
"status",
|
|
"--deep",
|
|
"--require-rpc",
|
|
"--json",
|
|
"--token",
|
|
token,
|
|
"--timeout",
|
|
String(RPC_TIMEOUT_MS),
|
|
],
|
|
env,
|
|
{
|
|
timeoutMs: Math.min(
|
|
RPC_TIMEOUT_MS + 10000,
|
|
remainingDeadlineMs(started, READY_TIMEOUT_MS),
|
|
),
|
|
allowFailure: true,
|
|
},
|
|
);
|
|
if (lastResult.code === 0) {
|
|
return parseJsonOutput(lastResult.stdout);
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await delay(Math.min(500, remainingDeadlineMs(started, READY_TIMEOUT_MS)));
|
|
}
|
|
const lastOutput =
|
|
lastError instanceof Error
|
|
? lastError.message
|
|
: lastError
|
|
? formatErrorMessage(lastError)
|
|
: lastResult?.stderr || lastResult?.stdout || "<no output>";
|
|
throw new Error(scrub(`managed gateway did not become RPC-ready\n${lastOutput}`));
|
|
}
|
|
|
|
async function runWithProof(name, description, fn) {
|
|
const started = Date.now();
|
|
try {
|
|
const evidence = await fn();
|
|
const elapsedMs = Date.now() - started;
|
|
results.push({ name, status: "pass", elapsedMs, evidence });
|
|
console.log(
|
|
`[PASS] ${name} ${description} (${elapsedMs}ms) ${evidence ? scrub(evidence) : ""}`,
|
|
);
|
|
} catch (error) {
|
|
const elapsedMs = Date.now() - started;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
results.push({ name, status: "fail", elapsedMs, evidence: scrub(message) });
|
|
console.error(`[FAIL] ${name} ${description} (${elapsedMs}ms)`);
|
|
console.error(scrub(message));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function withProofEnv(name, fn, values, pluginOptions) {
|
|
const envCtx = makeEnv(name);
|
|
try {
|
|
const plugin = writeProofPlugin(envCtx, pluginOptions);
|
|
const storePath = writeSecretStore(envCtx, values);
|
|
return await fn(envCtx, plugin, storePath);
|
|
} finally {
|
|
await cleanupEnv(envCtx.root);
|
|
}
|
|
}
|
|
|
|
async function p1StartupSucceeds() {
|
|
await withProofEnv("p1", async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port));
|
|
const authPath = path.join(envCtx.stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
writeJson(authPath, {
|
|
version: 1,
|
|
profiles: {
|
|
[OPENAI_PROFILE]: {
|
|
type: "api_key",
|
|
provider: "openai",
|
|
keyRef: proofSecretRef("plugin-exec/token"),
|
|
},
|
|
},
|
|
});
|
|
const gateway = await startGateway(envCtx, port, TOKEN_V1);
|
|
try {
|
|
await expectGatewayCallOk(envCtx.env, port, TOKEN_V1);
|
|
const callsBeforeProbe = readJson(storePath).calls;
|
|
const probe = await expectGatewayCallOk(
|
|
envCtx.env,
|
|
port,
|
|
TOKEN_V1,
|
|
"secret-provider-proof.serviceProbe",
|
|
);
|
|
if (probe.ok !== true || probe.profileId !== OPENAI_PROFILE) {
|
|
throw new Error("proof plugin serviceProbe returned unexpected payload");
|
|
}
|
|
const callsAfterProbe = readJson(storePath).calls;
|
|
if (callsAfterProbe <= callsBeforeProbe) {
|
|
throw new Error("proof plugin serviceProbe did not invoke the SecretRef resolver");
|
|
}
|
|
} finally {
|
|
await gateway.stop();
|
|
}
|
|
});
|
|
return "gateway health succeeded and proof plugin resolved persisted keyRef through SecretRef API";
|
|
}
|
|
|
|
async function p2StartupFailsClosed() {
|
|
return await withProofEnv("p2", async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port));
|
|
mutateStore(storePath, (store) => ({ ...store, mode: "fail" }));
|
|
const output = await expectGatewayStartupFails(envCtx, port, "unresolved plugin integration");
|
|
if (!/secret|ref|resolve|provider/iu.test(output)) {
|
|
throw new Error(`startup failure did not include actionable SecretRef context: ${output}`);
|
|
}
|
|
return "gateway exited non-zero without exposing resolved credential";
|
|
});
|
|
}
|
|
|
|
async function p3ThroughP6StaticReloadAndCommandSnapshot() {
|
|
await withProofEnv("p3-p6", async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port));
|
|
const gateway = await startGateway(envCtx, port, TOKEN_V1);
|
|
try {
|
|
const before = readJson(storePath).calls;
|
|
mutateStore(storePath, (store) => ({
|
|
...store,
|
|
values: { ...store.values, "gateway/token": TOKEN_V2 },
|
|
}));
|
|
await expectGatewayCallOk(envCtx.env, port, TOKEN_V1);
|
|
await expectGatewayCallFails(envCtx.env, port, TOKEN_V2);
|
|
const afterStaticCalls = readJson(storePath).calls;
|
|
if (afterStaticCalls !== before) {
|
|
throw new Error(
|
|
`resolver was called after static capture (${before} -> ${afterStaticCalls})`,
|
|
);
|
|
}
|
|
|
|
await expectReloadMayCloseForAuthChange(envCtx.env, port, TOKEN_V1);
|
|
await expectGatewayCallOk(envCtx.env, port, TOKEN_V2);
|
|
await expectGatewayCallFails(envCtx.env, port, TOKEN_V1);
|
|
|
|
mutateStore(storePath, (store) => ({ ...store, mode: "fail" }));
|
|
await expectGatewayCallFails(envCtx.env, port, TOKEN_V2, "secrets.reload");
|
|
await expectGatewayCallOk(envCtx.env, port, TOKEN_V2);
|
|
|
|
mutateStore(storePath, (store) => ({ ...store, mode: "ok" }));
|
|
const resolved = await expectGatewayCallOk(envCtx.env, port, TOKEN_V2, "secrets.resolve", {
|
|
commandName: "secret-provider-proof",
|
|
targetIds: ["gateway.auth.token"],
|
|
allowedPaths: ["gateway.auth.token"],
|
|
forcedActivePaths: ["gateway.auth.token"],
|
|
});
|
|
const assignment = resolved.assignments?.find?.(
|
|
(entry) => entry.path === "gateway.auth.token",
|
|
);
|
|
if (!assignment || assignment.value !== TOKEN_V2) {
|
|
throw new Error(
|
|
"secrets.resolve did not return the active gateway.auth.token snapshot value",
|
|
);
|
|
}
|
|
} finally {
|
|
await gateway.stop();
|
|
}
|
|
});
|
|
return "static capture, reload success, reload LKG, and command snapshot resolution proved";
|
|
}
|
|
|
|
async function p7AuthProfileSecretRefPersistsAndResolves() {
|
|
await withProofEnv("p7", async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(
|
|
envCtx.env.OPENCLAW_CONFIG_PATH,
|
|
baseConfig(port, {
|
|
root: {
|
|
models: {
|
|
providers: {
|
|
openai: {},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const authPath = path.join(envCtx.stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
writeJson(authPath, {
|
|
version: 1,
|
|
profiles: {
|
|
[OPENAI_PROFILE]: {
|
|
type: "api_key",
|
|
provider: "openai",
|
|
keyRef: proofSecretRef("plugin-exec/token"),
|
|
},
|
|
},
|
|
});
|
|
const callsBefore = readJson(storePath).calls;
|
|
const result = await runOpenClaw(
|
|
[
|
|
"models",
|
|
"status",
|
|
"--json",
|
|
"--probe",
|
|
"--probe-provider",
|
|
"openai",
|
|
"--probe-profile",
|
|
OPENAI_PROFILE,
|
|
"--probe-timeout",
|
|
"15000",
|
|
],
|
|
envCtx.env,
|
|
{ allowFailure: true, timeoutMs: 45000 },
|
|
);
|
|
const combined = scrub(`${result.stdout}\n${result.stderr}`);
|
|
if (
|
|
/unresolved_ref|could not resolve SecretRef|missing PROOF_SECRET_STORE_PATH/iu.test(combined)
|
|
) {
|
|
throw new Error(
|
|
`auth-profile SecretRef did not resolve through plugin integration: ${combined}`,
|
|
);
|
|
}
|
|
const callsAfter = readJson(storePath).calls;
|
|
if (callsAfter <= callsBefore) {
|
|
throw new Error("auth-profile proof did not invoke the plugin-managed resolver");
|
|
}
|
|
if (!combined.includes(OPENAI_PROFILE)) {
|
|
throw new Error(`auth-profile proof did not mention expected profile ${OPENAI_PROFILE}`);
|
|
}
|
|
});
|
|
return "auth-profile keyRef reached the model status probe without unresolved-ref diagnostics";
|
|
}
|
|
|
|
async function p8ManagedServiceEnvProof() {
|
|
if (process.env.OPENCLAW_SECRET_PROOF_SERVICE !== "1") {
|
|
if (requireFullMatrix()) {
|
|
throw new Error("OPENCLAW_SECRET_PROOF_SERVICE=1 is required for full matrix service proof");
|
|
}
|
|
return "not run in local rehearsal; final matrix must set OPENCLAW_SECRET_PROOF_SERVICE=1 on a service-capable host";
|
|
}
|
|
await withProofEnv("p8", async (envCtx) => {
|
|
const port = await allocatePort();
|
|
writeJson(
|
|
envCtx.env.OPENCLAW_CONFIG_PATH,
|
|
baseConfig(port, {
|
|
gateway: { auth: { mode: "token", token: TOKEN_V1 } },
|
|
}),
|
|
);
|
|
const authPath = path.join(envCtx.stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
writeJson(authPath, {
|
|
version: 1,
|
|
profiles: {
|
|
[OPENAI_PROFILE]: {
|
|
type: "api_key",
|
|
provider: "openai",
|
|
keyRef: proofSecretRef("plugin-exec/token"),
|
|
},
|
|
},
|
|
});
|
|
let installAttempted = false;
|
|
let proofError;
|
|
let cleanupError;
|
|
const managerEnv = serviceManagerEnv(envCtx.env);
|
|
try {
|
|
const callsBeforeInstall = readJson(envCtx.env.PROOF_SECRET_STORE_PATH).calls;
|
|
installAttempted = true;
|
|
const install = await runOpenClaw(
|
|
["gateway", "install", "--force", "--port", String(port), "--json"],
|
|
managerEnv,
|
|
{ timeoutMs: 120000 },
|
|
);
|
|
const payload = parseJsonOutput(install.stdout);
|
|
if (payload.ok !== true) {
|
|
throw new Error(
|
|
`gateway install did not succeed: ${scrub(install.stdout || install.stderr)}`,
|
|
);
|
|
}
|
|
const callsAfterInstall = readJson(envCtx.env.PROOF_SECRET_STORE_PATH).calls;
|
|
if (callsAfterInstall !== callsBeforeInstall) {
|
|
throw new Error(
|
|
"managed service proof unexpectedly resolved the plugin SecretRef during install",
|
|
);
|
|
}
|
|
await waitForManagedGatewayStatus(managerEnv, TOKEN_V1);
|
|
const callsBeforeProbe = readJson(envCtx.env.PROOF_SECRET_STORE_PATH).calls;
|
|
const probe = await expectGatewayCallOk(
|
|
envWithout(envCtx.env, ["PROOF_SECRET_STORE_PATH"]),
|
|
port,
|
|
TOKEN_V1,
|
|
"secret-provider-proof.serviceProbe",
|
|
);
|
|
if (probe.ok !== true || probe.profileId !== OPENAI_PROFILE) {
|
|
throw new Error(`managed service proof method returned unexpected payload`);
|
|
}
|
|
const callsAfterProbe = readJson(envCtx.env.PROOF_SECRET_STORE_PATH).calls;
|
|
if (callsAfterProbe <= callsBeforeProbe) {
|
|
throw new Error("managed service auth-profile proof did not invoke the resolver");
|
|
}
|
|
} catch (error) {
|
|
proofError = error;
|
|
} finally {
|
|
if (installAttempted) {
|
|
try {
|
|
await uninstallManagedGateway(managerEnv);
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
if (proofError) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`[cleanup] ${scrub(message)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (proofError) {
|
|
throw proofError;
|
|
}
|
|
if (cleanupError) {
|
|
throw cleanupError;
|
|
}
|
|
});
|
|
return "real managed service install preserved auth-profile exec provider passEnv";
|
|
}
|
|
|
|
async function p9ProviderVariants() {
|
|
await withProofEnv("p9", async (envCtx, plugin, storePath) => {
|
|
const scenarios = [
|
|
{
|
|
name: "env",
|
|
token: ENV_TOKEN,
|
|
env: { PROOF_GATEWAY_TOKEN: ENV_TOKEN },
|
|
config: (port) =>
|
|
baseConfig(port, {
|
|
gateway: {
|
|
auth: {
|
|
mode: "token",
|
|
token: { source: "env", provider: "default", id: "PROOF_GATEWAY_TOKEN" },
|
|
},
|
|
},
|
|
secrets: {
|
|
providers: { default: { source: "env", allowlist: ["PROOF_GATEWAY_TOKEN"] } },
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "file",
|
|
token: FILE_TOKEN,
|
|
before: () => {
|
|
const filePath = path.join(envCtx.stateDir, "file-secret.txt");
|
|
fs.writeFileSync(filePath, FILE_TOKEN, { mode: 0o600 });
|
|
return { filePath };
|
|
},
|
|
config: (port, ctx) =>
|
|
baseConfig(port, {
|
|
gateway: {
|
|
auth: { mode: "token", token: { source: "file", provider: "filemain", id: "value" } },
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
filemain: { source: "file", path: ctx.filePath, mode: "singleValue" },
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "manual exec",
|
|
token: MANUAL_EXEC_TOKEN,
|
|
before: () => {
|
|
mutateStore(storePath, (store) => ({
|
|
...store,
|
|
values: { ...store.values, "manual-exec/token": MANUAL_EXEC_TOKEN },
|
|
}));
|
|
return {};
|
|
},
|
|
config: (port) =>
|
|
baseConfig(port, {
|
|
gateway: {
|
|
auth: {
|
|
mode: "token",
|
|
token: { source: "exec", provider: "manualexec", id: "manual-exec/token" },
|
|
},
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
manualexec: {
|
|
source: "exec",
|
|
command: process.execPath,
|
|
args: [plugin.resolverPath],
|
|
trustedDirs: [plugin.pluginRoot, path.dirname(process.execPath)],
|
|
passEnv: ["PROOF_SECRET_STORE_PATH"],
|
|
timeoutMs: 1200,
|
|
noOutputTimeoutMs: 800,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "plugin exec",
|
|
token: PLUGIN_EXEC_TOKEN,
|
|
config: (port) =>
|
|
baseConfig(port, {
|
|
gateway: { auth: { mode: "token", token: proofSecretRef("plugin-exec/token") } },
|
|
}),
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
const port = await allocatePort();
|
|
const ctx = scenario.before?.() ?? {};
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, scenario.config(port, ctx));
|
|
const childEnv = { ...envCtx.env, ...scenario.env };
|
|
const scenarioCtx = { ...envCtx, env: childEnv };
|
|
const gateway = await startGateway(scenarioCtx, port, scenario.token);
|
|
try {
|
|
await expectGatewayCallOk(childEnv, port, scenario.token);
|
|
} finally {
|
|
await gateway.stop();
|
|
}
|
|
}
|
|
});
|
|
return "env, file, manual exec, and plugin exec providers each authenticated a live gateway";
|
|
}
|
|
|
|
async function p10UntrustedPluginFailsClosed() {
|
|
return await withProofEnv("p10", async (envCtx) => {
|
|
const port = await allocatePort();
|
|
writeJson(
|
|
envCtx.env.OPENCLAW_CONFIG_PATH,
|
|
baseConfig(port, {
|
|
plugins: {
|
|
entries: {
|
|
[PLUGIN_ID]: { enabled: false },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await expectGatewayStartupFails(envCtx, port, "disabled plugin integration");
|
|
return "disabled plugin integration blocked startup";
|
|
});
|
|
}
|
|
|
|
async function p11TimeoutFailClosedAndLkg() {
|
|
await withProofEnv("p11", async (envCtx, _plugin, storePath) => {
|
|
const failPort = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(failPort));
|
|
mutateStore(storePath, (store) => ({ ...store, sleepMs: 3000 }));
|
|
await expectGatewayStartupFails(envCtx, failPort, "resolver timeout");
|
|
|
|
mutateStore(storePath, (store) => ({ ...store, sleepMs: 0 }));
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port));
|
|
const gateway = await startGateway(envCtx, port, TOKEN_V1);
|
|
try {
|
|
mutateStore(storePath, (store) => ({ ...store, sleepMs: 3000 }));
|
|
await expectGatewayCallFails(envCtx.env, port, TOKEN_V1, "secrets.reload");
|
|
mutateStore(storePath, (store) => ({ ...store, sleepMs: 0 }));
|
|
await expectGatewayCallOk(envCtx.env, port, TOKEN_V1);
|
|
} finally {
|
|
await gateway.stop();
|
|
}
|
|
});
|
|
return "timeout fails startup and reload timeout preserves Last Known Good";
|
|
}
|
|
|
|
async function p12OpenAiLiveProof() {
|
|
if (!process.env.OPENAI_API_KEY) {
|
|
if (requireFullMatrix()) {
|
|
throw new Error("OPENAI_API_KEY is required for full matrix OpenAI proof");
|
|
}
|
|
return "OPENAI_API_KEY not present; final live matrix must forward the provided OpenAI env profile";
|
|
}
|
|
await withProofEnv(
|
|
"p12",
|
|
async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(
|
|
envCtx.env.OPENCLAW_CONFIG_PATH,
|
|
baseConfig(port, { agents: { defaults: { model: OPENAI_LIVE_PROOF_MODEL } } }),
|
|
);
|
|
const authPath = path.join(envCtx.stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
writeJson(authPath, {
|
|
version: 1,
|
|
profiles: {
|
|
[OPENAI_PROFILE]: {
|
|
type: "api_key",
|
|
provider: "openai",
|
|
keyRef: proofSecretRef("openai/apiKey"),
|
|
},
|
|
},
|
|
});
|
|
const callsBefore = readJson(storePath).calls;
|
|
const result = await runOpenClaw(
|
|
[
|
|
"models",
|
|
"status",
|
|
"--json",
|
|
"--probe",
|
|
"--probe-provider",
|
|
"openai",
|
|
"--probe-profile",
|
|
OPENAI_PROFILE,
|
|
"--probe-timeout",
|
|
"60000",
|
|
"--probe-max-tokens",
|
|
"8",
|
|
],
|
|
envCtx.env,
|
|
{ timeoutMs: 90000, allowFailure: true },
|
|
);
|
|
const combined = scrub(`${result.stdout}\n${result.stderr}`);
|
|
if (result.code !== 0) {
|
|
throw new Error(`OpenAI live probe failed: ${combined}`);
|
|
}
|
|
const payload = parseJsonOutput(result.stdout);
|
|
const probeResult = payload.auth?.probes?.results?.find?.(
|
|
(entry) => entry?.profileId === OPENAI_PROFILE && entry?.source === "profile",
|
|
);
|
|
if (!probeResult || probeResult.status !== "ok") {
|
|
throw new Error(`OpenAI live probe did not report ok for ${OPENAI_PROFILE}: ${combined}`);
|
|
}
|
|
const callsAfter = readJson(storePath).calls;
|
|
if (callsAfter <= callsBefore) {
|
|
throw new Error("OpenAI proof did not invoke the plugin-managed resolver");
|
|
}
|
|
if (!combined.includes(OPENAI_PROFILE)) {
|
|
throw new Error(`OpenAI proof did not mention expected profile ${OPENAI_PROFILE}`);
|
|
}
|
|
if (!/openai/iu.test(combined) || /unresolved_ref/iu.test(combined)) {
|
|
throw new Error(`OpenAI live probe did not produce usable OpenAI proof: ${combined}`);
|
|
}
|
|
},
|
|
undefined,
|
|
{ includeOpenAiPassEnv: true },
|
|
);
|
|
return "OpenAI model auth probe consumed API key through plugin-managed auth-profile SecretRef";
|
|
}
|
|
|
|
async function runPtySecretsConfigurePreset(envCtx) {
|
|
const { spawn } = await import("@lydell/node-pty");
|
|
const command = await resolveOpenClawCommand(
|
|
[
|
|
"secrets",
|
|
"configure",
|
|
"--providers-only",
|
|
"--apply",
|
|
"--yes",
|
|
"--allow-exec",
|
|
"--json",
|
|
],
|
|
envCtx.env,
|
|
);
|
|
const child = spawn(
|
|
command.command,
|
|
command.args,
|
|
{
|
|
name: "xterm-256color",
|
|
cols: 100,
|
|
rows: 30,
|
|
cwd: command.options.cwd ?? process.cwd(),
|
|
env: command.options.env ?? envCtx.env,
|
|
},
|
|
);
|
|
let output = "";
|
|
let phase = "providers-menu";
|
|
const sendKeys = (keys) => {
|
|
keys.forEach((key, index) => {
|
|
setTimeout(() => child.write(key), index * 80);
|
|
});
|
|
};
|
|
return await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill();
|
|
reject(new Error(`secrets configure preset timed out: ${scrub(output)}`));
|
|
}, 60000);
|
|
child.onData((data) => {
|
|
output += data;
|
|
if (phase === "providers-menu" && output.includes("Configure secret providers")) {
|
|
phase = "selecting-preset";
|
|
sendKeys(["\x1b[B", "\r"]);
|
|
return;
|
|
}
|
|
if (phase === "selecting-preset" && output.includes("Select plugin preset")) {
|
|
phase = "preset-selected";
|
|
sendKeys(["\r"]);
|
|
output = "";
|
|
return;
|
|
}
|
|
if (phase === "preset-selected" && output.includes("Configure secret providers")) {
|
|
phase = "continue-selected";
|
|
sendKeys(["\x1b[A", "\r"]);
|
|
}
|
|
});
|
|
child.onExit(({ exitCode }) => {
|
|
clearTimeout(timer);
|
|
if (exitCode !== 0) {
|
|
reject(new Error(`secrets configure preset failed (${exitCode}): ${scrub(output)}`));
|
|
return;
|
|
}
|
|
resolve(output);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function p13SecretsConfigurePreset() {
|
|
await withProofEnv("p13", async (envCtx) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port, { secrets: { providers: {} } }));
|
|
await runPtySecretsConfigurePreset(envCtx);
|
|
const config = readJson(envCtx.env.OPENCLAW_CONFIG_PATH);
|
|
const provider = config.secrets?.providers?.[PROVIDER_ALIAS];
|
|
if (JSON.stringify(provider) !== JSON.stringify(proofProviderConfig())) {
|
|
throw new Error(
|
|
`secrets configure did not persist pluginIntegration provider: ${JSON.stringify(provider)}`,
|
|
);
|
|
}
|
|
});
|
|
return "interactive secrets configure selected plugin preset and wrote only pluginIntegration metadata";
|
|
}
|
|
|
|
async function p14ConfigPatchValidation() {
|
|
await withProofEnv("p14", async (envCtx) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port, { secrets: { providers: {} } }));
|
|
const validPatch = {
|
|
secrets: {
|
|
providers: {
|
|
[PROVIDER_ALIAS]: proofProviderConfig(),
|
|
},
|
|
},
|
|
};
|
|
const valid = await runOpenClaw(
|
|
["config", "patch", "--stdin", "--dry-run", "--allow-exec", "--json"],
|
|
envCtx.env,
|
|
{ input: JSON.stringify(validPatch), timeoutMs: 60000 },
|
|
);
|
|
const validPayload = parseJsonOutput(valid.stdout);
|
|
if (!validPayload.valid && validPayload.ok !== true && validPayload.changed === undefined) {
|
|
throw new Error(
|
|
`valid pluginIntegration config patch was not accepted: ${scrub(valid.stdout)}`,
|
|
);
|
|
}
|
|
const invalidPatch = {
|
|
secrets: {
|
|
providers: {
|
|
[PROVIDER_ALIAS]: {
|
|
source: "exec",
|
|
pluginIntegration: { pluginId: PLUGIN_ID, integrationId: "missing" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const invalid = await runOpenClaw(
|
|
["config", "patch", "--stdin", "--dry-run", "--allow-exec", "--json"],
|
|
envCtx.env,
|
|
{ input: JSON.stringify(invalidPatch), timeoutMs: 60000, allowFailure: true },
|
|
);
|
|
if (invalid.code === 0) {
|
|
throw new Error("invalid pluginIntegration config patch unexpectedly succeeded");
|
|
}
|
|
const output = scrub(`${invalid.stdout}\n${invalid.stderr}`);
|
|
if (!/plugin|integration|secret/iu.test(output)) {
|
|
throw new Error(`invalid pluginIntegration patch did not explain the failure: ${output}`);
|
|
}
|
|
});
|
|
return "config patch accepts valid pluginIntegration and rejects invalid integration metadata";
|
|
}
|
|
|
|
async function p15ModelsAuthCliScope() {
|
|
const envCtx = makeEnv("p15");
|
|
try {
|
|
const help = await runOpenClaw(["models", "auth", "paste-api-key", "--help"], envCtx.env, {
|
|
timeoutMs: 30000,
|
|
});
|
|
const text = help.stdout;
|
|
if (/keyRef|SecretRef|--ref|--secret/iu.test(text)) {
|
|
throw new Error(
|
|
"models auth paste-api-key appears to expose a SecretRef input; add a live creation proof for it",
|
|
);
|
|
}
|
|
} finally {
|
|
await cleanupEnv(envCtx.root);
|
|
}
|
|
return "models auth has no non-interactive SecretRef creation flag; auth-profile encounter path is covered by P7/P8/P12";
|
|
}
|
|
|
|
async function p16DiagnosticsNoLeak() {
|
|
await withProofEnv("p16", async (envCtx, _plugin, storePath) => {
|
|
const port = await allocatePort();
|
|
writeJson(envCtx.env.OPENCLAW_CONFIG_PATH, baseConfig(port));
|
|
mutateStore(storePath, (store) => ({ ...store, mode: "fail" }));
|
|
const output = await expectGatewayStartupFails(envCtx, port, "diagnostic redaction");
|
|
if (
|
|
output.includes(TOKEN_V1) ||
|
|
output.includes(TOKEN_V2) ||
|
|
output.includes(PLUGIN_EXEC_TOKEN)
|
|
) {
|
|
throw new Error("diagnostic output leaked a proof secret");
|
|
}
|
|
if (!/secret|provider|resolve|ref/iu.test(output)) {
|
|
throw new Error(`diagnostic output was not actionable: ${output}`);
|
|
}
|
|
});
|
|
return "startup diagnostics are actionable and do not include secret values";
|
|
}
|
|
|
|
async function p17StaticMetadataAlignment() {
|
|
const envCtx = makeEnv("p17");
|
|
try {
|
|
const schema = await runOpenClaw(["config", "schema"], envCtx.env, { timeoutMs: 60000 });
|
|
const schemaText = schema.stdout;
|
|
if (!schemaText.includes("pluginIntegration") || !schemaText.includes("integrationId")) {
|
|
throw new Error("config schema does not expose pluginIntegration metadata");
|
|
}
|
|
const secretsHelp = await runOpenClaw(["secrets", "configure", "--help"], envCtx.env, {
|
|
timeoutMs: 30000,
|
|
});
|
|
if (
|
|
!secretsHelp.stdout.includes("--providers-only") ||
|
|
!secretsHelp.stdout.includes("--allow-exec")
|
|
) {
|
|
throw new Error("secrets configure help is missing expected provider/exec flags");
|
|
}
|
|
await runCommand(
|
|
"node",
|
|
["--import", "tsx", "scripts/generate-config-doc-baseline.ts", "--check"],
|
|
{ timeoutMs: 60000 },
|
|
);
|
|
} finally {
|
|
await cleanupEnv(envCtx.root);
|
|
}
|
|
return "schema/help/static diff metadata aligned";
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`[info] runner=${resolveOpenClawRunner().label}`);
|
|
console.log(`[info] results=${RESULTS_PATH}`);
|
|
let runError;
|
|
try {
|
|
await runWithProof(
|
|
"P1",
|
|
"startup succeeds with plugin-managed exec SecretRef",
|
|
p1StartupSucceeds,
|
|
);
|
|
await runWithProof(
|
|
"P2",
|
|
"startup fails closed when plugin integration cannot resolve",
|
|
p2StartupFailsClosed,
|
|
);
|
|
await runWithProof(
|
|
"P3-P6",
|
|
"static capture, reload, LKG, and secrets.resolve",
|
|
p3ThroughP6StaticReloadAndCommandSnapshot,
|
|
);
|
|
await runWithProof(
|
|
"P7",
|
|
"persisted auth-profile keyRef resolves through plugin integration",
|
|
p7AuthProfileSecretRefPersistsAndResolves,
|
|
);
|
|
await runWithProof(
|
|
"P8",
|
|
"managed service install/start preserves auth-profile exec passEnv",
|
|
p8ManagedServiceEnvProof,
|
|
);
|
|
await runWithProof(
|
|
"P9",
|
|
"env/file/manual-exec/plugin-exec provider variants",
|
|
p9ProviderVariants,
|
|
);
|
|
await runWithProof(
|
|
"P10",
|
|
"disabled/untrusted plugin integration fails closed",
|
|
p10UntrustedPluginFailsClosed,
|
|
);
|
|
await runWithProof(
|
|
"P11",
|
|
"resolver timeout fails closed and reload keeps LKG",
|
|
p11TimeoutFailClosedAndLkg,
|
|
);
|
|
await runWithProof("P12", "real OpenAI auth-profile SecretRef live probe", p12OpenAiLiveProof);
|
|
await runWithProof(
|
|
"P13",
|
|
"interactive secrets configure plugin preset",
|
|
p13SecretsConfigurePreset,
|
|
);
|
|
await runWithProof(
|
|
"P14",
|
|
"config patch validation for pluginIntegration",
|
|
p14ConfigPatchValidation,
|
|
);
|
|
await runWithProof("P15", "models auth SecretRef creation scope", p15ModelsAuthCliScope);
|
|
await runWithProof("P16", "diagnostics are actionable and redacted", p16DiagnosticsNoLeak);
|
|
await runWithProof("P17", "schema/help/static metadata alignment", p17StaticMetadataAlignment);
|
|
} catch (error) {
|
|
runError = error;
|
|
} finally {
|
|
writeJson(RESULTS_PATH, {
|
|
generatedAt: new Date().toISOString(),
|
|
runner: resolveOpenClawRunner().label,
|
|
results,
|
|
});
|
|
}
|
|
if (runError) {
|
|
throw runError;
|
|
}
|
|
const failed = results.filter((entry) => entry.status !== "pass");
|
|
if (failed.length > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
export { gatewayCall, runCommand, startGateway, waitForManagedGatewayStatus };
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
await main();
|
|
}
|