/** * 1Password CLI integration for `rp ai-test`. Each user keeps their sandbox / * production test credentials in their own 1Password vault — no shared * .env file, no secrets in git, per-user audit trail. * * Required setup on the user's machine: * 1. brew install --cask 1password-cli * 2. op signin (one-time per terminal session) * 3. Have a Login item with the dashboard email + password + 6-digit TOTP * * The skill calls `op read` for password and `op item get --otp` for TOTP, * so we never echo either through stdout in this process. The Claude CLI * subprocess receives them via its argv-passed prompt — that's a tradeoff * we make consciously: the alternative (writing creds to a temp file the * inner agent reads) is strictly worse since the file lingers on disk. */ import { execFileSync } from 'node:child_process'; /** Injectable exec for tests. Real callers leave undefined and get execFileSync. */ export type ExecFn = (cmd: string, args: string[]) => string; const defaultExec: ExecFn = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); export interface OnePasswordCreds { username: string; password: string; totp: string; } export class OnePasswordNotInstalledError extends Error { constructor() { super( 'The 1Password CLI (`op`) is required by `rp ai-test`. Install it with `brew install --cask 1password-cli` and run `op signin` once before retrying.', ); this.name = 'OnePasswordNotInstalledError'; } } export class OnePasswordNotSignedInError extends Error { constructor(stderr: string) { super( `1Password CLI is installed but not signed in for this terminal. Run \`op signin\` and try again.\nUnderlying error: ${stderr.trim()}`, ); this.name = 'OnePasswordNotSignedInError'; } } export class OnePasswordItemMissingError extends Error { constructor(item: string, field: string, stderr: string) { super( `1Password item "${item}" doesn't expose a "${field}" field (or the item itself doesn't exist).\nUnderlying error: ${stderr.trim()}`, ); this.name = 'OnePasswordItemMissingError'; } } /** Run `op` with the given args; map common failures to typed errors. */ export const opExec = (args: string[], exec: ExecFn = defaultExec): string => { try { return exec('op', args); } catch (error) { const e = error as { code?: string; stderr?: Buffer | string }; if (e.code === 'ENOENT') { throw new OnePasswordNotInstalledError(); } const stderr = typeof e.stderr === 'string' ? e.stderr : (e.stderr?.toString() ?? ''); if (/not currently signed in|missing session|sessionToken/i.test(stderr)) { throw new OnePasswordNotSignedInError(stderr); } throw error; } }; /** * Fetch username + password + current TOTP from a 1Password item by its * stable title. `op item get ` searches every vault the signed-in * user can read, so items in any vault (Private, Engineering, a shared * team vault) resolve without us guessing the vault name — pass `vault` only * to disambiguate a title that exists in more than one vault. The TOTP * request has to be its own `op item get --otp` call — `op read` of the * totp field returns the URI, not the live 6-digit code. */ export const readDashboardCreds = ( params: { /** 1Password item title (e.g. "localhost", "root-sandbox-test"). */ item: string; /** Optional vault to disambiguate a title present in multiple vaults. */ vault?: string; }, exec: ExecFn = defaultExec, ): OnePasswordCreds => { const { item, vault } = params; const vaultArgs = vault ? ['--vault', vault] : []; const getField = (field: string, extra: string[] = []): string => opExec(['item', 'get', item, ...vaultArgs, '--field', field, ...extra], exec); let username: string; let password: string; let totp: string; try { username = getField('username'); } catch (error) { if (error instanceof OnePasswordNotInstalledError || error instanceof OnePasswordNotSignedInError) { throw error; } throw new OnePasswordItemMissingError(item, 'username', String((error as Error).message ?? '')); } try { password = getField('password', ['--reveal']); } catch (error) { if (error instanceof OnePasswordNotInstalledError || error instanceof OnePasswordNotSignedInError) { throw error; } throw new OnePasswordItemMissingError(item, 'password', String((error as Error).message ?? '')); } try { totp = opExec(['item', 'get', item, ...vaultArgs, '--otp'], exec); } catch (error) { if (error instanceof OnePasswordNotInstalledError || error instanceof OnePasswordNotSignedInError) { throw error; } throw new OnePasswordItemMissingError(item, 'otp', String((error as Error).message ?? '')); } if (!/^\d{6}$/.test(totp)) { throw new OnePasswordItemMissingError( item, 'otp', `expected 6 digits, got ${JSON.stringify(totp)} (item likely has no TOTP / one-time password configured)`, ); } return { username, password, totp }; };