@algosuite/vo-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/bin/vo-mcp +36 -0
- package/dist/autostart-cli.js +167 -0
- package/dist/autostart-cli.js.map +7 -0
- package/dist/cli.js +5730 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.js +4968 -0
- package/dist/index.js.map +7 -0
- package/dist/install-cli.js +603 -0
- package/dist/install-cli.js.map +7 -0
- package/dist/login-cli.js +382 -0
- package/dist/login-cli.js.map +7 -0
- package/package.json +50 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/install.ts", "../src/cloud/login.ts", "../src/cloud/credential-store.ts", "../src/cloud/keychain.ts", "../src/cloud/auth-token-source.ts", "../src/cloud/vo-credential-exchange.ts", "../src/autostart.ts", "../src/install-cli.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * `vo-mcp install` \u2014 one-command client installer for non-coders.\r\n *\r\n * Configures Claude Desktop / Claude Code to load vo-mcp, guides login, and\r\n * explains starting the runner. Cross-platform (Windows + macOS/Linux).\r\n *\r\n * Steps:\r\n * 1. Write/merge the MCP server config (backing up existing config).\r\n * 2. Guide `vo-mcp login` to mint the scoped credential.\r\n * 3. Explain `vo-mcp runner` (not started here \u2014 user does it manually).\r\n * 4. Print clear next steps + success summary.\r\n */\r\nimport { homedir, platform } from 'node:os';\r\nimport { join, dirname } from 'node:path';\r\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';\r\nimport { resolve } from 'node:path';\r\nimport { runLogin, DEFAULT_DASHBOARD_URL } from './cloud/login.js';\r\nimport { exchangeForVoCredential } from './cloud/vo-credential-exchange.js';\r\nimport { installAutostart } from './autostart.js';\r\n\r\nexport interface InstallOptions {\r\n readonly log?: (msg: string) => void;\r\n readonly skipLogin?: boolean;\r\n readonly skipAutostart?: boolean;\r\n readonly env?: Readonly<Record<string, string | undefined>>;\r\n}\r\n\r\ninterface McpServerEntry {\r\n command: string;\r\n args?: string[];\r\n env?: Record<string, string>;\r\n}\r\n\r\ninterface McpConfig {\r\n mcpServers?: Record<string, McpServerEntry>;\r\n}\r\n\r\n/**\r\n * Resolve the Claude config path. Prefers Claude Code's `.claude.json` in the\r\n * user home, else Claude Desktop's `claude_desktop_config.json` in the platform\r\n * config directory.\r\n */\r\nfunction resolveClaudeConfigPath(): string {\r\n const plat = platform();\r\n // Claude Code uses ~/.claude.json\r\n const codeConfig = join(homedir(), '.claude.json');\r\n if (existsSync(codeConfig)) return codeConfig;\r\n\r\n // Claude Desktop platform-specific paths\r\n if (plat === 'win32') {\r\n const appData = process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');\r\n return join(appData, 'Claude', 'claude_desktop_config.json');\r\n }\r\n if (plat === 'darwin') {\r\n return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');\r\n }\r\n // Linux / others\r\n return join(homedir(), '.config', 'claude', 'claude_desktop_config.json');\r\n}\r\n\r\n/**\r\n * Read + parse existing Claude config (or return empty shape if missing).\r\n * Never throws \u2014 returns a default shape on any problem.\r\n */\r\nfunction readClaudeConfig(path: string): McpConfig {\r\n try {\r\n if (!existsSync(path)) return { mcpServers: {} };\r\n const raw = readFileSync(path, 'utf8');\r\n const parsed = JSON.parse(raw) as Partial<McpConfig>;\r\n return { mcpServers: parsed.mcpServers ?? {} };\r\n } catch {\r\n return { mcpServers: {} };\r\n }\r\n}\r\n\r\n/**\r\n * Write the Claude config, creating parent directories as needed. Writes with\r\n * 2-space indentation for human readability.\r\n */\r\nfunction writeClaudeConfig(path: string, config: McpConfig): void {\r\n mkdirSync(dirname(path), { recursive: true });\r\n writeFileSync(path, `${JSON.stringify(config, null, 2)}\\n`, 'utf8');\r\n}\r\n\r\n/**\r\n * Resolve the absolute path to the vo-mcp CLI (dist/cli.js). Computes from\r\n * process.argv[1] which is the path to the currently running script\r\n * (dist/install-cli.js when invoked via `vo-mcp install`).\r\n */\r\nfunction resolveVoMcpCliPath(): string {\r\n // process.argv[1] is the path to the script being run (e.g., dist/install-cli.js).\r\n // We want dist/cli.js (sibling file).\r\n const scriptPath = process.argv[1] ?? '';\r\n if (scriptPath.includes('install-cli.js') || scriptPath.includes('install-cli')) {\r\n // Replace install-cli.js with cli.js\r\n return scriptPath.replace(/install-cli\\.js$/, 'cli.js').replace(/install-cli$/, 'cli.js');\r\n }\r\n // Fallback: assume we're in the dist/ directory\r\n return resolve(dirname(scriptPath), 'cli.js');\r\n}\r\n\r\n/**\r\n * Merge the vo-mcp MCP server entry into the config. Backs up the existing\r\n * config if present. Idempotent: if a `vo` or `vo-mcp` entry already exists\r\n * pointing to our cli.js, no-op.\r\n */\r\nfunction installMcpConfig(log: (msg: string) => void, env: Readonly<Record<string, string | undefined>>): void {\r\n const configPath = resolveClaudeConfigPath();\r\n const existing = readClaudeConfig(configPath);\r\n const cliPath = resolveVoMcpCliPath();\r\n\r\n // Check if vo-mcp is already configured\r\n const voEntry = existing.mcpServers?.['vo'] ?? existing.mcpServers?.['vo-mcp'];\r\n if (voEntry && voEntry.args && voEntry.args.some((a: string) => a.includes('vo-mcp'))) {\r\n log(`\u2713 vo-mcp MCP server is already configured at: ${configPath}`);\r\n return;\r\n }\r\n\r\n // Backup existing config\r\n if (existsSync(configPath)) {\r\n const backupPath = `${configPath}.backup-${Date.now()}`;\r\n copyFileSync(configPath, backupPath);\r\n log(` Backed up existing config to: ${backupPath}`);\r\n }\r\n\r\n // Merge the vo-mcp entry\r\n const controlPlaneUrl = env['VO_CONTROL_PLANE_URL']?.trim() || DEFAULT_DASHBOARD_URL.replace('vo-dashboard.web.app', 'vo-control-plane-bzjphrajaq-uc.a.run.app');\r\n const merged: McpConfig = {\r\n mcpServers: {\r\n ...existing.mcpServers,\r\n 'vo-mcp': {\r\n command: 'node',\r\n args: [cliPath],\r\n env: {\r\n VO_CONTROL_PLANE_URL: controlPlaneUrl,\r\n },\r\n },\r\n },\r\n };\r\n\r\n writeClaudeConfig(configPath, merged);\r\n log(`\u2713 Wrote vo-mcp MCP server config to: ${configPath}`);\r\n}\r\n\r\n/**\r\n * Run the interactive `vo-mcp login` flow, guiding the user through browser\r\n * sign-in and credential minting.\r\n */\r\nasync function runLoginFlow(log: (msg: string) => void, env: Readonly<Record<string, string | undefined>>): Promise<void> {\r\n log('\\n\u2501\u2501\u2501 Step 2: Link your Claude account \u2501\u2501\u2501');\r\n log('We\\'ll open your browser to sign in, then mint a scoped credential.');\r\n log('Your raw token never persists locally (it\\'s exchanged for a vocred_).\\n');\r\n\r\n const controlPlaneUrl = env['VO_CONTROL_PLANE_URL']?.trim() || 'https://vo-control-plane-bzjphrajaq-uc.a.run.app';\r\n const exchange = async (refreshToken: string, apiKey: string): Promise<{ vo_credential: string; expires_at: string } | null> => {\r\n return exchangeForVoCredential({\r\n refreshToken,\r\n apiKey,\r\n controlPlaneUrl,\r\n label: `vo-mcp install (${platform()})`,\r\n });\r\n };\r\n\r\n try {\r\n const result = await runLogin({ env, log, exchange });\r\n log(`\u2713 Signed in${result.email ? ` as ${result.email}` : ''}. Credential stored at: ${result.credentialPath}`);\r\n } catch (err: unknown) {\r\n log(`\u2717 Login failed: ${err instanceof Error ? err.message : String(err)}`);\r\n log(' You can retry later by running: vo-mcp login');\r\n throw err;\r\n }\r\n}\r\n\r\n/**\r\n * Print the runner guidance + success summary.\r\n */\r\nfunction printNextSteps(log: (msg: string) => void, autostartInstalled: boolean): void {\r\n log('\\n\u2501\u2501\u2501 Installation complete! \u2501\u2501\u2501\\n');\r\n log('What\\'s configured:');\r\n log(' \u2713 Claude Desktop / Claude Code will load vo-mcp on next restart');\r\n log(' \u2713 Your scoped credential is stored (revocable via the dashboard)');\r\n if (autostartInstalled) {\r\n log(' \u2713 Runner daemon will start automatically at login\\n');\r\n } else {\r\n log('\\n');\r\n }\r\n log('Next steps:');\r\n log(' 1. Restart Claude Desktop / Claude Code (if running).');\r\n if (autostartInstalled) {\r\n log(' 2. Log out and back in (or start the runner manually now: vo-mcp runner)');\r\n } else {\r\n log(' 2. Start the agent runner in a terminal (keep it running):');\r\n log(' vo-mcp runner');\r\n log(' (To set up auto-start at login: vo-mcp runner --install-autostart)');\r\n }\r\n log(' 3. Visit the VO dashboard to dispatch your first agent:');\r\n log(' https://vo-dashboard.web.app\\n');\r\n log('The runner watches for tasks you dispatch and spins up agents in fresh worktrees.');\r\n log('Agents only run while the runner is connected. Ctrl+C to stop it anytime.\\n');\r\n}\r\n\r\n/**\r\n * Run the full install flow: MCP config \u2192 login \u2192 auto-start \u2192 guidance.\r\n */\r\nexport async function install(opts: InstallOptions = {}): Promise<void> {\r\n const log = opts.log ?? ((m: string) => console.error(m));\r\n const env = opts.env ?? process.env;\r\n\r\n log('\u2501\u2501\u2501 vo-mcp installer \u2501\u2501\u2501');\r\n log('This will set up your machine to dispatch VO agents from anywhere.\\n');\r\n\r\n // Step 1: MCP config\r\n log('\u2501\u2501\u2501 Step 1: Configure Claude Desktop / Claude Code \u2501\u2501\u2501');\r\n installMcpConfig(log, env);\r\n\r\n // Step 2: Login (unless skipped)\r\n if (!opts.skipLogin) {\r\n await runLoginFlow(log, env);\r\n } else {\r\n log('\\n(Login skipped \u2014 run `vo-mcp login` manually when ready.)');\r\n }\r\n\r\n // Step 3: Auto-start (unless skipped)\r\n let autostartInstalled = false;\r\n if (!opts.skipAutostart) {\r\n const plat = platform();\r\n if (plat === 'win32' || plat === 'darwin') {\r\n log('\\n\u2501\u2501\u2501 Step 3: Set up auto-start \u2501\u2501\u2501');\r\n log('Would you like the runner daemon to start automatically at login?');\r\n log('(You can skip this and set it up later with: vo-mcp runner --install-autostart)\\n');\r\n await installAutostart({ log, env });\r\n autostartInstalled = true;\r\n }\r\n }\r\n\r\n // Step 4: Guidance\r\n printNextSteps(log, autostartInstalled);\r\n}\r\n", "/**\r\n * `vo-mcp login` \u2014 the thin-client loopback browser-callback flow (Increment 3b,\r\n * Option A; `docs/vo/vo-command-center-inc3b-login-design-2026-06-05.md`).\r\n *\r\n * The standard CLI-auth pattern (`gcloud auth login`, `gh auth login`): start a\r\n * loopback HTTP server on 127.0.0.1, open the browser to the dashboard's\r\n * `/cli-login` page (which runs the live Firebase Google/GitHub SSO), and capture\r\n * the user's refresh token when the page redirects back to the loopback. The\r\n * captured token (the user's OWN credential \u2014 never the god-token, never model\r\n * keys) is stored locally; Inc 3a's source auto-refreshes ID tokens from it.\r\n *\r\n * FROZEN loopback contract v1 (the dashboard `/cli-login` page builds against this):\r\n * CLI \u2192 browser: <dashboard>/cli-login?port=<loopbackPort>&state=<csrf>\r\n * page \u2192 browser: redirect to http://127.0.0.1:<port>/callback#state=..&refresh_token=..&api_key=..&email=..\r\n * (data in the URL FRAGMENT \u2014 never sent to a server log; the loopback /callback\r\n * page reads the fragment and POSTs it same-origin to /capture as JSON.)\r\n * CLI /capture: validate `state`, store {refresh_token, api_key, email}, 200, close.\r\n */\r\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\r\nimport { randomBytes } from 'node:crypto';\r\nimport { spawn } from 'node:child_process';\r\nimport { writeStoredCredential, type StoredCredential } from './credential-store.js';\r\n\r\nexport const DEFAULT_DASHBOARD_URL = 'https://vo-dashboard.web.app';\r\nconst DEFAULT_TIMEOUT_MS = 120_000;\r\nconst MAX_BODY_BYTES = 16_384;\r\n\r\nexport interface LoginResult {\r\n readonly email?: string;\r\n readonly credentialPath: string;\r\n}\r\n\r\nexport interface CaptureOutcome {\r\n readonly ok: boolean;\r\n readonly httpStatus: number;\r\n readonly result?: LoginResult;\r\n readonly error?: string;\r\n /**\r\n * The captured Firebase refresh credential (Inc 3b.4b). The caller may exchange\r\n * it for a scoped `vocred_` and re-store, dropping the raw refresh token.\r\n */\r\n readonly captured?: { readonly refresh_token: string; readonly api_key: string; readonly email?: string };\r\n}\r\n\r\n/**\r\n * Pure core of the /capture handler \u2014 validate the CSRF state + required fields,\r\n * then persist via the injected `store`. Fully unit-testable; the http server is\r\n * a thin shell around this.\r\n */\r\nexport function processCapture(\r\n rawBody: string,\r\n expectedState: string,\r\n store: (cred: StoredCredential) => string,\r\n): CaptureOutcome {\r\n let data: { state?: unknown; refresh_token?: unknown; api_key?: unknown; email?: unknown };\r\n try {\r\n data = JSON.parse(rawBody);\r\n } catch {\r\n return { ok: false, httpStatus: 400, error: 'invalid JSON body' };\r\n }\r\n if (!data || typeof data !== 'object') return { ok: false, httpStatus: 400, error: 'invalid body' };\r\n // CSRF: the state echoed by the browser MUST match the one we generated.\r\n if (typeof data.state !== 'string' || data.state !== expectedState) {\r\n return { ok: false, httpStatus: 403, error: 'state mismatch (possible CSRF) \u2014 login aborted' };\r\n }\r\n const refresh = typeof data.refresh_token === 'string' ? data.refresh_token.trim() : '';\r\n const apiKey = typeof data.api_key === 'string' ? data.api_key.trim() : '';\r\n if (!refresh || !apiKey) {\r\n return { ok: false, httpStatus: 400, error: 'login response missing refresh_token / api_key' };\r\n }\r\n const email = typeof data.email === 'string' && data.email.trim() ? data.email.trim() : undefined;\r\n const path = store({ refresh_token: refresh, api_key: apiKey, ...(email ? { email } : {}) });\r\n return {\r\n ok: true,\r\n httpStatus: 200,\r\n result: { ...(email ? { email } : {}), credentialPath: path },\r\n captured: { refresh_token: refresh, api_key: apiKey, ...(email ? { email } : {}) },\r\n };\r\n}\r\n\r\n/** The tiny page served at /callback: read the fragment, POST it same-origin to /capture. */\r\nfunction captureHtml(): string {\r\n return `<!doctype html><html><head><meta charset=\"utf-8\"><title>VO login</title></head>\r\n<body style=\"font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center\">\r\n<h2 id=\"m\">Completing sign-in\u2026</h2>\r\n<script>\r\n(function(){\r\n var h=location.hash.replace(/^#/,''), p=new URLSearchParams(h), b={};\r\n ['state','refresh_token','api_key','email'].forEach(function(k){ if(p.get(k)) b[k]=p.get(k); });\r\n fetch('/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)})\r\n .then(function(r){ document.getElementById('m').textContent = r.ok ? 'Sign-in complete \u2014 you can close this tab.' : 'Sign-in failed \u2014 check the terminal.'; })\r\n .catch(function(){ document.getElementById('m').textContent = 'Sign-in failed \u2014 check the terminal.'; });\r\n})();\r\n</script></body></html>`;\r\n}\r\n\r\n/** Cross-platform browser open (best-effort; the URL is also printed for manual open). */\r\nfunction defaultOpenBrowser(url: string): void {\r\n const platform = process.platform;\r\n if (platform === 'win32') {\r\n spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();\r\n } else if (platform === 'darwin') {\r\n spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();\r\n } else {\r\n spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();\r\n }\r\n}\r\n\r\nexport interface RunLoginOptions {\r\n readonly dashboardUrl?: string;\r\n readonly timeoutMs?: number;\r\n readonly env?: Readonly<Record<string, string | undefined>>;\r\n readonly openBrowser?: (url: string) => void;\r\n readonly log?: (msg: string) => void;\r\n readonly nowIso?: () => string;\r\n /**\r\n * Inc 3b.4b: exchange the captured Firebase refresh credential for a scoped\r\n * `vocred_`. When provided AND it succeeds, the vocred_ is stored INSTEAD of the\r\n * raw refresh token (spec \u00A76). Fail-open: a null/throwing exchange keeps the\r\n * refresh credential. Production wires `exchangeForVoCredential` when a\r\n * `VO_CONTROL_PLANE_URL` is configured.\r\n */\r\n readonly exchange?: (\r\n refreshToken: string,\r\n apiKey: string,\r\n label?: string,\r\n ) => Promise<{ vo_credential: string; expires_at: string } | null>;\r\n}\r\n\r\n/**\r\n * Run the interactive loopback login. Resolves with the stored credential path on\r\n * success; rejects on timeout / CSRF mismatch / malformed response. FAIL-CLOSED:\r\n * nothing is stored unless the state matches and the fields are present.\r\n */\r\nexport async function runLogin(opts: RunLoginOptions = {}): Promise<LoginResult> {\r\n const dashboardUrl = (opts.dashboardUrl ?? DEFAULT_DASHBOARD_URL).replace(/\\/+$/, '');\r\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\r\n const env = opts.env ?? process.env;\r\n const log = opts.log ?? ((m: string) => console.error(m));\r\n const nowIso = opts.nowIso ?? (() => new Date().toISOString());\r\n const openBrowser = opts.openBrowser ?? defaultOpenBrowser;\r\n const state = randomBytes(32).toString('base64url');\r\n\r\n return new Promise<LoginResult>((resolve, reject) => {\r\n let settled = false;\r\n const finish = (err: Error | null, result?: LoginResult): void => {\r\n if (settled) return;\r\n settled = true;\r\n clearTimeout(timer);\r\n server.close();\r\n if (err) reject(err);\r\n else resolve(result as LoginResult);\r\n };\r\n\r\n const server = createServer((req: IncomingMessage, res: ServerResponse) => {\r\n const url = new URL(req.url ?? '/', 'http://127.0.0.1');\r\n if (req.method === 'GET' && url.pathname === '/callback') {\r\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\r\n res.end(captureHtml());\r\n return;\r\n }\r\n if (req.method === 'POST' && url.pathname === '/capture') {\r\n let body = '';\r\n req.on('data', (chunk: Buffer) => {\r\n body += chunk.toString('utf8');\r\n if (body.length > MAX_BODY_BYTES) req.destroy();\r\n });\r\n req.on('end', () => {\r\n void (async () => {\r\n // Guard against a dribble that crossed the cap before destroy() took effect.\r\n if (body.length > MAX_BODY_BYTES) {\r\n res.writeHead(413, { 'content-type': 'text/plain' });\r\n res.end('payload too large');\r\n finish(new Error('login request body exceeded the size cap'));\r\n return;\r\n }\r\n // Inc 3b.4b: when an exchange is configured, VALIDATE without writing the\r\n // raw refresh token first \u2014 then write exactly ONE credential (the\r\n // vocred_ on success, else the refresh on fail-open). This avoids a\r\n // window where the raw Firebase refresh token persists on disk during\r\n // the exchange round-trip (\u00A76: the raw token must not persist at all).\r\n const writeNow = (cred: StoredCredential): string => writeStoredCredential(cred, nowIso(), env);\r\n const outcome = processCapture(body, state, opts.exchange ? () => 'pending' : writeNow);\r\n let result = outcome.result;\r\n if (outcome.ok && outcome.captured && opts.exchange) {\r\n const capt = outcome.captured;\r\n let cred: StoredCredential = {\r\n refresh_token: capt.refresh_token,\r\n api_key: capt.api_key,\r\n ...(capt.email ? { email: capt.email } : {}),\r\n };\r\n try {\r\n const voc = await opts.exchange(capt.refresh_token, capt.api_key);\r\n if (voc && voc.vo_credential) {\r\n // Store ONLY the vocred_ \u2014 the raw refresh token never lands on disk.\r\n cred = {\r\n vo_credential: voc.vo_credential,\r\n vo_credential_expires_at: voc.expires_at,\r\n ...(capt.email ? { email: capt.email } : {}),\r\n };\r\n }\r\n } catch {\r\n /* keep the refresh credential \u2014 fail-open */\r\n }\r\n const path = writeNow(cred);\r\n result = { ...(capt.email ? { email: capt.email } : {}), credentialPath: path };\r\n }\r\n res.writeHead(outcome.httpStatus, { 'content-type': 'text/html; charset=utf-8' });\r\n res.end(outcome.ok ? '<h2>VO login complete \u2014 you can close this tab.</h2>' : `<h2>Login failed: ${outcome.error}</h2>`);\r\n finish(outcome.ok ? null : new Error(outcome.error ?? 'login failed'), result);\r\n })();\r\n });\r\n return;\r\n }\r\n res.writeHead(404);\r\n res.end('not found');\r\n });\r\n\r\n const timer = setTimeout(\r\n () => finish(new Error(`login timed out after ${timeoutMs}ms \u2014 no sign-in captured`)),\r\n timeoutMs,\r\n );\r\n\r\n server.on('error', (e: Error) => finish(e));\r\n server.listen(0, '127.0.0.1', () => {\r\n const addr = server.address();\r\n const port = addr && typeof addr === 'object' ? addr.port : 0;\r\n if (!port) {\r\n finish(new Error('failed to bind a loopback port'));\r\n return;\r\n }\r\n const loginUrl = `${dashboardUrl}/cli-login?port=${port}&state=${encodeURIComponent(state)}`;\r\n log(`[vo-mcp] Opening your browser to sign in:\\n ${loginUrl}`);\r\n log('[vo-mcp] If it did not open, paste that URL into your browser. Waiting for sign-in\u2026');\r\n try {\r\n openBrowser(loginUrl);\r\n } catch {\r\n /* user opens manually */\r\n }\r\n });\r\n });\r\n}\r\n", "/**\r\n * Local credential store for the thin-client `vo-mcp login` flow (Increment 3b,\r\n * Option A \u2014 `docs/vo/vo-command-center-inc3b-login-design-2026-06-05.md`).\r\n *\r\n * Persists the per-user Firebase refresh token (the user's OWN credential, never\r\n * the god-token, never model keys) captured by `login`, so the auto-refreshing\r\n * token source (Inc 3a) can mint fresh ID tokens across MCP restarts.\r\n *\r\n * Storage precedence (Inc 3b.3):\r\n * 1. **OS keychain** (Windows Credential Manager / macOS Keychain / libsecret)\r\n * via the optional `@napi-rs/keyring` backend (`keychain.ts`). The DEFAULT\r\n * when available \u2014 the secret never lands in plaintext on disk.\r\n * 2. **0600 file** at `$VO_MCP_CREDENTIALS_PATH` or `~/.config/vo-mcp/credentials.json`.\r\n * The fallback when the keychain is unavailable or disabled\r\n * (`VO_MCP_DISABLE_KEYCHAIN`). `VO_MCP_CREDENTIALS_PATH` only sets the file\r\n * LOCATION; force file storage with `VO_MCP_DISABLE_KEYCHAIN`.\r\n *\r\n * `env`-supplied tokens (`VO_USER_REFRESH_TOKEN`, etc.) still win over BOTH\r\n * stores \u2014 that precedence lives upstream in `auth-token-source.ts`.\r\n *\r\n * **Single source of truth.** The credential lives in EITHER the keychain OR the\r\n * file, never both: a write to one store CLEARS the other, so a stale entry can\r\n * never shadow the current credential on read, and the secret never lingers in\r\n * plaintext after a migration to the keychain.\r\n *\r\n * **Keychain durability.** A keychain-stored credential is only readable while\r\n * the `@napi-rs/keyring` native module loads. If the module later becomes\r\n * unavailable (an ABI break across a Node upgrade, a corrupted install), the\r\n * credential can't be read and the user re-runs `vo-mcp login` \u2014 the same\r\n * behaviour as `gh` / `gcloud` / `firebase` keychain storage. We deliberately do\r\n * NOT mirror the secret to a plaintext file as a fallback: that would defeat the\r\n * entire point of keychain storage (keeping the secret off plaintext disk).\r\n */\r\nimport { homedir } from 'node:os';\r\nimport { join, dirname } from 'node:path';\r\nimport {\r\n existsSync,\r\n mkdirSync,\r\n readFileSync,\r\n writeFileSync,\r\n chmodSync,\r\n rmSync,\r\n} from 'node:fs';\r\n\r\nimport { keychainAvailable, keychainGet, keychainSet, keychainDelete } from './keychain.js';\r\n\r\nexport interface StoredCredential {\r\n /**\r\n * Firebase refresh token (long-lived; exchanged for short-lived ID tokens).\r\n * OPTIONAL since Inc 3b.4b: once a scoped `vo_credential` is minted, the raw\r\n * refresh token is dropped, so a stored credential may carry ONLY the vocred_.\r\n */\r\n readonly refresh_token?: string;\r\n /** Firebase Web API key (PUBLIC) needed for the securetoken refresh exchange. */\r\n readonly api_key?: string;\r\n /**\r\n * Scoped, revocable VO credential (`vocred_`) minted by the control-plane\r\n * (Inc 3b.4b). Preferred over the raw refresh token; lets the client present a\r\n * revocable, server-side credential instead of the Firebase refresh token.\r\n */\r\n readonly vo_credential?: string;\r\n /** ISO-8601 expiry of `vo_credential` (the client re-logs-in past this). */\r\n readonly vo_credential_expires_at?: string;\r\n /** The signed-in operator email (diagnostics only). */\r\n readonly email?: string;\r\n /** ISO timestamp the credential was stored. */\r\n readonly stored_at?: string;\r\n}\r\n\r\n/**\r\n * Pluggable OS-keychain backend. Defaults to the real `@napi-rs/keyring` wrapper;\r\n * tests inject a deterministic fake so they never touch the host keychain.\r\n */\r\nexport interface KeychainBackend {\r\n available(): boolean;\r\n get(): string | null;\r\n set(secret: string): boolean;\r\n delete(): boolean;\r\n}\r\n\r\nconst realKeychain: KeychainBackend = {\r\n available: keychainAvailable,\r\n get: keychainGet,\r\n set: keychainSet,\r\n delete: keychainDelete,\r\n};\r\n\r\n/** Human-readable \"location\" returned when the credential was stored in the OS keychain. */\r\nexport const KEYCHAIN_LOCATION = 'OS keychain (service \"vo-mcp\")';\r\n\r\n/** Resolve the credentials file path (env override \u2192 XDG-ish default under home). */\r\nexport function credentialPath(env: Readonly<Record<string, string | undefined>> = process.env): string {\r\n const override = env['VO_MCP_CREDENTIALS_PATH']?.trim();\r\n if (override) return override;\r\n return join(homedir(), '.config', 'vo-mcp', 'credentials.json');\r\n}\r\n\r\n/**\r\n * Whether the keychain should be consulted at all (read OR write). False when the\r\n * native backend is unavailable or `VO_MCP_DISABLE_KEYCHAIN` is set (CI/headless).\r\n */\r\nfunction keychainEnabled(\r\n env: Readonly<Record<string, string | undefined>>,\r\n keychain: KeychainBackend,\r\n): boolean {\r\n const disabled = (env['VO_MCP_DISABLE_KEYCHAIN'] ?? '').trim().toLowerCase();\r\n if (disabled === '1' || disabled === 'true' || disabled === 'yes') return false;\r\n return keychain.available();\r\n}\r\n\r\n/** Parse + validate a stored credential blob. Returns null on any problem (never throws). */\r\nfunction deserialize(raw: string): StoredCredential | null {\r\n try {\r\n const parsed = JSON.parse(raw) as Partial<StoredCredential>;\r\n const refresh = typeof parsed.refresh_token === 'string' ? parsed.refresh_token.trim() : '';\r\n const apiKey = typeof parsed.api_key === 'string' ? parsed.api_key.trim() : '';\r\n const voCred = typeof parsed.vo_credential === 'string' ? parsed.vo_credential.trim() : '';\r\n // Valid if it carries a scoped vocred_ OR a full Firebase refresh pair.\r\n if (!voCred && (!refresh || !apiKey)) return null;\r\n return {\r\n ...(refresh ? { refresh_token: refresh } : {}),\r\n ...(apiKey ? { api_key: apiKey } : {}),\r\n ...(voCred ? { vo_credential: voCred } : {}),\r\n ...(typeof parsed.vo_credential_expires_at === 'string' ? { vo_credential_expires_at: parsed.vo_credential_expires_at } : {}),\r\n ...(typeof parsed.email === 'string' ? { email: parsed.email } : {}),\r\n ...(typeof parsed.stored_at === 'string' ? { stored_at: parsed.stored_at } : {}),\r\n };\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction readFromFile(env: Readonly<Record<string, string | undefined>>): StoredCredential | null {\r\n try {\r\n const p = credentialPath(env);\r\n if (!existsSync(p)) return null;\r\n return deserialize(readFileSync(p, 'utf8'));\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/**\r\n * Read the stored credential, or `null` if absent/unreadable/invalid (never\r\n * throws). Consults an ENABLED keychain first (regardless of the write-target\r\n * flags, so a credential written to the keychain is found even if\r\n * `VO_MCP_CREDENTIALS_PATH` is later set), then the 0600 file.\r\n */\r\nexport function readStoredCredential(\r\n env: Readonly<Record<string, string | undefined>> = process.env,\r\n keychain: KeychainBackend = realKeychain,\r\n): StoredCredential | null {\r\n if (keychainEnabled(env, keychain)) {\r\n const raw = keychain.get();\r\n const fromKeychain = raw ? deserialize(raw) : null;\r\n if (fromKeychain) return fromKeychain;\r\n }\r\n return readFromFile(env);\r\n}\r\n\r\nfunction deleteFile(env: Readonly<Record<string, string | undefined>>): void {\r\n try {\r\n rmSync(credentialPath(env), { force: true });\r\n } catch {\r\n /* best-effort */\r\n }\r\n}\r\n\r\nfunction writeToFile(\r\n payload: StoredCredential,\r\n env: Readonly<Record<string, string | undefined>>,\r\n): string {\r\n const p = credentialPath(env);\r\n mkdirSync(dirname(p), { recursive: true });\r\n writeFileSync(p, `${JSON.stringify(payload, null, 2)}\\n`, { mode: 0o600 });\r\n // Best-effort tighten (no-op / throws on some Windows filesystems \u2014 ignore).\r\n try {\r\n chmodSync(p, 0o600);\r\n } catch {\r\n /* best-effort */\r\n }\r\n return p;\r\n}\r\n\r\n/**\r\n * Persist the credential. Prefers the OS keychain (secret never hits plaintext\r\n * disk); otherwise writes the 0600 file. Writing to one store CLEARS the other\r\n * (single source of truth \u2014 no stale shadow, no lingering plaintext). Returns the\r\n * location it was stored (`KEYCHAIN_LOCATION` or the file path).\r\n */\r\nexport function writeStoredCredential(\r\n cred: StoredCredential,\r\n storedAt: string,\r\n env: Readonly<Record<string, string | undefined>> = process.env,\r\n keychain: KeychainBackend = realKeychain,\r\n): string {\r\n const payload: StoredCredential = {\r\n ...(cred.refresh_token ? { refresh_token: cred.refresh_token } : {}),\r\n ...(cred.api_key ? { api_key: cred.api_key } : {}),\r\n ...(cred.vo_credential ? { vo_credential: cred.vo_credential } : {}),\r\n ...(cred.vo_credential_expires_at ? { vo_credential_expires_at: cred.vo_credential_expires_at } : {}),\r\n ...(cred.email ? { email: cred.email } : {}),\r\n stored_at: cred.stored_at ?? storedAt,\r\n };\r\n if (keychainEnabled(env, keychain) && keychain.set(JSON.stringify(payload))) {\r\n // Stored in the keychain \u2192 clear any stale plaintext file so the secret\r\n // doesn't linger on disk and can't shadow the keychain on read.\r\n deleteFile(env);\r\n return KEYCHAIN_LOCATION;\r\n }\r\n const p = writeToFile(payload, env);\r\n // Stored in the file \u2192 clear any stale keychain entry so it can't shadow the\r\n // newer file credential on read.\r\n if (keychainEnabled(env, keychain)) keychain.delete();\r\n return p;\r\n}\r\n", "/**\r\n * Optional OS-keychain backend for the thin-client credential store (Increment\r\n * 3b.3 \u2014 `docs/vo/vo-command-center-inc3b-login-design-2026-06-05.md` \u00A75/\u00A76).\r\n *\r\n * Loads `@napi-rs/keyring` at runtime via `createRequire`, so it is a TRUE\r\n * optional dependency: if the native module is absent or fails to load\r\n * (unsupported platform, prebuilt binary missing, headless CI), every function\r\n * degrades to a no-op and the caller (`credential-store.ts`) falls back to the\r\n * 0600 file store. `@napi-rs/keyring`'s `Entry` API is SYNCHRONOUS, so the\r\n * credential store stays synchronous \u2014 no async ripple into the Inc-3a token\r\n * source that reads it.\r\n *\r\n * Why `createRequire` and not a static/dynamic `import`: a static import would\r\n * make the native module a HARD dependency (a missing prebuilt would crash the\r\n * MCP at startup); a dynamic `import()` is async (would force the whole read\r\n * path async). `createRequire(...)` inside a try/catch loads it lazily and\r\n * synchronously, and a load failure is just \"keychain unavailable\".\r\n */\r\nimport { createRequire } from 'node:module';\r\n\r\n/** Keychain service + account the single refresh credential is stored under. */\r\nconst SERVICE = 'vo-mcp';\r\nconst ACCOUNT = 'refresh-credential';\r\n\r\ninterface KeyringEntry {\r\n getPassword(): string | null;\r\n setPassword(password: string): void;\r\n deletePassword(): boolean;\r\n}\r\ninterface KeyringModule {\r\n Entry: new (service: string, account: string) => KeyringEntry;\r\n}\r\n\r\n// undefined = not yet attempted; null = attempted and unavailable.\r\nlet cached: KeyringModule | null | undefined;\r\n\r\nfunction loadKeyring(): KeyringModule | null {\r\n if (cached !== undefined) return cached;\r\n try {\r\n const req = createRequire(import.meta.url);\r\n const mod = req('@napi-rs/keyring') as Partial<KeyringModule>;\r\n cached = mod && typeof mod.Entry === 'function' ? (mod as KeyringModule) : null;\r\n } catch {\r\n cached = null;\r\n }\r\n return cached;\r\n}\r\n\r\n/** True when the OS keychain backend is usable in this runtime. */\r\nexport function keychainAvailable(): boolean {\r\n return loadKeyring() !== null;\r\n}\r\n\r\n/** Read the raw stored secret string from the OS keychain, or null. Never throws. */\r\nexport function keychainGet(): string | null {\r\n const k = loadKeyring();\r\n if (!k) return null;\r\n try {\r\n return new k.Entry(SERVICE, ACCOUNT).getPassword();\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** Store the raw secret string in the OS keychain. Returns true on success. Never throws. */\r\nexport function keychainSet(secret: string): boolean {\r\n const k = loadKeyring();\r\n if (!k) return false;\r\n try {\r\n new k.Entry(SERVICE, ACCOUNT).setPassword(secret);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n/**\r\n * Delete the stored secret from the OS keychain. Returns true if an entry was\r\n * removed. Never throws \u2014 a no-op (and `false`) when the backend is unavailable\r\n * or the entry is absent. Used to keep ONE source of truth: when the credential\r\n * is (re)written to the file, any stale keychain entry is cleared so it can't\r\n * shadow the newer file credential on read (and vice-versa).\r\n */\r\nexport function keychainDelete(): boolean {\r\n const k = loadKeyring();\r\n if (!k) return false;\r\n try {\r\n return new k.Entry(SERVICE, ACCOUNT).deletePassword();\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n/** Test-only seam to reset the memoised module load. */\r\nexport function __resetKeychainCache(): void {\r\n cached = undefined;\r\n}\r\n", "/**\r\n * Auth token sources for the cloud admin client \u2014 Increment 3 of the VO Command\r\n * Center unification (`docs/vo/vo-command-center-unification-spec-2026-06-05.md`\r\n * \u00A76): retire the shared god admin-token from the LOCAL install. Instead of a\r\n * single static `VO_CONTROL_PLANE_ADMIN_TOKEN`, the client can authenticate as\r\n * the USER with a short-lived Firebase ID token (the same credential the\r\n * dashboard sends; the control-plane already accepts it for an allow-listed\r\n * operator \u2014 `cloud-run/vo-control-plane/src/firebase-auth.ts`).\r\n *\r\n * Three sources, selected by env (per-user preferred):\r\n * 1. `VO_USER_REFRESH_TOKEN` (+ `VO_FIREBASE_API_KEY`) \u2192 auto-refreshing Firebase\r\n * user token (durable; exchanges the refresh token for fresh ID tokens via\r\n * the PUBLIC securetoken endpoint \u2014 no backend, no god-token, no model keys).\r\n * 2. `VO_USER_ID_TOKEN` \u2192 a raw Firebase ID token (simplest interim; expires ~1h).\r\n * 3. `VO_CONTROL_PLANE_ADMIN_TOKEN` \u2192 the legacy static god-token (back-compat).\r\n *\r\n * FAIL-CLOSED: a source that cannot produce a token returns `null`; the caller\r\n * (admin client) then refuses the request rather than sending an empty Bearer.\r\n *\r\n * NOTE: this slice removes the god-token from the *client config* (the user's\r\n * own credential is sent instead). Acquiring the initial refresh/ID token via a\r\n * browser/device-flow `login` command, and OS-keychain storage, are follow-up\r\n * slices (spec \u00A77); for now the token is supplied via env.\r\n */\r\n\r\n/** A source of a fresh bearer token for the control-plane. */\r\nexport interface AuthTokenSource {\r\n /** Stable label for diagnostics/logs \u2014 NEVER the token value. */\r\n readonly kind: 'admin-token' | 'firebase-id-token' | 'firebase-refresh' | 'vo-credential';\r\n /** Returns a fresh bearer token, or `null` when unavailable (fail-closed). */\r\n getToken(): Promise<string | null>;\r\n}\r\n\r\ntype FetchLike = (\r\n url: string,\r\n init?: { method?: string; headers?: Record<string, string>; body?: string },\r\n) => Promise<{ status: number; text: () => Promise<string> }>;\r\n\r\n/** Public Google endpoint that exchanges a Firebase refresh token for a fresh ID token. */\r\nexport const FIREBASE_SECURETOKEN_URL = 'https://securetoken.googleapis.com/v1/token';\r\n\r\n/** Refresh this many ms BEFORE the ID token actually expires (clock-skew margin). */\r\nconst REFRESH_SKEW_MS = 60_000;\r\n\r\n/** A fixed, already-available token (the god-token or a pasted ID token). */\r\nexport function createStaticTokenSource(\r\n token: string,\r\n kind: AuthTokenSource['kind'] = 'admin-token',\r\n): AuthTokenSource {\r\n const value = token.trim();\r\n return { kind, getToken: async () => (value.length > 0 ? value : null) };\r\n}\r\n\r\nexport interface FirebaseRefreshTokenSourceOptions {\r\n readonly refreshToken: string;\r\n /** The Firebase Web API key (PUBLIC, not a secret) for the Algosuite project. */\r\n readonly apiKey: string;\r\n /** Injectable clock (ms) for tests; defaults to `Date.now`. */\r\n readonly now?: () => number;\r\n /** Injectable fetch for tests; defaults to the global `fetch`. */\r\n readonly fetchFn?: FetchLike;\r\n}\r\n\r\n/**\r\n * Auto-refreshing Firebase user token. Exchanges the long-lived refresh token\r\n * for short-lived ID tokens via the public securetoken endpoint and caches the\r\n * ID token until shortly before it expires. Fail-closed: any error \u21D2 `null`.\r\n */\r\nexport function createFirebaseRefreshTokenSource(\r\n opts: FirebaseRefreshTokenSourceOptions,\r\n): AuthTokenSource {\r\n const refreshToken = opts.refreshToken.trim();\r\n const apiKey = opts.apiKey.trim();\r\n const now = opts.now ?? (() => Date.now());\r\n const fetchFn: FetchLike = opts.fetchFn ?? (globalThis.fetch as unknown as FetchLike);\r\n\r\n let cachedToken: string | null = null;\r\n let expiresAtMs = 0;\r\n // Collapse concurrent refreshes so N parallel tool calls trigger ONE exchange.\r\n let inFlight: Promise<string | null> | null = null;\r\n\r\n async function refresh(): Promise<string | null> {\r\n try {\r\n const res = await fetchFn(`${FIREBASE_SECURETOKEN_URL}?key=${encodeURIComponent(apiKey)}`, {\r\n method: 'POST',\r\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\r\n body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`,\r\n });\r\n const text = await res.text();\r\n if (res.status < 200 || res.status >= 300) {\r\n cachedToken = null;\r\n return null;\r\n }\r\n const parsed = JSON.parse(text) as { id_token?: unknown; expires_in?: unknown };\r\n const idToken = typeof parsed.id_token === 'string' ? parsed.id_token : '';\r\n if (!idToken) {\r\n cachedToken = null;\r\n return null;\r\n }\r\n const expiresInSec = Number(parsed.expires_in);\r\n const ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1000 : 3_600_000;\r\n cachedToken = idToken;\r\n expiresAtMs = now() + ttlMs;\r\n return idToken;\r\n } catch {\r\n cachedToken = null;\r\n return null;\r\n }\r\n }\r\n\r\n return {\r\n kind: 'firebase-refresh',\r\n async getToken(): Promise<string | null> {\r\n if (cachedToken && now() < expiresAtMs - REFRESH_SKEW_MS) return cachedToken;\r\n if (!inFlight) {\r\n inFlight = refresh().finally(() => {\r\n inFlight = null;\r\n });\r\n }\r\n return inFlight;\r\n },\r\n };\r\n}\r\n\r\n/**\r\n * Select an auth token source from env. Per-user credentials win over the legacy\r\n * god-token, so a thin install configured with the user's token needs NO\r\n * `VO_CONTROL_PLANE_ADMIN_TOKEN`. Returns `null` when nothing is configured.\r\n * Throws on a half-configured refresh source (refresh token without API key).\r\n */\r\n/** Minimal stored-credential shape this selector consumes (from `vo-mcp login`). */\r\nexport interface StoredRefreshCredential {\r\n /** Firebase refresh token (optional once a scoped vocred_ is minted \u2014 Inc 3b.4b). */\r\n readonly refresh_token?: string;\r\n readonly api_key?: string;\r\n /** Scoped, revocable VO credential (Inc 3b.4b) \u2014 preferred over the raw refresh. */\r\n readonly vo_credential?: string;\r\n}\r\n\r\nexport function createAuthTokenSourceFromEnv(\r\n env: Readonly<Record<string, string | undefined>> = process.env,\r\n fetchFn?: FetchLike,\r\n /**\r\n * Inc 3b: a reader for a stored login credential (`vo-mcp login`). Injected so\r\n * the env-only callers (and tests) stay filesystem-free; production passes\r\n * `readStoredCredential`. A stored login credential is preferred over the legacy\r\n * god-token (so logging in retires it) but explicit env user-tokens still win.\r\n */\r\n readStoredCred: () => StoredRefreshCredential | null = () => null,\r\n): AuthTokenSource | null {\r\n const refreshToken = env['VO_USER_REFRESH_TOKEN']?.trim();\r\n const apiKey = env['VO_FIREBASE_API_KEY']?.trim();\r\n const idToken = env['VO_USER_ID_TOKEN']?.trim();\r\n const adminToken = env['VO_CONTROL_PLANE_ADMIN_TOKEN']?.trim();\r\n\r\n // 1. explicit env per-user refresh (CI / override).\r\n if (refreshToken || apiKey) {\r\n if (!refreshToken || !apiKey) {\r\n throw new Error(\r\n 'Per-user refresh auth requires BOTH VO_USER_REFRESH_TOKEN and VO_FIREBASE_API_KEY',\r\n );\r\n }\r\n return createFirebaseRefreshTokenSource({\r\n refreshToken,\r\n apiKey,\r\n ...(fetchFn ? { fetchFn } : {}),\r\n });\r\n }\r\n // 2. explicit env ID token.\r\n if (idToken) return createStaticTokenSource(idToken, 'firebase-id-token');\r\n // 3. stored login credential (`vo-mcp login`) \u2014 preferred over the god-token.\r\n const stored = readStoredCred();\r\n // 3a. a scoped vocred_ (Inc 3b.4b) wins \u2014 revocable, server-validated, and it\r\n // means no raw Firebase refresh token sits on disk. Sent as a static bearer;\r\n // the control-plane validates expiry/revocation (a stale one \u21D2 401 \u21D2 re-login).\r\n if (stored?.vo_credential && stored.vo_credential.trim()) {\r\n return createStaticTokenSource(stored.vo_credential.trim(), 'vo-credential');\r\n }\r\n // 3b. else the Firebase refresh credential.\r\n if (stored && stored.refresh_token?.trim() && stored.api_key?.trim()) {\r\n return createFirebaseRefreshTokenSource({\r\n refreshToken: stored.refresh_token.trim(),\r\n apiKey: stored.api_key.trim(),\r\n ...(fetchFn ? { fetchFn } : {}),\r\n });\r\n }\r\n // 4. legacy static god-token (back-compat).\r\n if (adminToken) return createStaticTokenSource(adminToken, 'admin-token');\r\n return null;\r\n}\r\n", "/**\r\n * Increment 3b.4b \u2014 exchange a captured Firebase refresh credential for a scoped,\r\n * revocable VO credential. The thin client mints ONCE via the control-plane\r\n * `POST /api/v1/auth/vo-credential` (authenticated with a fresh Firebase ID token\r\n * derived from the refresh token), then presents the `vocred_` instead of the raw\r\n * Firebase token thereafter \u2014 so the raw refresh token need not persist on disk\r\n * (spec \u00A76: kill the local secret).\r\n *\r\n * FAIL-OPEN to the refresh credential: ANY failure (an older control-plane without\r\n * the mint endpoint, a network error, a non-operator principal, a malformed\r\n * response) returns `null`, and the caller keeps using the Firebase refresh flow.\r\n * Never throws.\r\n */\r\nimport { createFirebaseRefreshTokenSource } from './auth-token-source.js';\r\n\r\n/** Minimal fetch shape (matches `auth-token-source`'s injectable fetch). */\r\ntype ExchangeFetch = (\r\n url: string,\r\n init?: { method?: string; headers?: Record<string, string>; body?: string },\r\n) => Promise<{ status: number; text: () => Promise<string> }>;\r\n\r\nexport interface VoCredentialResult {\r\n readonly vo_credential: string;\r\n readonly expires_at: string;\r\n}\r\n\r\nexport interface ExchangeOptions {\r\n readonly refreshToken: string;\r\n readonly apiKey: string;\r\n /** Control-plane base URL (e.g. `https://vo-control-plane-\u2026run.app`). */\r\n readonly controlPlaneUrl: string;\r\n /** Operator-friendly label stored with the credential (e.g. host + client). */\r\n readonly label?: string;\r\n /** Injectable fetch (tests); defaults to the global `fetch`. */\r\n readonly fetchFn?: ExchangeFetch;\r\n}\r\n\r\n/**\r\n * Mint a `vocred_` from a Firebase refresh credential. Returns the credential on\r\n * success, else `null` (fail-open). Never throws.\r\n */\r\nexport async function exchangeForVoCredential(opts: ExchangeOptions): Promise<VoCredentialResult | null> {\r\n const base = opts.controlPlaneUrl.replace(/\\/+$/, '');\r\n if (!base) return null;\r\n const fetchFn: ExchangeFetch = opts.fetchFn ?? (globalThis.fetch as unknown as ExchangeFetch);\r\n try {\r\n // 1. Derive a fresh Firebase ID token from the refresh credential (Inc 3a flow).\r\n const idToken = await createFirebaseRefreshTokenSource({\r\n refreshToken: opts.refreshToken,\r\n apiKey: opts.apiKey,\r\n ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),\r\n }).getToken();\r\n if (!idToken) return null;\r\n\r\n // 2. Mint the scoped credential with the ID token.\r\n const res = await fetchFn(`${base}/api/v1/auth/vo-credential`, {\r\n method: 'POST',\r\n headers: { 'content-type': 'application/json', authorization: `Bearer ${idToken}` },\r\n body: JSON.stringify(opts.label ? { label: opts.label } : {}),\r\n });\r\n if (res.status !== 200) return null;\r\n const parsed = JSON.parse(await res.text()) as { token?: unknown; expires_at?: unknown };\r\n const token = typeof parsed.token === 'string' ? parsed.token : '';\r\n const expiresAt = typeof parsed.expires_at === 'string' ? parsed.expires_at : '';\r\n if (!token.startsWith('vocred_') || !expiresAt) return null;\r\n return { vo_credential: token, expires_at: expiresAt };\r\n } catch {\r\n return null;\r\n }\r\n}\r\n", "/**\r\n * Cross-platform auto-start registration for `vo-mcp runner`.\r\n *\r\n * WINDOWS: Uses the user Startup folder, NOT Task Scheduler. Headless\r\n * `claude -p` (which the runner spawns) HANGS under the Task Scheduler\r\n * service session \u2014 it only runs from an interactive, Explorer-descended\r\n * session. The Startup folder launches minimized at user login.\r\n *\r\n * MACOS: Uses ~/Library/LaunchAgents (launchd) with RunAtLoad + KeepAlive.\r\n *\r\n * All operations are idempotent and create backups where applicable.\r\n */\r\nimport { homedir, platform } from 'node:os';\r\nimport { join } from 'node:path';\r\nimport { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, copyFileSync } from 'node:fs';\r\n\r\nexport interface AutostartOptions {\r\n readonly log?: (msg: string) => void;\r\n readonly runnerCommand?: string; // Override for tests\r\n readonly env?: Readonly<Record<string, string | undefined>>;\r\n}\r\n\r\n/**\r\n * Resolve the path to the vo-mcp runner command. Defaults to 'vo-mcp runner'\r\n * (assumes it's in PATH). Can be overridden for tests.\r\n */\r\nfunction resolveRunnerCommand(override?: string): string {\r\n return override ?? 'vo-mcp runner';\r\n}\r\n\r\n/**\r\n * Windows: Install a launcher script in the Startup folder.\r\n *\r\n * Creates a .cmd file that starts `vo-mcp runner` minimized. The Startup\r\n * folder is %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\.\r\n */\r\nfunction installWindowsAutostart(runnerCommand: string, log: (msg: string) => void, env: Readonly<Record<string, string | undefined>>): void {\r\n const appData = env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');\r\n const startupDir = join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');\r\n mkdirSync(startupDir, { recursive: true });\r\n\r\n const launcherPath = join(startupDir, 'vo-runner.cmd');\r\n\r\n // Idempotent: if the launcher already exists with our content, skip\r\n if (existsSync(launcherPath)) {\r\n const existing = readFileSync(launcherPath, 'utf8');\r\n if (existing.includes('vo-mcp runner')) {\r\n log(`\u2713 Auto-start is already configured (Windows Startup folder)`);\r\n log(` Path: ${launcherPath}`);\r\n return;\r\n }\r\n // Backup if content differs\r\n const backupPath = `${launcherPath}.backup-${Date.now()}`;\r\n copyFileSync(launcherPath, backupPath);\r\n log(` Backed up existing launcher to: ${backupPath}`);\r\n }\r\n\r\n // Write the launcher. Uses `start /min` to launch minimized.\r\n const launcherContent = `@echo off\r\nREM Auto-start launcher for vo-mcp runner\r\nREM Created by vo-mcp autostart installer\r\nstart /min cmd /c \"${runnerCommand}\"\r\n`;\r\n\r\n writeFileSync(launcherPath, launcherContent, 'utf8');\r\n log(`\u2713 Installed Windows auto-start launcher`);\r\n log(` Path: ${launcherPath}`);\r\n log(` The runner will start minimized at next login.`);\r\n}\r\n\r\n/**\r\n * Windows: Uninstall the Startup folder launcher.\r\n */\r\nfunction uninstallWindowsAutostart(log: (msg: string) => void, env: Readonly<Record<string, string | undefined>>): void {\r\n const appData = env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');\r\n const startupDir = join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');\r\n const launcherPath = join(startupDir, 'vo-runner.cmd');\r\n\r\n if (!existsSync(launcherPath)) {\r\n log(`\u2713 Auto-start launcher not found (already removed)`);\r\n return;\r\n }\r\n\r\n unlinkSync(launcherPath);\r\n log(`\u2713 Removed Windows auto-start launcher`);\r\n log(` Path: ${launcherPath}`);\r\n}\r\n\r\n/**\r\n * macOS: Install a launchd plist in ~/Library/LaunchAgents.\r\n *\r\n * The plist runs `vo-mcp runner` at login with RunAtLoad=true and\r\n * KeepAlive=true (restarts if it crashes).\r\n */\r\nasync function installMacAutostart(runnerCommand: string, log: (msg: string) => void): Promise<void> {\r\n const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');\r\n mkdirSync(launchAgentsDir, { recursive: true });\r\n\r\n const plistPath = join(launchAgentsDir, 'ai.algosuite.vo-runner.plist');\r\n\r\n // Idempotent: if the plist already exists with our content, skip\r\n if (existsSync(plistPath)) {\r\n const existing = readFileSync(plistPath, 'utf8');\r\n if (existing.includes('vo-mcp runner')) {\r\n log(`\u2713 Auto-start is already configured (launchd)`);\r\n log(` Path: ${plistPath}`);\r\n return;\r\n }\r\n // Backup if content differs\r\n const backupPath = `${plistPath}.backup-${Date.now()}`;\r\n copyFileSync(plistPath, backupPath);\r\n log(` Backed up existing plist to: ${backupPath}`);\r\n }\r\n\r\n // Parse the runner command into program + args for launchd\r\n const parts = runnerCommand.split(/\\s+/);\r\n const program = parts[0] ?? 'vo-mcp';\r\n const args = parts.length > 1 ? parts.slice(1) : ['runner'];\r\n\r\n const plistContent = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\r\n<plist version=\"1.0\">\r\n<dict>\r\n <key>Label</key>\r\n <string>ai.algosuite.vo-runner</string>\r\n <key>ProgramArguments</key>\r\n <array>\r\n <string>${program}</string>\r\n${args.map((a) => ` <string>${a}</string>`).join('\\n')}\r\n </array>\r\n <key>RunAtLoad</key>\r\n <true/>\r\n <key>KeepAlive</key>\r\n <true/>\r\n <key>StandardOutPath</key>\r\n <string>${join(homedir(), '.claude', 'vo-runner.log')}</string>\r\n <key>StandardErrorPath</key>\r\n <string>${join(homedir(), '.claude', 'vo-runner-error.log')}</string>\r\n</dict>\r\n</plist>\r\n`;\r\n\r\n writeFileSync(plistPath, plistContent, 'utf8');\r\n log(`\u2713 Installed launchd plist`);\r\n log(` Path: ${plistPath}`);\r\n\r\n // Load the plist with launchctl\r\n try {\r\n const { execSync } = await import('node:child_process');\r\n execSync(`launchctl load \"${plistPath}\"`, { stdio: 'ignore' });\r\n log(`\u2713 Loaded plist with launchctl (runner will start at next login)`);\r\n log(` Logs: ${join(homedir(), '.claude', 'vo-runner.log')}`);\r\n } catch {\r\n log(`\u26A0 Failed to load plist with launchctl (you may need to load it manually)`);\r\n log(` Run: launchctl load \"${plistPath}\"`);\r\n }\r\n}\r\n\r\n/**\r\n * macOS: Uninstall the launchd plist.\r\n */\r\nasync function uninstallMacAutostart(log: (msg: string) => void): Promise<void> {\r\n const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');\r\n const plistPath = join(launchAgentsDir, 'ai.algosuite.vo-runner.plist');\r\n\r\n if (!existsSync(plistPath)) {\r\n log(`\u2713 Auto-start plist not found (already removed)`);\r\n return;\r\n }\r\n\r\n // Unload the plist with launchctl\r\n try {\r\n const { execSync } = await import('node:child_process');\r\n execSync(`launchctl unload \"${plistPath}\"`, { stdio: 'ignore' });\r\n log(`\u2713 Unloaded plist with launchctl`);\r\n } catch {\r\n log(`\u26A0 Failed to unload plist with launchctl (continuing anyway)`);\r\n }\r\n\r\n unlinkSync(plistPath);\r\n log(`\u2713 Removed launchd plist`);\r\n log(` Path: ${plistPath}`);\r\n}\r\n\r\n/**\r\n * Install auto-start for the runner daemon. Cross-platform.\r\n */\r\nexport async function installAutostart(opts: AutostartOptions = {}): Promise<void> {\r\n const log = opts.log ?? ((m: string) => console.error(m));\r\n const env = opts.env ?? process.env;\r\n const runnerCommand = resolveRunnerCommand(opts.runnerCommand);\r\n const plat = platform();\r\n\r\n if (plat === 'win32') {\r\n installWindowsAutostart(runnerCommand, log, env);\r\n } else if (plat === 'darwin') {\r\n await installMacAutostart(runnerCommand, log);\r\n } else {\r\n log(`\u2717 Auto-start is not supported on platform: ${plat}`);\r\n log(` Supported platforms: win32 (Windows), darwin (macOS)`);\r\n }\r\n}\r\n\r\n/**\r\n * Uninstall auto-start for the runner daemon. Cross-platform.\r\n */\r\nexport async function uninstallAutostart(opts: AutostartOptions = {}): Promise<void> {\r\n const log = opts.log ?? ((m: string) => console.error(m));\r\n const env = opts.env ?? process.env;\r\n const plat = platform();\r\n\r\n if (plat === 'win32') {\r\n uninstallWindowsAutostart(log, env);\r\n } else if (plat === 'darwin') {\r\n await uninstallMacAutostart(log);\r\n } else {\r\n log(`\u2717 Auto-start is not supported on platform: ${plat}`);\r\n log(` Supported platforms: win32 (Windows), darwin (macOS)`);\r\n }\r\n}\r\n", "#!/usr/bin/env node\r\n/**\r\n * `vo-mcp install` CLI entry point.\r\n *\r\n * Usage:\r\n * npx @algosuite/vo-mcp install\r\n * vo-mcp install\r\n */\r\nimport { install } from './install.js';\r\n\r\ninstall().catch((err: unknown) => {\r\n console.error('[vo-mcp install] fatal:', err);\r\n process.exit(1);\r\n});\r\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["homedir", "platform", "join", "dirname", "existsSync", "readFileSync", "writeFileSync", "mkdirSync", "copyFileSync", "platform", "resolve", "homedir", "join", "existsSync", "mkdirSync", "writeFileSync", "readFileSync", "join", "homedir", "mkdirSync", "existsSync", "readFileSync", "writeFileSync", "platform", "join", "homedir", "existsSync", "readFileSync", "mkdirSync", "dirname", "writeFileSync", "copyFileSync"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/cloud/login.ts
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
// src/cloud/credential-store.ts
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
chmodSync,
|
|
18
|
+
rmSync
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
|
|
21
|
+
// src/cloud/keychain.ts
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
var SERVICE = "vo-mcp";
|
|
24
|
+
var ACCOUNT = "refresh-credential";
|
|
25
|
+
var cached;
|
|
26
|
+
function loadKeyring() {
|
|
27
|
+
if (cached !== void 0) return cached;
|
|
28
|
+
try {
|
|
29
|
+
const req = createRequire(import.meta.url);
|
|
30
|
+
const mod = req("@napi-rs/keyring");
|
|
31
|
+
cached = mod && typeof mod.Entry === "function" ? mod : null;
|
|
32
|
+
} catch {
|
|
33
|
+
cached = null;
|
|
34
|
+
}
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
function keychainAvailable() {
|
|
38
|
+
return loadKeyring() !== null;
|
|
39
|
+
}
|
|
40
|
+
function keychainGet() {
|
|
41
|
+
const k = loadKeyring();
|
|
42
|
+
if (!k) return null;
|
|
43
|
+
try {
|
|
44
|
+
return new k.Entry(SERVICE, ACCOUNT).getPassword();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function keychainSet(secret) {
|
|
50
|
+
const k = loadKeyring();
|
|
51
|
+
if (!k) return false;
|
|
52
|
+
try {
|
|
53
|
+
new k.Entry(SERVICE, ACCOUNT).setPassword(secret);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function keychainDelete() {
|
|
60
|
+
const k = loadKeyring();
|
|
61
|
+
if (!k) return false;
|
|
62
|
+
try {
|
|
63
|
+
return new k.Entry(SERVICE, ACCOUNT).deletePassword();
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/cloud/credential-store.ts
|
|
70
|
+
var realKeychain = {
|
|
71
|
+
available: keychainAvailable,
|
|
72
|
+
get: keychainGet,
|
|
73
|
+
set: keychainSet,
|
|
74
|
+
delete: keychainDelete
|
|
75
|
+
};
|
|
76
|
+
var KEYCHAIN_LOCATION = 'OS keychain (service "vo-mcp")';
|
|
77
|
+
function credentialPath(env = process.env) {
|
|
78
|
+
const override = env["VO_MCP_CREDENTIALS_PATH"]?.trim();
|
|
79
|
+
if (override) return override;
|
|
80
|
+
return join(homedir(), ".config", "vo-mcp", "credentials.json");
|
|
81
|
+
}
|
|
82
|
+
function keychainEnabled(env, keychain) {
|
|
83
|
+
const disabled = (env["VO_MCP_DISABLE_KEYCHAIN"] ?? "").trim().toLowerCase();
|
|
84
|
+
if (disabled === "1" || disabled === "true" || disabled === "yes") return false;
|
|
85
|
+
return keychain.available();
|
|
86
|
+
}
|
|
87
|
+
function deleteFile(env) {
|
|
88
|
+
try {
|
|
89
|
+
rmSync(credentialPath(env), { force: true });
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function writeToFile(payload, env) {
|
|
94
|
+
const p = credentialPath(env);
|
|
95
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
96
|
+
writeFileSync(p, `${JSON.stringify(payload, null, 2)}
|
|
97
|
+
`, { mode: 384 });
|
|
98
|
+
try {
|
|
99
|
+
chmodSync(p, 384);
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
return p;
|
|
103
|
+
}
|
|
104
|
+
function writeStoredCredential(cred, storedAt, env = process.env, keychain = realKeychain) {
|
|
105
|
+
const payload = {
|
|
106
|
+
...cred.refresh_token ? { refresh_token: cred.refresh_token } : {},
|
|
107
|
+
...cred.api_key ? { api_key: cred.api_key } : {},
|
|
108
|
+
...cred.vo_credential ? { vo_credential: cred.vo_credential } : {},
|
|
109
|
+
...cred.vo_credential_expires_at ? { vo_credential_expires_at: cred.vo_credential_expires_at } : {},
|
|
110
|
+
...cred.email ? { email: cred.email } : {},
|
|
111
|
+
stored_at: cred.stored_at ?? storedAt
|
|
112
|
+
};
|
|
113
|
+
if (keychainEnabled(env, keychain) && keychain.set(JSON.stringify(payload))) {
|
|
114
|
+
deleteFile(env);
|
|
115
|
+
return KEYCHAIN_LOCATION;
|
|
116
|
+
}
|
|
117
|
+
const p = writeToFile(payload, env);
|
|
118
|
+
if (keychainEnabled(env, keychain)) keychain.delete();
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/cloud/login.ts
|
|
123
|
+
var DEFAULT_DASHBOARD_URL = "https://vo-dashboard.web.app";
|
|
124
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
125
|
+
var MAX_BODY_BYTES = 16384;
|
|
126
|
+
function processCapture(rawBody, expectedState, store) {
|
|
127
|
+
let data;
|
|
128
|
+
try {
|
|
129
|
+
data = JSON.parse(rawBody);
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, httpStatus: 400, error: "invalid JSON body" };
|
|
132
|
+
}
|
|
133
|
+
if (!data || typeof data !== "object") return { ok: false, httpStatus: 400, error: "invalid body" };
|
|
134
|
+
if (typeof data.state !== "string" || data.state !== expectedState) {
|
|
135
|
+
return { ok: false, httpStatus: 403, error: "state mismatch (possible CSRF) \u2014 login aborted" };
|
|
136
|
+
}
|
|
137
|
+
const refresh = typeof data.refresh_token === "string" ? data.refresh_token.trim() : "";
|
|
138
|
+
const apiKey = typeof data.api_key === "string" ? data.api_key.trim() : "";
|
|
139
|
+
if (!refresh || !apiKey) {
|
|
140
|
+
return { ok: false, httpStatus: 400, error: "login response missing refresh_token / api_key" };
|
|
141
|
+
}
|
|
142
|
+
const email = typeof data.email === "string" && data.email.trim() ? data.email.trim() : void 0;
|
|
143
|
+
const path = store({ refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} });
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
httpStatus: 200,
|
|
147
|
+
result: { ...email ? { email } : {}, credentialPath: path },
|
|
148
|
+
captured: { refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} }
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function captureHtml() {
|
|
152
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>VO login</title></head>
|
|
153
|
+
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center">
|
|
154
|
+
<h2 id="m">Completing sign-in\u2026</h2>
|
|
155
|
+
<script>
|
|
156
|
+
(function(){
|
|
157
|
+
var h=location.hash.replace(/^#/,''), p=new URLSearchParams(h), b={};
|
|
158
|
+
['state','refresh_token','api_key','email'].forEach(function(k){ if(p.get(k)) b[k]=p.get(k); });
|
|
159
|
+
fetch('/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)})
|
|
160
|
+
.then(function(r){ document.getElementById('m').textContent = r.ok ? 'Sign-in complete \u2014 you can close this tab.' : 'Sign-in failed \u2014 check the terminal.'; })
|
|
161
|
+
.catch(function(){ document.getElementById('m').textContent = 'Sign-in failed \u2014 check the terminal.'; });
|
|
162
|
+
})();
|
|
163
|
+
</script></body></html>`;
|
|
164
|
+
}
|
|
165
|
+
function defaultOpenBrowser(url) {
|
|
166
|
+
const platform2 = process.platform;
|
|
167
|
+
if (platform2 === "win32") {
|
|
168
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
169
|
+
} else if (platform2 === "darwin") {
|
|
170
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
171
|
+
} else {
|
|
172
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function runLogin(opts = {}) {
|
|
176
|
+
const dashboardUrl = (opts.dashboardUrl ?? DEFAULT_DASHBOARD_URL).replace(/\/+$/, "");
|
|
177
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
|
+
const env = opts.env ?? process.env;
|
|
179
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
180
|
+
const nowIso = opts.nowIso ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
181
|
+
const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
|
|
182
|
+
const state = randomBytes(32).toString("base64url");
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let settled = false;
|
|
185
|
+
const finish = (err, result) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
server.close();
|
|
190
|
+
if (err) reject(err);
|
|
191
|
+
else resolve(result);
|
|
192
|
+
};
|
|
193
|
+
const server = createServer((req, res) => {
|
|
194
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
195
|
+
if (req.method === "GET" && url.pathname === "/callback") {
|
|
196
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
197
|
+
res.end(captureHtml());
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (req.method === "POST" && url.pathname === "/capture") {
|
|
201
|
+
let body = "";
|
|
202
|
+
req.on("data", (chunk) => {
|
|
203
|
+
body += chunk.toString("utf8");
|
|
204
|
+
if (body.length > MAX_BODY_BYTES) req.destroy();
|
|
205
|
+
});
|
|
206
|
+
req.on("end", () => {
|
|
207
|
+
void (async () => {
|
|
208
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
209
|
+
res.writeHead(413, { "content-type": "text/plain" });
|
|
210
|
+
res.end("payload too large");
|
|
211
|
+
finish(new Error("login request body exceeded the size cap"));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const writeNow = (cred) => writeStoredCredential(cred, nowIso(), env);
|
|
215
|
+
const outcome = processCapture(body, state, opts.exchange ? () => "pending" : writeNow);
|
|
216
|
+
let result = outcome.result;
|
|
217
|
+
if (outcome.ok && outcome.captured && opts.exchange) {
|
|
218
|
+
const capt = outcome.captured;
|
|
219
|
+
let cred = {
|
|
220
|
+
refresh_token: capt.refresh_token,
|
|
221
|
+
api_key: capt.api_key,
|
|
222
|
+
...capt.email ? { email: capt.email } : {}
|
|
223
|
+
};
|
|
224
|
+
try {
|
|
225
|
+
const voc = await opts.exchange(capt.refresh_token, capt.api_key);
|
|
226
|
+
if (voc && voc.vo_credential) {
|
|
227
|
+
cred = {
|
|
228
|
+
vo_credential: voc.vo_credential,
|
|
229
|
+
vo_credential_expires_at: voc.expires_at,
|
|
230
|
+
...capt.email ? { email: capt.email } : {}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
const path = writeNow(cred);
|
|
236
|
+
result = { ...capt.email ? { email: capt.email } : {}, credentialPath: path };
|
|
237
|
+
}
|
|
238
|
+
res.writeHead(outcome.httpStatus, { "content-type": "text/html; charset=utf-8" });
|
|
239
|
+
res.end(outcome.ok ? "<h2>VO login complete \u2014 you can close this tab.</h2>" : `<h2>Login failed: ${outcome.error}</h2>`);
|
|
240
|
+
finish(outcome.ok ? null : new Error(outcome.error ?? "login failed"), result);
|
|
241
|
+
})();
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
res.writeHead(404);
|
|
246
|
+
res.end("not found");
|
|
247
|
+
});
|
|
248
|
+
const timer = setTimeout(
|
|
249
|
+
() => finish(new Error(`login timed out after ${timeoutMs}ms \u2014 no sign-in captured`)),
|
|
250
|
+
timeoutMs
|
|
251
|
+
);
|
|
252
|
+
server.on("error", (e) => finish(e));
|
|
253
|
+
server.listen(0, "127.0.0.1", () => {
|
|
254
|
+
const addr = server.address();
|
|
255
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
256
|
+
if (!port) {
|
|
257
|
+
finish(new Error("failed to bind a loopback port"));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const loginUrl = `${dashboardUrl}/cli-login?port=${port}&state=${encodeURIComponent(state)}`;
|
|
261
|
+
log(`[vo-mcp] Opening your browser to sign in:
|
|
262
|
+
${loginUrl}`);
|
|
263
|
+
log("[vo-mcp] If it did not open, paste that URL into your browser. Waiting for sign-in\u2026");
|
|
264
|
+
try {
|
|
265
|
+
openBrowser(loginUrl);
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/cloud/auth-token-source.ts
|
|
273
|
+
var FIREBASE_SECURETOKEN_URL = "https://securetoken.googleapis.com/v1/token";
|
|
274
|
+
var REFRESH_SKEW_MS = 6e4;
|
|
275
|
+
function createFirebaseRefreshTokenSource(opts) {
|
|
276
|
+
const refreshToken = opts.refreshToken.trim();
|
|
277
|
+
const apiKey = opts.apiKey.trim();
|
|
278
|
+
const now = opts.now ?? (() => Date.now());
|
|
279
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
280
|
+
let cachedToken = null;
|
|
281
|
+
let expiresAtMs = 0;
|
|
282
|
+
let inFlight = null;
|
|
283
|
+
async function refresh() {
|
|
284
|
+
try {
|
|
285
|
+
const res = await fetchFn(`${FIREBASE_SECURETOKEN_URL}?key=${encodeURIComponent(apiKey)}`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
288
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
|
289
|
+
});
|
|
290
|
+
const text = await res.text();
|
|
291
|
+
if (res.status < 200 || res.status >= 300) {
|
|
292
|
+
cachedToken = null;
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const parsed = JSON.parse(text);
|
|
296
|
+
const idToken = typeof parsed.id_token === "string" ? parsed.id_token : "";
|
|
297
|
+
if (!idToken) {
|
|
298
|
+
cachedToken = null;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const expiresInSec = Number(parsed.expires_in);
|
|
302
|
+
const ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1e3 : 36e5;
|
|
303
|
+
cachedToken = idToken;
|
|
304
|
+
expiresAtMs = now() + ttlMs;
|
|
305
|
+
return idToken;
|
|
306
|
+
} catch {
|
|
307
|
+
cachedToken = null;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
kind: "firebase-refresh",
|
|
313
|
+
async getToken() {
|
|
314
|
+
if (cachedToken && now() < expiresAtMs - REFRESH_SKEW_MS) return cachedToken;
|
|
315
|
+
if (!inFlight) {
|
|
316
|
+
inFlight = refresh().finally(() => {
|
|
317
|
+
inFlight = null;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return inFlight;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/cloud/vo-credential-exchange.ts
|
|
326
|
+
async function exchangeForVoCredential(opts) {
|
|
327
|
+
const base = opts.controlPlaneUrl.replace(/\/+$/, "");
|
|
328
|
+
if (!base) return null;
|
|
329
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
330
|
+
try {
|
|
331
|
+
const idToken = await createFirebaseRefreshTokenSource({
|
|
332
|
+
refreshToken: opts.refreshToken,
|
|
333
|
+
apiKey: opts.apiKey,
|
|
334
|
+
...opts.fetchFn ? { fetchFn: opts.fetchFn } : {}
|
|
335
|
+
}).getToken();
|
|
336
|
+
if (!idToken) return null;
|
|
337
|
+
const res = await fetchFn(`${base}/api/v1/auth/vo-credential`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
340
|
+
body: JSON.stringify(opts.label ? { label: opts.label } : {})
|
|
341
|
+
});
|
|
342
|
+
if (res.status !== 200) return null;
|
|
343
|
+
const parsed = JSON.parse(await res.text());
|
|
344
|
+
const token = typeof parsed.token === "string" ? parsed.token : "";
|
|
345
|
+
const expiresAt = typeof parsed.expires_at === "string" ? parsed.expires_at : "";
|
|
346
|
+
if (!token.startsWith("vocred_") || !expiresAt) return null;
|
|
347
|
+
return { vo_credential: token, expires_at: expiresAt };
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/login-cli.ts
|
|
354
|
+
import { platform } from "node:os";
|
|
355
|
+
async function main() {
|
|
356
|
+
const env = process.env;
|
|
357
|
+
const log = (m) => console.error(m);
|
|
358
|
+
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || "https://vo-control-plane-bzjphrajaq-uc.a.run.app";
|
|
359
|
+
const exchange = async (refreshToken, apiKey) => {
|
|
360
|
+
return exchangeForVoCredential({
|
|
361
|
+
refreshToken,
|
|
362
|
+
apiKey,
|
|
363
|
+
controlPlaneUrl,
|
|
364
|
+
label: `vo-mcp login (${platform()})`
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
try {
|
|
368
|
+
const result = await runLogin({ env, log, exchange });
|
|
369
|
+
log(`\u2713 Signed in${result.email ? ` as ${result.email}` : ""}. Credential stored at: ${result.credentialPath}`);
|
|
370
|
+
log("\nNext steps:");
|
|
371
|
+
log(" 1. Start the runner: vo-mcp runner");
|
|
372
|
+
log(" 2. Dispatch agents from: https://vo-dashboard.web.app");
|
|
373
|
+
} catch (err) {
|
|
374
|
+
log(`\u2717 Login failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
main().catch((err) => {
|
|
379
|
+
console.error("[vo-mcp login] fatal:", err);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
});
|
|
382
|
+
//# sourceMappingURL=login-cli.js.map
|