diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..d2dee19 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"fe80e088-baea-4d44-bb14-9186ef77587d","pid":1937523,"procStart":"120691628","acquiredAt":1777235915595} \ No newline at end of file diff --git a/docs/adr/0011-tally-managed-oauth-flow.md b/docs/adr/0011-tally-managed-oauth-flow.md new file mode 100644 index 0000000..2d0de68 --- /dev/null +++ b/docs/adr/0011-tally-managed-oauth-flow.md @@ -0,0 +1,171 @@ +# ADR-0011: 外部 MCP サーバーの OAuth 2.1 フローを Tally 側で管理する + +- **日付**: 2026-05-02 +- **ステータス**: Proposed + +## コンテキスト + +PR #19 (PR-A) で外部 MCP サーバー (Atlassian 等) を Tally Chat から呼べる土台を作り、PR #21 (PR-B) で OAuth 2.1 / Claude Agent SDK 任せの認証フローに pivot した。callback URL は UI の `AuthRequestCard` で paste させ、`mcp____complete_authentication` を AI に呼ばせる設計。 + +PR #22 (PR-C) で `ChatRunner` を long-lived Query 化したが、CodeRabbit から複数回にわたり以下の指摘を受けた: + +- callback URL の `code` / `state` を `this.input.push` 経由で SDK に渡すと、同 `sdk.query()` の **会話 context に turn 跨ぎで残る** +- `allowedTools` を callback turn だけ単一 (`mcp____complete_authentication`) に絞れず、prompt 指示頼みになる + +issue #28 で SDK API を調査した結果、以下が判明: + +- `Query.setMcpServers` / `toggleMcpServer` / `applyFlagSettings` は動的更新できる (mid-session で MCP server 入れ替え可能) +- しかし **`allowedTools` の動的変更 API は存在しない** +- MCP HTTP transport の OAuth state (PKCE / token) を **subprocess 跨ぎで共有する API も存在しない** +- 一方で SDK は **token を外部から `headers: { Authorization: Bearer ... }` で注入できる** + +つまり、SDK 機能だけで「再認証 avoid」と「context 漏洩防止」を両立する API は無い。 + +## 決定 + +OAuth 2.1 フロー全体を **Tally 側で完結させる** 設計に転換する。SDK は完成済み access token を `headers` で渡されるだけで、`mcp____authenticate` / `complete_authentication` は使わない。 + +これにより: + +- callback URL は Tally プロセス内の loopback callback サーバーで受けて token 交換する → AI 会話 context に **一切渡さない** +- access token は Tally の token store に永続化、SDK の `mcpServers` config の `headers` に inject +- subprocess を再起動しても token store から読めば再注入できる → **再認証不要** +- `allowedTools` の動的変更が不要になる (OAuth フローが SDK に乗らないため) + +## 詳細設計 + +### 1. OAuth client 設定 + +`McpServerConfig` (packages/core/src/schema.ts) を拡張: + +```typescript +interface McpServerConfig { + id: string; + name: string; + kind: 'atlassian'; // 将来 'github' / 'slack' 等に拡張可能 + url: string; + // OAuth client 設定 (kind ごとに endpoint を持つ registry を core 側に置く)。 + // 段階導入のため PR-E1 では optional で追加、PR-E4 で旧 auth_request 経路を + // 削除するのと同時に required 化する。 + oauth?: { + clientId: string; // OAuth client ID (各 server で発行されたもの) + scopes?: string[]; // 任意、kind ごとに default あり + }; + options: McpServerOptions; +} +``` + +`kind: 'atlassian'` の場合の OAuth 2.1 endpoint は core/src/oauth/atlassian.ts 等に hardcode する registry を持つ。MVP は Atlassian のみ。 + +### 2. Token store + +`.tally/oauth/.yaml` に永続化: + +```yaml +mcpServerId: atlassian +acquiredAt: 2026-05-02T10:00:00Z +accessToken: +refreshToken: <...> +expiresAt: 2026-05-02T11:00:00Z +scopes: [read:jira-work, read:jira-user, offline_access] +``` + +注: MVP は Atlassian Cloud の **Jira read 系のみ**を default scopes にする (`read:jira-work` / `read:jira-user` / `offline_access`)。Confluence や write 系を必要とする場合は `McpServerConfig.oauth.scopes` で追加指定する想定。 + +- 暗号化方針は ADR-0012 で別途検討 (MVP は plain で `chmod 600`、後から OS keychain 統合) +- token store は `~/.local/share/tally/projects//oauth/` ではなく **プロジェクトディレクトリ直下**に置く (ADR-0008 の「プロジェクト = 任意のディレクトリ」原則に従う) + +### 3. OAuth flow 実装 + +新パッケージ or `packages/ai-engine/src/oauth/` ディレクトリで以下を実装: + +- `OAuthClient` クラス: PKCE code_verifier / code_challenge 生成、authorization URL 構築、token 交換、refresh +- `LoopbackCallbackServer`: 一時的に `http://localhost:0/callback` を listen (port は OS 採番)、`code` と `state` を受領 +- `OAuthFlowOrchestrator`: UI からの開始要求を受けて authorization URL 生成 → ユーザーに返す → callback で token 交換 → token store に保存 + +依存ライブラリ候補: `oauth4webapi` (OAuth 2.1 + PKCE 公式準拠、軽量)。MVP では小さいので自前実装も検討。 + +### 4. SDK 統合 + +`packages/ai-engine/src/mcp/build-mcp-servers.ts` を拡張: + +```typescript +async function buildMcpServers(opts) { + for (const config of configs) { + const token = await tokenStore.read(config.id); + const headers = token + ? { Authorization: `Bearer ${token.accessToken}` } + : {}; + mcpServers[config.id] = { type: 'http', url: config.url, headers }; + } + // Tally MCP は従来通り + ... +} +``` + +token expiry が近ければ `OAuthClient.refresh` を呼んでから注入する。 + +### 5. UI 統合 + +既存の `AuthRequestCard` を再利用するが、内部実装を変更: + +- 「認証」ボタン → Tally の API (`POST /api/mcp//oauth/start`) を呼んで authorization URL を取得 → 新規タブで開く +- callback URL の paste は **削除** (Tally が直接 loopback で受けるため) +- 認証完了は `chat_auth_request` event ではなく WS の別 event (`mcp_oauth_completed`) で通知 + +### 6. ChatRunner 修正 + +- `runOAuthCallback` メソッドを削除 +- `auth-detector.ts` (mcp__*__authenticate / complete_authentication 検出) を削除 +- `handleAuthToolResult` (auth_request 変換) を削除 +- `stashedAuthUses` (TurnState) を削除 +- `chat_auth_request` event 型を削除 (or 一般的な `mcp_oauth_status` event に置換) + +これにより chat-runner は OAuth を完全に意識しないシンプルな構造に戻る。 + +## 実装段階 (PR 分割案) + +| PR | 範囲 | 規模 | +|---|---|---| +| **PR-E1** | core/schema.ts に oauth 設定追加、token store ファイル形式定義 + storage パッケージ実装 | 小 | +| **PR-E2** | OAuthClient (PKCE / token 交換 / refresh) + LoopbackCallbackServer 実装 | 中 | +| **PR-E3** | API endpoint (`POST /api/mcp//oauth/start` 等) + UI 統合 (`AuthRequestCard` の改修) | 中 | +| **PR-E4** | `buildMcpServers` に token 注入、`ChatRunner` から OAuth 関連削除 (auth-detector / handleAuthToolResult / runOAuthCallback) | 中 | +| **PR-E5** | E2E テスト (real OAuth flow を mock したシナリオ)、ドキュメント整備 | 中 | + +各 PR は独立に merge 可能な設計にする (PR-E4 は PR-E1 〜 E3 がすべて main に入った後)。 + +## 影響 + +### 利点 + +- **CR 指摘の根本解決**: callback URL が AI 会話 context に一切残らない +- **再認証不要**: subprocess 再起動後も token store から読み戻せる +- **`allowedTools` 動的変更不要**: OAuth フローが SDK に乗らないため、`runOAuthCallback` の単一 tool 強制問題が消える +- **chat-runner 簡素化**: OAuth 関連 (auth-detector / handleAuthToolResult 等) を削除 + +### 欠点 + +- **実装規模大**: PR-E1 〜 E5 で数日〜数週間 +- **kind ごとの endpoint registry**: 新しい MCP server kind を追加するたびに OAuth endpoint 設定が必要 (Atlassian 専用から離脱する際のコスト) +- **token store のセキュリティ**: ADR-0012 で別途検討が必要 (MVP は file mode 600) + +### 後方互換性 + +PoC / 個人開発段階のため **後方互換は不要**。具体的には: + +- 既存 `McpServerConfig` YAML に `oauth` フィールドが無いものは PR-E4 で validation エラーで弾く (`oauth` を required 化)。ユーザーは手動で追加する。PR-E1 〜 E3 の間は optional で並走する +- 旧 `auth_request` ブロック / `chat_auth_request` event は schema から完全削除 (PR-E4) +- 既存 chat YAML 内の旧 `auth_request` block は読み込み時 validation エラーで弾く想定。必要なら該当 chat thread を手動削除して clean state から開始する +- migration script や互換 layer は実装しない + +## 関連 + +- issue #28: PR-C 後続: OAuth callback の ephemeral 経路復元の再検討 +- PR #22 (merged): https://github.com/ignission/tally/pull/22 +- PR #21 (merged): OAuth 2.1 pivot +- ADR-0006: Claude Code OAuth for Agent SDK (Tally プロセス全体の OAuth) + +## 補足: 暫定処置 + +ADR-0011 の実装が完了するまでは PR-C (long-lived 統合) のままで運用する。CodeRabbit の指摘は「設計トレードオフ + ADR で trace」として受容している。 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2c27bd1..4e2134a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,7 +15,8 @@ export type { StoryProgress } from './logic/story'; export { computeStoryProgress, isStoryComplete } from './logic/story'; export type { EdgeMeta, NodeMeta } from './meta'; export { EDGE_META, NODE_META } from './meta'; -export type { Codebase } from './schema'; +export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './oauth'; +export type { Codebase, McpOAuthConfig, McpOAuthToken, McpServerConfig } from './schema'; export { ChatBlockSchema, ChatMessageSchema, @@ -26,6 +27,10 @@ export { EDGE_TYPES, EdgeSchema, IssueNodeSchema, + McpOAuthConfigSchema, + McpOAuthTokenSchema, + McpServerConfigSchema, + McpServerIdRegex, NODE_TYPES, NodeSchema, ProjectMetaPatchSchema, diff --git a/packages/core/src/oauth/atlassian.test.ts b/packages/core/src/oauth/atlassian.test.ts new file mode 100644 index 0000000..20fa088 --- /dev/null +++ b/packages/core/src/oauth/atlassian.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY } from './atlassian'; + +describe('ATLASSIAN_CLOUD_OAUTH', () => { + it('Atlassian 公式 endpoint を保持する', () => { + expect(ATLASSIAN_CLOUD_OAUTH.authorizationEndpoint).toBe( + 'https://auth.atlassian.com/authorize', + ); + expect(ATLASSIAN_CLOUD_OAUTH.tokenEndpoint).toBe('https://auth.atlassian.com/oauth/token'); + expect(ATLASSIAN_CLOUD_OAUTH.audience).toBe('api.atlassian.com'); + }); + + it('default scopes に offline_access が含まれる (refresh_token 取得のために必須)', () => { + expect(ATLASSIAN_CLOUD_OAUTH.defaultScopes).toContain('offline_access'); + }); +}); + +describe('OAUTH_REGISTRY', () => { + it('atlassian kind が登録されている', () => { + expect(OAUTH_REGISTRY.atlassian).toBe(ATLASSIAN_CLOUD_OAUTH); + }); +}); diff --git a/packages/core/src/oauth/atlassian.ts b/packages/core/src/oauth/atlassian.ts new file mode 100644 index 0000000..1b28449 --- /dev/null +++ b/packages/core/src/oauth/atlassian.ts @@ -0,0 +1,42 @@ +// Atlassian Cloud OAuth 2.1 endpoint registry。 +// ADR-0011 で導入。Tally 側で OAuth フローを管理する際に、kind ごとの定数として参照する。 +// +// 参考: +// - https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/ +// - https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/ +// +// 注: Server / DC は OAuth 2.0 endpoint のホスト名が different なので、Cloud のみここに置く。 +// Server/DC 対応が必要になった時点で kind を 'atlassian' から 'atlassian-cloud' / +// 'atlassian-server' に分割する想定。 + +// PR-E2 で OAuthClient / LoopbackCallbackServer が引数として受ける形を先に決めておく。 +// readonly array にしておくことで const-as-const 値も literal 型を維持できる。 +export interface OAuthProviderConfig { + authorizationEndpoint: string; + tokenEndpoint: string; + // OAuth 2.1 の token endpoint で必要な audience (provider 依存、optional)。 + audience?: string; + // requested scopes 未指定時の default。refresh_token に必要な scope (例: offline_access) + // はここに含める想定。 + defaultScopes: readonly string[]; +} + +export const ATLASSIAN_CLOUD_OAUTH: OAuthProviderConfig = { + // Authorization endpoint (PKCE で code を受け取る画面) + authorizationEndpoint: 'https://auth.atlassian.com/authorize', + // Token endpoint (code を access_token に交換) + tokenEndpoint: 'https://auth.atlassian.com/oauth/token', + // refresh_token を発行させるための audience。Atlassian の OAuth 2.1 仕様で必要。 + audience: 'api.atlassian.com', + // 既定 scopes。MVP は Jira read 系のみ。Confluence や write 系は McpServerConfig.oauth.scopes + // でユーザーが追加指定する。 + // refresh_token を得るには `offline_access` が必須。 + defaultScopes: ['read:jira-work', 'read:jira-user', 'offline_access'], +}; + +// kind ごとの OAuth endpoint registry。kind が増えたらここにエントリを追加する。 +export const OAUTH_REGISTRY: Readonly> = { + atlassian: ATLASSIAN_CLOUD_OAUTH, +}; + +export type OAuthKind = keyof typeof OAUTH_REGISTRY; diff --git a/packages/core/src/oauth/index.ts b/packages/core/src/oauth/index.ts new file mode 100644 index 0000000..bd28e23 --- /dev/null +++ b/packages/core/src/oauth/index.ts @@ -0,0 +1 @@ +export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './atlassian'; diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index d9ea1c5..dd5b238 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -8,6 +8,7 @@ import { CodebaseSchema, CodeRefNodeSchema, EdgeSchema, + McpOAuthTokenSchema, McpServerConfigSchema, NodeSchema, ProjectMetaSchema, @@ -421,6 +422,30 @@ describe('McpServerConfigSchema', () => { ).toThrow(); }); + it('oauth 設定 (clientId + scopes) を持って parse できる (ADR-0011 で導入、PR-E1 では optional)', () => { + const raw = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid-abc123', scopes: ['read:jira-work', 'offline_access'] }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed.oauth).toEqual(raw.oauth); + }); + + it('oauth.clientId が空文字なら fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + oauth: { clientId: '' }, + }), + ).toThrow(); + }); + it('auth フィールドが付いていても strict ではないので無視される (passthrough)', () => { // schema 上は auth キーを持たない。zod は default で strict ではないため余計なキーは drop。 // OAuth 移行前の YAML が混入しても parse 自体は通すが、auth 情報は使われない。 @@ -760,3 +785,51 @@ describe('RequirementNodeSchema.sourceUrl', () => { ).toThrow(); }); }); + +// ADR-0011: Tally 側で OAuth 2.1 フローを管理する。token store の YAML スキーマ。 +describe('McpOAuthTokenSchema', () => { + it('最小フィールド (mcpServerId / accessToken / acquiredAt) で parse 成功、tokenType に default Bearer', () => { + const parsed = McpOAuthTokenSchema.parse({ + mcpServerId: 'atlassian', + accessToken: 'a-tok', + acquiredAt: '2026-05-02T10:00:00Z', + }); + expect(parsed.tokenType).toBe('Bearer'); + expect(parsed.refreshToken).toBeUndefined(); + expect(parsed.expiresAt).toBeUndefined(); + expect(parsed.scopes).toBeUndefined(); + }); + + it('refresh_token / expiresAt / scopes を含めて round-trip', () => { + const raw = { + mcpServerId: 'atlassian', + accessToken: 'a-tok', + refreshToken: 'r-tok', + acquiredAt: '2026-05-02T10:00:00Z', + expiresAt: '2026-05-02T11:00:00Z', + scopes: ['read:jira-work', 'offline_access'], + tokenType: 'Bearer', + }; + expect(McpOAuthTokenSchema.parse(raw)).toEqual(raw); + }); + + it('mcpServerId が McpServerIdRegex に違反するなら fail', () => { + expect(() => + McpOAuthTokenSchema.parse({ + mcpServerId: 'BadID', // 大文字 NG + accessToken: 'a', + acquiredAt: '2026-05-02T10:00:00Z', + }), + ).toThrow(); + }); + + it('accessToken が空なら fail (min 1)', () => { + expect(() => + McpOAuthTokenSchema.parse({ + mcpServerId: 'atlassian', + accessToken: '', + acquiredAt: '2026-05-02T10:00:00Z', + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 28660bc..fb65172 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -240,7 +240,18 @@ const McpServerOptionsSchema = z // MCP サーバー id は SDK の wildcard `mcp____*` の id 部分に embed されるため、 // tool 名 matching が壊れないよう CodebaseSchema.id と同じ charset 制約を採用。 -const McpServerIdRegex = /^[a-z][a-z0-9-]{0,31}$/u; +// storage 層 (oauth-store.ts) でファイル名として使う際の path traversal 検査にも +// 流用するため export している。 +export const McpServerIdRegex = /^[a-z][a-z0-9-]{0,31}$/u; + +// ADR-0011: Tally 側で OAuth 2.1 フローを管理するための client 設定。 +// PR-E1 では optional で導入し、PR-E4 (旧 auth_request 経路の削除) と同時に required 化する。 +// scopes が空または未指定なら kind ごとの default を使う (core/src/oauth/.ts)。 +export const McpOAuthConfigSchema = z.object({ + clientId: z.string().min(1), + scopes: z.array(z.string().min(1)).optional(), +}); +export type McpOAuthConfig = z.infer; export const McpServerConfigSchema = z.object({ id: z.string().regex(McpServerIdRegex, { @@ -280,11 +291,40 @@ export const McpServerConfigSchema = z.object({ 'url は https で始まる必要があります (loopback の http は例外的に許容)。URL 内資格情報 (user:pass@) は禁止', }, ), + // ADR-0011: Tally 側で OAuth 2.1 フローを管理する。MVP は段階導入のため optional + // で開始し、PR-E4 で旧 auth_request 経路を削除する際に required 化する。 + oauth: McpOAuthConfigSchema.optional(), options: McpServerOptionsSchema, }); export type McpServerConfig = z.infer; +// ADR-0011: OAuth token store の YAML スキーマ。 +// `/oauth/.yaml` 1 ファイル 1 server で永続化する。 +// access token / refresh token はそのまま平文で書き込み、ファイルパーミッションを +// 0o600 に絞ることで多人数共有環境からの読み取りを防ぐ (MVP)。 +// 暗号化 (OS keychain 統合等) は ADR-0012 で別途検討する。 +export const McpOAuthTokenSchema = z.object({ + mcpServerId: z.string().regex(McpServerIdRegex, { + message: 'mcp server id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', + }), + // 認可コード交換で得た access token。MCP の HTTP transport の Authorization ヘッダで使う。 + accessToken: z.string().min(1), + // refresh token。受信していない provider もあるため optional。 + // provider が誤って空文字を返すとサイレント障害になるので min(1) を付けて検出する。 + refreshToken: z.string().min(1).optional(), + // ISO 8601 datetime。expiresAt と比較して期限間近なら refresh する。 + // 文字列のまま z.string() にしておくと "not-a-date" 等が通り、PR-E2 の expiry 判定が + // 文字列比較で境界バグを起こす (codex P2 指摘)。.datetime() で形式を強制する。 + acquiredAt: z.iso.datetime(), + expiresAt: z.iso.datetime().optional(), + // 実際に許可された scopes (provider が requested scopes を絞った場合に把握)。 + scopes: z.array(z.string()).optional(), + // Bearer 以外の token_type を返す provider が将来現れた場合に備える。default は Bearer。 + tokenType: z.string().default('Bearer'), +}); +export type McpOAuthToken = z.infer; + // .tally/project.yaml に対応する meta のみのスキーマ。 // ノード・エッジはファイル分割で永続化するため、ここには含めない。 export const ProjectMetaSchema = z diff --git a/packages/storage/src/clear-project.test.ts b/packages/storage/src/clear-project.test.ts index 8d56341..279ea43 100644 --- a/packages/storage/src/clear-project.test.ts +++ b/packages/storage/src/clear-project.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { FileSystemChatStore } from './chat-store'; import { clearProject } from './clear-project'; import { initProject } from './init-project'; +import { FileSystemOAuthStore } from './oauth-store'; import { FileSystemProjectStore } from './project-store'; function makeProjectDir(): string { @@ -14,28 +15,39 @@ function makeProjectDir(): string { } describe('clearProject', () => { - it('nodes / chats を全削除し edges を空配列に、project.yaml は維持', async () => { + it('nodes / chats / oauth tokens を全削除し edges を空配列に、project.yaml は維持', async () => { const projectDir = makeProjectDir(); try { await initProject({ projectDir, name: 'P', codebases: [] }); const ps = new FileSystemProjectStore(projectDir); const cs = new FileSystemChatStore(projectDir); + const os = new FileSystemOAuthStore(projectDir); await ps.addNode({ type: 'requirement', x: 0, y: 0, title: 'R1', body: '' }); await ps.addNode({ type: 'usecase', x: 0, y: 0, title: 'UC1', body: '' }); await cs.createChat({ projectId: 'p', title: 'T1' }); await cs.createChat({ projectId: 'p', title: 'T2' }); + // ADR-0011: oauth token はプロジェクトリセット時に確実に消す。 + await os.write({ + mcpServerId: 'atlassian', + accessToken: 'a', + acquiredAt: '2026-05-02T10:00:00Z', + tokenType: 'Bearer', + }); expect((await ps.listNodes()).length).toBe(2); expect((await cs.listChats()).length).toBe(2); + expect((await os.list()).length).toBe(1); const metaBefore = await ps.getProjectMeta(); const result = await clearProject(projectDir); expect(result.removedNodes).toBe(2); expect(result.removedChats).toBe(2); + expect(result.removedOAuthTokens).toBe(1); expect((await ps.listNodes()).length).toBe(0); expect((await cs.listChats()).length).toBe(0); + expect((await os.list()).length).toBe(0); expect((await ps.listEdges()).length).toBe(0); expect(await ps.getProjectMeta()).toEqual(metaBefore); } finally { @@ -50,12 +62,36 @@ describe('clearProject', () => { const result = await clearProject(projectDir); expect(result.removedNodes).toBe(0); expect(result.removedChats).toBe(0); + expect(result.removedOAuthTokens).toBe(0); expect(result.keptEdgesFile).toBe(true); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); + it('oauth/ の tmp 残骸 (ext 不問) も clearProject で削除される', async () => { + const projectDir = makeProjectDir(); + try { + await initProject({ projectDir, name: 'P', codebases: [] }); + const oauthDir = path.join(projectDir, 'oauth'); + await fs.mkdir(oauthDir, { recursive: true }); + // 正規 token + 中断 write の残骸 (.tmp..) + await fs.writeFile(path.join(oauthDir, 'atlassian.yaml'), 'mcpServerId: atlassian\n'); + await fs.writeFile( + path.join(oauthDir, 'atlassian.yaml.tmp.12345.abcd-efgh'), + 'partial-token', + ); + + const result = await clearProject(projectDir); + // removedOAuthTokens は YAML の数だけ (tmp は count 対象外、ただし削除はされる) + expect(result.removedOAuthTokens).toBe(1); + const remaining = await fs.readdir(oauthDir).catch(() => [] as string[]); + expect(remaining).toEqual([]); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it('project.yaml が無くてもディレクトリがあれば動く', async () => { const projectDir = makeProjectDir(); await fs.mkdir(path.join(projectDir, 'nodes'), { recursive: true }); diff --git a/packages/storage/src/clear-project.ts b/packages/storage/src/clear-project.ts index 53f2efc..205e144 100644 --- a/packages/storage/src/clear-project.ts +++ b/packages/storage/src/clear-project.ts @@ -6,19 +6,25 @@ import { resolveProjectPaths } from './project-dir'; export interface ClearProjectResult { removedNodes: number; removedChats: number; + removedOAuthTokens: number; keptEdgesFile: boolean; } -// プロジェクトの内容を初期化する。project.yaml は維持、nodes/*.yaml と chats/*.yaml を全削除、 -// edges.yaml は空配列に書き戻す。呼び出し側 (UI/CLI) が確認ダイアログを出す前提。 +// プロジェクトの内容を初期化する。project.yaml は維持、nodes/*.yaml と chats/*.yaml と +// oauth/*.yaml を全削除、edges.yaml は空配列に書き戻す。呼び出し側 (UI/CLI) が確認ダイアログを +// 出す前提。 +// +// ADR-0011: oauth/ には access token / refresh token が平文で保存されているため、 +// プロジェクトリセット時に確実に削除しないと次の利用者に漏洩しうる (codex P1 指摘)。 export async function clearProject(projectDir: string): Promise { const paths = resolveProjectPaths(projectDir); const removedNodes = await clearDir(paths.nodesDir); const removedChats = await clearDir(paths.chatsDir); + const removedOAuthTokens = await clearOAuthDir(paths.oauthDir); // edges.yaml を空配列で書き直す (無ければ作成)。 await fs.mkdir(paths.edgesDir, { recursive: true }); await fs.writeFile(paths.edgesFile, 'edges: []\n', 'utf8'); - return { removedNodes, removedChats, keptEdgesFile: true }; + return { removedNodes, removedChats, removedOAuthTokens, keptEdgesFile: true }; } // 指定ディレクトリ直下の *.yaml / *.yml を削除する。ディレクトリ自体は残す。 @@ -39,3 +45,21 @@ async function clearDir(dir: string): Promise { } return count; } + +// oauth/ 専用クリーナ。token YAML だけでなく `*.tmp..` 等の中間ファイル +// (oauth-store.ts の write 中断で残骸化しうる) も拡張子問わず削除する (CR Major)。 +// 戻り値は token YAML の件数 (= ユーザーが意識する「削除された token 数」)。 +async function clearOAuthDir(dir: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw err; + } + const tokenCount = entries.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')).length; + for (const name of entries) { + await fs.unlink(path.join(dir, name)); + } + return tokenCount; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 69047de..81a8206 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -6,6 +6,8 @@ export type { ClearProjectResult } from './clear-project'; export { clearProject } from './clear-project'; export type { InitProjectInput, InitProjectResult } from './init-project'; export { initProject } from './init-project'; +export type { OAuthStore } from './oauth-store'; +export { FileSystemOAuthStore } from './oauth-store'; export type { ProjectPaths } from './project-dir'; export { chatFileName, diff --git a/packages/storage/src/oauth-store.test.ts b/packages/storage/src/oauth-store.test.ts new file mode 100644 index 0000000..6f74866 --- /dev/null +++ b/packages/storage/src/oauth-store.test.ts @@ -0,0 +1,149 @@ +import { promises as fs, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { McpOAuthToken } from '@tally/core'; +import { describe, expect, it } from 'vitest'; + +import { FileSystemOAuthStore } from './oauth-store'; + +function makeProjectDir(): string { + return mkdtempSync(path.join(tmpdir(), 'tally-oauth-')); +} + +function makeToken(overrides: Partial = {}): McpOAuthToken { + return { + mcpServerId: 'atlassian', + accessToken: 'access-abc', + refreshToken: 'refresh-xyz', + acquiredAt: '2026-05-02T10:00:00Z', + expiresAt: '2026-05-02T11:00:00Z', + scopes: ['read:jira-work'], + tokenType: 'Bearer', + ...overrides, + }; +} + +describe('FileSystemOAuthStore', () => { + it('write → read で書き込んだ token が取り出せる', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + const token = makeToken(); + await store.write(token); + const got = await store.read('atlassian'); + expect(got).toEqual(token); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('未保存の mcpServerId は read で null', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + const got = await store.read('atlassian'); + expect(got).toBeNull(); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + // Windows は POSIX permission を持たないので skip (Vitest レポートに skipped と表示される)。 + it.skipIf(process.platform === 'win32')( + 'write 後のファイルは mode 0o600 (owner-only)', + async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + await store.write(makeToken()); + const stat = await fs.stat(path.join(projectDir, 'oauth', 'atlassian.yaml')); + // owner read/write のみ立っていることを確認 (group / others は 0)。 + expect(stat.mode & 0o077).toBe(0); + expect(stat.mode & 0o600).toBe(0o600); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); + + it('delete で該当 token ファイルが消える、未存在は no-op', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + await store.write(makeToken()); + await store.delete('atlassian'); + expect(await store.read('atlassian')).toBeNull(); + // 2 度目は no-op + await expect(store.delete('atlassian')).resolves.toBeUndefined(); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('list は保存済み mcpServerId をソート済みで返す、空ディレクトリは空配列', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + // 未保存時は空配列 (oauth ディレクトリ自体無し) + expect(await store.list()).toEqual([]); + + await store.write(makeToken({ mcpServerId: 'github' })); + await store.write(makeToken({ mcpServerId: 'atlassian' })); + const list = await store.list(); + expect(list).toEqual(['atlassian', 'github']); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('破損 YAML は read で warn + null を返す (FS 系エラーは再スロー)', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + const dir = path.join(projectDir, 'oauth'); + await fs.mkdir(dir, { recursive: true }); + // 必須フィールドが欠けた YAML を直接書き込む + await fs.writeFile(path.join(dir, 'atlassian.yaml'), 'mcpServerId: atlassian\n'); + const got = await store.read('atlassian'); + expect(got).toBeNull(); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('tokenType を持たない YAML を read すると schema default の Bearer が入る', async () => { + const projectDir = makeProjectDir(); + try { + const dir = path.join(projectDir, 'oauth'); + await fs.mkdir(dir, { recursive: true }); + // tokenType を欠いた最小 YAML を直接書き込み、schema の default 経路 (回帰守り)。 + await fs.writeFile( + path.join(dir, 'atlassian.yaml'), + 'mcpServerId: atlassian\naccessToken: a\nacquiredAt: 2026-05-02T10:00:00Z\n', + ); + const store = new FileSystemOAuthStore(projectDir); + const got = await store.read('atlassian'); + expect(got?.tokenType).toBe('Bearer'); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('mcpServerId が McpServerIdRegex 違反 (path traversal 含む) なら read/write/delete が throw', async () => { + const projectDir = makeProjectDir(); + try { + const store = new FileSystemOAuthStore(projectDir); + const bad = '../etc/passwd'; + await expect(store.read(bad)).rejects.toThrow(/invalid mcpServerId/); + await expect(store.delete(bad)).rejects.toThrow(/invalid mcpServerId/); + await expect(store.write(makeToken({ mcpServerId: bad as never }))).rejects.toThrow( + /invalid mcpServerId/, + ); + // 大文字も regex 違反 + await expect(store.read('Atlassian')).rejects.toThrow(/invalid mcpServerId/); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/storage/src/oauth-store.ts b/packages/storage/src/oauth-store.ts new file mode 100644 index 0000000..b38ab57 --- /dev/null +++ b/packages/storage/src/oauth-store.ts @@ -0,0 +1,117 @@ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { type McpOAuthToken, McpOAuthTokenSchema, McpServerIdRegex } from '@tally/core'; +import { stringify } from 'yaml'; + +import { resolveProjectPaths } from './project-dir'; +import { readYaml, YamlValidationError } from './yaml'; + +// ADR-0011: 外部 MCP server の OAuth 2.1 token store。 +// `/oauth/.yaml` 1 ファイル 1 server。 +// +// 設計判断: +// - 平文 YAML で書き込み、ファイル mode を 0o600 に絞る (MVP)。OS keychain 統合は +// ADR-0012 で別途検討する。 +// - mcpServerId は file 名にそのまま使うので、core の McpServerIdRegex +// (英小文字 / 数字 / ハイフン) で予め検証されている前提。`../foo` 等の path +// traversal は McpServerConfigSchema で弾かれる。 +// - 1 server 1 file にすることで、削除 / 個別読み出しが O(1) になる。プロジェクト +// 切替や server 削除時の cleanup も `unlink` 1 回で済む。 +// - 破損ファイル (YamlValidationError) は warn して null を返す。FS 系 IO エラー +// (EACCES 等) は再 throw する (chat-store.ts の listChats と同じ方針)。 +export interface OAuthStore { + // 該当 server の token を読む。未保存・破損なら null。 + read(mcpServerId: string): Promise; + // token を書き込む。ファイル mode は 0o600 に強制する。 + write(token: McpOAuthToken): Promise; + // 該当 server の token を削除する。存在しなければ no-op。 + delete(mcpServerId: string): Promise; + // 保存済み mcpServerId の一覧を返す (ソート済み)。 + list(): Promise; +} + +export class FileSystemOAuthStore implements OAuthStore { + private readonly oauthDir: string; + + constructor(projectDir: string) { + this.oauthDir = resolveProjectPaths(projectDir).oauthDir; + } + + private filePath(mcpServerId: string): string { + // ストレージ境界での path traversal 防御 (CR Major)。上流で McpServerConfigSchema + // が同じ regex で検証している前提だが、それに依存せず自己防衛する。 + if (!McpServerIdRegex.test(mcpServerId)) { + throw new Error(`invalid mcpServerId for oauth store: ${mcpServerId}`); + } + return path.join(this.oauthDir, `${mcpServerId}.yaml`); + } + + async read(mcpServerId: string): Promise { + try { + return await readYaml(this.filePath(mcpServerId), McpOAuthTokenSchema); + } catch (err) { + // YAML 破損は warn + null。FS 系エラーは再スロー (silent fail で原因隠蔽を防ぐ)。 + if (err instanceof YamlValidationError) { + console.warn(`[oauth-store] skip broken token file for ${mcpServerId}:`, err); + return null; + } + throw err; + } + } + + async write(token: McpOAuthToken): Promise { + await fs.mkdir(this.oauthDir, { recursive: true }); + const filePath = this.filePath(token.mcpServerId); + const yaml = stringify(token, { lineWidth: 120, blockQuote: true }); + // TOCTOU 防止: tmp ファイルを最初から 0o600 で open → 書き込み → rename する。 + // writeYaml + 後で chmod の経路だと、rename 直後 (0o644) に別プロセスが + // accessToken を読める瞬間がある (codex P1 指摘)。fs.open(mode) で初期パーミッションを + // owner-only に固定し、rename で perms を保つ。 + // Windows は POSIX permission を持たないが、open mode 引数は ignored で問題なし。 + // tmp suffix は 1 書き込みごとに一意。同 mcpServerId への並行 write が + // 同一プロセス内で起きても互いの tmp を上書きしない (CR Major)。 + const tmpPath = `${filePath}.tmp.${process.pid}.${randomUUID()}`; + const fd = await fs.open(tmpPath, 'w', 0o600); + try { + await fd.writeFile(yaml, 'utf8'); + } finally { + await fd.close(); + } + try { + await fs.rename(tmpPath, filePath); + } catch (err) { + // rename 失敗時は tmp を残さない。unlink 自体の失敗は元エラーを優先。 + try { + await fs.unlink(tmpPath); + } catch { + /* ignore */ + } + throw err; + } + } + + async delete(mcpServerId: string): Promise { + try { + await fs.unlink(this.filePath(mcpServerId)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + } + + async list(): Promise { + let entries: string[]; + try { + entries = await fs.readdir(this.oauthDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + return entries + .filter((f) => f.endsWith('.yaml')) + .map((f) => f.replace(/\.yaml$/, '')) + .sort(); + } +} diff --git a/packages/storage/src/project-dir.ts b/packages/storage/src/project-dir.ts index dd78f03..8ca2174 100644 --- a/packages/storage/src/project-dir.ts +++ b/packages/storage/src/project-dir.ts @@ -8,6 +8,8 @@ export interface ProjectPaths { edgesDir: string; edgesFile: string; chatsDir: string; + // ADR-0011: 外部 MCP server の OAuth token を 1 server 1 ファイルで永続化。 + oauthDir: string; } export function resolveProjectPaths(projectDir: string): ProjectPaths { @@ -19,6 +21,7 @@ export function resolveProjectPaths(projectDir: string): ProjectPaths { edgesDir: path.join(root, 'edges'), edgesFile: path.join(root, 'edges', 'edges.yaml'), chatsDir: path.join(root, 'chats'), + oauthDir: path.join(root, 'oauth'), }; }