Files
openclaw/extensions/onepassword/onepassword-secret-ref-resolver.js
Peter Steinberger 964c8c84c1 refactor: consolidate coercion ownership (#122299)
* refactor: consolidate coercion ownership

Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries.

The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries.

* fix: guard integer option ownership

Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation.

* fix: keep integer helpers on numeric facade

Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract.

* fix: point numeric coercion to number runtime

Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
2026-08-11 17:14:53 -07:00

278 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_SECRET_FILE_MAX_BYTES, tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
import { execa } from "execa";
import { coerceErrorMessage as errorMessage } from "openclaw/plugin-sdk/error-runtime";
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_SECRET_REFS_PER_REQUEST = 32;
const MAX_SECRET_VALUE_BYTES = 64 * 1024;
function readStdin() {
return new Promise((resolve, reject) => {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += String(chunk);
});
process.stdin.on("error", reject);
process.stdin.on("end", () => resolve(input));
});
}
function writeResponse(response) {
process.stdout.write(`${JSON.stringify(response)}\n`);
}
function parseRequest(input) {
const parsed = JSON.parse(input);
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) {
throw new Error("invalid exec SecretRef request");
}
return {
protocolVersion: 1,
ids: parsed.ids.filter((id) => typeof id === "string" && id.length > 0),
};
}
function resolveSecretReference(id) {
return resolveOnePasswordSecretReference(id);
}
async function resolveOpCommand() {
const command = process.env.CLAW_1PASSWORD_OP?.trim();
if (command && !path.isAbsolute(command)) {
throw new Error(`CLAW_1PASSWORD_OP must be an absolute path: ${command}`);
}
const resolved = await resolveTrustedOnePasswordCli({
...(command ? { configuredPath: command } : {}),
pathEnv: process.env.PATH,
});
if (resolved) {
return resolved;
}
throw new Error(
"1Password CLI was not found. Install the official CLI or set CLAW_1PASSWORD_OP to its absolute path.",
);
}
function opMissingMessage(command) {
return `1Password CLI "${command}" is not installed or cannot be executed. Install the official 1Password CLI v2, and set CLAW_1PASSWORD_OP to its absolute path.`;
}
function resolveOsHome() {
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);
}
function resolveOpenClawHome() {
const explicit = process.env.OPENCLAW_HOME?.trim();
if (!explicit) {
return resolveOsHome();
}
if (explicit === "~" || explicit.startsWith("~/") || explicit.startsWith("~\\")) {
return path.resolve(explicit.replace(/^~(?=$|[\\/])/u, resolveOsHome()));
}
return path.resolve(explicit);
}
function resolveStateDir() {
const override = process.env.OPENCLAW_STATE_DIR?.trim();
if (override) {
if (override === "~" || override.startsWith("~/") || override.startsWith("~\\")) {
return path.resolve(override.replace(/^~(?=$|[\\/])/u, resolveOpenClawHome()));
}
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;
}
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(
resolveStateDir(),
"credentials",
"onepassword",
"service-account-token",
);
try {
const token = tryReadSecretFileSync(tokenFile, "1Password service account token", {
maxBytes: DEFAULT_SECRET_FILE_MAX_BYTES,
rejectHardlinks: false,
rejectSymlink: true,
});
if (!token) {
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.",
);
}
}
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.
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")),
};
}
async function runOpRead(opCommand, token, secretReference) {
// Keep execa's default utf8 encoding: secrets are decoded as utf8 anyway, and
// Bun's spawn rejects execa's "buffer" encoding option (oven-sh/bun#36049),
// surfacing as a masked "Attempted to assign to readonly property." TypeError.
const subprocess = execa(opCommand, ["read", "--cache=false", "--no-newline", secretReference], {
cleanup: true,
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)}.`);
}
// A missing stdout string means the subprocess never produced output streams
// (spawn-level failure with reject:false), not an empty secret.
if (typeof result.stdout !== "string") {
throw new Error("op read could not be started.");
}
return result.stdout;
}
async function runWithConcurrency(values, limit, task) {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(values.length, limit) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
await task(values[index]);
}
});
await Promise.all(workers);
}
async function resolveFromOnePassword(ids) {
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));
} catch (error) {
response.errors[id] = {
message: errorMessage(error),
};
}
});
return response;
}
async function main() {
const input = await readStdin();
const request = parseRequest(input);
writeResponse(await resolveFromOnePassword(request.ids));
}
main().catch(() => {
process.stderr.write("1Password SecretRef resolver failed.\n");
process.exitCode = 1;
});