diff --git a/packages/dcp-agent/package.json b/packages/dcp-agent/package.json index e41bd97..8e84d19 100644 --- a/packages/dcp-agent/package.json +++ b/packages/dcp-agent/package.json @@ -55,16 +55,19 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { - "@dcprotocol/core": "^2.0.1", "@dcprotocol/client": "^2.0.1", + "@dcprotocol/core": "^2.0.1", "@modelcontextprotocol/sdk": "^1.0.0", "@noble/curves": "^1.4.0", + "@solana/web3.js": "^1.98.0", "chalk": "^5.3.0", "commander": "^12.1.0", - "ora": "^8.0.1" + "ora": "^8.0.1", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/node": "^22.10.2", + "@types/qrcode-terminal": "^0.12.2", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^5.7.2", diff --git a/packages/dcp-agent/src/config.ts b/packages/dcp-agent/src/config.ts index cf07bbb..0d570ed 100644 --- a/packages/dcp-agent/src/config.ts +++ b/packages/dcp-agent/src/config.ts @@ -13,7 +13,7 @@ import { generateSigningKeyPair, type SignedPairingGrant, } from '@dcprotocol/core'; -import { AgentConfig, AgentError } from './types.js'; +import { AgentConfig, AgentError, type MobilePairingApprovalStatus, type MobilePendingConfig } from './types.js'; // ============================================================================ // Constants @@ -151,6 +151,56 @@ export function saveConfig(config: AgentConfig): void { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); } +/** + * Save pending mobile pairing material. + * + * This is not a usable AgentConfig yet. The mobile vault must approve the + * invite and return vault identity before this can be promoted to a runtime + * agent config. + */ +export function saveMobilePendingConfig(config: MobilePendingConfig): void { + ensureConfigDir(); + const safeInviteId = config.invite_id.replace(/[^a-zA-Z0-9_-]/g, '_'); + const configPath = path.join(CONFIG_DIR, `${safeInviteId}.mobile-pending.json`); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); +} + +export function promoteMobilePendingConfig( + pending: MobilePendingConfig, + approval: MobilePairingApprovalStatus +): AgentConfig { + if ( + approval.status !== 'approved' || + !approval.agent_id || + !approval.vault_id || + !approval.vault_hpke_public_key || + !approval.vault_signing_public_key + ) { + throw new AgentError('INVALID_GRANT', 'Mobile pairing approval is incomplete'); + } + + const config: AgentConfig = { + agent_id: approval.agent_id, + agent_name: pending.invite.agent_name, + vault_id: approval.vault_id, + mode: 'mcp', + vault_hpke_public_key: approval.vault_hpke_public_key, + vault_signing_public_key: approval.vault_signing_public_key, + relay_url: pending.invite.relay_url, + service_keypair: pending.service_keypair, + paired_at: new Date().toISOString(), + grant_expires_at: pending.invite.expires_at, + }; + + saveConfig(config); + const safeInviteId = pending.invite_id.replace(/[^a-zA-Z0-9_-]/g, '_'); + const pendingPath = path.join(CONFIG_DIR, `${safeInviteId}.mobile-pending.json`); + if (fs.existsSync(pendingPath)) { + fs.unlinkSync(pendingPath); + } + return config; +} + /** * Load agent configuration * diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index e15b89a..48f328b 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -21,13 +21,18 @@ import chalk from 'chalk'; import ora, { type Ora } from 'ora'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import qrcode from 'qrcode-terminal'; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; import { parseAndVerifyGrant, createConfigFromGrant, exchangePairingGrant, saveConfig, + saveMobilePendingConfig, + promoteMobilePendingConfig, loadConfig, listConfigs, deleteConfig, @@ -35,16 +40,25 @@ import { findAgent, } from './config.js'; import { AgentProxy } from './proxy.js'; +import { AgentConnection } from './connection.js'; import { runMcpServer } from './mcp.js'; import { runHttpMcpServer } from './http-mcp.js'; import { AgentError } from './types.js'; import { processSecretsRequest, fetchSecret, fetchSecrets } from './secrets.js'; +import { createMobilePairingInvite, publishMobilePairingInvite, waitForMobilePairingApproval } from './mobile-pairing.js'; import { configureOpenClawCommand, installServiceCommand, uninstallServiceCommand, } from './commands/install-service.js'; -import type { PairOptions, RunOptions, StatusOptions } from './types.js'; +import type { + MobileAgentClient, + MobileAgentEnvironment, + MobileDcpScope, + PairOptions, + RunOptions, + StatusOptions, +} from './types.js'; // ============================================================================ // Helpers @@ -201,11 +215,408 @@ async function pairCommand(grantToken: string, options: PairOptions): Promise = { + 'claude-desktop': 'Claude Desktop', + cursor: 'Cursor', + vscode: 'VS Code', + hermes: 'Hermes', + openclaw: 'OpenClaw', + mcp: 'MCP Agent', + custom: 'Custom Agent', + hosted: 'Hosted Agent', + }; + return names[client] || 'DCP Agent'; +} + +function mcpServerConfig(agentId: string): { command: string; args: string[] } { + return { + command: 'npx', + args: ['-y', '@dcprotocol/agent', 'run', '--mode', 'mcp', '--agent', agentId, '--force-relay'], + }; +} + +function claudeDesktopConfigPath(): string | null { + const home = os.homedir(); + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + return appData ? path.join(appData, 'Claude', 'claude_desktop_config.json') : null; + } + return path.join(home, '.config', 'Claude', 'claude_desktop_config.json'); +} + +function readJsonFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return raw.trim() ? JSON.parse(raw) as Record : {}; + } catch { + throw new Error(`Could not parse existing MCP config: ${filePath}`); + } +} + +function upsertClaudeDesktopMcp(agentId: string): string { + const configPath = claudeDesktopConfigPath(); + if (!configPath) { + throw new Error('Could not locate Claude Desktop config path on this OS'); + } + + const config = readJsonFile(configPath); + const mcpServers = config.mcpServers && typeof config.mcpServers === 'object' + ? config.mcpServers as Record + : {}; + mcpServers.dcp = mcpServerConfig(agentId); + config.mcpServers = mcpServers; + + fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + return configPath; +} + +function printMcpInstallHelp(client: MobileAgentClient, agentId: string): void { + const serverConfig = mcpServerConfig(agentId); + if (client === 'cursor') { + const deepLink = `cursor://anysphere.cursor-deeplink/mcp/install?name=dcp&config=${Buffer.from(JSON.stringify(serverConfig)).toString('base64')}`; + console.log(); + console.log(chalk.bold('Cursor MCP install link:')); + console.log(deepLink); + return; + } + + console.log(); + console.log(chalk.bold('MCP config:')); + console.log(JSON.stringify({ mcpServers: { dcp: serverConfig } }, null, 2)); +} + +function configureMcpForClient(client: MobileAgentClient, agentId: string): void { + if (client === 'claude-desktop') { + const configPath = upsertClaudeDesktopMcp(agentId); + success('Claude Desktop MCP config updated'); + console.log(` ${dim('Config:')} ${configPath}`); + console.log(dim('Restart Claude Desktop to load the DCP mobile-backed server.')); + return; + } + + printMcpInstallHelp(client, agentId); +} + +async function mobilePairCommand(options: MobilePairOptions): Promise { + try { + const client = options.client || 'custom'; + const environment = options.environment || (client === 'hermes' || client === 'openclaw' ? 'vps' : 'local'); + const scopes = (options.scope?.length ? options.scope : []) as MobileDcpScope[]; + const daily = parseNumberOption(options.dailyBudget, 0, '--daily-budget'); + const approvalThreshold = parseNumberOption(options.approvalThreshold, 0, '--approval-threshold'); + const ttlSeconds = parseNumberOption(options.ttlSeconds, 10 * 60, '--ttl-seconds'); + + const created = createMobilePairingInvite({ + client, + environment, + agentName: options.name || defaultAgentName(client), + agentId: options.agentId, + relayUrl: options.relayUrl, + requestedScopes: scopes, + requestedBudget: { + daily, + currency: options.currency || 'SOL', + approval_threshold: approvalThreshold, + }, + ttlSeconds, + }); + + await publishMobilePairingInvite(created.invite); + saveMobilePendingConfig(created.pendingConfig); + + if (options.json) { + console.log(JSON.stringify({ + invite: created.invite, + invite_url: created.inviteUrl, + short_invite_url: created.shortInviteUrl, + }, null, 2)); + return; + } + + console.log(); + console.log(chalk.bold('DCP Mobile Pairing')); + console.log(dim('Scan this QR with DCP Mobile, or paste the pairing URL into the app.')); + console.log(); + + if (!options.noQr) { + qrcode.generate(created.shortInviteUrl, { small: !options.largeQr }); + console.log(); + } + + console.log(` ${dim('Agent:')} ${created.invite.agent_name}`); + console.log(` ${dim('Agent ID:')} ${created.invite.requested_agent_id}`); + console.log(` ${dim('Client:')} ${created.invite.agent_client}`); + console.log(` ${dim('Environment:')} ${created.invite.environment}`); + console.log(` ${dim('Invite ID:')} ${created.invite.invite_id}`); + console.log(` ${dim('Expires:')} ${created.invite.expires_at}`); + console.log(); + console.log(chalk.bold('Pairing URL:')); + console.log(created.shortInviteUrl); + console.log(dim('Full signed invite is stored on relay and verified by the mobile app.')); + console.log(); + console.log(dim('Pending agent key material was saved locally with 0600 permissions.')); + console.log(dim('The mobile vault must approve the invite before this agent can request DCP actions.')); + + if (options.wait) { + const waitSeconds = parseNumberOption(options.waitSeconds, ttlSeconds, '--wait-seconds'); + console.log(); + console.log(dim(`Waiting up to ${waitSeconds}s for DCP Mobile approval...`)); + const status = await waitForMobilePairingApproval( + created.invite.relay_url, + created.invite.invite_id, + { timeoutMs: waitSeconds * 1000 } + ); + + if (status.status !== 'approved') { + error(`Mobile pairing ${status.status}${status.error ? `: ${status.error}` : ''}`); + process.exit(1); + } + + success('Mobile pairing approved'); + console.log(` ${dim('Agent ID:')} ${status.agent_id}`); + console.log(` ${dim('Vault ID:')} ${status.vault_id}`); + const config = promoteMobilePendingConfig(created.pendingConfig, status); + console.log(dim(`Saved agent config: ${config.agent_id}`)); + if (options.configureMcp) { + configureMcpForClient(client, config.agent_id); + } else { + console.log(dim(`Next: run "dcp-agent run --agent ${config.agent_id} --mode mcp --force-relay" from your MCP host.`)); + } + } + } catch (err) { + error(err instanceof Error ? err.message : 'Failed to create mobile pairing invite'); + process.exit(1); + } +} + +async function mobileInstallCommand(options: MobilePairOptions): Promise { + await mobilePairCommand({ + ...options, + wait: true, + configureMcp: options.configureMcp ?? true, + }); +} + function getAgentDataDir(): string { const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; return path.join(homeDir, '.dcp', 'agents'); } +function loadAgentForCommand(agentIdOrName?: string) { + if (agentIdOrName) { + const config = findAgent(agentIdOrName); + if (!config) { + error(`Agent not found: ${agentIdOrName}`); + console.log(dim('Available agents:')); + listConfigs().forEach((c) => console.log(dim(` - ${c.agent_name} (${c.agent_id})`))); + process.exit(1); + } + return config; + } + + const config = getDefaultAgent(); + if (!config) { + error('No agent configured. Run "dcp-agent mobile pair --wait" first.'); + process.exit(1); + } + return config; +} + +function buildSmokeTransferTx(fromAddress: string, destinationAddress: string, amountSol: number): string { + if (!Number.isFinite(amountSol) || amountSol <= 0) { + throw new Error('--sign-tx-amount must be a positive SOL amount'); + } + + const fromPubkey = new PublicKey(fromAddress); + const toPubkey = new PublicKey(destinationAddress); + const lamports = Math.max(1, Math.round(amountSol * LAMPORTS_PER_SOL)); + const tx = new Transaction({ + feePayer: fromPubkey, + recentBlockhash: Keypair.generate().publicKey.toBase58(), + }).add( + SystemProgram.transfer({ + fromPubkey, + toPubkey, + lamports, + }) + ); + + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + +async function smokeCommand(options: SmokeOptions): Promise { + const config = loadAgentForCommand(options.agent); + const connection = new AgentConnection(config, { forceRelay: options.forceRelay ?? true }); + + try { + if (!options.json) { + console.log(); + console.log(chalk.bold('DCP Agent Smoke Test')); + console.log(dim('─'.repeat(50))); + console.log(` ${dim('Agent:')} ${config.agent_name} (${config.agent_id})`); + console.log(` ${dim('Vault:')} ${config.vault_id}`); + console.log(` ${dim('Relay:')} ${config.relay_url}`); + console.log(); + } + + await connection.connect(); + const result: Record = {}; + + const needsAddress = !options.skipAddress || Boolean(options.signTxAmount); + const address = needsAddress ? await connection.getAddress('solana') : null; + if (address) { + result.address = address; + + if (!options.json) { + success(`Wallet address: ${address.address}`); + } + } + + if (options.signMessage) { + if (!options.json) { + console.log(dim('Waiting for mobile approval of sign_message...')); + } + const signed = await connection.signMessage({ + chain: 'solana', + message: options.signMessage, + encoding: 'utf8', + description: 'DCP mobile smoke test', + }); + result.signature = signed; + if (!options.json) { + success(`Signature: ${signed.signature}`); + } + } + + if (options.signTxAmount) { + if (!address) { + throw new Error('Wallet address is required to build the smoke transaction'); + } + const amount = parseNumberOption(options.signTxAmount, Number.NaN, '--sign-tx-amount'); + const currency = (options.signTxCurrency || 'SOL').toUpperCase(); + if (currency !== 'SOL') { + throw new Error('--sign-tx-currency currently supports SOL for generated smoke transactions'); + } + const destination = options.signTxDestination || Keypair.generate().publicKey.toBase58(); + const unsignedTx = buildSmokeTransferTx(address.address, destination, amount); + + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_sign_tx ${amount} SOL...`)); + console.log(dim(`Destination: ${destination}`)); + } + + const signedTx = await connection.signTx({ + chain: 'solana', + unsignedTx, + amount, + currency, + destination, + description: `DCP mobile smoke test transfer (${amount} SOL)`, + idempotencyKey: `smoke-${Date.now()}`, + }); + result.sign_tx = signedTx; + if (!options.json) { + success(`Transaction signed: ${signedTx.signature}`); + if (signedTx.remaining_daily !== undefined) { + success(`Remaining daily budget: ${signedTx.remaining_daily} ${currency}`); + } + } + } + + if (options.writeScope) { + if (!options.writeValue) { + throw new Error('--write-value is required with --write-scope'); + } + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_write ${options.writeScope}...`)); + } + const written = await connection.writeCredential(options.writeScope, { + name: options.writeName || options.writeScope.split('.').at(-1) || options.writeScope, + value: options.writeValue, + }); + result.write = written; + if (!options.json) { + success(`Write approved: ${written.scope}`); + } + } + + if (options.readScope) { + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_read ${options.readScope}...`)); + } + const read = await connection.readCredential(options.readScope); + result.read = read; + if (!options.json) { + const keys = read.data ? Object.keys(read.data).join(', ') : 'no data'; + success(`Read approved: ${read.scope} (${keys})`); + } + } + + await connection.close(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } + } catch (err) { + await connection.close().catch(() => undefined); + if (options.json) { + console.log(JSON.stringify({ error: err instanceof Error ? err.message : 'Smoke test failed' }, null, 2)); + } else { + error(err instanceof Error ? err.message : 'Smoke test failed'); + } + process.exit(1); + } +} + async function runCommand(options: RunOptions): Promise { // Get agent config let config; @@ -550,6 +961,52 @@ program .option('-n, --name ', 'Override agent name') .action(pairCommand); +const mobileCommand = program + .command('mobile') + .description('DCP Mobile pairing commands'); + +mobileCommand + .command('pair') + .description('Create a DCP Mobile pairing invite') + .option('--client ', 'Agent client: claude-desktop, cursor, vscode, hermes, openclaw, mcp, custom, hosted', 'custom') + .option('--environment ', 'Environment: local, vps, hosted, dev') + .option('-n, --name ', 'Agent display name') + .option('--agent-id ', 'Agent ID to request. Defaults to canonical DCP ID for known clients.') + .option('--relay-url ', 'Mobile relay API/base URL') + .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) + .option('--daily-budget ', 'Requested daily budget', '0') + .option('--currency ', 'Budget currency: SOL, USDC, or 1LY', 'SOL') + .option('--approval-threshold ', 'Auto-approval threshold', '0') + .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') + .option('--json', 'Print machine-readable JSON') + .option('--no-qr', 'Do not print terminal QR') + .option('--large-qr', 'Print a larger terminal QR for difficult scanners') + .option('--wait', 'Wait until DCP Mobile approves or denies the pairing') + .option('--wait-seconds ', 'How long --wait should poll for approval') + .option('--configure-mcp', 'After approval, configure the local MCP client when supported') + .action(mobilePairCommand); + +mobileCommand + .command('install') + .description('Pair DCP Mobile and configure this MCP client when supported') + .option('--client ', 'Agent client: claude-desktop, cursor, vscode, hermes, openclaw, mcp, custom, hosted', 'claude-desktop') + .option('--environment ', 'Environment: local, vps, hosted, dev') + .option('-n, --name ', 'Agent display name') + .option('--agent-id ', 'Agent ID to request. Defaults to canonical DCP ID for known clients.') + .option('--relay-url ', 'Mobile relay API/base URL') + .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) + .option('--daily-budget ', 'Requested daily budget', '0') + .option('--currency ', 'Budget currency: SOL, USDC, or 1LY', 'SOL') + .option('--approval-threshold ', 'Auto-approval threshold', '0') + .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') + .option('--json', 'Print machine-readable JSON') + .option('--no-qr', 'Do not print terminal QR') + .option('--large-qr', 'Print a larger terminal QR for difficult scanners') + .option('--wait-seconds ', 'How long to wait for approval') + .option('--configure-mcp', 'Configure the local MCP client when supported', true) + .option('--no-configure-mcp', 'Do not write MCP client config; print next steps only') + .action(mobileInstallCommand); + program .command('run') .description('Run the agent') @@ -562,6 +1019,23 @@ program .option('--force-relay', 'Force relay mode (skip local vault detection)') .action(runCommand); +program + .command('smoke') + .description('Test a paired agent against the vault/relay without configuring Claude') + .option('-a, --agent ', 'Agent ID or name to test (default: first configured)') + .option('--sign-message ', 'Request a Solana message signature for login/auth challenge testing') + .option('--sign-tx-amount ', 'Generate and request signing for a smoke-test SOL transfer amount') + .option('--sign-tx-currency ', 'Currency for --sign-tx-amount (currently SOL)', 'SOL') + .option('--sign-tx-destination
', 'Destination public key for generated smoke-test transfer') + .option('--read-scope ', 'Also request a mobile-approved vault_read for a scope, e.g. credentials.api.openai') + .option('--write-scope ', 'Also request a mobile-approved vault_write for a scope, e.g. credentials.api.openai') + .option('--write-name ', 'Display name to store with --write-scope') + .option('--write-value ', 'Secret value to store with --write-scope') + .option('--skip-address', 'Skip the initial wallet address check') + .option('--force-relay', 'Force relay mode (default for smoke)', true) + .option('-j, --json', 'Output as JSON') + .action(smokeCommand); + program .command('status') .description('Show agent status') diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts new file mode 100644 index 0000000..a954131 --- /dev/null +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto'; +import { canonicalJson, signMessage, generateSigningKeyPair } from '@dcprotocol/core'; +import type { + MobileAgentClient, + MobileAgentEnvironment, + MobileDcpScope, + MobilePairingApprovalStatus, + MobilePairingBudget, + MobilePairingInvite, + MobilePendingConfig, +} from './types.js'; + +export const MOBILE_PAIRING_TYPE = 'dcp_agent_pairing'; +export const MOBILE_PAIRING_VERSION = '1.0'; +export const DEFAULT_MOBILE_RELAY_URL = 'https://relay.dcp.1ly.store'; + +const SUPPORTED_CLIENTS = new Set([ + 'claude-desktop', + 'cursor', + 'vscode', + 'hermes', + 'openclaw', + 'mcp', + 'custom', + 'hosted', +]); + +const SUPPORTED_ENVIRONMENTS = new Set([ + 'local', + 'vps', + 'hosted', + 'dev', +]); + +const SUPPORTED_SCOPES = new Set([ + 'read:wallet.address', + 'sign:solana', + 'vault_get_address', + 'vault_budget_check', + 'vault_sign_tx', + 'vault_sign_message', +]); + +function isSupportedMobileScope(scope: string): scope is MobileDcpScope { + return ( + SUPPORTED_SCOPES.has(scope as MobileDcpScope) || + scope.startsWith('read:credentials.api.') || + scope.startsWith('write:credentials.api.') + ); +} + +export interface CreateMobilePairingInviteInput { + client: MobileAgentClient; + environment: MobileAgentEnvironment; + agentName: string; + agentId?: string; + relayUrl?: string; + requestedScopes?: MobileDcpScope[]; + requestedBudget?: MobilePairingBudget; + ttlSeconds?: number; +} + +export interface CreatedMobilePairingInvite { + invite: MobilePairingInvite; + inviteUrl: string; + shortInviteUrl: string; + pendingConfig: MobilePendingConfig; +} + +export { canonicalJson }; + +function encodeMobilePairingInvite(invite: MobilePairingInvite): string { + return `dcp://pair?invite=${encodeURIComponent(JSON.stringify(invite))}`; +} + +function encodeShortMobilePairingInvite(invite: MobilePairingInvite): string { + const params = new URLSearchParams({ + relay: invite.relay_url, + invite_id: invite.invite_id, + }); + return `dcp://pair?${params.toString()}`; +} + +export function canonicalMobileAgentId(client: MobileAgentClient): string { + const ids: Record = { + 'claude-desktop': 'agent_claude_desktop', + cursor: 'agent_cursor', + vscode: 'agent_vscode', + hermes: 'agent_hermes_local', + openclaw: 'agent_openclaw_local', + mcp: 'agent_local_mcp', + custom: `agent_mobile_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + hosted: `agent_hosted_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }; + return ids[client]; +} + +function assertSupported(input: CreateMobilePairingInviteInput): void { + if (!SUPPORTED_CLIENTS.has(input.client)) { + throw new Error(`Unsupported mobile agent client: ${input.client}`); + } + if (!SUPPORTED_ENVIRONMENTS.has(input.environment)) { + throw new Error(`Unsupported mobile agent environment: ${input.environment}`); + } + for (const scope of input.requestedScopes || []) { + if (!isSupportedMobileScope(scope)) { + throw new Error(`Unsupported mobile MVP scope: ${scope}`); + } + } +} + +export function createMobilePairingInvite( + input: CreateMobilePairingInviteInput +): CreatedMobilePairingInvite { + assertSupported(input); + + const keypair = generateSigningKeyPair(); + const createdAt = new Date(); + const ttlSeconds = input.ttlSeconds ?? 10 * 60; + const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000); + const inviteId = `mob_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const requestedAgentId = input.agentId?.trim() || canonicalMobileAgentId(input.client); + const requestedBudget = input.requestedBudget ?? { + daily: 0, + currency: 'SOL', + approval_threshold: 0, + }; + + const unsignedInvite: Omit = { + type: MOBILE_PAIRING_TYPE, + version: MOBILE_PAIRING_VERSION, + relay_url: (input.relayUrl || process.env.DCP_MOBILE_RELAY_URL || DEFAULT_MOBILE_RELAY_URL).replace(/\/$/, ''), + invite_id: inviteId, + requested_agent_id: requestedAgentId, + agent_public_key: keypair.publicKey.toString('base64'), + agent_name: input.agentName.trim(), + agent_client: input.client, + environment: input.environment, + requested_scopes: input.requestedScopes ?? [], + requested_budget: requestedBudget, + created_at: createdAt.toISOString(), + expires_at: expiresAt.toISOString(), + nonce: randomUUID(), + }; + + const signature = Buffer.from( + signMessage(Buffer.from(canonicalJson(unsignedInvite), 'utf8'), keypair.privateKey) + ).toString('base64'); + + const invite: MobilePairingInvite = { + ...unsignedInvite, + signature, + }; + const inviteUrl = encodeMobilePairingInvite(invite); + const shortInviteUrl = encodeShortMobilePairingInvite(invite); + + return { + invite, + inviteUrl, + shortInviteUrl, + pendingConfig: { + invite_id: inviteId, + invite_url: inviteUrl, + invite, + service_keypair: { + public: keypair.publicKey.toString('base64'), + private: keypair.privateKey.toString('base64'), + }, + pairing_status: 'pending_mobile_approval', + created_at: createdAt.toISOString(), + }, + }; +} + +export async function publishMobilePairingInvite(invite: MobilePairingInvite): Promise { + const response = await fetch(`${invite.relay_url.replace(/\/$/, '')}/v1/mobile/pairings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Failed to publish mobile pairing invite (${response.status})${text ? `: ${text}` : ''}`); + } +} + +export async function getMobilePairingStatus( + relayUrl: string, + inviteId: string +): Promise { + const response = await fetch( + `${relayUrl.replace(/\/$/, '')}/v1/mobile/pairings/${encodeURIComponent(inviteId)}/status` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch mobile pairing status (${response.status})`); + } + + return (await response.json()) as MobilePairingApprovalStatus; +} + +export async function waitForMobilePairingApproval( + relayUrl: string, + inviteId: string, + options: { timeoutMs?: number; pollIntervalMs?: number } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000; + const pollIntervalMs = options.pollIntervalMs ?? 2000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const status = await getMobilePairingStatus(relayUrl, inviteId); + if (status.status !== 'pending') { + return status; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return { + status: 'expired', + invite_id: inviteId, + error: 'Timed out waiting for mobile approval', + }; +} diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index 34ba059..a9f492f 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -102,6 +102,76 @@ export interface VpsPendingConfig { agent_name?: string; } +export type MobileAgentClient = + | 'claude-desktop' + | 'cursor' + | 'vscode' + | 'hermes' + | 'openclaw' + | 'mcp' + | 'custom' + | 'hosted'; + +export type MobileAgentEnvironment = 'local' | 'vps' | 'hosted' | 'dev'; + +export type MobileDcpScope = + | 'read:wallet.address' + | 'sign:solana' + | 'vault_get_address' + | 'vault_budget_check' + | 'vault_sign_tx' + | 'vault_sign_message' + | `read:credentials.api.${string}` + | `write:credentials.api.${string}`; + +export interface MobilePairingBudget { + daily: number; + currency: 'SOL' | 'USDC' | '1LY'; + approval_threshold: number; +} + +export interface MobilePairingInvite { + type: 'dcp_agent_pairing'; + version: '1.0'; + relay_url: string; + invite_id: string; + requested_agent_id: string; + agent_public_key: string; + agent_name: string; + agent_client: MobileAgentClient; + environment: MobileAgentEnvironment; + requested_scopes: MobileDcpScope[]; + requested_budget: MobilePairingBudget; + created_at: string; + expires_at: string; + nonce: string; + signature?: string; +} + +export interface MobilePendingConfig { + invite_id: string; + invite_url: string; + invite: MobilePairingInvite; + service_keypair: { + public: string; + private: string; + }; + pairing_status: 'pending_mobile_approval'; + created_at: string; +} + +export interface MobilePairingApprovalStatus { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'not_found'; + invite_id?: string; + agent_id?: string; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + error?: string; +} + /** * Check if a config is a VPS pending config (not yet approved) */ diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts new file mode 100644 index 0000000..459e363 --- /dev/null +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { verifySignature } from '@dcprotocol/core'; +import { canonicalJson, createMobilePairingInvite } from '../src/mobile-pairing.js'; + +function canonicalInvitePayload(invite: Record): string { + const { signature: _signature, ...unsigned } = invite; + return canonicalJson(unsigned); +} + +describe('mobile pairing', () => { + it('creates a signed DCP Mobile pairing invite URL', () => { + const { invite, inviteUrl, pendingConfig } = createMobilePairingInvite({ + client: 'claude-desktop', + environment: 'local', + agentName: 'Claude Desktop', + relayUrl: 'https://relay.example.test/', + requestedScopes: ['read:wallet.address', 'sign:solana'], + requestedBudget: { + daily: 5, + currency: 'USDC', + approval_threshold: 0.01, + }, + ttlSeconds: 300, + }); + + expect(invite.type).toBe('dcp_agent_pairing'); + expect(invite.version).toBe('1.0'); + expect(invite.relay_url).toBe('https://relay.example.test'); + expect(invite.requested_agent_id).toBe('agent_claude_desktop'); + expect(invite.agent_client).toBe('claude-desktop'); + expect(invite.environment).toBe('local'); + expect(invite.requested_scopes).toEqual(['read:wallet.address', 'sign:solana']); + expect(invite.signature).toBeTruthy(); + expect(inviteUrl.startsWith('dcp://pair?invite=')).toBe(true); + expect(pendingConfig.service_keypair.private).toBeTruthy(); + expect(pendingConfig.invite_id).toBe(invite.invite_id); + }); + + it('signs the invite with the generated agent public key', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Test Agent', + agentId: 'agent_custom_test', + }); + + expect(invite.requested_agent_id).toBe('agent_custom_test'); + expect(invite.requested_scopes).toEqual([]); + + const valid = verifySignature( + Buffer.from(canonicalInvitePayload(invite), 'utf8'), + Buffer.from(invite.signature!, 'base64'), + Buffer.from(invite.agent_public_key, 'base64') + ); + + expect(valid).toBe(true); + }); + + it('covers nested budget fields in the invite signature', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Budget Integrity Agent', + requestedBudget: { + daily: 1, + currency: 'USDC', + approval_threshold: 0, + }, + }); + + const tampered = { + ...invite, + requested_budget: { + ...invite.requested_budget, + daily: 999, + }, + }; + + const valid = verifySignature( + Buffer.from(canonicalInvitePayload(tampered), 'utf8'), + Buffer.from(invite.signature!, 'base64'), + Buffer.from(invite.agent_public_key, 'base64') + ); + + expect(valid).toBe(false); + }); + + it('allows mobile API credential read and write scopes', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Credential Agent', + requestedScopes: ['read:credentials.api.openai', 'write:credentials.api.openai'], + }); + + expect(invite.requested_scopes).toEqual(['read:credentials.api.openai', 'write:credentials.api.openai']); + }); + + it('rejects unsupported MVP scopes', () => { + expect(() => + createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Bad Scope Agent', + requestedScopes: ['read:api.key' as never], + }) + ).toThrow('Unsupported mobile MVP scope'); + }); +}); diff --git a/packages/dcp-client/src/relay-transport.ts b/packages/dcp-client/src/relay-transport.ts index 9be1493..e102821 100644 --- a/packages/dcp-client/src/relay-transport.ts +++ b/packages/dcp-client/src/relay-transport.ts @@ -401,6 +401,8 @@ export class RelayTransport implements Transport { const data = response as { scope?: string; data?: Record; + name?: string; + value?: unknown; sensitivity?: 'standard' | 'sensitive' | 'critical'; is_reference?: boolean; requires_consent?: boolean; @@ -428,7 +430,7 @@ export class RelayTransport implements Transport { return { scope: data.scope || scope, - data: data.data ?? null, + data: data.data ?? (data.value !== undefined ? { name: data.name, value: data.value } : null), sensitivity: data.sensitivity, isReference: data.is_reference, sessionId: data.session_id, @@ -445,9 +447,19 @@ export class RelayTransport implements Transport { ): Promise { const sessionId = this.getSessionId(scope); + const secretValue = typeof data.value === 'string' + ? data.value + : typeof data.api_key === 'string' + ? data.api_key + : typeof data.token === 'string' + ? data.token + : undefined; + const params = { scope, data, + name: typeof data.name === 'string' ? data.name : undefined, + value: secretValue, session_id: sessionId, }; @@ -457,6 +469,7 @@ export class RelayTransport implements Transport { scope?: string; created?: boolean; updated?: boolean; + ok?: boolean; sensitivity?: 'standard' | 'sensitive' | 'critical'; requires_consent?: boolean; consent_id?: string; @@ -483,7 +496,7 @@ export class RelayTransport implements Transport { return { scope: result.scope || scope, - created: result.created ?? false, + created: result.created ?? result.ok === true, updated: result.updated ?? false, sensitivity: result.sensitivity, sessionId: result.session_id, diff --git a/packages/dcp-relay/src/index.ts b/packages/dcp-relay/src/index.ts index 8c52bbc..3eec219 100644 --- a/packages/dcp-relay/src/index.ts +++ b/packages/dcp-relay/src/index.ts @@ -21,7 +21,7 @@ // ============================================================================ export { RelayServer } from './relay.js'; -export { MessageStore, ConnectionStore, RateLimiter } from './store.js'; +export { MessageStore, ConnectionStore, RateLimiter, MobilePairingStore, PushTokenStore } from './store.js'; export { authenticateRegistration, verifyRegistrationSignature, @@ -43,11 +43,20 @@ export { type LongPollResponse, type StoredMessage, type VaultConnection, + type PushTokenRegistration, type RelayErrorCode, type PairingClaim, type StoredPairingClaim, type PairingClaimResponse, type PairingApprovalStatus, + type MobileAgentClient, + type MobileAgentEnvironment, + type MobileDcpScope, + type MobilePairingBudget, + type MobilePairingInvite, + type MobilePairingApprovalRequest, + type MobilePairingRecord, + type MobilePairingStatus, // Constants RELAY_VERSION, @@ -76,6 +85,7 @@ interface RelayCliOptions { host: string; debug: boolean; rateLimitPerMinute: number; + expoPushUrl: string; } function parseArgs(): RelayCliOptions { @@ -86,6 +96,7 @@ function parseArgs(): RelayCliOptions { let host = process.env.DCP_RELAY_HOST || DEFAULT_RELAY_CONFIG.host; let debug = process.env.DCP_RELAY_DEBUG === 'true' || false; let rateLimitPerMinute = parseInt(process.env.DCP_RELAY_RATE_LIMIT || '', 10) || DEFAULT_RELAY_CONFIG.rateLimitPerMinute; + let expoPushUrl = process.env.DCP_EXPO_PUSH_URL ?? DEFAULT_RELAY_CONFIG.expoPushUrl; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -97,6 +108,8 @@ function parseArgs(): RelayCliOptions { debug = true; } else if (arg === '--rate-limit' || arg === '-r') { rateLimitPerMinute = parseInt(args[++i], 10) || rateLimitPerMinute; + } else if (arg === '--no-push') { + expoPushUrl = ''; } else if (arg === '--help') { console.log(` DCP Relay - Encrypted message bus for cloud MCP clients @@ -116,6 +129,7 @@ Environment Variables: DCP_RELAY_HOST Host to bind to DCP_RELAY_DEBUG Enable debug logging (true/false) DCP_RELAY_RATE_LIMIT Max requests per vault per minute + DCP_EXPO_PUSH_URL Expo push API endpoint; set empty with --no-push to disable Examples: dcp-relay # Start on default port @@ -127,11 +141,11 @@ Examples: } } - return { port, host, debug, rateLimitPerMinute }; + return { port, host, debug, rateLimitPerMinute, expoPushUrl }; } async function main(): Promise { - const { port, host, debug, rateLimitPerMinute } = parseArgs(); + const { port, host, debug, rateLimitPerMinute, expoPushUrl } = parseArgs(); const relay = new RelayServer({ port, @@ -139,6 +153,7 @@ async function main(): Promise { debug, enableLongPoll: true, rateLimitPerMinute, + expoPushUrl, }); // Handle shutdown diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index e11ee6f..0c07637 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -35,13 +35,16 @@ import type { PairingClaimResponse, PairingApprovalStatus, StoredPairingClaim, + MobilePairingApprovalRequest, + MobilePairingStatus, + MobilePairingInvite, } from './types.js'; import { RelayError, DEFAULT_RELAY_CONFIG, RELAY_VERSION, } from './types.js'; -import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore } from './store.js'; +import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore, MobilePairingStore, PushTokenStore } from './store.js'; import { authenticateRegistration, authenticateRequest, closeAuth, type AuthConfig } from './auth.js'; // ============================================================================ @@ -54,6 +57,8 @@ export class RelayServer { private connectionStore: ConnectionStore; private rateLimiter: RateLimiter; private pairingClaimStore: PairingClaimStore; + private mobilePairingStore: MobilePairingStore; + private pushTokenStore: PushTokenStore; private config: RelayConfig; private authConfig: AuthConfig; private heartbeatInterval: ReturnType | null = null; @@ -74,6 +79,8 @@ export class RelayServer { this.config.rateLimitWindowMs ); this.pairingClaimStore = new PairingClaimStore(); + this.mobilePairingStore = new MobilePairingStore(); + this.pushTokenStore = new PushTokenStore(); this.server = Fastify({ logger: this.config.debug @@ -135,6 +142,7 @@ export class RelayServer { this.messageStore.close(); this.rateLimiter.close(); this.pairingClaimStore.close(); + this.mobilePairingStore.close(); closeAuth(); await this.server.close(); } @@ -157,6 +165,8 @@ export class RelayServer { ...this.connectionStore.getStats(), rateLimit: this.rateLimiter.getStats(), pairingClaims: this.pairingClaimStore.getStats(), + mobilePairings: this.mobilePairingStore.getStats(), + push: this.pushTokenStore.getStats(), timestamp: new Date().toISOString(), })); @@ -168,6 +178,8 @@ export class RelayServer { const connectionStats = this.connectionStore.getStats(); const rateLimitStats = this.rateLimiter.getStats(); const pairingStats = this.pairingClaimStore.getStats(); + const mobilePairingStats = this.mobilePairingStore.getStats(); + const pushStats = this.pushTokenStore.getStats(); const format = request.query.format; @@ -200,6 +212,12 @@ export class RelayServer { '# HELP dcp_relay_pairing_claims_pending Pending pairing claims', '# TYPE dcp_relay_pairing_claims_pending gauge', `dcp_relay_pairing_claims_pending ${pairingStats.pendingClaims}`, + '# HELP dcp_relay_mobile_pairings_total Total mobile pairings', + '# TYPE dcp_relay_mobile_pairings_total gauge', + `dcp_relay_mobile_pairings_total ${mobilePairingStats.totalMobilePairings}`, + '# HELP dcp_relay_push_tokens_registered Registered mobile push tokens', + '# TYPE dcp_relay_push_tokens_registered gauge', + `dcp_relay_push_tokens_registered ${pushStats.registeredPushTokens}`, '', ]; reply.header('Content-Type', 'text/plain; charset=utf-8'); @@ -212,6 +230,8 @@ export class RelayServer { connections: connectionStats, rateLimit: rateLimitStats, pairingClaims: pairingStats, + mobilePairings: mobilePairingStats, + push: pushStats, websockets: { vaultConnections: this.wsConnections.size, clientConnections: this.clientSockets.size, @@ -310,6 +330,134 @@ export class RelayServer { return reply.send({ success: true }); } ); + + // ======================================================================== + // DCP Mobile Pairing Routes (Agent QR → Mobile approval → Agent polling) + // ======================================================================== + + this.server.post<{ + Body: { invite?: MobilePairingInvite }; + }>( + '/v1/mobile/pairings', + async (request, reply) => { + const invite = request.body?.invite; + if (!invite || typeof invite !== 'object') { + return reply.status(400).send({ success: false, error: 'Missing pairing invite' }); + } + const inviteError = this.validateMobileInvite(invite); + if (inviteError) { + return reply.status(400).send({ success: false, error: inviteError }); + } + const record = this.mobilePairingStore.registerInvite(invite); + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + }); + } + ); + + this.server.get<{ Params: { inviteId: string } }>( + '/v1/mobile/pairings/:inviteId/invite', + async (request, reply) => { + const record = this.mobilePairingStore.get(request.params.inviteId); + if (!record || record.status === 'expired') { + return reply.status(404).send({ success: false, error: 'Pairing invite not found' }); + } + return reply.send({ + invite_id: record.invite_id, + invite: record.invite, + status: record.status, + }); + } + ); + + this.server.post<{ + Params: { inviteId: string }; + Body: MobilePairingApprovalRequest; + }>( + '/v1/mobile/pairings/:inviteId/approve', + async (request, reply) => { + return this.handleMobilePairingApprove( + request.params.inviteId, + request.body, + reply + ); + } + ); + + this.server.post<{ + Params: { inviteId: string }; + Body: { denied_reason?: string }; + }>( + '/v1/mobile/pairings/:inviteId/deny', + async (request, reply) => { + const record = this.mobilePairingStore.deny( + request.params.inviteId, + request.body?.denied_reason + ); + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + }); + } + ); + + this.server.get<{ Params: { inviteId: string } }>( + '/v1/mobile/pairings/:inviteId/status', + async (request, reply) => { + return this.handleMobilePairingStatus(request.params.inviteId, reply); + } + ); + + this.server.post<{ + Params: { vaultId: string }; + Body: { public_key: string; signing_public_key?: string }; + }>( + '/v1/mobile/vaults/:vaultId/online', + async (request, reply) => { + if (!request.body?.public_key) { + return reply.status(400).send({ error: 'Missing public_key' }); + } + this.connectionStore.register(request.params.vaultId, request.body.public_key); + return reply.send({ success: true, vault_id: request.params.vaultId }); + } + ); + + this.server.post<{ + Body: { + vault_id: string; + token: string; + platform?: 'ios' | 'android' | 'web' | 'unknown'; + device_id?: string; + }; + }>( + '/v1/devices/push-token', + async (request, reply) => { + const { vault_id, token, platform, device_id } = request.body; + if (!vault_id || !token) { + return reply.status(400).send({ error: 'Missing vault_id or token' }); + } + if (!this.isExpoPushToken(token)) { + return reply.status(400).send({ error: 'Unsupported push token format' }); + } + + const record = this.pushTokenStore.register({ + vault_id, + token, + platform: platform ?? 'unknown', + device_id, + }); + + return reply.send({ + success: true, + vault_id: record.vault_id, + platform: record.platform, + updated_at: new Date(record.updated_at).toISOString(), + }); + } + ); } private async handleRequest( @@ -399,6 +547,8 @@ export class RelayServer { this.messageStore.markDelivered(envelope.request_id); } + void this.notifyMobilePush(envelope); + return reply.status(202).send({ queued: true, accepted: true, @@ -676,6 +826,61 @@ export class RelayServer { }); } + private async handleMobilePairingApprove( + inviteId: string, + body: MobilePairingApprovalRequest, + reply: FastifyReply + ): Promise { + const validationError = this.validateMobileApproval(inviteId, body); + if (validationError) { + return reply.status(400).send({ success: false, error: validationError }); + } + + const record = this.mobilePairingStore.approve(body); + + if (this.config.debug) { + console.log( + `Mobile pairing approved: ${record.invite_id} (agent: ${record.agent_id}, vault: ${record.vault_id})` + ); + } + + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + agent_id: record.agent_id, + vault_id: record.vault_id, + vault_hpke_public_key: record.vault_hpke_public_key, + vault_signing_public_key: record.vault_signing_public_key, + }); + } + + private async handleMobilePairingStatus( + inviteId: string, + reply: FastifyReply + ): Promise { + const record = this.mobilePairingStore.get(inviteId); + + if (!record) { + return reply.send({ + status: 'pending', + invite_id: inviteId, + } satisfies MobilePairingStatus); + } + + return reply.send({ + status: record.status, + invite_id: record.invite_id, + agent_id: record.agent_id, + vault_id: record.vault_id, + vault_hpke_public_key: record.vault_hpke_public_key, + vault_signing_public_key: record.vault_signing_public_key, + approved_scopes: record.approved_scopes, + approved_budget: record.approved_budget, + error: record.denied_reason, + } satisfies MobilePairingStatus); + } + // -------------------------------------------------------------------------- // WebSocket Handler // -------------------------------------------------------------------------- @@ -1009,6 +1214,8 @@ export class RelayServer { this.messageStore.markDelivered(envelope.request_id); } + void this.notifyMobilePush(envelope); + this.sendWsMessage(ws, { type: 'ack', payload: { request_id: envelope.request_id, accepted: true }, @@ -1060,6 +1267,47 @@ export class RelayServer { this.unregisterClientRequest(response.request_id); } + private isExpoPushToken(token: string): boolean { + return /^Expo(nent)?PushToken\[[A-Za-z0-9_-]+\]$/.test(token); + } + + private async notifyMobilePush(envelope: RelayEnvelope): Promise { + if (!this.config.expoPushUrl) return; + const registration = this.pushTokenStore.get(envelope.vault_id); + if (!registration) return; + + try { + const response = await fetch(this.config.expoPushUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + to: registration.token, + title: 'DCP approval needed', + body: 'Open DCP to review this request.', + sound: 'default', + priority: 'high', + data: { + type: 'approval_pending', + vault_id: envelope.vault_id, + request_id: envelope.request_id, + }, + }), + }); + + if (!response.ok && this.config.debug) { + const text = await response.text().catch(() => ''); + console.error(`Expo push failed (${response.status})${text ? `: ${text}` : ''}`); + } + } catch (err) { + if (this.config.debug) { + console.error('Expo push dispatch failed:', err); + } + } + } + private sendWsMessage(ws: WebSocket, msg: WsMessage): void { try { ws.send(JSON.stringify(msg)); @@ -1156,6 +1404,98 @@ export class RelayServer { return null; } + private validateMobileApproval( + inviteId: string, + body: MobilePairingApprovalRequest + ): string | null { + if (!body || typeof body !== 'object') { + return 'Missing approval body'; + } + + const invite = body.invite; + if (!invite || typeof invite !== 'object') { + return 'Missing pairing invite'; + } + if (invite.invite_id !== inviteId) { + return 'Invite ID mismatch'; + } + if (!body.vault_id || !body.agent_id) { + return 'Missing vault_id or agent_id'; + } + if (body.agent_id !== invite.requested_agent_id) { + return 'Agent ID mismatch'; + } + if (!body.vault_hpke_public_key || !body.vault_signing_public_key) { + return 'Missing vault relay public keys'; + } + if (!Array.isArray(body.approved_scopes)) { + return 'approved_scopes must be an array'; + } + if (!body.approved_budget || typeof body.approved_budget.daily !== 'number') { + return 'approved_budget is required'; + } + + const inviteError = this.validateMobileInvite(invite); + if (inviteError) { + return inviteError; + } + + const requestedScopes = new Set(invite.requested_scopes); + const unrequestedScope = body.approved_scopes.find((scope) => !requestedScopes.has(scope)); + if (unrequestedScope) { + return `Approved scope was not requested: ${unrequestedScope}`; + } + + return null; + } + + private validateMobileInvite(invite: MobilePairingInvite): string | null { + if (invite.type !== 'dcp_agent_pairing' || invite.version !== '1.0') { + return 'Unsupported mobile pairing invite'; + } + if (!invite.signature) { + return 'Invite signature is required'; + } + + const expiresAt = new Date(invite.expires_at).getTime(); + if (!Number.isFinite(expiresAt)) { + return 'Invalid invite expiry'; + } + if (Date.now() > expiresAt) { + return 'Invite expired'; + } + + try { + const { signature: _signature, ...unsignedInvite } = invite; + const message = Buffer.from(this.canonicalJson(unsignedInvite), 'utf8'); + const signature = Buffer.from(invite.signature, 'base64'); + const publicKey = Buffer.from(invite.agent_public_key, 'base64'); + if (!ed25519.verify(signature, message, publicKey)) { + return 'Invalid invite signature'; + } + } catch (err) { + return 'Failed to verify invite signature: ' + (err instanceof Error ? err.message : 'unknown error'); + } + + return null; + } + + private canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => this.canonicalJson(item)).join(',')}]`; + } + + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${this.canonicalJson(record[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); + } + // -------------------------------------------------------------------------- // Getters (for testing) // -------------------------------------------------------------------------- @@ -1167,4 +1507,8 @@ export class RelayServer { getConnectionStore(): ConnectionStore { return this.connectionStore; } + + getPushTokenStore(): PushTokenStore { + return this.pushTokenStore; + } } diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index f7ac149..b62f2fc 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -20,6 +20,9 @@ import type { RelayConfig, PairingClaim, StoredPairingClaim, + MobilePairingApprovalRequest, + MobilePairingRecord, + PushTokenRegistration, } from './types.js'; import { RelayError, MESSAGE_TTL_MS } from './types.js'; import { createHash, randomUUID } from 'node:crypto'; @@ -46,6 +49,7 @@ export class MessageStore { debug: config.debug ?? false, rateLimitPerMinute: config.rateLimitPerMinute ?? 60, rateLimitWindowMs: config.rateLimitWindowMs ?? 60_000, + expoPushUrl: config.expoPushUrl ?? 'https://exp.host/--/api/v2/push/send', }; // Start cleanup interval @@ -775,3 +779,195 @@ export class PairingClaimStore { } } } + +// ============================================================================ +// DCP Mobile Pairing Store +// ============================================================================ + +/** Mobile pairing status TTL: enough time for the QR terminal to poll. */ +const MOBILE_PAIRING_TTL_MS = 10 * 60 * 1000; +const MOBILE_PAIRING_RESOLVED_TTL_MS = 60 * 60 * 1000; + +export class MobilePairingStore { + private records: Map = new Map(); + private cleanupInterval: ReturnType | null = null; + + constructor() { + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60_000); + } + + registerInvite(invite: MobilePairingApprovalRequest['invite']): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(invite.invite_id); + if (existing && existing.status !== 'expired') { + return existing; + } + + const record: MobilePairingRecord = { + invite_id: invite.invite_id, + invite, + received_at: now, + status: 'pending', + }; + + this.records.set(record.invite_id, record); + return record; + } + + approve(request: MobilePairingApprovalRequest): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(request.invite.invite_id); + if (existing && existing.status === 'approved') { + return existing; + } + + const record: MobilePairingRecord = { + invite_id: request.invite.invite_id, + invite: request.invite, + received_at: existing?.received_at ?? now, + status: 'approved', + vault_id: request.vault_id, + vault_hpke_public_key: request.vault_hpke_public_key, + vault_signing_public_key: request.vault_signing_public_key, + agent_id: request.agent_id, + approved_scopes: request.approved_scopes, + approved_budget: request.approved_budget, + resolved_at: now, + }; + + this.records.set(record.invite_id, record); + return record; + } + + deny(inviteId: string, deniedReason?: string): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(inviteId); + const record: MobilePairingRecord = { + invite_id: inviteId, + invite: existing?.invite ?? { + type: 'dcp_agent_pairing', + version: '1.0', + relay_url: '', + invite_id: inviteId, + requested_agent_id: '', + agent_public_key: '', + agent_name: '', + agent_client: 'custom', + environment: 'dev', + requested_scopes: [], + requested_budget: { daily: 0, currency: 'SOL', approval_threshold: 0 }, + created_at: new Date(now).toISOString(), + expires_at: new Date(now + MOBILE_PAIRING_TTL_MS).toISOString(), + nonce: '', + }, + received_at: existing?.received_at ?? now, + status: 'denied', + resolved_at: now, + denied_reason: deniedReason, + }; + + this.records.set(inviteId, record); + return record; + } + + get(inviteId: string): MobilePairingRecord | undefined { + const record = this.records.get(inviteId); + if (!record) return undefined; + + const now = Date.now(); + if (record.status === 'approved' || record.status === 'denied') { + return record; + } + + if (now - record.received_at > MOBILE_PAIRING_TTL_MS) { + record.status = 'expired'; + record.resolved_at = now; + } + + return record; + } + + cleanup(): number { + const now = Date.now(); + let removed = 0; + + for (const [inviteId, record] of this.records) { + if (record.status !== 'approved' && record.status !== 'denied' && now - record.received_at > MOBILE_PAIRING_TTL_MS) { + record.status = 'expired'; + record.resolved_at = now; + } + + if (record.resolved_at && now - record.resolved_at > MOBILE_PAIRING_RESOLVED_TTL_MS) { + this.records.delete(inviteId); + removed++; + } + } + + return removed; + } + + getStats(): { totalMobilePairings: number; approvedMobilePairings: number; deniedMobilePairings: number } { + let approved = 0; + let denied = 0; + + for (const record of this.records.values()) { + if (record.status === 'approved') approved++; + if (record.status === 'denied') denied++; + } + + return { + totalMobilePairings: this.records.size, + approvedMobilePairings: approved, + deniedMobilePairings: denied, + }; + } + + close(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } +} + +// ============================================================================ +// Mobile Push Token Store +// ============================================================================ + +const PUSH_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +export class PushTokenStore { + private tokensByVault: Map = new Map(); + + register(input: Omit): PushTokenRegistration { + const record: PushTokenRegistration = { + ...input, + platform: input.platform ?? 'unknown', + updated_at: Date.now(), + }; + this.tokensByVault.set(input.vault_id, record); + return record; + } + + get(vaultId: string): PushTokenRegistration | undefined { + const record = this.tokensByVault.get(vaultId); + if (!record) return undefined; + if (Date.now() - record.updated_at > PUSH_TOKEN_MAX_AGE_MS) { + this.tokensByVault.delete(vaultId); + return undefined; + } + return record; + } + + remove(vaultId: string): boolean { + return this.tokensByVault.delete(vaultId); + } + + getStats(): { registeredPushTokens: number } { + return { + registeredPushTokens: this.tokensByVault.size, + }; + } +} diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index cd2e283..d1f1acc 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -157,6 +157,14 @@ export interface VaultConnection { ws?: unknown; // WebSocket reference } +export interface PushTokenRegistration { + vault_id: string; + token: string; + platform?: 'ios' | 'android' | 'web' | 'unknown'; + device_id?: string; + updated_at: number; +} + // ============================================================================ // Error Types (from protocol spec section 7.3) // ============================================================================ @@ -219,6 +227,8 @@ export interface RelayConfig { rateLimitPerMinute: number; /** Rate limit window in ms (default: 60000 = 1 minute) */ rateLimitWindowMs: number; + /** Expo push API endpoint. Empty disables outbound push delivery. */ + expoPushUrl: string; } export const DEFAULT_RELAY_CONFIG: RelayConfig = { @@ -231,6 +241,7 @@ export const DEFAULT_RELAY_CONFIG: RelayConfig = { debug: false, rateLimitPerMinute: 60, rateLimitWindowMs: 60_000, + expoPushUrl: 'https://exp.host/--/api/v2/push/send', }; // ============================================================================ @@ -304,3 +315,88 @@ export interface PairingApprovalStatus { vault_id?: string; error?: string; } + +// ============================================================================ +// DCP Mobile Pairing Types (Agent QR → Mobile approval → Agent polling) +// ============================================================================ + +export type MobileAgentClient = + | 'claude-desktop' + | 'cursor' + | 'vscode' + | 'hermes' + | 'openclaw' + | 'mcp' + | 'custom' + | 'hosted'; + +export type MobileAgentEnvironment = 'local' | 'vps' | 'hosted' | 'dev'; + +export type MobileDcpScope = + | 'read:wallet.address' + | 'sign:solana' + | 'vault_get_address' + | 'vault_budget_check' + | 'vault_sign_tx' + | 'vault_sign_message'; + +export interface MobilePairingBudget { + daily: number; + currency: 'SOL' | 'USDC' | '1LY'; + approval_threshold: number; +} + +export interface MobilePairingInvite { + type: 'dcp_agent_pairing'; + version: '1.0'; + relay_url: string; + invite_id: string; + requested_agent_id: string; + agent_public_key: string; + agent_name: string; + agent_client: MobileAgentClient; + environment: MobileAgentEnvironment; + requested_scopes: MobileDcpScope[]; + requested_budget: MobilePairingBudget; + created_at: string; + expires_at: string; + nonce: string; + signature?: string; +} + +export interface MobilePairingApprovalRequest { + invite: MobilePairingInvite; + vault_id: string; + agent_id: string; + vault_hpke_public_key: string; + vault_signing_public_key: string; + approved_scopes: MobileDcpScope[]; + approved_budget: MobilePairingBudget; +} + +export interface MobilePairingRecord { + invite_id: string; + invite: MobilePairingInvite; + received_at: number; + status: 'pending' | 'approved' | 'denied' | 'expired'; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + agent_id?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + resolved_at?: number; + denied_reason?: string; +} + +export interface MobilePairingStatus { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'not_found'; + invite_id?: string; + agent_id?: string; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + error?: string; +} diff --git a/packages/dcp-relay/tests/relay.test.ts b/packages/dcp-relay/tests/relay.test.ts index ec32a83..51982e7 100644 --- a/packages/dcp-relay/tests/relay.test.ts +++ b/packages/dcp-relay/tests/relay.test.ts @@ -5,9 +5,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { ed25519 } from '@noble/curves/ed25519'; import { MessageStore, ConnectionStore, RateLimiter } from '../src/store.js'; import { RelayServer } from '../src/relay.js'; -import type { RelayEnvelope, RelayResponseEnvelope } from '../src/types.js'; +import type { MobilePairingInvite, RelayEnvelope, RelayResponseEnvelope } from '../src/types.js'; import { RelayError, RELAY_VERSION } from '../src/types.js'; // ============================================================================ @@ -35,6 +37,47 @@ function createTestResponse(requestId: string): RelayResponseEnvelope { }; } +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function createMobileInvite(overrides: Partial = {}): MobilePairingInvite { + const privateKey = randomBytes(32); + const publicKey = ed25519.getPublicKey(privateKey); + const unsigned: Omit = { + type: 'dcp_agent_pairing', + version: '1.0', + relay_url: 'http://127.0.0.1:8422', + invite_id: `mob_${Date.now()}_${Math.random().toString(36).slice(2)}`, + requested_agent_id: 'agent_claude_desktop', + agent_public_key: Buffer.from(publicKey).toString('base64'), + agent_name: 'Test Mobile Agent', + agent_client: 'custom', + environment: 'dev', + requested_scopes: ['read:wallet.address', 'sign:solana'], + requested_budget: { daily: 5, currency: 'USDC', approval_threshold: 0 }, + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + nonce: randomBytes(16).toString('hex'), + ...overrides, + }; + const signature = ed25519.sign(Buffer.from(canonicalJson(unsigned), 'utf8'), privateKey); + return { + ...unsigned, + signature: Buffer.from(signature).toString('base64'), + }; +} + // ============================================================================ // MessageStore Tests // ============================================================================ @@ -490,7 +533,143 @@ describe('RelayServer', () => { }); }); + describe('Mobile pairing endpoints', () => { + it('returns pending until the mobile vault approves an invite', async () => { + const invite = createMobileInvite(); + + const pendingResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/status` + ); + const pending = await pendingResponse.json(); + + expect(pendingResponse.status).toBe(200); + expect(pending.status).toBe('pending'); + + const approveResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite, + vault_id: 'vault_mobile_test', + agent_id: 'agent_claude_desktop', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: invite.requested_scopes, + approved_budget: invite.requested_budget, + }), + } + ); + const approved = await approveResponse.json(); + + expect(approveResponse.status).toBe(200); + expect(approved.status).toBe('approved'); + expect(approved.agent_id).toBe('agent_claude_desktop'); + + const statusResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/status` + ); + const status = await statusResponse.json(); + + expect(status.status).toBe('approved'); + expect(status.vault_id).toBe('vault_mobile_test'); + expect(status.vault_hpke_public_key).toBe(Buffer.alloc(32, 1).toString('base64')); + expect(status.approved_scopes).toEqual(invite.requested_scopes); + }); + + it('rejects forged mobile approval payloads', async () => { + const invite = createMobileInvite(); + const forged = { + ...invite, + requested_budget: { + ...invite.requested_budget, + daily: 999, + }, + }; + + const response = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite: forged, + vault_id: 'vault_mobile_test', + agent_id: 'agent_claude_desktop', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: forged.requested_scopes, + approved_budget: forged.requested_budget, + }), + } + ); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Invalid invite signature'); + }); + + it('rejects scopes the agent did not request', async () => { + const invite = createMobileInvite({ requested_scopes: ['read:wallet.address'] }); + + const response = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite, + vault_id: 'vault_mobile_test', + agent_id: 'agent_claude_desktop', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: ['read:wallet.address', 'sign:solana'], + approved_budget: invite.requested_budget, + }), + } + ); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Approved scope was not requested'); + }); + }); + describe('Request endpoint', () => { + it('registers mobile push tokens without exposing approval details', async () => { + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vault_id: 'vault_push', + token: 'ExpoPushToken[abc123_DEF-456]', + platform: 'android', + device_id: 'device_test', + }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.vault_id).toBe('vault_push'); + expect(server.getPushTokenStore().get('vault_push')?.token).toBe('ExpoPushToken[abc123_DEF-456]'); + }); + + it('rejects unsupported push token formats', async () => { + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vault_id: 'vault_push', + token: 'plain-secret-token', + platform: 'android', + }), + }); + + expect(response.status).toBe(400); + }); + it('should reject request for unconnected vault', async () => { const envelope = createTestEnvelope({ vault_id: 'vault_not_connected' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f19284..98dde7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@noble/curves': specifier: ^1.4.0 version: 1.9.7 + '@solana/web3.js': + specifier: ^1.98.0 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) chalk: specifier: ^5.3.0 version: 5.6.2 @@ -31,10 +34,16 @@ importers: ora: specifier: ^8.0.1 version: 8.2.0 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@types/node': specifier: ^22.10.2 version: 22.19.17 + '@types/qrcode-terminal': + specifier: ^0.12.2 + version: 0.12.2 tsup: specifier: ^8.3.5 version: 8.5.1(postcss@8.5.13)(tsx@4.21.0)(typescript@5.9.3) @@ -1296,6 +1305,9 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/qrcode-terminal@0.12.2': + resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2188,6 +2200,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -3404,6 +3420,8 @@ snapshots: '@types/node': 22.19.17 kleur: 3.0.3 + '@types/qrcode-terminal@0.12.2': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4072,7 +4090,7 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)): + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -4085,7 +4103,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)) + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2 @@ -4351,6 +4369,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + qrcode-terminal@0.12.0: {} + qs@6.15.1: dependencies: side-channel: 1.1.0