diff --git a/.changeset/1password-bridge.md b/.changeset/1password-bridge.md new file mode 100644 index 000000000..dfec96c7f --- /dev/null +++ b/.changeset/1password-bridge.md @@ -0,0 +1,11 @@ +--- +"@varlock/1password-plugin": minor-isolated +--- + +Add bridge mode for devcontainer / remote environments. + +The 1Password CLI normally can't reach the host's desktop app from inside a devcontainer, forcing users to fall back to service account tokens. This adds a `varlock-op-bridge` binary (shipped with the plugin) that runs on the host and proxies `op` invocations over TCP or Unix socket. The plugin detects `VARLOCK_OP_BRIDGE_SOCKET` and routes through the bridge transparently — so `op` doesn't even need to be installed inside the container, and host biometric auth still works. + +- New `varlock-op-bridge` CLI with `serve` and `ensure` subcommands (idempotent, suitable for devcontainer `initializeCommand`) +- Token-based auth (32-byte random token rotated per `ensure`, 0600 on host, bind-mounted read-only into the container) +- Supports TCP (`--addr host:port`) and Unix socket (`--addr /path.sock`); TCP recommended for Docker Desktop on macOS diff --git a/packages/plugins/1password/README.md b/packages/plugins/1password/README.md index cd4e1c6cb..d09c630a6 100644 --- a/packages/plugins/1password/README.md +++ b/packages/plugins/1password/README.md @@ -90,6 +90,8 @@ When enabled, if the service account token is empty, the plugin will use the des Keep in mind that this method connects as _YOU_ who likely has more access than a tightly scoped service account. Consider only enabling this for non-production secrets. ::: +> **Using this from a devcontainer?** The plugin ships a `varlock-op-bridge` helper that proxies `op` calls to your host's desktop app, so biometric unlock works from inside the container. See [Using desktop app auth from a devcontainer](https://varlock.dev/plugins/1password/#using-desktop-app-auth-from-a-devcontainer). + ### Connect server setup (self-hosted) If you are running a self-hosted [1Password Connect server](https://developer.1password.com/docs/connect/), you can authenticate using a Connect server URL and token: diff --git a/packages/plugins/1password/package.json b/packages/plugins/1password/package.json index ff7f45f10..50634f999 100644 --- a/packages/plugins/1password/package.json +++ b/packages/plugins/1password/package.json @@ -15,6 +15,9 @@ "exports": { "./plugin": "./dist/plugin.cjs" }, + "bin": { + "varlock-op-bridge": "./dist/bridge-cli.cjs" + }, "files": ["dist"], "scripts": { "dev": "bun run copy-wasm && tsup --watch", diff --git a/packages/plugins/1password/src/bridge/addr.ts b/packages/plugins/1password/src/bridge/addr.ts new file mode 100644 index 000000000..60816c65b --- /dev/null +++ b/packages/plugins/1password/src/bridge/addr.ts @@ -0,0 +1,46 @@ +export type BridgeAddr = | { kind: 'unix'; path: string } + | { kind: 'tcp'; host: string; port: number }; + +/** + * Parses a bridge address string. Supports: + * - Unix socket path: "/tmp/varlock-op-bridge.sock", "~/foo.sock" + * - TCP address: "host.docker.internal:4455", "127.0.0.1:4455", ":4455" + * + * TCP detection rule: contains a `:` AND the portion after the last `:` + * parses as a port number (1..65535). The `host:` prefix is optional — a bare + * "4455" or ":4455" is treated as TCP on 127.0.0.1. + */ +export function parseBridgeAddr(raw: string): BridgeAddr { + const s = raw.trim(); + if (!s) throw new Error('empty bridge address'); + + // Bare numeric port + if (/^\d+$/.test(s)) { + const port = Number(s); + if (port >= 1 && port <= 65535) return { kind: 'tcp', host: '127.0.0.1', port }; + } + + // host:port + const colonIdx = s.lastIndexOf(':'); + if (colonIdx !== -1) { + const hostPart = s.slice(0, colonIdx); + const portPart = s.slice(colonIdx + 1); + if (/^\d+$/.test(portPart)) { + const port = Number(portPart); + if (port >= 1 && port <= 65535) { + const host = hostPart || '127.0.0.1'; + // Only treat as TCP if host doesn't look like a path (no '/' or '\') + if (!host.includes('/') && !host.includes('\\')) { + return { kind: 'tcp', host, port }; + } + } + } + } + + // Fallback: treat as Unix socket path + return { kind: 'unix', path: s }; +} + +export function describeAddr(addr: BridgeAddr): string { + return addr.kind === 'unix' ? addr.path : `${addr.host}:${addr.port}`; +} diff --git a/packages/plugins/1password/src/bridge/cli.ts b/packages/plugins/1password/src/bridge/cli.ts new file mode 100644 index 000000000..9a5054641 --- /dev/null +++ b/packages/plugins/1password/src/bridge/cli.ts @@ -0,0 +1,268 @@ +/* eslint-disable no-console */ +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { startBridgeServer } from './server'; +import { parseBridgeAddr, describeAddr, type BridgeAddr } from './addr'; + +const DEFAULT_TCP_PORT = 7195; + +const HELP = `varlock-op-bridge — bridge container calls to \`op\` through the host CLI + +Usage: + varlock-op-bridge serve [--addr ] [--verbose] + varlock-op-bridge ensure [--addr ] [--log ] + +The --addr argument accepts either: + • a Unix socket path (e.g. /tmp/varlock-op-bridge.sock) + • a TCP address (e.g. 127.0.0.1:7195, :7195, 7195) + +Commands: + serve Run the bridge in the foreground (blocks). + ensure Start the bridge in the background if not already running. + Idempotent; intended for devcontainer initializeCommand. + +Defaults to TCP on 127.0.0.1:${DEFAULT_TCP_PORT}. TCP is recommended for +devcontainers — Docker Desktop on macOS has known issues bind-mounting +Unix sockets. + +Inside the devcontainer, set VARLOCK_OP_BRIDGE_SOCKET= so the +1Password plugin proxies through. For TCP, use host.docker.internal:. + +Example (devcontainer.json): + "initializeCommand": "npx -y -p @varlock/1password-plugin varlock-op-bridge ensure", + "containerEnv": { + "VARLOCK_OP_BRIDGE_SOCKET": "host.docker.internal:${DEFAULT_TCP_PORT}" + } +`; + +function defaultAddrString() { + return `127.0.0.1:${DEFAULT_TCP_PORT}`; +} + +function defaultLogPath() { + return path.join(os.homedir(), '.varlock-op-bridge.log'); +} + +function defaultTokenPath() { + return path.join(os.homedir(), '.varlock-op-bridge.token'); +} + +function generateToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +function writeTokenFile(tokenPath: string, token: string) { + // Write as 0600 atomically: create new file with restrictive mode, then rename. + const tmp = `${tokenPath}.${process.pid}.tmp`; + fs.writeFileSync(tmp, token, { mode: 0o600 }); + fs.renameSync(tmp, tokenPath); +} + +function readTokenFile(tokenPath: string): string | undefined { + try { + const v = fs.readFileSync(tokenPath, 'utf8').trim(); + return v || undefined; + } catch (err: any) { + if (err.code === 'ENOENT') return undefined; + throw err; + } +} + +function parseArgs(argv: Array, flags: Record) { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const spec = flags[a]; + if (!spec) { + console.error(`unknown argument: ${a}`); + process.exit(2); + } + if (spec === 'bool') out[a] = true; + else out[a] = argv[++i]; + } + return out; +} + +async function pingBridge(addr: BridgeAddr, timeoutMs = 500): Promise { + if (addr.kind === 'unix' && !fs.existsSync(addr.path)) return false; + return new Promise((resolve) => { + const s = addr.kind === 'unix' + ? net.createConnection(addr.path) + : net.createConnection(addr.port, addr.host); + const done = (ok: boolean) => { + s.destroy(); + resolve(ok); + }; + s.once('connect', () => done(true)); + s.once('error', () => done(false)); + setTimeout(() => done(false), timeoutMs).unref(); + }); +} + +async function waitForBridge(addr: BridgeAddr, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await pingBridge(addr, 300)) return true; + await new Promise((r) => { + setTimeout(r, 200); + }); + } + return false; +} + +async function cmdServe(argv: Array) { + const args = parseArgs(argv, { + '--addr': 'value', + '-a': 'value', + // kept for backward compat — treated as Unix path + '--socket': 'value', + '-s': 'value', + '--token-file': 'value', + '--no-token': 'bool', + '--verbose': 'bool', + '-v': 'bool', + }); + const addrStr = (args['--addr'] ?? args['-a'] ?? args['--socket'] ?? args['-s'] ?? defaultAddrString()) as string; + const addr = parseBridgeAddr(addrStr); + const verbose = Boolean(args['--verbose'] ?? args['-v']); + + // Resolve token: --token-file wins, else default path, unless --no-token. + let token: string | undefined; + if (!args['--no-token']) { + const tokenPath = (args['--token-file'] ?? defaultTokenPath()) as string; + token = readTokenFile(tokenPath); + if (!token) { + console.error(`[serve] no token file at ${tokenPath} — run \`varlock-op-bridge ensure\` first, or pass --no-token to disable auth.`); + process.exit(1); + } + } + + const server = await startBridgeServer({ addr, token, verbose }); + console.log(`varlock-op-bridge listening on ${describeAddr(addr)} (auth: ${token ? 'token' : 'none'})`); + + const shutdown = () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1000).unref(); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +async function cmdEnsure(argv: Array) { + const args = parseArgs(argv, { + '--addr': 'value', + '-a': 'value', + '--socket': 'value', + '-s': 'value', + '--log': 'value', + '--token-file': 'value', + '--no-token': 'bool', + '--print-token': 'bool', + }); + const addrStr = (args['--addr'] ?? args['-a'] ?? args['--socket'] ?? args['-s'] ?? defaultAddrString()) as string; + const addr = parseBridgeAddr(addrStr); + const logPath = (args['--log'] ?? defaultLogPath()) as string; + const tokenPath = (args['--token-file'] ?? defaultTokenPath()) as string; + const useToken = !args['--no-token']; + + if (useToken) { + // Rotate: new random token per ensure. Existing bridge will be killed below + // if reachable so the fresh token lands in both the file and the server. + const newToken = generateToken(); + writeTokenFile(tokenPath, newToken); + if (args['--print-token']) console.log(newToken); + } + + // If a bridge is already up, restart it so it picks up the rotated token. + if (await pingBridge(addr)) { + console.log(`[ensure] existing bridge found on ${describeAddr(addr)} — restarting to rotate token`); + // Best-effort kill: any `node ... bridge-cli.cjs serve --addr ` + try { + const { execSync } = await import('node:child_process'); + execSync(`pkill -f ${JSON.stringify(`bridge-cli.cjs serve --addr ${addrStr}`)}`, { stdio: 'ignore' }); + } catch { /* pkill returns 1 if nothing matched; ignore */ } + // Wait for the port/socket to free up + for (let i = 0; i < 20; i++) { + if (!(await pingBridge(addr, 200))) break; + await new Promise((r) => { + setTimeout(r, 100); + }); + } + } + + if (addr.kind === 'unix') { + // Stale socket file with no listener — remove so serve's listen() can bind. + try { + const st = fs.statSync(addr.path); + if (st.isSocket()) fs.unlinkSync(addr.path); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + } + + const scriptPath = process.argv[1]; + const logFd = fs.openSync(logPath, 'a'); + const serveArgs = [scriptPath, 'serve', '--addr', addrStr, '--verbose']; + if (useToken) serveArgs.push('--token-file', tokenPath); + else serveArgs.push('--no-token'); + + // Try to make the spawned bridge process introspect as "varlock-op-bridge" + // rather than "node" — e.g. in 1Password's "X wants to access 1Password" + // prompt, `ps`, and macOS privacy dialogs. Two cheap tricks combined: + // 1. Spawn via a symlink to process.execPath, named varlock-op-bridge — + // macOS exec-path APIs typically return the symlink path. + // 2. Set argv[0] to varlock-op-bridge for any API that reads it. + // Neither changes the binary's code signature (still node's); 1Password may + // still show "node" if it uses signature-based identity. Best-effort. + let execPath = process.execPath; + try { + const aliasDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-op-bridge-')); + const alias = path.join(aliasDir, 'varlock-op-bridge'); + fs.symlinkSync(process.execPath, alias); + execPath = alias; + } catch { /* fall through to plain node */ } + + const child = spawn(execPath, serveArgs, { + detached: true, + stdio: ['ignore', logFd, logFd], + env: process.env, + argv0: 'varlock-op-bridge', + }); + child.unref(); + fs.closeSync(logFd); + + console.log(`[ensure] starting bridge (pid ${child.pid}, log ${logPath}${useToken ? `, token file ${tokenPath}` : ', no auth'})`); + + const ready = await waitForBridge(addr, 5000); + if (!ready) { + console.error(`[ensure] bridge did not come up within 5s — check ${logPath}`); + process.exit(1); + } + console.log(`[ensure] bridge ready on ${describeAddr(addr)}`); +} + +async function main() { + const argv = process.argv.slice(2); + const cmd = argv[0]; + + if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') { + console.log(HELP); + process.exit(cmd ? 0 : 1); + } + + if (cmd === 'serve') return cmdServe(argv.slice(1)); + if (cmd === 'ensure') return cmdEnsure(argv.slice(1)); + + console.error(`unknown command: ${cmd}`); + console.error(HELP); + process.exit(2); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/plugins/1password/src/bridge/client.ts b/packages/plugins/1password/src/bridge/client.ts new file mode 100644 index 000000000..89e555599 --- /dev/null +++ b/packages/plugins/1password/src/bridge/client.ts @@ -0,0 +1,115 @@ +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import { ExecError } from '@env-spec/utils/exec-helpers'; +import { + BRIDGE_PROTOCOL_VERSION, + BRIDGE_SOCKET_ENV_VAR, + BRIDGE_TOKEN_ENV_VAR, + BRIDGE_TOKEN_FILE_ENV_VAR, + type BridgeRequest, + type BridgeResponse, +} from './protocol'; +import { parseBridgeAddr, type BridgeAddr } from './addr'; + +export function getBridgeAddr(): string | undefined { + return process.env[BRIDGE_SOCKET_ENV_VAR] || undefined; +} + +/** @deprecated use getBridgeAddr — kept for backward compat */ +export const getBridgeSocketPath = getBridgeAddr; + +/** + * Resolves the bridge auth token. + * Preference order: + * 1. VARLOCK_OP_BRIDGE_TOKEN env var (direct value) + * 2. VARLOCK_OP_BRIDGE_TOKEN_FILE env var (path to file containing token) + * Returns undefined when no token is configured (bridge auth is optional). + */ +export function getBridgeToken(): string | undefined { + const direct = process.env[BRIDGE_TOKEN_ENV_VAR]; + if (direct) return direct.trim(); + const filePath = process.env[BRIDGE_TOKEN_FILE_ENV_VAR]; + if (filePath) { + try { + return fs.readFileSync(filePath, 'utf8').trim() || undefined; + } catch (err: any) { + throw new Error(`failed to read bridge token file ${filePath}: ${err.message}`); + } + } + return undefined; +} + +function connect(addr: BridgeAddr): net.Socket { + return addr.kind === 'unix' + ? net.createConnection(addr.path) + : net.createConnection(addr.port, addr.host); +} + +function sendRequest(addrStr: string, req: BridgeRequest): Promise { + const addr = parseBridgeAddr(addrStr); + return new Promise((resolve, reject) => { + const socket = connect(addr); + let buf = ''; + let settled = false; + + const finish = (err: Error | null, res?: BridgeResponse) => { + if (settled) return; + settled = true; + socket.destroy(); + if (err) reject(err); + else resolve(res!); + }; + + socket.on('connect', () => { + socket.write(`${JSON.stringify(req)}\n`); + }); + socket.on('data', (chunk) => { + buf += chunk.toString('utf8'); + const nlIdx = buf.indexOf('\n'); + if (nlIdx === -1) return; + const line = buf.slice(0, nlIdx); + try { + finish(null, JSON.parse(line)); + } catch (e: any) { + finish(new Error(`invalid response from op bridge: ${e.message}`)); + } + }); + socket.on('end', () => { + if (!settled && buf) { + try { + finish(null, JSON.parse(buf)); + } catch (e: any) { + finish(new Error(`invalid response from op bridge: ${e.message}`)); + } + } else if (!settled) { + finish(new Error('op bridge closed connection without a response')); + } + }); + socket.on('error', (err) => finish(err)); + }); +} + +export async function invokeOpViaBridge( + addrStr: string, + argv: Array, + opts: { env?: Record; input?: string; token?: string } = {}, +): Promise { + const token = opts.token ?? getBridgeToken(); + const req: BridgeRequest = { + v: BRIDGE_PROTOCOL_VERSION, + ...(token && { token }), + argv, + env: opts.env ?? {}, + input: opts.input, + }; + + const res = await sendRequest(addrStr, req); + + if (res.error) { + throw new Error(`op bridge error: ${res.error}`); + } + if (res.exitCode !== 0) { + throw new ExecError(res.exitCode ?? -1, res.signal as NodeJS.Signals | null, res.stderr || 'command gave no output'); + } + return res.stdout; +} diff --git a/packages/plugins/1password/src/bridge/protocol.ts b/packages/plugins/1password/src/bridge/protocol.ts new file mode 100644 index 000000000..e7dc88852 --- /dev/null +++ b/packages/plugins/1password/src/bridge/protocol.ts @@ -0,0 +1,23 @@ +export const BRIDGE_PROTOCOL_VERSION = 1; + +export interface BridgeRequest { + v: typeof BRIDGE_PROTOCOL_VERSION; + /** Shared-secret token. Required when the bridge was started with a token. */ + token?: string; + argv: Array; + env: Record; + input?: string; +} + +export interface BridgeResponse { + v: typeof BRIDGE_PROTOCOL_VERSION; + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + error?: string; +} + +export const BRIDGE_SOCKET_ENV_VAR = 'VARLOCK_OP_BRIDGE_SOCKET'; +export const BRIDGE_TOKEN_ENV_VAR = 'VARLOCK_OP_BRIDGE_TOKEN'; +export const BRIDGE_TOKEN_FILE_ENV_VAR = 'VARLOCK_OP_BRIDGE_TOKEN_FILE'; diff --git a/packages/plugins/1password/src/bridge/server.ts b/packages/plugins/1password/src/bridge/server.ts new file mode 100644 index 000000000..5b99c7682 --- /dev/null +++ b/packages/plugins/1password/src/bridge/server.ts @@ -0,0 +1,204 @@ +/* eslint-disable no-console, @typescript-eslint/no-empty-function */ +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { + BRIDGE_PROTOCOL_VERSION, + type BridgeRequest, + type BridgeResponse, +} from './protocol'; +import { type BridgeAddr, describeAddr } from './addr'; + +interface ServeOptions { + addr: BridgeAddr; + /** If set, every incoming request must match `request.token === token`. */ + token?: string; + verbose?: boolean; + allowedEnvPassthrough?: Array; +} + +function safeEq(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +const DEFAULT_ALLOWED_ENV = [ + 'PATH', + 'HOME', + 'USER', + 'XDG_CONFIG_HOME', + 'OP_BIOMETRIC_UNLOCK_ENABLED', +]; + +// Env vars the CLIENT is not allowed to overlay — these must always come from +// the host. Otherwise e.g. a container passing HOME=/home/node would make the +// host's `op` try to write state to a path that doesn't exist on macOS. +const CLIENT_ENV_BLOCKLIST = new Set([ + 'HOME', + 'USER', + 'PATH', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_RUNTIME_DIR', + 'TMPDIR', + 'LOGNAME', + 'SHELL', + 'PWD', +]); + +const noopLog = (..._a: Array) => {}; + +function sendResponse(socket: net.Socket, partial: Partial) { + const res: BridgeResponse = { + v: BRIDGE_PROTOCOL_VERSION, + stdout: '', + stderr: '', + exitCode: null, + signal: null, + ...partial, + }; + try { + socket.end(`${JSON.stringify(res)}\n`); + } catch { + socket.destroy(); + } +} + +function runOp(req: BridgeRequest, allowedEnvKeys: Array): Promise { + return new Promise((resolve) => { + const env: Record = {}; + for (const key of allowedEnvKeys) { + const v = process.env[key]; + if (v !== undefined) env[key] = v; + } + for (const [k, v] of Object.entries(req.env ?? {})) { + if (CLIENT_ENV_BLOCKLIST.has(k)) continue; + if (v === undefined) delete env[k]; + else env[k] = v; + } + + const child = spawn('op', req.argv, { env }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d) => { + stdout += d.toString('utf8'); + }); + child.stderr?.on('data', (d) => { + stderr += d.toString('utf8'); + }); + + if (req.input !== undefined && child.stdin) { + child.stdin.write(req.input); + child.stdin.end(); + } + + child.on('error', (err: any) => { + resolve({ + v: BRIDGE_PROTOCOL_VERSION, + stdout, + stderr, + exitCode: null, + signal: null, + error: err?.code === 'ENOENT' ? 'ENOENT: `op` not found on host' : err?.message ?? String(err), + }); + }); + + child.on('exit', (exitCode, signal) => { + resolve({ + v: BRIDGE_PROTOCOL_VERSION, + stdout, + stderr, + exitCode, + signal, + }); + }); + }); +} + +export async function startBridgeServer(opts: ServeOptions): Promise { + const log = opts.verbose ? (...a: Array) => console.error('[op-bridge]', ...a) : noopLog; + + if (opts.addr.kind === 'unix') { + // Clean up stale socket file and make sure parent dir exists + try { + const st = fs.statSync(opts.addr.path); + if (st.isSocket()) fs.unlinkSync(opts.addr.path); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + fs.mkdirSync(path.dirname(opts.addr.path), { recursive: true }); + } + + const server = net.createServer((socket) => { + log('connection'); + let buf = ''; + let handled = false; + + socket.on('data', (chunk) => { + if (handled) return; + buf += chunk.toString('utf8'); + const nlIdx = buf.indexOf('\n'); + if (nlIdx === -1) return; + handled = true; + const line = buf.slice(0, nlIdx); + + let req: BridgeRequest; + try { + req = JSON.parse(line); + } catch (err: any) { + sendResponse(socket, { error: `invalid request json: ${err.message}` }); + return; + } + + if (req.v !== BRIDGE_PROTOCOL_VERSION) { + sendResponse(socket, { error: `protocol version mismatch: server=${BRIDGE_PROTOCOL_VERSION} client=${req.v}` }); + return; + } + + if (opts.token) { + if (!req.token || !safeEq(req.token, opts.token)) { + log('rejecting request: bad/missing token'); + sendResponse(socket, { error: 'unauthorized: invalid or missing bridge token' }); + return; + } + } + + runOp(req, opts.allowedEnvPassthrough ?? DEFAULT_ALLOWED_ENV) + .then((res) => sendResponse(socket, res)) + .catch((err) => sendResponse(socket, { error: err?.message ?? String(err) })); + }); + + socket.on('error', (err) => log('socket error', err.message)); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + const onListening = () => { + server.off('error', reject); + resolve(); + }; + if (opts.addr.kind === 'unix') { + server.listen(opts.addr.path, onListening); + } else { + server.listen(opts.addr.port, opts.addr.host, onListening); + } + }); + + if (opts.addr.kind === 'unix') { + // Restrict Unix socket to owner only + try { + fs.chmodSync(opts.addr.path, 0o600); + } catch (err) { + log('chmod failed', err); + } + } + + log(`listening on ${describeAddr(opts.addr)}`); + return server; +} diff --git a/packages/plugins/1password/src/cli-helper.ts b/packages/plugins/1password/src/cli-helper.ts index 571668f1c..23821efb0 100644 --- a/packages/plugins/1password/src/cli-helper.ts +++ b/packages/plugins/1password/src/cli-helper.ts @@ -1,10 +1,29 @@ import { ExecError, spawnAsync } from '@env-spec/utils/exec-helpers'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { plugin } from 'varlock/plugin-lib'; +import { getBridgeSocketPath, invokeOpViaBridge } from './bridge/client'; const { debug } = plugin; const { ResolutionError } = plugin.ERRORS; +/** + * Single chokepoint for invoking the `op` CLI. + * Routes to the host-side bridge over a Unix socket when VARLOCK_OP_BRIDGE_SOCKET is set + * (e.g. from inside a devcontainer that cannot reach the host's desktop app for biometric auth); + * otherwise spawns `op` locally. + */ +async function invokeOp( + argv: Array, + opts: { env?: Record; input?: string } = {}, +): Promise { + const bridgeSocket = getBridgeSocketPath(); + if (bridgeSocket) { + debug('invoking op via bridge', bridgeSocket, argv); + return invokeOpViaBridge(bridgeSocket, argv, opts); + } + return spawnAsync('op', argv, opts); +} + const ENABLE_BATCHING = true; const OP_CLI_CACHE: Record = {}; @@ -67,7 +86,7 @@ export async function execOpCliCommand(cmdArgs: Array) { // strip OP_SERVICE_ACCOUNT_TOKEN from env so the CLI doesn't auto-detect it // when the user hasn't explicitly wired it into their schema const { OP_SERVICE_ACCOUNT_TOKEN: _, ...cleanEnv } = process.env; - const cliResult = await spawnAsync('op', cmdArgs, { env: cleanEnv }); + const cliResult = await invokeOp(cmdArgs, { env: cleanEnv }); authCompletedFn?.(true); debug(`> took ${+new Date() - +startAt}ms`); // OP_CLI_CACHE[cacheKey] = cliResult; @@ -200,7 +219,7 @@ async function executeReadBatch(batchToExecute: NonNullable) const authCompletedFn = await checkOpCliAuth(); // `env -0` splits values by a null character instead of newlines // because otherwise we'll have trouble dealing with values that contain newlines - await spawnAsync('op', `run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { + await invokeOp(`run --no-masking ${lockCliToOpAccount ? `--account ${lockCliToOpAccount} ` : ''}-- env -0`.split(' '), { env: { // have to pass a few things through at least path so it can find `op` and related config files PATH: process.env.PATH!, diff --git a/packages/plugins/1password/tsup.config.ts b/packages/plugins/1password/tsup.config.ts index 85bebad1d..4ac2918c6 100644 --- a/packages/plugins/1password/tsup.config.ts +++ b/packages/plugins/1password/tsup.config.ts @@ -1,22 +1,27 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: [ // Entry point(s) - 'src/plugin.ts', - ], - - dts: true, - - // minify: true, // Minify output - sourcemap: true, // Generate sourcemaps - treeshake: true, // Remove unused code - - clean: false, // Clean output directory before building - outDir: 'dist', // Output directory - - format: ['cjs'], // Output format(s) - splitting: false, - - target: 'esnext', - external: ['varlock'], -}); +export default defineConfig([ + { + entry: ['src/plugin.ts'], + dts: true, + sourcemap: true, + treeshake: true, + clean: false, + outDir: 'dist', + format: ['cjs'], + splitting: false, + target: 'esnext', + external: ['varlock'], + }, + { + entry: { 'bridge-cli': 'src/bridge/cli.ts' }, + sourcemap: true, + treeshake: true, + clean: false, + outDir: 'dist', + format: ['cjs'], + splitting: false, + target: 'esnext', + banner: { js: '#!/usr/bin/env node' }, + }, +]); diff --git a/packages/varlock-website/src/content/docs/plugins/1password.mdx b/packages/varlock-website/src/content/docs/plugins/1password.mdx index 59dfaf34f..70b6e108c 100644 --- a/packages/varlock-website/src/content/docs/plugins/1password.mdx +++ b/packages/varlock-website/src/content/docs/plugins/1password.mdx @@ -114,6 +114,71 @@ With this option enabled, if the resolved service account token is empty, we wil Keep in mind that this method is connecting as _YOU_ who likely has more access than a tightly scoped service account. Consider only enabling this method for a plugin instance that will be handling non-production secrets. ::: +### Using desktop app auth from a devcontainer + +Desktop app auth normally doesn't work from inside a devcontainer — the `op` CLI inside the container can't reach your host's 1Password desktop app, so biometric unlock isn't available and you're forced back to a service account token. + +To make it work anyway, this plugin ships a small helper called `varlock-op-bridge`. Run it on your **host** and it proxies every `op` invocation from the container to the host's CLI (and through to the desktop app). The plugin inside the container detects the `VARLOCK_OP_BRIDGE_SOCKET` env var and routes transparently — you don't change your `.env.schema` at all. + +**Prereqs on the host:** + +- 1Password desktop app installed and unlocked +- CLI integration enabled (**Settings → Developer → Integrate with 1Password CLI**) +- Docker Desktop running +- The `op` CLI installed on the **host** (not the container — the bridge needs it) + +**Add to your `devcontainer.json`:** + +```jsonc title="devcontainer.json" +{ + // Runs on the HOST before the container starts. Spins up the bridge if not + // already running and writes a 0600 token file to ~/.varlock-op-bridge.token. + // Idempotent — safe to rerun on every container start. + "initializeCommand": "npx -y -p @varlock/1password-plugin varlock-op-bridge ensure --addr 0.0.0.0:7195", + + // Point the plugin at the host bridge (via Docker's host.docker.internal) + // and at the mounted token file. + "containerEnv": { + "VARLOCK_OP_BRIDGE_SOCKET": "host.docker.internal:7195", + "VARLOCK_OP_BRIDGE_TOKEN_FILE": "/run/varlock-op-bridge.token" + }, + + // Bind-mount the host token file read-only into the container. + "mounts": [{ + "source": "${localEnv:HOME}/.varlock-op-bridge.token", + "target": "/run/varlock-op-bridge.token", + "type": "bind", + "readonly": true + }], + + // Needed on Linux hosts so host.docker.internal resolves. Harmless elsewhere. + "runArgs": ["--add-host=host.docker.internal:host-gateway"] +} +``` + +The `initializeCommand` alternatives for other package managers: + +```jsonc +// pnpm +"initializeCommand": "pnpm dlx -p @varlock/1password-plugin varlock-op-bridge ensure --addr 0.0.0.0:7195" +// bun +"initializeCommand": "bunx --package @varlock/1password-plugin varlock-op-bridge ensure --addr 0.0.0.0:7195" +``` + +**Confirming it works:** open the folder in VS Code → **Reopen in Container**, then inside the container run `varlock load`. The biometric prompt appears on the **host** (saying "varlock-op-bridge wants to access 1Password"). First approval covers subsequent calls until your 1Password session expires. + +:::caution[Security model] +The bridge is gated by a random 32-byte token stored in `~/.varlock-op-bridge.token` (mode `0600`) and mounted read-only into the container. Any process on the host that runs as your user can read that file and use the bridge — roughly the same threat model as SSH keys on disk. The bridge is not a replacement for 1Password's per-app approval when multiple processes on the host call `op` — once approved, any caller with the token rides on that single approval. +::: + +**Troubleshooting:** + +- `connect ECONNREFUSED 192.168.65.254:7195` — bridge is bound to `127.0.0.1` instead of `0.0.0.0`. Use `--addr 0.0.0.0:7195` in `initializeCommand`. +- `invalid mount config for type "bind": bind source path does not exist: /socket_mnt/…` — you're using Unix-socket mode on macOS; switch to TCP. +- `unauthorized: invalid or missing bridge token` — the token-file mount is missing, or `VARLOCK_OP_BRIDGE_TOKEN_FILE` is pointing at the wrong path. +- `op bridge error: ENOENT: \`op\` not found on host` — install the `op` CLI on the host. +- Bridge not coming up — check `~/.varlock-op-bridge.log` on the host. + ## Pulling data from 1Password Once the plugin is installed and initialized, you can start adding config items that load values from 1Password using the `op()` resolver function.