diff --git a/.gitignore b/.gitignore index 5c42398..9dadc49 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,21 @@ packages/wallet/src/__tests__/find-txs.mts # Claude CLAUDE.md .claude/ + +# Supabase CLI scratch state — NEVER commit, this repo is PUBLIC. +# `.temp/project-ref` and `.temp/pooler-url` identify the production database +# project and its connection host. Not credentials (no password is stored), but +# they tell anyone reading the repo exactly where the database lives. +supabase/.temp/ + +# Internal engineering notes — NEVER commit, this repo is PUBLIC. +# These describe the PRIVATE agentaos-platform repo: branch names, guard and +# endpoint internals, dashboard source line numbers, and internal deliberation +# about breaking integrators. No credentials, but none of it is ours to publish. +# Their home is the private platform repo's implementation/ directory. +/implementation/ +/API-V2-COMPAT-AND-SDK-PLAN.md + +# Stray copies of the build output. `bin` in package.json points at +# ./dist/index.js and `files` publishes only dist/ — nothing references bin/. +packages/wallet/bin/ diff --git a/packages/mpc-wasm/Cargo.lock b/packages/mpc-wasm/Cargo.lock index 532660f..961d0f0 100644 --- a/packages/mpc-wasm/Cargo.lock +++ b/packages/mpc-wasm/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "agentaos-mpc-wasm" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cggmp24", + "cggmp24-keygen", + "critical-section", + "generic-ec", + "getrandom", + "js-sys", + "key-share", + "num-bigint-dig", + "rand", + "rand_core", + "round-based", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "wasm-bindgen", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -549,29 +572,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "agentaos-mpc-wasm" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "cggmp24", - "cggmp24-keygen", - "critical-section", - "generic-ec", - "getrandom", - "js-sys", - "key-share", - "num-bigint-dig", - "rand", - "rand_core", - "round-based", - "serde", - "serde-wasm-bindgen", - "serde_json", - "sha2", - "wasm-bindgen", -] - [[package]] name = "hd-wallet" version = "0.6.1" diff --git a/packages/wallet/package.json b/packages/wallet/package.json index b85b540..39f1de6 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -1,7 +1,7 @@ { "name": "agentaos", "version": "2.0.0", - "description": "AgentaOS — CLI + MCP server for threshold signing. The key never exists.", + "description": "AgentaOS \u2014 CLI + MCP server for threshold signing. The key never exists.", "license": "Apache-2.0", "author": "AgentaOS ", "repository": { @@ -33,20 +33,11 @@ "clean": "rm -rf dist" }, "dependencies": { - "@agentaos/core": "workspace:*", - "@agentaos/engine": "workspace:*", "@agentaos/pay": "workspace:^", - "@agentaos/sdk": "workspace:*", - "@inquirer/prompts": "^8.2.1", "@modelcontextprotocol/sdk": "^1.12.0", - "@noble/hashes": "^1.7.1", - "@scure/bip39": "^2.0.1", - "@x402/core": "^2.2.0", - "@x402/evm": "^2.2.0", "chalk": "^5.3.0", "commander": "^12.1.0", "ora": "^8.1.0", - "viem": "^2.21.0", "zod": "^3.24.0" }, "devDependencies": { diff --git a/packages/wallet/src/__tests__/mcp-server.test.ts b/packages/wallet/src/__tests__/mcp-server.test.ts index cf05ac8..1efb394 100644 --- a/packages/wallet/src/__tests__/mcp-server.test.ts +++ b/packages/wallet/src/__tests__/mcp-server.test.ts @@ -2,32 +2,11 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +/** Merchant tools only. The agent sub-account surface (MPC signers, on-chain + * sends, contract calls, message signing, the signing audit log and x402) was + * removed as wallet-era legacy — this list is the assertion that it stays + * removed, so a stray re-registration fails here rather than shipping. */ const EXPECTED_TOOLS = [ - // Discovery - 'agenta_wallet_overview', - 'agenta_list_networks', - 'agenta_list_signers', - 'agenta_resolve_address', - // Common operations - 'agenta_send_eth', - 'agenta_send_token', - 'agenta_get_balances', - // Advanced - 'agenta_call_contract', - 'agenta_read_contract', - 'agenta_execute', - 'agenta_simulate', - // Signing - 'agenta_sign_message', - 'agenta_sign_typed_data', - // Management - 'agenta_get_status', - 'agenta_get_audit_log', - // x402 - 'agenta_x402_check', - 'agenta_x402_discover', - 'agenta_x402_fetch', - // Merchant payments 'agenta_pay_create_checkout', 'agenta_pay_get_checkout', 'agenta_pay_list_checkouts', @@ -82,23 +61,34 @@ describe('AgentaOS Terminal MCP Server', () => { } }); - it('x402 tools have correct input schema', async () => { + // The removal is the point, so assert it directly: anything needing key + // material must not come back, because this server no longer holds any. + it('exposes no wallet, signing or x402 tools', async () => { const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); - const check = tools.find((t) => t.name === 'agenta_x402_check'); - expect(check).toBeDefined(); - const checkProps = check!.inputSchema.properties as Record; - expect(checkProps).toHaveProperty('url'); - - const discover = tools.find((t) => t.name === 'agenta_x402_discover'); - expect(discover).toBeDefined(); - const discoverProps = discover!.inputSchema.properties as Record; - expect(discoverProps).toHaveProperty('domain'); + for (const gone of [ + 'agenta_wallet_overview', + 'agenta_list_signers', + 'agenta_send_eth', + 'agenta_send_token', + 'agenta_call_contract', + 'agenta_execute', + 'agenta_sign_message', + 'agenta_sign_typed_data', + 'agenta_get_audit_log', + 'agenta_x402_check', + 'agenta_x402_discover', + 'agenta_x402_fetch', + ]) { + expect(names).not.toContain(gone); + } + }); - const fetchTool = tools.find((t) => t.name === 'agenta_x402_fetch'); - expect(fetchTool).toBeDefined(); - const fetchProps = fetchTool!.inputSchema.properties as Record; - expect(fetchProps).toHaveProperty('url'); - expect(fetchProps).toHaveProperty('maxAmount'); + it('every tool is a merchant payment tool', async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + expect(tool.name).toMatch(/^agenta_pay_/); + } }); }); diff --git a/packages/wallet/src/__tests__/x402-client.test.ts b/packages/wallet/src/__tests__/x402-client.test.ts deleted file mode 100644 index 5e11e40..0000000 --- a/packages/wallet/src/__tests__/x402-client.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { checkX402, discoverX402 } from '../lib/x402-client.js'; - -// `checkX402` / `discoverX402` call the global `fetch`. We mock it here so these -// are deterministic unit tests. (The old version hit https://httpbin.org live and -// was flaky — 15s timeouts and a different assertion failing on each run.) - -function response( - status: number, - init?: { headers?: Record; body?: string }, -): Response { - return new Response(init?.body ?? '', { status, headers: init?.headers }); -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('x402-client', () => { - it('checkX402 returns requires402=false for a non-402 URL', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => response(200)), - ); - const result = await checkX402('https://example.com/free'); - expect(result.requires402).toBe(false); - expect(result.url).toBe('https://example.com/free'); - }); - - it('checkX402 returns requires402=true for a 402 URL', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => response(402)), - ); - const result = await checkX402('https://example.com/paid'); - expect(result.requires402).toBe(true); - }); - - it('checkX402 parses payment requirements from a JSON 402 body', async () => { - const paymentRequired = { - x402Version: 1, - accepts: [{ scheme: 'exact', network: 'eip155:84532', amount: '1000000', asset: '0xUSDC' }], - }; - vi.stubGlobal( - 'fetch', - vi.fn(async () => - response(402, { - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(paymentRequired), - }), - ), - ); - const result = await checkX402('https://example.com/paid'); - expect(result.requires402).toBe(true); - expect(result.paymentRequired?.accepts[0]?.amount).toBe('1000000'); - }); - - it('discoverX402 returns empty when nothing requires payment', async () => { - // .well-known/x402 → 404 (no manifest); every probe → 200 (no 402) - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string | URL) => - String(url).includes('/.well-known/x402') ? response(404) : response(200), - ), - ); - const result = await discoverX402('example.com'); - expect(result.domain).toBe('example.com'); - expect(result.endpoints).toEqual([]); - }); - - it('discoverX402 reads the .well-known/x402 manifest when present', async () => { - const manifest = { - endpoints: [ - { - path: '/api/data', - method: 'GET', - scheme: 'exact', - network: 'eip155:84532', - amount: '1000000', - asset: '0xUSDC', - }, - ], - }; - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string | URL) => - String(url).includes('/.well-known/x402') - ? response(200, { - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(manifest), - }) - : response(200), - ), - ); - const result = await discoverX402('https://example.com'); - expect(result.endpoints).toHaveLength(1); - expect(result.endpoints[0]?.path).toBe('/api/data'); - }); -}); diff --git a/packages/wallet/src/__tests__/x402-live.integration.test.ts b/packages/wallet/src/__tests__/x402-live.integration.test.ts deleted file mode 100644 index 98a6c8a..0000000 --- a/packages/wallet/src/__tests__/x402-live.integration.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Live x402 integration tests against https://x402.payai.network - * - * These tests hit a real x402 server on Base Sepolia. - * - checkX402 and discoverX402: always run (no signer needed) - * - fetchWithX402: only runs when AGENTA_API_SECRET + AGENTA_SERVER_URL are set - * AND the signer has USDC on Base Sepolia - * - * Run: pnpm --filter agenta vitest run x402-live - */ -import { describe, expect, it } from 'vitest'; -import { checkX402, discoverX402, fetchWithX402 } from '../lib/x402-client.js'; - -const X402_URL = 'https://x402.payai.network/api/base-sepolia/paid-content'; -const X402_DOMAIN = 'https://x402.payai.network'; - -describe('x402 live integration', () => { - describe('checkX402', () => { - it('detects 402 on paid endpoint', async () => { - const result = await checkX402(X402_URL); - expect(result.requires402).toBe(true); - expect(result.url).toBe(X402_URL); - expect(result.paymentRequired).toBeDefined(); - expect(result.paymentRequired!.accepts.length).toBeGreaterThan(0); - - const accept = result.paymentRequired!.accepts[0]!; - expect(accept.scheme).toBe('exact'); - expect(accept.network).toBe('eip155:84532'); // Base Sepolia - expect(accept.asset).toMatch(/^0x/); - expect(accept.amount).toBeDefined(); - expect(accept.payTo).toMatch(/^0x/); - }, 15_000); - - it('returns non-402 for free endpoints', async () => { - const result = await checkX402(`${X402_DOMAIN}/`); - expect(result.requires402).toBe(false); - }, 15_000); - }); - - describe('discoverX402', () => { - it('discovers paid endpoints on the domain', async () => { - const result = await discoverX402(X402_DOMAIN); - expect(result.domain).toBe(X402_DOMAIN); - expect(Array.isArray(result.endpoints)).toBe(true); - // The /api path or subpaths should show up as 402 - // (depends on what probes match — at minimum the domain itself is reachable) - }, 60_000); - }); - - describe('fetchWithX402', () => { - it.skipIf(!process.env.AGENTA_API_SECRET)( - 'pays and fetches protected content', - async () => { - const { ThresholdSigner } = await import('@agentaos/sdk'); - const { CGGMP24Scheme } = await import('@agentaos/engine'); - - const signer = await ThresholdSigner.fromSecret({ - apiSecret: process.env.AGENTA_API_SECRET!, - serverUrl: process.env.AGENTA_SERVER_URL || 'http://localhost:8080', - apiKey: process.env.AGENTA_API_KEY || '', - scheme: new CGGMP24Scheme(), - }); - - try { - const result = await fetchWithX402(X402_URL, signer, { - maxAmount: '100000', // 0.1 USDC max - }); - - expect(result.paid).toBe(true); - expect(result.status).toBe(200); - expect(result.scheme).toBe('exact'); - expect(result.body).toBeTruthy(); - console.log('x402 payment result:', { - status: result.status, - paid: result.paid, - scheme: result.scheme, - transaction: result.transaction, - payer: result.payer, - contentType: result.contentType, - bodyLength: result.body.length, - }); - } finally { - signer.destroy(); - } - }, - 60_000, - ); - }); -}); diff --git a/packages/wallet/src/__tests__/x402-live.script.ts b/packages/wallet/src/__tests__/x402-live.script.ts deleted file mode 100644 index 1e01846..0000000 --- a/packages/wallet/src/__tests__/x402-live.script.ts +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env tsx -/** - * Live x402 integration script against https://x402.payai.network - * - * Tests: checkX402, discoverX402, fetchWithX402 - * - * Usage: - * npx tsx packages/wallet/src/__tests__/x402-live.script.ts - * npx tsx packages/wallet/src/__tests__/x402-live.script.ts --pay # actually pay - */ -import { checkX402, discoverX402, fetchWithX402 } from '../lib/x402-client.js'; - -const X402_URL = 'https://x402.payai.network/api/base-sepolia/paid-content'; -const X402_DOMAIN = 'https://x402.payai.network'; - -const doPay = process.argv.includes('--pay'); - -async function main() { - console.log('=== x402 Live Integration Test ===\n'); - - // ── 1. checkX402 ── - console.log('1. checkX402:', X402_URL); - const check = await checkX402(X402_URL); - console.log(' requires402:', check.requires402); - if (check.paymentRequired) { - for (const a of check.paymentRequired.accepts) { - console.log(' accept:', { - scheme: a.scheme, - network: a.network, - amount: a.amount, - asset: a.asset, - payTo: a.payTo, - }); - if (a.extra) { - console.log(' extra:', a.extra); - } - } - } - console.log(' ✓ checkX402 passed\n'); - - // ── 2. checkX402 on free URL ── - const freeUrl = `${X402_DOMAIN}/`; - console.log('2. checkX402 (free):', freeUrl); - const checkFree = await checkX402(freeUrl); - console.log(' requires402:', checkFree.requires402); - console.log(' ✓ free endpoint check passed\n'); - - // ── 3. discoverX402 ── - console.log('3. discoverX402:', X402_DOMAIN); - const discover = await discoverX402(X402_DOMAIN); - console.log(' found', discover.endpoints.length, 'endpoint(s)'); - for (const ep of discover.endpoints) { - console.log(' ', ep.method, ep.path, '→', ep.scheme, ep.network, ep.amount); - } - console.log(' ✓ discoverX402 passed\n'); - - // ── 4. fetchWithX402 (only with --pay) ── - if (!doPay) { - console.log('4. fetchWithX402: SKIPPED (pass --pay to actually pay)\n'); - console.log('=== All read-only tests passed ==='); - return; - } - - console.log('4. fetchWithX402:', X402_URL); - const { ThresholdSigner } = await import('@agentaos/sdk'); - const { CGGMP24Scheme } = await import('@agentaos/engine'); - - const apiSecret = process.env.AGENTA_API_SECRET; - const serverUrl = process.env.AGENTA_SERVER || 'http://localhost:8080'; - const apiKey = process.env.AGENTA_API_KEY || ''; - - if (!apiSecret) { - console.log(' AGENTA_API_SECRET not set — skipping payment test'); - return; - } - - console.log(' Loading signer from AGENTA_API_SECRET env var'); - const signer = await ThresholdSigner.fromSecret({ - apiSecret, - serverUrl, - apiKey, - scheme: new CGGMP24Scheme(), - }); - - try { - console.log(' Signer address:', signer.address); - console.log(' Paying up to 0.1 USDC...'); - - const result = await fetchWithX402(X402_URL, signer, { - maxAmount: '100000', // 0.1 USDC max (6 decimals) - }); - - console.log(' status:', result.status); - console.log(' paid:', result.paid); - console.log(' scheme:', result.scheme); - console.log(' transaction:', result.transaction); - console.log(' payer:', result.payer); - console.log(' contentType:', result.contentType); - console.log(' body:', result.body.slice(0, 500)); - console.log(' ✓ fetchWithX402 passed\n'); - } finally { - signer.destroy(); - } - - console.log('=== All tests passed (including payment) ==='); -} - -main().catch((err) => { - console.error('FAILED:', err); - process.exit(1); -}); diff --git a/packages/wallet/src/cli/commands/admin.command.ts b/packages/wallet/src/cli/commands/admin.command.ts deleted file mode 100644 index 270f820..0000000 --- a/packages/wallet/src/cli/commands/admin.command.ts +++ /dev/null @@ -1,626 +0,0 @@ -import { CRITERION_CATALOG } from '@agentaos/core'; -import { confirm, input, select } from '@inquirer/prompts'; -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { formatEther } from 'viem'; -import { type SignerConfig, loadSignerConfig } from '../../lib/config.js'; -import { getSession } from '../../lib/keychain.js'; -import { buildRules, parseFormValues } from '../../lib/policy-conversions.js'; -import { brand, danger, dim, failMark, promptTheme, success, warn } from '../theme.js'; - -// --------------------------------------------------------------------------- -// Error handler (clean output, no stack traces) -// --------------------------------------------------------------------------- - -function withErrorHandler( - // biome-ignore lint/suspicious/noExplicitAny: commander action callbacks use any[] - fn: (...args: any[]) => Promise, - // biome-ignore lint/suspicious/noExplicitAny: commander action callbacks use any[] -): (...args: any[]) => Promise { - // biome-ignore lint/suspicious/noExplicitAny: commander action callbacks use any[] - return async (...args: any[]) => { - try { - await fn(...args); - } catch (error: unknown) { - if (error instanceof Error && error.name === 'ExitPromptError') { - console.log(dim('\n Cancelled.\n')); - return; - } - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`\n ${failMark(message)}\n`); - process.exitCode = 1; - } - }; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface AdminContext { - config: SignerConfig; - signerName: string; - signerId: string; - headers: Record; -} - -interface PolicyDocument { - rules: Record[]; - version?: number; -} - -interface AuditEntry { - id: string; - signerId: string; - requestType: string; - signingPath: string; - status: string; - toAddress?: string; - valueWei?: string; - txHash?: string; - policyViolations?: unknown[]; - createdAt: string; -} - -// --------------------------------------------------------------------------- -// Auth resolution — uses session JWT from `agenta login` -// --------------------------------------------------------------------------- - -async function getAdminContext(command: CommandType): Promise { - const signerName = command.optsWithGlobals().signer; - const config = loadSignerConfig(signerName); - - if (!config.signerId) { - throw new Error( - 'No signer ID in config. Re-run `agenta sub create` or add signerId to config.', - ); - } - - const token = await getSession(); - if (!token) { - throw new Error(`Not logged in. Run ${chalk.bold('agenta login')} first.`); - } - - const headers: Record = { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }; - - return { config, signerName: config.signerName, signerId: config.signerId, headers }; -} - -function getAuditContext(command: CommandType): { - config: SignerConfig; - headers: Record; -} { - const signerName = command.optsWithGlobals().signer; - const config = loadSignerConfig(signerName); - return { - config, - headers: { - 'x-api-key': config.apiKey, - 'content-type': 'application/json', - }, - }; -} - -// --------------------------------------------------------------------------- -// Fetch helper -// --------------------------------------------------------------------------- - -async function adminFetch( - baseUrl: string, - path: string, - headers: Record, - method = 'GET', - body?: unknown, -): Promise { - const url = `${baseUrl.replace(/\/+$/, '')}/api/v1${path}`; - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Server returned ${response.status}: ${text}`); - } - - if (response.status === 204) return undefined as T; - return response.json() as Promise; -} - -// --------------------------------------------------------------------------- -// Visual helpers -// --------------------------------------------------------------------------- - -function fmtAddr(address: string): string { - if (!address || address.length < 10) return dim('—'); - return `${address.slice(0, 6)}…${address.slice(-4)}`; -} - -function fmtId(id: string): string { - return id.length > 12 ? `${id.slice(0, 8)}…` : id; -} - -function fmtCriterion(type: string, criterion: Record): string { - switch (type) { - case 'evmAddress': { - const op = criterion.operator as string; - const addrs = (criterion.addresses as string[]) ?? []; - const label = op === 'not_in' ? 'Blocked' : 'Approved'; - return `${label}: ${addrs.map(fmtAddr).join(', ') || dim('none')}`; - } - case 'maxPerTxUsd': - return `Max/tx: $${criterion.maxUsd}`; - case 'dailyLimitUsd': - return `Daily: $${criterion.maxUsd}`; - case 'monthlyLimitUsd': - return `Monthly: $${criterion.maxUsd}`; - case 'ethValue': - return `ETH value ${criterion.operator ?? '<='} ${criterion.value}`; - case 'rateLimit': - return `Rate: ${criterion.maxPerHour}/hr`; - case 'timeWindow': - return `Hours: ${criterion.startHour}:00–${criterion.endHour}:00 UTC`; - case 'evmNetwork': { - const ids = (criterion.chainIds as number[]) ?? []; - return `Chains: ${ids.join(', ')}`; - } - case 'evmFunction': { - const sels = (criterion.selectors as string[]) ?? []; - return `Functions: ${sels.join(', ') || dim('any')}`; - } - case 'ipAddress': { - const ips = (criterion.ips as string[]) ?? []; - return `IPs: ${ips.join(', ')}`; - } - case 'blockInfiniteApprovals': - return 'Block infinite approvals'; - case 'maxSlippage': - return `Max slippage: ${criterion.maxPercent}%`; - case 'mevProtection': - return `MEV protection ${dim('(advisory)')}`; - default: - return `${type}: ${JSON.stringify(criterion)}`; - } -} - -function formatWei(weiStr: string | undefined): string { - if (!weiStr || weiStr === '0') return '0'; - try { - return formatEther(BigInt(weiStr)); - } catch { - return weiStr; - } -} - -// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping is intentional -const ANSI_RE = /\u001B\[[0-9;]*m/g; - -function pad(str: string, width: number): string { - const visible = str.replace(ANSI_RE, ''); - return str + ' '.repeat(Math.max(0, width - visible.length)); -} - -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Subcommands -// --------------------------------------------------------------------------- - -// -- Policies ---------------------------------------------------------------- - -async function handlePoliciesList(command: CommandType): Promise { - const ctx = await getAdminContext(command); - const spinner = ora({ text: 'Fetching policy\u2026', indent: 2 }).start(); - - let doc: PolicyDocument | null = null; - try { - doc = await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/policy`, - ctx.headers, - ); - } catch { - // No active policy - } - - spinner.stop(); - - console.log(''); - console.log( - ` Policy for ${chalk.bold(ctx.signerName)} ${dim(`(${fmtAddr(ctx.config.ethAddress)})`)}`, - ); - console.log(''); - - if (!doc || !doc.rules || doc.rules.length === 0) { - console.log(dim(' No policy configured.')); - console.log(dim(` Run ${chalk.reset('agenta admin policies edit')} to create one.`)); - } else { - console.log(` ${dim('Status:')} ${success('active')}`); - if (doc.version) console.log(` ${dim('Version:')} ${doc.version}`); - console.log(''); - - for (const rule of doc.rules) { - const action = (rule as { action?: string }).action ?? 'accept'; - const criteria = (rule as { criteria?: Record[] }).criteria ?? []; - const actionLabel = action === 'reject' ? danger('REJECT') : success('ACCEPT'); - - console.log(` ${actionLabel} if:`); - for (const criterion of criteria) { - const type = criterion.type as string; - console.log(` ${fmtCriterion(type, criterion)}`); - } - console.log(''); - } - - console.log(dim(' Always-on: Scam blacklist, Contract scanner')); - } - console.log(''); -} - -const policiesEditCommand = new Command('edit') - .description('Edit policy rules interactively') - .action( - withErrorHandler(async (_opts: unknown, command: CommandType) => { - const ctx = await getAdminContext(command); - const spinner = ora({ text: 'Fetching current policy\u2026', indent: 2 }).start(); - - let currentRules: Record[] = []; - try { - const doc = await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/policy`, - ctx.headers, - ); - currentRules = doc.rules ?? []; - } catch { - // No active policy - } - - spinner.stop(); - const { values, enabled } = parseFormValues(currentRules); - const newEnabled: Record = { ...enabled }; - const newValues: Record> = { ...values }; - - const editableCriteria = CRITERION_CATALOG.filter((m) => !m.alwaysOn); - - console.log(''); - console.log(brand(' Configure policy rules')); - console.log(dim(' Toggle rules on/off, then set values for enabled rules.')); - console.log(''); - - for (const meta of editableCriteria) { - const isEnabled = newEnabled[meta.type] ?? false; - const shouldEnable = await confirm({ - message: `${meta.label} \u2014 ${meta.description}`, - default: isEnabled, - theme: promptTheme, - }); - - newEnabled[meta.type] = shouldEnable; - - if (shouldEnable && meta.fields.length > 0) { - const fieldValues = newValues[meta.type] ?? meta.fromCriterion({}); - - for (const field of meta.fields) { - const currentVal = fieldValues[field.key]; - - if (field.type === 'toggle') { - fieldValues[field.key] = await confirm({ - message: ` ${field.label}`, - default: currentVal !== false, - theme: promptTheme, - }); - } else if ( - field.type === 'addresses' || - field.type === 'selectors' || - field.type === 'ips' - ) { - const current = (currentVal as string[]) ?? []; - const answer = await input({ - message: ` ${field.label} (comma-separated)`, - default: current.join(', '), - theme: promptTheme, - }); - fieldValues[field.key] = answer - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - } else if (field.type === 'chains') { - const current = (currentVal as number[]) ?? []; - const answer = await input({ - message: ` ${field.label} (chain IDs, comma-separated)`, - default: current.join(', '), - theme: promptTheme, - }); - fieldValues[field.key] = answer - .split(',') - .map((s) => Number(s.trim())) - .filter((n) => !Number.isNaN(n)); - } else { - const suffix = field.unit ? ` (${field.unit})` : ''; - const answer = await input({ - message: ` ${field.label}${suffix}`, - default: currentVal !== undefined ? String(currentVal) : undefined, - theme: promptTheme, - }); - fieldValues[field.key] = answer === '' ? undefined : Number(answer); - } - } - - newValues[meta.type] = fieldValues; - } - } - - // Build rules - const rules = buildRules(newValues, newEnabled); - const enabledCount = Object.values(newEnabled).filter(Boolean).length; - - console.log(''); - console.log(` ${enabledCount} rule${enabledCount !== 1 ? 's' : ''} configured.`); - - if (rules.length === 0) { - console.log(warn(' No rules \u2014 all transactions will use default deny.')); - } - - const ok = await confirm({ - message: 'Save policy?', - default: true, - theme: promptTheme, - }); - - if (!ok) { - console.log(dim('\n Cancelled.\n')); - return; - } - - const saveSpinner = ora({ text: 'Saving policy\u2026', indent: 2 }).start(); - - await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/policy`, - ctx.headers, - 'PUT', - { rules }, - ); - - saveSpinner.succeed(`Policy saved (${enabledCount} rules)`); - console.log(''); - }), - ); - -// -- Pause / Resume ---------------------------------------------------------- - -const pauseCommand = new Command('pause').description('Pause wallet (blocks all signing)').action( - withErrorHandler(async (_opts: unknown, command: CommandType) => { - const ctx = await getAdminContext(command); - - console.log(''); - console.log( - ` Pause ${chalk.bold(ctx.signerName)} ${dim(`(${fmtAddr(ctx.config.ethAddress)})`)}`, - ); - console.log(warn(' This will block ALL signing requests until resumed.')); - console.log(''); - - const ok = await confirm({ message: 'Confirm pause?', default: false, theme: promptTheme }); - if (!ok) { - console.log(dim('\n Cancelled.\n')); - return; - } - - const spinner = ora({ text: 'Pausing…', indent: 2 }).start(); - await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/pause`, - ctx.headers, - 'POST', - ); - spinner.succeed('Wallet paused'); - console.log(''); - }), -); - -const resumeCommand = new Command('resume') - .description('Resume wallet (re-enables signing)') - .action( - withErrorHandler(async (_opts: unknown, command: CommandType) => { - const ctx = await getAdminContext(command); - - const ok = await confirm({ - message: `Resume "${ctx.signerName}"?`, - default: true, - theme: promptTheme, - }); - if (!ok) { - console.log(dim('\n Cancelled.\n')); - return; - } - - const spinner = ora({ text: 'Resuming…', indent: 2 }).start(); - await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/resume`, - ctx.headers, - 'POST', - ); - spinner.succeed('Wallet resumed'); - console.log(''); - }), - ); - -// -- Audit ------------------------------------------------------------------- - -const auditCommand = new Command('audit') - .description('View signing request audit log') - .option('--limit ', 'Number of entries', '20') - .option('--status ', 'Filter: completed, blocked, failed, pending') - .option('--export', 'Output CSV to stdout') - .action( - withErrorHandler( - async (opts: { limit: string; status?: string; export?: boolean }, command: CommandType) => { - const params = new URLSearchParams(); - - // Export requires session auth (SessionGuard); list accepts either - if (opts.export) { - const { config, headers } = await getAdminContext(command); - if (!config.signerId) throw new Error('No signer ID in config.'); - params.set('signerId', config.signerId); - if (opts.status) params.set('status', opts.status); - - const baseUrl = config.serverUrl.replace(/\/+$/, ''); - const url = `${baseUrl}/api/v1/audit-log/export?${params}`; - const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }); - if (!response.ok) throw new Error(`Server returned ${response.status}`); - const csv = await response.text(); - process.stdout.write(csv); - return; - } - - const { config } = getAuditContext(command); - const signerId = config.signerId; - if (!signerId) throw new Error('No signer ID in config.'); - - params.set('signerId', signerId); - params.set('limit', opts.limit); - if (opts.status) params.set('status', opts.status); - - const baseUrl = config.serverUrl.replace(/\/+$/, ''); - const headers: Record = { 'x-api-key': config.apiKey }; - - const spinner = ora({ text: 'Fetching audit log…', indent: 2 }).start(); - const url = `${baseUrl}/api/v1/audit-log?${params}`; - const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }); - if (!response.ok) throw new Error(`Server returned ${response.status}`); - - const data = (await response.json()) as { entries?: AuditEntry[]; data?: AuditEntry[] }; - const entries = data.entries || data.data || []; - - spinner.stop(); - - console.log(''); - console.log(` Audit log for ${chalk.bold(config.signerName)}`); - console.log(''); - - if (entries.length === 0) { - console.log(dim(' No entries found.')); - } else { - const tw = 12; - const pw = 14; - const sw = 10; - const aw = 14; - - console.log( - ` ${pad(dim('Time'), 19)} ${pad(dim('Type'), tw)} ${pad(dim('Path'), pw)} ${pad(dim('Status'), sw)} ${pad(dim('To'), aw)} ${dim('Value')}`, - ); - console.log( - dim( - ` ${'─'.repeat(19)} ${'─'.repeat(tw)} ${'─'.repeat(pw)} ${'─'.repeat(sw)} ${'─'.repeat(aw)} ${'─'.repeat(10)}`, - ), - ); - - for (const e of entries) { - const time = new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19); - const type = pad(e.requestType || '—', tw); - const path = pad(e.signingPath || '—', pw); - const statusFn = - e.status === 'completed' ? success : e.status === 'blocked' ? danger : warn; - const status = pad(statusFn(e.status), sw); - const to = pad(e.toAddress ? fmtAddr(e.toAddress) : '—', aw); - const value = e.valueWei ? `${formatWei(e.valueWei)} ETH` : '—'; - - console.log(` ${time} ${type} ${path} ${status} ${to} ${value}`); - } - - console.log(''); - console.log( - dim(` ${entries.length} entries. Use --limit to see more, --export for CSV.`), - ); - } - console.log(''); - }, - ), - ); - -// --------------------------------------------------------------------------- -// Main command group -// --------------------------------------------------------------------------- - -const policiesCommand = new Command('policies').description('Manage signing policies'); - -// agenta sub policies get [--json] -policiesCommand - .command('get') - .description('Show current policy') - .option('--json', 'Output as JSON (agent-friendly)') - .action( - withErrorHandler(async (opts: { json?: boolean }, command: CommandType) => { - if (opts.json) { - const ctx = await getAdminContext(command); - let doc: PolicyDocument | null = null; - try { - doc = await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/policy`, - ctx.headers, - ); - } catch { - /* no policy */ - } - console.log(JSON.stringify(doc ?? { rules: [] })); - } else { - await handlePoliciesList(command); - } - }), - ); - -// agenta sub policies set --file | --json -policiesCommand - .command('set') - .description('Set policy from JSON file or string') - .option('--file ', 'Read policy from JSON file') - .option('--json ', 'Policy as inline JSON string') - .action( - withErrorHandler(async (opts: { file?: string; json?: string }, command: CommandType) => { - if (!opts.file && !opts.json) { - throw new Error('Specify --file or --json \'{"rules":[...]}\''); - } - - let rules: Record[]; - if (opts.file) { - const { readFileSync, existsSync } = await import('node:fs'); - if (!existsSync(opts.file)) throw new Error(`File not found: ${opts.file}`); - const content = readFileSync(opts.file, 'utf-8'); - const parsed = JSON.parse(content) as PolicyDocument; - rules = parsed.rules ?? []; - } else { - const parsed = JSON.parse(opts.json!) as PolicyDocument; - rules = parsed.rules ?? []; - } - - const ctx = await getAdminContext(command); - await adminFetch( - ctx.config.serverUrl, - `/signers/${ctx.signerId}/policy`, - ctx.headers, - 'PUT', - { rules }, - ); - console.log(JSON.stringify({ success: true, ruleCount: rules.length })); - }), - ); - -// Keep interactive edit as hidden subcommand for backward compat -policiesCommand.addCommand(policiesEditCommand); - -export const adminCommand = new Command('admin') - .description('Admin operations — requires agenta login (policies, pause/resume, audit)') - .addCommand(policiesCommand) - .addCommand(pauseCommand) - .addCommand(resumeCommand) - .addCommand(auditCommand); - -export { policiesCommand, pauseCommand, resumeCommand, auditCommand }; diff --git a/packages/wallet/src/cli/commands/balance.command.ts b/packages/wallet/src/cli/commands/balance.command.ts deleted file mode 100644 index 6fbbb4a..0000000 --- a/packages/wallet/src/cli/commands/balance.command.ts +++ /dev/null @@ -1,69 +0,0 @@ -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { formatUnits } from 'viem'; -import { type SignerConfig, createClientFromConfig, loadSignerConfig } from '../../lib/config.js'; -import { brand, danger, dim, statusColor, warn } from '../theme.js'; - -export const balanceCommand = new Command('balance') - .description('Show ETH balance for the configured account') - .option('-n, --network ', 'Override default network') - .action(async (options: { network?: string }, command: CommandType) => { - const spinner = ora({ text: 'Loading configuration...', indent: 2 }).start(); - - let config: SignerConfig; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(message); - process.exitCode = 1; - return; - } - - const network = options.network ?? config.network; - if (!network) { - spinner.fail('No network specified. Use --network or set it in signer config.'); - process.exitCode = 1; - return; - } - spinner.text = `Fetching balance on ${network}...`; - - const { api } = createClientFromConfig(config); - - try { - const signer = await api.getDefaultSigner(); - const bal = await api.getBalance(signer.id, network); - - spinner.succeed(`Balance on ${network}`); - console.log(''); - console.log(` ${chalk.bold(signer.name)}`); - console.log(` Address: ${brand(signer.ethAddress)}`); - - if (bal.balances.length === 0) { - console.log(` ${warn(' No balance data available')}`); - } else { - for (const nb of bal.balances) { - const ethDisplay = formatUnits(BigInt(nb.balance), 18); - const label = ` ${nb.network}:`; - if (nb.rpcError) { - console.log(`${label.padEnd(20)}${warn('RPC error')}`); - } else { - console.log(`${label.padEnd(20)}${chalk.bold(ethDisplay)} ETH`); - } - } - } - - console.log(` Status: ${statusColor(signer.status)}`); - - const explorerUrl = await api.getExplorerTxUrl(network, signer.ethAddress); - if (explorerUrl) { - console.log(` Explorer: ${dim(explorerUrl)}`); - } - console.log(''); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Failed to fetch balance: ${message}`); - process.exitCode = 1; - } - }); diff --git a/packages/wallet/src/cli/commands/deploy.command.ts b/packages/wallet/src/cli/commands/deploy.command.ts deleted file mode 100644 index 05d45b0..0000000 --- a/packages/wallet/src/cli/commands/deploy.command.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import type { ThresholdSigner } from '@agentaos/sdk'; -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { - type SignerConfig, - createClientFromConfig, - createSignerFromConfig, - loadSignerConfig, -} from '../../lib/config.js'; -import { brand, danger, dim } from '../theme.js'; - -function isHexString(value: string): boolean { - const clean = value.startsWith('0x') ? value.slice(2) : value; - return /^[0-9a-fA-F]*$/.test(clean) && clean.length > 0 && clean.length % 2 === 0; -} - -function loadBytecode(bytecodeArg: string): string { - if (existsSync(bytecodeArg)) { - const content = readFileSync(bytecodeArg, 'utf-8').trim(); - if (!isHexString(content)) { - throw new Error('File does not contain valid hex bytecode.'); - } - return content.startsWith('0x') ? content : `0x${content}`; - } - - if (!isHexString(bytecodeArg)) { - throw new Error( - 'Bytecode must be a valid hex string or a path to a file containing hex bytecode.', - ); - } - - return bytecodeArg.startsWith('0x') ? bytecodeArg : `0x${bytecodeArg}`; -} - -export const deployCommand = new Command('deploy') - .description('Deploy a smart contract') - .argument('', 'Contract bytecode (hex string or file path)') - .option('-n, --network ', 'Override default network') - .option('--constructor-args ', 'ABI-encoded constructor arguments (hex)') - .action( - async ( - bytecodeArg: string, - options: { network?: string; constructorArgs?: string }, - command: CommandType, - ) => { - let config: SignerConfig; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(danger(`\n Error: ${message}\n`)); - process.exitCode = 1; - return; - } - - let bytecode: string; - try { - bytecode = loadBytecode(bytecodeArg); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(danger(`\n Error: ${message}\n`)); - process.exitCode = 1; - return; - } - - if (options.constructorArgs) { - const args = options.constructorArgs.startsWith('0x') - ? options.constructorArgs.slice(2) - : options.constructorArgs; - bytecode = `${bytecode}${args}`; - } - - const network = options.network ?? config.network; - if (!network) { - console.error(danger('\n Error: No network specified. Use --network .\n')); - process.exitCode = 1; - return; - } - const { api } = createClientFromConfig(config); - - console.log(chalk.bold('\n Contract Deployment')); - console.log(dim(` ${'-'.repeat(40)}`)); - console.log(` Network: ${network}`); - console.log( - ` Bytecode: ${dim(bytecode.slice(0, 24))}...${dim(`(${bytecode.length} chars)`)}`, - ); - if (options.constructorArgs) { - console.log(` Args: ${dim(options.constructorArgs.slice(0, 24))}...`); - } - console.log(''); - - const spinner = ora({ text: 'Loading keyshare...', indent: 2 }).start(); - - let signer: ThresholdSigner | undefined; - - try { - signer = await createSignerFromConfig(config); - spinner.text = 'Deploying contract (threshold ECDSA)...'; - - const transaction: Record = { - to: null, - data: bytecode, - value: '0', - network, - }; - - const result = await signer.signTransaction(transaction); - - spinner.succeed('Contract deployed successfully'); - - console.log(''); - console.log(` ${chalk.bold('Tx Hash:')} ${brand(result.txHash)}`); - - const txExplorer = await api.getExplorerTxUrl(network, result.txHash); - - if (txExplorer) { - console.log(` ${chalk.bold('Tx URL:')} ${dim(txExplorer)}`); - } - console.log(''); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Deployment failed: ${message}`); - process.exitCode = 1; - } finally { - signer?.destroy(); - } - }, - ); diff --git a/packages/wallet/src/cli/commands/info.command.ts b/packages/wallet/src/cli/commands/info.command.ts deleted file mode 100644 index 0cb1b47..0000000 --- a/packages/wallet/src/cli/commands/info.command.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Command, type Command as CommandType } from 'commander'; -import { type SignerConfig, getSignerConfigPath, loadSignerConfig } from '../../lib/config.js'; -import { brand, brandBold, dim, failMark } from '../theme.js'; - -export const infoCommand = new Command('info') - .description('Show full wallet details (copyable)') - .argument('[name]', 'Wallet name') - .action( - async (name: string | undefined, _options: Record, command: CommandType) => { - let config: SignerConfig; - try { - config = loadSignerConfig(name ?? command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`\n ${failMark(message)}\n`); - process.exitCode = 1; - return; - } - - console.log(''); - console.log(` ${brand('●')} ${brandBold(config.signerName)}`); - console.log(''); - console.log(` ${dim('Address')} ${config.ethAddress}`); - console.log(` ${dim('API Key')} ${config.apiKey.slice(0, 12)}…`); - console.log(` ${dim('Policy')} ${config.serverUrl}`); - if (config.signerId) { - console.log(` ${dim('Account ID')} ${config.signerId}`); - } - console.log(` ${dim('Config')} ${getSignerConfigPath(config.signerName)}`); - console.log(''); - }, - ); diff --git a/packages/wallet/src/cli/commands/init.command.ts b/packages/wallet/src/cli/commands/init.command.ts deleted file mode 100644 index 3c17502..0000000 --- a/packages/wallet/src/cli/commands/init.command.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { existsSync } from 'node:fs'; -import chalk from 'chalk'; -import { Command } from 'commander'; -import { - type SignerConfig, - createClientFromConfig, - getSignerConfigPath, - saveSignerConfig, - setDefaultSigner, - validateSignerName, -} from '../../lib/config.js'; -import { ensureSession } from '../../lib/ensure-session.js'; -import { storeUserShare } from '../../lib/keychain.js'; -import { isJsonMode, outputError } from '../output.js'; -import { brand, dim, failMark, successMark } from '../theme.js'; - -interface PublicCreateResponse { - signerId: string; - ethAddress: string; - apiKey: string; - signerShare: string; - userShare: string; -} - -function validateName(name: string): void { - const err = validateSignerName(name); - if (err) throw new Error(err); - if (existsSync(getSignerConfigPath(name))) throw new Error(`"${name}" already exists.`); -} - -function validateStorage(storage?: string): 'keychain' | 'file' { - if (storage && storage !== 'keychain' && storage !== 'file') { - throw new Error(`Invalid --storage "${storage}". Use "keychain" or "file".`); - } - return (storage ?? 'keychain') as 'keychain' | 'file'; -} - -// --------------------------------------------------------------------------- -// agenta sub create --name -// --------------------------------------------------------------------------- - -export const createCommand = new Command('create') - .description('Create a new sub-account') - .requiredOption('--name ', 'Sub-account name') - .option('--server ', 'Server URL') - .option('--storage ', 'Recovery key storage: keychain or file', 'keychain') - .option('--json', 'Output as JSON') - .action(async (opts: { name: string; server?: string; storage?: string }) => { - try { - validateName(opts.name); - const storage = validateStorage(opts.storage); - - const session = await ensureSession(); - if (!session.ok) throw new Error('Not logged in. Run agenta login first.'); - - const serverUrl = opts.server || session.serverUrl; - - const response = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/v1/signers`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.token}` }, - body: JSON.stringify({ name: opts.name }), - signal: AbortSignal.timeout(120_000), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Server returned ${response.status}: ${text}`); - } - - const result = (await response.json()) as PublicCreateResponse; - await storeUserShare(opts.name, result.userShare, storage); - - const config: SignerConfig = { - version: 1, - serverUrl, - apiKey: result.apiKey, - apiSecret: result.signerShare, - signerName: opts.name, - ethAddress: result.ethAddress, - signerId: result.signerId, - createdAt: new Date().toISOString(), - }; - saveSignerConfig(opts.name, config); - setDefaultSigner(opts.name); - - const out = { - name: opts.name, - address: result.ethAddress || null, - signerId: result.signerId || null, - apiKey: result.apiKey || null, - configPath: getSignerConfigPath(opts.name), - }; - if (isJsonMode()) { - console.log(JSON.stringify(out)); - } else { - console.log(`\n ${successMark('Sub-account created')}`); - console.log(` ${chalk.bold('Name:')} ${opts.name}`); - console.log(` ${chalk.bold('Address:')} ${brand(result.ethAddress)}`); - console.log(` ${chalk.bold('ID:')} ${dim(result.signerId)}`); - console.log(` ${chalk.bold('API Key:')} ${dim(result.apiKey)}`); - console.log(` ${chalk.bold('Config:')} ${dim(getSignerConfigPath(opts.name))}\n`); - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - if (isJsonMode()) { - console.error(JSON.stringify({ error: msg })); - } else { - console.error(`\n ${failMark(msg)}\n`); - } - process.exitCode = 1; - } - }); - -// --------------------------------------------------------------------------- -// agenta sub import --name --api-key --api-secret -// --------------------------------------------------------------------------- - -export const importCommand = new Command('import') - .description('Import an existing sub-account') - .requiredOption('--name ', 'Sub-account name') - .requiredOption('--api-key ', 'API key') - .requiredOption('--api-secret ', 'API secret base64') - .option('--server ', 'Server URL') - .option('--storage ', 'Recovery key storage: keychain or file', 'keychain') - .option('--json', 'Output as JSON') - .action( - async (opts: { - name: string; - apiKey: string; - apiSecret: string; - server?: string; - storage?: string; - }) => { - try { - validateName(opts.name); - const storage = validateStorage(opts.storage); - - const { getSessionServerUrl } = await import('../../lib/keychain.js'); - const serverUrl = - opts.server || - (await getSessionServerUrl()) || - process.env.AGENTA_SERVER || - 'https://api.agentaos.ai'; - - let signerId: string | undefined; - let ethAddress = ''; - - try { - const { api } = createClientFromConfig({ serverUrl, apiKey: opts.apiKey }); - const signers = await api.listSigners(); - const [s] = signers; - if (s) { - signerId = s.id; - ethAddress = s.ethAddress; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : ''; - if ( - msg.includes('401') || - msg.includes('403') || - msg.includes('nauthorized') || - msg.includes('orbidden') - ) { - throw new Error('Invalid API key or secret'); - } - } - - await storeUserShare(opts.name, opts.apiSecret, storage); - - const config: SignerConfig = { - version: 1, - serverUrl, - apiKey: opts.apiKey, - apiSecret: opts.apiSecret, - signerName: opts.name, - ethAddress, - signerId, - createdAt: new Date().toISOString(), - }; - saveSignerConfig(opts.name, config); - setDefaultSigner(opts.name); - - const out = { - name: opts.name, - address: ethAddress || null, - signerId: signerId || null, - apiKey: opts.apiKey, - configPath: getSignerConfigPath(opts.name), - }; - if (isJsonMode()) { - console.log(JSON.stringify(out)); - } else { - console.log(`\n ${successMark('Config saved')}`); - console.log(` ${chalk.bold('Name:')} ${opts.name}`); - if (ethAddress) console.log(` ${chalk.bold('Address:')} ${brand(ethAddress)}`); - if (signerId) console.log(` ${chalk.bold('ID:')} ${dim(signerId)}`); - console.log(` ${chalk.bold('Config:')} ${dim(getSignerConfigPath(opts.name))}\n`); - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - if (isJsonMode()) { - console.error(JSON.stringify({ error: msg })); - } else { - console.error(`\n ${failMark(msg)}\n`); - } - process.exitCode = 1; - } - }, - ); diff --git a/packages/wallet/src/cli/commands/link.command.ts b/packages/wallet/src/cli/commands/link.command.ts deleted file mode 100644 index e6cf787..0000000 --- a/packages/wallet/src/cli/commands/link.command.ts +++ /dev/null @@ -1,138 +0,0 @@ -import chalk from 'chalk'; -import { Command } from 'commander'; -import ora from 'ora'; -import { loadSignerConfig, resolveSignerName } from '../../lib/config.js'; -import { getSession, getUserShare } from '../../lib/keychain.js'; -import { encryptShareForTransfer, generateTransferCode } from '../../lib/transfer-crypto.js'; -import { brand, dim, failMark, hint, section, success } from '../theme.js'; - -// --------------------------------------------------------------------------- -// agenta link — Export share to another device via 6-word code -// --------------------------------------------------------------------------- - -export const linkCommand = new Command('link') - .description('Send recovery key to AgentaOS (via 6-word transfer code)') - .argument('[signer]', 'Signer name') - .option('--server ', 'Server URL override') - .action(async (signerArg: string | undefined, opts: { server?: string }) => { - try { - // 1. Require session - const token = await getSession(); - if (!token) { - console.error( - `\n ${failMark(`Not logged in. Run ${chalk.bold('agenta login')} first.`)}\n`, - ); - process.exitCode = 1; - return; - } - - const signerName = resolveSignerName(signerArg); - const config = loadSignerConfig(signerName); - const signerId = config.signerId; - if (!signerId) { - throw new Error( - 'No signer ID in config. Re-run `agenta sub create` or add signerId to config.', - ); - } - - const baseUrl = (opts.server ?? config.serverUrl).replace(/\/+$/, ''); - - // 2. Read user share from keychain (triggers biometric) - section('Send to AgentaOS'); - hint('Reading recovery key from keychain — you may be prompted for biometric auth.'); - console.log(''); - - const spinner = ora({ text: 'Reading recovery key…', indent: 2 }).start(); - const userShare = await getUserShare(signerName); - if (!userShare) { - spinner.fail('No recovery key found'); - console.error(dim('\n Was this wallet created via `agenta sub create`?\n')); - process.exitCode = 1; - return; - } - spinner.succeed('Recovery key loaded'); - - // 3. Initiate transfer on server - const initSpinner = ora({ text: 'Creating transfer session…', indent: 2 }).start(); - const initResponse = await fetch(`${baseUrl}/api/v1/auth/transfer/initiate`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ signerId, direction: 'cli_to_dashboard' }), - signal: AbortSignal.timeout(15_000), - }); - - if (!initResponse.ok) { - const text = await initResponse.text(); - initSpinner.fail('Failed to create transfer'); - throw new Error(`Server returned ${initResponse.status}: ${text}`); - } - - const { transferId, expiresAt } = (await initResponse.json()) as { - transferId: string; - expiresAt: string; - }; - initSpinner.succeed('Transfer session created'); - - // 4. Generate 6-word code + encrypt share - const encryptSpinner = ora({ text: 'Encrypting share…', indent: 2 }).start(); - const { words, transferKey } = generateTransferCode(transferId); - const shareBytes = Buffer.from(userShare, 'base64'); - const encryptedPayload = await encryptShareForTransfer(shareBytes, transferKey); - - // Wipe sensitive material - shareBytes.fill(0); - transferKey.fill(0); - encryptSpinner.succeed('Share encrypted'); - - // 5. Upload encrypted payload - const uploadSpinner = ora({ text: 'Uploading…', indent: 2 }).start(); - const uploadResponse = await fetch(`${baseUrl}/api/v1/auth/transfer/${transferId}`, { - method: 'PATCH', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ encryptedPayload }), - signal: AbortSignal.timeout(15_000), - }); - - if (!uploadResponse.ok) { - const text = await uploadResponse.text(); - uploadSpinner.fail('Failed to upload'); - throw new Error(`Server returned ${uploadResponse.status}: ${text}`); - } - uploadSpinner.succeed('Transfer ready'); - - // 6. Display code - const expiresIn = Math.max( - 0, - Math.round((new Date(expiresAt).getTime() - Date.now()) / 60_000), - ); - - console.log(''); - console.log(` ${brand('Transfer code:')}`); - console.log(''); - console.log(` ${chalk.bold.cyan(words.join(' '))}`); - console.log(''); - console.log(` ${dim(`Expires in ${expiresIn} minutes. Enter this code in AgentaOS.`)}`); - console.log( - ` ${dim(`Open AgentaOS → click ${chalk.reset('Receive')} on the signer card.`)}`, - ); - console.log(''); - console.log( - ` ${success('✓')} ${dim('The code is single-use. Once claimed, it cannot be reused.')}`, - ); - console.log(''); - } catch (error: unknown) { - if (error instanceof Error && error.name === 'ExitPromptError') { - console.log(dim('\n Cancelled.\n')); - return; - } - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`\n ${failMark(message)}\n`); - process.exitCode = 1; - } - }); diff --git a/packages/wallet/src/cli/commands/login.command.ts b/packages/wallet/src/cli/commands/login.command.ts index e9d6eea..5b53488 100644 --- a/packages/wallet/src/cli/commands/login.command.ts +++ b/packages/wallet/src/cli/commands/login.command.ts @@ -3,13 +3,14 @@ import chalk from 'chalk'; import { Command } from 'commander'; import ora from 'ora'; import { getConfigDir } from '../../lib/config.js'; +import { ensureSession } from '../../lib/ensure-session.js'; import { deleteSession, getRefreshToken, getSession, getSessionServerUrl, storeSession, -} from '../../lib/keychain.js'; +} from '../../lib/session-store.js'; import { dim, failMark, section, successMark } from '../theme.js'; // --------------------------------------------------------------------------- @@ -19,7 +20,9 @@ import { dim, failMark, section, successMark } from '../theme.js'; async function postJson(url: string, body: unknown): Promise { const response = await fetch(url, { method: 'POST', - headers: { 'content-type': 'application/json' }, + // See ensure-session.ts: without `x-client: cli` the auth endpoints hand + // tokens back as httpOnly cookies only, which a CLI cannot read. + headers: { 'content-type': 'application/json', 'x-client': 'cli' }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); @@ -54,13 +57,23 @@ export const loginCommand = new Command('login') .option('--server ', 'Server URL', process.env.AGENTA_SERVER ?? 'https://api.agentaos.ai') .action(async (opts: { server: string }) => { try { - const existing = await getSession(); - if (existing) { + // A session FILE is not a session. Every other command tells an expired + // user to run `agenta login`, so refusing them here on the strength of a + // dead token left them with no way back in at all. Only a session that + // still works is grounds for turning them away. + const existing = await ensureSession(); + if (existing.ok) { console.log( `\n ${dim('Already logged in. Run')} ${chalk.bold('agenta logout')} ${dim('to switch accounts.')}\n`, ); return; } + if (existing.reason === 'session-expired') { + // Clear it before re-authenticating: the stale refresh token is spent, + // and leaving it on disk would strand the next command the same way. + await deleteSession(); + console.log(`\n ${dim('Your previous session expired. Signing you in again.')}`); + } const baseUrl = opts.server.replace(/\/+$/, ''); @@ -98,22 +111,12 @@ export const loginCommand = new Command('login') }>(`${baseUrl}/api/v1/auth/device-code/poll`, { deviceCode }); if (result.status === 'completed' && result.token) { + // The poll already returned a fresh token pair. Immediately spending + // the refresh token to "upgrade" it burned the new one milliseconds + // after issue and left the session one race away from the server's + // reuse detection, which revokes the entire family. await storeSession(result.token, baseUrl, result.refreshToken); - if (result.refreshToken) { - try { - const refreshRes = await postJson<{ token?: string; refreshToken?: string }>( - `${baseUrl}/api/v1/auth/refresh`, - { refreshToken: result.refreshToken }, - ); - if (refreshRes.token) { - await storeSession(refreshRes.token, baseUrl, refreshRes.refreshToken); - } - } catch { - /* best-effort */ - } - } - spinner.succeed('Logged in'); console.log(''); console.log(` ${successMark(`Authenticated as ${chalk.bold(result.email ?? 'user')}`)}`); diff --git a/packages/wallet/src/cli/commands/network.command.ts b/packages/wallet/src/cli/commands/network.command.ts deleted file mode 100644 index a9c348a..0000000 --- a/packages/wallet/src/cli/commands/network.command.ts +++ /dev/null @@ -1,259 +0,0 @@ -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { type SignerConfig, loadSignerConfig, saveSignerConfig } from '../../lib/config.js'; -import { brand, dim, success, warn } from '../theme.js'; - -// --------------------------------------------------------------------------- -// agenta network list — show available networks from server -// --------------------------------------------------------------------------- - -const listCommand = new Command('list') - .description('Show available networks') - .action(async (_opts: unknown, command: CommandType) => { - const spinner = ora({ text: 'Loading configuration…', indent: 2 }).start(); - - let config: SignerConfig | undefined; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(message); - process.exitCode = 1; - return; - } - - spinner.text = 'Fetching networks…'; - - try { - const url = `${config.serverUrl.replace(/\/+$/, '')}/api/v1/networks`; - const response = await fetch(url, { - headers: { 'x-api-key': config.apiKey }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } - - const networks = (await response.json()) as { - name: string; - displayName: string; - chainId: number; - nativeCurrency: string; - isTestnet: boolean; - enabled: boolean; - }[]; - - spinner.stop(); - - console.log(''); - console.log(` ${chalk.bold('Available Networks')}`); - console.log(` ${dim('Networks supported by your AgentaOS server.')}`); - console.log(''); - - const current = config.network; - - for (const net of networks) { - const isCurrent = net.name === current; - const marker = isCurrent ? success(' ●') : ' '; - const tag = net.isTestnet ? dim(' (testnet)') : ''; - const currentLabel = isCurrent ? success(' ← default') : ''; - - console.log( - `${marker} ${chalk.bold(net.name.padEnd(20))} ${dim(`chain ${net.chainId}`).padEnd(24)} ${net.nativeCurrency}${tag}${currentLabel}`, - ); - } - - console.log(''); - if (!current) { - console.log( - ` ${warn('No default network set.')} Run ${chalk.bold('agenta network set ')} to pick one.`, - ); - console.log(''); - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Failed to fetch networks: ${message}`); - process.exitCode = 1; - } - }); - -// --------------------------------------------------------------------------- -// agenta network set — save default network to signer config -// --------------------------------------------------------------------------- - -const setCommand = new Command('set') - .description('Set the default network for an account') - .argument('', 'Network name (e.g. base-sepolia, ethereum)') - .action(async (networkName: string, _opts: unknown, command: CommandType) => { - const spinner = ora({ text: 'Loading configuration…', indent: 2 }).start(); - - let config: SignerConfig | undefined; - let signerName: string; - try { - signerName = command.optsWithGlobals().signer; - config = loadSignerConfig(signerName); - signerName = config.signerName; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(message); - process.exitCode = 1; - return; - } - - // Validate against server - spinner.text = 'Validating network…'; - - try { - const url = `${config.serverUrl.replace(/\/+$/, '')}/api/v1/networks`; - const response = await fetch(url, { - headers: { 'x-api-key': config.apiKey }, - signal: AbortSignal.timeout(10_000), - }); - - if (response.ok) { - const networks = (await response.json()) as { name: string }[]; - const valid = networks.some((n) => n.name === networkName); - if (!valid) { - spinner.fail( - `Unknown network "${networkName}". Run ${chalk.bold('agenta network list')} to see available networks.`, - ); - process.exitCode = 1; - return; - } - } - // If server unreachable, save anyway — user knows what they're doing - } catch { - // Server validation is best-effort - } - - const previous = config.network; - config.network = networkName; - saveSignerConfig(signerName, config); - - if (previous) { - spinner.succeed(`Default network changed: ${dim(previous)} → ${brand(networkName)}`); - } else { - spinner.succeed(`Default network set to ${brand(networkName)}`); - } - }); - -// --------------------------------------------------------------------------- -// agenta network get — show current default network -// --------------------------------------------------------------------------- - -const getCommand = new Command('get') - .description('Show the current default network for an account') - .action(async (_opts: unknown, command: CommandType) => { - let config: SignerConfig | undefined; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(` ${message}`); - process.exitCode = 1; - return; - } - - console.log(''); - if (config.network) { - console.log(` Default network: ${brand(config.network)}`); - console.log( - ` ${dim('Used by agenta balance, agenta send, and other commands when --network is omitted.')}`, - ); - } else { - console.log(` ${warn('No default network set.')}`); - console.log(` ${dim('Commands will require --network until you set one.')}`); - console.log(` ${dim(`Run ${chalk.bold('agenta network set ')} to pick a default.`)}`); - } - console.log(''); - }); - -// --------------------------------------------------------------------------- -// agenta network info — show details for a specific network -// --------------------------------------------------------------------------- - -const infoCommand = new Command('info') - .description('Show details for a specific network') - .argument('', 'Network name (e.g. base-sepolia)') - .action(async (networkName: string, _opts: unknown, command: CommandType) => { - const spinner = ora({ text: 'Loading configuration…', indent: 2 }).start(); - - let config: SignerConfig | undefined; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(message); - process.exitCode = 1; - return; - } - - spinner.text = 'Fetching network info…'; - - try { - const url = `${config.serverUrl.replace(/\/+$/, '')}/api/v1/networks`; - const response = await fetch(url, { - headers: { 'x-api-key': config.apiKey }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } - - const networks = (await response.json()) as { - name: string; - displayName: string; - chainId: number; - rpcUrl: string; - explorerUrl: string; - nativeCurrency: string; - isTestnet: boolean; - enabled: boolean; - }[]; - - const net = networks.find((n) => n.name === networkName); - if (!net) { - spinner.fail( - `Unknown network "${networkName}". Run ${chalk.bold('agenta network list')} to see available networks.`, - ); - process.exitCode = 1; - return; - } - - spinner.stop(); - - const isCurrent = config.network === net.name; - - console.log(''); - console.log(` ${chalk.bold(net.displayName)}`); - console.log(''); - console.log(` Name: ${brand(net.name)}${isCurrent ? success(' ← default') : ''}`); - console.log(` Chain ID: ${net.chainId}`); - console.log(` Currency: ${net.nativeCurrency}`); - console.log(` Type: ${net.isTestnet ? warn('testnet') : success('mainnet')}`); - console.log(` Explorer: ${dim(net.explorerUrl || 'none')}`); - console.log(''); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Failed to fetch network info: ${message}`); - process.exitCode = 1; - } - }); - -// --------------------------------------------------------------------------- -// Main command group -// --------------------------------------------------------------------------- - -export const networkCommand = new Command('network') - .description('Manage networks — list available chains, set your default') - .addCommand(listCommand) - .addCommand(setCommand) - .addCommand(getCommand) - .addCommand(infoCommand) - .action(() => { - // Default action: show list - listCommand.parse(process.argv); - }); diff --git a/packages/wallet/src/cli/commands/onboarding.command.ts b/packages/wallet/src/cli/commands/onboarding.command.ts new file mode 100644 index 0000000..6ddde0e --- /dev/null +++ b/packages/wallet/src/cli/commands/onboarding.command.ts @@ -0,0 +1,425 @@ +import { writeFileSync } from 'node:fs'; +import { Command } from 'commander'; +import { ensureSession } from '../../lib/ensure-session.js'; +import { auditLabel, fetchGoLive, fetchOrg, postJson, verifyLabel } from '../../lib/go-live.js'; + +/** + * Merchant onboarding: the free audit, then business verification. + * + * These commands are built for AI tools and scripts, not for a person at a + * keyboard. That decides three things and they are not negotiable per-command: + * + * - NO PROMPTS. Every input is a flag. A caller with no terminal must be able + * to complete the whole journey, so there is no path that waits on stdin. + * - JSON ONLY. Output is a single JSON object on stdout, whether or not a TTY + * is attached, so the contract never depends on how it was invoked. + * - Errors name what to do about them, in one shot: `{"error": "..."}` on + * stderr with exit code 1, listing EVERY missing flag rather than the first, + * because a caller with no terminal cannot be asked twice. + */ + +interface Ctx { + token: string; + serverUrl: string; + orgId: string; +} + +async function context(): Promise { + const session = await ensureSession(); + if (!session.ok) { + throw new Error( + session.reason === 'session-expired' + ? 'Your session expired. Run agenta login.' + : 'Not logged in. Run agenta login first.', + ); + } + const org = await fetchOrg(session.serverUrl, session.token); + if (!org) throw new Error('Could not read your organization. Is the server reachable?'); + return { token: session.token, serverUrl: session.serverUrl, orgId: org.id }; +} + +function orgQuery(ctx: Ctx, path: string): string { + return `${path}?orgId=${encodeURIComponent(ctx.orgId)}`; +} + +/** The single output path. One JSON object, always. */ +function emit(data: Record): void { + console.log(JSON.stringify(data)); +} + +function fail(error: unknown): void { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(JSON.stringify({ error: message })); + process.exitCode = 1; +} + +/** Accepted values, echoed in `--help` so a caller can discover them without + * reading our docs. Same values the merchant dashboard writes. */ +const CATEGORIES = ['saas', 'digital', 'services', 'marketplace', 'physical', 'other'] as const; +const DELIVERY = [ + 'instant_digital', + 'email_delivery', + 'subscription_access', + 'manual', + 'scheduled_service', + 'physical_shipped', + 'other', +] as const; +const VOLUME_BANDS = ['under_1k', '1k_10k', '10k_50k', 'over_50k'] as const; + +function isUrl(value: string): boolean { + return /^https?:\/\/.+\..+/.test(value.trim()); +} + +/** Reject an unknown enum value loudly instead of posting it and letting the + * server decide — the caller can fix a named mistake. */ +function checkEnum(value: string | undefined, allowed: readonly string[], flag: string): void { + if (value && !allowed.includes(value)) { + throw new Error(`${flag} must be one of: ${allowed.join(', ')}.`); + } +} + +// --------------------------------------------------------------------------- +// agenta audit — the free Revenue & Pricing Audit, onboarding step 1 +// --------------------------------------------------------------------------- + +const auditRequestCommand = new Command('request') + .description('Ask for the free Revenue & Pricing Audit') + .requiredOption('--url ', 'The page on the merchant website where they sell it') + .option('--description ', 'One sentence on what it does') + .option('--category ', CATEGORIES.join(' | ')) + .option('--delivery ', DELIVERY.join(' | ')) + .action( + async (opts: { url: string; description?: string; category?: string; delivery?: string }) => { + try { + const productUrl = opts.url.trim(); + if (!isUrl(productUrl)) throw new Error('--url must be a live URL (https://…).'); + checkEnum(opts.category, CATEGORIES, '--category'); + checkEnum(opts.delivery, DELIVERY, '--delivery'); + + const ctx = await context(); + const result = await postJson<{ review: unknown }>( + ctx.serverUrl, + orgQuery(ctx, '/gateway/account-review/audit'), + ctx.token, + { + productUrl, + ...(opts.description?.trim() ? { productDescription: opts.description.trim() } : {}), + ...(opts.category ? { productCategory: opts.category } : {}), + ...(opts.delivery ? { deliveryMethod: opts.delivery } : {}), + }, + ); + if (!result.ok) throw new Error(result.message); + + emit({ + audit: { + requested: true, + productUrl, + label: 'Being written', + writtenBy: 'a person, usually within 24 to 48 hours', + }, + }); + } catch (error: unknown) { + fail(error); + } + }, + ); + +const auditShowCommand = new Command('show') + .description('The audit state, and download the report PDF once it exists') + .option('-o, --output ', 'Save the report PDF here (default ./revenue-audit.pdf)') + .option('--no-download', 'Return the link without saving the PDF') + .action(async (opts: { output?: string; download?: boolean }) => { + try { + const ctx = await context(); + const readiness = await fetchGoLive(ctx.serverUrl, ctx.token, ctx.orgId); + if (!readiness) throw new Error('Could not read your account.'); + + const reportUrl = readiness.audit?.reportUrl ?? null; + // Saving is the point of asking, so it is the default. Only ever the + // merchant's OWN report, from the URL the server just handed us. + let savedTo: string | null = null; + if (opts.download !== false && reportUrl) { + savedTo = opts.output ?? './revenue-audit.pdf'; + const res = await fetch(reportUrl, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) throw new Error(`Could not download the report (${res.status}).`); + writeFileSync(savedTo, Buffer.from(await res.arrayBuffer())); + } + + emit({ + audit: { + requested: !!readiness.audit, + label: auditLabel(readiness), + reportUrl, + grade: readiness.audit?.grade ?? null, + savedTo, + ...(readiness.audit ? {} : { next: 'agenta audit request' }), + }, + }); + } catch (error: unknown) { + fail(error); + } + }); + +/** Named for the revenue audit specifically: this CLI already has an + * `auditCommand` for the sub-account signing log (`agenta sub audit`), which is + * an unrelated thing that happens to share the word. */ +export const revenueAuditCommand = new Command('audit') + .description('The free Revenue & Pricing Audit') + .addCommand(auditRequestCommand) + .addCommand(auditShowCommand); + +// --------------------------------------------------------------------------- +// agenta verify — business verification +// --------------------------------------------------------------------------- + +/** The five statements `--accept-declaration` attests to. Exposed as its own + * command so an agent can surface them to the merchant who is actually making + * the attestation, rather than accepting on their behalf blind. */ +const DECLARATION = [ + 'My pricing is visible before checkout, not after.', + "My product name doesn't borrow someone else's brand.", + 'Any reviews or user counts on my site are real.', + 'My site has a public Privacy Policy and Terms.', + "My product isn't built for spam, fraud or harassment.", +]; + +/** Regulated and restricted activities. Answering `--restricted` does not block + * the application: it means extra checks and a higher chance of a decline. */ +const RESTRICTED_ACTIVITIES = [ + 'Adult content', + 'Gambling', + 'Weapons', + 'Drugs & supplements', + 'Financial advice', + 'Counterfeit goods', + 'Data scraping', + 'Engagement farming', +]; + +interface SubmitOptions { + entity?: string; + legalName?: string; + registrationNumber?: string; + displayName?: string; + country?: string; + street?: string; + city?: string; + postal?: string; + addressCountry?: string; + url?: string; + description?: string; + category?: string; + delivery?: string; + volume?: string; + customers?: boolean; + restricted?: boolean; + usageClaims?: boolean; + acceptDeclaration?: boolean; +} + +/** Build the POST body from flags, reporting EVERY missing field at once. */ +function bodyFromOptions(opts: SubmitOptions): Record { + const entityType = opts.entity ?? 'business'; + if (entityType !== 'business' && entityType !== 'individual') { + throw new Error('--entity must be "business" or "individual".'); + } + + const missing: string[] = []; + const need = (value: string | undefined, flag: string): string => { + if (!value?.trim()) missing.push(flag); + return value?.trim() ?? ''; + }; + + const legalName = need(opts.legalName, '--legal-name'); + const taxCountry = need(opts.country, '--country'); + const street = need(opts.street, '--street'); + const city = need(opts.city, '--city'); + const productUrl = need(opts.url, '--url'); + const registrationNumber = + entityType === 'business' ? need(opts.registrationNumber, '--registration-number') : undefined; + if (!opts.acceptDeclaration) missing.push('--accept-declaration'); + if (missing.length) { + throw new Error( + `Missing required flags: ${missing.join(', ')}. Run agenta verify declaration to read what --accept-declaration attests to.`, + ); + } + + if (!/^[A-Za-z]{2}$/.test(taxCountry)) throw new Error('--country must be a 2-letter code.'); + // Registered in X, address in X, for very nearly everyone. + const addressCountry = (opts.addressCountry ?? taxCountry).trim(); + if (!/^[A-Za-z]{2}$/.test(addressCountry)) { + throw new Error('--address-country must be a 2-letter code.'); + } + if (!isUrl(productUrl)) throw new Error('--url must be a live URL (https://…).'); + checkEnum(opts.category, CATEGORIES, '--category'); + checkEnum(opts.delivery, DELIVERY, '--delivery'); + checkEnum(opts.volume, VOLUME_BANDS, '--volume'); + + return { + entityType, + legalName, + ...(registrationNumber ? { registrationNumber } : {}), + taxCountry: taxCountry.toUpperCase(), + address: { + street, + city, + ...(opts.postal?.trim() ? { postal: opts.postal.trim() } : {}), + country: addressCountry.toUpperCase(), + }, + productUrl, + ...(opts.description?.trim() ? { productDescription: opts.description.trim() } : {}), + displayName: (opts.displayName ?? legalName).trim(), + checklist: { + prohibited_ok: opts.restricted !== true, + checklist_ack: true, + cooldown_ack: true, + privacy_ok: true, + tos_ok: true, + has_usage_claims: opts.usageClaims === true, + no_false_claims_ok: true, + has_customers: opts.customers === true, + trademark_ok: true, + pricing_clear_ok: true, + ethical_ok: true, + ...(opts.category ? { product_category: opts.category } : {}), + ...(opts.delivery ? { delivery_method: opts.delivery } : {}), + ...(opts.volume ? { volume_band: opts.volume } : {}), + }, + }; +} + +function verificationPayload( + readiness: NonNullable>>, +): Record { + return { + state: readiness.verifyState, + label: verifyLabel(readiness), + heldReason: readiness.heldReason, + rejectReason: readiness.rejectReason, + cooldownUntil: readiness.cooldownUntil, + /** Non-empty means we are waiting on the merchant, not the other way round. */ + changesRequested: readiness.rfi?.items.map((i) => i.text) ?? [], + ...(readiness.rfi ? { next: 'agenta verify resubmit' } : {}), + }; +} + +const verifyDeclarationCommand = new Command('declaration') + .description('The five statements --accept-declaration attests to') + .action(() => { + emit({ + declaration: DECLARATION, + restrictedActivities: RESTRICTED_ACTIVITIES, + consequence: + 'If one turns out not to be true, we stop payouts and may close the account. A review rejected for a prohibited product or fraud cannot be resubmitted for three months.', + }); + }); + +const verifyStatusCommand = new Command('status') + .description('Where the verification has got to') + .action(async () => { + try { + const ctx = await context(); + const readiness = await fetchGoLive(ctx.serverUrl, ctx.token, ctx.orgId); + if (!readiness) throw new Error('Could not read your account.'); + emit({ verification: verificationPayload(readiness) }); + } catch (error: unknown) { + fail(error); + } + }); + +const verifyResubmitCommand = new Command('resubmit') + .description('Send back for review after making the changes we asked for') + .action(async () => { + try { + const ctx = await context(); + const result = await postJson<{ review: unknown }>( + ctx.serverUrl, + orgQuery(ctx, '/gateway/account-review/resubmit'), + ctx.token, + {}, + ); + if (!result.ok) throw new Error(result.message); + emit({ + verification: { + state: 'in_review', + label: 'In review', + resubmitted: true, + note: 'Nothing was retyped: the application already on file was reused.', + }, + }); + } catch (error: unknown) { + fail(error); + } + }); + +const verifySubmitCommand = new Command('submit') + .description('Submit business verification so the merchant can accept live payments') + .option('--entity ', 'business | individual (default business)') + .option('--legal-name ', 'Registered company name, or full legal name') + .option('--registration-number ', 'Company registration number (business only)') + .option('--display-name ', 'Name buyers see on their statement (default --legal-name)') + .option('--country ', 'Country of registration, 2-letter code') + .option('--street ', 'Registered address') + .option('--city ', 'City') + .option('--postal ', 'Postal code') + .option('--address-country ', '2-letter code (defaults to --country)') + .option('--url ', 'The merchant project page') + .option('--description ', 'One sentence on what it does') + .option('--category ', CATEGORIES.join(' | ')) + .option('--delivery ', DELIVERY.join(' | ')) + .option('--volume ', VOLUME_BANDS.join(' | ')) + .option('--customers', 'The merchant already has paying customers') + .option('--restricted', 'The merchant sells a regulated or restricted activity') + .option('--usage-claims', 'The site shows reviews or user counts') + .option('--accept-declaration', 'Attest to the five statements (agenta verify declaration)') + .action(async (opts: SubmitOptions) => { + try { + const ctx = await context(); + const readiness = await fetchGoLive(ctx.serverUrl, ctx.token, ctx.orgId); + // Already submitted: say so explicitly rather than silently re-posting. + if (readiness && readiness.verifyState !== 'unverified') { + emit({ + verification: { + ...verificationPayload(readiness), + submitted: false, + reason: 'already_submitted', + }, + }); + return; + } + + const body = bodyFromOptions(opts); + const result = await postJson<{ review: unknown }>( + ctx.serverUrl, + orgQuery(ctx, '/gateway/account-review'), + ctx.token, + body, + ); + if (!result.ok) throw new Error(result.message); + + emit({ + verification: { + state: 'in_review', + label: 'In review', + submitted: true, + reviewedBy: 'a person, usually within 24 to 48 hours', + ...(opts.restricted + ? { + warning: + 'A regulated or restricted activity was declared. The application still goes to review, but our payment partner runs extra checks and there is a higher chance it comes back declined.', + } + : {}), + }, + }); + } catch (error: unknown) { + fail(error); + } + }); + +export const verifyCommand = new Command('verify') + .description('Business verification, so the merchant can accept live payments') + .addCommand(verifySubmitCommand) + .addCommand(verifyStatusCommand) + .addCommand(verifyResubmitCommand) + .addCommand(verifyDeclarationCommand); diff --git a/packages/wallet/src/cli/commands/proxy.command.ts b/packages/wallet/src/cli/commands/proxy.command.ts deleted file mode 100644 index da8d871..0000000 --- a/packages/wallet/src/cli/commands/proxy.command.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { type IncomingMessage, type ServerResponse, createServer } from 'node:http'; -import type { ThresholdSigner } from '@agentaos/sdk'; -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { - type SignerConfig, - createClientFromConfig, - createSignerFromConfig, - loadSignerConfig, -} from '../../lib/config.js'; -import { brand, danger, dim, success, warn } from '../theme.js'; - -interface JsonRpcRequest { - readonly jsonrpc: '2.0'; - readonly method: string; - readonly params?: readonly unknown[]; - readonly id: number | string | null; -} - -interface JsonRpcResponse { - readonly jsonrpc: '2.0'; - readonly id: number | string | null; - readonly result?: unknown; - readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown }; -} - -const SIGNING_METHODS = new Set([ - 'eth_sendTransaction', - 'eth_signTransaction', - 'eth_sign', - 'personal_sign', -]); - -const ACCOUNT_METHODS = new Set(['eth_accounts', 'eth_requestAccounts']); - -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - req.on('error', reject); - }); -} - -function sendJsonResponse(res: ServerResponse, body: JsonRpcResponse): void { - const json = JSON.stringify(body); - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(json), - }); - res.end(json); -} - -function makeErrorResponse( - id: number | string | null, - code: number, - message: string, -): JsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; -} - -async function forwardToRpc(rpcUrl: string, request: JsonRpcRequest): Promise { - const response = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) { - const text = await response.text(); - return makeErrorResponse(request.id, -32603, `RPC error: HTTP ${response.status} -- ${text}`); - } - return response.json() as Promise; -} - -async function handleSigningRequest( - request: JsonRpcRequest, - signer: ThresholdSigner, -): Promise { - const params = request.params; - if (!params || !Array.isArray(params) || params.length === 0) { - return makeErrorResponse(request.id, -32602, 'Missing transaction parameters'); - } - - try { - if (request.method === 'eth_sendTransaction' || request.method === 'eth_signTransaction') { - const txParams = params[0] as Record | undefined; - if (!txParams || typeof txParams !== 'object') { - return makeErrorResponse(request.id, -32602, 'Invalid transaction parameters'); - } - - const transaction: Record = {}; - if (txParams.to !== undefined) transaction.to = txParams.to; - if (txParams.value !== undefined) transaction.value = txParams.value; - if (txParams.data !== undefined) transaction.data = txParams.data; - if (txParams.input !== undefined) transaction.data = txParams.input; - if (txParams.gas !== undefined) transaction.gasLimit = txParams.gas; - if (txParams.gasLimit !== undefined) transaction.gasLimit = txParams.gasLimit; - if (txParams.nonce !== undefined) { - const raw = txParams.nonce; - transaction.nonce = typeof raw === 'string' ? Number.parseInt(raw, 16) : raw; - } - if (txParams.chainId !== undefined) { - const raw = txParams.chainId; - transaction.chainId = typeof raw === 'string' ? Number.parseInt(raw, 16) : raw; - } - - const result = await signer.signTransaction(transaction); - return { jsonrpc: '2.0', id: request.id, result: result.txHash }; - } - - // eth_sign and personal_sign - const message = typeof params[0] === 'string' ? params[0] : String(params[0]); - const signResult = await signer.signMessage(message); - return { jsonrpc: '2.0', id: request.id, result: signResult.signature }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - return makeErrorResponse(request.id, -32603, `Signing failed: ${msg}`); - } -} - -export const proxyCommand = new Command('proxy') - .description('Start a network-agnostic JSON-RPC signing proxy for Foundry/Hardhat') - .option('-p, --port ', 'Port to listen on', '8545') - .option('-r, --rpc-url ', 'Override upstream RPC URL (default: auto-detected from server)') - .action(async (options: { port: string; rpcUrl?: string }, command: CommandType) => { - let config: SignerConfig; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(danger(`\n Error: ${message}\n`)); - process.exitCode = 1; - return; - } - - const port = Number.parseInt(options.port, 10); - if (Number.isNaN(port) || port < 1 || port > 65535) { - console.error(danger('\n Error: Port must be a number between 1 and 65535.\n')); - process.exitCode = 1; - return; - } - - console.log(chalk.bold('\n AgentaOS RPC Proxy')); - console.log(dim(` ${'-'.repeat(40)}`)); - - const { api } = createClientFromConfig(config); - - // Fetch network config from the server - const networkSpinner = ora({ text: 'Fetching networks from server...', indent: 2 }).start(); - let rpcUrl: string; - let networkName: string; - - try { - if (options.rpcUrl) { - rpcUrl = options.rpcUrl; - networkName = config.network ?? ''; - networkSpinner.succeed('Using custom RPC URL'); - console.log(dim(` Network: ${networkName} (RPC override)`)); - } else { - const networks = await api.listNetworks(); - networkSpinner.succeed(`Loaded ${networks.length} networks from server`); - - if (!config.network) { - networkSpinner.fail('No network specified. Use --rpc-url or set network in config.'); - process.exitCode = 1; - return; - } - const matched = networks.find((n) => n.name === config.network); - if (!matched) { - console.error(danger(`\n Error: Network "${config.network}" not found on server.`)); - console.error(dim(` Available: ${networks.map((n) => n.name).join(', ')}\n`)); - process.exitCode = 1; - return; - } - rpcUrl = matched.rpcUrl; - networkName = matched.displayName; - console.log(` Network: ${networkName} (chainId: ${matched.chainId})`); - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - networkSpinner.fail(`Failed to fetch networks: ${message}`); - process.exitCode = 1; - return; - } - - console.log(` RPC: ${rpcUrl}`); - console.log(''); - - const signerSpinner = ora({ text: 'Loading keyshare...', indent: 2 }).start(); - - let signer: ThresholdSigner; - try { - signer = await createSignerFromConfig(config); - signerSpinner.succeed(`Keyshare loaded (address: ${signer.address})`); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - signerSpinner.fail(`Failed to load keyshare: ${message}`); - process.exitCode = 1; - return; - } - - let requestCount = 0; - - const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Method not allowed' })); - return; - } - - try { - const body = await readRequestBody(req); - let rpcRequest: JsonRpcRequest; - try { - rpcRequest = JSON.parse(body) as JsonRpcRequest; - } catch { - sendJsonResponse(res, makeErrorResponse(null, -32700, 'Parse error')); - return; - } - - requestCount++; - const reqNum = requestCount; - const method = rpcRequest.method; - - if (ACCOUNT_METHODS.has(method)) { - console.log(dim(` #${reqNum} ${method} -> [${signer.address}]`)); - sendJsonResponse(res, { jsonrpc: '2.0', id: rpcRequest.id, result: [signer.address] }); - } else if (SIGNING_METHODS.has(method)) { - console.log(`${warn(` #${reqNum}`)}${dim(` ${method} `)}${warn('(signing)')}`); - const response = await handleSigningRequest(rpcRequest, signer); - sendJsonResponse(res, response); - if (response.error) { - console.log(danger(` #${reqNum} error: ${response.error.message}`)); - } else { - console.log(success(` #${reqNum} done`)); - } - } else { - console.log(dim(` #${reqNum} ${method} -> RPC`)); - const response = await forwardToRpc(rpcUrl, rpcRequest); - sendJsonResponse(res, response); - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - sendJsonResponse(res, makeErrorResponse(null, -32603, msg)); - } - }); - - const shutdown = (): void => { - console.log(dim('\n Shutting down proxy...')); - try { - signer.destroy(); - console.log(dim(' Share wiped from memory.')); - } catch { - // ignore destroy errors - } - server.close(() => { - console.log(dim(' Proxy stopped.\n')); - process.exit(0); - }); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - server.listen(port, () => { - console.log(''); - console.log(`${success(' Proxy running on ')}${chalk.bold(`http://localhost:${port}`)}`); - console.log(''); - console.log(dim(' Usage with Foundry:')); - console.log(dim(` forge script Script.s.sol --rpc-url http://localhost:${port}`)); - console.log(dim(' Usage with cast:')); - console.log(dim(` cast send --rpc-url http://localhost:${port}`)); - console.log(''); - console.log(dim(' Press Ctrl+C to stop.\n')); - }); - }); diff --git a/packages/wallet/src/cli/commands/receive.command.ts b/packages/wallet/src/cli/commands/receive.command.ts deleted file mode 100644 index 1103339..0000000 --- a/packages/wallet/src/cli/commands/receive.command.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { existsSync } from 'node:fs'; -import { confirm, input, select } from '@inquirer/prompts'; -import chalk from 'chalk'; -import { Command } from 'commander'; -import ora from 'ora'; -import { - getSignerConfigPath, - loadRecoveryMeta, - loadSignerConfig, - saveRecoveryMeta, - validateSignerName, -} from '../../lib/config.js'; -import { - getSession, - getSessionServerUrl, - getUserShare, - storeUserShare, -} from '../../lib/keychain.js'; -import { decryptShareFromTransfer, deriveTransferKey } from '../../lib/transfer-crypto.js'; -import { dim, failMark, hint, promptTheme, section, success, successMark } from '../theme.js'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface PublicSigner { - id: string; - name: string; - ethAddress: string; - network?: string; - status: string; -} - -interface PendingTransfer { - transferId: string; - direction: string; - expiresAt: string; -} - -/** Fully resolved context — all fields guaranteed present after resolution. */ -interface ResolvedContext { - signerName: string; - signerId: string; - ethAddress: string; - baseUrl: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function truncateAddress(addr: string): string { - if (addr.length <= 10) return addr; - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; -} - -async function fetchWithAuth( - url: string, - token: string, - opts: { method?: string; body?: unknown } = {}, -): Promise<{ ok: true; data: T } | { ok: false; status: number; text: string }> { - const headers: Record = { authorization: `Bearer ${token}` }; - const init: RequestInit = { - method: opts.method ?? 'GET', - headers, - signal: AbortSignal.timeout(15_000), - }; - if (opts.body !== undefined) { - headers['content-type'] = 'application/json'; - init.body = JSON.stringify(opts.body); - } - - let response: Response; - try { - response = await fetch(url, init); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { ok: false, status: 0, text: message }; - } - - if (!response.ok) { - const text = await response.text(); - return { ok: false, status: response.status, text }; - } - - return { ok: true, data: (await response.json()) as T }; -} - -// --------------------------------------------------------------------------- -// Server URL resolution (priority order) -// --------------------------------------------------------------------------- - -async function resolveServerUrl(opts: { - signerName?: string; - cliFlag?: string; -}): Promise { - // 1. Local signer config (agenta sub create was run) - if (opts.signerName) { - const configPath = getSignerConfigPath(opts.signerName); - if (existsSync(configPath)) { - try { - const config = loadSignerConfig(opts.signerName); - return config.serverUrl; - } catch { - // Config exists but is malformed — fall through - } - } - - // 2. Recovery metadata (previous agenta receive) - const meta = loadRecoveryMeta(opts.signerName); - if (meta) return meta.serverUrl; - } - - // 3. --server CLI flag - if (opts.cliFlag) return opts.cliFlag; - - // 4. Session file (stored during agenta login) - const sessionUrl = await getSessionServerUrl(); - if (sessionUrl) return sessionUrl; - - // 5. No URL found - return null; -} - -// --------------------------------------------------------------------------- -// Context resolution — populates signerName, signerId, ethAddress, baseUrl -// --------------------------------------------------------------------------- - -async function resolveContext( - signerArg: string | undefined, - serverFlag: string | undefined, - token: string, -): Promise { - // Path A: Local signer config exists (agenta sub create was run on this device) - if (signerArg) { - const configPath = getSignerConfigPath(signerArg); - if (existsSync(configPath)) { - try { - const config = loadSignerConfig(signerArg); - if (config.signerId) { - return { - signerName: signerArg, - signerId: config.signerId, - ethAddress: config.ethAddress, - baseUrl: (serverFlag ?? config.serverUrl).replace(/\/+$/, ''), - }; - } - } catch { - // Config malformed — fall through - } - } - - // Path B: Recovery metadata exists (second+ receive on same device) - const meta = loadRecoveryMeta(signerArg); - if (meta) { - return { - signerName: meta.signerName, - signerId: meta.signerId, - ethAddress: meta.ethAddress, - baseUrl: (serverFlag ?? meta.serverUrl).replace(/\/+$/, ''), - }; - } - } - - // Path C: Server discovery (first receive on fresh device) - return resolveFromServer(signerArg, serverFlag, token); -} - -async function resolveFromServer( - signerArg: string | undefined, - serverFlag: string | undefined, - token: string, -): Promise { - const resolvedUrl = await resolveServerUrl({ - signerName: signerArg, - cliFlag: serverFlag, - }); - - let baseUrl: string; - if (resolvedUrl) { - baseUrl = resolvedUrl; - } else { - baseUrl = await input({ - message: 'Server URL', - default: process.env.AGENTA_SERVER ?? 'https://api.agentaos.ai', - theme: promptTheme, - }); - } - baseUrl = baseUrl.replace(/\/+$/, ''); - - section('Receive share'); - hint('Checking for pending transfers…'); - console.log(''); - - // Fetch signers from server - const spinner = ora({ text: 'Connecting to server…', indent: 2 }).start(); - const signersResult = await fetchWithAuth(`${baseUrl}/api/v1/signers`, token); - - if (!signersResult.ok) { - spinner.fail('Could not connect to server'); - if (signersResult.status === 401) { - console.error( - `\n ${failMark(`Session expired. Run ${chalk.bold('agenta login')} again.`)}\n`, - ); - } else if (signersResult.status === 0) { - console.error( - `\n ${failMark(`Could not reach server at ${baseUrl}. Check the URL and try again.`)}\n`, - ); - } else { - console.error( - `\n ${failMark(`Server error (${signersResult.status}): ${signersResult.text}`)}\n`, - ); - } - return null; - } - - spinner.succeed('Connected to server'); - - const signers = signersResult.data; - if (signers.length === 0) { - console.log(''); - console.log( - ` ${failMark('No accounts found. Create one in AgentaOS or run')} ${chalk.bold('agenta sub create')} ${dim('on the agent device first.')}`, - ); - console.log(''); - return null; - } - - // Match by name or pick interactively - let picked: PublicSigner; - if (signerArg) { - const match = signers.find((s) => s.name === signerArg); - if (!match) { - const available = signers.map((s) => s.name).join(', '); - console.log(''); - console.log( - ` ${failMark(`No account named "${signerArg}" found. Available: ${available}`)}`, - ); - console.log(''); - return null; - } - picked = match; - } else if (signers.length === 1) { - picked = signers[0] as PublicSigner; - console.log( - ` Found 1 account: ${chalk.bold(picked.name)} (${truncateAddress(picked.ethAddress)})`, - ); - } else { - console.log(` Found ${signers.length} accounts:`); - console.log(''); - picked = await select({ - message: 'Which account do you want to receive the share for?', - choices: signers.map((s) => ({ - name: `${s.name} (${truncateAddress(s.ethAddress)})`, - value: s, - })), - theme: promptTheme, - }); - } - - // Validate signer name from server (defense against path traversal) - const nameError = validateSignerName(picked.name); - if (nameError) { - console.error( - `\n ${failMark(`Invalid account name from server: "${picked.name}". ${nameError}`)}\n`, - ); - return null; - } - - return { - signerName: picked.name, - signerId: picked.id, - ethAddress: picked.ethAddress, - baseUrl, - }; -} - -// --------------------------------------------------------------------------- -// agenta receive [signer] — Receive a share from another device via 6-word code -// --------------------------------------------------------------------------- - -export const receiveCommand = new Command('receive') - .description('Receive a wallet share from another device (enter 6-word code)') - .argument('[signer]', 'Signer name') - .option('--server ', 'Server URL override') - .action(async (signerArg: string | undefined, opts: { server?: string }) => { - try { - // 1. Require session - const token = await getSession(); - if (!token) { - console.error( - `\n ${failMark(`Not logged in. Run ${chalk.bold('agenta login')} first.`)}\n`, - ); - process.exitCode = 1; - return; - } - - // 2. Resolve signer context (3 paths: local config → recovery meta → server) - const ctx = await resolveContext(signerArg, opts.server, token); - if (!ctx) { - process.exitCode = 1; - return; - } - - const { signerName, signerId, ethAddress, baseUrl } = ctx; - - // Show section header if not already shown by server discovery (Path C) - if (signerArg) { - const hasLocalConfig = existsSync(getSignerConfigPath(signerArg)); - const hasRecoveryMeta = loadRecoveryMeta(signerArg) !== null; - if (hasLocalConfig || hasRecoveryMeta) { - section('Receive share'); - hint('Checking for pending transfers…'); - console.log(''); - } - } - - // 3. Check if share already exists locally - const existingShare = await getUserShare(signerName); - if (existingShare) { - console.log(''); - console.log( - dim(` This device already has a recovery key for ${chalk.reset.bold(signerName)}.`), - ); - const overwrite = await confirm({ - message: 'Overwrite the existing recovery key?', - default: false, - theme: promptTheme, - }); - if (!overwrite) { - console.log(dim('\n Cancelled.\n')); - return; - } - } - - // 4. Check for pending transfer - const pendingSpinner = ora({ text: 'Looking for pending transfer…', indent: 2 }).start(); - const pendingResult = await fetchWithAuth( - `${baseUrl}/api/v1/auth/transfer/pending?signerId=${signerId}`, - token, - ); - - if (!pendingResult.ok) { - pendingSpinner.fail('Failed to check transfers'); - if (pendingResult.status === 401) { - console.error( - `\n ${failMark(`Session expired. Run ${chalk.bold('agenta login')} again.`)}\n`, - ); - } else { - console.error( - `\n ${failMark(`Server returned ${pendingResult.status}: ${pendingResult.text}`)}\n`, - ); - } - process.exitCode = 1; - return; - } - - const pending = pendingResult.data; - if (!pending || !pending.transferId) { - pendingSpinner.info('No pending transfer found'); - console.log(''); - console.log( - dim(` Run ${chalk.reset(`agenta link ${signerName}`)} on the source device first.`), - ); - console.log(''); - return; - } - - pendingSpinner.succeed('Pending transfer found'); - - // 5. Prompt for 6-word code - console.log(''); - const wordsInput = await input({ - message: 'Enter the 6-word transfer code', - theme: promptTheme, - validate: (v) => { - const words = v.trim().split(/\s+/); - if (words.length !== 6) return 'Enter exactly 6 words separated by spaces'; - return true; - }, - }); - - const words = wordsInput - .trim() - .split(/\s+/) - .map((w) => w.toLowerCase()); - - // 6. Derive key + claim transfer - const claimSpinner = ora({ text: 'Claiming transfer…', indent: 2 }).start(); - - let transferKey: Uint8Array; - try { - transferKey = deriveTransferKey(words, pending.transferId); - } catch (err) { - claimSpinner.fail('Invalid transfer code'); - throw err; - } - - const claimResult = await fetchWithAuth<{ encryptedPayload: string; lockExpiresAt: string }>( - `${baseUrl}/api/v1/auth/transfer/${pending.transferId}/claim`, - token, - { method: 'POST' }, - ); - - if (!claimResult.ok) { - transferKey.fill(0); - claimSpinner.fail('Failed to claim transfer'); - throw new Error(`Server returned ${claimResult.status}: ${claimResult.text}`); - } - - claimSpinner.succeed('Transfer claimed'); - - // 7. Decrypt share - const decryptSpinner = ora({ text: 'Decrypting share…', indent: 2 }).start(); - let shareBytes: Uint8Array; - try { - shareBytes = await decryptShareFromTransfer(claimResult.data.encryptedPayload, transferKey); - } catch { - transferKey.fill(0); - decryptSpinner.fail('Decryption failed — wrong transfer code'); - process.exitCode = 1; - return; - } - transferKey.fill(0); - decryptSpinner.succeed('Share decrypted'); - - // 8. Store in keychain - const storeSpinner = ora({ text: 'Storing recovery key…', indent: 2 }).start(); - const shareBase64 = Buffer.from(shareBytes).toString('base64'); - shareBytes.fill(0); - await storeUserShare(signerName, shareBase64); - storeSpinner.succeed('Recovery key stored'); - - // 9. Confirm transfer (non-critical — share is already stored locally) - const confirmSpinner = ora({ text: 'Confirming transfer…', indent: 2 }).start(); - try { - const confirmResult = await fetchWithAuth( - `${baseUrl}/api/v1/auth/transfer/${pending.transferId}/confirm`, - token, - { method: 'POST' }, - ); - - if (!confirmResult.ok) { - confirmSpinner.warn('Confirmation failed — share is still stored locally'); - } else { - confirmSpinner.succeed('Transfer confirmed'); - } - } catch { - confirmSpinner.warn('Could not confirm transfer — share is still stored locally'); - } - - // 10. Save recovery metadata (so next agenta receive skips server discovery) - saveRecoveryMeta(signerName, { - signerName, - signerId, - ethAddress, - serverUrl: baseUrl, - receivedAt: new Date().toISOString(), - }); - - console.log(''); - console.log(` ${successMark(`Share for ${chalk.bold(signerName)} received and stored`)}`); - console.log(''); - console.log(` ${success('Done!')} This device now holds the recovery key.`); - console.log(` Run ${chalk.bold('agenta admin policies')} to manage policies.`); - console.log(''); - } catch (error: unknown) { - if (error instanceof Error && error.name === 'ExitPromptError') { - console.log(dim('\n Cancelled.\n')); - return; - } - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`\n ${failMark(message)}\n`); - process.exitCode = 1; - } - }); diff --git a/packages/wallet/src/cli/commands/send.command.ts b/packages/wallet/src/cli/commands/send.command.ts deleted file mode 100644 index a793816..0000000 --- a/packages/wallet/src/cli/commands/send.command.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ThresholdSigner } from '@agentaos/sdk'; -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { parseEther } from 'viem'; -import { - type SignerConfig, - createClientFromConfig, - createSignerFromConfig, - loadSignerConfig, -} from '../../lib/config.js'; -import { brand, danger, dim } from '../theme.js'; - -export const sendCommand = new Command('send') - .description('Send ETH to an address') - .argument('', 'Destination address (0x...)') - .argument('', 'Amount in ETH (e.g., 0.01)') - .option('-n, --network ', 'Override default network') - .option('--gas-limit ', 'Gas limit') - .option('--data ', 'Calldata as hex string') - .action( - async ( - to: string, - amount: string, - options: { network?: string; gasLimit?: string; data?: string }, - command: CommandType, - ) => { - if (!/^0x[0-9a-fA-F]{40}$/.test(to)) { - console.error(danger('\n Error: Invalid Ethereum address format.\n')); - process.exitCode = 1; - return; - } - - const amountFloat = Number.parseFloat(amount); - if (Number.isNaN(amountFloat) || amountFloat <= 0) { - console.error(danger('\n Error: Amount must be a positive number.\n')); - process.exitCode = 1; - return; - } - - let config: SignerConfig; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(danger(`\n Error: ${message}\n`)); - process.exitCode = 1; - return; - } - - const network = options.network ?? config.network; - if (!network) { - console.error(danger('\n Error: No network specified. Use --network .\n')); - process.exitCode = 1; - return; - } - const valueWei = parseEther(amount).toString(); - const { api } = createClientFromConfig(config); - - console.log(chalk.bold('\n Transaction Details')); - console.log(dim(` ${'-'.repeat(40)}`)); - console.log(` To: ${brand(to)}`); - console.log(` Amount: ${chalk.bold(amount)} ETH (${valueWei} wei)`); - console.log(` Network: ${network}`); - if (options.gasLimit) console.log(` Gas: ${options.gasLimit}`); - if (options.data) console.log(` Data: ${options.data.slice(0, 20)}...`); - console.log(''); - - const spinner = ora({ text: 'Loading keyshare...', indent: 2 }).start(); - - let signer: ThresholdSigner | undefined; - - try { - signer = await createSignerFromConfig(config); - spinner.text = 'Signing transaction (threshold ECDSA)...'; - - const transaction: Record = { to, value: valueWei, network }; - if (options.gasLimit) transaction.gasLimit = options.gasLimit; - if (options.data) transaction.data = options.data; - - const result = await signer.signTransaction(transaction); - - spinner.succeed('Transaction signed and broadcast'); - - const explorerUrl = await api.getExplorerTxUrl(network, result.txHash); - - console.log(''); - console.log(` ${chalk.bold('Tx Hash:')} ${brand(result.txHash)}`); - if (explorerUrl) { - console.log(` ${chalk.bold('Explorer:')} ${dim(explorerUrl)}`); - } - console.log(''); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Transaction failed: ${message}`); - process.exitCode = 1; - } finally { - signer?.destroy(); - } - }, - ); diff --git a/packages/wallet/src/cli/commands/sign.command.ts b/packages/wallet/src/cli/commands/sign.command.ts deleted file mode 100644 index bd96e2f..0000000 --- a/packages/wallet/src/cli/commands/sign.command.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ThresholdSigner } from '@agentaos/sdk'; -import chalk from 'chalk'; -import { Command, type Command as CommandType } from 'commander'; -import ora from 'ora'; -import { type SignerConfig, createSignerFromConfig, loadSignerConfig } from '../../lib/config.js'; -import { brand, danger, dim } from '../theme.js'; - -export const signMessageCommand = new Command('sign-message') - .description('Sign a message using threshold ECDSA') - .argument('', 'Message to sign (string or hex with 0x prefix)') - .action(async (message: string, _options: Record, command: CommandType) => { - let config: SignerConfig; - try { - config = loadSignerConfig(command.optsWithGlobals().signer); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - console.error(danger(`\n Error: ${msg}\n`)); - process.exitCode = 1; - return; - } - - console.log(chalk.bold('\n Sign Message')); - console.log(dim(` ${'-'.repeat(40)}`)); - - const isHex = /^0x[0-9a-fA-F]*$/.test(message); - if (isHex) { - console.log(` Message: ${dim(message.slice(0, 40))}${message.length > 40 ? '...' : ''}`); - } else { - console.log( - ` Message: ${dim(`"${message.slice(0, 60)}"`)}${message.length > 60 ? '...' : ''}`, - ); - } - console.log(''); - - const spinner = ora({ text: 'Loading keyshare...', indent: 2 }).start(); - - let signer: ThresholdSigner | undefined; - - try { - signer = await createSignerFromConfig(config); - spinner.text = 'Signing message (threshold ECDSA)...'; - - const result = await signer.signMessage(message); - - spinner.succeed('Message signed successfully'); - - console.log(''); - console.log(` ${chalk.bold('v:')} ${result.v}`); - console.log(` ${chalk.bold('r:')} ${brand(result.r)}`); - console.log(` ${chalk.bold('s:')} ${brand(result.s)}`); - console.log(` ${chalk.bold('sig:')} ${dim(result.signature)}`); - console.log(''); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - spinner.fail(`Signing failed: ${msg}`); - process.exitCode = 1; - } finally { - signer?.destroy(); - } - }); diff --git a/packages/wallet/src/cli/commands/status.command.ts b/packages/wallet/src/cli/commands/status.command.ts index db2da6e..bb9a871 100644 --- a/packages/wallet/src/cli/commands/status.command.ts +++ b/packages/wallet/src/cli/commands/status.command.ts @@ -1,341 +1,96 @@ -import chalk from 'chalk'; import { Command } from 'commander'; -import ora from 'ora'; -import { formatUnits } from 'viem'; -import { - type SignerConfig, - createClientFromConfig, - getConfigDir, - getDefaultSignerName, - listSigners, - loadSignerConfig, -} from '../../lib/config.js'; -import { decodeJwt, ensureSession } from '../../lib/ensure-session.js'; -import { isJsonMode } from '../output.js'; -import { brand, brandBold, brandDot, dim, failMark, statusColor, successMark } from '../theme.js'; - -// --------------------------------------------------------------------------- -// JWT decode (display only, no verification) -// --------------------------------------------------------------------------- - -const decodeJwtPayload = decodeJwt; - -function formatExpiry(exp: number): string { - const remaining = exp * 1000 - Date.now(); - if (remaining <= 0) return chalk.red('Expired'); - const hours = Math.floor(remaining / 3_600_000); - const mins = Math.floor((remaining % 3_600_000) / 60_000); - const timeStr = new Date(exp * 1000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ' UTC'); - return `${timeStr} (${hours}h ${mins}m remaining)`; -} - -// --------------------------------------------------------------------------- -// Wallet helpers (existing) -// --------------------------------------------------------------------------- - -// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping is intentional -const ANSI_RE = /\u001B\[[0-9;]*m/g; - -function pad(str: string, width: number): string { - const visible = str.replace(ANSI_RE, ''); - return str + ' '.repeat(Math.max(0, width - visible.length)); -} - -function fmtAddr(address: string): string { - if (!address || address.length < 10) return dim('—'); - return dim(`${address.slice(0, 6)}…${address.slice(-4)}`); -} - -interface WalletInfo { - name: string; - address: string; - status: string; - balance: string | undefined; - policies: number | undefined; - isDefault: boolean; -} - -async function fetchWalletInfo(name: string, config: SignerConfig): Promise { - const info: WalletInfo = { - name, - address: config.ethAddress || '', - status: 'unknown', - balance: undefined, - policies: undefined, - isDefault: false, - }; - try { - const { api } = createClientFromConfig(config); - const signers = await api.listSigners(); - const [s] = signers; - if (s) { - info.status = s.status; - info.balance = s.balance; - info.policies = s.policyCount; - if (s.ethAddress) info.address = s.ethAddress; - } - } catch { - info.status = 'offline'; - } - return info; -} - -// --------------------------------------------------------------------------- -// Command -// --------------------------------------------------------------------------- - +import { getConfigDir } from '../../lib/config.js'; +import { type SessionResult, decodeJwt, ensureSession } from '../../lib/ensure-session.js'; +import { auditLabel, fetchGoLive, fetchOrg, nextStep, verifyLabel } from '../../lib/go-live.js'; + +/** + * `agenta status` — who you are, and how far along going live you are. + * + * Built for AI tools: one JSON object on stdout, always. It used to lead with + * wallet activation and sub-account listings, which meant a perfectly healthy + * merchant-of-record account reported a red "Activate wallet first" forever, + * because an MoR merchant never has a wallet. Those surfaces are gone. + */ export const statusCommand = new Command('status') .alias('whoami') - .description('Show account and connection status') - .option('--json', 'Output as JSON') + .description('Account and go-live overview') .action(async () => { try { const session = await ensureSession(); - - // --- JSON mode --- - if (isJsonMode()) { - await outputStatusJson(session); - return; - } - - // --- Human mode --- - console.log(''); - console.log(` ${chalk.bold('AgentaOS')} ${dim('CLI')}`); - console.log(` ${dim('─'.repeat(35))}`); - - // --- Account info from JWT --- - if (!session.ok) { - if (session.reason === 'session-expired') { - console.log(` ${dim('Account:')} ${chalk.red('Session expired')}`); - console.log( - ` ${dim(' ')} Run ${chalk.bold('agenta login')} to re-authenticate.`, - ); - } else { - console.log(` ${dim('Account:')} ${chalk.yellow('Not logged in')}`); - console.log( - ` ${dim(' ')} Run ${chalk.bold('agenta login')} to get started.`, - ); - } - } - - const token = session.ok ? session.token : null; - const serverUrl = session.ok ? session.serverUrl : null; - const payload = token ? decodeJwtPayload(token) : null; - const email = payload?.email as string | undefined; - const exp = payload?.exp as number | undefined; - - if (email) console.log(` ${dim('Account:')} ${email}`); - - // --- Fetch org info from server --- - let orgName: string | undefined; - let walletAddress: string | null = null; - if (token && serverUrl) { - try { - const orgsRes = await fetch(`${serverUrl}/api/v1/orgs`, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(5_000), - }); - if (orgsRes.ok) { - const orgs = (await orgsRes.json()) as Array<{ - name?: string; - wallet_address?: string; - }>; - if (orgs[0]) { - orgName = orgs[0].name; - walletAddress = orgs[0].wallet_address ?? null; - } - } - } catch { - // Offline — show what we can from JWT - } - } - - if (orgName) console.log(` ${dim('Organization:')} ${orgName}`); - if (walletAddress) { - const short = `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; - console.log(` ${dim('Wallet:')} ${short}`); - } else if (orgName) { - console.log(` ${dim('Wallet:')} ${chalk.yellow('Not activated')}`); - } - if (exp) console.log(` ${dim('JWT expires:')} ${formatExpiry(exp)}`); - if (serverUrl) console.log(` ${dim('Server:')} ${serverUrl}`); - console.log(` ${dim('Config:')} ${getConfigDir()}`); - - // --- Tool readiness --- - console.log(''); - if (walletAddress) { - console.log(` ${dim('Payment tools:')} ${successMark('Ready')}`); - } else { - console.log( - ` ${dim('Payment tools:')} ${failMark('Activate wallet first')} → ${dim('agenta login')}`, - ); - } - - const signerNames = listSigners(); - if (signerNames.length > 0) { - console.log( - ` ${dim('Agent accounts:')} ${successMark(`Ready (${signerNames.length} sub-account${signerNames.length > 1 ? 's' : ''})`)}`, - ); - } else { - console.log( - ` ${dim('Agent accounts:')} ${dim('Run')} ${chalk.bold('agenta sub create')} ${dim('to create a sub-account')}`, - ); - } - - // --- Next steps --- - console.log(''); - if (!walletAddress) { - console.log( - ` ${dim('Next:')} ${chalk.bold('agenta login')} ${dim('to activate your wallet')}`, - ); - } else if (signerNames.length === 0) { - console.log( - ` ${dim('Next:')} ${chalk.bold('agenta pay checkout -a 50')} ${dim('to create a checkout')}`, - ); - console.log( - ` ${chalk.bold('agenta sub create')} ${dim('to create an agent sub-account')}`, - ); - } else { - console.log( - ` ${dim('Next:')} ${chalk.bold('agenta pay checkout -a 50')} ${dim('to create a checkout')}`, - ); - console.log(` ${chalk.bold('agenta pay list')} ${dim('to view your checkouts')}`); - } - - // --- Signer wallets (if any) --- - if (signerNames.length > 0) { - const defaultName = getDefaultSignerName(); - const spinner = ora({ - text: `Checking ${signerNames.length} sub-account${signerNames.length > 1 ? 's' : ''}…`, - indent: 2, - }).start(); - - const wallets = await Promise.all( - signerNames.map(async (name) => { - try { - const config = loadSignerConfig(name); - const info = await fetchWalletInfo(name, config); - info.isDefault = name === defaultName; - return info; - } catch { - return { - name, - address: '', - status: 'error', - balance: undefined, - policies: undefined, - isDefault: name === defaultName, - }; - } - }), - ); - - spinner.stop(); - console.log(''); - console.log(` ${dim('Sub-accounts:')}`); - - const nw = Math.max(4, ...wallets.map((w) => w.name.length)) + 3; - for (const w of wallets) { - const dot = brandDot(w.isDefault); - const name = w.isDefault ? brandBold(w.name) : w.name; - const addr = fmtAddr(w.address); - const status = statusColor(w.status); - console.log(` ${dot} ${pad(name, nw)} ${pad(addr, 15)} ${status}`); - } - } - - console.log(''); + console.log(JSON.stringify(await buildStatus(session))); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; - if (isJsonMode()) { - console.error(JSON.stringify({ error: message })); - } else { - console.error(`\n ${failMark(message)}\n`); - } + console.error(JSON.stringify({ error: message })); process.exitCode = 1; } }); -// --------------------------------------------------------------------------- -// JSON output for AI/scripts -// --------------------------------------------------------------------------- - -import type { SessionResult } from '../../lib/ensure-session.js'; - -async function outputStatusJson(session: SessionResult): Promise { - const result: Record = {}; - +async function buildStatus(session: SessionResult): Promise> { if (!session.ok) { - result.account = { - authenticated: false, - reason: session.reason, - hint: - session.reason === 'session-expired' - ? 'Run agenta login to re-authenticate.' - : 'Run agenta login to get started.', - }; - } else { - const { token, serverUrl } = session; - const payload = decodeJwt(token); - const exp = typeof payload?.exp === 'number' ? payload.exp : null; - - const account: Record = { - authenticated: true, - email: (payload?.email as string) ?? null, - server: serverUrl, - configDir: getConfigDir(), + return { + account: { + authenticated: false, + reason: session.reason, + next: 'agenta login', + }, }; - if (exp) { - account.jwtExpiresAt = new Date(exp * 1000).toISOString(); - account.jwtSecondsRemaining = Math.max(0, Math.floor((exp * 1000 - Date.now()) / 1000)); - } + } - try { - const orgsRes = await fetch(`${serverUrl}/api/v1/orgs`, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(5_000), - }); - if (orgsRes.ok) { - const orgs = (await orgsRes.json()) as Array<{ name?: string; wallet_address?: string }>; - if (orgs[0]) { - account.organization = orgs[0].name ?? null; - account.walletAddress = orgs[0].wallet_address ?? null; - account.walletActivated = !!orgs[0].wallet_address; + const { token, serverUrl } = session; + const payload = decodeJwt(token); + const exp = typeof payload?.exp === 'number' ? payload.exp : null; + + const account: Record = { + authenticated: true, + email: (payload?.email as string) ?? null, + server: serverUrl, + configDir: getConfigDir(), + ...(exp + ? { + jwtExpiresAt: new Date(exp * 1000).toISOString(), + jwtSecondsRemaining: Math.max(0, Math.floor((exp * 1000 - Date.now()) / 1000)), } - } - } catch { - account.serverReachable = false; - } + : {}), + }; - const walletReady = !!account.walletAddress; - account.paymentTools = { - ready: walletReady, - hint: walletReady - ? 'Run agenta pay checkout -a 50 to create a checkout.' - : 'Activate wallet first via agenta login.', - }; - result.account = account; + const org = await fetchOrg(serverUrl, token); + if (!org) { + account.serverReachable = false; + return { account }; } - - const signerNames = listSigners(); - result.subAccounts = { - count: signerNames.length, - hint: - signerNames.length === 0 - ? 'Run agenta sub create --name to create a sub-account.' - : `${signerNames.length} sub-account(s). Run agenta sub info for details.`, - items: signerNames.map((name) => { - try { - const c = loadSignerConfig(name); - return { name, address: c.ethAddress || null, isDefault: name === getDefaultSignerName() }; - } catch { - return { name, address: null, isDefault: false }; - } - }), + account.orgId = org.id; + account.organization = org.name; + + // Test mode is usable the moment you are authenticated; only LIVE money waits + // on verification and a payout account. + account.paymentTools = { ready: true, mode: 'test', next: 'agenta pay checkout -a 50' }; + + const readiness = await fetchGoLive(serverUrl, token, org.id); + if (!readiness) return { account }; + + return { + account, + goLive: { + canGoLive: readiness.canGoLive, + progress: readiness.progress, + verification: { + state: readiness.verifyState, + label: verifyLabel(readiness), + heldReason: readiness.heldReason, + rejectReason: readiness.rejectReason, + cooldownUntil: readiness.cooldownUntil, + /** Non-empty means we are waiting on the merchant, not the reverse. */ + changesRequested: readiness.rfi?.items.map((i) => i.text) ?? [], + }, + audit: { + label: auditLabel(readiness), + requested: !!readiness.audit, + reportUrl: readiness.audit?.reportUrl ?? null, + grade: readiness.audit?.grade ?? null, + }, + payouts: { connected: readiness.hasPayoutAccount }, + milestones: readiness.milestones, + next: nextStep(readiness), + }, }; - - console.log(JSON.stringify(result)); } diff --git a/packages/wallet/src/cli/commands/switch.command.ts b/packages/wallet/src/cli/commands/switch.command.ts deleted file mode 100644 index 739e454..0000000 --- a/packages/wallet/src/cli/commands/switch.command.ts +++ /dev/null @@ -1,78 +0,0 @@ -import chalk from 'chalk'; -import { Command } from 'commander'; -import { - getDefaultSignerName, - listSigners, - loadSignerConfig, - setDefaultSigner, -} from '../../lib/config.js'; -import { isJsonMode, outputError } from '../output.js'; - -export const switchCommand = new Command('switch') - .description('Switch or list active sub-account') - .argument('[name]', 'Sub-account name to switch to') - .option('--json', 'Output as JSON') - .action(async (name?: string) => { - try { - const signers = listSigners(); - const current = getDefaultSignerName(); - - if (!name) { - // List mode - if (isJsonMode()) { - console.log( - JSON.stringify({ - active: current ?? null, - available: signers.map((s) => { - try { - const c = loadSignerConfig(s); - return { name: s, address: c.ethAddress || null, isDefault: s === current }; - } catch { - return { name: s, address: null, isDefault: s === current }; - } - }), - }), - ); - } else { - if (signers.length === 0) { - console.log( - `\n No sub-accounts. Run ${chalk.bold('agenta sub create --name ')} to create one.\n`, - ); - return; - } - console.log(`\n ${chalk.bold('Sub-accounts:')}\n`); - for (const s of signers) { - const isActive = s === current; - const marker = isActive ? chalk.green('●') : chalk.dim('○'); - let addr = ''; - try { - addr = loadSignerConfig(s).ethAddress || ''; - } catch {} - const short = addr ? chalk.dim(`${addr.slice(0, 6)}…${addr.slice(-4)}`) : ''; - console.log(` ${marker} ${isActive ? chalk.bold(s) : s} ${short}`); - } - console.log(`\n ${chalk.dim('Switch:')} agenta sub switch \n`); - } - return; - } - - // Switch mode - if (!signers.includes(name)) { - throw new Error( - `Sub-account "${name}" not found. Available: ${signers.join(', ') || 'none'}`, - ); - } - - setDefaultSigner(name); - - if (isJsonMode()) { - console.log(JSON.stringify({ active: name })); - } else { - console.log(`\n ${chalk.green('●')} ${chalk.bold(name)} is now active.\n`); - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - outputError(msg); - process.exitCode = 1; - } - }); diff --git a/packages/wallet/src/cli/commands/x402.command.ts b/packages/wallet/src/cli/commands/x402.command.ts deleted file mode 100644 index b378521..0000000 --- a/packages/wallet/src/cli/commands/x402.command.ts +++ /dev/null @@ -1,167 +0,0 @@ -import chalk from 'chalk'; -import { Command } from 'commander'; -import ora from 'ora'; -import { isJsonMode, output, outputError } from '../output.js'; - -export const x402Command = new Command('x402').description( - 'x402 payment protocol — check, discover, and pay for resources', -); - -// --------------------------------------------------------------------------- -// agenta sub x402 check -// --------------------------------------------------------------------------- - -x402Command - .command('check ') - .description('Check if a URL requires x402 payment') - .option('--json', 'Output as JSON') - .action(async (url: string) => { - const json = isJsonMode(); - const spinner = json ? null : ora({ text: 'Checking...', indent: 2 }).start(); - - try { - const { checkX402 } = await import('../../lib/x402-client.js'); - const result = await checkX402(url); - spinner?.stop(); - - if (json) { - console.log(JSON.stringify(result)); - } else { - if (!result.requires402) { - console.log(`\n ${chalk.green('Free')} — ${url} is freely accessible.\n`); - } else { - console.log(`\n ${chalk.yellow('Payment required')} (HTTP 402)\n`); - if (result.paymentRequired?.accepts?.length) { - for (const req of result.paymentRequired.accepts) { - console.log(` ${chalk.bold('Scheme:')} ${req.scheme}`); - console.log(` ${chalk.bold('Network:')} ${req.network}`); - console.log(` ${chalk.bold('Amount:')} ${req.amount}`); - console.log(` ${chalk.bold('Asset:')} ${req.asset}`); - console.log(` ${chalk.bold('Pay to:')} ${req.payTo}`); - console.log(''); - } - } - } - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - if (spinner) { - spinner.fail(msg); - } else { - outputError(msg); - } - process.exitCode = 1; - } - }); - -// --------------------------------------------------------------------------- -// agenta sub x402 discover -// --------------------------------------------------------------------------- - -x402Command - .command('discover ') - .description('Discover x402-protected endpoints on a domain') - .option('--json', 'Output as JSON') - .action(async (domain: string) => { - const json = isJsonMode(); - const spinner = json ? null : ora({ text: 'Discovering...', indent: 2 }).start(); - - try { - const { discoverX402 } = await import('../../lib/x402-client.js'); - const result = await discoverX402(domain); - spinner?.stop(); - - if (json) { - console.log(JSON.stringify(result)); - } else { - if (result.endpoints.length === 0) { - console.log(`\n No x402 endpoints found on ${domain}.\n`); - } else { - console.log(`\n Found ${result.endpoints.length} x402 endpoint(s) on ${domain}:\n`); - for (const ep of result.endpoints) { - console.log(` ${chalk.bold(ep.method)} ${ep.path}`); - if (ep.scheme) console.log(` Scheme: ${ep.scheme}`); - if (ep.amount) console.log(` Amount: ${ep.amount} ${ep.asset ?? ''}`); - if (ep.description) console.log(` ${ep.description}`); - console.log(''); - } - } - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - if (spinner) { - spinner.fail(msg); - } else { - outputError(msg); - } - process.exitCode = 1; - } - }); - -// --------------------------------------------------------------------------- -// agenta sub x402 fetch -// --------------------------------------------------------------------------- - -x402Command - .command('fetch ') - .description('Fetch a 402-protected resource, automatically paying with your sub-account') - .option('--max-amount ', 'Maximum willing to pay in atomic units (e.g. 1000000 = 1 USDC)') - .option('--json', 'Output as JSON') - .action(async (url: string, opts: { maxAmount?: string }) => { - const json = isJsonMode(); - const spinner = json ? null : ora({ text: 'Fetching...', indent: 2 }).start(); - - try { - const { SignerManager } = await import('../../lib/signer-manager.js'); - const { fetchWithX402 } = await import('../../lib/x402-client.js'); - - const signerManager = new SignerManager(); - const signer = await signerManager.getSigner(); - - const result = await fetchWithX402(url, signer, { - maxAmount: opts.maxAmount, - }); - - spinner?.stop(); - - if (json) { - console.log( - JSON.stringify({ - paid: result.paid, - scheme: result.scheme ?? null, - transaction: result.transaction ?? null, - payer: result.payer ?? null, - status: result.status, - contentType: result.contentType ?? null, - body: result.body.length > 10000 ? result.body.slice(0, 10000) : result.body, - truncated: result.body.length > 10000, - }), - ); - } else { - if (result.paid) { - console.log(`\n ${chalk.green('Paid')} via ${result.scheme ?? 'exact'} scheme`); - if (result.transaction) console.log(` ${chalk.bold('Tx:')} ${result.transaction}`); - if (result.payer) console.log(` ${chalk.bold('Payer:')} ${result.payer}`); - } else { - console.log(`\n ${chalk.green('Free')} — no payment needed`); - } - console.log(` ${chalk.bold('Status:')} ${result.status}`); - if (result.contentType) console.log(` ${chalk.bold('Type:')} ${result.contentType}`); - console.log(''); - const body = - result.body.length > 2000 ? `${result.body.slice(0, 2000)}\n...(truncated)` : result.body; - console.log(body); - console.log(''); - } - - signerManager.destroy(); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - if (spinner) { - spinner.fail(msg); - } else { - outputError(msg); - } - process.exitCode = 1; - } - }); diff --git a/packages/wallet/src/cli/index.ts b/packages/wallet/src/cli/index.ts index 3e8b35c..e86789f 100644 --- a/packages/wallet/src/cli/index.ts +++ b/packages/wallet/src/cli/index.ts @@ -3,58 +3,24 @@ import { Command } from 'commander'; const require = createRequire(import.meta.url); const { version } = require('../../package.json') as { version: string }; -import { - auditCommand, - pauseCommand, - policiesCommand, - resumeCommand, -} from './commands/admin.command.js'; -import { balanceCommand } from './commands/balance.command.js'; import { customersCommand } from './commands/customers.command.js'; -import { deployCommand } from './commands/deploy.command.js'; -import { infoCommand } from './commands/info.command.js'; -import { createCommand, importCommand } from './commands/init.command.js'; import { invoicesCommand } from './commands/invoices.command.js'; -import { linkCommand } from './commands/link.command.js'; import { loginCommand, logoutCommand } from './commands/login.command.js'; -import { networkCommand } from './commands/network.command.js'; +import { revenueAuditCommand, verifyCommand } from './commands/onboarding.command.js'; import { payCommand } from './commands/pay.command.js'; -import { proxyCommand } from './commands/proxy.command.js'; -import { receiveCommand } from './commands/receive.command.js'; -import { sendCommand } from './commands/send.command.js'; -import { signMessageCommand } from './commands/sign.command.js'; import { statusCommand } from './commands/status.command.js'; import { subscriptionsCommand } from './commands/subscriptions.command.js'; -import { switchCommand } from './commands/switch.command.js'; -import { x402Command } from './commands/x402.command.js'; import { BRAND_BANNER, dim } from './theme.js'; -// --------------------------------------------------------------------------- -// agenta sub — agent sub-account commands -// --------------------------------------------------------------------------- - -const subCommand = new Command('sub') - .description('Agent sub-account operations (send, sign, deploy)') - .addCommand(createCommand) - .addCommand(importCommand) - .addCommand(switchCommand) - .addCommand(infoCommand) - .addCommand(balanceCommand) - .addCommand(sendCommand) - .addCommand(signMessageCommand) - .addCommand(policiesCommand) - .addCommand(pauseCommand) - .addCommand(resumeCommand) - .addCommand(auditCommand) - .addCommand(x402Command) - .addCommand(deployCommand) - .addCommand(proxyCommand) - .addCommand(networkCommand) - .addCommand(linkCommand) - .addCommand(receiveCommand); - // --------------------------------------------------------------------------- // Main CLI +// +// Merchant commands only. The `agenta sub` tree (MPC sub-accounts: create, +// import, switch, info, balance, send, sign-message, policies, pause, resume, +// audit, deploy, proxy, network, link, receive, x402) was removed as wallet-era +// legacy. That also retired the `agenta sub audit` signing log, which used to +// collide with `agenta audit` — the merchant's Revenue & Pricing Audit — and +// made "audit" ambiguous for the AI tools this CLI is built for. // --------------------------------------------------------------------------- export async function runCli(): Promise { @@ -69,9 +35,17 @@ export async function runCli(): Promise { ` ${dim('Getting started:')} $ agenta login Sign in via browser - $ agenta status Account, wallet & readiness overview + $ agenta status Account & go-live overview $ agenta logout Clear session +${dim('Going live (start here):')} + $ agenta audit request --url Get the free Revenue & Pricing Audit + $ agenta audit show Read it and save the PDF once written + $ agenta verify declaration What --accept-declaration attests to + $ agenta verify submit Submit business verification + $ agenta verify status Where the review has got to + $ agenta verify resubmit Reapply after making changes we asked for + ${dim('Payments (accept & track):')} $ agenta pay checkout -a 50 Create a checkout session $ agenta pay get Get checkout details @@ -87,26 +61,6 @@ ${dim('Invoices & receipts:')} $ agenta invoices receipt Download the receipt PDF $ agenta invoices send-receipt Re-send the receipt email -${dim('Agent sub-accounts (send & sign):')} - $ agenta sub create --name bot1 Create a sub-account - $ agenta sub import --name bot1 \\ - --api-key gw_... --api-secret Import existing - $ agenta sub switch Switch active sub-account - $ agenta sub switch List sub-accounts - $ agenta sub info Sub-account details - $ agenta sub balance ETH & token balances - $ agenta sub send 0x... 0.01 Send ETH - $ agenta sub sign-message "hello" Sign a message - $ agenta sub policies get [--json] View policies - $ agenta sub policies set --file p.json Set policies from JSON - $ agenta sub pause / resume Pause or resume signing - $ agenta sub audit View signing audit log - -${dim('x402 (agent-to-agent payments):')} - $ agenta sub x402 check Check if URL requires payment - $ agenta sub x402 discover Find x402 endpoints on a domain - $ agenta sub x402 fetch Pay and fetch a 402-protected resource - ${dim('Docs: https://github.com/AgentaOS/agentaos')} `, ); @@ -114,11 +68,12 @@ ${dim('Docs: https://github.com/AgentaOS/agentaos')} program.addCommand(loginCommand); program.addCommand(logoutCommand); program.addCommand(statusCommand); + program.addCommand(revenueAuditCommand); + program.addCommand(verifyCommand); program.addCommand(payCommand); program.addCommand(subscriptionsCommand); program.addCommand(customersCommand); program.addCommand(invoicesCommand); - program.addCommand(subCommand); await program.parseAsync(); } diff --git a/packages/wallet/src/cli/theme.ts b/packages/wallet/src/cli/theme.ts index 337cfaf..8d49631 100644 --- a/packages/wallet/src/cli/theme.ts +++ b/packages/wallet/src/cli/theme.ts @@ -94,22 +94,5 @@ export function hint(text: string): void { console.log(` ${dim(text)}`); } -// --------------------------------------------------------------------------- -// @inquirer/prompts theme — monochrome brand -// -// Pass as `theme` option to select(), input(), confirm(), password() -// e.g. select({ message: '...', choices: [...], theme: promptTheme }) -// --------------------------------------------------------------------------- - -export const promptTheme = { - prefix: { - idle: bold('?'), - done: success('✓'), - }, - style: { - answer: (text: string) => bold(text), - highlight: (text: string) => bold(text), - key: (text: string) => bold(`<${text}>`), - description: (text: string) => dim(text), - }, -}; +// The `@inquirer/prompts` theme lived here. It went with the prompts: this CLI +// is driven by AI tools, so nothing waits on stdin any more. diff --git a/packages/wallet/src/lib/__tests__/go-live.test.ts b/packages/wallet/src/lib/__tests__/go-live.test.ts new file mode 100644 index 0000000..afee61e --- /dev/null +++ b/packages/wallet/src/lib/__tests__/go-live.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { type GoLiveReadiness, auditLabel, nextStep, verifyLabel } from '../go-live.js'; + +/** A brand-new account: logged in, nothing done. Every test starts here and + * changes only the field under test, so a passing assertion can only be + * explained by that field. */ +function fresh(over: Partial = {}): GoLiveReadiness { + return { + triedIt: false, + verifyState: 'unverified', + hasPayoutAccount: false, + canGoLive: false, + progress: { done: 0, total: 3 }, + rejectReason: null, + cooldownUntil: null, + heldReason: null, + rfi: null, + audit: null, + milestones: { + firstProductAt: null, + firstTestPaymentAt: null, + firstLivePaymentAt: null, + livePaymentCount: 0, + }, + ...over, + }; +} + +/** Asked for, not yet written — the state an audit spends most of its life in. */ +function requestedAudit(): GoLiveReadiness['audit'] { + return { + requestedAt: '2026-08-26T09:00:00.000Z', + publishedAt: null, + reportUrl: null, + grade: null, + }; +} + +function withRfi(text: string): GoLiveReadiness['rfi'] { + return { + question: text, + askedAt: '2026-08-26T09:00:00.000Z', + items: [{ id: 'a', text, at: '2026-08-26T09:00:00.000Z' }], + }; +} + +// --------------------------------------------------------------------------- +// verifyLabel +// --------------------------------------------------------------------------- + +describe('verifyLabel', () => { + it('reads "Not submitted" for a brand-new account', () => { + expect(verifyLabel(fresh())).toBe('Not submitted'); + }); + + it.each([ + ['verified', 'Verified'], + ['in_review', 'In review'], + ['on_hold', 'On hold'], + ['rejected', 'Rejected'], + ] as const)('maps %s to %s', (state, label) => { + expect(verifyLabel(fresh({ verifyState: state }))).toBe(label); + }); + + // The merchant is the one being waited on, and "In review" would tell them to + // sit still while we wait for them. + it('says changes are requested even while the state is still in_review', () => { + const r = fresh({ verifyState: 'in_review', rfi: withRfi('Publish your Terms.') }); + expect(verifyLabel(r)).toBe('Changes requested'); + }); +}); + +// --------------------------------------------------------------------------- +// auditLabel +// --------------------------------------------------------------------------- + +describe('auditLabel', () => { + it('reads "Not requested" before they ask', () => { + expect(auditLabel(fresh())).toBe('Not requested'); + }); + + it('reads "Being written" once asked but before the report exists', () => { + expect(auditLabel(fresh({ audit: requestedAudit() }))).toBe('Being written'); + }); + + it('reads the grade once the report is published', () => { + const r = fresh({ + audit: { + requestedAt: '2026-08-26T09:00:00.000Z', + publishedAt: '2026-08-26T12:00:00.000Z', + reportUrl: 'https://example.test/report.pdf', + grade: 'C', + }, + }); + expect(auditLabel(r)).toBe('Ready (graded C)'); + }); + + // A published report with no grade must still read as ready, not as pending. + it('reads "Ready" for a published report with no grade', () => { + const r = fresh({ + audit: { + requestedAt: '2026-08-26T09:00:00.000Z', + publishedAt: '2026-08-26T12:00:00.000Z', + reportUrl: 'https://example.test/report.pdf', + grade: null, + }, + }); + expect(auditLabel(r)).toBe('Ready'); + }); +}); + +// --------------------------------------------------------------------------- +// nextStep — the precedence is the whole point, so test the ORDER, not just +// each branch in isolation. +// --------------------------------------------------------------------------- + +describe('nextStep', () => { + it('starts a new merchant on the free audit', () => { + expect(nextStep(fresh())?.command).toBe('agenta audit request'); + }); + + it('moves to verification once the audit is requested', () => { + expect(nextStep(fresh({ audit: requestedAudit() }))?.command).toBe('agenta verify submit'); + }); + + it('asks for a payout account once verified', () => { + const r = fresh({ verifyState: 'verified', hasPayoutAccount: false, audit: requestedAudit() }); + expect(nextStep(r)?.command).toBe('agenta payouts add'); + }); + + // The audit is a gift, never a gate. A verified merchant who skipped it needs + // a payout account, and telling them to go get an audit first would put a + // present in front of getting paid. + it('does not send a verified merchant back for an audit they never asked for', () => { + const r = fresh({ verifyState: 'verified', hasPayoutAccount: false, audit: null }); + expect(nextStep(r)?.command).toBe('agenta payouts add'); + }); + + it('has nothing to chase once the merchant can go live', () => { + const r = fresh({ + verifyState: 'verified', + hasPayoutAccount: true, + canGoLive: true, + audit: requestedAudit(), + }); + expect(nextStep(r)).toBeNull(); + }); + + // An open change request is the ONLY state where we are waiting on them, so + // it has to beat every other candidate step. + it('puts an open change request ahead of everything else', () => { + const r = fresh({ + verifyState: 'in_review', + rfi: withRfi('Add a currency next to your price.'), + audit: null, + hasPayoutAccount: false, + }); + expect(nextStep(r)?.command).toBe('agenta verify changes'); + }); + + // Nothing a terminal can do about these, and inventing a step would be worse + // than saying nothing. + it.each(['in_review', 'on_hold', 'rejected'] as const)( + 'offers no step while %s with no open request', + (state) => { + expect(nextStep(fresh({ verifyState: state, audit: requestedAudit() }))).toBeNull(); + }, + ); +}); diff --git a/packages/wallet/src/lib/__tests__/transfer-crypto.test.ts b/packages/wallet/src/lib/__tests__/transfer-crypto.test.ts deleted file mode 100644 index ceaa2c4..0000000 --- a/packages/wallet/src/lib/__tests__/transfer-crypto.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { wordlist } from '@scure/bip39/wordlists/english.js'; -import { describe, expect, it } from 'vitest'; -import { - decryptShareFromTransfer, - deriveTransferKey, - encryptShareForTransfer, - generateTransferCode, -} from '../transfer-crypto.js'; - -// --------------------------------------------------------------------------- -// generateTransferCode -// --------------------------------------------------------------------------- - -describe('generateTransferCode', () => { - const transferId = 'test-transfer-001'; - - it('returns exactly 6 words', () => { - const { words } = generateTransferCode(transferId); - expect(words).toHaveLength(6); - }); - - it('all words are valid BIP39 English words', () => { - const wordSet = new Set(wordlist); - const { words } = generateTransferCode(transferId); - for (const word of words) { - expect(wordSet.has(word)).toBe(true); - } - }); - - it('returns a 32-byte transfer key', () => { - const { transferKey } = generateTransferCode(transferId); - expect(transferKey).toBeInstanceOf(Uint8Array); - expect(transferKey.length).toBe(32); - }); - - it('different calls produce different words', () => { - const a = generateTransferCode(transferId); - const b = generateTransferCode(transferId); - // 6 words from 2048 wordlist — collision probability per word is 1/2048. - // All 6 matching is ~(1/2048)^6 ≈ 1.4e-20, effectively impossible. - expect(a.words).not.toEqual(b.words); - }); - - it('transfer key matches deriveTransferKey with same words and transferId', () => { - const { words, transferKey } = generateTransferCode(transferId); - const derived = deriveTransferKey(words, transferId); - expect(Buffer.from(transferKey).toString('hex')).toBe(Buffer.from(derived).toString('hex')); - }); -}); - -// --------------------------------------------------------------------------- -// deriveTransferKey -// --------------------------------------------------------------------------- - -describe('deriveTransferKey', () => { - const words = ['abandon', 'ability', 'able', 'about', 'above', 'absent']; - const transferId = 'transfer-abc-123'; - - it('is deterministic — same words + transferId produce same key', () => { - const key1 = deriveTransferKey(words, transferId); - const key2 = deriveTransferKey(words, transferId); - expect(Buffer.from(key1).toString('hex')).toBe(Buffer.from(key2).toString('hex')); - }); - - it('different words produce a different key', () => { - const altWords = ['zoo', 'zone', 'zero', 'youth', 'young', 'year']; - const key1 = deriveTransferKey(words, transferId); - const key2 = deriveTransferKey(altWords, transferId); - expect(Buffer.from(key1).toString('hex')).not.toBe(Buffer.from(key2).toString('hex')); - }); - - it('different transferId produces a different key (same words)', () => { - const key1 = deriveTransferKey(words, 'transfer-aaa'); - const key2 = deriveTransferKey(words, 'transfer-bbb'); - expect(Buffer.from(key1).toString('hex')).not.toBe(Buffer.from(key2).toString('hex')); - }); - - it('throws if word count is not 6', () => { - expect(() => deriveTransferKey(['abandon'], transferId)).toThrowError( - 'Expected 6 words, got 1', - ); - expect(() => - deriveTransferKey( - ['abandon', 'ability', 'able', 'about', 'above', 'absent', 'extra'], - transferId, - ), - ).toThrowError('Expected 6 words, got 7'); - expect(() => deriveTransferKey([], transferId)).toThrowError('Expected 6 words, got 0'); - }); - - it('throws if a word is not in the BIP39 wordlist', () => { - const badWords = ['abandon', 'ability', 'able', 'about', 'above', 'notaword']; - expect(() => deriveTransferKey(badWords, transferId)).toThrowError('Invalid word: "notaword"'); - }); - - it('is case insensitive — lowercase and uppercase produce the same key', () => { - const upper = words.map((w) => w.toUpperCase()); - const mixed = words.map((w, i) => (i % 2 === 0 ? w.toUpperCase() : w)); - const keyLower = deriveTransferKey(words, transferId); - const keyUpper = deriveTransferKey(upper, transferId); - const keyMixed = deriveTransferKey(mixed, transferId); - const hex = Buffer.from(keyLower).toString('hex'); - expect(Buffer.from(keyUpper).toString('hex')).toBe(hex); - expect(Buffer.from(keyMixed).toString('hex')).toBe(hex); - }); -}); - -// --------------------------------------------------------------------------- -// encryptShareForTransfer + decryptShareFromTransfer (round-trip) -// --------------------------------------------------------------------------- - -describe('encryptShareForTransfer / decryptShareFromTransfer', () => { - const transferId = 'roundtrip-test'; - - /** Helper: generate a fresh 32-byte key for testing. */ - function makeKey(): Uint8Array { - const { transferKey } = generateTransferCode(transferId); - return transferKey; - } - - it('encrypt then decrypt returns original data', async () => { - const key = makeKey(); - const plaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); - const ciphertext = await encryptShareForTransfer(plaintext, key); - const recovered = await decryptShareFromTransfer(ciphertext, key); - expect(Buffer.from(recovered).toString('hex')).toBe(Buffer.from(plaintext).toString('hex')); - }); - - it('works with an empty Uint8Array', async () => { - const key = makeKey(); - const plaintext = new Uint8Array(0); - const ciphertext = await encryptShareForTransfer(plaintext, key); - const recovered = await decryptShareFromTransfer(ciphertext, key); - expect(recovered.length).toBe(0); - }); - - it('works with large data (1 MB)', async () => { - const key = makeKey(); - const plaintext = new Uint8Array(1024 * 1024); - // Fill with a deterministic pattern so we can verify round-trip - for (let i = 0; i < plaintext.length; i++) { - plaintext[i] = i & 0xff; - } - const ciphertext = await encryptShareForTransfer(plaintext, key); - const recovered = await decryptShareFromTransfer(ciphertext, key); - expect(recovered.length).toBe(plaintext.length); - expect(Buffer.from(recovered).toString('hex')).toBe(Buffer.from(plaintext).toString('hex')); - }); - - it('different keys produce different ciphertext', async () => { - const key1 = makeKey(); - const key2 = makeKey(); - const plaintext = new Uint8Array([10, 20, 30, 40]); - const ct1 = await encryptShareForTransfer(plaintext, key1); - const ct2 = await encryptShareForTransfer(plaintext, key2); - // Different keys + different random IVs means ciphertexts should differ - expect(ct1).not.toBe(ct2); - }); - - it('wrong key throws on decrypt', async () => { - const correctKey = makeKey(); - const wrongKey = makeKey(); - const plaintext = new Uint8Array([99, 88, 77]); - const ciphertext = await encryptShareForTransfer(plaintext, correctKey); - await expect(decryptShareFromTransfer(ciphertext, wrongKey)).rejects.toThrowError( - 'Decryption failed', - ); - }); - - it('corrupted ciphertext throws on decrypt', async () => { - const key = makeKey(); - const plaintext = new Uint8Array([1, 2, 3, 4]); - const ciphertext = await encryptShareForTransfer(plaintext, key); - - // Decode, flip a byte in the ciphertext body, re-encode - const packed = Buffer.from(ciphertext, 'base64'); - // Flip a byte past the 12-byte IV, in the ciphertext/tag region - packed[14] = packed[14]! ^ 0xff; - const corrupted = packed.toString('base64'); - - await expect(decryptShareFromTransfer(corrupted, key)).rejects.toThrowError( - 'Decryption failed', - ); - }); - - it('truncated ciphertext (less than IV + tag) throws "Ciphertext too short"', async () => { - const key = makeKey(); - // IV is 12 bytes, GCM tag is 16 bytes — minimum valid length is 28 bytes. - // Provide only 20 bytes (less than 12 + 16 = 28). - const tooShort = Buffer.from(new Uint8Array(20)).toString('base64'); - await expect(decryptShareFromTransfer(tooShort, key)).rejects.toThrowError( - 'Ciphertext too short', - ); - }); -}); diff --git a/packages/wallet/src/lib/authenticated-fetch.ts b/packages/wallet/src/lib/authenticated-fetch.ts deleted file mode 100644 index 3246770..0000000 --- a/packages/wallet/src/lib/authenticated-fetch.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { getRefreshToken, getSession, getSessionServerUrl, storeSession } from './keychain.js'; - -// Deduplicate concurrent refresh calls (same pattern as browser api-client.ts) -let refreshing: Promise | null = null; - -/** - * Fetch wrapper for CLI commands that require authentication. - * Automatically retries with a refreshed access token on 401. - */ -export async function authenticatedFetch(url: string, opts?: RequestInit): Promise { - const token = await getSession(); - if (!token) { - throw new Error('Not logged in. Run agenta login first.'); - } - - let res = await fetch(url, { - ...opts, - headers: { ...opts?.headers, authorization: `Bearer ${token}` }, - }); - - if (res.status === 401) { - const refreshed = await refreshSession(); - if (refreshed) { - const newToken = await getSession(); - res = await fetch(url, { - ...opts, - headers: { ...opts?.headers, authorization: `Bearer ${newToken}` }, - }); - } else { - throw new Error('Session expired. Run agenta login again.'); - } - } - - return res; -} - -/** - * Attempt to refresh the access token using the stored refresh token. - * On success, persists new tokens to session.json. Returns true on success. - * Deduplicates concurrent calls to prevent rotation race conditions. - */ -async function refreshSession(): Promise { - if (refreshing) return refreshing; - - refreshing = (async () => { - const refreshToken = await getRefreshToken(); - if (!refreshToken) return false; - - const serverUrl = await getSessionServerUrl(); - if (!serverUrl) return false; - - try { - const res = await fetch(`${serverUrl}/api/v1/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - signal: AbortSignal.timeout(10_000), - }); - - if (!res.ok) return false; - - const data = (await res.json()) as { - token?: string; - refreshToken?: string; - }; - - if (data.token) { - await storeSession(data.token, serverUrl, data.refreshToken); - return true; - } - - return false; - } catch { - return false; - } - })(); - - try { - return await refreshing; - } finally { - refreshing = null; - } -} diff --git a/packages/wallet/src/lib/config.ts b/packages/wallet/src/lib/config.ts index f65df92..f565f2d 100644 --- a/packages/wallet/src/lib/config.ts +++ b/packages/wallet/src/lib/config.ts @@ -1,222 +1,16 @@ -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - writeFileSync, -} from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { CGGMP24Scheme } from '@agentaos/engine'; -import { AgentaApi, HttpClient, ThresholdSigner } from '@agentaos/sdk'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SignerConfig { - version: 1; - serverUrl: string; - apiKey: string; - apiSecret?: string; - network?: string; - signerName: string; - ethAddress: string; - signerId?: string; - createdAt?: string; -} - -// --------------------------------------------------------------------------- -// Paths -// --------------------------------------------------------------------------- +/** + * Where the CLI keeps its state. + * + * This file used to hold the agent sub-account config: per-signer JSON files, + * a default-signer pointer, name validation and an SDK client factory. All of + * it went with the sub-account surface, which is wallet-era legacy. Only the + * config directory itself is still meaningful, because the session lives there. + */ const CONFIG_DIR = join(homedir(), '.agenta'); export function getConfigDir(): string { return CONFIG_DIR; } - -export function getSignerConfigPath(name: string): string { - return join(CONFIG_DIR, 'signers', `${name}.json`); -} - -// --------------------------------------------------------------------------- -// Name validation -// --------------------------------------------------------------------------- - -const VALID_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; - -export function validateSignerName(name: string): string | null { - if (!name) return 'Account name cannot be empty'; - if (!VALID_NAME_RE.test(name)) { - return 'Must be 1-64 chars: letters, numbers, hyphens, underscores. Start with alphanumeric.'; - } - return null; -} - -// --------------------------------------------------------------------------- -// Default signer -// --------------------------------------------------------------------------- - -export function getDefaultSignerName(): string | null { - const p = join(CONFIG_DIR, '.default'); - if (!existsSync(p)) return null; - return readFileSync(p, 'utf-8').trim() || null; -} - -export function setDefaultSigner(name: string): void { - ensureDir(CONFIG_DIR); - writeFileSync(join(CONFIG_DIR, '.default'), `${name}\n`, { mode: 0o600 }); -} - -// --------------------------------------------------------------------------- -// List / resolve signers -// --------------------------------------------------------------------------- - -export function listSigners(): string[] { - const dir = join(CONFIG_DIR, 'signers'); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((f) => f.endsWith('.json') && !f.endsWith('.recovery.json')) - .map((f) => f.replace(/\.json$/, '')) - .filter((name) => { - try { - const raw = readFileSync(getSignerConfigPath(name), 'utf-8'); - const config = JSON.parse(raw); - return typeof config.serverUrl === 'string' && typeof config.apiKey === 'string'; - } catch { - return false; - } - }); -} - -export function resolveSignerName(explicit?: string): string { - if (explicit) return explicit; - - const defaultName = getDefaultSignerName(); - if (defaultName) return defaultName; - - const signers = listSigners(); - if (signers.length === 1) return signers[0] as string; - if (signers.length === 0) { - throw new Error('No accounts configured. Run `agenta sub create` first.'); - } - throw new Error( - `Multiple accounts found: ${signers.join(', ')}.\nUse --signer or run \`agenta sub create\` to set a default.`, - ); -} - -// --------------------------------------------------------------------------- -// Signer config I/O -// --------------------------------------------------------------------------- - -export function loadSignerConfig(name?: string): SignerConfig { - const signerName = resolveSignerName(name); - const p = getSignerConfigPath(signerName); - if (!existsSync(p)) { - throw new Error(`Account "${signerName}" not found. Run \`agenta sub create\` first.`); - } - return JSON.parse(readFileSync(p, 'utf-8')) as SignerConfig; -} - -export function saveSignerConfig(name: string, config: SignerConfig): void { - ensureDir(join(CONFIG_DIR, 'signers')); - const p = getSignerConfigPath(name); - const tmp = `${p}.tmp`; - writeFileSync(tmp, JSON.stringify(config, null, '\t'), { mode: 0o600 }); - renameSync(tmp, p); -} - -// --------------------------------------------------------------------------- -// Recovery metadata (recovery-only devices — no secrets, public info only) -// --------------------------------------------------------------------------- - -export interface RecoveryMeta { - signerName: string; - signerId: string; - ethAddress: string; - serverUrl: string; - network?: string; - receivedAt: string; -} - -function getRecoveryMetaPath(name: string): string { - return join(CONFIG_DIR, 'signers', `${name}.recovery.json`); -} - -export function saveRecoveryMeta(name: string, meta: RecoveryMeta): void { - ensureDir(join(CONFIG_DIR, 'signers')); - const p = getRecoveryMetaPath(name); - const tmp = `${p}.tmp`; - writeFileSync(tmp, JSON.stringify(meta, null, '\t'), { mode: 0o600 }); - renameSync(tmp, p); -} - -export function loadRecoveryMeta(name: string): RecoveryMeta | null { - const p = getRecoveryMetaPath(name); - if (!existsSync(p)) return null; - try { - return JSON.parse(readFileSync(p, 'utf-8')) as RecoveryMeta; - } catch { - return null; - } -} - -export function listRecoveryMetas(): RecoveryMeta[] { - const dir = join(CONFIG_DIR, 'signers'); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((f) => f.endsWith('.recovery.json')) - .map((f) => { - try { - return JSON.parse(readFileSync(join(dir, f), 'utf-8')) as RecoveryMeta; - } catch { - return null; - } - }) - .filter((m): m is RecoveryMeta => m !== null); -} - -// --------------------------------------------------------------------------- -// Secret resolution -// --------------------------------------------------------------------------- - -export function resolveApiSecret(config: SignerConfig): string { - if (config.apiSecret) return config.apiSecret; - throw new Error( - 'No API secret found in config. Run `agenta sub create` to reconfigure with your API Secret from AgentaOS.', - ); -} - -// --------------------------------------------------------------------------- -// Factories -// --------------------------------------------------------------------------- - -export function createClientFromConfig(config: { serverUrl: string; apiKey: string }): { - client: HttpClient; - api: AgentaApi; -} { - const client = new HttpClient({ baseUrl: config.serverUrl, apiKey: config.apiKey }); - const api = new AgentaApi(client); - return { client, api }; -} - -export async function createSignerFromConfig(config: SignerConfig): Promise { - return ThresholdSigner.fromSecret({ - apiSecret: resolveApiSecret(config), - serverUrl: config.serverUrl, - apiKey: config.apiKey, - scheme: new CGGMP24Scheme(), - }); -} - -// --------------------------------------------------------------------------- -// Internal -// --------------------------------------------------------------------------- - -function ensureDir(dir: string): void { - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } -} diff --git a/packages/wallet/src/lib/ensure-session.ts b/packages/wallet/src/lib/ensure-session.ts index fae346d..5f446ce 100644 --- a/packages/wallet/src/lib/ensure-session.ts +++ b/packages/wallet/src/lib/ensure-session.ts @@ -1,4 +1,4 @@ -import { getRefreshToken, getSession, getSessionServerUrl, storeSession } from './keychain.js'; +import { getRefreshToken, getSession, getSessionServerUrl, storeSession } from './session-store.js'; /** Decode JWT payload without verification (display + expiry check only). */ export function decodeJwt(token: string): Record | null { @@ -27,21 +27,30 @@ export async function ensureSession(): Promise { (await getSessionServerUrl()) || process.env.AGENTA_SERVER || 'https://api.agentaos.ai'; const jwt = decodeJwt(token); const exp = typeof jwt?.exp === 'number' ? jwt.exp : undefined; - const needsUpgrade = jwt?.scope === 'setup'; // passkey was set up after token was issued - // Still valid and full scope (with 30s buffer) - if (exp && exp * 1000 > Date.now() + 30_000 && !needsUpgrade) { + // Still valid (with 30s buffer). Deliberately NOT keyed on `scope`: a token's + // scope reflects what the ACCOUNT can do (crypto signing needs a passkey), not + // whether the session is fresh, and a merchant who never sets a passkey stays + // `setup` forever. Refreshing on it rotated the refresh token on EVERY command, + // and two commands racing that rotation trip the server's reuse detection, + // which revokes the whole family and logs the user out for good. + if (exp && exp * 1000 > Date.now() + 30_000) { return { ok: true, token, serverUrl }; } - // Expired or needs scope upgrade — try refresh + // Expired (or unreadable) — try refresh const refreshToken = await getRefreshToken(); if (!refreshToken) return { ok: false, reason: 'session-expired' }; try { const res = await fetch(`${serverUrl}/api/v1/auth/refresh`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + // `x-client: cli` is REQUIRED, not decoration. Without it the server + // treats us as a browser and returns the rotated tokens ONLY as httpOnly + // cookies, which we cannot read — while still having spent the refresh + // token server-side. That combination burns the session on every refresh + // and then reports it expired. + headers: { 'content-type': 'application/json', 'x-client': 'cli' }, body: JSON.stringify({ refreshToken }), signal: AbortSignal.timeout(10_000), }); diff --git a/packages/wallet/src/lib/erc20-abi.ts b/packages/wallet/src/lib/erc20-abi.ts deleted file mode 100644 index b211365..0000000 --- a/packages/wallet/src/lib/erc20-abi.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const ERC20_ABI = [ - { - name: 'transfer', - type: 'function', - inputs: [ - { name: 'to', type: 'address' }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [{ type: 'bool' }], - stateMutability: 'nonpayable', - }, - { - name: 'balanceOf', - type: 'function', - inputs: [{ name: 'account', type: 'address' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - }, - { - name: 'decimals', - type: 'function', - inputs: [], - outputs: [{ type: 'uint8' }], - stateMutability: 'view', - }, - { - name: 'symbol', - type: 'function', - inputs: [], - outputs: [{ type: 'string' }], - stateMutability: 'view', - }, -] as const; diff --git a/packages/wallet/src/lib/errors.ts b/packages/wallet/src/lib/errors.ts deleted file mode 100644 index 85475ec..0000000 --- a/packages/wallet/src/lib/errors.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { HttpClientError } from '@agentaos/sdk'; - -interface PolicyViolation { - type: string; - reason: string; -} - -/** Matches MCP SDK's CallToolResult — index signature required by the Zod schema. */ -interface ToolResult { - [key: string]: unknown; - content: Array<{ type: 'text'; text: string }>; - isError?: boolean; -} - -export function formatError(error: unknown, prefix: string): ToolResult { - if (error instanceof HttpClientError && error.statusCode === 403) { - const lines = [`${prefix}: policy violation`]; - try { - const body = JSON.parse(error.body) as { violations?: PolicyViolation[] }; - if (body.violations?.length) { - lines.push(''); - lines.push('Policy violations:'); - for (const v of body.violations) { - lines.push(` - [${v.type}] ${v.reason}`); - } - } - } catch { - lines.push(error.body); - } - return { content: [{ type: 'text', text: lines.join('\n') }], isError: true }; - } - - const msg = error instanceof Error ? error.message : String(error); - return { content: [{ type: 'text', text: `${prefix}: ${msg}` }], isError: true }; -} diff --git a/packages/wallet/src/lib/go-live.ts b/packages/wallet/src/lib/go-live.ts new file mode 100644 index 0000000..5d269ce --- /dev/null +++ b/packages/wallet/src/lib/go-live.ts @@ -0,0 +1,186 @@ +/** + * Merchant onboarding state, read from the server. + * + * `GET /gateway/go-live` already returns a render-ready object: the server + * decides what "ready" means and this module only prints it. Nothing here + * recomputes readiness from parts, which is the whole reason that endpoint + * exists. + */ + +/** Mirrors `GoLiveVerifyState` (server: gateway/domain/mor-verification-readiness.ts). */ +export type VerifyState = 'unverified' | 'in_review' | 'on_hold' | 'verified' | 'rejected'; + +/** One thing ops asked the merchant to change. Mirrors `RfiItem`. */ +export interface RfiItem { + id: string; + text: string; + at: string; +} + +/** Mirrors `GoLiveReadiness` (server: gateway/go-live/go-live.service.ts). Only + * the fields the CLI actually prints are declared. */ +export interface GoLiveReadiness { + triedIt: boolean; + verifyState: VerifyState; + hasPayoutAccount: boolean; + canGoLive: boolean; + progress: { done: number; total: number }; + rejectReason: string | null; + cooldownUntil: string | null; + heldReason: string | null; + /** Open change requests only. Non-null means the merchant owes us something. */ + rfi: { question: string; askedAt: string; items: RfiItem[] } | null; + /** Null until they ask for one. `reportUrl` is null while it is being written. */ + audit: { + requestedAt: string; + publishedAt: string | null; + reportUrl: string | null; + grade: string | null; + } | null; + milestones: { + firstProductAt: string | null; + firstTestPaymentAt: string | null; + firstLivePaymentAt: string | null; + livePaymentCount: number; + }; +} + +export interface Org { + id: string; + name: string | null; + walletAddress: string | null; +} + +const REQUEST_TIMEOUT_MS = 8_000; + +function apiUrl(serverUrl: string, path: string): string { + return `${serverUrl.replace(/\/+$/, '')}/api/v1${path}`; +} + +async function getJson(serverUrl: string, path: string, token: string): Promise { + try { + const res = await fetch(apiUrl(serverUrl, path), { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +export async function postJson( + serverUrl: string, + path: string, + token: string, + body: unknown, +): Promise<{ ok: true; data: T } | { ok: false; status: number; message: string }> { + try { + const res = await fetch(apiUrl(serverUrl, path), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + const text = await res.text(); + if (!res.ok) { + // Nest validation errors arrive as { message: string | string[] }. + let message = text || `Server returned ${res.status}`; + try { + const parsed = JSON.parse(text) as { message?: string | string[] }; + if (Array.isArray(parsed.message)) message = parsed.message.join('; '); + else if (parsed.message) message = parsed.message; + } catch { + // Not JSON — the raw body is the best message we have. + } + return { ok: false, status: res.status, message }; + } + return { ok: true, data: (text ? JSON.parse(text) : {}) as T }; + } catch (error: unknown) { + return { + ok: false, + status: 0, + message: error instanceof Error ? error.message : 'Request failed', + }; + } +} + +/** The caller's first org. Every merchant command needs its id, because the + * server resolves the org from `?orgId=` on a session token. */ +export async function fetchOrg(serverUrl: string, token: string): Promise { + const orgs = await getJson>( + serverUrl, + '/orgs', + token, + ); + const first = orgs?.[0]; + if (!first?.id) return null; + return { + id: first.id, + name: first.name ?? null, + walletAddress: first.wallet_address ?? null, + }; +} + +export async function fetchGoLive( + serverUrl: string, + token: string, + orgId: string, +): Promise { + return getJson( + serverUrl, + `/gateway/go-live?orgId=${encodeURIComponent(orgId)}`, + token, + ); +} + +/** One line for the verification row. Plain words, because a merchant reading a + * terminal has no chip colour to read it with. */ +export function verifyLabel(r: GoLiveReadiness): string { + if (r.rfi) return 'Changes requested'; + switch (r.verifyState) { + case 'verified': + return 'Verified'; + case 'in_review': + return 'In review'; + case 'on_hold': + return 'On hold'; + case 'rejected': + return 'Rejected'; + default: + return 'Not submitted'; + } +} + +export function auditLabel(r: GoLiveReadiness): string { + if (!r.audit) return 'Not requested'; + if (!r.audit.reportUrl) return 'Being written'; + return r.audit.grade ? `Ready (graded ${r.audit.grade})` : 'Ready'; +} + +/** + * The single most useful next command, or null when there is nothing to chase. + * + * Ordered the way the journey actually blocks: a change request is the + * merchant's move and outranks everything, then verification, then payouts. + * States we cannot act on from a terminal (in review, on hold, rejected) + * deliberately return null rather than inventing busywork. + */ +export function nextStep(r: GoLiveReadiness): { command: string; why: string } | null { + if (r.rfi) { + return { command: 'agenta verify changes', why: 'we asked you to change something' }; + } + if (r.verifyState === 'unverified') { + // The audit is offered FIRST only to someone who has not started verifying: + // it is a gift, not a gate, so it must never stand between a verified + // merchant and getting paid. + return r.audit + ? { command: 'agenta verify submit', why: 'verify your business to accept live payments' } + : { command: 'agenta audit request', why: 'start with the free audit' }; + } + if (r.verifyState === 'verified' && !r.hasPayoutAccount) { + return { command: 'agenta payouts add', why: 'add a payout account to get paid' }; + } + return null; +} diff --git a/packages/wallet/src/lib/keychain.ts b/packages/wallet/src/lib/keychain.ts deleted file mode 100644 index 3b0fd72..0000000 --- a/packages/wallet/src/lib/keychain.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; -import { platform } from 'node:os'; -import { join } from 'node:path'; -import { getConfigDir } from './config.js'; - -// --------------------------------------------------------------------------- -// macOS login keychain via `security` CLI -// -// Same pattern as: gh (GitHub CLI), aws-vault, docker credential helper. -// Uses the default (login) keychain — auto-unlocked on user login. -// -// We store with -T '' (empty trusted-app list) so macOS prompts the user -// on every keychain read. On Macs with Touch ID this triggers biometric; -// on Mac Mini / headless it shows a password dialog. -// -// If the keychain read fails (headless SSH without GUI, locked keychain), -// getUserShare() falls through to the .user-share file fallback. -// During `agenta sub create`, headless users should pick "Local file" storage. -// -// For SSH sessions: `security unlock-keychain` once per session, then -// the approval dialog still fires per-item access (because -T ''). -// --------------------------------------------------------------------------- - -const SERVICE_NAME = 'agenta'; - -// --------------------------------------------------------------------------- -// Session (JWT) storage -// --------------------------------------------------------------------------- - -// Session tokens use file storage only (~/.agenta/session.json, 0600). -// No keychain — a short-lived JWT doesn't need biometric protection, -// and keychain prompts add unnecessary friction for admin ops. - -export async function storeSession( - token: string, - serverUrl?: string, - refreshToken?: string, -): Promise { - storeSessionToFile(token, serverUrl, refreshToken); -} - -export async function getSession(): Promise { - return loadSessionFromFile(); -} - -export async function getRefreshToken(): Promise { - return loadRefreshTokenFromFile(); -} - -export async function deleteSession(): Promise { - return deleteSessionFile(); -} - -// --------------------------------------------------------------------------- -// Public API — User Shares -// --------------------------------------------------------------------------- - -export async function storeUserShare( - signerName: string, - shareBase64: string, - target: 'keychain' | 'file' = 'keychain', -): Promise { - if (target === 'keychain') { - macKeychainSet(signerName, shareBase64); - return; - } - storeUserShareToFile(signerName, shareBase64); -} - -export async function getUserShare(signerName: string): Promise { - if (isMacOS()) { - try { - return macKeychainGet(signerName); - } catch { - // Keychain locked, headless, or user denied → fall through to file - } - } - return loadUserShareFromFile(signerName); -} - -export async function deleteUserShare(signerName: string): Promise { - if (isMacOS()) { - try { - macKeychainDelete(signerName); - return true; - } catch { - // fall through to file - } - } - return deleteUserShareFile(signerName); -} - -export async function isKeychainAvailable(): Promise { - if (!isMacOS()) return false; - try { - execFileSync('security', ['help'], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// macOS Keychain -// --------------------------------------------------------------------------- - -function isMacOS(): boolean { - return platform() === 'darwin'; -} - -function macKeychainSet(account: string, secret: string): void { - // -U = upsert (update if exists, add if not). Atomic, no delete-then-add race. - // -T '' = empty trusted-app list → forces Touch ID / password on every read. - // Used for user shares (signing keys) — biometric gate for fund movement. - // -j = comment shown in Keychain Access.app for user clarity. - execFileSync( - 'security', - [ - 'add-generic-password', - '-U', - '-s', - SERVICE_NAME, - '-a', - account, - '-w', - secret, - '-T', - '', - '-j', - 'AgentaOS signing key', - ], - { stdio: 'ignore' }, - ); -} - -function macKeychainGet(account: string): string | null { - try { - const result = execFileSync( - 'security', - ['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w'], - { encoding: 'utf-8' }, - ); - return result.trim() || null; - } catch { - return null; - } -} - -function macKeychainDelete(account: string): void { - execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { - stdio: 'ignore', - }); -} - -// --------------------------------------------------------------------------- -// File-based fallback (Linux / Windows / CI / headless SSH) -// --------------------------------------------------------------------------- - -function getUserShareFilePath(signerName: string): string { - return join(getConfigDir(), 'signers', `${signerName}.user-share`); -} - -function storeUserShareToFile(signerName: string, shareBase64: string): void { - const p = getUserShareFilePath(signerName); - const tmpPath = `${p}.tmp`; - writeFileSync(tmpPath, shareBase64, { mode: 0o600 }); - renameSync(tmpPath, p); -} - -function loadUserShareFromFile(signerName: string): string | null { - const p = getUserShareFilePath(signerName); - if (!existsSync(p)) return null; - return readFileSync(p, 'utf-8').trim(); -} - -function deleteUserShareFile(signerName: string): boolean { - const p = getUserShareFilePath(signerName); - if (!existsSync(p)) return false; - unlinkSync(p); - return true; -} - -// --------------------------------------------------------------------------- -// File-based session fallback -// --------------------------------------------------------------------------- - -function getSessionFilePath(): string { - return join(getConfigDir(), 'session.json'); -} - -function storeSessionToFile(token: string, serverUrl?: string, refreshToken?: string): void { - const dir = getConfigDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - const p = getSessionFilePath(); - const tmpPath = `${p}.tmp`; - const payload: Record = { token, createdAt: new Date().toISOString() }; - if (serverUrl) payload.serverUrl = serverUrl; - if (refreshToken) payload.refreshToken = refreshToken; - writeFileSync(tmpPath, JSON.stringify(payload), { mode: 0o600 }); - renameSync(tmpPath, p); -} - -function loadSessionFromFile(): string | null { - const p = getSessionFilePath(); - if (!existsSync(p)) return null; - try { - const data = JSON.parse(readFileSync(p, 'utf-8')) as { token?: string }; - return data.token ?? null; - } catch { - return null; - } -} - -function loadRefreshTokenFromFile(): string | null { - const p = getSessionFilePath(); - if (!existsSync(p)) return null; - try { - const data = JSON.parse(readFileSync(p, 'utf-8')) as { refreshToken?: string }; - return data.refreshToken ?? null; - } catch { - return null; - } -} - -/** Read the server URL stored alongside the session token (if present). */ -export async function getSessionServerUrl(): Promise { - const p = getSessionFilePath(); - if (!existsSync(p)) return null; - try { - const data = JSON.parse(readFileSync(p, 'utf-8')) as { serverUrl?: string }; - return data.serverUrl ?? null; - } catch { - return null; - } -} - -function deleteSessionFile(): boolean { - const p = getSessionFilePath(); - if (!existsSync(p)) return false; - unlinkSync(p); - return true; -} diff --git a/packages/wallet/src/lib/policy-conversions.ts b/packages/wallet/src/lib/policy-conversions.ts deleted file mode 100644 index 245d532..0000000 --- a/packages/wallet/src/lib/policy-conversions.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Policy conversion utilities for the CLI. - * Mirrors the app's policy-builder/conversions.ts but for CLI use. - */ - -import { CRITERION_CATALOG } from '@agentaos/core'; -import type { CriterionMeta } from '@agentaos/core'; - -export type FormValues = Record>; -export type EnabledMap = Record; - -/** - * Convert form state → PolicyRule[] for the API. - * Reject rules first, then one accept rule with all AND'd criteria. - */ -export function buildRules(values: FormValues, enabled: EnabledMap): Record[] { - const rules: Record[] = []; - const rejectCriteria: Record[] = []; - const acceptCriteria: Record[] = []; - - for (const meta of CRITERION_CATALOG) { - if (meta.alwaysOn) continue; - if (!enabled[meta.type]) continue; - - const fieldValues = values[meta.type] ?? {}; - const criterion = meta.toCriterion(fieldValues); - - if (meta.type === 'evmAddressBlocked' || meta.type === 'blockInfiniteApprovals') { - rejectCriteria.push(criterion); - } else { - acceptCriteria.push(criterion); - } - } - - for (const c of rejectCriteria) { - rules.push({ action: 'reject', criteria: [c] }); - } - - if (acceptCriteria.length > 0) { - rules.push({ action: 'accept', criteria: acceptCriteria }); - } - - return rules; -} - -/** - * Parse a PolicyRule[] back to form state. - */ -export function parseFormValues(rules: Record[]): { - values: FormValues; - enabled: EnabledMap; -} { - const values: FormValues = {}; - const enabled: EnabledMap = {}; - - const metaByType = new Map(); - for (const meta of CRITERION_CATALOG) { - metaByType.set(meta.type, meta); - } - - for (const rule of rules) { - const criteria = (rule as { criteria?: Record[] }).criteria ?? []; - for (const criterion of criteria) { - const type = criterion.type as string; - if (!type) continue; - - const resolvedType = - type === 'evmAddress' && criterion.operator === 'not_in' ? 'evmAddressBlocked' : type; - - const meta = metaByType.get(resolvedType); - if (!meta) continue; - - enabled[resolvedType] = true; - values[resolvedType] = meta.fromCriterion(criterion); - } - } - - return { values, enabled }; -} diff --git a/packages/wallet/src/lib/session-store.ts b/packages/wallet/src/lib/session-store.ts new file mode 100644 index 0000000..4efa1c9 --- /dev/null +++ b/packages/wallet/src/lib/session-store.ts @@ -0,0 +1,83 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { getConfigDir } from './config.js'; + +/** + * The CLI session on disk: `~/.agenta/session.json`, mode 0600. + * + * This was `keychain.ts`, and most of it was the macOS `security` integration + * that held agent sub-account signing shares behind Touch ID. That went with + * the sub-account surface. Sessions never used the keychain — a short-lived JWT + * does not need a biometric gate, and the prompt-per-read was pure friction — + * so what is left is a plain file store, now named for what it does. + * + * Writes go through a temp file and `rename`, which is atomic on POSIX: a + * crash mid-write can never leave a half-written session behind. + */ + +interface StoredSession { + token?: string; + refreshToken?: string; + serverUrl?: string; + createdAt?: string; +} + +function sessionFilePath(): string { + return join(getConfigDir(), 'session.json'); +} + +function read(): StoredSession | null { + const p = sessionFilePath(); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as StoredSession; + } catch { + return null; + } +} + +export async function storeSession( + token: string, + serverUrl?: string, + refreshToken?: string, +): Promise { + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const payload: StoredSession = { token, createdAt: new Date().toISOString() }; + if (serverUrl) payload.serverUrl = serverUrl; + if (refreshToken) payload.refreshToken = refreshToken; + + const p = sessionFilePath(); + const tmp = `${p}.tmp`; + writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 }); + renameSync(tmp, p); +} + +export async function getSession(): Promise { + return read()?.token ?? null; +} + +export async function getRefreshToken(): Promise { + return read()?.refreshToken ?? null; +} + +/** The server this session was created against, so every later command talks to + * the same one without needing `--server` again. */ +export async function getSessionServerUrl(): Promise { + return read()?.serverUrl ?? null; +} + +export async function deleteSession(): Promise { + const p = sessionFilePath(); + if (!existsSync(p)) return false; + unlinkSync(p); + return true; +} diff --git a/packages/wallet/src/lib/signer-manager.ts b/packages/wallet/src/lib/signer-manager.ts deleted file mode 100644 index 88f655e..0000000 --- a/packages/wallet/src/lib/signer-manager.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { CGGMP24Scheme } from '@agentaos/engine'; -import { AgentaApi, HttpClient, ThresholdSigner } from '@agentaos/sdk'; -import { loadSignerConfig, resolveApiSecret } from './config.js'; - -export class SignerManager { - private signer: ThresholdSigner | null = null; - private signerPromise: Promise | null = null; - private httpClient: HttpClient | null = null; - private api: AgentaApi | null = null; - - private getConfig() { - const apiSecret = process.env.AGENTA_API_SECRET; - const apiKey = process.env.AGENTA_API_KEY; - const serverUrl = process.env.AGENTA_SERVER || 'https://api.agentaos.ai'; - - // Env vars present — use them (MCP / CI mode) - if (apiSecret && apiKey) { - return { apiSecret, serverUrl, apiKey }; - } - - // Fall back to local signer config (~/.agenta/signers/) - const config = loadSignerConfig(); - return { - apiSecret: resolveApiSecret(config), - serverUrl: config.serverUrl, - apiKey: config.apiKey, - }; - } - - async getSigner(): Promise { - if (this.signer && !this.signer.isDestroyed) return this.signer; - - // Prevent concurrent creation — reuse the in-flight promise - if (this.signerPromise) return this.signerPromise; - - this.signer = null; - const { apiSecret, serverUrl, apiKey } = this.getConfig(); - this.signerPromise = ThresholdSigner.fromSecret({ - apiSecret, - serverUrl, - apiKey, - scheme: new CGGMP24Scheme(), - }) - .then((s) => { - this.signer = s; - this.signerPromise = null; - return s; - }) - .catch((err) => { - this.signerPromise = null; - throw err; - }); - - return this.signerPromise; - } - - getHttpClient(): HttpClient { - if (this.httpClient) return this.httpClient; - - const { serverUrl, apiKey } = this.getConfig(); - this.httpClient = new HttpClient({ baseUrl: serverUrl, apiKey }); - return this.httpClient; - } - - getApi(): AgentaApi { - if (this.api) return this.api; - this.api = new AgentaApi(this.getHttpClient()); - return this.api; - } - - /** AGENTA_NETWORK — network name matching server's GET /api/v1/networks (e.g. "base-sepolia", "mainnet"). */ - getNetwork(): string | null { - return process.env.AGENTA_NETWORK || null; - } - - requireNetwork(networkParam?: string): string { - const network = networkParam || this.getNetwork(); - if (!network) { - throw new Error( - 'No network specified. Call agenta_list_networks first to see available networks, then pass the "network" parameter to this tool.', - ); - } - return network; - } - - getSignerAddress(): string | undefined { - return this.signer?.address; - } - - destroy(): void { - if (this.signer && !this.signer.isDestroyed) { - this.signer.destroy(); - } - this.signer = null; - this.signerPromise = null; - this.httpClient = null; - this.api = null; - } -} diff --git a/packages/wallet/src/lib/transfer-crypto.ts b/packages/wallet/src/lib/transfer-crypto.ts deleted file mode 100644 index 3a5949d..0000000 --- a/packages/wallet/src/lib/transfer-crypto.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { hkdf } from '@noble/hashes/hkdf'; -import { sha256 } from '@noble/hashes/sha256'; -import { wordlist } from '@scure/bip39/wordlists/english.js'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const HKDF_SALT = 'agentaos-transfer-v1'; -const HKDF_INFO_PREFIX = 'aes-256-gcm:'; -const WORD_COUNT = 6; -const AES_KEY_BYTES = 32; -const AES_IV_BYTES = 12; - -// --------------------------------------------------------------------------- -// Transfer code generation & key derivation -// --------------------------------------------------------------------------- - -/** - * Generate a 6-word BIP39 transfer code and derive the corresponding AES-256-GCM key. - * - * The words are randomly chosen from the BIP39 English wordlist (2048 words). - * 6 words = ~66 bits of entropy — sufficient for a 10-minute expiry window. - * - * The AES key is derived via HKDF-SHA256 with a fixed salt and transfer-specific info, - * binding the key to a specific transfer ID to prevent cross-transfer reuse. - */ -export function generateTransferCode(transferId: string): { - words: string[]; - transferKey: Uint8Array; -} { - const entropy = randomBytes(WORD_COUNT * 2); // 2 bytes per word → 0..65535 mod 2048 - const words: string[] = []; - - for (let i = 0; i < WORD_COUNT; i++) { - const hi = entropy[i * 2] as number; - const lo = entropy[i * 2 + 1] as number; - const index = ((hi << 8) | lo) % wordlist.length; - words.push(wordlist[index] as string); - } - - const transferKey = deriveTransferKey(words, transferId); - entropy.fill(0); - return { words, transferKey }; -} - -/** - * Derive the AES-256-GCM key from 6 BIP39 words and a transfer ID. - * - * Uses HKDF-SHA256 with: - * - IKM: space-joined lowercase words - * - Salt: 'agentaos-transfer-v1' - * - Info: 'aes-256-gcm:{transferId}' - */ -export function deriveTransferKey(words: string[], transferId: string): Uint8Array { - if (words.length !== WORD_COUNT) { - throw new Error(`Expected ${WORD_COUNT} words, got ${words.length}`); - } - - // Validate all words are in the BIP39 wordlist - const wordSet = new Set(wordlist); - for (const word of words) { - if (!wordSet.has(word.toLowerCase())) { - throw new Error(`Invalid word: "${word}". Must be a valid BIP39 word.`); - } - } - - const ikm = new TextEncoder().encode(words.join(' ').toLowerCase()); - const salt = new TextEncoder().encode(HKDF_SALT); - const info = new TextEncoder().encode(`${HKDF_INFO_PREFIX}${transferId}`); - - return hkdf(sha256, ikm, salt, info, AES_KEY_BYTES); -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** Extract a plain ArrayBuffer from a Uint8Array (handles Buffer subarrays). */ -function toArrayBuffer(buf: Uint8Array): ArrayBuffer { - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; -} - -// --------------------------------------------------------------------------- -// AES-256-GCM encrypt / decrypt -// --------------------------------------------------------------------------- - -/** - * Encrypt share bytes with AES-256-GCM using the transfer key. - * - * Returns base64-encoded ciphertext: IV (12 bytes) || ciphertext || tag (16 bytes). - */ -export async function encryptShareForTransfer( - shareBytes: Uint8Array, - transferKey: Uint8Array, -): Promise { - const iv = randomBytes(AES_IV_BYTES); - const key = await crypto.subtle.importKey( - 'raw', - toArrayBuffer(transferKey), - { name: 'AES-GCM' }, - false, - ['encrypt'], - ); - - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: toArrayBuffer(iv) }, - key, - toArrayBuffer(shareBytes), - ), - ); - - // Pack: IV || ciphertext (includes GCM tag) - const packed = new Uint8Array(iv.length + ciphertext.length); - packed.set(iv, 0); - packed.set(ciphertext, iv.length); - - return Buffer.from(packed).toString('base64'); -} - -/** - * Decrypt share bytes from AES-256-GCM ciphertext. - * - * Input: base64-encoded IV (12 bytes) || ciphertext || tag (16 bytes). - * Throws on wrong key (GCM tag verification failure). - */ -export async function decryptShareFromTransfer( - ciphertextBase64: string, - transferKey: Uint8Array, -): Promise { - const packed = Buffer.from(ciphertextBase64, 'base64'); - if (packed.length < AES_IV_BYTES + 16) { - throw new Error('Ciphertext too short — expected at least IV + GCM tag'); - } - - const iv = packed.subarray(0, AES_IV_BYTES); - const ciphertext = packed.subarray(AES_IV_BYTES); - - const key = await crypto.subtle.importKey( - 'raw', - toArrayBuffer(transferKey), - { name: 'AES-GCM' }, - false, - ['decrypt'], - ); - - try { - const plaintext = new Uint8Array( - await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: toArrayBuffer(iv) }, - key, - toArrayBuffer(ciphertext), - ), - ); - return plaintext; - } catch { - throw new Error('Decryption failed — wrong transfer code or corrupted data'); - } -} diff --git a/packages/wallet/src/lib/x402-client.ts b/packages/wallet/src/lib/x402-client.ts deleted file mode 100644 index bfcbb43..0000000 --- a/packages/wallet/src/lib/x402-client.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * x402 Protocol Client for AgentaOS - * - * Supports the x402 **exact** payment scheme (ERC-3009 / Permit2) via @x402/evm. - * Both v2 (CAIP-2 networks, PAYMENT-REQUIRED/PAYMENT-SIGNATURE headers) and - * v1 (legacy network names, X-PAYMENT header) are supported. - */ - -import type { ThresholdSigner } from '@agentaos/sdk'; -import type { PaymentRequired, PaymentRequirements } from '@x402/core/types'; -import type { ClientEvmSigner } from '@x402/evm'; - -// --------------------------------------------------------------------------- -// Public Types -// --------------------------------------------------------------------------- - -export interface X402CheckResult { - requires402: boolean; - url: string; - paymentRequired?: PaymentRequired; -} - -export interface X402FetchResult { - status: number; - url: string; - paid: boolean; - scheme?: string; - transaction?: string; - payer?: string; - contentType?: string; - body: string; -} - -export interface X402DiscoverResult { - domain: string; - endpoints: Array<{ - path: string; - method: string; - scheme?: string; - network?: string; - amount?: string; - asset?: string; - description?: string; - }>; -} - -export interface X402FetchOptions { - /** Maximum amount willing to pay in atomic units (e.g., "1000000" = 1 USDC). */ - maxAmount?: string; - /** Preferred token addresses in order. Reorders the accepts list. */ - preferTokens?: string[]; -} - -// --------------------------------------------------------------------------- -// Signer Bridge -// --------------------------------------------------------------------------- - -/** - * Bridge ThresholdSigner → ClientEvmSigner for @x402/evm. - * ThresholdSigner.signMessage() detects EIP-712 typed data via `{ domain }`. - */ -function toX402Signer(signer: ThresholdSigner): ClientEvmSigner { - return { - address: signer.address as `0x${string}`, - async signTypedData(message: { - domain: Record; - types: Record; - primaryType: string; - message: Record; - }): Promise<`0x${string}`> { - const result = await signer.signMessage(message); - return result.signature as `0x${string}`; - }, - }; -} - -// --------------------------------------------------------------------------- -// Header Parsing (for checkX402 / discoverX402 — no signer needed) -// --------------------------------------------------------------------------- - -const PAYMENT_REQUIRED_HEADERS = ['payment-required', 'x-payment'] as const; -const PAYMENT_RESPONSE_HEADERS = ['payment-response', 'x-payment-response'] as const; - -function decodeBase64Header(raw: string): T { - return JSON.parse(Buffer.from(raw, 'base64').toString('utf-8')); -} - -function readHeader(headers: Headers, names: readonly string[]): string | null { - for (const name of names) { - const val = headers.get(name); - if (val) return val; - } - return null; -} - -async function parsePaymentRequired(response: Response): Promise { - const raw = readHeader(response.headers, PAYMENT_REQUIRED_HEADERS); - if (raw) { - try { - return decodeBase64Header(raw); - } catch { - // Malformed header — try body - } - } - - const ct = response.headers.get('content-type') || ''; - if (ct.includes('json')) { - try { - const body = (await response.json()) as Record; - if (Array.isArray(body.accepts)) return body as unknown as PaymentRequired; - } catch { - // Not JSON - } - } - - return null; -} - -interface SettlementData { - transaction?: string; - payer?: string; -} - -function parseSettlement(headers: Headers): SettlementData | null { - const raw = readHeader(headers, PAYMENT_RESPONSE_HEADERS); - if (!raw) return null; - try { - return decodeBase64Header(raw); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Policies -// --------------------------------------------------------------------------- - -function maxAmountPolicy(max: string) { - const limit = BigInt(max); - return (_v: number, reqs: PaymentRequirements[]): PaymentRequirements[] => - reqs.filter((r) => BigInt(r.amount) <= limit); -} - -function preferTokensPolicy(tokens: string[]) { - const normalized = tokens.map((t) => t.toLowerCase()); - return (_v: number, reqs: PaymentRequirements[]): PaymentRequirements[] => { - const preferred: (PaymentRequirements | undefined)[] = new Array(normalized.length); - const rest: PaymentRequirements[] = []; - for (const r of reqs) { - const idx = normalized.indexOf(r.asset.toLowerCase()); - if (idx >= 0) preferred[idx] = r; - else rest.push(r); - } - return [...(preferred.filter(Boolean) as PaymentRequirements[]), ...rest]; - }; -} - -// --------------------------------------------------------------------------- -// x402 Client Factory -// --------------------------------------------------------------------------- - -async function createHttpClient(signer: ThresholdSigner, opts?: X402FetchOptions) { - const { x402Client, x402HTTPClient } = await import('@x402/core/client'); - const { registerExactEvmScheme } = await import('@x402/evm/exact/client'); - - const client = new x402Client(); - registerExactEvmScheme(client, { signer: toX402Signer(signer) }); - - if (opts?.maxAmount) client.registerPolicy(maxAmountPolicy(opts.maxAmount)); - if (opts?.preferTokens?.length) client.registerPolicy(preferTokensPolicy(opts.preferTokens)); - - return new x402HTTPClient(client); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Check if a URL requires x402 payment. No signer needed. - */ -export async function checkX402(url: string): Promise { - const response = await fetch(url, { - method: 'GET', - signal: AbortSignal.timeout(15_000), - redirect: 'follow', - }); - - if (response.status !== 402) { - return { requires402: false, url }; - } - - const paymentRequired = await parsePaymentRequired(response); - return { requires402: true, url, paymentRequired: paymentRequired ?? undefined }; -} - -/** - * Discover x402-protected endpoints on a domain. - * Checks .well-known/x402 first, then probes common paths. - */ -export async function discoverX402(domain: string): Promise { - const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`; - const endpoints: X402DiscoverResult['endpoints'] = []; - - // 1. Try .well-known/x402 (returns endpoint manifest) - try { - const wellKnown = await fetch(`${baseUrl}/.well-known/x402`, { - signal: AbortSignal.timeout(10_000), - }); - if (wellKnown.ok) { - const body = (await wellKnown.json()) as { endpoints?: Array> }; - if (body.endpoints) { - for (const ep of body.endpoints) { - endpoints.push({ - path: String(ep.path || '/'), - method: String(ep.method || 'GET'), - scheme: ep.scheme ? String(ep.scheme) : undefined, - network: ep.network ? String(ep.network) : undefined, - amount: ep.amount ? String(ep.amount) : undefined, - asset: ep.asset ? String(ep.asset) : undefined, - description: ep.description ? String(ep.description) : undefined, - }); - } - return { domain, endpoints }; - } - } - } catch { - // No well-known endpoint - } - - // 2. Probe common paths (well-known not included — already checked above) - const probePaths = ['/', '/api', '/api/v1', '/data', '/premium', '/content']; - - const probes = probePaths.map(async (path) => { - try { - const result = await checkX402(`${baseUrl}${path}`); - if (result.requires402 && result.paymentRequired) { - for (const req of result.paymentRequired.accepts) { - endpoints.push({ - path, - method: 'GET', - scheme: req.scheme, - network: req.network, - amount: req.amount, - asset: req.asset, - }); - } - } - } catch { - // Unreachable - } - }); - - await Promise.allSettled(probes); - return { domain, endpoints }; -} - -/** - * Fetch a 402-protected resource, automatically paying via the x402 exact scheme. - * - * 1. GET → if not 402, return response - * 2. Parse PAYMENT-REQUIRED header → PaymentRequirements[] - * 3. Apply policies (maxAmount, preferTokens) - * 4. Sign EIP-712 payment payload via threshold signer - * 5. Retry with PAYMENT-SIGNATURE header - * 6. Parse settlement from PAYMENT-RESPONSE header - */ -export async function fetchWithX402( - url: string, - signer: ThresholdSigner, - opts?: X402FetchOptions, -): Promise { - const initial = await fetch(url, { - method: 'GET', - signal: AbortSignal.timeout(15_000), - redirect: 'follow', - }); - - if (initial.status !== 402) { - return { - status: initial.status, - url, - paid: false, - contentType: initial.headers.get('content-type') || undefined, - body: await initial.text(), - }; - } - - const paymentRequired = await parsePaymentRequired(initial); - if (!paymentRequired?.accepts?.length) { - throw new Error('402 response but could not parse payment requirements'); - } - - const httpClient = await createHttpClient(signer, opts); - const paymentPayload = await httpClient.createPaymentPayload(paymentRequired); - const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload); - - const paidResponse = await fetch(url, { - method: 'GET', - headers: paymentHeaders, - signal: AbortSignal.timeout(30_000), - redirect: 'follow', - }); - - const settlement = parseSettlement(paidResponse.headers); - - return { - status: paidResponse.status, - url, - paid: true, - scheme: 'exact', - transaction: settlement?.transaction, - payer: settlement?.payer, - contentType: paidResponse.headers.get('content-type') || undefined, - body: await paidResponse.text(), - }; -} diff --git a/packages/wallet/src/mcp/index.ts b/packages/wallet/src/mcp/index.ts index de97cac..b68057e 100644 --- a/packages/wallet/src/mcp/index.ts +++ b/packages/wallet/src/mcp/index.ts @@ -1,39 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { SignerManager } from '../lib/signer-manager.js'; - -import { registerListNetworks } from './tools/list-networks.js'; -import { registerListSigners } from './tools/list-signers.js'; -import { registerResolveAddress } from './tools/resolve-address.js'; -// Discovery -import { registerWalletOverview } from './tools/wallet-overview.js'; - -import { registerGetBalances } from './tools/get-balances.js'; -// Common operations -import { registerSendEth } from './tools/send-eth.js'; -import { registerSendToken } from './tools/send-token.js'; - -// Advanced — contract interaction -import { registerCallContract } from './tools/call-contract.js'; -import { registerExecute } from './tools/execute.js'; -import { registerReadContract } from './tools/read-contract.js'; -import { registerSimulate } from './tools/simulate.js'; - -// Signing -import { registerSignMessage } from './tools/sign-message.js'; -import { registerSignTypedData } from './tools/sign-typed-data.js'; - -import { registerGetAuditLog } from './tools/get-audit-log.js'; -// Management & Audit -import { registerGetStatus } from './tools/get-status.js'; - -// x402 payment tools -import { registerX402Check } from './tools/x402-check.js'; -import { registerX402Discover } from './tools/x402-discover.js'; -import { registerX402Fetch } from './tools/x402-fetch.js'; - -// Merchant payment tools (agenta_pay_*) — uses @agentaos/pay SDK import { registerPayCancelSubscription } from './tools/pay-cancel-subscription.js'; import { registerPayCreateCheckout } from './tools/pay-create-checkout.js'; import { registerPayGetCheckout } from './tools/pay-get-checkout.js'; @@ -43,8 +10,12 @@ import { registerPayListSubscriptions } from './tools/pay-list-subscriptions.js' import { registerPaySendReceipt } from './tools/pay-send-receipt.js'; /** - * Start the AgentaOS MCP server with all tools. - * Connects via stdio transport. + * The AgentaOS MCP server, over stdio. + * + * Merchant tools only. The agent sub-account surface (MPC signers, on-chain + * sends, contract calls, message signing, the signing audit log and x402) was + * removed: it is the wallet-era product, not the merchant-of-record one, and + * every one of those tools needed key material this server no longer holds. */ export async function runMcp() { const server = new McpServer({ @@ -52,39 +23,6 @@ export async function runMcp() { version: '0.1.0', }); - const signerManager = new SignerManager(); - - // Discovery — the LLM should call these first - registerWalletOverview(server, signerManager); - registerListNetworks(server, signerManager); - registerListSigners(server, signerManager); - registerResolveAddress(server, signerManager); - - // Common operations — web2-style - registerSendEth(server, signerManager); - registerSendToken(server, signerManager); - registerGetBalances(server, signerManager); - - // Advanced — arbitrary contract interaction - registerCallContract(server, signerManager); - registerReadContract(server, signerManager); - registerExecute(server, signerManager); - registerSimulate(server, signerManager); - - // Signing - registerSignMessage(server, signerManager); - registerSignTypedData(server, signerManager); - - // Management & Audit - registerGetStatus(server, signerManager); - registerGetAuditLog(server, signerManager); - - // x402 payment tools - registerX402Check(server); - registerX402Discover(server); - registerX402Fetch(server, signerManager); - - // Merchant payment tools — no signerManager needed, uses @agentaos/pay SDK registerPayCreateCheckout(server); registerPayGetCheckout(server); registerPayListCheckouts(server); @@ -93,15 +31,6 @@ export async function runMcp() { registerPayListCustomers(server); registerPaySendReceipt(server); - // Graceful shutdown — wipe key material - const shutdown = () => { - signerManager.destroy(); - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - // Connect via stdio const transport = new StdioServerTransport(); await server.connect(transport); } diff --git a/packages/wallet/src/mcp/tools/call-contract.ts b/packages/wallet/src/mcp/tools/call-contract.ts deleted file mode 100644 index e144c19..0000000 --- a/packages/wallet/src/mcp/tools/call-contract.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { encodeFunctionData, parseEther } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerCallContract(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_call_contract', - { - description: - 'Call a function on any smart contract using AgentaOS threshold signing (2-of-3 MPC). Provide the contract ABI, function name, and arguments. The full private key never exists. Use agenta_simulate first to estimate gas.', - inputSchema: { - contractAddress: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .describe('Contract address (0x...)'), - abi: z - .array(z.record(z.unknown())) - .describe('Contract ABI (JSON array of function/event definitions)'), - functionName: z - .string() - .describe('Name of the function to call (e.g. "swap", "approve", "mint")'), - args: z - .array(z.unknown()) - .optional() - .default([]) - .describe('Function arguments as an ordered array'), - value: z - .string() - .optional() - .describe('ETH value to send with the call, in ETH (e.g. "0.1"). Defaults to "0".'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ contractAddress, abi, functionName, args, value, network }) => { - const api = signerManager.getApi(); - const signer = await signerManager.getSigner(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - const data = encodeFunctionData({ abi, functionName, args }); - - const result = await signer.signTransaction({ - to: contractAddress, - data, - network: targetNetwork, - value: value ? parseEther(value).toString() : '0', - }); - - const explorer = await api.getExplorerTxUrl(targetNetwork, result.txHash); - return { - content: [ - { - type: 'text' as const, - text: [ - 'Contract call successful.', - `Function: ${functionName}`, - `Contract: ${contractAddress}`, - `Tx Hash: ${result.txHash}`, - `Network: ${targetNetwork}`, - explorer ? `Explorer: ${explorer}` : '', - ] - .filter(Boolean) - .join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, `Contract call ${functionName}() failed`); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/execute.ts b/packages/wallet/src/mcp/tools/execute.ts deleted file mode 100644 index 12a752e..0000000 --- a/packages/wallet/src/mcp/tools/execute.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { parseEther } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerExecute(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_execute', - { - description: - 'Execute a raw Ethereum transaction with pre-encoded calldata using AgentaOS threshold signing. For advanced use cases where you already have the encoded transaction data.', - inputSchema: { - to: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .describe('Target address (0x...)'), - data: z - .string() - .regex(/^0x[0-9a-fA-F]*$/) - .describe('Pre-encoded calldata as hex string (0x...)'), - value: z - .string() - .optional() - .describe('ETH value to send, in ETH (e.g. "0.1"). Defaults to "0".'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ to, data, value, network }) => { - const api = signerManager.getApi(); - const signer = await signerManager.getSigner(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - const result = await signer.signTransaction({ - to, - data, - value: value ? parseEther(value).toString() : '0', - network: targetNetwork, - }); - - const explorer = await api.getExplorerTxUrl(targetNetwork, result.txHash); - return { - content: [ - { - type: 'text' as const, - text: [ - 'Transaction executed.', - `To: ${to}`, - `Tx Hash: ${result.txHash}`, - `Network: ${targetNetwork}`, - explorer ? `Explorer: ${explorer}` : '', - ] - .filter(Boolean) - .join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'Transaction execution failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/get-audit-log.ts b/packages/wallet/src/mcp/tools/get-audit-log.ts deleted file mode 100644 index 05686d1..0000000 --- a/packages/wallet/src/mcp/tools/get-audit-log.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { formatUnits } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerGetAuditLog(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_get_audit_log', - { - description: - 'Get recent signing activity from the AgentaOS audit log. Shows past transactions, policy evaluations, decoded function calls, and gas costs. Use this to check spending history or verify past actions.', - inputSchema: { - limit: z - .number() - .int() - .min(1) - .max(100) - .optional() - .default(20) - .describe('Number of entries to return (default: 20, max: 100)'), - status: z - .enum(['all', 'completed', 'blocked', 'failed']) - .optional() - .default('all') - .describe( - 'Filter by status: "all", "completed", "blocked" (policy violation), or "failed"', - ), - page: z - .number() - .int() - .min(1) - .optional() - .default(1) - .describe('Page number for pagination (default: 1)'), - }, - }, - async ({ limit, status, page }) => { - const api = signerManager.getApi(); - - try { - const result = await api.getAuditLog({ - limit, - page, - status: status === 'all' ? undefined : status, - }); - - const { entries, meta } = result; - - if (!entries.length) { - return { - content: [ - { - type: 'text' as const, - text: 'No signing activity found.', - }, - ], - }; - } - - const lines: string[] = []; - - if (meta) { - lines.push( - `Showing ${entries.length} of ${meta.total} entries (page ${meta.page}/${meta.totalPages})`, - '', - ); - } - - for (const e of entries) { - const parts = [ - `[${e.createdAt}] ${e.status}`, - ` Type: ${e.requestType} | Path: ${e.signingPath}`, - ]; - if (e.toAddress) parts.push(` To: ${e.toAddress}`); - if (e.valueWei && e.valueWei !== '0') { - try { - parts.push(` Value: ${formatUnits(BigInt(e.valueWei), 18)} ETH`); - } catch { - parts.push(` Value: ${e.valueWei} wei`); - } - } - if (e.decodedAction) parts.push(` Action: ${e.decodedAction}`); - if (e.txHash) parts.push(` Tx: ${e.txHash}`); - if (e.policyViolations?.length) { - parts.push(` Violations: ${e.policyViolations.map((v) => v.type).join(', ')}`); - } - lines.push(parts.join('\n')); - } - - return { - content: [{ type: 'text' as const, text: lines.join('\n\n') }], - }; - } catch (error) { - return formatError(error, 'Audit log fetch failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/get-balances.ts b/packages/wallet/src/mcp/tools/get-balances.ts deleted file mode 100644 index 101096b..0000000 --- a/packages/wallet/src/mcp/tools/get-balances.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { http, createPublicClient, formatUnits } from 'viem'; -import { z } from 'zod'; -import { ERC20_ABI } from '../../lib/erc20-abi.js'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerGetBalances(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_get_balances', - { - description: - 'Get the ETH balance and optionally ERC-20 token balances of the AgentaOS threshold wallet. Returns balances across all configured networks.', - inputSchema: { - tokens: z - .array(z.string().regex(/^0x[0-9a-fA-F]{40}$/)) - .optional() - .describe( - 'Optional list of ERC-20 token addresses to check (0x...). If omitted, returns ETH balance only.', - ), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ tokens, network }) => { - const api = signerManager.getApi(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - const defaultSigner = await api.getDefaultSigner(); - const lines = [`Address: ${defaultSigner.ethAddress}`, `Network: ${targetNetwork}`]; - - try { - const balance = await api.getBalance(defaultSigner.id, targetNetwork); - - for (const nb of balance.balances) { - const label = balance.balances.length > 1 ? `ETH (${nb.network})` : 'ETH'; - if (nb.rpcError) { - lines.push(`${label}: RPC error`); - } else { - lines.push(`${label}: ${formatUnits(BigInt(nb.balance), 18)}`); - } - } - } catch { - lines.push('ETH: unable to fetch balance'); - } - - if (tokens?.length) { - const rpcUrl = await api.getRpcUrl(targetNetwork); - const publicClient = createPublicClient({ - transport: http(rpcUrl), - }); - const signerAddress = defaultSigner.ethAddress as `0x${string}`; - - for (const tokenAddr of tokens) { - const typedAddr = tokenAddr as `0x${string}`; - try { - const [bal, decimals, symbol] = await Promise.all([ - publicClient.readContract({ - address: typedAddr, - abi: ERC20_ABI, - functionName: 'balanceOf', - args: [signerAddress], - }), - publicClient.readContract({ - address: typedAddr, - abi: ERC20_ABI, - functionName: 'decimals', - }), - publicClient - .readContract({ - address: typedAddr, - abi: ERC20_ABI, - functionName: 'symbol', - }) - .catch(() => tokenAddr.slice(0, 10)), - ]); - lines.push(`${symbol}: ${formatUnits(bal, decimals)}`); - } catch { - lines.push(`${tokenAddr.slice(0, 10)}...: error reading balance`); - } - } - } - - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - } catch (error) { - return formatError(error, 'Balance check failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/get-status.ts b/packages/wallet/src/mcp/tools/get-status.ts deleted file mode 100644 index 2ed00e5..0000000 --- a/packages/wallet/src/mcp/tools/get-status.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerGetStatus(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_get_status', - { - description: - 'Get the AgentaOS server health status and signer information. Use this to verify the server is running and the signer is configured.', - }, - async () => { - const api = signerManager.getApi(); - const lines: string[] = []; - - try { - const health = await api.getHealth(); - - lines.push(`Server: ${health.status === 'ok' ? 'connected' : 'degraded'}`); - if (typeof health.uptime === 'number') { - lines.push(`Uptime: ${health.uptime}s`); - } - - // Vault / share store status - if (health.shareStore) { - lines.push( - `Share store: ${health.shareStore.connected ? 'connected' : 'disconnected'} (${health.shareStore.provider || 'unknown'})`, - ); - } else if (health.vault) { - lines.push(`Vault: ${health.vault.connected ? 'connected' : 'disconnected'}`); - } - - // Database - if (typeof health.db === 'boolean') { - lines.push(`Database: ${health.db ? 'connected' : 'error'}`); - } else if (health.database) { - lines.push( - `Database: ${health.database.connected ?? health.database.status ?? 'unknown'}`, - ); - } - - // Aux info pool - if (health.auxInfoPool) { - if ( - typeof health.auxInfoPool.ready === 'number' && - typeof health.auxInfoPool.total === 'number' - ) { - lines.push( - `Aux info pool: ${health.auxInfoPool.ready}/${health.auxInfoPool.total} ready`, - ); - } - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - lines.push(`Server: unreachable (${msg})`); - } - - try { - const signers = await api.listSigners(); - if (signers.length) { - for (const s of signers) { - lines.push(''); - lines.push(`Signer: ${s.name || 'unnamed'} [${s.id}]`); - lines.push(` Address: ${s.ethAddress}`); - lines.push(` Chain: ${s.chain || 'ethereum'}`); - lines.push(` Network: ${s.network || 'any (specify per request)'}`); - lines.push(` Status: ${s.status || 'unknown'}`); - lines.push(` DKG: ${s.dkgCompleted ? 'completed' : 'pending'}`); - } - } else { - lines.push(''); - lines.push('No signers found.'); - } - } catch { - lines.push(''); - lines.push('Signers: unable to fetch'); - } - - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/list-networks.ts b/packages/wallet/src/mcp/tools/list-networks.ts deleted file mode 100644 index 3080d13..0000000 --- a/packages/wallet/src/mcp/tools/list-networks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerListNetworks(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_list_networks', - { - description: - 'List all available networks configured on the AgentaOS server. Returns network name, CAIP-2 networkId (e.g. "eip155:84532"), chain ID, testnet status, and native currency. Pass the "name" value as the "network" parameter to other tools.', - }, - async () => { - const api = signerManager.getApi(); - const envDefault = signerManager.getNetwork(); - - try { - const networks = await api.listNetworks(); - - if (!networks.length) { - return { - content: [ - { - type: 'text' as const, - text: 'No networks configured on the server.', - }, - ], - }; - } - - const lines = [`${networks.length} network(s) available:`, '']; - - for (const n of networks) { - const active = envDefault && n.name === envDefault ? ' (default)' : ''; - const testnet = n.isTestnet ? ' [testnet]' : ''; - lines.push( - `${n.displayName || n.name}${active}${testnet}`, - ` Name: ${n.name}`, - ` Network ID: ${n.networkId}`, - ` Chain ID: ${n.chainId}`, - ` Currency: ${n.nativeCurrency}`, - '', - ); - } - - lines.push('Tip: Pass "network" parameter to any tool to select a network.'); - - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - } catch (error) { - return formatError(error, 'Failed to list networks'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/list-signers.ts b/packages/wallet/src/mcp/tools/list-signers.ts deleted file mode 100644 index ca98ab8..0000000 --- a/packages/wallet/src/mcp/tools/list-signers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerListSigners(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_list_signers', - { - description: - 'List all signers accessible with the current API key. Shows name, ID, address, chain, network, and status.', - }, - async () => { - const api = signerManager.getApi(); - - try { - const signers = await api.listSigners(); - - if (!signers.length) { - return { - content: [{ type: 'text' as const, text: 'No accounts found.' }], - }; - } - - const lines = signers.map((s) => - [ - `${s.name || 'unnamed'} (${s.status || 'unknown'})`, - ` ID: ${s.id}`, - ` Address: ${s.ethAddress}`, - ` Chain: ${s.chain || 'ethereum'}`, - ` Network: ${s.network || 'unknown'}`, - ` DKG: ${s.dkgCompleted ? 'completed' : 'pending'}`, - ].join('\n'), - ); - - return { - content: [ - { - type: 'text' as const, - text: `${signers.length} account(s):\n\n${lines.join('\n\n')}`, - }, - ], - }; - } catch (error) { - return formatError(error, 'Failed to list signers'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/read-contract.ts b/packages/wallet/src/mcp/tools/read-contract.ts deleted file mode 100644 index a3511eb..0000000 --- a/packages/wallet/src/mcp/tools/read-contract.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { http, createPublicClient } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -function bigIntReplacer(_key: string, value: unknown) { - return typeof value === 'bigint' ? value.toString() : value; -} - -export function registerReadContract(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_read_contract', - { - description: - 'Read data from a smart contract (view/pure functions). No gas spent, no signing needed. Use this for checking balances, prices, allowances, or any on-chain state.', - inputSchema: { - contractAddress: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .describe('Contract address (0x...)'), - abi: z - .array(z.record(z.unknown())) - .describe('Contract ABI (JSON array). Can be just the relevant function fragment.'), - functionName: z - .string() - .describe('Name of the view/pure function to call (e.g. "balanceOf", "totalSupply")'), - args: z - .array(z.unknown()) - .optional() - .default([]) - .describe('Function arguments as an ordered array'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ contractAddress, abi, functionName, args, network }) => { - const api = signerManager.getApi(); - const targetNetwork = signerManager.requireNetwork(network); - const rpcUrl = await api.getRpcUrl(targetNetwork); - const client = createPublicClient({ transport: http(rpcUrl) }); - - try { - const result = await client.readContract({ - address: contractAddress as `0x${string}`, - abi, - functionName, - args, - }); - - return { - content: [ - { - type: 'text' as const, - text: [ - 'Contract read successful.', - `Function: ${functionName}`, - `Contract: ${contractAddress}`, - `Result: ${JSON.stringify(result, bigIntReplacer)}`, - ].join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'Read failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/resolve-address.ts b/packages/wallet/src/mcp/tools/resolve-address.ts deleted file mode 100644 index 825a4c7..0000000 --- a/packages/wallet/src/mcp/tools/resolve-address.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerResolveAddress(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_resolve_address', - { - description: - 'Resolve an ENS name (e.g. "vitalik.eth") to an Ethereum address. Useful before sending transactions to human-readable names.', - inputSchema: { - addressOrEns: z.string().describe('ENS name (e.g. "vitalik.eth") or 0x address to resolve'), - }, - }, - async ({ addressOrEns }) => { - const api = signerManager.getApi(); - - try { - const result = await api.resolveAddress(addressOrEns); - - if (result.isEns) { - return { - content: [ - { - type: 'text' as const, - text: `${result.ensName} → ${result.address}`, - }, - ], - }; - } - - return { - content: [ - { - type: 'text' as const, - text: `${result.address} (already a valid address)`, - }, - ], - }; - } catch (error) { - return formatError(error, 'Address resolution failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/send-eth.ts b/packages/wallet/src/mcp/tools/send-eth.ts deleted file mode 100644 index c238a88..0000000 --- a/packages/wallet/src/mcp/tools/send-eth.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { parseEther } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerSendEth(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_send_eth', - { - description: - 'Send ETH to any address or ENS name (e.g. "vitalik.eth"). Uses AgentaOS threshold signing — the full private key never exists. Policy-enforced by the server.', - inputSchema: { - to: z.string().describe('Recipient — 0x address or ENS name (e.g. "vitalik.eth")'), - value: z.string().describe('Amount in ETH (e.g. "0.01", "1.5")'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ to, value, network }) => { - const api = signerManager.getApi(); - const signer = await signerManager.getSigner(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - const resolved = await api.resolveAddress(to); - - const result = await signer.signTransaction({ - to: resolved.address, - value: parseEther(value).toString(), - network: targetNetwork, - }); - - const explorer = await api.getExplorerTxUrl(targetNetwork, result.txHash); - const recipientDisplay = resolved.isEns - ? `${resolved.ensName} (${resolved.address})` - : resolved.address; - - return { - content: [ - { - type: 'text' as const, - text: [ - `Sent ${value} ETH successfully.`, - `To: ${recipientDisplay}`, - `Tx Hash: ${result.txHash}`, - `Network: ${targetNetwork}`, - explorer ? `Explorer: ${explorer}` : '', - ] - .filter(Boolean) - .join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'ETH send failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/send-token.ts b/packages/wallet/src/mcp/tools/send-token.ts deleted file mode 100644 index d4cf4e9..0000000 --- a/packages/wallet/src/mcp/tools/send-token.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { http, createPublicClient, encodeFunctionData, parseUnits } from 'viem'; -import { z } from 'zod'; -import { ERC20_ABI } from '../../lib/erc20-abi.js'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerSendToken(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_send_token', - { - description: - 'Send ERC-20 tokens by symbol (e.g. "USDC", "WETH") or contract address. Supports ENS names for recipients. Automatically handles decimal conversion. The full private key never exists.', - inputSchema: { - token: z - .string() - .describe( - 'Token symbol (e.g. "USDC", "WETH") or contract address (0x...). Symbols are resolved from the server\'s tracked token list.', - ), - to: z.string().describe('Recipient — 0x address or ENS name (e.g. "vitalik.eth")'), - amount: z - .string() - .describe('Amount in human-readable units (e.g. "100" for 100 USDC, "0.5" for 0.5 WETH)'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ token, to, amount, network }) => { - const api = signerManager.getApi(); - const signer = await signerManager.getSigner(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - // Resolve recipient (ENS or 0x) - const resolved = await api.resolveAddress(to); - - // Get default signer for token resolution - const defaultSigner = await api.getDefaultSigner(); - - // Get chainId for this network - const chainId = await api.getChainId(targetNetwork); - - let tokenAddress: `0x${string}`; - let symbol: string; - let decimals: number; - - const tokenResolved = await api.resolveToken(token, defaultSigner.id, chainId); - tokenAddress = tokenResolved.address; - - if (tokenResolved.resolvedBySymbol) { - // Got everything from server registry - symbol = tokenResolved.symbol; - decimals = tokenResolved.decimals; - } else { - // Raw address — read decimals & symbol from chain - const rpcUrl = await api.getRpcUrl(targetNetwork); - const publicClient = createPublicClient({ - transport: http(rpcUrl), - }); - - [decimals, symbol] = await Promise.all([ - publicClient.readContract({ - address: tokenAddress, - abi: ERC20_ABI, - functionName: 'decimals', - }), - publicClient - .readContract({ - address: tokenAddress, - abi: ERC20_ABI, - functionName: 'symbol', - }) - .catch(() => 'TOKEN'), - ]); - } - - const rawAmount = parseUnits(amount, decimals); - const data = encodeFunctionData({ - abi: ERC20_ABI, - functionName: 'transfer', - args: [resolved.address, rawAmount], - }); - - const result = await signer.signTransaction({ - to: tokenAddress, - data, - value: '0', - network: targetNetwork, - }); - - const explorer = await api.getExplorerTxUrl(targetNetwork, result.txHash); - const recipientDisplay = resolved.isEns - ? `${resolved.ensName} (${resolved.address})` - : resolved.address; - - return { - content: [ - { - type: 'text' as const, - text: [ - `Sent ${amount} ${symbol} successfully.`, - `To: ${recipientDisplay}`, - `Token: ${symbol} (${tokenAddress})`, - `Tx Hash: ${result.txHash}`, - `Network: ${targetNetwork}`, - explorer ? `Explorer: ${explorer}` : '', - ] - .filter(Boolean) - .join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'Token send failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/sign-message.ts b/packages/wallet/src/mcp/tools/sign-message.ts deleted file mode 100644 index fd06575..0000000 --- a/packages/wallet/src/mcp/tools/sign-message.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerSignMessage(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_sign_message', - { - description: - 'Sign an arbitrary message using AgentaOS threshold signing (2-of-3 MPC). Returns an EIP-191 personal signature. No gas is spent.', - inputSchema: { - message: z.string().min(1).describe('The message to sign (plain text string)'), - }, - }, - async ({ message }) => { - const signer = await signerManager.getSigner(); - try { - const result = await signer.signMessage(message); - const preview = message.length > 100 ? `${message.slice(0, 100)}...` : message; - return { - content: [ - { - type: 'text' as const, - text: [ - 'Message signed successfully.', - `Message: "${preview}"`, - `Signature: ${result.signature}`, - `v: ${result.v} r: ${result.r} s: ${result.s}`, - ].join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'Message signing failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/sign-typed-data.ts b/packages/wallet/src/mcp/tools/sign-typed-data.ts deleted file mode 100644 index f22cac4..0000000 --- a/packages/wallet/src/mcp/tools/sign-typed-data.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerSignTypedData(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_sign_typed_data', - { - description: - 'Sign EIP-712 typed data using AgentaOS threshold signing. Used for x402 payments, Permit2 approvals, ERC-3009 transfers, and off-chain structured signatures. No gas is spent.', - inputSchema: { - domain: z - .record(z.unknown()) - .describe( - 'EIP-712 domain (e.g. { name: "USD Coin", version: "2", chainId: 84532, verifyingContract: "0x..." })', - ), - types: z - .record(z.array(z.object({ name: z.string(), type: z.string() }))) - .describe('EIP-712 type definitions'), - primaryType: z - .string() - .describe('Primary type name (e.g. "ReceiveWithAuthorization", "PermitTransferFrom")'), - message: z.record(z.unknown()).describe('The structured message data to sign'), - }, - }, - async ({ domain, types, primaryType, message }) => { - const signer = await signerManager.getSigner(); - try { - const result = await signer.signMessage({ - domain, - types, - primaryType, - message, - }); - const domainName = typeof domain.name === 'string' ? domain.name : 'unnamed'; - return { - content: [ - { - type: 'text' as const, - text: [ - 'Typed data signed successfully.', - `Primary type: ${primaryType}`, - `Domain: ${domainName}`, - `Signature: ${result.signature}`, - `v: ${result.v} r: ${result.r} s: ${result.s}`, - ].join('\n'), - }, - ], - }; - } catch (error) { - return formatError(error, 'Typed data signing failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/simulate.ts b/packages/wallet/src/mcp/tools/simulate.ts deleted file mode 100644 index e30bc56..0000000 --- a/packages/wallet/src/mcp/tools/simulate.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerSimulate(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_simulate', - { - description: - 'Simulate a transaction to estimate gas cost before sending. No signing, no broadcast. Use this before agenta_send_eth or agenta_call_contract to verify the transaction will succeed and see gas estimates. Note: policy evaluation only happens during actual signing.', - inputSchema: { - to: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .describe('Target address (0x...)'), - value: z - .string() - .optional() - .describe('ETH value in ETH units (e.g. "0.1"). Defaults to "0".'), - data: z - .string() - .optional() - .describe('Calldata as hex string (0x...). Omit for simple ETH transfers.'), - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ to, value, data, network }) => { - const api = signerManager.getApi(); - const targetNetwork = signerManager.requireNetwork(network); - - try { - const defaultSigner = await api.getDefaultSigner(); - - const result = await api.simulate(defaultSigner.id, { - to, - value: value || '0', - data: data || '0x', - network: targetNetwork, - }); - - const lines = [ - 'Simulation result:', - ` Would succeed: ${result.success ? 'yes' : 'no'}`, - ` Estimated gas: ${result.estimatedGas}`, - ` Gas cost: ~${result.gasCostEth} ETH`, - ` Network: ${targetNetwork}`, - ]; - - if (!result.success && result.error) { - lines.push(` Error: ${result.error}`); - } - - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - } catch (error) { - return formatError(error, 'Simulation failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/wallet-overview.ts b/packages/wallet/src/mcp/tools/wallet-overview.ts deleted file mode 100644 index 079b471..0000000 --- a/packages/wallet/src/mcp/tools/wallet-overview.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { formatUnits } from 'viem'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; - -export function registerWalletOverview(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_wallet_overview', - { - description: - 'Get a complete overview of the AgentaOS wallet — address, balances, tracked token balances, and recent transactions. This is the best starting tool for any conversation about the wallet. Requires a network to show balances.', - inputSchema: { - network: z - .string() - .optional() - .describe( - 'Network name from agenta_list_networks (e.g. "base-sepolia", "mainnet", "arbitrum"). Required to show balances — call agenta_list_networks first if unknown.', - ), - }, - }, - async ({ network }) => { - const api = signerManager.getApi(); - const lines: string[] = []; - - try { - // Fetch signer info - const signers = await api.listSigners(); - - if (!signers.length) { - return { - content: [ - { - type: 'text' as const, - text: 'No wallet found. Create an account first in AgentaOS.', - }, - ], - }; - } - - // Safe — guarded by !signers.length above - const signer = signers[0]!; - - lines.push(`Wallet: ${signer.name || 'AgentaOS'}`); - lines.push(`Address: ${signer.ethAddress}`); - lines.push(`Status: ${signer.status || 'active'}`); - - // If no network specified and no env default, list available networks - const targetNetwork = network || signerManager.getNetwork(); - if (!targetNetwork) { - lines.push(''); - lines.push( - 'No network specified — call agenta_list_networks to see available networks, then pass "network" to see balances.', - ); - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - } - - const chainId = await api.getChainId(targetNetwork); - lines.push( - chainId ? `Network: ${targetNetwork} (eip155:${chainId})` : `Network: ${targetNetwork}`, - ); - lines.push(''); - - // ETH balance - try { - const balance = await api.getBalance(signer.id, targetNetwork); - - for (const nb of balance.balances) { - if (nb.rpcError) { - lines.push('ETH: RPC error'); - } else { - lines.push(`ETH: ${formatUnits(BigInt(nb.balance), 18)}`); - } - } - } catch { - lines.push('ETH: unable to fetch'); - } - - // Token balances from server's tracked tokens - try { - if (chainId) { - const tokenBalances = await api.getTokenBalances(signer.id, chainId); - - for (const tb of tokenBalances) { - const formatted = formatUnits(BigInt(tb.balance), tb.decimals); - if (formatted !== '0') { - lines.push(`${tb.symbol}: ${formatted}`); - } - } - - if (!tokenBalances.length) { - lines.push('No tracked tokens. Add tokens in AgentaOS or use agenta_call_contract.'); - } - } - } catch { - // Token balances not available — skip silently - } - - // Recent activity (last 5) - lines.push(''); - lines.push('Recent activity:'); - try { - const audit = await api.getAuditLog({ limit: 5 }); - const entries = audit.entries; - - if (!entries.length) { - lines.push(' No transactions yet.'); - } else { - for (const e of entries) { - const status = e.status === 'completed' ? 'OK' : e.status.toUpperCase(); - const action = e.decodedAction || e.requestType || 'tx'; - const to = e.toAddress ? ` → ${e.toAddress.slice(0, 10)}...` : ''; - lines.push(` [${status}] ${action}${to}`); - } - } - } catch { - lines.push(' Unable to fetch activity.'); - } - - return { - content: [{ type: 'text' as const, text: lines.join('\n') }], - }; - } catch (error) { - return formatError(error, 'Wallet overview failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/x402-check.ts b/packages/wallet/src/mcp/tools/x402-check.ts deleted file mode 100644 index 0e7f5f9..0000000 --- a/packages/wallet/src/mcp/tools/x402-check.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import { checkX402 } from '../../lib/x402-client.js'; - -export function registerX402Check(server: McpServer) { - server.registerTool( - 'agenta_x402_check', - { - description: - 'Check if a URL requires x402 payment. Returns payment requirements (scheme, network, amount, asset) for each accepted payment option.', - inputSchema: { - url: z.string().url().describe('URL to check for x402 payment requirements'), - }, - }, - async ({ url }) => { - try { - const result = await checkX402(url); - - if (!result.requires402) { - return { - content: [{ type: 'text' as const, text: `${url} is freely accessible (no 402).` }], - }; - } - - const lines = ['Payment required (HTTP 402):']; - - if (result.paymentRequired?.accepts?.length) { - lines.push(`${result.paymentRequired.accepts.length} payment option(s):`); - lines.push(''); - for (const req of result.paymentRequired.accepts) { - lines.push(` Scheme: ${req.scheme}`); - lines.push(` Network: ${req.network}`); - lines.push(` Amount: ${req.amount}`); - lines.push(` Asset: ${req.asset}`); - lines.push(` Pay to: ${req.payTo}`); - if (req.extra && Object.keys(req.extra).length > 0) { - lines.push(` Extra: ${JSON.stringify(req.extra)}`); - } - lines.push(''); - } - } else { - lines.push('Could not parse payment details from response.'); - } - - return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; - } catch (error) { - return formatError(error, 'x402 check failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/x402-discover.ts b/packages/wallet/src/mcp/tools/x402-discover.ts deleted file mode 100644 index f05cac5..0000000 --- a/packages/wallet/src/mcp/tools/x402-discover.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import { discoverX402 } from '../../lib/x402-client.js'; - -export function registerX402Discover(server: McpServer) { - server.registerTool( - 'agenta_x402_discover', - { - description: - 'Discover x402-protected endpoints on a domain. Probes common paths and the .well-known/x402 endpoint to find paid resources. Returns scheme, network, amount, and asset for each endpoint.', - inputSchema: { - domain: z - .string() - .describe('Domain to probe (e.g., "api.example.com" or "https://api.example.com")'), - }, - }, - async ({ domain }) => { - try { - const result = await discoverX402(domain); - - if (result.endpoints.length === 0) { - return { - content: [{ type: 'text' as const, text: `No x402 endpoints found on ${domain}.` }], - }; - } - - const lines = [`Found ${result.endpoints.length} x402 endpoint(s) on ${domain}:`, '']; - for (const ep of result.endpoints) { - lines.push(`${ep.method} ${ep.path}`); - if (ep.scheme) lines.push(` Scheme: ${ep.scheme}`); - if (ep.network) lines.push(` Network: ${ep.network}`); - if (ep.amount) lines.push(` Amount: ${ep.amount}`); - if (ep.asset) lines.push(` Asset: ${ep.asset}`); - if (ep.description) lines.push(` ${ep.description}`); - lines.push(''); - } - - return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; - } catch (error) { - return formatError(error, 'x402 discovery failed'); - } - }, - ); -} diff --git a/packages/wallet/src/mcp/tools/x402-fetch.ts b/packages/wallet/src/mcp/tools/x402-fetch.ts deleted file mode 100644 index 5ae5d32..0000000 --- a/packages/wallet/src/mcp/tools/x402-fetch.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { formatError } from '../../lib/errors.js'; -import type { SignerManager } from '../../lib/signer-manager.js'; -import { fetchWithX402 } from '../../lib/x402-client.js'; - -export function registerX402Fetch(server: McpServer, signerManager: SignerManager) { - server.registerTool( - 'agenta_x402_fetch', - { - description: - 'Fetch a 402-protected resource, automatically paying with the AgentaOS threshold signer via the x402 exact scheme (ERC-3009/Permit2). The network and asset are auto-detected from the 402 payment requirements. The full private key never exists.', - inputSchema: { - url: z.string().url().describe('URL to fetch (may require x402 payment)'), - maxAmount: z - .string() - .optional() - .describe( - 'Maximum amount willing to pay in atomic units (e.g., "1000000" = 1 USDC). If omitted, any amount is accepted.', - ), - }, - }, - async ({ url, maxAmount }) => { - try { - const signer = await signerManager.getSigner(); - - const result = await fetchWithX402(url, signer, { - maxAmount, - }); - - const lines: string[] = []; - if (result.paid) { - lines.push(`Paid via ${result.scheme || 'exact'} scheme`); - if (result.transaction) lines.push(`Transaction: ${result.transaction}`); - if (result.payer) lines.push(`Payer: ${result.payer}`); - } else { - lines.push('Fetched (no payment needed)'); - } - lines.push(`Status: ${result.status}`); - if (result.contentType) lines.push(`Content-Type: ${result.contentType}`); - lines.push(''); - - // Truncate long bodies - const body = - result.body.length > 4000 ? `${result.body.slice(0, 4000)}\n...(truncated)` : result.body; - lines.push(body); - - return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; - } catch (error) { - return formatError(error, 'x402 fetch failed'); - } - }, - ); -}