mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
49d8cfd393
* fix(security): honor blocked SearXNG secret refs * docs(searxng): clarify blocked SecretRef policy * test(codex): route attempt tools coverage
951 lines
32 KiB
TypeScript
951 lines
32 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { DEFAULT_SECRET_FILE_MAX_BYTES } from "openclaw/plugin-sdk/secret-file-runtime";
|
|
import {
|
|
resolvePreferredOpenClawTmpDir,
|
|
tempWorkspaceSync,
|
|
type TempWorkspaceSync,
|
|
} from "openclaw/plugin-sdk/temp-path";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
|
|
import { createTrustedNodeFixture } from "./trusted-node.test-support.js";
|
|
|
|
const sourceResolverPath = fileURLToPath(
|
|
new URL("../onepassword-secret-ref-resolver.js", import.meta.url),
|
|
);
|
|
const sourceStaticAssetPaths = [
|
|
sourceResolverPath,
|
|
fileURLToPath(new URL("../onepassword-op-path.js", import.meta.url)),
|
|
fileURLToPath(new URL("../onepassword-secret-id.js", import.meta.url)),
|
|
];
|
|
const manifestPath = fileURLToPath(new URL("../openclaw.plugin.json", import.meta.url));
|
|
const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
const tsxCliPath = fileURLToPath(import.meta.resolve("tsx/cli"));
|
|
const rootTsconfigPath = path.resolve("tsconfig.json");
|
|
const secretRefRuntimeSourceUrl = pathToFileURL(
|
|
path.resolve("src/plugin-sdk/secret-ref-runtime.ts"),
|
|
).href;
|
|
// The manifest test reads the production source; the timeout-only staged executable needs a
|
|
// shorter deadline to prove cleanup without sleeping for the production seven seconds.
|
|
const TEST_OP_READ_TIMEOUT_MS = process.platform === "win32" ? 5_000 : 1_500;
|
|
const TEST_DESCENDANT_MARKER_DELAY_MS = TEST_OP_READ_TIMEOUT_MS + 500;
|
|
const TEST_DESCENDANT_SETTLE_MARGIN_MS = 2_000;
|
|
const resolverStateWorkspaces: TempWorkspaceSync[] = [];
|
|
let fixtureWorkspace: TempWorkspaceSync;
|
|
let resolverPath = sourceResolverPath;
|
|
let timeoutResolverPath: string | undefined;
|
|
let stagedResolverRoot: string | undefined;
|
|
let trustedNodeRoot: string | undefined;
|
|
let trustedNodePath: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
const tempRoot = path.join(process.cwd(), ".tmp");
|
|
fs.mkdirSync(tempRoot, { recursive: true });
|
|
stagedResolverRoot = fs.mkdtempSync(path.join(tempRoot, "onepassword-resolver-"));
|
|
for (const sourcePath of sourceStaticAssetPaths) {
|
|
const stagedPath = path.join(stagedResolverRoot, path.basename(sourcePath));
|
|
if (sourcePath.endsWith("onepassword-op-path.js")) {
|
|
fs.writeFileSync(
|
|
stagedPath,
|
|
fs
|
|
.readFileSync(sourcePath, "utf8")
|
|
.replace(
|
|
'"openclaw/plugin-sdk/secret-ref-runtime"',
|
|
JSON.stringify(secretRefRuntimeSourceUrl),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
fs.copyFileSync(sourcePath, stagedPath);
|
|
}
|
|
// Keep the relative static assets together, but resolve the real SDK from source so this
|
|
// focused test does not depend on a parallel build producing dist/plugin-sdk first.
|
|
resolverPath = path.join(stagedResolverRoot, path.basename(sourceResolverPath));
|
|
const resolverSource = fs.readFileSync(sourceResolverPath, "utf8");
|
|
const timeoutResolverSource = resolverSource.replace(
|
|
"const OP_READ_TIMEOUT_MS = 7_000;",
|
|
`const OP_READ_TIMEOUT_MS = ${TEST_OP_READ_TIMEOUT_MS};`,
|
|
);
|
|
if (timeoutResolverSource === resolverSource) {
|
|
throw new Error("failed to shorten the staged 1Password resolver timeout");
|
|
}
|
|
timeoutResolverPath = path.join(stagedResolverRoot, "onepassword-timeout-resolver.js");
|
|
fs.writeFileSync(timeoutResolverPath, timeoutResolverSource);
|
|
// The fake op paths remain per-test; only their trusted Node interpreter is shared.
|
|
// Re-copying the runtime does not strengthen path-ownership coverage.
|
|
trustedNodeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-1password-node-"));
|
|
trustedNodePath = createTrustedNodeFixture(trustedNodeRoot);
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (stagedResolverRoot) {
|
|
fs.rmSync(stagedResolverRoot, { recursive: true, force: true });
|
|
}
|
|
if (trustedNodeRoot) {
|
|
fs.rmSync(trustedNodeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function getTrustedNodePath(): string {
|
|
if (!trustedNodePath) {
|
|
throw new Error("trusted Node fixture was not initialized");
|
|
}
|
|
return trustedNodePath;
|
|
}
|
|
|
|
function getTimeoutResolverPath(): string {
|
|
if (!timeoutResolverPath) {
|
|
throw new Error("timeout resolver fixture was not initialized");
|
|
}
|
|
return timeoutResolverPath;
|
|
}
|
|
|
|
async function waitForPath(filePath: string, timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!fs.existsSync(filePath)) {
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(`Timed out waiting for test path: ${filePath}`);
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 10);
|
|
});
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function runResolver(params: {
|
|
request: unknown;
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
resolverExecutablePath?: string;
|
|
token?: string | null;
|
|
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
let stateDir = params.env?.OPENCLAW_STATE_DIR;
|
|
if (!stateDir) {
|
|
const workspace = tempWorkspaceSync({
|
|
rootDir: resolvePreferredOpenClawTmpDir(),
|
|
prefix: "openclaw-1password-test-",
|
|
});
|
|
resolverStateWorkspaces.push(workspace);
|
|
stateDir = workspace.dir;
|
|
}
|
|
if (params.token !== null) {
|
|
const tokenDir = path.join(stateDir, "credentials", "onepassword");
|
|
fs.mkdirSync(tokenDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tokenDir, "service-account-token"),
|
|
params.token ?? "not-a-real-service-account-token",
|
|
{ mode: 0o600 },
|
|
);
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[tsxCliPath, "--tsconfig", rootTsconfigPath, params.resolverExecutablePath ?? resolverPath],
|
|
{
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
OP_SERVICE_ACCOUNT_TOKEN: "",
|
|
CLAW_1PASSWORD_OP: "",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
...params.env,
|
|
},
|
|
},
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
resolve({ stdout, stderr, code });
|
|
});
|
|
child.stdin.end(`${JSON.stringify(params.request)}\n`);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fixtureWorkspace = tempWorkspaceSync({
|
|
rootDir: resolvePreferredOpenClawTmpDir(),
|
|
prefix: "openclaw-1password-test-",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const workspace of resolverStateWorkspaces.splice(0)) {
|
|
workspace.cleanup();
|
|
}
|
|
fixtureWorkspace.cleanup();
|
|
});
|
|
|
|
describe("plugin manifest", () => {
|
|
it("declares the 1Password resolver as a managed Node SecretRef preset", () => {
|
|
const resolverSource = fs.readFileSync(sourceResolverPath, "utf8");
|
|
const readIntegerConstant = (name: string): number => {
|
|
const match = new RegExp(`const ${name} = (\\d[\\d_]*)`, "u").exec(resolverSource);
|
|
return Number(match?.[1]?.replaceAll("_", ""));
|
|
};
|
|
const opReadConcurrency = readIntegerConstant("OP_READ_CONCURRENCY");
|
|
const opReadTimeoutMs = readIntegerConstant("OP_READ_TIMEOUT_MS");
|
|
const maxRefsPerRequest = readIntegerConstant("MAX_SECRET_REFS_PER_REQUEST");
|
|
const maxSecretValueBytes = readIntegerConstant("MAX_SECRET_VALUE_BYTES");
|
|
const worstCaseBatchTimeoutMs =
|
|
Math.ceil(maxRefsPerRequest / opReadConcurrency) * opReadTimeoutMs;
|
|
const worstCaseEscapedValueBytes = Buffer.byteLength(
|
|
JSON.stringify("\0".repeat(maxSecretValueBytes)),
|
|
);
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
commandAliases?: Array<{ name?: string; cliCommand?: string }>;
|
|
secretProviderIntegrations?: Record<string, Record<string, unknown>>;
|
|
};
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")) as {
|
|
openclaw?: {
|
|
build?: {
|
|
staticAssets?: Array<{ source?: string; output?: string }>;
|
|
};
|
|
};
|
|
};
|
|
const integration = manifest.secretProviderIntegrations?.onepassword;
|
|
|
|
expect(manifest.commandAliases).toContainEqual({
|
|
name: "onepassword",
|
|
cliCommand: "onepassword",
|
|
});
|
|
expect(integration).toMatchObject({
|
|
providerAlias: "onepassword",
|
|
source: "exec",
|
|
command: "${node}",
|
|
args: ["./onepassword-secret-ref-resolver.js"],
|
|
timeoutMs: 90_000,
|
|
noOutputTimeoutMs: 90_000,
|
|
maxOutputBytes: 16 * 1024 * 1024,
|
|
passEnv: expect.arrayContaining([
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"TEMP",
|
|
"TMP",
|
|
"OPENCLAW_STATE_DIR",
|
|
"OPENCLAW_PROFILE",
|
|
"PATH",
|
|
"SYSTEMROOT",
|
|
"WINDIR",
|
|
]),
|
|
});
|
|
expect(integration?.passEnv).not.toContain("OP_SERVICE_ACCOUNT_TOKEN");
|
|
expect(integration?.passEnv).not.toContain("OP_CONNECT_HOST");
|
|
expect(integration?.passEnv).not.toContain("OP_CONNECT_TOKEN");
|
|
expect(integration?.passEnv).not.toContain("OP_ACCOUNT");
|
|
expect(integration?.passEnv).not.toContain("OP_CACHE");
|
|
expect(integration).not.toHaveProperty("trustedDirs");
|
|
expect(integration?.timeoutMs).toBeGreaterThan(worstCaseBatchTimeoutMs);
|
|
expect(integration?.noOutputTimeoutMs).toBeGreaterThan(worstCaseBatchTimeoutMs);
|
|
expect(integration?.maxOutputBytes).toBeGreaterThan(
|
|
maxRefsPerRequest * worstCaseEscapedValueBytes,
|
|
);
|
|
expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({
|
|
source: "./onepassword-op-path.js",
|
|
output: "onepassword-op-path.js",
|
|
});
|
|
expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({
|
|
source: "./onepassword-secret-ref-resolver.js",
|
|
output: "onepassword-secret-ref-resolver.js",
|
|
});
|
|
expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({
|
|
source: "./onepassword-secret-id.js",
|
|
output: "onepassword-secret-id.js",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("1Password SecretRef resolver", () => {
|
|
it.runIf(process.platform === "win32")(
|
|
"preserves the Windows profile directories required by op",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const appData = path.join(tempDir, "profile", "AppData", "Roaming");
|
|
const localAppData = path.join(tempDir, "profile", "AppData", "Local");
|
|
const temp = path.join(localAppData, "Temp");
|
|
const tmp = path.join(localAppData, "Tmp");
|
|
for (const directory of [appData, localAppData, temp, tmp]) {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(tempDir, "read"),
|
|
`process.stdout.write(JSON.stringify({
|
|
USERPROFILE: process.env.USERPROFILE,
|
|
APPDATA: process.env.APPDATA,
|
|
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
TEMP: process.env.TEMP,
|
|
TMP: process.env.TMP,
|
|
serviceAccount: process.env.OP_SERVICE_ACCOUNT_TOKEN === "not-a-real-service-account-token",
|
|
}));\n`,
|
|
);
|
|
|
|
const id = "op://Engineering/OpenRouter/apiKey";
|
|
const result = await runResolver({
|
|
request: { protocolVersion: 1, provider: "onepassword", ids: [id] },
|
|
cwd: tempDir,
|
|
env: {
|
|
CLAW_1PASSWORD_OP: process.execPath,
|
|
HOME: path.join(tempDir, "wrong-home"),
|
|
USERPROFILE: path.join(tempDir, "profile"),
|
|
APPDATA: appData,
|
|
LOCALAPPDATA: localAppData,
|
|
TEMP: temp,
|
|
TMP: tmp,
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(JSON.parse(result.stdout).values[id])).toEqual({
|
|
USERPROFILE: path.join(tempDir, "profile"),
|
|
APPDATA: appData,
|
|
LOCALAPPDATA: localAppData,
|
|
TEMP: temp,
|
|
TMP: tmp,
|
|
serviceAccount: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"uses op read with native 1Password secret references",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const logPath = path.join(tempDir, "op-args.json");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const fs = require("node:fs");
|
|
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({
|
|
args: process.argv.slice(2),
|
|
biometric: process.env.OP_BIOMETRIC_UNLOCK_ENABLED,
|
|
loadDesktopSettings: process.env.OP_LOAD_DESKTOP_APP_SETTINGS,
|
|
account: process.env.OP_ACCOUNT,
|
|
serviceAccount: process.env.OP_SERVICE_ACCOUNT_TOKEN === "not-a-real-service-account-token",
|
|
}));
|
|
process.stdout.write("not-a-real-value \\t");
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: {
|
|
CLAW_1PASSWORD_OP: opPath,
|
|
OP_ACCOUNT: "should-not-reach-op",
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
protocolVersion: 1,
|
|
values: {
|
|
"op://Engineering/OpenRouter/apiKey": "not-a-real-value \t",
|
|
},
|
|
errors: {},
|
|
});
|
|
expect(JSON.parse(fs.readFileSync(logPath, "utf8"))).toEqual({
|
|
args: ["read", "--cache=false", "--no-newline", "op://Engineering/OpenRouter/apiKey"],
|
|
biometric: "false",
|
|
loadDesktopSettings: "false",
|
|
serviceAccount: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"decodes native references that do not fit the shared exec id grammar",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const logPath = path.join(tempDir, "op-args.json");
|
|
const nativeRef = "op://Personal/OpenClaw QA API Key/password?attribute=value%20one";
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const fs = require("node:fs");
|
|
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(process.argv.slice(2)));
|
|
process.stdout.write("not-a-real-value");
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const encodedId = encodeOnePasswordSecretId(nativeRef);
|
|
const result = await runResolver({
|
|
request: { protocolVersion: 1, provider: "onepassword", ids: [encodedId] },
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
|
|
expect(encodedId).toMatch(/^opb64:[A-Za-z0-9_-]+$/);
|
|
expect(JSON.parse(result.stdout).values).toEqual({ [encodedId]: "not-a-real-value" });
|
|
expect(JSON.parse(fs.readFileSync(logPath, "utf8"))).toEqual([
|
|
"read",
|
|
"--cache=false",
|
|
"--no-newline",
|
|
nativeRef,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("escapes shorthand ids that begin with the opaque encoding prefix", async () => {
|
|
const nativeRef = "opb64:team/item/field";
|
|
const encodedId = encodeOnePasswordSecretId(nativeRef);
|
|
|
|
expect(encodedId).toMatch(/^opb64:[A-Za-z0-9_-]+$/);
|
|
expect(encodedId).not.toBe(nativeRef);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"waits for inherited op stdout to close before returning the secret",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const { spawn } = require("node:child_process");
|
|
spawn(process.execPath, ["-e", "setTimeout(() => process.stdout.write('tail'), 50)"], {
|
|
stdio: ["ignore", process.stdout, "ignore"],
|
|
});
|
|
process.stdout.write("head");
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
|
|
expect(JSON.parse(result.stdout).values).toEqual({
|
|
"Engineering/OpenRouter/apiKey": "headtail",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"builds op secret references from shorthand ids",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const logPath = path.join(tempDir, "op-args.json");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const fs = require("node:fs");
|
|
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(process.argv.slice(2)));
|
|
process.stdout.write("not-a-real-value");
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
protocolVersion: 1,
|
|
values: {
|
|
"Engineering/OpenRouter/apiKey": "not-a-real-value",
|
|
},
|
|
errors: {},
|
|
});
|
|
expect(JSON.parse(fs.readFileSync(logPath, "utf8"))).toEqual([
|
|
"read",
|
|
"--cache=false",
|
|
"--no-newline",
|
|
"op://Engineering/OpenRouter/apiKey",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("requires an absolute op CLI path", async () => {
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: {
|
|
CLAW_1PASSWORD_OP: "op",
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
});
|
|
|
|
it("rejects oversized batches before reading credentials or starting op", async () => {
|
|
const ids = Array.from(
|
|
{ length: 33 },
|
|
(_, index) => `op://Engineering/Item${index}/credential`,
|
|
);
|
|
const result = await runResolver({
|
|
request: { protocolVersion: 1, provider: "onepassword", ids },
|
|
env: { CLAW_1PASSWORD_OP: "/does/not/exist/op" },
|
|
token: null,
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
const response = JSON.parse(result.stdout) as {
|
|
values: Record<string, string>;
|
|
errors: Record<string, { message: string }>;
|
|
};
|
|
expect(response.values).toEqual({});
|
|
expect(Object.keys(response.errors)).toEqual(ids);
|
|
expect(new Set(Object.values(response.errors).map((error) => error.message))).toEqual(
|
|
new Set(["1Password SecretRef resolver supports at most 32 references per request."]),
|
|
);
|
|
});
|
|
|
|
it("requires the broker service-account token file", async () => {
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: process.execPath },
|
|
token: null,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
});
|
|
|
|
it("rejects an oversized broker service-account token file", async () => {
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: process.execPath },
|
|
token: "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1),
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")("rejects a symlinked broker token file", async () => {
|
|
const stateDir = fixtureWorkspace.dir;
|
|
const tokenDir = path.join(stateDir, "credentials", "onepassword");
|
|
const targetPath = path.join(tokenDir, "service-account-token-target");
|
|
const tokenPath = path.join(tokenDir, "service-account-token");
|
|
fs.mkdirSync(tokenDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, "linked-service-account-token", { mode: 0o600 });
|
|
fs.symlinkSync(targetPath, tokenPath);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: process.execPath, OPENCLAW_STATE_DIR: stateDir },
|
|
token: null,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"accepts the broker token file through a hardlink",
|
|
async () => {
|
|
const stateDir = fixtureWorkspace.dir;
|
|
const tokenDir = path.join(stateDir, "credentials", "onepassword");
|
|
const targetPath = path.join(tokenDir, "service-account-token-target");
|
|
const tokenPath = path.join(tokenDir, "service-account-token");
|
|
const opPath = path.join(stateDir, "op");
|
|
fs.mkdirSync(tokenDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, "linked-service-account-token", { mode: 0o600 });
|
|
fs.linkSync(targetPath, tokenPath);
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}\nprocess.stdout.write(process.env.OP_SERVICE_ACCOUNT_TOKEN === "linked-service-account-token" ? "ok" : "bad");\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: opPath, OPENCLAW_STATE_DIR: stateDir },
|
|
token: null,
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(result.stdout).values).toEqual({
|
|
"op://Engineering/OpenRouter/apiKey": "ok",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"reads the service token from the selected profile state directory",
|
|
async () => {
|
|
const home = fixtureWorkspace.dir;
|
|
const profileTokenDir = path.join(home, ".openclaw-work", "credentials", "onepassword");
|
|
const defaultTokenDir = path.join(home, ".openclaw", "credentials", "onepassword");
|
|
const opPath = path.join(home, "op");
|
|
fs.mkdirSync(profileTokenDir, { recursive: true });
|
|
fs.mkdirSync(defaultTokenDir, { recursive: true });
|
|
fs.writeFileSync(path.join(profileTokenDir, "service-account-token"), "profile-token", {
|
|
mode: 0o600,
|
|
});
|
|
fs.writeFileSync(path.join(defaultTokenDir, "service-account-token"), "default-token", {
|
|
mode: 0o600,
|
|
});
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}\nprocess.stdout.write(process.env.OP_SERVICE_ACCOUNT_TOKEN);\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: {
|
|
CLAW_1PASSWORD_OP: opPath,
|
|
HOME: home,
|
|
OPENCLAW_HOME: "",
|
|
OPENCLAW_PROFILE: "work",
|
|
OPENCLAW_STATE_DIR: "",
|
|
},
|
|
token: null,
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(result.stdout).values).toEqual({
|
|
"op://Engineering/OpenRouter/apiKey": "profile-token",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"does not include failed child output in resolver errors",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
process.stdout.write("secret-output-must-not-escape");
|
|
process.stderr.write("secret-error-must-not-escape");
|
|
process.exitCode = 1;
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
expect(result.stdout).not.toContain("secret-output-must-not-escape");
|
|
expect(result.stdout).not.toContain("secret-error-must-not-escape");
|
|
expect(JSON.parse(result.stdout).errors).toEqual({
|
|
"op://Engineering/OpenRouter/apiKey": { message: "op read failed with exit code 1." },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"kills the op process tree when output exceeds the limit",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const descendantPidPath = path.join(tempDir, "descendant.pid");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const fs = require("node:fs");
|
|
const { spawn } = require("node:child_process");
|
|
const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(`process.on("SIGTERM", () => {}); setInterval(() => {}, 1000);`)}], { stdio: "ignore" });
|
|
descendant.once("spawn", () => {
|
|
fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));
|
|
process.stdout.write("x".repeat(70 * 1024));
|
|
});
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
let descendantPid: number | undefined;
|
|
try {
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
expect(JSON.parse(result.stdout).errors).toEqual({
|
|
"op://Engineering/OpenRouter/apiKey": {
|
|
message: "op read output exceeded the secret value limit.",
|
|
},
|
|
});
|
|
const pid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
|
descendantPid = pid;
|
|
expect(pid).toBeGreaterThan(0);
|
|
await expect
|
|
.poll(
|
|
() => (isProcessAlive(pid) ? `descendant ${String(pid)} is still alive` : "exited"),
|
|
{ timeout: 2_000, interval: 10 },
|
|
)
|
|
.toBe("exited");
|
|
} finally {
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it(
|
|
"kills the op process tree when a read times out",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const descendantReady = path.join(tempDir, "timed-out-descendant-ready");
|
|
const descendantMarker = path.join(tempDir, "timed-out-descendant-survived");
|
|
const descendantBody = `const fs = require("node:fs");
|
|
process.on("SIGTERM", () => {});
|
|
setTimeout(() => fs.writeFileSync(${JSON.stringify(descendantMarker)}, "survived"), ${TEST_DESCENDANT_MARKER_DELAY_MS});
|
|
setInterval(() => {}, 1000);
|
|
`;
|
|
let opPath = process.execPath;
|
|
if (process.platform === "win32") {
|
|
const opBody = `const fs = require("node:fs");
|
|
const { spawn } = require("node:child_process");
|
|
const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(descendantBody)}], { stdio: "ignore" });
|
|
descendant.once("spawn", () => fs.writeFileSync(${JSON.stringify(descendantReady)}, "ready"));
|
|
setInterval(() => {}, 1000);
|
|
`;
|
|
fs.writeFileSync(path.join(tempDir, "read"), opBody);
|
|
} else {
|
|
// Executable trust validation requires the literal shebang path to be canonical.
|
|
const shellPath = fs.realpathSync("/bin/sh");
|
|
const descendantPath = path.join(tempDir, "descendant.cjs");
|
|
opPath = path.join(tempDir, "op");
|
|
fs.writeFileSync(descendantPath, descendantBody);
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${shellPath}
|
|
${JSON.stringify(getTrustedNodePath())} ${JSON.stringify(descendantPath)} &
|
|
printf ready > ${JSON.stringify(descendantReady)}
|
|
while true; do sleep 1; done
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
|
|
const resultPromise = runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
cwd: tempDir,
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
resolverExecutablePath: getTimeoutResolverPath(),
|
|
});
|
|
// Windows verifies the executable owner and ACL chain through OS tooling before op starts.
|
|
// Keep the synchronization bound above that preflight without weakening the kill deadline.
|
|
await Promise.race([
|
|
// Source-mode loading now includes the shared process runtime before op starts.
|
|
waitForPath(descendantReady, process.platform === "win32" ? 15_000 : 10_000),
|
|
resultPromise.then((result) => {
|
|
throw new Error(
|
|
`Resolver exited before the descendant was ready: ${JSON.stringify(result)}`,
|
|
);
|
|
}),
|
|
]);
|
|
const descendantReadyAt = Date.now();
|
|
const result = await resultPromise;
|
|
expect(JSON.parse(result.stdout).errors).toEqual({
|
|
"op://Engineering/OpenRouter/apiKey": {
|
|
message: `op read timed out after ${TEST_OP_READ_TIMEOUT_MS}ms.`,
|
|
},
|
|
});
|
|
const remainingMarkerDelayMs =
|
|
TEST_DESCENDANT_MARKER_DELAY_MS +
|
|
TEST_DESCENDANT_SETTLE_MARGIN_MS -
|
|
(Date.now() - descendantReadyAt);
|
|
if (remainingMarkerDelayMs > 0) {
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, remainingMarkerDelayMs);
|
|
});
|
|
}
|
|
expect(fs.existsSync(descendantMarker)).toBe(false);
|
|
},
|
|
process.platform === "win32" ? 30_000 : 15_000,
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")("bounds concurrent op reads", async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const logPath = path.join(tempDir, "events.log");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}
|
|
const fs = require("node:fs");
|
|
fs.appendFileSync(${JSON.stringify(logPath)}, "start " + process.pid + "\\n");
|
|
setTimeout(() => {
|
|
fs.appendFileSync(${JSON.stringify(logPath)}, "end " + process.pid + "\\n");
|
|
process.stdout.write("not-a-real-value");
|
|
}, 80);
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const ids = Array.from({ length: 20 }, (_, index) => `op://Vault/Item${index}/field`);
|
|
const result = await runResolver({
|
|
request: { protocolVersion: 1, provider: "onepassword", ids },
|
|
env: { CLAW_1PASSWORD_OP: opPath },
|
|
});
|
|
|
|
expect(Object.keys(JSON.parse(result.stdout).values)).toHaveLength(ids.length);
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
for (const event of fs.readFileSync(logPath, "utf8").trim().split("\n")) {
|
|
active += event.startsWith("start ") ? 1 : -1;
|
|
maxActive = Math.max(maxActive, active);
|
|
}
|
|
expect(maxActive).toBeLessThanOrEqual(4);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")("resolves the op CLI from PATH", async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, process.platform === "win32" ? "op.exe" : "op");
|
|
fs.writeFileSync(opPath, `#!${getTrustedNodePath()}\nprocess.stdout.write('from-path');\n`, {
|
|
mode: 0o755,
|
|
});
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { PATH: tempDir },
|
|
});
|
|
|
|
expect(result).toMatchObject({ code: 0, stderr: "" });
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
protocolVersion: 1,
|
|
values: { "op://Engineering/OpenRouter/apiKey": "from-path" },
|
|
errors: {},
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"refuses to pass the token to an op CLI in an unsafe PATH directory",
|
|
async () => {
|
|
const tempDir = fixtureWorkspace.dir;
|
|
const opPath = path.join(tempDir, "op");
|
|
const tokenLogPath = path.join(tempDir, "token.log");
|
|
fs.writeFileSync(
|
|
opPath,
|
|
`#!${getTrustedNodePath()}\nrequire("node:fs").writeFileSync(${JSON.stringify(tokenLogPath)}, process.env.OP_SERVICE_ACCOUNT_TOKEN);\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
fs.chmodSync(tempDir, 0o777);
|
|
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: { PATH: tempDir },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
expect(fs.existsSync(tokenLogPath)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("fails the provider request when the op CLI is missing", async () => {
|
|
const result = await runResolver({
|
|
request: {
|
|
protocolVersion: 1,
|
|
provider: "onepassword",
|
|
ids: ["op://Engineering/OpenRouter/apiKey"],
|
|
},
|
|
env: {
|
|
CLAW_1PASSWORD_OP: "/does/not/exist/op",
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "1Password SecretRef resolver failed.\n",
|
|
});
|
|
});
|
|
});
|