diff --git a/packages/ai-engine/src/index.ts b/packages/ai-engine/src/index.ts index 764e64b..ee6223d 100644 --- a/packages/ai-engine/src/index.ts +++ b/packages/ai-engine/src/index.ts @@ -8,6 +8,16 @@ export const PACKAGE_NAME = '@tally/ai-engine'; export type { ChatRunnerDeps } from './chat-runner'; export { ChatRunner } from './chat-runner'; export { loadConfig } from './config'; +export { + __resetAllFlowsForTest, + awaitOAuthFlowSettled, + clearOAuthFlow, + getOAuthFlowStatus, + type OAuthFlowStatus, + type StartOAuthFlowInput, + type StartOAuthFlowResult, + startOAuthFlow, +} from './oauth'; export { startServer } from './server'; export type { AgentEvent, ChatEvent } from './stream'; diff --git a/packages/ai-engine/src/oauth/oauth-flow-orchestrator.test.ts b/packages/ai-engine/src/oauth/oauth-flow-orchestrator.test.ts index 24cdf31..2bc7737 100644 --- a/packages/ai-engine/src/oauth/oauth-flow-orchestrator.test.ts +++ b/packages/ai-engine/src/oauth/oauth-flow-orchestrator.test.ts @@ -17,6 +17,10 @@ function makeProjectDir(): string { return mkdtempSync(path.join(tmpdir(), 'tally-oauth-orch-')); } +// codex Major 対応: orchestrator の flow key は projectId + mcpServerId の composite +// key になった。テストはほぼ単一 project で書くので定数で揃える。 +const TEST_PID = 'p1'; + describe('startOAuthFlow / getOAuthFlowStatus', () => { beforeEach(async () => { await __resetAllFlowsForTest(); @@ -31,6 +35,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { try { // fetch を呼ぶのは callback 受領後 (token 交換) なので start 単独では呼ばれない。 const { authorizationUrl } = await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -40,7 +45,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { expect(authorizationUrl).toContain('client_id=cid'); expect(authorizationUrl).toContain('code_challenge_method=S256'); - const status = getOAuthFlowStatus('atlassian'); + const status = getOAuthFlowStatus(TEST_PID, 'atlassian'); expect(status?.status).toBe('pending'); } finally { rmSync(projectDir, { recursive: true, force: true }); @@ -67,6 +72,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { vi.stubGlobal('fetch', fetchMock); const { authorizationUrl } = await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -108,9 +114,9 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { expect(cbRes.status).toBe(200); // bg promise の settle を待つ - await awaitOAuthFlowSettled('atlassian'); + await awaitOAuthFlowSettled(TEST_PID, 'atlassian'); - const status = getOAuthFlowStatus('atlassian'); + const status = getOAuthFlowStatus(TEST_PID, 'atlassian'); expect(status?.status).toBe('completed'); // token store に書かれていることを確認 @@ -134,6 +140,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const { authorizationUrl } = await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -144,9 +151,9 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { // 不正な state で callback を叩く await fetch(`${redirectUri}?code=AAA&state=wrong-state`); - await awaitOAuthFlowSettled('atlassian'); + await awaitOAuthFlowSettled(TEST_PID, 'atlassian'); - const status = getOAuthFlowStatus('atlassian'); + const status = getOAuthFlowStatus(TEST_PID, 'atlassian'); expect(status?.status).toBe('failed'); if (status?.status === 'failed') { // ユーザー向けの failureMessage は固定 (raw 例外メッセージは漏らさない) @@ -170,6 +177,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { const projectDir = makeProjectDir(); try { await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -177,6 +185,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { }); await expect( startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -195,12 +204,14 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { // 両方が `existing?.status === 'pending'` チェックを通過してフローが二重に走る。 const results = await Promise.allSettled([ startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', projectDir, }), startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -220,6 +231,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { const projectDir = makeProjectDir(); try { await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -228,10 +240,11 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { // 直接 clearOAuthFlow → bg IIFE が awaitCallback を reject されて catch に行く // が、entry は既に消えているので状態遷移は起きない (warn が出る)。 const { clearOAuthFlow } = await import('./oauth-flow-orchestrator'); - clearOAuthFlow('atlassian'); + clearOAuthFlow(TEST_PID, 'atlassian'); // bg promise が settle するまで待つ helper はもう entry が無いので no-op。 // ここでは「再 start が即可能」であることを確認する。 const { authorizationUrl } = await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -244,7 +257,47 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { }); it('未開始の mcpServerId は getOAuthFlowStatus が null を返す', () => { - expect(getOAuthFlowStatus('never-started')).toBeNull(); + expect(getOAuthFlowStatus(TEST_PID, 'never-started')).toBeNull(); + }); + + it('別 project の同名 mcpServerId は flow が独立 (codex Major 対応)', async () => { + // codex 指摘: 旧実装は flow key が mcpServerId のみだったため、project A と + // project B が両方 'atlassian' を持つと A の flow を B が観測 / clear できた。 + // 修正後は composite key (projectId + mcpServerId) なので、片方を start しても + // もう片方には漏れない。 + const dirA = makeProjectDir(); + const dirB = makeProjectDir(); + try { + await startOAuthFlow({ + projectId: 'pA', + mcpServerId: 'atlassian', + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + projectDir: dirA, + }); + // pA で pending 中、pB はまだ未開始 (= null) + expect(getOAuthFlowStatus('pA', 'atlassian')?.status).toBe('pending'); + expect(getOAuthFlowStatus('pB', 'atlassian')).toBeNull(); + + // pB の clear は pA の flow に影響しない + const { clearOAuthFlow } = await import('./oauth-flow-orchestrator'); + clearOAuthFlow('pB', 'atlassian'); + expect(getOAuthFlowStatus('pA', 'atlassian')?.status).toBe('pending'); + + // pB の独立 start も成功する (pA の "already in progress" が漏れない) + await startOAuthFlow({ + projectId: 'pB', + mcpServerId: 'atlassian', + provider: ATLASSIAN_CLOUD_OAUTH, + clientId: 'cid', + projectDir: dirB, + }); + expect(getOAuthFlowStatus('pA', 'atlassian')?.status).toBe('pending'); + expect(getOAuthFlowStatus('pB', 'atlassian')?.status).toBe('pending'); + } finally { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } }); it('store.write 直前に preempt されたら旧 run はトークンを書き込まない (codex Major 対応)', async () => { @@ -275,14 +328,16 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { try { const { clearOAuthFlow } = await import('./oauth-flow-orchestrator'); const { authorizationUrl } = await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', projectDir, }); // 旧 run を clear してすぐ新 run を始める (旧 bg はまだ awaitCallback 中) - clearOAuthFlow('atlassian'); + clearOAuthFlow(TEST_PID, 'atlassian'); await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -296,7 +351,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { expect(tokenEndpointHits).toBe(0); // 新 run は依然 pending、旧 run のトークンが書かれていないこと - const status = getOAuthFlowStatus('atlassian'); + const status = getOAuthFlowStatus(TEST_PID, 'atlassian'); expect(status?.status).toBe('pending'); const store = new FileSystemOAuthStore(projectDir); expect(await store.read('atlassian')).toBeNull(); @@ -317,17 +372,19 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { const { clearOAuthFlow } = await import('./oauth-flow-orchestrator'); await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', projectDir, }); // 旧 run を clear → bg はまだ awaitCallback に居るが close() で reject される - clearOAuthFlow('atlassian'); + clearOAuthFlow(TEST_PID, 'atlassian'); // 旧 bg の catch ブランチが flows.get する前に新 run を開始したい。 // clearOAuthFlow は同期で flows.delete + bg の close() を非同期 fire-and-forget // するので、この時点で flows は空。新 run を始める。 await startOAuthFlow({ + projectId: TEST_PID, mcpServerId: 'atlassian', provider: ATLASSIAN_CLOUD_OAUTH, clientId: 'cid', @@ -336,7 +393,7 @@ describe('startOAuthFlow / getOAuthFlowStatus', () => { // 旧 bg の catch が走り終えるまで microtask を回す。 await new Promise((r) => setTimeout(r, 20)); // 新 run は依然として pending (旧 bg に踏まれていない) - const status = getOAuthFlowStatus('atlassian'); + const status = getOAuthFlowStatus(TEST_PID, 'atlassian'); expect(status?.status).toBe('pending'); // 旧 bg の preempted ログが出ている (failure / completion 両方ありうるが、 // close() が awaitCallback を reject するので failure 経由)。 diff --git a/packages/ai-engine/src/oauth/oauth-flow-orchestrator.ts b/packages/ai-engine/src/oauth/oauth-flow-orchestrator.ts index 3a9353d..f4f5e36 100644 --- a/packages/ai-engine/src/oauth/oauth-flow-orchestrator.ts +++ b/packages/ai-engine/src/oauth/oauth-flow-orchestrator.ts @@ -64,9 +64,22 @@ type FlowEntry = OAuthFlowStatus & { }; // プロセスローカルの flow 状態。Next の Route Handler が共有する。 +// key: makeFlowKey(projectId, mcpServerId) で生成する composite key。 +// projectId のみ / mcpServerId のみだと、複数プロジェクトが同名 mcpServerId を持つ +// ケース (例: project A と project B が両方 'atlassian' を使う) で flow がクロス汚染される +// (codex Major 対応)。 const flows = new Map(); +// project と mcpServerId から flows Map のキーを生成する。両者は core schema の +// id 制約 (`[a-z][a-z0-9-]{0,31}`) で区切り文字 ':' を含まないため、衝突しない。 +function makeFlowKey(projectId: string, mcpServerId: string): string { + return `${projectId}:${mcpServerId}`; +} + export interface StartOAuthFlowInput { + // codex Major 対応: 同名 mcpServerId を持つプロジェクト間の取り違えを防ぐため、 + // flow key には projectId を含める。Route Handler が path param から渡す。 + projectId: string; mcpServerId: string; provider: OAuthProviderConfig; clientId: string; @@ -86,19 +99,20 @@ export interface StartOAuthFlowResult { // 戻り値の `authorizationUrl` を UI 側がブラウザで開く。`getOAuthFlowStatus` で // 完了を polling する。 export async function startOAuthFlow(input: StartOAuthFlowInput): Promise { + const flowKey = makeFlowKey(input.projectId, input.mcpServerId); // CR HIGH 対応: スロット予約を await より前に同期で確保する。これをしないと // `await startLoopbackCallbackServer()` 中に並走 start が来た場合、両方が // `existing?.status === 'pending'` を通過してフローが二重に走る。 // sentinel として一旦 authorizationUrl='' で予約し、本物が決まったら上書きする。 - const existing = flows.get(input.mcpServerId); + const existing = flows.get(flowKey); if (existing?.status === 'pending') { - throw new Error(`OAuth flow already in progress for "${input.mcpServerId}"`); + throw new Error(`OAuth flow already in progress for "${flowKey}"`); } // CR Major 対応: この呼び出し固有の runId を発行する。bg IIFE は状態遷移する前に // 自分が flows に登録された当時の runId を保持しているか確認する。clearOAuthFlow // → 別 start で entry が置き換わったケースでは、古い run の bg は何もしない。 const runId = randomUUID(); - flows.set(input.mcpServerId, { + flows.set(flowKey, { status: 'pending', authorizationUrl: '', promise: Promise.resolve(), @@ -112,21 +126,21 @@ export async function startOAuthFlow(input: StartOAuthFlowInput): Promise { console.warn(`[oauth-flow] callback server close failed (abort path): ${String(closeErr)}`); }); - throw new Error(`OAuth flow was preempted for "${input.mcpServerId}"`); + throw new Error(`OAuth flow was preempted for "${flowKey}"`); } const scopes = input.scopes ?? input.provider.defaultScopes; @@ -180,17 +194,17 @@ export async function startOAuthFlow(input: StartOAuthFlowInput): Promise { @@ -236,15 +250,15 @@ export async function startOAuthFlow(input: StartOAuthFlowInput): Promise {}); - throw new Error(`OAuth flow was preempted for "${input.mcpServerId}"`); + throw new Error(`OAuth flow was preempted for "${flowKey}"`); } - flows.set(input.mcpServerId, { + flows.set(flowKey, { status: 'pending', authorizationUrl, promise, @@ -256,8 +270,8 @@ export async function startOAuthFlow(input: StartOAuthFlowInput): Promise { - const f = flows.get(mcpServerId); +export async function awaitOAuthFlowSettled(projectId: string, mcpServerId: string): Promise { + const f = flows.get(makeFlowKey(projectId, mcpServerId)); if (!f) return; await f.promise; } @@ -283,14 +297,15 @@ export async function awaitOAuthFlowSettled(mcpServerId: string): Promise // callbackHandle.close() を呼んで bg IIFE を中断する。close 後に awaitCallback が // reject され IIFE は catch ブランチに行くが、その時点で flows entry は無いので // console.warn が出るのは想定動作。 -export function clearOAuthFlow(mcpServerId: string): void { - const f = flows.get(mcpServerId); +export function clearOAuthFlow(projectId: string, mcpServerId: string): void { + const flowKey = makeFlowKey(projectId, mcpServerId); + const f = flows.get(flowKey); if (f?.status === 'pending' && f.callbackHandle) { f.callbackHandle.close().catch(() => { /* swallow: close 失敗は cleanup の妨げにしない */ }); } - flows.delete(mcpServerId); + flows.delete(flowKey); } /** diff --git a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts new file mode 100644 index 0000000..dd7b64f --- /dev/null +++ b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts @@ -0,0 +1,164 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { __resetAllFlowsForTest, getOAuthFlowStatus } from '@tally/ai-engine'; +import { FileSystemProjectStore, initProject } from '@tally/storage'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DELETE, GET, POST } from './route'; + +let home: string; +let ws: string; +let projectId: string; +let projectDir: string; +const prevHome = process.env.TALLY_HOME; + +beforeEach(async () => { + await __resetAllFlowsForTest(); + home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); + ws = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); + process.env.TALLY_HOME = home; + projectDir = path.join(ws, 'p'); + const res = await initProject({ projectDir, name: 'P', codebases: [] }); + projectId = res.id; +}); + +afterEach(async () => { + await __resetAllFlowsForTest(); + vi.unstubAllGlobals(); + if (prevHome === undefined) delete process.env.TALLY_HOME; + else process.env.TALLY_HOME = prevHome; + await fs.rm(home, { recursive: true, force: true }); + await fs.rm(ws, { recursive: true, force: true }); +}); + +// project meta に Atlassian の mcpServer (oauth 設定付き) を 1 件追加する helper。 +async function addAtlassianServer(opts: { withOAuth: boolean }): Promise { + const store = new FileSystemProjectStore(projectDir); + const meta = await store.getProjectMeta(); + if (!meta) throw new Error('meta missing'); + await store.saveProjectMeta({ + ...meta, + mcpServers: [ + { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian', + url: 'https://api.atlassian.com/mcp', + ...(opts.withOAuth ? { oauth: { clientId: 'cid-xyz' } } : {}), + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + updatedAt: new Date().toISOString(), + }); +} + +describe('POST /api/projects/:id/mcp/:mcpServerId/oauth', () => { + it('start で authorizationUrl を返し、orchestrator が pending になる', async () => { + await addAtlassianServer({ withOAuth: true }); + const res = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { authorizationUrl: string }; + expect(body.authorizationUrl).toMatch(/^https:\/\/auth\.atlassian\.com\/authorize\?/); + expect(body.authorizationUrl).toContain('client_id=cid-xyz'); + // orchestrator state も pending + expect(getOAuthFlowStatus(projectId, 'atlassian')?.status).toBe('pending'); + }); + + it('未知 project id は 404', async () => { + const res = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: 'nope', mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(404); + }); + + it('未知 mcpServerId は 404', async () => { + await addAtlassianServer({ withOAuth: true }); + const res = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'no-such' }), + }); + expect(res.status).toBe(404); + }); + + it('oauth 未設定 mcpServer は 400 (clientId が無いと start できない)', async () => { + await addAtlassianServer({ withOAuth: false }); + const res = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/no oauth config/); + }); + + it('既に pending の状態で再 start すると 409 Conflict (UI 漏洩しない固定文言)', async () => { + await addAtlassianServer({ withOAuth: true }); + // 1 回目 start + const r1 = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(r1.status).toBe(200); + // 2 回目 start (pending 中) + const r2 = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(r2.status).toBe(409); + const body = (await r2.json()) as { error: string }; + // 内部の `OAuth flow already in progress for "atlassian"` を直接出さず固定文言で返す + expect(body.error).toBe('oauth flow already in progress'); + }); +}); + +describe('GET /api/projects/:id/mcp/:mcpServerId/oauth', () => { + it('未開始は 404', async () => { + const res = await GET(new Request('http://localhost'), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(404); + }); + + it('start 後は pending 状態を返す', async () => { + await addAtlassianServer({ withOAuth: true }); + await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + const res = await GET(new Request('http://localhost'), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; authorizationUrl: string }; + expect(body.status).toBe('pending'); + expect(body.authorizationUrl).toMatch(/^https:\/\/auth\.atlassian\.com\//); + }); +}); + +describe('DELETE /api/projects/:id/mcp/:mcpServerId/oauth', () => { + it('進行中フローを clear し、再 start が可能になる', async () => { + await addAtlassianServer({ withOAuth: true }); + await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(getOAuthFlowStatus(projectId, 'atlassian')?.status).toBe('pending'); + + const del = await DELETE(new Request('http://localhost', { method: 'DELETE' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(del.status).toBe(200); + expect(getOAuthFlowStatus(projectId, 'atlassian')).toBeNull(); + + // clear 後の再 start は 409 にならず正常 + const r = await POST(new Request('http://localhost', { method: 'POST' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(r.status).toBe(200); + }); + + it('未開始でも 200 を返す (idempotent)', async () => { + const res = await DELETE(new Request('http://localhost', { method: 'DELETE' }), { + params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), + }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts new file mode 100644 index 0000000..f02e7d7 --- /dev/null +++ b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts @@ -0,0 +1,126 @@ +// ADR-0011 PR-E3b: 外部 MCP サーバの OAuth 2.1 フローを Tally から開始するための +// Route Handler。OAuthFlowOrchestrator (PR-E3a) の薄いラッパー。 +// +// メソッド対応: +// - POST ... /oauth → flow を start (authorizationUrl を返す) +// - GET ... /oauth → 現在の flow status (未開始なら 404) +// - DELETE ... /oauth → 進行中 flow を中止 (= UI の「やり直し」) +// +// project は path param `id`、mcp server は `mcpServerId` で identify する。 +// orchestrator の singleton state は Next の dev/prod の同一プロセスで共有される。 +// +// 戻り値の failureMessage は orchestrator 側で固定文字列に正規化済み。 + +import { clearOAuthFlow, getOAuthFlowStatus, startOAuthFlow } from '@tally/ai-engine'; +import { OAUTH_REGISTRY, type OAuthKind } from '@tally/core'; +import { FileSystemProjectStore, listProjects } from '@tally/storage'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +interface RouteContext { + params: Promise<{ id: string; mcpServerId: string }>; +} + +interface ResolvedTarget { + projectDir: string; + clientId: string; + scopes: readonly string[] | undefined; + kind: OAuthKind; +} + +// project + mcpServer を解決し、OAuth に必要な値を取り出す。 +// project が無い / mcpServer が無い / oauth 未設定 / kind が registry に無い、の各ケースで +// 個別に 404/400 を返す (UI に「何が原因か」が分かるよう error code を変えてある)。 +async function resolveTarget( + projectId: string, + mcpServerId: string, +): Promise<{ ok: true; target: ResolvedTarget } | { ok: false; status: number; error: string }> { + const list = await listProjects(); + const projectDir = list.find((p) => p.id === projectId)?.path; + if (!projectDir) { + return { ok: false, status: 404, error: `project not found: ${projectId}` }; + } + const store = new FileSystemProjectStore(projectDir); + const meta = await store.getProjectMeta(); + if (!meta) { + return { ok: false, status: 404, error: `project meta missing: ${projectId}` }; + } + const server = meta.mcpServers.find((s) => s.id === mcpServerId); + if (!server) { + return { + ok: false, + status: 404, + error: `mcp server not found: ${mcpServerId}`, + }; + } + if (!server.oauth) { + return { + ok: false, + status: 400, + error: `mcp server "${mcpServerId}" has no oauth config (set oauth.clientId in project.yaml)`, + }; + } + // kind は schema 上 'atlassian' literal だが registry lookup は将来の kind 追加に備えて + // 共通の経路を残す。registry に無ければ 400。 + const kind = server.kind as OAuthKind; + if (!(kind in OAUTH_REGISTRY)) { + return { + ok: false, + status: 400, + error: `unsupported oauth kind: ${kind}`, + }; + } + return { + ok: true, + target: { + projectDir, + clientId: server.oauth.clientId, + scopes: server.oauth.scopes, + kind, + }, + }; +} + +export async function POST(_req: Request, ctx: RouteContext): Promise { + const { id, mcpServerId } = await ctx.params; + const r = await resolveTarget(id, mcpServerId); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + try { + const provider = OAUTH_REGISTRY[r.target.kind]; + const result = await startOAuthFlow({ + projectId: id, + mcpServerId, + provider, + clientId: r.target.clientId, + ...(r.target.scopes !== undefined ? { scopes: r.target.scopes } : {}), + projectDir: r.target.projectDir, + }); + return NextResponse.json({ authorizationUrl: result.authorizationUrl }); + } catch (err) { + // already in progress や preempted 系は 409 Conflict、そのほかは 500 にする。 + // err.message を UI に直接出すと内部詳細が漏れるので、メッセージは server log に残し、 + // UI には固定文言を返す。 + const message = err instanceof Error ? err.message : String(err); + console.warn(`[api/oauth] start failed (id=${id}, mcp=${mcpServerId}): ${message}`); + if (/already in progress|preempted/.test(message)) { + return NextResponse.json({ error: 'oauth flow already in progress' }, { status: 409 }); + } + return NextResponse.json({ error: 'failed to start oauth flow' }, { status: 500 }); + } +} + +export async function GET(_req: Request, ctx: RouteContext): Promise { + const { id, mcpServerId } = await ctx.params; + // status は project lookup を行わない: 未開始の状態を返したいだけなので fast path。 + // composite key (projectId + mcpServerId) でクロスプロジェクト汚染を防ぐ。 + const status = getOAuthFlowStatus(id, mcpServerId); + if (!status) return NextResponse.json({ error: 'oauth flow not started' }, { status: 404 }); + return NextResponse.json(status); +} + +export async function DELETE(_req: Request, ctx: RouteContext): Promise { + const { id, mcpServerId } = await ctx.params; + clearOAuthFlow(id, mcpServerId); + return NextResponse.json({ ok: true }); +} diff --git a/packages/frontend/src/components/chat/auth-request-card.test.tsx b/packages/frontend/src/components/chat/auth-request-card.test.tsx index 9098ff5..68cf5dd 100644 --- a/packages/frontend/src/components/chat/auth-request-card.test.tsx +++ b/packages/frontend/src/components/chat/auth-request-card.test.tsx @@ -1,118 +1,152 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useCanvasStore } from '@/lib/store'; import { AuthRequestCard } from './auth-request-card'; -describe('AuthRequestCard', () => { +// ADR-0011 PR-E3b: AuthRequestCard は Tally Route Handler 駆動になり、UI は内部 +// cardState でドライブされる。block.status / block.authUrl は使わない (PR-E4 まで残る +// 過渡期の field なので prop として渡るが見ない)。block.mcpServerId / mcpServerLabel +// だけが意味を持つ。 + +const pendingBlock = { + type: 'auth_request' as const, + mcpServerId: 'atlassian', + mcpServerLabel: 'My Atlassian', + // 旧 SDK 由来 URL。新カードは見ないが prop の shape を満たすため渡す。 + authUrl: 'https://legacy/sdk-loopback', + status: 'pending' as const, +}; + +const PROJECT_ID = 'proj-1'; +const EXPECTED_BASE_URL = `/api/projects/${PROJECT_ID}/mcp/atlassian/oauth`; + +describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { beforeEach(() => { useCanvasStore.getState().reset(); + useCanvasStore.setState({ projectId: PROJECT_ID } as never); }); - // window.open / sendChatMessage の spy がテスト中の throw でリストアされず - // 後続テストに漏れるのを防ぐ (vi.restoreAllMocks は spyOn で作った spy のみ復元する)。 afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - const pendingBlock = { - type: 'auth_request' as const, - mcpServerId: 'atlassian', - mcpServerLabel: 'My Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', - status: 'pending' as const, - }; - - it('pending: ラベル / 認証ボタン / paste 入力欄が表示される', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); + it('idle: 認証ボタンを表示し、paste 入力欄は出ない', () => { render(); expect(screen.getByText(/My Atlassian 認証/)).toBeInTheDocument(); - expect(screen.getByText(/未認証/)).toBeInTheDocument(); + expect(screen.getByText('未認証')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: /callback URL/ })).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /callback URL/ })).toBeNull(); }); - it('「認証」ボタンクリックで authUrl を新規タブで開く (window.open)', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); + it('認証ボタン → POST /oauth → window.open + 承認待ち状態に遷移', async () => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === EXPECTED_BASE_URL && init?.method === 'POST') { + return new Response( + JSON.stringify({ authorizationUrl: 'https://auth.atlassian.com/authorize?x=1' }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + render(); fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); - expect(openSpy).toHaveBeenCalledWith(pendingBlock.authUrl, '_blank', 'noopener,noreferrer'); - }); - it('callback URL 入力 → 認証完了で sendOAuthCallback に mcpServerId と URL を構造化送信', async () => { - const send = vi.fn(); - useCanvasStore.setState({ sendOAuthCallback: send } as never); - render(); - const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; - fireEvent.change(input, { - target: { value: 'http://localhost:54801/callback?code=AAA&state=xyz' }, - }); - fireEvent.click(screen.getByRole('button', { name: /^認証完了$/ })); - await screen.findByDisplayValue(''); - expect(send).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenCalledWith( - 'atlassian', - 'http://localhost:54801/callback?code=AAA&state=xyz', + await waitFor(() => + expect(openSpy).toHaveBeenCalledWith( + 'https://auth.atlassian.com/authorize?x=1', + '_blank', + 'noopener,noreferrer', + ), ); + expect(screen.getByText('承認待ち')).toBeInTheDocument(); + // paste UI は完全に消えている + expect(screen.queryByRole('textbox', { name: /callback URL/ })).toBeNull(); }); - it('callback URL の形式が不正なら認証完了ボタンが disabled (送信されない)', () => { - const send = vi.fn(); - useCanvasStore.setState({ sendOAuthCallback: send } as never); + it('POST が 409 を返したら failed 状態 + サーバ返却 error 文言を表示 (固定文言)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ error: 'oauth flow already in progress' }), { + status: 409, + }), + ), + ); render(); - const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; - fireEvent.change(input, { target: { value: 'not a url' } }); - const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; - expect(btn.disabled).toBe(true); - fireEvent.click(btn); - expect(send).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); + await waitFor(() => expect(screen.getByText('失敗')).toBeInTheDocument()); + expect(screen.getByText(/oauth flow already in progress/)).toBeInTheDocument(); + // やり直すボタン + expect(screen.getByRole('button', { name: 'やり直す' })).toBeInTheDocument(); }); - it('host が localhost / 127.0.0.1 でない URL は reject (paste 偽造の防御)', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); - render(); - const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; - fireEvent.change(input, { - target: { value: 'http://evil.example.com/callback?code=AAA&state=xyz' }, + it('やり直す → DELETE /oauth → idle に戻り再度認証ボタンが押せる', async () => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === EXPECTED_BASE_URL && init?.method === 'POST') { + return new Response(JSON.stringify({ error: 'boom' }), { status: 500 }); + } + if (url === EXPECTED_BASE_URL && init?.method === 'DELETE') { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + throw new Error(`unexpected: ${url}`); }); - const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; - expect(btn.disabled).toBe(true); - }); + vi.stubGlobal('fetch', fetchMock); - it('credential 付き URL (user:pass@) は reject', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); render(); - const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; - fireEvent.change(input, { - target: { value: 'http://user:pass@localhost:54801/callback?code=AAA&state=xyz' }, - }); - const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; - expect(btn.disabled).toBe(true); + fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); + await waitFor(() => expect(screen.getByText('失敗')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'やり直す' })); + await waitFor(() => + expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(), + ); + expect(fetchMock).toHaveBeenCalledWith(EXPECTED_BASE_URL, { method: 'DELETE' }); }); - it('completed: 完了メッセージを表示し paste 欄は出ない', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); - render(); - expect(screen.getByText(/認証済/)).toBeInTheDocument(); - expect(screen.getByText(/認証完了/)).toBeInTheDocument(); - expect(screen.queryByRole('textbox', { name: /callback URL/ })).toBeNull(); - expect(screen.queryByRole('button', { name: /My Atlassian で認証/ })).toBeNull(); + it('マウント時に GET /oauth で状態 rehydrate (codex Major 対応): completed なら 認証済 表示', async () => { + // codex 指摘: チャット再表示で AuthRequestCard がリマウントされた時、orchestrator + // 側に completed / pending が残っていても card は常に idle で起動するため、ユーザーが + // 「認証」を押すと 409 で詰まる。マウント時に GET で状態を取得して rehydrate する。 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === EXPECTED_BASE_URL && (!init?.method || init.method === 'GET')) { + return new Response( + JSON.stringify({ status: 'completed', authorizationUrl: 'https://x' }), + { status: 200 }, + ); + } + throw new Error(`unexpected: ${init?.method ?? 'GET'} ${url}`); + }), + ); + render(); + await waitFor(() => expect(screen.getByText('認証済')).toBeInTheDocument()); }); - it('failed: 失敗メッセージと failureMessage 内容を表示', () => { - useCanvasStore.setState({ sendOAuthCallback: vi.fn() } as never); - render( - , + it('マウント時 GET が 404 なら idle のまま (orchestrator 未開始)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'not started' }), { status: 404 })), ); - expect(screen.getAllByText(/失敗/).length).toBeGreaterThan(0); - expect(screen.getByText(/invalid_grant: state mismatch/)).toBeInTheDocument(); + render(); + // 認証ボタンが残っている (idle 状態) + expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); + }); + + it('projectId 未設定なら認証ボタンが disabled (誤発火を防ぐ)', () => { + useCanvasStore.setState({ projectId: null } as never); + render(); + const btn = screen.getByRole('button', { name: /My Atlassian で認証/ }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + expect(screen.getByText(/プロジェクトが開かれていません/)).toBeInTheDocument(); }); }); diff --git a/packages/frontend/src/components/chat/auth-request-card.tsx b/packages/frontend/src/components/chat/auth-request-card.tsx index 845fbb5..5c10ec2 100644 --- a/packages/frontend/src/components/chat/auth-request-card.tsx +++ b/packages/frontend/src/components/chat/auth-request-card.tsx @@ -1,111 +1,225 @@ 'use client'; import type { ChatBlock } from '@tally/core'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useCanvasStore } from '@/lib/store'; type AuthRequestBlock = Extract; -// callback URL が「http://localhost:XXXXX/callback?code=...&state=...」形式かを軽く検査。 -// SDK が立てた一時 callback 鯖は agent turn 終了で死ぬので、ユーザーがアドレスバーから -// コピーして貼ることを想定。host は loopback (localhost / 127.0.0.1 / ::1) のみ通す。 -// schema.ts (McpServerConfigSchema.url) と loopback 判定を揃えており、IPv6 優先環境で -// SDK が `http://[::1]:XXXXX/callback?...` を返した場合にも認証フローが進むようにする。 -function isLikelyCallbackUrl(s: string): boolean { - try { - const u = new URL(s.trim()); - if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; - // URL 内資格情報 (user:pass@host) は誤って貼り付けると chat 履歴に永続化される - // (sendChatMessage 経由で残る) ため、ここで弾く。schema.ts の url validator と整合。 - if (u.username || u.password) return false; - const host = u.hostname; - if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]') { - return false; - } - return u.searchParams.has('code') && u.searchParams.has('state'); - } catch { - return false; - } +// ADR-0011 PR-E3b: 外部 MCP の OAuth 2.1 認証要求カード。 +// 旧実装は SDK の loopback URL (block.authUrl) を開いて、ユーザーが callback URL を +// アドレスバーから paste する 2 ステップ UX だった。新実装では Tally プロセス内の +// OAuthFlowOrchestrator が loopback callback を直接受けるため、ユーザー操作は +// 「認証ボタンを押す → 別タブで承認 → 自動で完了」の 1 ステップに簡略化する。 +// +// 状態は orchestrator の Route Handler (POST/GET/DELETE /api/projects//mcp//oauth) +// から polling で取得する。block.status / block.authUrl は使わない (PR-E4 で chat-runner +// 側の auth_request 発行が削除されると block 自体が消えるため、過渡期の表示は API 側に +// 単一ソース化する)。 +// +// 状態遷移: +// idle ボタン未押下。block 来訪直後の初期状態。 +// starting POST 中 (短い)。 +// pending authorize URL を別タブで開いた後、polling 中。 +// completed 成功。Atlassian tools が利用可能。 +// failed 失敗。orchestrator から返ってきた固定 failureMessage を表示。 +// +// API errors は固定文言で UI に出す (orchestrator 側で詳細は server log に分離済み)。 + +const POLL_INTERVAL_MS = 2000; + +interface ApiStatus { + status: 'pending' | 'completed' | 'failed'; + authorizationUrl: string; + failureMessage?: string; } -// 外部 MCP の OAuth 2.1 認証要求ブロック。 -// 「Atlassian で認証」ボタン (新規タブ) と callback URL paste 入力欄を 1 等地でまとめる。 -// 設計意図: SDK が tool 出力した auth URL をプレーンテキスト中に紛れさせると、 -// ・URL がクリックできない / 同タブ遷移で session を壊す -// ・redirect 先 localhost:XXXXX が即死しているのにユーザーが原因を特定できない -// という UX が破綻するため、専用カードで「やるべきこと」を 2 ステップに分けて提示する。 +type CardState = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'pending'; authorizationUrl: string } + | { kind: 'completed' } + | { kind: 'failed'; message: string }; + export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { - const sendOAuthCallback = useCanvasStore((s) => s.sendOAuthCallback); - const [callbackUrl, setCallbackUrl] = useState(''); - const [submitting, setSubmitting] = useState(false); - - const isPending = block.status === 'pending'; - const isCompleted = block.status === 'completed'; - const isFailed = block.status === 'failed'; - - const onAuthClick = () => { - if (!isPending) return; - window.open(block.authUrl, '_blank', 'noopener,noreferrer'); - }; - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = callbackUrl.trim(); - if (!trimmed || !isLikelyCallbackUrl(trimmed)) return; - setSubmitting(true); + const projectId = useCanvasStore((s) => s.projectId); + const [cardState, setCardState] = useState({ kind: 'idle' }); + const pollTimerRef = useRef | null>(null); + + const baseUrl = projectId + ? `/api/projects/${encodeURIComponent(projectId)}/mcp/${encodeURIComponent(block.mcpServerId)}/oauth` + : null; + + // codex Major 対応: マウント時に orchestrator の現状を取りに行き、cardState を + // rehydrate する。チャットスレッドの再表示や router 遷移でカードがリマウントされた際、 + // orchestrator が pending / completed の状態を持っているのに UI が idle に戻ってしまうと、 + // ユーザーが「認証」を再押下 → POST が 409 になり詰まる、という UX バグを防ぐ。 + // baseUrl が無い (projectId 未設定) ケースは何もしない。 + useEffect(() => { + if (!baseUrl) return; + let cancelled = false; + (async () => { + try { + const res = await fetch(baseUrl, { method: 'GET', cache: 'no-store' }); + if (cancelled) return; + if (res.status === 404) { + // orchestrator 未開始。idle のままで良い。 + return; + } + if (!res.ok) { + // 取得失敗だが card はまだ操作可能 (idle のまま)。失敗にはしない。 + return; + } + const body = (await res.json()) as ApiStatus; + if (cancelled) return; + if (body.status === 'pending') { + setCardState({ kind: 'pending', authorizationUrl: body.authorizationUrl }); + } else if (body.status === 'completed') { + setCardState({ kind: 'completed' }); + } else if (body.status === 'failed') { + setCardState({ + kind: 'failed', + message: body.failureMessage ?? 'OAuth flow failed', + }); + } + } catch { + // 初期 hydrate で失敗しても idle のまま (操作可能)。 + } + })(); + return () => { + cancelled = true; + }; + // baseUrl 変化のみで再実行する。block.mcpServerId / projectId が変わったケースを拾う。 + }, [baseUrl]); + + // pending 中は POLL_INTERVAL_MS ごとに status を取りに行く。setInterval ではなく + // setTimeout の self-rescheduling で「fetch 完了を待ってから次の timer を立てる」形にし、 + // 遅い fetch が重なるのを避ける。 + useEffect(() => { + if (cardState.kind !== 'pending' || !baseUrl) return; + let cancelled = false; + const tick = async () => { + try { + const res = await fetch(baseUrl, { method: 'GET', cache: 'no-store' }); + if (cancelled) return; + if (res.status === 404) { + // orchestrator から消えていた (DELETE 等)。idle に戻す。 + setCardState({ kind: 'idle' }); + return; + } + if (!res.ok) { + // 予期しない 5xx。failure として表示するが retry は idle 経由で可能。 + setCardState({ kind: 'failed', message: 'OAuth status の取得に失敗しました' }); + return; + } + const body = (await res.json()) as ApiStatus; + if (cancelled) return; + if (body.status === 'completed') { + setCardState({ kind: 'completed' }); + return; + } + if (body.status === 'failed') { + setCardState({ + kind: 'failed', + message: body.failureMessage ?? 'OAuth flow failed', + }); + return; + } + // まだ pending。次回 tick を予約。 + pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS); + } catch { + if (cancelled) return; + // 一時的なネットワーク失敗は再試行ではなく failure 表示にする (UI を確定させる)。 + setCardState({ kind: 'failed', message: 'OAuth status の取得に失敗しました' }); + } + }; + pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (pollTimerRef.current) clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + }; + }, [cardState.kind, baseUrl]); + + const onAuthClick = useCallback(async () => { + if (!baseUrl) return; + setCardState({ kind: 'starting' }); try { - // 構造化 WS message で送信。自然文 user_message と異なり、サーバ側で - // mcpServerId が確定するので AI が別 server の complete_authentication を - // 呼ぶ事故を排除できる (PR-B CR Major)。 - sendOAuthCallback(block.mcpServerId, trimmed); - setCallbackUrl(''); - } finally { - setSubmitting(false); + const res = await fetch(baseUrl, { method: 'POST' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setCardState({ + kind: 'failed', + message: body.error ?? `OAuth start に失敗しました (HTTP ${res.status})`, + }); + return; + } + const body = (await res.json()) as { authorizationUrl: string }; + // 別タブで認可画面を開く。orchestrator の loopback が自動で callback を受け、 + // polling が completed を検知する。 + window.open(body.authorizationUrl, '_blank', 'noopener,noreferrer'); + setCardState({ kind: 'pending', authorizationUrl: body.authorizationUrl }); + } catch { + setCardState({ kind: 'failed', message: 'OAuth start に失敗しました' }); } - }; + }, [baseUrl]); + + const onRetryClick = useCallback(async () => { + if (!baseUrl) return; + // 既存 pending を一度 clear してから再 start (UI 上の「やり直す」)。 + await fetch(baseUrl, { method: 'DELETE' }).catch(() => undefined); + setCardState({ kind: 'idle' }); + }, [baseUrl]); + + const showStartButton = cardState.kind === 'idle' || cardState.kind === 'starting'; + const showPendingHint = cardState.kind === 'pending'; + const isCompleted = cardState.kind === 'completed'; + const isFailed = cardState.kind === 'failed'; return (
🔐 {block.mcpServerLabel} 認証 - {statusLabel(block.status)} + {statusLabel(cardState)}
- {isPending && ( + {showStartButton && ( <>
下のボタンで {block.mcpServerLabel} の認証ページを別タブで開いて承認してください。
- 承認後にブラウザが「接続できません」を表示しても問題ありません。 -
- アドレスバーの URL (例: http://localhost:XXXXX/callback?code=...) を - コピーして、下の入力欄に貼り付け「認証完了」を押してください。 + 承認が完了すると自動で連携が有効になります (paste 不要)。
- -
- setCallbackUrl(e.target.value)} - placeholder="http://localhost:XXXXX/callback?code=...&state=..." - style={INPUT_STYLE} - disabled={submitting} - aria-label="callback URL" - /> - -
+ {!projectId &&
プロジェクトが開かれていません。
} )} + {showPendingHint && ( +
+ 別タブで承認を進めてください。完了すると自動で反映されます。 +
+ + 認証画面を再度開く + +
+ )} + {isCompleted && (
✅ 認証完了。{block.mcpServerLabel} のツールが利用可能になりました。 @@ -114,29 +228,30 @@ export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { {isFailed && (
- ❌ 認証に失敗しました。 - {block.failureMessage ? ( -
{block.failureMessage}
- ) : null} + ❌ {cardState.message}
- 再度 AI に認証を要求してください (例: 「もう一度認証して」)。 +
)}
); } -function statusLabel(status: AuthRequestBlock['status']): string { - if (status === 'pending') return '未認証'; - if (status === 'completed') return '認証済'; - return '失敗'; +function statusLabel(state: CardState): string { + if (state.kind === 'completed') return '認証済'; + if (state.kind === 'failed') return '失敗'; + if (state.kind === 'pending') return '承認待ち'; + if (state.kind === 'starting') return '開始中'; + return '未認証'; } -function badgeStyle(status: AuthRequestBlock['status']) { - if (status === 'completed') { +function badgeStyle(state: CardState) { + if (state.kind === 'completed') { return { ...BADGE_BASE_STYLE, background: '#23863633', color: '#7ee787' }; } - if (status === 'failed') { + if (state.kind === 'failed') { return { ...BADGE_BASE_STYLE, background: '#f8514933', color: '#ffa198' }; } return { ...BADGE_BASE_STYLE, background: '#bf8700aa', color: '#ffd33d' }; @@ -162,19 +277,9 @@ const HEADER_STYLE = { const LABEL_STYLE = { flex: 1, fontWeight: 600 }; const BADGE_BASE_STYLE = { fontSize: 10, padding: '1px 6px', borderRadius: 4 }; const DESC_STYLE = { fontSize: 11, color: '#c8d1da', lineHeight: 1.5 }; +const WARN_STYLE = { fontSize: 11, color: '#ffa198' }; const COMPLETED_DESC_STYLE = { fontSize: 12, color: '#7ee787' }; const FAILED_DESC_STYLE = { fontSize: 11, color: '#ffa198', lineHeight: 1.5 }; -const FAILURE_PRE_STYLE = { - background: '#0d1117', - border: '1px solid #30363d', - borderRadius: 4, - padding: 6, - fontSize: 10, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', - color: '#ffa198', - marginTop: 4, - whiteSpace: 'pre-wrap' as const, -}; const AUTH_BUTTON_STYLE = { background: '#1f6feb', color: '#fff', @@ -185,23 +290,14 @@ const AUTH_BUTTON_STYLE = { fontWeight: 600, cursor: 'pointer', }; -const FORM_STYLE = { display: 'flex', gap: 6 }; -const INPUT_STYLE = { - flex: 1, - background: '#0d1117', - border: '1px solid #30363d', - borderRadius: 4, - padding: '6px 8px', - fontSize: 11, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', +const RETRY_BUTTON_STYLE = { + background: '#30363d', color: '#e6edf3', -}; -const SUBMIT_BUTTON_STYLE = { - background: '#238636', - color: '#fff', - border: '1px solid #2ea043', - borderRadius: 6, + border: '1px solid #484f58', + borderRadius: 4, padding: '4px 10px', fontSize: 11, cursor: 'pointer', + marginTop: 4, }; +const LINK_STYLE = { color: '#58a6ff', textDecoration: 'underline' };