diff --git a/apps/api/package.json b/apps/api/package.json index 46dea6b..c956efc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,8 @@ "test:performance": "tsx scripts/performance.ts after", "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts", "mcp:probe": "tsx scripts/mcp-probe.ts", - "icons:seed": "node scripts/seed-icons.cjs" + "icons:seed": "node scripts/seed-icons.cjs", + "test:mcp:codex": "tsx --test --test-concurrency=1 test/codex-oauth.test.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "1.31.0", diff --git a/apps/api/test/codex-oauth.test.ts b/apps/api/test/codex-oauth.test.ts new file mode 100644 index 0000000..6300270 --- /dev/null +++ b/apps/api/test/codex-oauth.test.ts @@ -0,0 +1,210 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { mkdtemp, writeFile, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, basename, sep } from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { PrismaClient } from '@prisma/client'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { today } from '../src/validation'; + +// Optional real installed-client check; never uses the user's Codex credentials. +for (const useDefaultScopes of [false, true]) + test( + 'Codex OAuth ' + + (useDefaultScopes ? 'default metadata scopes' : 'explicit scopes') + + ' are narrowed to draft without hidden access or direct posting', + async () => { + const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp'; + const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.1:3100'; + const origin = + process.env.WEB_ORIGIN && process.env.WEB_ORIGIN !== '*' + ? process.env.WEB_ORIGIN + : 'http://localhost:5173'; + const db = new PrismaClient(); + const home = await mkdtemp(join(tmpdir(), 'worthpath-codex-scope-')); + const username = 'codex_scope_' + randomUUID().slice(0, 12), + password = randomBytes(20).toString('hex'); + let userId = '', + clientId = '', + cli: ChildProcessWithoutNullStreams | undefined; + const client = new Client({ name: 'Codex OAuth grant verification', version: '1.31.0' }); + async function web(path: string, body: unknown, cookie = '') { + const r = await fetch(root + '/api' + path, { + method: 'POST', + headers: { + Origin: origin, + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify(body), + }); + assert.equal(r.status, 201, 'Web request failed: ' + path); + return { + data: await r.json(), + cookie: r.headers.get('set-cookie')?.split(';')[0] || cookie, + }; + } + async function deadline(promise: Promise, label: string) { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(Error(label + ' timed out')), 30000); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + try { + const fixture = await web('/auth/register', { username, password }); + userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; + await writeFile( + join(home, 'config.toml'), + 'mcp_oauth_credentials_store = "file"\n[mcp_servers.worthpath]\nurl = ' + + JSON.stringify(resource) + + '\n', + ); + cli = spawn( + process.env.CODEX_CLI || 'codex', + [ + 'mcp', + 'login', + 'worthpath', + '--no-browser', + ...(useDefaultScopes ? [] : ['--scopes', 'read,draft']), + '--oauth-client-registration', + 'dcr', + ], + { + env: { ...process.env, CODEX_HOME: home }, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + const processExit = new Promise((resolve, reject) => { + cli!.on('exit', resolve); + cli!.on('error', reject); + }); + // Observe the exit from the start so spawn errors never become unhandled promises. + void processExit.catch(() => {}); + const authorization = await deadline( + new Promise((resolve, reject) => { + let output = ''; + const receive = (chunk: Buffer) => { + output += chunk.toString(); + const found = output.match(/https?:\/\/[^\s]+\/authorize\?[^\s]+/); + if (found) resolve(found[0]); + }; + cli!.stdout.on('data', receive); + cli!.stderr.on('data', receive); + cli!.on('error', reject); + cli!.on('exit', () => reject(Error('CLI exited before authorization URL'))); + }), + 'CLI authorization URL', + ); + const request = new URL(authorization); + assert.equal(request.origin, new URL(resource).origin); + const requested = (request.searchParams.get('scope') || '').split(' ').filter(Boolean); + assert.deepEqual( + requested, + useDefaultScopes + ? ['read', 'draft', 'write', 'hidden_read', 'hidden_write'] + : ['read', 'draft'], + ); + clientId = request.searchParams.get('client_id')!; + const redirect = await fetch(request, { redirect: 'manual' }); + assert.equal(redirect.status, 302); + const id = new URL(redirect.headers.get('location')!).searchParams.get( + 'agent_authorization', + ); + assert.ok(id); + // Isolated disposable fixture only. Real users complete consent in the website. + const consent = await web( + '/agent/authorizations/' + id, + { approve: true, scopes: ['read', 'draft'] }, + fixture.cookie, + ); + cli.stdin.write(consent.data.redirect + '\n'); + cli.stdin.end(); + assert.equal(await deadline(processExit, 'CLI callback'), 0); + let access = ''; + const find = (value: unknown) => { + if (value && typeof value === 'object') { + const row = value as Record; + if (typeof row.access_token === 'string') access = row.access_token; + Object.values(row).forEach(find); + } + }; + for (const file of (await readdir(home)).filter((n) => n.endsWith('.json'))) + find(JSON.parse(await readFile(join(home, file), 'utf8'))); + assert.ok(access, 'CLI did not save an OAuth token in its isolated credentials store'); + const grant = await db.agentGrant.findFirstOrThrow({ where: { userId, clientId } }); + assert.deepEqual(grant.scopes, ['read', 'draft']); + await client.connect( + new StreamableHTTPClientTransport(new URL(resource), { + requestInit: { headers: { Authorization: 'Bearer ' + access } }, + }), + ); + assert.equal((await client.listTools()).tools.length, 39); + async function call(name: string, args: Record = {}) { + const result = await client.callTool({ name, arguments: args }); + assert.ok(!result.isError, 'Tool failed: ' + name); + return (result.structuredContent as { data: any }).data; + } + const info = await call('connection_info'); + assert.deepEqual(info.scopes, ['read', 'draft']); + assert.equal(info.permission, 'draft'); + assert.equal(info.readHidden, false); + assert.equal(info.writeHidden, false); + const state = (await call('state_get')).state; + const operation = await call('position_create', { + kind: 'account', + side: 'asset', + name: 'Must remain draft', + category: 'cash', + currency: 'CNY', + amount: '25.50', + date: today(), + notes: '', + expectedState: state, + idempotencyKey: randomUUID(), + }); + assert.equal(operation.status, 'pending'); + assert.equal( + await db.position.count({ where: { userId } }), + 0, + 'Draft unexpectedly posted financial data', + ); + console.log( + JSON.stringify({ + requestedScopes: requested, + grantedScopes: info.scopes, + readHidden: info.readHidden, + writeHidden: info.writeHidden, + ordinaryWrite: operation.status, + tools: 39, + }), + ); + } finally { + cli?.kill(); + await client.close().catch(() => {}); + if (userId) await db.user.deleteMany({ where: { id: userId } }); + if (clientId) { + await db.agentAuthorization.deleteMany({ where: { clientId } }); + await db.agentClient.deleteMany({ where: { id: clientId } }); + } + await db.$disconnect(); + assert.ok( + resolve(home).startsWith(resolve(tmpdir()) + sep) && + basename(home).startsWith('worthpath-codex-scope-'), + ); + await rm(home, { recursive: true, force: true }); + } + }, + ); diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx index cab85c3..5e466ee 100644 --- a/apps/web/src/AgentConnections.tsx +++ b/apps/web/src/AgentConnections.tsx @@ -119,7 +119,10 @@ function codexSetupPrompt(url: string, level: string) { '执行 codex mcp login worthpath --scopes ' + selected + ' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。', - '授权后检查连接配置与真实工具可用性。能调用时先 tools/list,再 connection_info 核对权限;若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。', + '实测 Codex CLI 0.160.0 显式执行 --scopes read,draft 时只请求这两项;默认登录会使用 scopes_supported,授权链接列出 read,draft,write,hidden_read,hidden_write。如果实际请求范围较宽,这是客户端请求的候选范围,不是最终授权。WorthPath 网页支持收窄:选择本次指定的权限等级,两个隐藏账户选项保持关闭,页面会明确显示最终授予权限;服务端仅按网页选择发行令牌。不要仅因请求包含全部候选权限就终止登录或让我自行检查页面。你可以打开正确 WorthPath 资源的授权页面供我审阅,最终选择和确认仍由我完成。', + '授权后检查连接配置与真实工具可用性。能调用时先 tools/list,再 connection_info;最终 scopes 必须仅为本次指定的 ' + + selected + + ',且 readHidden/writeHidden 均为 false。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。', '配置期间不要创建、修改或删除我的账目。后续资产查询、转账和还款优先使用 WorthPath MCP;工具或权限不可用时如实说明。', ...instructions(url).split('\n\n').slice(3), ].join('\n\n'); @@ -657,7 +660,10 @@ export function AgentConnections() {

审核 OAuth 连接

{consent.name}

-

权限:{consent.scopes.join(', ')}

+

客户端请求的候选权限:{consent.scopes.join(', ')}

+

+ 候选范围不代表已授权。最终仅授予下方选择的权限,隐藏账户权限默认关闭。 +

资源:{consent.resource}

回调地址:{consent.redirectUri}

只批准你正在连接的客户端;请核对回调地址。

@@ -678,7 +684,7 @@ export function AgentConnections() { {consent.scopes.includes('hidden_read') && ( -