fix(onepassword): bound SecretRef resolution lifecycle

This commit is contained in:
joshavant 2026-07-25 01:24:49 -05:00 committed by Josh Avant
parent fb8589ebdb
commit 56bf326371
3 changed files with 478 additions and 146 deletions

View file

@ -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;
});

View file

@ -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"
]
}
},

View file

@ -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",
});
});
});