diff --git a/extensions/onepassword/onepassword-secret-ref-resolver.js b/extensions/onepassword/onepassword-secret-ref-resolver.js index cc6e90905d4..cbc1a7c5c52 100644 --- a/extensions/onepassword/onepassword-secret-ref-resolver.js +++ b/extensions/onepassword/onepassword-secret-ref-resolver.js @@ -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; }); diff --git a/extensions/onepassword/openclaw.plugin.json b/extensions/onepassword/openclaw.plugin.json index f8c7f4e4247..c78da5a6926 100644 --- a/extensions/onepassword/openclaw.plugin.json +++ b/extensions/onepassword/openclaw.plugin.json @@ -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" ] } }, diff --git a/extensions/onepassword/src/secret-ref-resolver.test.ts b/extensions/onepassword/src/secret-ref-resolver.test.ts index f9e5d944c01..a8ae83fb3e9 100644 --- a/extensions/onepassword/src/secret-ref-resolver.test.ts +++ b/extensions/onepassword/src/secret-ref-resolver.test.ts @@ -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 { + 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; 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>; }; 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; + errors: Record; + }; + 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", }); }); });