mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 13:55:45 +00:00
fix(onepassword): bound SecretRef resolution lifecycle
This commit is contained in:
parent
fb8589ebdb
commit
56bf326371
3 changed files with 478 additions and 146 deletions
|
|
@ -1,16 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
|
||||
import { execa } from "execa";
|
||||
import { resolveTrustedOnePasswordCli } from "./onepassword-op-path.js";
|
||||
import { resolveOnePasswordSecretReference } from "./onepassword-secret-id.js";
|
||||
|
||||
const OP_READ_CONCURRENCY = 4;
|
||||
const OP_READ_TIMEOUT_MS = 7_000;
|
||||
const MAX_REQUEST_IDS = 16;
|
||||
const MAX_SECRET_REFS_PER_REQUEST = 32;
|
||||
const MAX_SECRET_VALUE_BYTES = 64 * 1024;
|
||||
const MAX_TOKEN_BYTES = 16 * 1024;
|
||||
|
||||
|
|
@ -35,9 +35,6 @@ function parseRequest(input) {
|
|||
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) {
|
||||
throw new Error("invalid exec SecretRef request");
|
||||
}
|
||||
if (parsed.ids.length > MAX_REQUEST_IDS) {
|
||||
throw new Error(`1Password SecretRef requests support at most ${MAX_REQUEST_IDS} ids.`);
|
||||
}
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
ids: parsed.ids.filter((id) => typeof id === "string" && id.length > 0),
|
||||
|
|
@ -74,10 +71,19 @@ function errorMessage(error) {
|
|||
}
|
||||
|
||||
function resolveOsHome() {
|
||||
const home = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
|
||||
const home =
|
||||
process.platform === "win32"
|
||||
? process.env.USERPROFILE?.trim() || process.env.HOME?.trim() || os.homedir()
|
||||
: process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
|
||||
if (!home) {
|
||||
throw new Error("Unable to resolve the user home for the 1Password CLI.");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
if (!path.win32.isAbsolute(home)) {
|
||||
throw new Error("The Windows user profile path for the 1Password CLI must be absolute.");
|
||||
}
|
||||
return path.win32.normalize(home);
|
||||
}
|
||||
return path.resolve(home);
|
||||
}
|
||||
|
||||
|
|
@ -101,12 +107,20 @@ function resolveStateDir() {
|
|||
return path.resolve(override);
|
||||
}
|
||||
const home = resolveOpenClawHome();
|
||||
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
||||
if (profile && profile.toLowerCase() !== "default") {
|
||||
// Keep the static resolver aligned with the root CLI profile contract without importing core.
|
||||
if (!/^[A-Za-z0-9_-]+$/u.test(profile)) {
|
||||
throw new Error("invalid OpenClaw profile name");
|
||||
}
|
||||
return path.join(home, `.openclaw-${profile}`);
|
||||
}
|
||||
const current = path.join(home, ".openclaw");
|
||||
const legacy = path.join(home, ".clawdbot");
|
||||
return fsSync.existsSync(current) || !fsSync.existsSync(legacy) ? current : legacy;
|
||||
}
|
||||
|
||||
async function readServiceAccountToken() {
|
||||
function readServiceAccountToken() {
|
||||
// Keep this child-process path aligned with the broker path in index.ts.
|
||||
// The resolver is a static asset and cannot import the plugin runtime.
|
||||
const tokenFile = path.join(
|
||||
|
|
@ -115,91 +129,105 @@ async function readServiceAccountToken() {
|
|||
"onepassword",
|
||||
"service-account-token",
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
const linkStat = await fs.lstat(tokenFile);
|
||||
if (linkStat.isSymbolicLink()) {
|
||||
throw new Error("symlinked token file");
|
||||
}
|
||||
handle = await fs.open(
|
||||
tokenFile,
|
||||
fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > MAX_TOKEN_BYTES) {
|
||||
throw new Error("invalid token file");
|
||||
}
|
||||
const token = (await handle.readFile("utf8")).trim();
|
||||
const token = tryReadSecretFileSync(tokenFile, "1Password service account token", {
|
||||
maxBytes: MAX_TOKEN_BYTES,
|
||||
rejectHardlinks: false,
|
||||
rejectSymlink: true,
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error("empty token file");
|
||||
throw new Error("missing token file");
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"1Password service account token file is missing, empty, unsafe, or too large. Configure the onepassword plugin token file first.",
|
||||
);
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function opEnvironment(token) {
|
||||
// The managed resolver is non-interactive. Never let a host desktop integration turn a
|
||||
// Gateway secret read into an authorization or macOS App Data prompt.
|
||||
return {
|
||||
HOME: resolveOsHome(),
|
||||
const home = resolveOsHome();
|
||||
const env = {
|
||||
HOME: home,
|
||||
OP_SERVICE_ACCOUNT_TOKEN: token,
|
||||
OP_BIOMETRIC_UNLOCK_ENABLED: "false",
|
||||
OP_LOAD_DESKTOP_APP_SETTINGS: "false",
|
||||
};
|
||||
if (process.platform !== "win32") {
|
||||
return env;
|
||||
}
|
||||
const readWindowsDirectory = (name, fallback) => {
|
||||
const value = process.env[name]?.trim() || fallback;
|
||||
if (!path.win32.isAbsolute(value)) {
|
||||
throw new Error(`The Windows ${name} path for the 1Password CLI must be absolute.`);
|
||||
}
|
||||
return path.win32.normalize(value);
|
||||
};
|
||||
const profileRoot = path.win32.parse(home).root;
|
||||
const localAppData = readWindowsDirectory(
|
||||
"LOCALAPPDATA",
|
||||
path.win32.join(home, "AppData", "Local"),
|
||||
);
|
||||
return {
|
||||
...env,
|
||||
USERPROFILE: home,
|
||||
HOMEDRIVE: profileRoot.replace(/[\\/]$/u, ""),
|
||||
HOMEPATH: home.slice(Math.max(0, profileRoot.length - 1)),
|
||||
APPDATA: readWindowsDirectory("APPDATA", path.win32.join(home, "AppData", "Roaming")),
|
||||
LOCALAPPDATA: localAppData,
|
||||
TEMP: readWindowsDirectory("TEMP", path.win32.join(localAppData, "Temp")),
|
||||
TMP: readWindowsDirectory("TMP", path.win32.join(localAppData, "Temp")),
|
||||
};
|
||||
}
|
||||
|
||||
function runOpRead(opCommand, token, secretReference) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(opCommand, ["read", "--no-newline", secretReference], {
|
||||
env: opEnvironment(token),
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let settled = false;
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
result();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
finish(() => reject(new Error(`op read timed out after ${OP_READ_TIMEOUT_MS}ms.`)));
|
||||
}, OP_READ_TIMEOUT_MS);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (stdoutBytes > MAX_SECRET_VALUE_BYTES) {
|
||||
child.kill();
|
||||
finish(() => reject(new Error("op read output exceeded the secret value limit.")));
|
||||
return;
|
||||
}
|
||||
stdout += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (error && typeof error === "object" && error.code === "ENOENT") {
|
||||
finish(() => reject(new Error(opMissingMessage(opCommand))));
|
||||
return;
|
||||
}
|
||||
finish(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
finish(() => resolve(stdout));
|
||||
return;
|
||||
}
|
||||
finish(() => reject(new Error(`op read failed with exit code ${String(code)}.`)));
|
||||
});
|
||||
async function runOpRead(opCommand, token, secretReference) {
|
||||
const subprocess = execa(opCommand, ["read", "--cache=false", "--no-newline", secretReference], {
|
||||
cleanup: true,
|
||||
encoding: "buffer",
|
||||
env: opEnvironment(token),
|
||||
extendEnv: false,
|
||||
killDescendants: true,
|
||||
killSignal: "SIGKILL",
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
});
|
||||
let outputBytes = 0;
|
||||
let terminationReason;
|
||||
const terminate = (reason) => {
|
||||
if (terminationReason) {
|
||||
return;
|
||||
}
|
||||
terminationReason = reason;
|
||||
subprocess.kill("SIGKILL");
|
||||
};
|
||||
subprocess.stdout?.on("data", (chunk) => {
|
||||
outputBytes += chunk.byteLength;
|
||||
if (outputBytes > MAX_SECRET_VALUE_BYTES) {
|
||||
terminate("output-limit");
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => terminate("timeout"), OP_READ_TIMEOUT_MS);
|
||||
timeout.unref?.();
|
||||
const result = await subprocess.finally(() => clearTimeout(timeout));
|
||||
if (result.code === "ENOENT") {
|
||||
throw new Error(opMissingMessage(opCommand));
|
||||
}
|
||||
if (terminationReason === "timeout") {
|
||||
throw new Error(`op read timed out after ${OP_READ_TIMEOUT_MS}ms.`);
|
||||
}
|
||||
if (terminationReason === "output-limit") {
|
||||
throw new Error("op read output exceeded the secret value limit.");
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`op read failed with exit code ${String(result.exitCode)}.`);
|
||||
}
|
||||
if (!(result.stdout instanceof Uint8Array)) {
|
||||
throw new Error("op read could not be started.");
|
||||
}
|
||||
return Buffer.from(result.stdout).toString("utf8");
|
||||
}
|
||||
|
||||
async function runWithConcurrency(values, limit, task) {
|
||||
|
|
@ -215,8 +243,16 @@ async function runWithConcurrency(values, limit, task) {
|
|||
}
|
||||
|
||||
async function resolveFromOnePassword(ids) {
|
||||
const [opCommand, token] = await Promise.all([resolveOpCommand(), readServiceAccountToken()]);
|
||||
const response = { protocolVersion: 1, values: {}, errors: {} };
|
||||
if (ids.length > MAX_SECRET_REFS_PER_REQUEST) {
|
||||
const message = `1Password SecretRef resolver supports at most ${MAX_SECRET_REFS_PER_REQUEST} references per request.`;
|
||||
for (const id of ids) {
|
||||
response.errors[id] = { message };
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const opCommand = await resolveOpCommand();
|
||||
const token = readServiceAccountToken();
|
||||
await runWithConcurrency(ids, OP_READ_CONCURRENCY, async (id) => {
|
||||
try {
|
||||
response.values[id] = await runOpRead(opCommand, token, resolveSecretReference(id));
|
||||
|
|
@ -235,13 +271,7 @@ async function main() {
|
|||
writeResponse(await resolveFromOnePassword(request.ids));
|
||||
}
|
||||
|
||||
main().catch((/** @type {unknown} */ error) => {
|
||||
const message = errorMessage(error);
|
||||
writeResponse({
|
||||
protocolVersion: 1,
|
||||
values: {},
|
||||
errors: {
|
||||
request: { message },
|
||||
},
|
||||
});
|
||||
main().catch(() => {
|
||||
process.stderr.write("1Password SecretRef resolver failed.\n");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"commandAliases": [
|
||||
{
|
||||
"name": "onepassword",
|
||||
"kind": "cli"
|
||||
"cliCommand": "onepassword"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
|
|
@ -24,18 +24,25 @@
|
|||
"source": "exec",
|
||||
"command": "${node}",
|
||||
"args": ["./onepassword-secret-ref-resolver.js"],
|
||||
"timeoutMs": 40000,
|
||||
"noOutputTimeoutMs": 40000,
|
||||
"timeoutMs": 90000,
|
||||
"noOutputTimeoutMs": 90000,
|
||||
"maxOutputBytes": 8388608,
|
||||
"passEnv": [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_PROFILE",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"PATH",
|
||||
"CLAW_1PASSWORD_OP"
|
||||
"CLAW_1PASSWORD_OP",
|
||||
"SYSTEMROOT",
|
||||
"WINDIR"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
|
||||
import { createTrustedNodeFixture } from "./trusted-node.test-support.js";
|
||||
|
||||
const resolverPath = fileURLToPath(
|
||||
new URL("../onepassword-secret-ref-resolver.js", import.meta.url),
|
||||
|
|
@ -19,8 +20,21 @@ function makeTempDir(): string {
|
|||
return dir;
|
||||
}
|
||||
|
||||
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 runResolver(params: {
|
||||
request: unknown;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
token?: string | null;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
|
|
@ -36,6 +50,7 @@ function runResolver(params: {
|
|||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [resolverPath], {
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -71,7 +86,18 @@ afterEach(() => {
|
|||
|
||||
describe("plugin manifest", () => {
|
||||
it("declares the 1Password resolver as a managed Node SecretRef preset", () => {
|
||||
const resolverSource = fs.readFileSync(resolverPath, "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 worstCaseBatchTimeoutMs =
|
||||
Math.ceil(maxRefsPerRequest / opReadConcurrency) * opReadTimeoutMs;
|
||||
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 {
|
||||
|
|
@ -83,15 +109,33 @@ describe("plugin manifest", () => {
|
|||
};
|
||||
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: 40_000,
|
||||
noOutputTimeoutMs: 40_000,
|
||||
timeoutMs: 90_000,
|
||||
noOutputTimeoutMs: 90_000,
|
||||
maxOutputBytes: 8 * 1024 * 1024,
|
||||
passEnv: expect.arrayContaining(["HOME", "OPENCLAW_STATE_DIR", "PATH"]),
|
||||
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");
|
||||
|
|
@ -99,10 +143,10 @@ describe("plugin manifest", () => {
|
|||
expect(integration?.passEnv).not.toContain("OP_ACCOUNT");
|
||||
expect(integration?.passEnv).not.toContain("OP_CACHE");
|
||||
expect(integration).not.toHaveProperty("trustedDirs");
|
||||
expect(fs.readFileSync(resolverPath, "utf8")).toContain("#!/usr/bin/env node");
|
||||
expect(fs.readFileSync(resolverPath, "utf8")).not.toContain(
|
||||
["openclaw", "plugin-sdk"].join("/"),
|
||||
);
|
||||
expect(integration?.timeoutMs).toBeGreaterThan(worstCaseBatchTimeoutMs);
|
||||
expect(integration?.noOutputTimeoutMs).toBeGreaterThan(worstCaseBatchTimeoutMs);
|
||||
expect(resolverSource).toContain("#!/usr/bin/env node");
|
||||
expect(resolverSource).toContain('from "execa"');
|
||||
expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({
|
||||
source: "./onepassword-op-path.js",
|
||||
output: "onepassword-op-path.js",
|
||||
|
|
@ -119,6 +163,56 @@ describe("plugin manifest", () => {
|
|||
});
|
||||
|
||||
describe("1Password SecretRef resolver", () => {
|
||||
it.runIf(process.platform === "win32")(
|
||||
"preserves the Windows profile directories required by op",
|
||||
async () => {
|
||||
const tempDir = makeTempDir();
|
||||
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 () => {
|
||||
|
|
@ -127,7 +221,7 @@ describe("1Password SecretRef resolver", () => {
|
|||
const logPath = path.join(tempDir, "op-args.json");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const fs = require("node:fs");
|
||||
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({
|
||||
args: process.argv.slice(2),
|
||||
|
|
@ -162,7 +256,7 @@ process.stdout.write("not-a-real-value \\t");
|
|||
errors: {},
|
||||
});
|
||||
expect(JSON.parse(fs.readFileSync(logPath, "utf8"))).toEqual({
|
||||
args: ["read", "--no-newline", "op://Engineering/OpenRouter/apiKey"],
|
||||
args: ["read", "--cache=false", "--no-newline", "op://Engineering/OpenRouter/apiKey"],
|
||||
biometric: "false",
|
||||
loadDesktopSettings: "false",
|
||||
serviceAccount: true,
|
||||
|
|
@ -179,7 +273,7 @@ process.stdout.write("not-a-real-value \\t");
|
|||
const nativeRef = "op://Personal/OpenClaw QA API Key/password?attribute=value%20one";
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const fs = require("node:fs");
|
||||
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(process.argv.slice(2)));
|
||||
process.stdout.write("not-a-real-value");
|
||||
|
|
@ -197,6 +291,7 @@ process.stdout.write("not-a-real-value");
|
|||
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,
|
||||
]);
|
||||
|
|
@ -218,7 +313,7 @@ process.stdout.write("not-a-real-value");
|
|||
const opPath = path.join(tempDir, "op");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const { spawn } = require("node:child_process");
|
||||
spawn(process.execPath, ["-e", "setTimeout(() => process.stdout.write('tail'), 50)"], {
|
||||
stdio: ["ignore", process.stdout, "ignore"],
|
||||
|
|
@ -251,7 +346,7 @@ process.stdout.write("head");
|
|||
const logPath = path.join(tempDir, "op-args.json");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const fs = require("node:fs");
|
||||
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(process.argv.slice(2)));
|
||||
process.stdout.write("not-a-real-value");
|
||||
|
|
@ -278,6 +373,7 @@ process.stdout.write("not-a-real-value");
|
|||
});
|
||||
expect(JSON.parse(fs.readFileSync(logPath, "utf8"))).toEqual([
|
||||
"read",
|
||||
"--cache=false",
|
||||
"--no-newline",
|
||||
"op://Engineering/OpenRouter/apiKey",
|
||||
]);
|
||||
|
|
@ -296,30 +392,34 @@ process.stdout.write("not-a-real-value");
|
|||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ code: 0, stderr: "" });
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
protocolVersion: 1,
|
||||
values: {},
|
||||
errors: {
|
||||
request: {
|
||||
message: "CLAW_1PASSWORD_OP must be an absolute path: op",
|
||||
},
|
||||
},
|
||||
expect(result).toEqual({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "1Password SecretRef resolver failed.\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects requests larger than the supported batch", async () => {
|
||||
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: Array.from({ length: 17 }, (_, index) => `op://Vault/Item${index}/field`),
|
||||
},
|
||||
request: { protocolVersion: 1, provider: "onepassword", ids },
|
||||
env: { CLAW_1PASSWORD_OP: "/does/not/exist/op" },
|
||||
token: null,
|
||||
});
|
||||
|
||||
expect(JSON.parse(result.stdout).errors).toEqual({
|
||||
request: { message: "1Password SecretRef requests support at most 16 ids." },
|
||||
});
|
||||
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 () => {
|
||||
|
|
@ -333,18 +433,117 @@ process.stdout.write("not-a-real-value");
|
|||
token: null,
|
||||
});
|
||||
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
protocolVersion: 1,
|
||||
values: {},
|
||||
errors: {
|
||||
request: {
|
||||
message:
|
||||
"1Password service account token file is missing, empty, unsafe, or too large. Configure the onepassword plugin token file first.",
|
||||
},
|
||||
},
|
||||
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 = makeTempDir();
|
||||
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 = makeTempDir();
|
||||
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,
|
||||
`#!${createTrustedNodeFixture(stateDir)}\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 = makeTempDir();
|
||||
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,
|
||||
`#!${createTrustedNodeFixture(home)}\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 () => {
|
||||
|
|
@ -352,7 +551,7 @@ process.stdout.write("not-a-real-value");
|
|||
const opPath = path.join(tempDir, "op");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
process.stdout.write("secret-output-must-not-escape");
|
||||
process.stderr.write("secret-error-must-not-escape");
|
||||
process.exitCode = 1;
|
||||
|
|
@ -376,13 +575,109 @@ process.exitCode = 1;
|
|||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"kills the op process tree when output exceeds the limit",
|
||||
async () => {
|
||||
const tempDir = makeTempDir();
|
||||
const opPath = path.join(tempDir, "op");
|
||||
const descendantMarker = path.join(tempDir, "descendant-survived");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const { spawn } = require("node:child_process");
|
||||
spawn(process.execPath, ["-e", ${JSON.stringify(`process.on("SIGTERM", () => {}); setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(descendantMarker)}, "survived"), 800); setInterval(() => {}, 1000);`)}], { stdio: "ignore" });
|
||||
process.stdout.write("x".repeat(70 * 1024));
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
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.",
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 650);
|
||||
});
|
||||
expect(fs.existsSync(descendantMarker)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"kills the op process tree when a read times out",
|
||||
async () => {
|
||||
const tempDir = makeTempDir();
|
||||
const descendantReady = path.join(tempDir, "timed-out-descendant-ready");
|
||||
const descendantMarker = path.join(tempDir, "timed-out-descendant-survived");
|
||||
const opBody = `const { spawn } = require("node:child_process");
|
||||
spawn(process.execPath, ["-e", ${JSON.stringify(`const fs = require("node:fs"); fs.writeFileSync(${JSON.stringify(descendantReady)}, "ready"); process.on("SIGTERM", () => {}); setTimeout(() => fs.writeFileSync(${JSON.stringify(descendantMarker)}, "survived"), 7500); setInterval(() => {}, 1000);`)}], { stdio: "ignore" });
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
let opPath = process.execPath;
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(path.join(tempDir, "read"), opBody);
|
||||
} else {
|
||||
// Do not assume the test runner's Node binary passes production path-trust policy.
|
||||
// A per-test executable keeps the scenario independent of package-manager ownership.
|
||||
opPath = path.join(tempDir, "op");
|
||||
fs.writeFileSync(opPath, `#!${createTrustedNodeFixture(tempDir)}\n${opBody}`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
|
||||
const resultPromise = runResolver({
|
||||
request: {
|
||||
protocolVersion: 1,
|
||||
provider: "onepassword",
|
||||
ids: ["op://Engineering/OpenRouter/apiKey"],
|
||||
},
|
||||
cwd: tempDir,
|
||||
env: { CLAW_1PASSWORD_OP: opPath },
|
||||
});
|
||||
// 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([
|
||||
waitForPath(descendantReady, process.platform === "win32" ? 15_000 : 5_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 7000ms.",
|
||||
},
|
||||
});
|
||||
const remainingMarkerDelayMs = 8_000 - (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 = makeTempDir();
|
||||
const opPath = path.join(tempDir, "op");
|
||||
const logPath = path.join(tempDir, "events.log");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}
|
||||
`#!${createTrustedNodeFixture(tempDir)}
|
||||
const fs = require("node:fs");
|
||||
fs.appendFileSync(${JSON.stringify(logPath)}, "start " + process.pid + "\\n");
|
||||
setTimeout(() => {
|
||||
|
|
@ -392,7 +687,7 @@ setTimeout(() => {
|
|||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const ids = Array.from({ length: 12 }, (_, index) => `op://Vault/Item${index}/field`);
|
||||
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 },
|
||||
|
|
@ -411,9 +706,13 @@ setTimeout(() => {
|
|||
it.runIf(process.platform !== "win32")("resolves the op CLI from PATH", async () => {
|
||||
const tempDir = makeTempDir();
|
||||
const opPath = path.join(tempDir, process.platform === "win32" ? "op.exe" : "op");
|
||||
fs.writeFileSync(opPath, `#!${process.execPath}\nprocess.stdout.write('from-path');\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${createTrustedNodeFixture(tempDir)}\nprocess.stdout.write('from-path');\n`,
|
||||
{
|
||||
mode: 0o755,
|
||||
},
|
||||
);
|
||||
const result = await runResolver({
|
||||
request: {
|
||||
protocolVersion: 1,
|
||||
|
|
@ -439,7 +738,7 @@ setTimeout(() => {
|
|||
const tokenLogPath = path.join(tempDir, "token.log");
|
||||
fs.writeFileSync(
|
||||
opPath,
|
||||
`#!${process.execPath}\nrequire("node:fs").writeFileSync(${JSON.stringify(tokenLogPath)}, process.env.OP_SERVICE_ACCOUNT_TOKEN);\n`,
|
||||
`#!${createTrustedNodeFixture(tempDir)}\nrequire("node:fs").writeFileSync(${JSON.stringify(tokenLogPath)}, process.env.OP_SERVICE_ACCOUNT_TOKEN);\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
fs.chmodSync(tempDir, 0o777);
|
||||
|
|
@ -453,14 +752,16 @@ setTimeout(() => {
|
|||
env: { PATH: tempDir },
|
||||
});
|
||||
|
||||
expect(JSON.parse(result.stdout).errors.request.message).toContain(
|
||||
"Refusing unsafe 1Password CLI path",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "1Password SecretRef resolver failed.\n",
|
||||
});
|
||||
expect(fs.existsSync(tokenLogPath)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns an actionable error when the op CLI is missing", async () => {
|
||||
it("fails the provider request when the op CLI is missing", async () => {
|
||||
const result = await runResolver({
|
||||
request: {
|
||||
protocolVersion: 1,
|
||||
|
|
@ -472,16 +773,10 @@ setTimeout(() => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ code: 0, stderr: "" });
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
protocolVersion: 1,
|
||||
values: {},
|
||||
errors: {
|
||||
request: {
|
||||
message:
|
||||
"1Password CLI was not found. Install the official CLI or set CLAW_1PASSWORD_OP to its absolute path.",
|
||||
},
|
||||
},
|
||||
expect(result).toEqual({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "1Password SecretRef resolver failed.\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue