diff --git a/docs/sdd/design/chrome-sidecar/api/environment-api.md b/docs/sdd/design/chrome-sidecar/api/environment-api.md new file mode 100644 index 000000000..43a5e5070 --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/api/environment-api.md @@ -0,0 +1,91 @@ +# API設計: Environment API拡張 + +## 概要 + +環境APIの作成・更新エンドポイントにchromeSidecar設定のバリデーションを追加する。 + +## 対応要件 + +REQ-003-001, REQ-003-002, REQ-003-003 + +## 変更エンドポイント + +### PUT /api/environments/:id + +既存のconfig JSONバリデーションに、chromeSidecar固有のバリデーションルールを追加する。 + +### POST /api/environments + +環境作成時も同様のバリデーションを適用する。 + +## バリデーション仕様 + +### リクエストボディ (config部分) + +```json +{ + "config": { + "imageName": "ghcr.io/windschord/claude-work-sandbox", + "imageTag": "latest", + "chromeSidecar": { + "enabled": true, + "image": "ghcr.io/windschord/claude-work-sandbox", + "tag": "chrome-devtools" + } + } +} +``` + +### バリデーションルール + +| フィールド | ルール | HTTPステータス | エラーメッセージ | +|-----------|--------|--------------|-----------------| +| chromeSidecar | オプション(省略時はenabled:falseとして扱う) | - | - | +| chromeSidecar.enabled | boolean型、必須(chromeSidecarが存在する場合) | 400 | "chromeSidecar.enabled is required and must be a boolean" | +| chromeSidecar.image | 文字列型、`/^[-a-z0-9._/]+$/` に適合、必須 | 400 | "chromeSidecar.image must be a valid Docker image name" | +| chromeSidecar.tag | 文字列型、空文字不可、`latest`不可、必須 | 400 | "chromeSidecar.tag must be a specific version (latest is not allowed)" | +| chromeSidecar + type=HOST | chromeSidecarは無視される(HOST環境には適用不可) | - | - | + +### バリデーション実装箇所 + +環境APIのバリデーションロジック内に追加する。 + +```typescript +function validateChromeSidecarConfig( + config: Record +): string | null { + if (!config.chromeSidecar) return null; // 省略OK + + const sidecar = config.chromeSidecar as Record; + + if (typeof sidecar.enabled !== 'boolean') { + return 'chromeSidecar.enabled is required and must be a boolean'; + } + + if (!sidecar.enabled) return null; // disabled時はimage/tagのバリデーション不要 + + if (typeof sidecar.image !== 'string' || !/^[-a-z0-9._/]+$/.test(sidecar.image)) { + return 'chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, hyphens)'; + } + + if (typeof sidecar.tag !== 'string' || sidecar.tag.trim() === '') { + return 'chromeSidecar.tag is required'; + } + + if (sidecar.tag === 'latest') { + return 'chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'; + } + + return null; +} +``` + +## レスポンス + +変更なし。既存のEnvironmentレスポンスにconfig JSONが含まれており、chromeSidecarもその中に格納される。 + +## 後方互換性 + +- chromeSidecarキーが存在しないconfig JSONは引き続き有効 +- 既存の環境レコードに変更は不要 +- HOST環境のconfigにchromeSidecarが含まれていても無視される(エラーにはしない) diff --git a/docs/sdd/design/chrome-sidecar/api/session-api.md b/docs/sdd/design/chrome-sidecar/api/session-api.md new file mode 100644 index 000000000..bc0acec9a --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/api/session-api.md @@ -0,0 +1,62 @@ +# API設計: Session API拡張 + +## 概要 + +セッションAPIのレスポンスにChromeサイドカー情報(コンテナID、デバッグポート)を追加する。 + +## 対応要件 + +REQ-004-002, REQ-004-003 + +## 変更エンドポイント + +### GET /api/sessions/:id + +レスポンスに chrome_container_id と chrome_debug_port を追加する。 + +#### レスポンス例 (サイドカーあり) + +```json +{ + "id": "abc-123", + "name": "my-session", + "status": "running", + "container_id": "claude-env-xxx-12345", + "chrome_container_id": "cw-chrome-abc-123", + "chrome_debug_port": 32789, + ... +} +``` + +#### レスポンス例 (サイドカーなし) + +```json +{ + "id": "abc-123", + "name": "my-session", + "status": "running", + "container_id": "claude-env-xxx-12345", + "chrome_container_id": null, + "chrome_debug_port": null, + ... +} +``` + +### GET /api/projects/:id/sessions + +セッション一覧のレスポンスにも同様に chrome_container_id と chrome_debug_port を含める。 + +### DELETE /api/sessions/:id + +セッション削除時、DockerAdapter.destroySession がサイドカーのクリーンアップを担当するため、APIレイヤーでの追加処理は不要。 + +## アクセス制御 + +REQ-004-003 では「セッション所有者または管理者権限を持つユーザーのみ」にChrome情報の表示を制限する要件がある。 + +現状のclaude-workには認証/認可機能が存在しないため、以下の方針とする: + +1. 現時点: chrome_debug_port は全セッションAPIレスポンスに含める +2. 将来の認証機能実装時: APIミドルウェアでユーザー権限を検証し、権限がない場合は chrome_debug_port を null にマスクする + +この方針により、将来の認証機能追加時にAPIレスポンスの構造変更が不要となる。 diff --git a/docs/sdd/design/chrome-sidecar/components/chrome-sidecar-service.md b/docs/sdd/design/chrome-sidecar/components/chrome-sidecar-service.md new file mode 100644 index 000000000..48b973bea --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/components/chrome-sidecar-service.md @@ -0,0 +1,266 @@ +# コンポーネント設計: ChromeSidecarService + +## 概要 + +Chromeサイドカーコンテナのライフサイクル(起動・ヘルスチェック・停止・クリーンアップ)を管理する専用サービス。DockerAdapterから呼び出され、サイドカーに関するDocker操作を一箇所に集約する。 + +## 対応要件 + +- 機能要件: REQ-001-001, REQ-001-002, REQ-001-004, REQ-002-001, REQ-002-002, REQ-004-001 +- 非機能要件: NFR-SEC-001, NFR-SEC-002, NFR-RES-001, NFR-RES-002, NFR-RES-003 + +## ファイル配置 + +`src/services/chrome-sidecar-service.ts` + +## インターフェース設計 + +```typescript +/** + * サイドカー起動結果 + */ +export interface SidecarStartResult { + /** 起動成功かどうか */ + success: boolean; + /** Chromeコンテナ名 (成功時) */ + containerName?: string; + /** ネットワーク名 (成功時) */ + networkName?: string; + /** ホスト側デバッグポート (ポートマッピング成功時) */ + debugPort?: number; + /** Chrome内部URL (Claude CodeからのCDP接続先) */ + browserUrl?: string; + /** 失敗理由 (失敗時) */ + error?: string; +} + +/** + * サイドカー設定 (ExecutionEnvironment.config.chromeSidecarから取得) + */ +export interface ChromeSidecarConfig { + enabled: boolean; + image: string; // デフォルト: 'ghcr.io/windschord/claude-work-sandbox' + tag: string; // デフォルト: 'chrome-devtools' +} + +/** + * ChromeSidecarService + * + * セッション単位のChromeサイドカーコンテナを管理する。 + * DockerClientを使用してDocker APIと通信する。 + */ +export class ChromeSidecarService { + /** + * サイドカーChromeを起動する + * + * 1. セッション専用ブリッジネットワーク作成 + * 2. Chromeコンテナ作成・起動 + * 3. CDPヘルスチェック (最大30秒) + * 4. ヘルスチェック成功: SidecarStartResult返却 + * ヘルスチェック失敗: クリーンアップしてsuccess=false返却 + * + * @param sessionId - セッションID + * @param config - サイドカー設定 + * @returns サイドカー起動結果 + */ + async startSidecar( + sessionId: string, + config: ChromeSidecarConfig + ): Promise; + + /** + * サイドカーChromeを停止・削除する + * + * 1. Chromeコンテナ停止 (AutoRemoveで自動削除) + * 2. セッション専用ネットワーク削除 + * + * @param sessionId - セッションID + * @param containerName - Chromeコンテナ名 + * @param networkName - ネットワーク名 (省略時はセッションIDから推定) + */ + async stopSidecar( + sessionId: string, + containerName: string, + networkName?: string + ): Promise; + + /** + * Claude Codeコンテナをサイドカーネットワークに接続する + * + * Claude Codeコンテナ起動後に呼び出し、 + * セッション専用ネットワークに追加接続する。 + * + * @param claudeContainerName - Claude Codeコンテナ名 + * @param networkName - セッション専用ネットワーク名 + */ + async connectClaudeContainer( + claudeContainerName: string, + networkName: string + ): Promise; + + /** + * 孤立したChromeコンテナ・ネットワークをクリーンアップする + * + * サーバー起動時に呼び出す。 + * DBに記録されたchrome_container_idに対応するコンテナが + * 実行中でない場合、コンテナとネットワークを削除する。 + */ + async cleanupOrphaned(): Promise; + + /** + * 現在起動中のサイドカー数を取得する + * + * Dockerラベル `claude-work.chrome-sidecar=true` で + * 実行中コンテナをカウントする。 + */ + async getActiveSidecarCount(): Promise; +} +``` + +## 内部設計 + +### 命名規則 + +| リソース | 命名パターン | 例 | +|----------|------------|-----| +| Chromeコンテナ | `cw-chrome-` | `cw-chrome-abc123` | +| ブリッジネットワーク | `cw-net-` | `cw-net-abc123` | + +### Dockerラベル + +全てのリソース(コンテナ、ネットワーク)に以下のラベルを付与する: + +| ラベルキー | 値 | 用途 | +|-----------|-----|------| +| `claude-work.session-id` | セッションID | 孤立リソース検出・クリーンアップ | +| `claude-work.chrome-sidecar` | `true` | Chromeコンテナの識別 | +| `claude-work.managed-by` | `claude-work` | ClaudeWork管理リソースの識別 | + +### Chromeコンテナ作成オプション + +```typescript +const chromeContainerOptions: Docker.ContainerCreateOptions = { + name: `cw-chrome-${sessionId}`, + Image: `${config.image}:${config.tag}`, + // ghcr.io/windschord/claude-work-sandbox はCLI引数をCmdで渡す + Cmd: [ + '--no-sandbox', // コンテナ内ではsandbox不要 + '--disable-gpu', // ヘッドレス環境 + '--remote-debugging-address=0.0.0.0', + '--remote-debugging-port=9222', + ], + ExposedPorts: { + '9222/tcp': {}, + }, + Labels: { + 'claude-work.session-id': sessionId, + 'claude-work.chrome-sidecar': 'true', + 'claude-work.managed-by': 'claude-work', + }, + HostConfig: { + PortBindings: { + '9222/tcp': [{ HostPort: '', HostIp: '127.0.0.1' }], + }, + CapDrop: ['ALL'], + SecurityOpt: ['no-new-privileges'], + Memory: 512 * 1024 * 1024, // 512MB + AutoRemove: true, + NetworkMode: `cw-net-${sessionId}`, + }, +}; +``` + +### ネットワーク作成オプション + +```typescript +const networkOptions = { + Name: `cw-net-${sessionId}`, + Driver: 'bridge', + Labels: { + 'claude-work.session-id': sessionId, + 'claude-work.managed-by': 'claude-work', + }, +}; +``` + +### CDPヘルスチェック + +```typescript +/** + * CDPヘルスチェック + * + * Chrome DevTools Protocol の /json/version エンドポイントに + * HTTP GETリクエストを送信し、応答を待つ。 + * + * ポーリング間隔: 1秒 + * 最大待機時間: 30秒 + * 成功判定: HTTP 200レスポンス + */ +private async waitForCDP( + containerName: string, + networkName: string, + timeoutMs: number = 30000 +): Promise { + const startTime = Date.now(); + const interval = 1000; + + while (Date.now() - startTime < timeoutMs) { + try { + // コンテナ内からCDP確認(ネットワーク内部通信) + // docker exec でcurlやwgetの代わりにNode.js http.getを使用 + const container = DockerClient.getInstance().getContainer(containerName); + const exec = await container.exec({ + Cmd: ['wget', '-q', '-O', '-', 'http://localhost:9222/json/version'], + AttachStdout: true, + }); + const stream = await exec.start({}); + // ストリームからレスポンスを読み取り + // 成功すればtrue + return true; + } catch { + // まだ準備中 + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + return false; // タイムアウト +} +``` + +注: 実装時にはghcr.io/windschord/claude-work-sandboxイメージにwgetが含まれるかを確認し、含まれない場合はNode.jsのhttp モジュールでコンテナの公開ポート経由でチェックする方式に変更する。その場合、ホスト側にマッピングされたポートまたはDockerネットワーク内部のIPアドレスを使用する。 + +### 孤立リソースクリーンアップ + +```text +サーバー起動時: + +1. DBから chrome_container_id が NOT NULL のセッションを取得 +2. 各コンテナについて: + a. docker inspect で実行状態を確認 + b. 実行中でない場合: + - コンテナが存在すれば削除(AutoRemoveで既に消えている可能性あり) + - cw-net- ネットワークを削除 + - DB: chrome_container_id = NULL, chrome_debug_port = NULL に更新 +3. ラベルベースの追加チェック: + a. claude-work.chrome-sidecar=true のコンテナ一覧を取得 + b. DBに対応するセッションがないコンテナを停止・削除 + c. claude-work.managed-by=claude-work のネットワーク一覧を取得 + d. 接続コンテナがゼロのネットワークを削除 +``` + +### ポートマッピング失敗時の処理 + +HostPort='' での動的マッピングが失敗する場合(エフェメラルポート枯渇等): + +1. Chromeコンテナ作成時にPortBindingsを省略して再試行 +2. chrome_debug_port = NULL のまま +3. 警告ログを出力 +4. コンテナ間通信(内部ネットワーク経由)は維持される + +## 依存関係 + +| 依存先 | 用途 | +|--------|------| +| DockerClient | Docker API操作(コンテナ作成・停止・ネットワーク作成等) | +| logger | ログ出力 | +| db, schema | セッションのchrome_container_id/chrome_debug_port更新 | diff --git a/docs/sdd/design/chrome-sidecar/components/db-schema.md b/docs/sdd/design/chrome-sidecar/components/db-schema.md new file mode 100644 index 000000000..1f2cf2355 --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/components/db-schema.md @@ -0,0 +1,115 @@ +# コンポーネント設計: DBスキーマ拡張 + +## 概要 + +SessionテーブルにChromeサイドカー関連のカラムを追加し、ExecutionEnvironment.config JSONにchromeSidecar設定オブジェクトの型定義を追加する。 + +## 対応要件 + +REQ-002-003, REQ-003-001, REQ-003-002, REQ-003-003 + +## Sessionテーブル拡張 + +### 新規カラム + +| カラム名 | 型 | NULL可否 | デフォルト | 説明 | +|---------|------|----------|-----------|------| +| chrome_container_id | TEXT | YES | NULL | Chromeサイドカーのコンテナ名 (`cw-chrome-` 形式) | +| chrome_debug_port | INTEGER | YES | NULL | ホストにマッピングされたデバッグポート番号 | + +### スキーマ定義の変更 + +```typescript +// src/db/schema.ts - sessions テーブル +export const sessions = sqliteTable('Session', { + // ... 既存カラム + container_id: text('container_id'), + + // Chrome Sidecar関連(新規追加) + chrome_container_id: text('chrome_container_id'), // Chromeサイドカーコンテナ名 + chrome_debug_port: integer('chrome_debug_port'), // ホスト側デバッグポート + + // ... 既存カラム続き +}, (table) => [ + // ... 既存インデックス +]); +``` + +### マイグレーション + +Drizzle ORM の `db:generate` で自動生成される。マイグレーションSQL(参考): + +```sql +ALTER TABLE Session ADD COLUMN chrome_container_id TEXT; +ALTER TABLE Session ADD COLUMN chrome_debug_port INTEGER; +``` + +既存レコードはNULLのまま(後方互換性あり)。 + +## ExecutionEnvironment.config JSON拡張 + +### 型定義 + +```typescript +// src/types/environment.ts +export interface DockerEnvironmentConfig { + // ... 既存フィールド + imageSource?: 'existing' | 'dockerfile'; + imageName?: string; + imageTag?: string; + dockerfilePath?: string; + dockerfileUploaded?: boolean; + skipPermissions?: boolean; + portMappings?: PortMapping[]; + volumeMounts?: VolumeMount[]; + + // Chrome Sidecar設定(新規追加) + chromeSidecar?: { + enabled: boolean; + image: string; // デフォルト: 'ghcr.io/windschord/claude-work-sandbox' + tag: string; // 固定バージョン必須、'latest'禁止 + }; +} +``` + +### configデータ例 + +サイドカー未設定(デフォルト、後方互換): + +```json +{ + "imageName": "ghcr.io/windschord/claude-work-sandbox", + "imageTag": "latest" +} +``` + +サイドカー有効: + +```json +{ + "imageName": "ghcr.io/windschord/claude-work-sandbox", + "imageTag": "latest", + "chromeSidecar": { + "enabled": true, + "image": "ghcr.io/windschord/claude-work-sandbox", + "tag": "chrome-devtools" + } +} +``` + +### バリデーションルール + +| フィールド | ルール | エラーメッセージ例 | +|-----------|--------|-----------------| +| chromeSidecar.enabled | boolean型、必須(chromeSidecarキーが存在する場合) | "enabled must be a boolean" | +| chromeSidecar.image | 文字列型、`[-a-z0-9._/:]+` に適合、必須 | "image must be a valid Docker image name" | +| chromeSidecar.tag | 文字列型、必須、`latest`禁止 | "tag must be a specific version (latest is not allowed)" | +| chromeSidecarキー自体 | 存在しない場合は`enabled: false`として扱う | (バリデーションエラーなし) | + +バリデーションは Environment API の PUT/POST エンドポイントで実行する。 + +## 後方互換性 + +- `chrome_container_id` と `chrome_debug_port` はNULL許可のため、既存レコードに影響なし +- `chromeSidecar` キーが config JSON に存在しない場合は `enabled: false` として扱う +- `chromeSidecar` キー未存在時のみ無効扱い。キーが存在する場合は `enabled` フィールドで有効/無効を判定する diff --git a/docs/sdd/design/chrome-sidecar/components/docker-adapter.md b/docs/sdd/design/chrome-sidecar/components/docker-adapter.md new file mode 100644 index 000000000..9f14ca8fa --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/components/docker-adapter.md @@ -0,0 +1,291 @@ +# コンポーネント設計: DockerAdapter拡張 + +## 概要 + +DockerAdapterの`createSession`と`destroySession`を拡張し、ChromeSidecarServiceを呼び出してサイドカーChromeの起動・停止をセッションライフサイクルに統合する。また、`.mcp.json`への`--browserUrl`注入をコンテナ起動スクリプトに組み込む。 + +## 対応要件 + +REQ-001-002, REQ-001-003, REQ-002-001, REQ-003-003 + +## 変更ファイル + +- `src/services/adapters/docker-adapter.ts` +- `src/services/environment-adapter.ts` +- `src/services/adapter-factory.ts` +- `src/types/environment.ts` + +## 設計 + +### CreateSessionOptionsの拡張 + +```typescript +// src/services/environment-adapter.ts +export interface CreateSessionOptions { + // ... 既存フィールド + /** サイドカーChrome設定 */ + chromeSidecar?: ChromeSidecarConfig; +} +``` + +### DockerEnvironmentConfigの拡張 + +```typescript +// src/types/environment.ts +export interface DockerEnvironmentConfig { + // ... 既存フィールド + /** サイドカーChrome設定 */ + chromeSidecar?: { + enabled: boolean; + image: string; + tag: string; + }; +} +``` + +### DockerAdapter.createSession の拡張 + +既存の`createSession`メソッドに、サイドカー起動フェーズを追加する。 + +```typescript +async createSession( + sessionId: string, + workingDir: string, + initialPrompt?: string, + options?: CreateSessionOptions +): Promise { + // ... 既存の前処理(シェルモード判定、フィルタリング確認等) + + // === サイドカー起動フェーズ(新規追加) === + let sidecarResult: SidecarStartResult | undefined; + if (options?.chromeSidecar?.enabled) { + const sidecarService = new ChromeSidecarService(); + sidecarResult = await sidecarService.startSidecar( + sessionId, + options.chromeSidecar + ); + + if (sidecarResult.success) { + logger.info('DockerAdapter: Chrome sidecar started', { + sessionId, + chromeContainer: sidecarResult.containerName, + networkName: sidecarResult.networkName, + debugPort: sidecarResult.debugPort, + }); + } else { + logger.warn('DockerAdapter: Chrome sidecar failed, continuing without sidecar', { + sessionId, + error: sidecarResult.error, + }); + } + } + + // === 既存のコンテナオプション構築 === + const { createOptions, containerName } = this.buildContainerOptions(workingDir, { + ...options, + filterEnabled, + registryFirewallEnabled, + }); + + // === .mcp.json注入のためのEntrypoint拡張(サイドカー成功時) === + if (sidecarResult?.success && sidecarResult.browserUrl) { + this.injectBrowserUrl(createOptions, sidecarResult.browserUrl); + } + + // ... 既存のコンテナ作成・起動ロジック + + // === サイドカーネットワークへのClaude Code接続(新規追加) === + if (sidecarResult?.success && sidecarResult.networkName) { + try { + const sidecarService = new ChromeSidecarService(); + await sidecarService.connectClaudeContainer( + containerName, + sidecarResult.networkName + ); + } catch (error) { + logger.warn('DockerAdapter: Failed to connect Claude container to sidecar network', { + sessionId, + networkName: sidecarResult.networkName, + error: error instanceof Error ? error.message : 'Unknown', + }); + // Claude Code自体は起動済みなので続行 + } + } + + // === DB更新: chrome_container_id, chrome_debug_port === + if (sidecarResult?.success) { + db.update(schema.sessions) + .set({ + chrome_container_id: sidecarResult.containerName, + chrome_debug_port: sidecarResult.debugPort ?? null, + updated_at: new Date(), + }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + + // ... 既存のイベント転送ロジック +} +``` + +### .mcp.json への --browserUrl 注入 + +Claude Codeコンテナの起動スクリプトに`.mcp.json`の操作を追加する。既存のregistry-firewallパターン(Entrypointをshell経由に変更してセットアップスクリプトを注入)を踏襲する。 + +```typescript +/** + * .mcp.jsonにbrowserUrlを注入するためのEntrypoint拡張 + * + * 既存のregistry-firewallパターンと同様に、 + * Entrypointをshell経由に変更してセットアップスクリプトを挿入する。 + */ +private injectBrowserUrl( + createOptions: Docker.ContainerCreateOptions, + browserUrl: string +): void { + // browserUrlを環境変数経由で渡す(シェルインジェクション防止) + if (!createOptions.Env) createOptions.Env = []; + createOptions.Env.push(`__CHROME_BROWSER_URL=${browserUrl}`); + + // .mcp.json更新スクリプト + // jqが利用可能な場合はjqを使用、なければNode.jsワンライナーで処理 + const mcpInjectScript = [ + // WorkingDir(リポジトリルート)の.mcp.jsonを対象とする + 'MCP_FILE="${MCP_FILE:-.mcp.json}"', + // .mcp.jsonが存在しない場合は空オブジェクトで初期化 + '[ -f "$MCP_FILE" ] || echo \'{"mcpServers":{}}\' > "$MCP_FILE"', + // Node.jsで.mcp.jsonを更新(jqがない環境を考慮) + 'node -e \'' + + 'const fs = require("fs");' + + 'const f = process.env.MCP_FILE || ".mcp.json";' + + 'const url = process.env.__CHROME_BROWSER_URL;' + + 'if (!url) process.exit(0);' + + 'let cfg = {};' + + 'try { cfg = JSON.parse(fs.readFileSync(f, "utf8")); } catch {}' + + 'if (!cfg.mcpServers) cfg.mcpServers = {};' + + 'if (!cfg.mcpServers["chrome-devtools"]) {' + + 'cfg.mcpServers["chrome-devtools"] = {' + + '"command": "npx",' + + '"args": ["-y", "@anthropic-ai/chrome-devtools-mcp@latest", "--browserUrl=" + url]' + + '};' + + '} else {' + + 'const args = cfg.mcpServers["chrome-devtools"].args || [];' + + 'const idx = args.findIndex(a => a.startsWith("--browserUrl="));' + + 'if (idx >= 0) args[idx] = "--browserUrl=" + url;' + + 'else args.push("--browserUrl=" + url);' + + 'cfg.mcpServers["chrome-devtools"].args = args;' + + '}' + + 'fs.writeFileSync(f, JSON.stringify(cfg, null, 2) + "\\n");' + + '\'', + ].join(' && '); + + // 既存のEntrypoint/Cmdパターンを維持しつつ、前段にスクリプトを追加 + // registry-firewallと同じパターン: shell -c で元コマンドをexec + const originalEntrypoint = createOptions.Entrypoint || []; + const originalCmd = createOptions.Cmd || []; + + // 既にshell経由の場合(registry-firewall等で変換済み) + if ( + Array.isArray(originalEntrypoint) && + originalEntrypoint.length === 2 && + originalEntrypoint[0] === '/bin/sh' && + originalEntrypoint[1] === '-c' && + Array.isArray(originalCmd) && + originalCmd.length > 0 + ) { + // 既存のsetupスクリプトの前にmcp注入を追加 + originalCmd[0] = mcpInjectScript + ' && ' + originalCmd[0]; + } else { + // shell経由に変換 + const allArgs = [ + ...(Array.isArray(originalEntrypoint) ? originalEntrypoint : [originalEntrypoint]), + ...(Array.isArray(originalCmd) ? originalCmd : []), + ].filter(Boolean); + createOptions.Entrypoint = ['/bin/sh', '-c']; + createOptions.Cmd = [ + mcpInjectScript + ' && exec "$@"', + '--', + ...allArgs, + ]; + } +} +``` + +### DockerAdapter.destroySession の拡張 + +```typescript +async destroySession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (session) { + // ... 既存のクリーンアップロジック + + // === サイドカーChrome停止(新規追加) === + // Claude Codeコンテナ停止後にChromeサイドカーを停止 + try { + const sessionRecord = db.select({ + chrome_container_id: schema.sessions.chrome_container_id, + }).from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .get(); + + if (sessionRecord?.chrome_container_id) { + const sidecarService = new ChromeSidecarService(); + await sidecarService.stopSidecar( + sessionId, + sessionRecord.chrome_container_id + ); + + // DB更新: chromeカラムをクリア + db.update(schema.sessions) + .set({ + chrome_container_id: null, + chrome_debug_port: null, + updated_at: new Date(), + }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + } catch (error) { + logger.warn('DockerAdapter: Failed to stop Chrome sidecar', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + // Chrome停止失敗はセッション破棄を妨げない + // 次回サーバー起動時のcleanupOrphanedで回収される + } + + // ... 既存のフィルタリングルールクリーンアップ + } +} +``` + +### AdapterFactory の変更 + +AdapterFactoryの`getDockerAdapter`メソッドにおいて、`chromeSidecar`設定を`DockerAdapterConfig`には含めない。サイドカー設定はセッション作成時のオプションとしてPTYSessionManager経由で渡される。これは既存のfilterEnabled/registryFirewallEnabledと同じパターンである。 + +### PTYSessionManager の変更 + +`PTYSessionManager.createSession`で環境のconfig JSONからchromeSidecar設定を読み取り、`CreateSessionOptions`に渡す。 + +```typescript +// PTYSessionManager.createSession 内 +const envConfig: DockerEnvironmentConfig = JSON.parse(environment.config || '{}'); +const chromeSidecar = envConfig.chromeSidecar?.enabled + ? envConfig.chromeSidecar + : undefined; + +await adapter.createSession(sessionId, worktreePath, initialPrompt, { + ...options, + chromeSidecar, +}); +``` + +## 既存コードへの影響 + +| ファイル | 変更種別 | 影響 | +|---------|---------|------| +| docker-adapter.ts | メソッド拡張 | createSession, destroySessionにサイドカーフェーズ追加 | +| environment-adapter.ts | インターフェース拡張 | CreateSessionOptionsにchromeSidecarフィールド追加 | +| adapter-factory.ts | 変更なし | サイドカー設定はセッション単位オプションで渡す | +| pty-session-manager.ts | 軽微な変更 | chromeSidecar設定の読み取り・転送 | +| environment.ts | 型拡張 | DockerEnvironmentConfigにchromeSidecar追加 | diff --git a/docs/sdd/design/chrome-sidecar/components/environment-ui.md b/docs/sdd/design/chrome-sidecar/components/environment-ui.md new file mode 100644 index 000000000..78a28d927 --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/components/environment-ui.md @@ -0,0 +1,96 @@ +# コンポーネント設計: 環境設定UI拡張 + +## 概要 + +環境設定画面 (`/settings/environments`) にChrome Sidecar設定セクションを追加する。Docker環境の編集フォームにトグルとイメージ設定フィールドを追加し、サイドカーの有効/無効やイメージのカスタマイズを可能にする。 + +## 対応要件 + +REQ-003-001, REQ-003-002, REQ-003-003, REQ-004-002 + +## 変更ファイル + +### 環境設定関連 + +- `src/app/settings/environments/` - 環境設定ページ +- `src/components/environments/` - 環境UIコンポーネント + +### セッション情報表示関連 + +- `src/components/` - セッション詳細・リスト関連コンポーネント + +## 環境設定フォーム拡張 + +### Chrome Sidecarセクション + +環境編集フォームに以下のセクションを追加する。Docker環境 (`type === 'DOCKER'`) の場合のみ表示する。 + +``` ++--------------------------------------------------+ +| Chrome Sidecar | ++--------------------------------------------------+ +| Enable Chrome Sidecar [ OFF ] | +| | +| Chrome Image [ghcr.io/windschord/claude-work-sandbox] | <- disabled when OFF +| Chrome Tag [chrome-devtools ] | <- disabled when OFF +| | +| Note: Chrome Sidecar provides an isolated | +| browser for each session via Chrome DevTools MCP. | ++--------------------------------------------------+ +``` + +### フィールド仕様 + +| フィールド | UI要素 | デフォルト | バリデーション | 非活性条件 | +|-----------|--------|-----------|--------------|-----------| +| Enable Chrome Sidecar | トグルスイッチ | OFF | - | HOST環境 | +| Chrome Image | テキスト入力 | `ghcr.io/windschord/claude-work-sandbox` | `[-a-z0-9._/]+` | トグルOFF | +| Chrome Tag | テキスト入力 | `chrome-devtools` | 空文字禁止、`latest`禁止 | トグルOFF | + +### フロントエンドバリデーション + +- Chrome Tagに`latest`が入力された場合、即座にバリデーションエラーを表示 + - エラーメッセージ: "再現性のためバージョンを固定してください(latestは使用できません)" +- Chrome Imageが空の場合、デフォルト値を使用 +- トグルOFF時もenabled: falseを明示送信する(後方互換性のため) + +## セッション情報へのデバッグポート表示 + +### セッション詳細画面 + +セッション詳細画面にChromeサイドカーの情報を表示する。`chrome_container_id` がNULLでないセッションにのみ表示する。 + +``` ++--------------------------------------------------+ +| Session Info | ++--------------------------------------------------+ +| ...既存情報... | +| | +| Chrome DevTools Debug | +| Status: Running | +| Debug URL: localhost:32789 | ++--------------------------------------------------+ +``` + +### 表示ロジック + +| 条件 | 表示 | +|------|------| +| chrome_container_id がNULL | セクション非表示 | +| chrome_container_id が存在、chrome_debug_port がNULL | "Running (debug port unavailable)" | +| chrome_container_id が存在、chrome_debug_port が存在 | "Running - localhost:" | + +### セッションリスト + +セッションリストのバッジ表示にChromeサイドカーの状態を追加する。 + +| 条件 | バッジ | +|------|--------| +| chrome_container_id がNULL | バッジなし | +| chrome_container_id が存在 | "Chrome" バッジ (色: オレンジ) | + +## REQ-004-003(アクセス制御)について + +現時点でclaude-workには認証/認可機能が実装されていないため、Chrome Debugポートとステータスの表示は全ユーザーに対して行う。将来の認証機能実装時に、セッション所有者/管理者のみに表示を制限する機能を追加する。 + +設計上は、セッションAPIのレスポンスにchrome_debug_portを含めるだけとし、将来の認証機能追加時にAPI層でフィルタリング可能な構造にしておく。 diff --git a/docs/sdd/design/chrome-sidecar/index.md b/docs/sdd/design/chrome-sidecar/index.md new file mode 100644 index 000000000..d01ceb15e --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/index.md @@ -0,0 +1,103 @@ +# 設計書: Chrome Sidecar MCP環境 + +## アーキテクチャ概要 + +Docker環境のセッション起動時に、セッション専用のChromeサイドカーコンテナを自動起動し、Chrome DevTools MCP経由でClaude Codeからブラウザ操作を可能にする。既存のPR#186(単一コンテナ方式: Chromiumを同梱した拡張イメージ)と共存し、環境設定で切り替え可能にする。 + +``` +Session起動フロー (サイドカー有効時): + ++-------------------+ +------------------+ +-------------------+ +| ClaudeWork App |-------->| Docker Engine |-------->| cw-net- | +| (DockerAdapter) | | (Dockerode) | | (Bridge Network) | ++-------------------+ +------------------+ +-------------------+ + | | + +-------+-------+ +------+------+ + | | | | + +-----------+ +-----------+ (接続) (接続) + | Chrome | | Claude | | | + | Container | | Container |-----+ | + | (CDP) | | (sandbox) | | + +-----------+ +-----------+-------------------+ + | + CDP:9222 + (内部通信) +``` + +### 既存アーキテクチャとの関係 + +| 方式 | コンポーネント | 説明 | +|------|--------------|------| +| 単一コンテナ (PR#186) | 拡張Dockerイメージ | Chromiumを同梱、プロセスレベル分離 | +| サイドカー (本設計) | 別コンテナ + ブリッジネットワーク | コンテナレベル分離、独立リソース管理 | + +サイドカーが無効(デフォルト)の場合、従来通りPR#186の単一コンテナ方式で動作する。 + +## コンポーネント一覧 + +| コンポーネント | 説明 | 詳細リンク | +|---------------|------|------------| +| ChromeSidecarService | サイドカーChrome起動・停止・ヘルスチェック | [詳細](components/chrome-sidecar-service.md) | +| DockerAdapter拡張 | サイドカー連携の統合 | [詳細](components/docker-adapter.md) | +| DBスキーマ拡張 | Sessionテーブル・config JSON拡張 | [詳細](components/db-schema.md) | +| 環境設定UI拡張 | サイドカー設定フォーム | [詳細](components/environment-ui.md) | + +## API一覧 + +| エンドポイント | 説明 | 詳細リンク | +|--------------|------|------------| +| Environment API拡張 | chromeSidecar config バリデーション | [詳細](api/environment-api.md) | +| Session API拡張 | chrome_debug_port レスポンス追加 | [詳細](api/session-api.md) | + +## シーケンス図 + +| シーケンス | 説明 | 詳細リンク | +|-----------|------|------------| +| セッション起動 | サイドカー付きセッション起動フロー | [詳細](sequences/session-startup.md) | +| セッション破棄 | サイドカー付きセッション破棄フロー | [詳細](sequences/session-teardown.md) | +| クリーンアップ | 孤立リソースのクリーンアップ | [詳細](sequences/cleanup.md) | + +## 要件トレーサビリティ + +| 要件ID | 設計要素 | 対応方法 | +|--------|---------|---------| +| REQ-001-001 | ChromeSidecarService | ネットワーク作成 + Chromeコンテナ起動 | +| REQ-001-002 | ChromeSidecarService + DockerAdapter | Claude Codeコンテナを同一ネットワークに接続 | +| REQ-001-003 | DockerAdapter拡張 | `.mcp.json`へのbrowserUrl注入 | +| REQ-001-004 | ChromeSidecarService | CDPヘルスチェック (最大30秒)、タイムアウト時はサイドカーなしで起動 | +| REQ-002-001 | DockerAdapter拡張 | destroySessionでChrome停止 -> ネットワーク削除 | +| REQ-002-002 | ChromeSidecarService | サーバー起動時の孤立リソースクリーンアップ | +| REQ-002-003 | DBスキーマ拡張 | chrome_container_id, chrome_debug_portカラム追加 | +| REQ-003-001 | 環境設定UI拡張 | Chrome Sidecarトグル | +| REQ-003-002 | 環境設定UI拡張 | Chrome Image/Tagフィールド | +| REQ-003-003 | DockerAdapter拡張 | chromeSidecar未設定時は既存動作維持 | +| REQ-004-001 | ChromeSidecarService | CDP 9222を127.0.0.1:*に動的マッピング | +| REQ-004-002 | Session API拡張 + UI | chrome_debug_portをレスポンスに含める | +| REQ-004-003 | Session API | 表示は全ユーザーに許可(現状認証機能なし) | +| NFR-SEC-001 | ChromeSidecarService | CapDrop ALL, no-new-privileges, HostIp 127.0.0.1 | +| NFR-SEC-002 | ChromeSidecarService | セッション専用ネットワークで隔離 | +| NFR-RES-001 | ChromeSidecarService | メモリ制限512MB | +| NFR-RES-002 | ChromeSidecarService | CDPヘルスチェック30秒タイムアウト | +| NFR-RES-003 | ChromeSidecarService | サイドカー数をログ記録 | + +## エラーハンドリング方針 + +| エラー種別 | 対応方針 | 根拠 | +|-----------|---------|------| +| ネットワーク作成失敗 | セッション作成をエラーとして中止、作成途中のリソースをロールバック | NFR-SEC-002 | +| Chromeコンテナ起動失敗 | ネットワーク削除後、サイドカーなしでClaude Code起動 | REQ-001-004 | +| CDPヘルスチェックタイムアウト | Chromeコンテナ停止、ネットワーク削除後、サイドカーなしで起動 | NFR-RES-002 | +| ポートマッピング失敗 | サイドカーChromeをホストポートなしで起動、chrome_debug_port=NULL | REQ-004-001 | +| .mcp.json注入失敗 | 警告ログ出力、セッション起動は続行 | graceful degradation | +| Chrome停止失敗(破棄時) | force-kill試行、container_id保持で次回クリーンアップ | 既存パターン踏襲 | +| ネットワーク削除失敗(破棄時) | 警告ログ出力、次回クリーンアップで回収 | REQ-002-002 | + +## 技術的決定事項 + +| 決定 | 選択 | 代替案 | 理由 | +|------|------|--------|------| +| サイドカー管理 | 専用サービスクラス (ChromeSidecarService) | DockerAdapterに直接実装 | 責務分離。DockerAdapterは既に大きく、サイドカーロジックを分離することで保守性向上 | +| ネットワーク管理 | セッション単位Dockerブリッジ | 共有ネットワーク | セッション間の完全隔離 (NFR-SEC-002) | +| .mcp.json注入 | Claude Codeコンテナ起動時にEntrypointスクリプトで注入 | Volume経由でホストから注入 | コンテナ内のリポジトリパスに書き込む必要があるため、コンテナ内実行が自然 | +| CDPヘルスチェック | HTTP GET /json/version をポーリング | docker exec でChrome内部チェック | CDPプロトコル準拠、外部からの到達性を確認できる | +| Chromeイメージ | ghcr.io/windschord/claude-work-sandbox (デフォルト) | browserless/chrome | ライセンス問題なし、軽量、公式イメージ | diff --git a/docs/sdd/design/chrome-sidecar/sequences/cleanup.md b/docs/sdd/design/chrome-sidecar/sequences/cleanup.md new file mode 100644 index 000000000..7866fdee2 --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/sequences/cleanup.md @@ -0,0 +1,123 @@ +# シーケンス図: 孤立リソースのクリーンアップ + +## 概要 + +サーバー起動時に、前回のクラッシュや異常終了で残った孤立Chromeコンテナ・ネットワークをクリーンアップするフロー。 + +## 対応要件 + +REQ-002-002 + +## クリーンアップフロー + +``` +Server起動 ChromeSidecarSvc DockerClient DB + | | | | + | cleanupOrphaned()| | | + |----------------->| | | + | | | | + | | [Phase 1: DBベースのクリーンアップ] | + | | | | + | | SELECT sessions | + | | WHERE chrome_container_id IS NOT NULL + | |-------------------------------------->| + | |<--------------------------------------| + | | | | + | | [各セッションについて] | + | | | | + | | inspectContainer | | + | | (chrome_container_id) | + | |----------------->| | + | | | | + | [コンテナが存在しない or 停止済み] | + | | | | + | | removeNetwork | | + | | (cw-net-) | | + | |----------------->| | + | |<-----------------| | + | | | | + | | UPDATE sessions | + | | SET chrome_container_id = NULL, | + | | chrome_debug_port = NULL | + | |-------------------------------------->| + | | | | + | | [Phase 2: ラベルベースのクリーンアップ]| + | | | | + | | listContainers | | + | | (label: claude-work.chrome-sidecar=true) + | |----------------->| | + | |<-----------------| | + | | | | + | | [DBに対応セッションがないコンテナ] | + | | | | + | | stopContainer | | + | |----------------->| | + | |<-----------------| | + | | | | + | | listNetworks | | + | | (label: claude-work.managed-by=claude-work) + | |----------------->| | + | |<-----------------| | + | | | | + | | [接続コンテナがゼロのネットワーク] | + | | | | + | | removeNetwork | | + | |----------------->| | + | |<-----------------| | + | | | | + | | log: クリーンアップ完了 | + | | (削除コンテナ数, 削除ネットワーク数) | + | | | | + |<-----------------| | | +``` + +## Phase 1: DBベースのクリーンアップ + +DBに記録されたchrome_container_idを起点にクリーンアップする。 + +``` +1. DB SELECT: chrome_container_id IS NOT NULL のセッション一覧取得 +2. 各セッションについて: + a. docker inspect でコンテナの状態確認 + b. コンテナが実行中 → スキップ(正常稼働中) + c. コンテナが存在しない or 停止済み: + i. コンテナが存在すれば停止を試行(AutoRemoveで既に消えている可能性あり) + ii. cw-net- ネットワークを削除 + iii. DB UPDATE: chrome_container_id = NULL, chrome_debug_port = NULL +``` + +## Phase 2: ラベルベースのクリーンアップ + +DBに記録が残っていない(DBも破損した場合等の)孤立リソースをラベルで検出する。 + +``` +1. docker listContainers (label: claude-work.chrome-sidecar=true) +2. 各コンテナについて: + a. ラベルからsession-idを取得 + b. DBに対応するセッションが存在するか確認 + c. 存在しない → コンテナを停止 + +3. docker listNetworks (label: claude-work.managed-by=claude-work, name prefix: cw-net-) +4. 各ネットワークについて: + a. 接続コンテナ数を確認 + b. 接続コンテナがゼロ → ネットワークを削除 +``` + +## クリーンアップ呼び出しタイミング + +サーバー起動時(`server.ts`)で、既存の初期化処理の後に呼び出す。 + +```typescript +// server.ts (起動処理内) +import { ChromeSidecarService } from './services/chrome-sidecar-service'; + +// 既存の初期化処理の後 +const sidecarService = new ChromeSidecarService(); +await sidecarService.cleanupOrphaned(); +``` + +## エラーハンドリング + +- 個々のコンテナ/ネットワークの削除失敗は警告ログを出力し、次のリソースに進む +- Phase全体がDockerデーモン未接続等で失敗した場合は、エラーログを出力するがサーバー起動は妨げない +- クリーンアップはbest-effortであり、失敗してもサーバーの動作に影響しない diff --git a/docs/sdd/design/chrome-sidecar/sequences/session-startup.md b/docs/sdd/design/chrome-sidecar/sequences/session-startup.md new file mode 100644 index 000000000..e46bda25e --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/sequences/session-startup.md @@ -0,0 +1,153 @@ +# シーケンス図: セッション起動 (サイドカー有効) + +## 概要 + +Chrome Sidecarが有効な環境でDockerセッションを作成する際のフロー。 + +## 対応要件 + +REQ-001-001, REQ-001-002, REQ-001-003, REQ-001-004, NFR-RES-002 + +## 正常系フロー + +``` +Client PTYSessionMgr DockerAdapter ChromeSidecarSvc DockerClient + | | | | | + | createSession | | | | + |----------------->| | | | + | | read env config | | | + | | (chromeSidecar) | | | + | | | | | + | | createSession | | | + | |---------------->| | | + | | | | | + | | | startSidecar() | | + | | |----------------->| | + | | | | | + | | | | createNetwork | + | | | | (cw-net-) | + | | | |----------------->| + | | | |<-----------------| + | | | | | + | | | | createContainer | + | | | | (cw-chrome-)| + | | | |----------------->| + | | | |<-----------------| + | | | | | + | | | | startContainer | + | | | |----------------->| + | | | |<-----------------| + | | | | | + | | | | waitForCDP() | + | | | | (max 30s) | + | | | |~~~~ polling ~~~~>| + | | | |<~~~~ 200 OK ~~~~| + | | | | | + | | | | inspectContainer | + | | | | (get debug port) | + | | | |----------------->| + | | | |<-----------------| + | | | | | + | | |<-- SidecarResult | | + | | | (success=true) | | + | | | | | + | | | buildContainerOptions | + | | | + injectBrowserUrl | + | | | | | + | | | createContainer | | + | | | (Claude Code) | | + | | |---------------------------------------->| + | | |<----------------------------------------| + | | | | | + | | | startContainer | | + | | |---------------------------------------->| + | | |<----------------------------------------| + | | | | | + | | | connectClaudeContainer | + | | |----------------->| | + | | | | connectToNetwork | + | | | | (cw-net-) | + | | | |----------------->| + | | | |<-----------------| + | | |<----------------| | + | | | | | + | | | DB update | | + | | | (chrome_container_id, | + | | | chrome_debug_port) | + | | | | | + | |<----------------| | | + |<-----------------| | | | +``` + +## タイムアウト時のフロー (CDPヘルスチェック失敗) + +CDPヘルスチェックがタイムアウトした場合、サイドカーのリソースをクリーンアップし、 +サイドカーなしでClaude Codeを起動する(graceful degradation)。 + +``` +DockerAdapter ChromeSidecarSvc DockerClient + | | | + | startSidecar() | | + |----------------->| | + | | createNetwork | + | |----------------->| + | |<-----------------| + | | | + | | createContainer | + | |----------------->| + | |<-----------------| + | | | + | | startContainer | + | |----------------->| + | |<-----------------| + | | | + | | waitForCDP() | + | | (30s timeout) | + | |~~~~ polling ~~~~>| + | | ... 30秒経過 ...| + | | | + | | -- TIMEOUT -- | + | | | + | | stopContainer | + | | (Chrome) | + | |----------------->| + | |<-----------------| + | | | + | | removeNetwork | + | |----------------->| + | |<-----------------| + | | | + |<-- SidecarResult | | + | (success=false)| | + | | | + | [サイドカーなしでClaude Code起動] | + | buildContainerOptions (通常) | + | createContainer (Claude Code) | + |---------------------------------------->| +``` + +## ネットワーク作成失敗時のフロー + +ネットワーク作成失敗もサイドカー起動失敗として扱い、サイドカーなしでClaude Codeを起動する(graceful degradation)。 + +注: 現在の実装では、ネットワーク作成失敗を含むすべてのサイドカー起動失敗はgraceful degradationで処理される。DockerAdapterはstartSidecarの戻り値 `success: false` を受け取り、警告ログを出力してサイドカーなしでClaude Codeを起動する。 + +``` +DockerAdapter ChromeSidecarSvc DockerClient + | | | + | startSidecar() | | + |----------------->| | + | | createNetwork | + | |----------------->| + | |<-- ERROR --------| + | | | + |<-- SidecarResult | | + | (success=false,| | + | error=msg) | | + | | | + | [警告ログを出力] | + | [サイドカーなしでClaude Code起動] | + | buildContainerOptions (通常) | + | createContainer (Claude Code) | + |---------------------------------------->| +``` diff --git a/docs/sdd/design/chrome-sidecar/sequences/session-teardown.md b/docs/sdd/design/chrome-sidecar/sequences/session-teardown.md new file mode 100644 index 000000000..e2fe040f2 --- /dev/null +++ b/docs/sdd/design/chrome-sidecar/sequences/session-teardown.md @@ -0,0 +1,99 @@ +# シーケンス図: セッション破棄 (サイドカー付き) + +## 概要 + +Chrome Sidecarが付いたセッションの破棄フロー。破棄順序は依存関係に基づく: Claude Code停止 -> Chrome停止 -> ネットワーク削除。 + +## 対応要件 + +REQ-002-001, REQ-002-003 + +## 正常系フロー + +``` +Client PTYSessionMgr DockerAdapter ChromeSidecarSvc DockerClient DB + | | | | | | + | destroySession | | | | | + |----------------->| | | | | + | | destroySession | | | | + | |---------------->| | | | + | | | | | | + | | | [1] Claude Code PTY kill | | + | | | sessions.delete | | | + | | | ptyProcess.kill() | | | + | | | | | | + | | | [2] Claude Codeコンテナ停止 | | + | | | stopContainer | | | + | | |---------------------------------------->| | + | | |<----------------------------------------| | + | | | | | | + | | | [3] DB: container_id = NULL | | + | | |------------------------------------------------------>| + | | | | | | + | | | [4] DB: chrome_container_id読取 | | + | | |------------------------------------------------------>| + | | |<------------------------------------------------------| + | | | | | | + | | | [5] Chrome停止 | | | + | | | stopSidecar() | | | + | | |----------------->| | | + | | | | | | + | | | | [5a] stopContainer | + | | | | (Chrome, AutoRemove=true) | + | | | |----------------->| | + | | | |<-----------------| | + | | | | | | + | | | | [5b] disconnectNetwork | + | | | | (Claude Codeを先にdisconnect) | + | | | |----------------->| | + | | | |<-----------------| | + | | | | | | + | | | | [5c] removeNetwork | + | | | | (cw-net-) | | + | | | |----------------->| | + | | | |<-----------------| | + | | | | | | + | | |<----------------| | | + | | | | | | + | | | [6] DB更新 | | | + | | | chrome_container_id = NULL | | + | | | chrome_debug_port = NULL | | + | | |------------------------------------------------------>| + | | | | | | + | | | [7] proxyルールクリーンアップ | | + | | | (既存処理、best-effort) | | + | | | | | | + | |<----------------| | | | + |<-----------------| | | | | +``` + +## Chrome停止失敗時のフロー + +``` +DockerAdapter ChromeSidecarSvc DockerClient DB + | | | | + | [4-5] Chrome停止 | | | + | stopSidecar() | | | + |----------------->| | | + | | stopContainer | | + | |----------------->| | + | |<-- ERROR --------| | + | | | | + | | force-kill | | + | |----------------->| | + | |<-- ERROR --------| | + | | | | + |<-- warn log -----| | | + | | | | + | [Chrome停止失敗でもセッション破棄は続行] | + | [chrome_container_idは保持、次回cleanupOrphanedで回収] | + | | | | + | DB: container_id = NULL (Claude Code側のみクリア) | + |------------------------------------------------------>| +``` + +## 備考 + +- AutoRemove=true により、Chromeコンテナは停止と同時に自動削除される。明示的なremoveContainer呼び出しは不要 +- ネットワーク削除前に、接続中の全コンテナをdisconnectする必要がある(Claude Codeコンテナが既に停止済みの場合は不要だが、念のためdisconnectを試行する) +- Chrome停止失敗はセッション破棄を妨げない(best-effort)。失敗した場合はcontainer_idを保持し、サーバー起動時のcleanupOrphanedで回収する diff --git a/docs/sdd/requirements/chrome-sidecar/index.md b/docs/sdd/requirements/chrome-sidecar/index.md index 321366a23..c35018ab1 100644 --- a/docs/sdd/requirements/chrome-sidecar/index.md +++ b/docs/sdd/requirements/chrome-sidecar/index.md @@ -45,7 +45,7 @@ claude-workのDockerセッションにおいて、セッション単位で独立 - **EnvironmentService**: 環境CRUD(src/services/environment-service.ts) - **chrome-devtools-mcp v0.12.1**: `--browserUrl`オプションによる外部Chrome接続 - **Dockerode**: Docker APIクライアント(既存依存) -- **chromium公式イメージ**: chromium/headless-shell等 +- **chromium公式イメージ**: ghcr.io/windschord/claude-work-sandbox等 ## スコープ外 diff --git a/docs/sdd/requirements/chrome-sidecar/stories/US-003.md b/docs/sdd/requirements/chrome-sidecar/stories/US-003.md index b35a72d96..17a7ab330 100644 --- a/docs/sdd/requirements/chrome-sidecar/stories/US-003.md +++ b/docs/sdd/requirements/chrome-sidecar/stories/US-003.md @@ -23,8 +23,8 @@ | フィールド | 型 | デフォルト | 説明 | |-----------|------|-----------|------| | Chrome Sidecar | トグル | OFF | サイドカーChrome有効/無効 | -| Chrome Image | テキスト | chromium/headless-shell | Chromeコンテナイメージ名 | -| Chrome Tag | テキスト | 131.0.6778.204 | Chromeイメージタグ。再現性のため固定バージョンを必須とする(`latest`禁止) | +| Chrome Image | テキスト | ghcr.io/windschord/claude-work-sandbox | Chromeコンテナイメージ名 | +| Chrome Tag | テキスト | chrome-devtools | Chromeイメージタグ。再現性のため固定バージョンを必須とする(`latest`禁止) | - サイドカーがOFFの場合、Chrome Image/Tagフィールドは非活性(disabled) - Docker環境でのみ表示(HOST環境では非表示) @@ -39,8 +39,8 @@ "imageTag": "latest", "chromeSidecar": { "enabled": false, - "image": "chromium/headless-shell", - "tag": "131.0.6778.204" + "image": "ghcr.io/windschord/claude-work-sandbox", + "tag": "chrome-devtools" } } ``` @@ -52,7 +52,7 @@ ## 備考 - 既存のDocker環境設定との後方互換性を維持(chromeSidecarキーが存在しない場合はOFFとして扱う) -- Chromeイメージタグは再現性と障害切り分けのため固定バージョン(例: `131.0.6778.204`)を必須とする。`latest` の使用はバリデーションエラーとして拒否し、明示的な更新運用を前提とする +- Chromeイメージタグは再現性と障害切り分けのため固定バージョン(例: `chrome-devtools`)を必須とする。`latest` の使用はバリデーションエラーとして拒否し、明示的な更新運用を前提とする ### config JSONバリデーションルール diff --git a/docs/sdd/tasks/chrome-sidecar/index.md b/docs/sdd/tasks/chrome-sidecar/index.md new file mode 100644 index 000000000..5f78b1052 --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/index.md @@ -0,0 +1,81 @@ +# タスク: Chrome Sidecar MCP環境 + +## 概要 + +Docker環境のセッション起動時に、セッション専用のChromeサイドカーコンテナを自動起動し、Chrome DevTools MCP経由でClaude Codeからブラウザ操作を可能にする。 + +- 要件定義: [docs/sdd/requirements/chrome-sidecar/](../../requirements/chrome-sidecar/index.md) +- 設計書: [docs/sdd/design/chrome-sidecar/](../../design/chrome-sidecar/index.md) + +## タスク一覧 + +| ID | タイトル | ステータス | 依存 | 推定工数 | 詳細 | +|----|---------|-----------|------|---------|------| +| TASK-001 | DBスキーマ拡張 (Sessionテーブル) | TODO | - | 20min | [詳細](phase-1/TASK-001.md) | +| TASK-002 | 型定義拡張 (DockerEnvironmentConfig, CreateSessionOptions) | TODO | - | 15min | [詳細](phase-1/TASK-002.md) | +| TASK-003 | ChromeSidecarService | TODO | TASK-001 | 60min | [詳細](phase-2/TASK-003.md) | +| TASK-004 | Environment APIバリデーション | TODO | TASK-002 | 25min | [詳細](phase-2/TASK-004.md) | +| TASK-005 | DockerAdapter拡張 (createSession / destroySession) | TODO | TASK-001, TASK-002, TASK-003 | 50min | [詳細](phase-3/TASK-005.md) | +| TASK-006 | Session API拡張 | TODO | TASK-001 | 20min | [詳細](phase-3/TASK-006.md) | +| TASK-007 | 環境設定UI拡張 (Chrome Sidecarセクション) | TODO | TASK-002, TASK-004 | 35min | [詳細](phase-4/TASK-007.md) | +| TASK-008 | セッションUI拡張 (デバッグポート表示・Chromeバッジ) | TODO | TASK-006 | 30min | [詳細](phase-4/TASK-008.md) | +| TASK-009 | サーバー起動時クリーンアップ統合 | TODO | TASK-003, TASK-005 | 20min | [詳細](phase-4/TASK-009.md) | + +## 依存関係図 + +``` +Phase 1 (基盤 - 並列実行可能) + TASK-001: DBスキーマ拡張 + TASK-002: 型定義拡張 + +Phase 2 (サービス・API - Phase 1完了後、並列実行可能) + TASK-003: ChromeSidecarService [depends: TASK-001] + TASK-004: Environment APIバリデーション [depends: TASK-002] + +Phase 3 (統合 - Phase 2完了後、並列実行可能) + TASK-005: DockerAdapter拡張 [depends: TASK-001, TASK-002, TASK-003] + TASK-006: Session API拡張 [depends: TASK-001] + +Phase 4 (UI・統合 - Phase 3完了後、並列実行可能) + TASK-007: 環境設定UI拡張 [depends: TASK-002, TASK-004] + TASK-008: セッションUI拡張 [depends: TASK-006] + TASK-009: サーバー起動時クリーンアップ [depends: TASK-003, TASK-005] +``` + +## 並列実行グループ + +### グループA (Phase 1: 基盤 - 並列実行可能) + +| タスク | 対象ファイル | 依存 | +|--------|-------------|------| +| TASK-001 | src/db/schema.ts, drizzle/ | なし | +| TASK-002 | src/types/environment.ts, src/services/environment-adapter.ts | なし | + +### グループB (Phase 2: サービス・API - グループA完了後に並列実行可能) + +| タスク | 対象ファイル | 依存 | +|--------|-------------|------| +| TASK-003 | src/services/chrome-sidecar-service.ts + テスト | TASK-001 | +| TASK-004 | src/app/api/environments/ + テスト | TASK-002 | + +### グループC (Phase 3: 統合 - グループB完了後に並列実行可能) + +| タスク | 対象ファイル | 依存 | +|--------|-------------|------| +| TASK-005 | src/services/adapters/docker-adapter.ts, src/services/pty-session-manager.ts + テスト | TASK-001, TASK-002, TASK-003 | +| TASK-006 | src/app/api/sessions/ + テスト | TASK-001 | + +### グループD (Phase 4: UI・統合仕上げ - グループC完了後に並列実行可能) + +| タスク | 対象ファイル | 依存 | +|--------|-------------|------| +| TASK-007 | src/components/environments/, src/app/settings/environments/ | TASK-002, TASK-004 | +| TASK-008 | src/components/ (セッション関連) | TASK-006 | +| TASK-009 | server.ts | TASK-003, TASK-005 | + +## 進捗サマリ + +- 全タスク: 9 +- 完了: 0 +- 進行中: 0 +- TODO: 9 diff --git a/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-001.md b/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-001.md new file mode 100644 index 000000000..e995e9b47 --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-001.md @@ -0,0 +1,60 @@ +# TASK-001: DBスキーマ拡張 (Sessionテーブル) + +## 説明 + +SessionテーブルにChromeサイドカー関連のカラム (`chrome_container_id`, `chrome_debug_port`) を追加し、Drizzleマイグレーションを生成する。 + +**対象ファイル**: +- `src/db/schema.ts` - Sessionテーブルにカラム追加 +- `drizzle/` - マイグレーションファイル(自動生成) + +**設計書**: `docs/sdd/design/chrome-sidecar/components/db-schema.md` + +## 技術的文脈 + +- 既存の `container_id` カラムの直後に配置 +- 両カラムともNULL許可(既存レコードとの後方互換性) +- `chrome_container_id`: TEXT型 (`cw-chrome-` 形式) +- `chrome_debug_port`: INTEGER型(ホスト側にマッピングされたポート番号) + +## TDD手順 + +### テストファイル + +`src/db/__tests__/schema-chrome-sidecar.test.ts` + +### テストケース + +1. **スキーマ定義の検証** + - sessions テーブルに `chrome_container_id` カラムが存在すること + - sessions テーブルに `chrome_debug_port` カラムが存在すること + +2. **NULL許可の検証** + - `chrome_container_id` がNULL状態でレコード挿入できること + - `chrome_debug_port` がNULL状態でレコード挿入できること + +3. **値の設定・取得の検証** + - `chrome_container_id` に文字列値を設定・取得できること + - `chrome_debug_port` に整数値を設定・取得できること + +4. **既存レコードへの影響なしの検証** + - chrome関連カラムなしで作成されたセッションの読み取りでNULLが返ること + +### 実装手順 + +1. テストファイル作成・テスト実行(全件RED確認) +2. `src/db/schema.ts` の sessions テーブル定義にカラム追加 +3. `npm run db:generate` でマイグレーション生成 +4. テスト実行(全件GREEN確認) + +## 受入基準 + +- [ ] `src/db/schema.ts` に `chrome_container_id` (TEXT, NULL許可) が追加されている +- [ ] `src/db/schema.ts` に `chrome_debug_port` (INTEGER, NULL許可) が追加されている +- [ ] `drizzle/` にマイグレーションファイルが生成されている +- [ ] 既存テストが壊れていないこと +- [ ] 新規テストが全てパスすること + +**依存関係**: なし +**推定工数**: 20分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-002.md b/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-002.md new file mode 100644 index 000000000..441ad151e --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-1/TASK-002.md @@ -0,0 +1,61 @@ +# TASK-002: 型定義拡張 (DockerEnvironmentConfig, CreateSessionOptions) + +## 説明 + +`DockerEnvironmentConfig` に `chromeSidecar` 設定オブジェクトの型定義を追加し、`CreateSessionOptions` にサイドカー設定フィールドを追加する。 + +**対象ファイル**: +- `src/types/environment.ts` - DockerEnvironmentConfigにchromeSidecar型追加 +- `src/services/environment-adapter.ts` - CreateSessionOptionsにchromeSidecarフィールド追加 + +**設計書**: +- `docs/sdd/design/chrome-sidecar/components/db-schema.md` (型定義部分) +- `docs/sdd/design/chrome-sidecar/components/docker-adapter.md` (CreateSessionOptions部分) + +## 技術的文脈 + +- `ChromeSidecarConfig` インターフェース: `{ enabled: boolean; image: string; tag: string; }` +- `DockerEnvironmentConfig.chromeSidecar` はオプショナル(後方互換性) +- `CreateSessionOptions.chromeSidecar` もオプショナル +- `chromeSidecar` キーが存在しない場合は `enabled: false` として扱う + +## TDD手順 + +### テストファイル + +`src/types/__tests__/chrome-sidecar-config.test.ts` + +### テストケース + +1. **ChromeSidecarConfig型の検証** + - enabled, image, tagフィールドを持つオブジェクトが型に適合すること + - 必須フィールドが欠けている場合のTypeScriptコンパイルエラー確認(型テスト) + +2. **DockerEnvironmentConfig後方互換性の検証** + - chromeSidecarキーなしのconfigが有効であること + - chromeSidecarキーありのconfigが有効であること + - 既存のimageName, imageTag等のフィールドが影響を受けないこと + +3. **CreateSessionOptions拡張の検証** + - chromeSidecarフィールドなしのオプションが有効であること + - chromeSidecarフィールドありのオプションが有効であること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. `src/types/environment.ts` に `ChromeSidecarConfig` インターフェースを追加 +3. `DockerEnvironmentConfig` に `chromeSidecar?: ChromeSidecarConfig` を追加 +4. `src/services/environment-adapter.ts` の `CreateSessionOptions` に `chromeSidecar?: ChromeSidecarConfig` を追加 +5. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] `ChromeSidecarConfig` インターフェースがexportされている +- [ ] `DockerEnvironmentConfig.chromeSidecar` がオプショナルフィールドとして追加されている +- [ ] `CreateSessionOptions.chromeSidecar` がオプショナルフィールドとして追加されている +- [ ] 既存のDockerEnvironmentConfig利用箇所でコンパイルエラーが発生しないこと +- [ ] 既存テストが壊れていないこと + +**依存関係**: なし +**推定工数**: 15分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-003.md b/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-003.md new file mode 100644 index 000000000..cf0d7bd01 --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-003.md @@ -0,0 +1,122 @@ +# TASK-003: ChromeSidecarService + +## 説明 + +Chromeサイドカーコンテナのライフサイクル(起動・ヘルスチェック・停止・クリーンアップ)を管理する `ChromeSidecarService` を新規作成する。 + +**対象ファイル**: +- `src/services/chrome-sidecar-service.ts` - 新規作成 +- `src/services/__tests__/chrome-sidecar-service.test.ts` - テスト + +**設計書**: `docs/sdd/design/chrome-sidecar/components/chrome-sidecar-service.md` + +## 技術的文脈 + +- DockerClientを使用してDocker APIと通信 +- セッション専用ブリッジネットワーク (`cw-net-`) を作成 +- Chromeコンテナ (`cw-chrome-`) をネットワーク内に起動 +- CDPヘルスチェック(HTTP GET /json/version、最大30秒ポーリング) +- セキュリティ: CapDrop ALL, no-new-privileges, メモリ制限512MB +- ポートマッピング: CDP 9222を127.0.0.1:*に動的マッピング +- Dockerラベル: `claude-work.session-id`, `claude-work.chrome-sidecar`, `claude-work.managed-by` + +## TDD手順 + +### テストファイル + +`src/services/__tests__/chrome-sidecar-service.test.ts` + +### テストケース + +#### startSidecar + +1. **正常系: サイドカー起動成功** + - ネットワーク作成 -> コンテナ作成 -> コンテナ起動 -> CDPヘルスチェック成功の順で呼ばれること + - SidecarStartResult が `success: true` で、containerName, networkName, debugPort, browserUrl が設定されること + - コンテナ作成オプションに正しいラベル、CapDrop, SecurityOpt, Memory が設定されること + +2. **正常系: ポートマッピングからdebugPort取得** + - container.inspect() の結果からホスト側ポート番号を正しく抽出すること + - browserUrl が `ws://cw-chrome-:9222` 形式であること + +3. **異常系: ネットワーク作成失敗** + - `success: false` が返り、error にエラーメッセージが含まれること + - コンテナ作成が呼ばれないこと(ロールバック) + +4. **異常系: CDPヘルスチェックタイムアウト** + - 30秒経過後に `success: false` が返ること + - Chromeコンテナ停止とネットワーク削除が呼ばれること(クリーンアップ) + +5. **異常系: コンテナ起動失敗** + - ネットワーク削除が呼ばれること(ロールバック) + - `success: false` が返ること + +6. **異常系: ポートマッピング失敗** + - `success: true` かつ `debugPort: undefined` が返ること + - Chromeコンテナ自体は起動成功として扱うこと + +#### stopSidecar + +7. **正常系: サイドカー停止成功** + - コンテナ停止 -> ネットワークからdisconnect -> ネットワーク削除の順で呼ばれること + +8. **異常系: コンテナ停止失敗** + - 警告ログが出力されること + - 例外がスローされないこと(best-effort) + +9. **異常系: ネットワーク削除失敗** + - 警告ログが出力されること + - 例外がスローされないこと + +#### connectClaudeContainer + +10. **正常系: Claude Codeコンテナをネットワークに接続** + - docker network connect が正しいパラメータで呼ばれること + +11. **異常系: 接続失敗** + - 例外がスローされること(呼び出し元でハンドリング) + +#### cleanupOrphaned + +12. **Phase 1: DBベースのクリーンアップ** + - chrome_container_id IS NOT NULL のセッションを取得すること + - 停止済みコンテナのネットワークを削除し、DBをNULL更新すること + - 実行中コンテナはスキップすること + +13. **Phase 2: ラベルベースのクリーンアップ** + - `claude-work.chrome-sidecar=true` ラベルのコンテナ一覧を取得すること + - DBに対応セッションがないコンテナを停止すること + - 接続コンテナがゼロのネットワークを削除すること + +#### getActiveSidecarCount + +14. **アクティブサイドカー数取得** + - `claude-work.chrome-sidecar=true` ラベルで実行中コンテナ数を返すこと + +### 実装手順 + +1. テストファイル作成(DockerClient, db をモック) +2. テスト実行(全件RED確認) +3. `src/services/chrome-sidecar-service.ts` を作成 + - SidecarStartResult, ChromeSidecarConfig のインポート + - startSidecar: ネットワーク作成 -> コンテナ作成・起動 -> CDPヘルスチェック -> ポート取得 + - stopSidecar: コンテナ停止 -> ネットワーク削除 + - connectClaudeContainer: ネットワーク接続 + - cleanupOrphaned: DB + ラベルベースのクリーンアップ + - getActiveSidecarCount: ラベルベースのカウント +4. テスト実行(全件GREEN確認) + +## 受入基準 + +- [ ] `ChromeSidecarService` クラスがexportされている +- [ ] `startSidecar` がセキュリティ設定付きでChromeコンテナを起動できること +- [ ] CDPヘルスチェックが30秒タイムアウトで動作すること +- [ ] ネットワーク作成失敗時にコンテナが作成されないこと +- [ ] CDPタイムアウト時にリソースがクリーンアップされること +- [ ] `stopSidecar` がbest-effortで動作すること +- [ ] `cleanupOrphaned` がDB + ラベルベースで孤立リソースを検出・削除すること +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-001 (DBスキーマ: chrome_container_id, chrome_debug_port) +**推定工数**: 60分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-004.md b/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-004.md new file mode 100644 index 000000000..07dcee22b --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-2/TASK-004.md @@ -0,0 +1,77 @@ +# TASK-004: Environment APIバリデーション + +## 説明 + +環境APIの作成・更新エンドポイント (`POST /api/environments`, `PUT /api/environments/:id`) に `chromeSidecar` config のバリデーションロジックを追加する。 + +**対象ファイル**: +- `src/app/api/environments/route.ts` - POSTエンドポイントにバリデーション追加 +- `src/app/api/environments/[id]/route.ts` - PUTエンドポイントにバリデーション追加 +- `src/app/api/environments/__tests__/` - テスト + +**設計書**: `docs/sdd/design/chrome-sidecar/api/environment-api.md` + +## 技術的文脈 + +- chromeSidecarキーはオプション(省略時は enabled: false として扱う) +- enabled: true の場合のみ image, tag のバリデーションを実行 +- image: `[-a-z0-9._/]+` パターンに適合する文字列 +- tag: 空文字不可、`latest` 不可(再現性のためバージョン固定必須) +- HOST環境の場合は chromeSidecar が含まれていても無視(エラーにはしない) + +## TDD手順 + +### テストファイル + +`src/app/api/environments/__tests__/chrome-sidecar-validation.test.ts` + +### テストケース + +1. **chromeSidecarキー省略時: バリデーション成功** + - configにchromeSidecarがない場合、既存動作と同じく成功すること + +2. **enabled: false の場合: image/tagバリデーションスキップ** + - enabled: false であれば image, tag の内容に関わらず成功すること + +3. **正常な設定: バリデーション成功** + - `{ enabled: true, image: "ghcr.io/windschord/claude-work-sandbox", tag: "chrome-devtools" }` が成功すること + +4. **enabled が boolean でない場合: 400エラー** + - `enabled: "true"` や `enabled: 1` でエラーが返ること + - エラーメッセージに "chromeSidecar.enabled" が含まれること + +5. **image が不正な場合: 400エラー** + - 空文字列、大文字を含む文字列、特殊文字を含む文字列でエラーが返ること + - エラーメッセージに "chromeSidecar.image" が含まれること + +6. **tag が空文字の場合: 400エラー** + - 空文字列でエラーが返ること + +7. **tag が "latest" の場合: 400エラー** + - "latest" で専用のエラーメッセージが返ること + +8. **HOST環境でchromeSidecarが含まれる場合: エラーなし** + - HOST環境のconfig内にchromeSidecarが存在しても無視されること + +9. **PUTエンドポイントでも同じバリデーションが適用されること** + - PUT /api/environments/:id に不正なchromeSidecar設定を送信して400が返ること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. `validateChromeSidecarConfig` 関数を実装(設計書のロジックに従う) +3. POST/PUT エンドポイントのバリデーション処理に組み込み +4. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] chromeSidecarキー省略時にバリデーションエラーが発生しないこと +- [ ] enabled: true 時に image, tag の正規表現バリデーションが動作すること +- [ ] tag: "latest" が明確に拒否されること +- [ ] HOST環境での chromeSidecar 設定が無視されること(エラーにならない) +- [ ] POST と PUT の両エンドポイントで同じバリデーションが適用されること +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-002 (型定義: ChromeSidecarConfig) +**推定工数**: 25分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-005.md b/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-005.md new file mode 100644 index 000000000..94797f8ac --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-005.md @@ -0,0 +1,108 @@ +# TASK-005: DockerAdapter拡張 (createSession / destroySession) + +## 説明 + +DockerAdapterの`createSession`と`destroySession`を拡張し、ChromeSidecarServiceとの統合を行う。サイドカー起動、.mcp.json注入、ネットワーク接続、サイドカー停止のフローを既存のセッションライフサイクルに組み込む。 + +**対象ファイル**: +- `src/services/adapters/docker-adapter.ts` - createSession / destroySession 拡張、injectBrowserUrl メソッド追加 +- `src/services/pty-session-manager.ts` - chromeSidecar設定の読み取り・転送 +- `src/services/adapters/__tests__/docker-adapter-chrome-sidecar.test.ts` - テスト + +**設計書**: `docs/sdd/design/chrome-sidecar/components/docker-adapter.md` + +## 技術的文脈 + +- createSession: サイドカー起動フェーズを既存処理の前段に追加 +- サイドカー成功時: .mcp.json注入(Entrypointをshell経由に変更)、ネットワーク接続、DB更新 +- サイドカー失敗時(CDPタイムアウト等): サイドカーなしでClaude Code起動(graceful degradation) +- ネットワーク作成失敗時: セッション作成自体をエラーとして中止(NFR-SEC-002) +- destroySession: Claude Code停止後にChromeサイドカー停止(best-effort) +- .mcp.json注入は既存のregistry-firewallパターン(Entrypointをshell経由に変更)を踏襲 +- PTYSessionManager: 環境configからchromeSidecar設定を読み取りCreateSessionOptionsに渡す + +## TDD手順 + +### テストファイル + +`src/services/adapters/__tests__/docker-adapter-chrome-sidecar.test.ts` + +### テストケース + +#### createSession 拡張 + +1. **サイドカー無効時: 既存動作と同一** + - chromeSidecarオプションなしの場合、ChromeSidecarServiceが呼ばれないこと + - 既存のコンテナ作成フローが変わらないこと + +2. **サイドカー有効・起動成功: フルフロー** + - startSidecar -> buildContainerOptions + injectBrowserUrl -> コンテナ作成 -> connectClaudeContainer -> DB更新の順で実行されること + - DB に chrome_container_id, chrome_debug_port が設定されること + +3. **サイドカー有効・CDPタイムアウト: graceful degradation** + - startSidecar が success: false を返した場合、サイドカーなしでClaude Codeが起動すること + - 警告ログが出力されること + - DB の chrome_container_id は NULL のままであること + +4. **ネットワーク作成失敗: セッション作成中止** + - ChromeSidecarService.startSidecarがネットワーク作成失敗を返した場合 + - セッション作成がエラーとして中止されること(NFR-SEC-002) + +5. **connectClaudeContainer失敗: Claude Code起動は続行** + - ネットワーク接続失敗時、Claude Code自体は起動済みなので続行すること + - 警告ログが出力されること + +#### injectBrowserUrl + +6. **新規Entrypoint設定(shell経由への変換)** + - 元のEntrypoint/Cmdがshell経由でない場合、`/bin/sh -c` に変換されること + - `__CHROME_BROWSER_URL` 環境変数が追加されること + - .mcp.json更新スクリプトが Cmd に含まれること + +7. **既存shell Entrypoint拡張(registry-firewall等で変換済み)** + - 元のEntrypointが `/bin/sh -c` の場合、既存スクリプトの前段にmcp注入を追加すること + +#### destroySession 拡張 + +8. **サイドカー付きセッション破棄: 正常系** + - Claude Code停止後にstopSidecarが呼ばれること + - DB の chrome_container_id, chrome_debug_port が NULL に更新されること + +9. **サイドカーなしセッション破棄: 既存動作と同一** + - chrome_container_id が NULL のセッションでは stopSidecar が呼ばれないこと + +10. **Chrome停止失敗: セッション破棄は続行** + - stopSidecar失敗時も例外がスローされないこと + - 警告ログが出力されること + - chrome_container_id は保持されること(次回クリーンアップで回収) + +#### PTYSessionManager + +11. **chromeSidecar設定の転送** + - 環境configから `chromeSidecar.enabled: true` の場合、CreateSessionOptionsに設定が渡されること + - `chromeSidecar.enabled: false` または未設定の場合、undefinedが渡されること + +### 実装手順 + +1. テストファイル作成(ChromeSidecarService, DockerClient, db をモック) +2. テスト実行(RED確認) +3. `docker-adapter.ts` を拡張: + - createSession にサイドカー起動フェーズ追加 + - injectBrowserUrl private メソッド追加 + - destroySession にサイドカー停止フェーズ追加 +4. `pty-session-manager.ts` にchromeSidecar設定読み取りロジック追加 +5. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] サイドカー無効時に既存動作が変わらないこと +- [ ] サイドカー有効時にChromeコンテナ起動 -> .mcp.json注入 -> ネットワーク接続の完全フローが動作すること +- [ ] CDPタイムアウト時にサイドカーなしでClaude Codeが起動すること +- [ ] ネットワーク作成失敗時にセッション作成が中止されること +- [ ] destroySessionでChromeサイドカーがbest-effortで停止されること +- [ ] 既存のDockerAdapter テストが壊れていないこと +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-001 (DBスキーマ), TASK-002 (型定義), TASK-003 (ChromeSidecarService) +**推定工数**: 50分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-006.md b/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-006.md new file mode 100644 index 000000000..5fb024794 --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-3/TASK-006.md @@ -0,0 +1,60 @@ +# TASK-006: Session API拡張 + +## 説明 + +セッションAPIのレスポンスに `chrome_container_id` と `chrome_debug_port` フィールドを追加する。 + +**対象ファイル**: +- `src/app/api/sessions/[id]/route.ts` - GET レスポンスにchromeフィールド追加 +- `src/app/api/projects/[id]/sessions/route.ts` - GET レスポンスにchromeフィールド追加 +- `src/app/api/sessions/__tests__/chrome-sidecar-response.test.ts` - テスト + +**設計書**: `docs/sdd/design/chrome-sidecar/api/session-api.md` + +## 技術的文脈 + +- 既存のセッションレスポンスにフィールドを追加するだけの軽微な変更 +- DBスキーマにカラムが追加された後、SELECTの結果に自動的に含まれる +- Drizzle ORMの`select()`は明示的にカラムを指定している場合のみ変更が必要 +- 現在認証機能が存在しないため、全ユーザーに chrome_debug_port を返す + +## TDD手順 + +### テストファイル + +`src/app/api/sessions/__tests__/chrome-sidecar-response.test.ts` + +### テストケース + +1. **GET /api/sessions/:id - サイドカーあり** + - chrome_container_id と chrome_debug_port がレスポンスに含まれること + - 値がDB上の値と一致すること + +2. **GET /api/sessions/:id - サイドカーなし** + - chrome_container_id が null であること + - chrome_debug_port が null であること + +3. **GET /api/projects/:id/sessions - セッション一覧** + - 各セッションに chrome_container_id, chrome_debug_port が含まれること + - サイドカーありセッションとなしセッションが混在する場合に正しく返ること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. セッションAPI のレスポンス構築部分を確認 + - Drizzle の select() でカラムが明示的に列挙されている場合は追加 + - `select()` が全カラムの場合は変更不要(スキーマ変更で自動的に含まれる) +3. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] GET /api/sessions/:id のレスポンスに chrome_container_id が含まれること +- [ ] GET /api/sessions/:id のレスポンスに chrome_debug_port が含まれること +- [ ] GET /api/projects/:id/sessions の各セッションにも同様のフィールドが含まれること +- [ ] サイドカーなしセッションでは null が返ること +- [ ] 既存テストが壊れていないこと +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-001 (DBスキーマ: chrome_container_id, chrome_debug_port) +**推定工数**: 20分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-007.md b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-007.md new file mode 100644 index 000000000..c1c01fcaa --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-007.md @@ -0,0 +1,73 @@ +# TASK-007: 環境設定UI拡張 (Chrome Sidecarセクション) + +## 説明 + +環境設定画面 (`/settings/environments`) のDocker環境編集フォームにChrome Sidecar設定セクションを追加する。トグルスイッチでサイドカーの有効/無効を切り替え、イメージ名・タグのカスタマイズを可能にする。 + +**対象ファイル**: +- `src/components/environments/` - 環境フォームコンポーネント(既存を拡張) +- `src/app/settings/environments/` - 環境設定ページ(必要に応じて拡張) + +**設計書**: `docs/sdd/design/chrome-sidecar/components/environment-ui.md` + +## 技術的文脈 + +- Docker環境 (`type === 'DOCKER'`) の場合のみセクションを表示 +- トグルOFF時は image/tag フィールドを無効化 +- トグルOFF時は config JSON から chromeSidecar キーを除外(後方互換性) +- Chrome Tag に "latest" が入力された場合は即時バリデーションエラーを表示 +- デフォルト値: image = `ghcr.io/windschord/claude-work-sandbox`, tag = `chrome-devtools` + +## TDD手順 + +### テストファイル + +`src/components/environments/__tests__/chrome-sidecar-form.test.ts` + +### テストケース + +1. **Docker環境でChrome Sidecarセクションが表示されること** + - type=DOCKERの環境編集時にセクションが存在すること + +2. **HOST環境でChrome Sidecarセクションが非表示であること** + - type=HOSTの環境編集時にセクションが存在しないこと + +3. **トグルOFF時にimage/tagフィールドが無効化されること** + - デフォルト状態(OFF)でフィールドがdisabledであること + +4. **トグルON時にimage/tagフィールドが有効化されること** + - トグルをONにするとフィールドが入力可能になること + - デフォルト値が表示されること + +5. **tag に "latest" を入力するとバリデーションエラーが表示されること** + - "latest" 入力時に即座にエラーメッセージが表示されること + - エラーメッセージ: "再現性のためバージョンを固定してください(latestは使用できません)" + +6. **トグルOFF時にconfig JSONからchromeSidecarが除外されること** + - トグルをONにしてからOFFに戻した場合、送信されるconfigにchromeSidecarキーが含まれないこと + +7. **既存のchromeSidecar設定がフォームに正しく反映されること** + - config内にchromeSidecarがある環境を編集する際、値がフィールドに表示されること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. 環境フォームコンポーネントにChrome Sidecarセクションを追加 + - トグルスイッチ (Enable Chrome Sidecar) + - テキスト入力 (Chrome Image, Chrome Tag) + - バリデーションロジック +3. フォーム送信時のconfig JSON構築ロジックを拡張 +4. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] Docker環境のみセクションが表示されること +- [ ] トグルON/OFFでフィールドの有効/無効が切り替わること +- [ ] tag: "latest" がフロントエンドバリデーションで拒否されること +- [ ] トグルOFF時にchromeSidecarキーが送信データから除外されること +- [ ] 既存の環境設定が正しく読み込まれること +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-002 (型定義), TASK-004 (APIバリデーション) +**推定工数**: 35分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-008.md b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-008.md new file mode 100644 index 000000000..0737967f3 --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-008.md @@ -0,0 +1,73 @@ +# TASK-008: セッションUI拡張 (デバッグポート表示・Chromeバッジ) + +## 説明 + +セッション詳細画面にChromeデバッグポート情報を表示し、セッションリストにChromeバッジを追加する。 + +**対象ファイル**: +- `src/components/` - セッション詳細コンポーネント +- `src/components/` - セッションリストコンポーネント + +**設計書**: `docs/sdd/design/chrome-sidecar/components/environment-ui.md` (セッション情報表示部分) + +## 技術的文脈 + +- chrome_container_id が NULL でないセッションにのみ情報表示 +- セッション詳細: "Chrome DevTools Debug" セクション (Status + Debug URL) +- セッションリスト: "Chrome" バッジ (オレンジ色) +- chrome_debug_port が NULL の場合: "Running (debug port unavailable)" +- chrome_debug_port が存在する場合: "Running - localhost:" + +## TDD手順 + +### テストファイル + +`src/components/__tests__/session-chrome-info.test.ts` + +### テストケース + +#### セッション詳細画面 + +1. **サイドカーあり・ポートあり: デバッグ情報が表示されること** + - "Chrome DevTools Debug" セクションが表示されること + - "Running" ステータスが表示されること + - "localhost:" が表示されること + +2. **サイドカーあり・ポートなし: ポート不明表示** + - "Running (debug port unavailable)" が表示されること + +3. **サイドカーなし: セクション非表示** + - chrome_container_id が null のセッションでセクションが表示されないこと + +#### セッションリスト + +4. **サイドカーあり: Chromeバッジ表示** + - "Chrome" バッジがオレンジ色で表示されること + +5. **サイドカーなし: バッジ非表示** + - Chromeバッジが表示されないこと + +6. **混在リスト: 正しいバッジ表示** + - サイドカーありセッションのみにバッジが表示されること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. セッション詳細コンポーネントに "Chrome DevTools Debug" セクションを追加 + - chrome_container_id の有無で表示/非表示を制御 + - chrome_debug_port の有無でURL表示を分岐 +3. セッションリストコンポーネントにChromeバッジを追加 + - 既存の環境バッジ(HOST=green, DOCKER=blue)と同列に配置 +4. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] サイドカー付きセッションの詳細にデバッグ情報が表示されること +- [ ] ポート不明時のフォールバック表示が正しいこと +- [ ] サイドカーなしセッションでは情報セクションが非表示であること +- [ ] セッションリストにChromeバッジが正しく表示されること +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-006 (Session API: chrome_container_id, chrome_debug_port レスポンス) +**推定工数**: 30分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-009.md b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-009.md new file mode 100644 index 000000000..c643bd38d --- /dev/null +++ b/docs/sdd/tasks/chrome-sidecar/phase-4/TASK-009.md @@ -0,0 +1,61 @@ +# TASK-009: サーバー起動時クリーンアップ統合 + +## 説明 + +サーバー起動時 (`server.ts`) に `ChromeSidecarService.cleanupOrphaned()` を呼び出し、前回クラッシュや異常終了で残った孤立Chromeコンテナ・ネットワークを自動クリーンアップする。 + +**対象ファイル**: +- `server.ts` - 起動時初期化処理に cleanupOrphaned 呼び出し追加 + +**設計書**: `docs/sdd/design/chrome-sidecar/sequences/cleanup.md` + +## 技術的文脈 + +- 既存の初期化処理(DB migrate、デフォルト環境作成等)の後に追加 +- cleanupOrphaned は best-effort であり、失敗してもサーバー起動を妨げない +- Docker未接続時はエラーログ出力のみで続行 +- 処理結果(削除コンテナ数、ネットワーク数)をログ出力 + +## TDD手順 + +### テストファイル + +`src/services/__tests__/chrome-sidecar-cleanup-integration.test.ts` + +### テストケース + +1. **サーバー起動時にcleanupOrphanedが呼ばれること** + - server.ts の起動フローで ChromeSidecarService.cleanupOrphaned が実行されること + +2. **cleanupOrphaned失敗時にサーバー起動が続行されること** + - cleanupOrphaned が例外をスローしても、サーバーが正常に起動すること + - エラーログが出力されること + +3. **Docker未接続時にgracefulにスキップされること** + - Docker Engine に接続できない環境でもサーバーが起動すること + +### 実装手順 + +1. テストファイル作成・テスト実行(RED確認) +2. `server.ts` の起動処理に以下を追加: + ```typescript + try { + const sidecarService = new ChromeSidecarService(); + await sidecarService.cleanupOrphaned(); + } catch (error) { + logger.error('Chrome sidecar cleanup failed', { error }); + } + ``` +3. テスト実行(GREEN確認) + +## 受入基準 + +- [ ] サーバー起動時に cleanupOrphaned が呼び出されること +- [ ] クリーンアップ失敗時にサーバー起動が中止されないこと +- [ ] Docker未接続環境でもサーバーが正常起動すること +- [ ] クリーンアップ結果がログに記録されること +- [ ] 全テストケースがパスすること + +**依存関係**: TASK-003 (ChromeSidecarService), TASK-005 (DockerAdapter拡張) +**推定工数**: 20分 +**ステータス**: `TODO` diff --git a/docs/sdd/tasks/index.md b/docs/sdd/tasks/index.md index 20b6b8057..6b40aa0c5 100644 --- a/docs/sdd/tasks/index.md +++ b/docs/sdd/tasks/index.md @@ -11,6 +11,7 @@ | [Volume命名規則](volume-naming/index.md) | Docker named volumeの命名規則統一 | 未着手 | 計画中 | | [デフォルト環境削除](remove-default-env/index.md) | デフォルト実行環境の自動作成廃止 | 未着手 | 計画中 | | [Registry Firewall統合](registry-firewall/index.md) | パッケージレジストリプロキシによるサプライチェーン攻撃保護 | 7/7 タスク完了 | 完了 | +| [Chrome Sidecar MCP環境](chrome-sidecar/index.md) | セッション専用Chromeサイドカーコンテナによるブラウザ操作 | 0/9 タスク | 計画中 | ## 完了済みプロジェクト diff --git a/drizzle/0001_youthful_naoko.sql b/drizzle/0001_youthful_naoko.sql new file mode 100644 index 000000000..1f575da4c --- /dev/null +++ b/drizzle/0001_youthful_naoko.sql @@ -0,0 +1,2 @@ +ALTER TABLE `Session` ADD `chrome_container_id` text;--> statement-breakpoint +ALTER TABLE `Session` ADD `chrome_debug_port` integer; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..c73e18162 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1024 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7e9c93d-a4dc-4ef1-8833-e5d4cdb0d8ef", + "prevId": "29763ad2-a148-46e1-8de1-11a4b3fd6525", + "tables": { + "DeveloperSettings": { + "name": "DeveloperSettings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_username": { + "name": "git_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_email": { + "name": "git_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "developer_settings_scope_project_id_idx": { + "name": "developer_settings_scope_project_id_idx", + "columns": [ + "scope", + "project_id" + ], + "isUnique": false + }, + "developer_settings_global_unique": { + "name": "developer_settings_global_unique", + "columns": [ + "scope" + ], + "isUnique": true, + "where": "scope = 'GLOBAL'" + }, + "developer_settings_project_unique": { + "name": "developer_settings_project_unique", + "columns": [ + "project_id" + ], + "isUnique": true, + "where": "scope = 'PROJECT' AND project_id IS NOT NULL" + } + }, + "foreignKeys": { + "DeveloperSettings_project_id_Project_id_fk": { + "name": "DeveloperSettings_project_id_Project_id_fk", + "tableFrom": "DeveloperSettings", + "tableTo": "Project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExecutionEnvironment": { + "name": "ExecutionEnvironment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_dir_path": { + "name": "auth_dir_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ExecutionEnvironment_project_id_unique": { + "name": "ExecutionEnvironment_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ExecutionEnvironment_project_id_Project_id_fk": { + "name": "ExecutionEnvironment_project_id_Project_id_fk", + "tableFrom": "ExecutionEnvironment", + "tableTo": "Project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "GitHubPAT": { + "name": "GitHubPAT", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Message": { + "name": "Message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sub_agents": { + "name": "sub_agents", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "Message_session_id_Session_id_fk": { + "name": "Message_session_id_Session_id_fk", + "tableFrom": "Message", + "tableTo": "Session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "NetworkFilterConfig": { + "name": "NetworkFilterConfig", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "NetworkFilterConfig_environment_id_unique": { + "name": "NetworkFilterConfig_environment_id_unique", + "columns": [ + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "NetworkFilterConfig_environment_id_ExecutionEnvironment_id_fk": { + "name": "NetworkFilterConfig_environment_id_ExecutionEnvironment_id_fk", + "tableFrom": "NetworkFilterConfig", + "tableTo": "ExecutionEnvironment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "NetworkFilterRule": { + "name": "NetworkFilterRule", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "NetworkFilterRule_environment_id_ExecutionEnvironment_id_fk": { + "name": "NetworkFilterRule_environment_id_ExecutionEnvironment_id_fk", + "tableFrom": "NetworkFilterRule", + "tableTo": "ExecutionEnvironment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Project": { + "name": "Project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_code_options": { + "name": "claude_code_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "custom_env_vars": { + "name": "custom_env_vars", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "clone_location": { + "name": "clone_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'docker'" + }, + "docker_volume_id": { + "name": "docker_volume_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Project_path_unique": { + "name": "Project_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "Project_environment_id_unique": { + "name": "Project_environment_id_unique", + "columns": [ + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "Project_environment_id_ExecutionEnvironment_id_fk": { + "name": "Project_environment_id_ExecutionEnvironment_id_fk", + "tableFrom": "Project", + "tableTo": "ExecutionEnvironment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Prompt": { + "name": "Prompt", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Prompt_content_unique": { + "name": "Prompt_content_unique", + "columns": [ + "content" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "RunScript": { + "name": "RunScript", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_scripts_project_id_idx": { + "name": "run_scripts_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "RunScript_project_id_Project_id_fk": { + "name": "RunScript_project_id_Project_id_fk", + "tableFrom": "RunScript", + "tableTo": "Project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Session": { + "name": "Session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resume_session_id": { + "name": "resume_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_status": { + "name": "pr_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_updated_at": { + "name": "pr_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chrome_container_id": { + "name": "chrome_container_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chrome_debug_port": { + "name": "chrome_debug_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_code_options": { + "name": "claude_code_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_env_vars": { + "name": "custom_env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_connections": { + "name": "active_connections", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroy_at": { + "name": "destroy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ACTIVE'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_session_state_idx": { + "name": "sessions_session_state_idx", + "columns": [ + "session_state" + ], + "isUnique": false + }, + "sessions_destroy_at_idx": { + "name": "sessions_destroy_at_idx", + "columns": [ + "destroy_at" + ], + "isUnique": false + }, + "sessions_last_activity_at_idx": { + "name": "sessions_last_activity_at_idx", + "columns": [ + "last_activity_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Session_project_id_Project_id_fk": { + "name": "Session_project_id_Project_id_fk", + "tableFrom": "Session", + "tableTo": "Project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "SshKey": { + "name": "SshKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_encrypted": { + "name": "private_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_iv": { + "name": "encryption_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_passphrase": { + "name": "has_passphrase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "SshKey_name_unique": { + "name": "SshKey_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4fdaa05e4..fa0b253c3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1773886403510, "tag": "0000_condemned_namora", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1774085870368, + "tag": "0001_youthful_naoko", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/test-migration-ci.ts b/scripts/test-migration-ci.ts index d60b2bf11..620e81aa9 100644 --- a/scripts/test-migration-ci.ts +++ b/scripts/test-migration-ci.ts @@ -89,19 +89,29 @@ function getMigrationEntryCount(): number { * 全マイグレーションSQLを順番に実行してテーブルを作成する(__drizzle_migrationsなし) * Drizzle migrate()を通さず、SQLを直接実行してテーブルだけを作る */ -function applyAllMigrationsSqlDirectly(sqlite: InstanceType): void { +/** + * 初期マイグレーションSQL(CREATE TABLE)のみ直接実行してテーブルを作成する(__drizzle_migrationsなし) + * + * 旧カスタムマイグレーション済みDBを再現する。 + * CREATE TABLE IF NOT EXISTS のみを含む初期マイグレーション(0000)を適用し、 + * ALTER TABLE を含む増分マイグレーション(0001〜)は migrate() に任せる。 + * これにより、実運用と同じアップグレードパス(旧DB -> Drizzle migrate())を検証できる。 + */ +function applyInitialMigrationSqlDirectly(sqlite: InstanceType): void { const journal = readJournal(); + const initialEntry = journal.entries[0]; + if (!initialEntry) { + throw new Error('No initial migration entry found in journal'); + } sqlite.transaction(() => { - for (const entry of journal.entries) { - const sqlFile = path.join(migrationsFolder, `${entry.tag}.sql`); - const migrationSql = fs.readFileSync(sqlFile, 'utf-8'); - const statements = migrationSql - .split('--> statement-breakpoint') - .map(s => s.trim()) - .filter(s => s.length > 0); - for (const stmt of statements) { - sqlite.exec(stmt); - } + const sqlFile = path.join(migrationsFolder, `${initialEntry.tag}.sql`); + const migrationSql = fs.readFileSync(sqlFile, 'utf-8'); + const statements = migrationSql + .split('--> statement-breakpoint') + .map(s => s.trim()) + .filter(s => s.length > 0); + for (const stmt of statements) { + sqlite.exec(stmt); } })(); } @@ -358,13 +368,12 @@ console.log(' (旧カスタムマイグレーション済みDBからの移行 const { sqlite, db } = createTestDb(dbPath); try { - // マイグレーションSQLを直接実行してテーブルを作成する + // 初期マイグレーション(CREATE TABLE IF NOT EXISTS)のみ直接実行してテーブルを作成する // __drizzle_migrationsは作成しない(旧カスタムマイグレーション済みDBを再現) - // マイグレーションSQLはIF NOT EXISTSを使用しているため、 - // migrate()実行時に既存テーブルはスキップされる + // ALTER TABLEを含む増分マイグレーションはmigrate()で適用される try { - applyAllMigrationsSqlDirectly(sqlite); - assert(true, 'テーブル手動作成成功'); + applyInitialMigrationSqlDirectly(sqlite); + assert(true, 'テーブル手動作成成功(初期マイグレーションのみ)'); } catch (e) { assert(false, `テーブル作成失敗: ${e}`); throw e; @@ -375,13 +384,21 @@ console.log(' (旧カスタムマイグレーション済みDBからの移行 ).get() as { cnt: number }).cnt; assert(hasDrizzleTable === 0, '__drizzle_migrations 未作成(旧DB状態)'); - const ids = insertTestData(db); - assert(true, 'データ挿入成功'); + // 初期スキーマ(ALTER TABLE前)でデータを生SQLで挿入 + // Drizzle ORMは最新スキーマのカラム全てをINSERTに含むため、旧スキーマでは使えない + const preMigrationSessionId = crypto.randomUUID(); + const preMigrationProjectId = crypto.randomUUID(); + const preMigrationEnvId = crypto.randomUUID(); + sqlite.exec(`INSERT INTO ExecutionEnvironment (id, name, type, config, created_at, updated_at) VALUES ('${preMigrationEnvId}', 'pre-env', 'DOCKER', '{}', 1000000, 1000000)`); + sqlite.exec(`INSERT INTO Project (id, name, path, environment_id, created_at, updated_at) VALUES ('${preMigrationProjectId}', 'pre-project', '/pre', '${preMigrationEnvId}', 1000000, 1000000)`); + sqlite.exec(`INSERT INTO Session (id, project_id, name, status, worktree_path, branch_name, session_state, created_at, updated_at) VALUES ('${preMigrationSessionId}', '${preMigrationProjectId}', 'pre-session', 'stopped', '/pre/.worktrees/pre', 'session/pre', 'ACTIVE', 1000000, 1000000)`); + assert(true, 'データ挿入成功(旧スキーマ、生SQL)'); // 全テーブルのレコード数を記録 const countBefore = getRowCounts(sqlite); - // migrate()実行: IF NOT EXISTSにより既存テーブルはスキップされ、 + // migrate()実行: CREATE TABLE IF NOT EXISTSにより既存テーブルはスキップされ、 + // ALTER TABLE ADD COLUMNで新カラムが追加され、 // __drizzle_migrationsが新規作成されてマイグレーションが記録される try { migrate(db, { migrationsFolder }); @@ -405,7 +422,14 @@ console.log(' (旧カスタムマイグレーション済みDBからの移行 assert(countBefore[table] === countAfter[table], `${table} 件数保全 (${countAfter[table]})`); } + // migrate()後は最新スキーマ。Drizzle ORMでデータ挿入・検証が可能 + const ids = insertTestData(db); verifyTestData(db, ids); + + // 旧データの新カラムがNULLであることを確認 + const preSession = sqlite.prepare(`SELECT chrome_container_id, chrome_debug_port FROM Session WHERE id = '${preMigrationSessionId}'`).get() as Record; + assert(preSession.chrome_container_id === null, 'migrate()後の旧セッション: chrome_container_id = NULL'); + assert(preSession.chrome_debug_port === null, 'migrate()後の旧セッション: chrome_debug_port = NULL'); } finally { sqlite.close(); cleanupDb(dbPath); diff --git a/server.ts b/server.ts index bc3304568..b2c249dba 100644 --- a/server.ts +++ b/server.ts @@ -17,6 +17,7 @@ import { ProcessLifecycleManager, } from './src/services/process-lifecycle-manager'; import { DockerAdapter } from './src/services/adapters/docker-adapter'; +import { ChromeSidecarService } from './src/services/chrome-sidecar-service'; import { db } from './src/lib/db'; import { ptySessionManager } from './src/services/pty-session-manager'; import { runMigrations } from './src/lib/migrate'; @@ -333,6 +334,16 @@ app.prepare().then(() => { // クリーンアップ失敗はクリティカルではないため、サーバーは継続 } + // 孤立したChrome Sidecarコンテナ・ネットワークのクリーンアップ + try { + const sidecarService = ChromeSidecarService.getInstance(); + await sidecarService.cleanupOrphaned(); + logger.info('Chrome sidecar orphaned resources cleanup completed'); + } catch (error) { + logger.error('Chrome sidecar cleanup failed', { error }); + // クリーンアップ失敗はクリティカルではないため、サーバーは継続 + } + // アイドルタイムアウトチェッカーを開始 const idleTimeoutMinutes = ProcessLifecycleManager.getIdleTimeoutMinutes(); const lifecycleManager = getProcessLifecycleManager(); diff --git a/src/app/api/environments/__tests__/chrome-sidecar-validation.test.ts b/src/app/api/environments/__tests__/chrome-sidecar-validation.test.ts new file mode 100644 index 000000000..14b09d413 --- /dev/null +++ b/src/app/api/environments/__tests__/chrome-sidecar-validation.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from 'vitest'; +import { validateChromeSidecarConfig } from '@/lib/chrome-sidecar-validator'; + +describe('Chrome Sidecar バリデーション', () => { + describe('validateChromeSidecarConfig', () => { + // === chromeSidecarキー不在・null・undefined === + + it('chromeSidecarキー省略時: nullを返すこと', () => { + const config = { imageName: 'test', imageTag: 'v1' }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + it('chromeSidecar が null の場合: nullを返すこと', () => { + const config = { chromeSidecar: null }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + it('chromeSidecar が undefined の場合: nullを返すこと', () => { + const config = { chromeSidecar: undefined }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + // === chromeSidecarがオブジェクトでない場合 === + + it('chromeSidecar が文字列の場合: エラーメッセージを返すこと', () => { + const config = { chromeSidecar: 'invalid' }; + const error = validateChromeSidecarConfig(config as Record); + expect(error).toBe('chromeSidecar must be an object'); + }); + + it('chromeSidecar が数値の場合: エラーメッセージを返すこと', () => { + const config = { chromeSidecar: 42 }; + const error = validateChromeSidecarConfig(config as Record); + expect(error).toBe('chromeSidecar must be an object'); + }); + + it('chromeSidecar が配列の場合: エラーメッセージを返さないこと (typeof array === object)', () => { + // 配列はtypeof === 'object'なのでオブジェクトチェックは通過するが、enabledが未定義になる + const config = { chromeSidecar: [] }; + const error = validateChromeSidecarConfig(config as Record); + expect(error).toBe('chromeSidecar.enabled is required and must be a boolean'); + }); + + it('chromeSidecar が true (boolean) の場合: エラーメッセージを返すこと', () => { + const config = { chromeSidecar: true }; + const error = validateChromeSidecarConfig(config as Record); + expect(error).toBe('chromeSidecar must be an object'); + }); + + // === enabled フィールドのバリデーション === + + it('enabled が boolean でない場合 (文字列): 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: 'true', image: 'test', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.enabled is required and must be a boolean'); + }); + + it('enabled が数値の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: 1, image: 'test', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.enabled is required and must be a boolean'); + }); + + it('enabled が未定義の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { image: 'test', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.enabled is required and must be a boolean'); + }); + + it('enabled が null の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: null, image: 'test', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.enabled is required and must be a boolean'); + }); + + // === enabled: false の場合 === + + it('enabled: false の場合: image/tagバリデーションスキップ (nullを返す)', () => { + const config = { + chromeSidecar: { enabled: false, image: '', tag: '' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + it('enabled: false の場合: image/tagがなくてもnullを返す', () => { + const config = { + chromeSidecar: { enabled: false }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + // === enabled: true + image バリデーション === + + it('正常な設定: nullを返すこと', () => { + const config = { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBeNull(); + }); + + it('image が空文字の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: '', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + it('image が未定義の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + it('image が数値の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: 123, tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + it('image に大文字を含む場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: 'Chromium/HeadlessShell', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + it('image に @ を含む場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: 'test@image', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + it('image にスペースを含む場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'test image', tag: 'v1' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'); + }); + + // 正常なimage値のバリエーション + it('image: ドットを含むレジストリ名は正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'registry.example.com/ghcr.io/windschord/claude-work-sandbox', tag: 'v1' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + + it('image: コロン付きプライベートレジストリは正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'registry.example.com:5000/ghcr.io/windschord/claude-work-sandbox', tag: 'v1' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + + it('image: ハイフンを含む名前は正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'headless-shell', tag: 'v1' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + + it('image: アンダースコアを含む名前は正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'headless_shell', tag: 'v1' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + + // === enabled: true + tag バリデーション === + + it('tag が空文字の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: '' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag is required'); + }); + + it('tag が空白のみの場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: ' ' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag is required'); + }); + + it('tag が未定義の場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag is required'); + }); + + it('tag が数値の場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 123 }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag is required'); + }); + + it('tag が "latest" の場合: 正確なエラーメッセージ', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 'latest' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'); + }); + + it('tag が "LATEST" (大文字) の場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 'LATEST' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'); + }); + + it('tag が " latest " (前後空白) の場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: ' latest ' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'); + }); + + it('tag が "Latest" (混合ケース) の場合: エラー', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 'Latest' }, + }; + const error = validateChromeSidecarConfig(config); + expect(error).toBe('chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'); + }); + + // 正常なtag値 + it('tag が具体的なバージョン文字列の場合: 正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 'chrome-devtools' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + + it('tag が "stable" の場合: 正常', () => { + const config = { + chromeSidecar: { enabled: true, image: 'ghcr.io/windschord/claude-work-sandbox', tag: 'stable' }, + }; + expect(validateChromeSidecarConfig(config)).toBeNull(); + }); + }); +}); diff --git a/src/app/api/projects/[project_id]/environment/route.ts b/src/app/api/projects/[project_id]/environment/route.ts index 65ebf9630..8bea9ef7d 100644 --- a/src/app/api/projects/[project_id]/environment/route.ts +++ b/src/app/api/projects/[project_id]/environment/route.ts @@ -4,6 +4,7 @@ import { db, schema } from '@/lib/db'; import { eq, and } from 'drizzle-orm'; import { logger } from '@/lib/logger'; import { validatePortMappings, validateVolumeMounts } from '@/lib/docker-config-validator'; +import { validateChromeSidecarConfig } from '@/lib/chrome-sidecar-validator'; import { isHostEnvironmentAllowed } from '@/lib/environment-detect'; import type { PortMapping, VolumeMount } from '@/types/environment'; @@ -212,6 +213,19 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { } } + // chromeSidecar のバリデーション(DOCKER環境の場合のみ) + const effectiveType = type ?? existing.type; + if (effectiveType === 'DOCKER') { + const chromeSidecarError = validateChromeSidecarConfig(sanitizedConfig); + if (chromeSidecarError) { + return NextResponse.json( + { error: chromeSidecarError }, + { status: 400 } + ); + } + } + // HOST環境の場合はchromeSidecarを無視(エラーにはしない) + updateData.config = sanitizedConfig; } diff --git a/src/app/api/sessions/__tests__/chrome-sidecar-response.test.ts b/src/app/api/sessions/__tests__/chrome-sidecar-response.test.ts new file mode 100644 index 000000000..a07d1d77e --- /dev/null +++ b/src/app/api/sessions/__tests__/chrome-sidecar-response.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock logger +vi.mock('@/lib/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock ProcessManager +vi.mock('@/services/process-manager', () => ({ + ProcessManager: { + getInstance: () => ({ + getStatus: vi.fn().mockReturnValue('stopped'), + }), + }, +})); + +const mockFindFirst = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + query: { + sessions: { + findFirst: (...args: unknown[]) => mockFindFirst(...args), + }, + }, + }, + schema: { + sessions: { + id: 'id', + project_id: 'project_id', + created_at: 'created_at', + chrome_container_id: 'chrome_container_id', + chrome_debug_port: 'chrome_debug_port', + }, + }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), + desc: vi.fn(), + and: vi.fn(), +})); + +describe('Session API Chrome Sidecar レスポンス', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /api/sessions/:id', () => { + it('サイドカーあり: chrome_container_id と chrome_debug_port がレスポンスに含まれること', async () => { + const sessionWithChrome = { + id: 'session-1', + project_id: 'project-1', + name: 'Test Session', + status: 'running', + worktree_path: '/workspace', + branch_name: 'main', + chrome_container_id: 'cw-chrome-session-1', + chrome_debug_port: 49152, + container_id: 'cw-session-1', + project: { + environment: { + name: 'Docker Environment', + type: 'DOCKER', + }, + }, + }; + + mockFindFirst.mockResolvedValueOnce(sessionWithChrome); + + const { GET } = await import('../[id]/route'); + const request = new Request('http://localhost:3000/api/sessions/session-1'); + const response = await GET(request as any, { + params: Promise.resolve({ id: 'session-1' }), + }); + const data = await response.json(); + + expect(data.session.chrome_container_id).toBe('cw-chrome-session-1'); + expect(data.session.chrome_debug_port).toBe(49152); + }); + + it('サイドカーなし: chrome_container_id が null であること', async () => { + const sessionWithoutChrome = { + id: 'session-2', + project_id: 'project-1', + name: 'Test Session 2', + status: 'running', + worktree_path: '/workspace', + branch_name: 'main', + chrome_container_id: null, + chrome_debug_port: null, + container_id: 'cw-session-2', + project: { + environment: { + name: 'Docker Environment', + type: 'DOCKER', + }, + }, + }; + + mockFindFirst.mockResolvedValueOnce(sessionWithoutChrome); + + const { GET } = await import('../[id]/route'); + const request = new Request('http://localhost:3000/api/sessions/session-2'); + const response = await GET(request as any, { + params: Promise.resolve({ id: 'session-2' }), + }); + const data = await response.json(); + + expect(data.session.chrome_container_id).toBeNull(); + expect(data.session.chrome_debug_port).toBeNull(); + }); + }); + + describe('レスポンスフィールド検証', () => { + it('Drizzle findFirstの結果にchrome_container_idが含まれればレスポンスにも含まれること', () => { + // Drizzle ORMのfindFirst/findManyはスキーマの全カラムを返す + // schema.tsにchrome_container_id, chrome_debug_portが追加済みなので + // レスポンスのスプレッド演算子で自動的に含まれる + const dbResult = { + id: 'session-1', + chrome_container_id: 'cw-chrome-1', + chrome_debug_port: 49152, + }; + const response = { ...dbResult }; + expect(response.chrome_container_id).toBe('cw-chrome-1'); + expect(response.chrome_debug_port).toBe(49152); + }); + }); +}); diff --git a/src/components/__tests__/session-chrome-info.test.tsx b/src/components/__tests__/session-chrome-info.test.tsx new file mode 100644 index 000000000..5d8f4a337 --- /dev/null +++ b/src/components/__tests__/session-chrome-info.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChromeDebugInfo } from '../sessions/ChromeDebugInfo'; +import { ChromeBadge } from '../sessions/ChromeBadge'; + +describe('Session Chrome Info', () => { + describe('ChromeDebugInfo (セッション詳細画面)', () => { + it('サイドカーあり・ポートあり: デバッグ情報が表示されること', () => { + render( + + ); + expect(screen.getByText('Chrome DevTools Debug')).toBeDefined(); + expect(screen.getByText(/Running/)).toBeDefined(); + expect(screen.getByText(/localhost:49152/)).toBeDefined(); + }); + + it('サイドカーあり・ポートなし: ポート不明表示', () => { + render( + + ); + expect(screen.getByText(/debug port unavailable/)).toBeDefined(); + }); + + it('サイドカーなし: セクション非表示', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('ChromeBadge (セッションリスト)', () => { + it('サイドカーあり: Chromeバッジ表示', () => { + render(); + expect(screen.getByText('Chrome')).toBeDefined(); + }); + + it('サイドカーなし: バッジ非表示', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/src/components/environments/ChromeSidecarSection.tsx b/src/components/environments/ChromeSidecarSection.tsx new file mode 100644 index 000000000..2f3319c53 --- /dev/null +++ b/src/components/environments/ChromeSidecarSection.tsx @@ -0,0 +1,102 @@ +'use client'; + +interface ChromeSidecarSectionProps { + enabled: boolean; + image: string; + tag: string; + onEnabledChange: (enabled: boolean) => void; + onImageChange: (image: string) => void; + onTagChange: (tag: string) => void; + disabled?: boolean; +} + +/** + * Chrome Sidecar設定セクション + * + * Docker環境設定内に組み込むChrome Sidecarの有効/無効切り替え、 + * イメージ名・タグのカスタマイズUI。 + */ +export function ChromeSidecarSection({ + enabled, + image, + tag, + onEnabledChange, + onImageChange, + onTagChange, + disabled = false, +}: ChromeSidecarSectionProps) { + const tagError = enabled && tag.trim().toLowerCase() === 'latest' + ? '再現性のためバージョンを固定してください(latestは使用できません)' + : null; + + return ( +
+
+
+ Chrome Sidecar +
+ +
+ +

+ セッション起動時にChrome DevTools MCPサーバー用のヘッドレスChromeコンテナを自動起動します +

+ +
+
+ + onImageChange(e.target.value)} + placeholder="ghcr.io/windschord/claude-work-sandbox" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm disabled:opacity-50 disabled:cursor-not-allowed" + disabled={disabled || !enabled} + /> +
+ +
+ + onTagChange(e.target.value)} + placeholder="chrome-devtools" + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${ + tagError + ? 'border-red-500 dark:border-red-400' + : 'border-gray-300 dark:border-gray-600' + }`} + disabled={disabled || !enabled} + /> + {tagError && ( +

{tagError}

+ )} +
+
+
+ ); +} diff --git a/src/components/environments/__tests__/chrome-sidecar-form.test.tsx b/src/components/environments/__tests__/chrome-sidecar-form.test.tsx new file mode 100644 index 000000000..00802513f --- /dev/null +++ b/src/components/environments/__tests__/chrome-sidecar-form.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChromeSidecarSection } from '../ChromeSidecarSection'; + +describe('ChromeSidecarSection', () => { + const defaultProps = { + enabled: false, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + onEnabledChange: vi.fn(), + onImageChange: vi.fn(), + onTagChange: vi.fn(), + disabled: false, + }; + + it('Chrome Sidecarセクションが表示されること', () => { + render(); + expect(screen.getByText('Chrome Sidecar')).toBeDefined(); + }); + + it('トグルOFF時にimage/tagフィールドが無効化されること', () => { + render(); + const imageInput = screen.getByLabelText('Chrome Image') as HTMLInputElement; + const tagInput = screen.getByLabelText('Chrome Tag') as HTMLInputElement; + expect(imageInput.disabled).toBe(true); + expect(tagInput.disabled).toBe(true); + }); + + it('トグルON時にimage/tagフィールドが有効化されること', () => { + render(); + const imageInput = screen.getByLabelText('Chrome Image') as HTMLInputElement; + const tagInput = screen.getByLabelText('Chrome Tag') as HTMLInputElement; + expect(imageInput.disabled).toBe(false); + expect(tagInput.disabled).toBe(false); + }); + + it('トグルON時にデフォルト値が表示されること', () => { + render(); + const imageInput = screen.getByLabelText('Chrome Image') as HTMLInputElement; + const tagInput = screen.getByLabelText('Chrome Tag') as HTMLInputElement; + expect(imageInput.value).toBe('ghcr.io/windschord/claude-work-sandbox'); + expect(tagInput.value).toBe('chrome-devtools'); + }); + + it('tag に "latest" を入力するとバリデーションエラーが表示されること', () => { + render(); + expect(screen.getByText(/latestは使用できません/)).toBeDefined(); + }); + + it('トグルを切り替えるとonEnabledChangeが呼ばれること', () => { + const onEnabledChange = vi.fn(); + render(); + const toggle = screen.getByRole('checkbox'); + fireEvent.click(toggle); + expect(onEnabledChange).toHaveBeenCalledWith(true); + }); + + it('既存のchromeSidecar設定がフォームに正しく反映されること', () => { + render( + + ); + const imageInput = screen.getByLabelText('Chrome Image') as HTMLInputElement; + const tagInput = screen.getByLabelText('Chrome Tag') as HTMLInputElement; + expect(imageInput.value).toBe('my-chrome/image'); + expect(tagInput.value).toBe('120.0.0.1'); + }); +}); diff --git a/src/components/projects/ProjectEnvironmentSection.tsx b/src/components/projects/ProjectEnvironmentSection.tsx index 53d7917b9..2abe00e39 100644 --- a/src/components/projects/ProjectEnvironmentSection.tsx +++ b/src/components/projects/ProjectEnvironmentSection.tsx @@ -8,6 +8,7 @@ import { VolumeMountList } from '@/components/environments/VolumeMountList'; import { DangerousPathWarning } from '@/components/environments/DangerousPathWarning'; import { NetworkFilterSection } from '@/components/environments/NetworkFilterSection'; import { ApplyChangesButton } from '@/components/environments/ApplyChangesButton'; +import { ChromeSidecarSection } from '@/components/environments/ChromeSidecarSection'; import type { PortMapping, VolumeMount } from '@/types/environment'; interface ProjectEnvironmentSectionProps { @@ -29,6 +30,12 @@ interface AppClaudeDefaults { worktree: boolean; } +interface ChromeSidecarUIConfig { + enabled: boolean; + image: string; + tag: string; +} + interface EnvironmentConfig { imageName?: string; imageTag?: string; @@ -36,6 +43,7 @@ interface EnvironmentConfig { portMappings?: PortMapping[]; volumeMounts?: VolumeMount[]; claude_defaults_override?: ClaudeDefaultsOverride; + chromeSidecar?: ChromeSidecarUIConfig; } const ENVIRONMENT_TYPES: { value: EnvironmentType; label: string; description: string }[] = [ @@ -70,6 +78,11 @@ export function ProjectEnvironmentSection({ projectId }: ProjectEnvironmentSecti const [volumeMounts, setVolumeMounts] = useState([]); const [dangerousPath, setDangerousPath] = useState(null); + // Chrome Sidecar設定 + const [chromeSidecarEnabled, setChromeSidecarEnabled] = useState(false); + const [chromeSidecarImage, setChromeSidecarImage] = useState('ghcr.io/windschord/claude-work-sandbox'); + const [chromeSidecarTag, setChromeSidecarTag] = useState('chrome-devtools'); + // Claude Code設定オーバーライド const [overrideSkipPermissions, setOverrideSkipPermissions] = useState('inherit'); const [overrideWorktree, setOverrideWorktree] = useState('inherit'); @@ -139,6 +152,18 @@ export function ProjectEnvironmentSection({ projectId }: ProjectEnvironmentSecti setOverrideSkipPermissions(true); } + // Chrome Sidecar設定の復元 + if (config.chromeSidecar) { + setChromeSidecarEnabled(config.chromeSidecar.enabled ?? false); + if (config.chromeSidecar.image) setChromeSidecarImage(config.chromeSidecar.image); + if (config.chromeSidecar.tag) setChromeSidecarTag(config.chromeSidecar.tag); + } else { + // chromeSidecar未設定時はデフォルト値にリセット + setChromeSidecarEnabled(false); + setChromeSidecarImage('ghcr.io/windschord/claude-work-sandbox'); + setChromeSidecarTag('chrome-devtools'); + } + initialConfigRef.current = { imageName: config.imageName, imageTag: config.imageTag, @@ -209,6 +234,13 @@ export function ProjectEnvironmentSection({ projectId }: ProjectEnvironmentSecti dangerouslySkipPermissions: overrideSkipPermissions, worktree: overrideWorktree, }, + // Chrome Sidecar設定(トグルOFF時もenabled: falseを明示的に送信) + // OFF時もimage/tagを保持することで、再度ONにした際に前回の値を復元できる + chromeSidecar: { + enabled: chromeSidecarEnabled, + image: chromeSidecarImage.trim() || 'ghcr.io/windschord/claude-work-sandbox', + tag: chromeSidecarTag.trim() || 'chrome-devtools', + }, } : {}; @@ -506,6 +538,17 @@ export function ProjectEnvironmentSection({ projectId }: ProjectEnvironmentSecti onDangerousPath={(path) => setDangerousPath(path)} /> + + {/* Chrome Sidecar */} + )} diff --git a/src/components/sessions/ChromeBadge.tsx b/src/components/sessions/ChromeBadge.tsx new file mode 100644 index 000000000..0a9b92c95 --- /dev/null +++ b/src/components/sessions/ChromeBadge.tsx @@ -0,0 +1,24 @@ +'use client'; + +interface ChromeBadgeProps { + chromeContainerId: string | null; +} + +/** + * Chrome サイドカーバッジ + * + * セッションリストで表示される。 + * chrome_container_id が存在する場合のみ表示。 + * オレンジ色のバッジ。 + */ +export function ChromeBadge({ chromeContainerId }: ChromeBadgeProps) { + if (!chromeContainerId) { + return null; + } + + return ( + + Chrome + + ); +} diff --git a/src/components/sessions/ChromeDebugInfo.tsx b/src/components/sessions/ChromeDebugInfo.tsx new file mode 100644 index 000000000..d6d92e22c --- /dev/null +++ b/src/components/sessions/ChromeDebugInfo.tsx @@ -0,0 +1,44 @@ +'use client'; + +interface ChromeDebugInfoProps { + chromeContainerId: string | null; + chromeDebugPort: number | null; +} + +/** + * Chrome DevTools デバッグ情報セクション + * + * セッション詳細画面に表示される。 + * chrome_container_id が存在する場合のみ表示。 + */ +export function ChromeDebugInfo({ chromeContainerId, chromeDebugPort }: ChromeDebugInfoProps) { + if (!chromeContainerId) { + return null; + } + + return ( +
+
+ Chrome DevTools Debug +
+
+
+ Status: + + {chromeDebugPort + ? 'Running' + : 'Running (debug port unavailable)'} + +
+ {chromeDebugPort && ( +
+ Debug URL: + + localhost:{chromeDebugPort} + +
+ )} +
+
+ ); +} diff --git a/src/db/__tests__/schema-chrome-sidecar.test.ts b/src/db/__tests__/schema-chrome-sidecar.test.ts new file mode 100644 index 000000000..a9e50e359 --- /dev/null +++ b/src/db/__tests__/schema-chrome-sidecar.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { sessions } from '../schema'; + +describe('Chrome Sidecar DBスキーマ', () => { + describe('スキーマ定義の検証', () => { + it('sessions テーブルに chrome_container_id カラムが存在すること', () => { + expect(sessions.chrome_container_id).toBeDefined(); + expect(sessions.chrome_container_id.name).toBe('chrome_container_id'); + }); + + it('sessions テーブルに chrome_debug_port カラムが存在すること', () => { + expect(sessions.chrome_debug_port).toBeDefined(); + expect(sessions.chrome_debug_port.name).toBe('chrome_debug_port'); + }); + }); + + describe('カラム定義の検証', () => { + it('chrome_container_id がNULL許可であること', () => { + // Drizzle ORMでNULL許可=notNullが設定されていない + expect(sessions.chrome_container_id.notNull).toBe(false); + }); + + it('chrome_debug_port がNULL許可であること', () => { + expect(sessions.chrome_debug_port.notNull).toBe(false); + }); + + it('chrome_container_id が text 型であること', () => { + expect(sessions.chrome_container_id.columnType).toBe('SQLiteText'); + }); + + it('chrome_debug_port が integer 型であること', () => { + expect(sessions.chrome_debug_port.columnType).toBe('SQLiteInteger'); + }); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index a2cb6b865..e57663cd8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -77,6 +77,11 @@ export const sessions = sqliteTable('Session', { pr_status: text('pr_status'), pr_updated_at: integer('pr_updated_at', { mode: 'timestamp' }), container_id: text('container_id'), + + // Chrome Sidecar関連 + chrome_container_id: text('chrome_container_id'), // Chromeサイドカーコンテナ名 + chrome_debug_port: integer('chrome_debug_port'), // ホスト側デバッグポート + claude_code_options: text('claude_code_options'), custom_env_vars: text('custom_env_vars'), diff --git a/src/lib/chrome-sidecar-validator.ts b/src/lib/chrome-sidecar-validator.ts new file mode 100644 index 000000000..7a2aff2d2 --- /dev/null +++ b/src/lib/chrome-sidecar-validator.ts @@ -0,0 +1,46 @@ +/** + * Chrome Sidecar設定のバリデーション + * + * Environment APIのconfig JSONに含まれるchromeSidecar設定を検証する。 + */ + +/** + * chromeSidecar config のバリデーション + * + * @param config - config オブジェクト全体 + * @returns エラーメッセージ(正常時はnull) + */ +export function validateChromeSidecarConfig( + config: Record +): string | null { + // chromeSidecarキーが存在しない場合は省略OK + if (!('chromeSidecar' in config)) return null; + + // null/undefinedは省略と同じ扱いで許容(キー存在するが値なし) + if (config.chromeSidecar === null || config.chromeSidecar === undefined) return null; + if (typeof config.chromeSidecar !== 'object') { + return 'chromeSidecar must be an object'; + } + + const sidecar = config.chromeSidecar as Record; + + if (typeof sidecar.enabled !== 'boolean') { + return 'chromeSidecar.enabled is required and must be a boolean'; + } + + if (!sidecar.enabled) return null; // disabled時はimage/tagのバリデーション不要 + + if (typeof sidecar.image !== 'string' || !/^[-a-z0-9._/:]+$/.test(sidecar.image)) { + return 'chromeSidecar.image must be a valid Docker image name (lowercase alphanumeric, dots, slashes, colons, hyphens)'; + } + + if (typeof sidecar.tag !== 'string' || sidecar.tag.trim() === '') { + return 'chromeSidecar.tag is required'; + } + + if (typeof sidecar.tag === 'string' && sidecar.tag.trim().toLowerCase() === 'latest') { + return 'chromeSidecar.tag must be a specific version (latest is not allowed for reproducibility)'; + } + + return null; +} diff --git a/src/services/__tests__/chrome-sidecar-cleanup-integration.test.ts b/src/services/__tests__/chrome-sidecar-cleanup-integration.test.ts new file mode 100644 index 000000000..930cd4b2b --- /dev/null +++ b/src/services/__tests__/chrome-sidecar-cleanup-integration.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockCleanupOrphaned } = vi.hoisted(() => ({ + mockCleanupOrphaned: vi.fn(), +})); + +vi.mock('../chrome-sidecar-service', () => { + return { + ChromeSidecarService: class { + cleanupOrphaned = mockCleanupOrphaned; + }, + }; +}); + +vi.mock('@/lib/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { ChromeSidecarService } from '../chrome-sidecar-service'; +import { logger } from '@/lib/logger'; + +describe('Chrome Sidecar サーバー起動時クリーンアップ', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('cleanupOrphanedが正常に実行されること', async () => { + mockCleanupOrphaned.mockResolvedValueOnce(undefined); + + // server.ts の起動フローを再現 + const sidecarService = new ChromeSidecarService(); + await sidecarService.cleanupOrphaned(); + + expect(mockCleanupOrphaned).toHaveBeenCalledTimes(1); + }); + + it('cleanupOrphaned失敗時にサーバー起動が続行されること', async () => { + mockCleanupOrphaned.mockRejectedValueOnce(new Error('Docker not available')); + + // server.ts の起動フロー: try-catch で囲んで失敗してもサーバーは起動する + let serverStarted = false; + try { + const sidecarService = new ChromeSidecarService(); + await sidecarService.cleanupOrphaned(); + } catch (error) { + logger.error('Chrome sidecar cleanup failed', { error }); + } + // サーバー起動処理は続行 + serverStarted = true; + + expect(serverStarted).toBe(true); + expect(logger.error).toHaveBeenCalledWith( + 'Chrome sidecar cleanup failed', + expect.objectContaining({ error: expect.any(Error) }) + ); + }); + + it('Docker未接続時にgracefulにスキップされること', async () => { + mockCleanupOrphaned.mockRejectedValueOnce( + new Error('connect ENOENT /var/run/docker.sock') + ); + + let serverStarted = false; + try { + const sidecarService = new ChromeSidecarService(); + await sidecarService.cleanupOrphaned(); + } catch (error) { + logger.error('Chrome sidecar cleanup failed', { error }); + } + serverStarted = true; + + expect(serverStarted).toBe(true); + // 例外が外に漏れないことを検証(serverStartedがtrueであること自体が証明) + // logger.errorが呼ばれていることを検証 + expect(logger.error).toHaveBeenCalledWith( + 'Chrome sidecar cleanup failed', + expect.objectContaining({ error: expect.any(Error) }) + ); + }); +}); diff --git a/src/services/__tests__/chrome-sidecar-service.test.ts b/src/services/__tests__/chrome-sidecar-service.test.ts new file mode 100644 index 000000000..6504963b7 --- /dev/null +++ b/src/services/__tests__/chrome-sidecar-service.test.ts @@ -0,0 +1,1020 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// vi.hoisted で変数をホイスティング +const { + mockCreateContainer, + mockGetContainer, + mockListContainers, + mockCreateNetwork, + mockGetNetwork, + mockListNetworks, + mockDbRun, + mockDbAll, + mockDbGet, +} = vi.hoisted(() => ({ + mockCreateContainer: vi.fn(), + mockGetContainer: vi.fn(), + mockListContainers: vi.fn(), + mockCreateNetwork: vi.fn(), + mockGetNetwork: vi.fn(), + mockListNetworks: vi.fn().mockResolvedValue([]), + mockDbRun: vi.fn(), + mockDbAll: vi.fn().mockReturnValue([]), + mockDbGet: vi.fn(), +})); + +// Mock DockerClient +vi.mock('../docker-client', () => ({ + DockerClient: { + getInstance: () => ({ + createContainer: mockCreateContainer, + getContainer: mockGetContainer, + listContainers: mockListContainers, + getDockerInstance: () => ({ + createNetwork: mockCreateNetwork, + getNetwork: mockGetNetwork, + listNetworks: mockListNetworks, + }), + }), + }, +})); + +// Mock logger +vi.mock('@/lib/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock db +vi.mock('@/lib/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + all: mockDbAll, + get: mockDbGet, + }), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + run: mockDbRun, + }), + }), + }), + }, + schema: { + sessions: { + id: 'id', + chrome_container_id: 'chrome_container_id', + chrome_debug_port: 'chrome_debug_port', + }, + }, +})); + +// Mock drizzle-orm +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), + isNotNull: vi.fn(), +})); + +import { ChromeSidecarService } from '../chrome-sidecar-service'; +import type { ChromeSidecarConfig } from '@/types/environment'; + +describe('ChromeSidecarService', () => { + let service: ChromeSidecarService; + const testSessionId = 'test-session-123'; + const testConfig: ChromeSidecarConfig = { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }; + + // Mock container object + const mockContainer = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + inspect: vi.fn().mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { + Ports: { + '9222/tcp': [{ HostIp: '127.0.0.1', HostPort: '49152' }], + }, + }, + }), + }; + + // Mock network object + const mockNetwork = { + remove: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(undefined), + inspect: vi.fn().mockResolvedValue({ Containers: {} }), + }; + + // Mock fetch for CDP health check + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + service = new ChromeSidecarService(); + mockCreateContainer.mockResolvedValue(mockContainer); + mockGetContainer.mockReturnValue(mockContainer); + mockCreateNetwork.mockResolvedValue(mockNetwork); + mockGetNetwork.mockReturnValue(mockNetwork); + mockListContainers.mockResolvedValue([]); + mockListNetworks.mockResolvedValue([]); + mockDbAll.mockReturnValue([]); + + // mockContainer.inspectのデフォルト戻り値を再設定(clearAllMocksでリセットされるため) + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { + Ports: { + '9222/tcp': [{ HostIp: '127.0.0.1', HostPort: '49152' }], + }, + }, + }); + mockContainer.start.mockResolvedValue(undefined); + mockContainer.stop.mockResolvedValue(undefined); + mockContainer.remove.mockResolvedValue(undefined); + mockNetwork.remove.mockResolvedValue(undefined); + mockNetwork.disconnect.mockResolvedValue(undefined); + mockNetwork.connect.mockResolvedValue(undefined); + mockNetwork.inspect.mockResolvedValue({ Containers: {} }); + + // CDPヘルスチェック用のfetchモック + mockFetch.mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('getInstance', () => { + afterEach(() => { + ChromeSidecarService.resetInstance(); + }); + + it('シングルトンインスタンスを返すこと', () => { + const instance1 = ChromeSidecarService.getInstance(); + const instance2 = ChromeSidecarService.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('resetInstance後に新しいインスタンスが作成されること', () => { + const instance1 = ChromeSidecarService.getInstance(); + ChromeSidecarService.resetInstance(); + const instance2 = ChromeSidecarService.getInstance(); + expect(instance1).not.toBe(instance2); + }); + }); + + describe('startSidecar', () => { + it('正常系: サイドカー起動成功 - 全フィールドが正しく返されること', async () => { + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(true); + expect(result.containerName).toBe(`cw-chrome-${testSessionId}`); + expect(result.networkName).toBe(`cw-net-${testSessionId}`); + expect(result.debugPort).toBe(49152); + expect(result.browserUrl).toBe(`http://cw-chrome-${testSessionId}:9222`); + expect(result.error).toBeUndefined(); + }); + + it('正常系: ネットワーク作成パラメータの検証', async () => { + await service.startSidecar(testSessionId, testConfig); + + expect(mockCreateNetwork).toHaveBeenCalledWith({ + Name: `cw-net-${testSessionId}`, + Driver: 'bridge', + Labels: { + 'claude-work.session-id': testSessionId, + 'claude-work.managed-by': 'claude-work', + }, + }); + }); + + it('正常系: コンテナ作成パラメータの完全検証', async () => { + await service.startSidecar(testSessionId, testConfig); + + expect(mockCreateContainer).toHaveBeenCalledWith({ + name: `cw-chrome-${testSessionId}`, + Image: `${testConfig.image}:${testConfig.tag}`, + Entrypoint: ['/usr/bin/chromium'], + Cmd: [ + '--headless', + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--remote-debugging-address=0.0.0.0', + '--remote-debugging-port=9222', + ], + ExposedPorts: { + '9222/tcp': {}, + }, + Labels: { + 'claude-work.session-id': testSessionId, + 'claude-work.chrome-sidecar': 'true', + 'claude-work.managed-by': 'claude-work', + }, + HostConfig: { + PortBindings: { + '9222/tcp': [{ HostPort: '', HostIp: '127.0.0.1' }], + }, + CapDrop: ['ALL'], + SecurityOpt: ['no-new-privileges'], + Memory: 512 * 1024 * 1024, + AutoRemove: true, + NetworkMode: `cw-net-${testSessionId}`, + }, + }); + }); + + it('正常系: Chromeコマンドライン引数が正確であること', async () => { + await service.startSidecar(testSessionId, testConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + expect(callArgs.Entrypoint).toEqual(['/usr/bin/chromium']); + expect(callArgs.Cmd).toEqual([ + '--headless', + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--remote-debugging-address=0.0.0.0', + '--remote-debugging-port=9222', + ]); + expect(callArgs.Cmd).toHaveLength(6); + }); + + it('正常系: セキュリティ設定の具体的な値を検証', async () => { + await service.startSidecar(testSessionId, testConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + // CapDropがALLであること(配列の要素数と値) + expect(callArgs.HostConfig.CapDrop).toEqual(['ALL']); + expect(callArgs.HostConfig.CapDrop).toHaveLength(1); + // SecurityOptがno-new-privilegesであること + expect(callArgs.HostConfig.SecurityOpt).toEqual(['no-new-privileges']); + expect(callArgs.HostConfig.SecurityOpt).toHaveLength(1); + // メモリ制限が512MBであること + expect(callArgs.HostConfig.Memory).toBe(512 * 1024 * 1024); + // AutoRemoveがtrueであること + expect(callArgs.HostConfig.AutoRemove).toBe(true); + }); + + it('正常系: Dockerラベルの値が正確であること', async () => { + await service.startSidecar(testSessionId, testConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + expect(callArgs.Labels['claude-work.session-id']).toBe(testSessionId); + expect(callArgs.Labels['claude-work.chrome-sidecar']).toBe('true'); + expect(callArgs.Labels['claude-work.managed-by']).toBe('claude-work'); + }); + + it('正常系: ネットワークラベルの値が正確であること', async () => { + await service.startSidecar(testSessionId, testConfig); + + const networkArgs = mockCreateNetwork.mock.calls[0][0]; + expect(networkArgs.Labels['claude-work.session-id']).toBe(testSessionId); + expect(networkArgs.Labels['claude-work.managed-by']).toBe('claude-work'); + }); + + it('正常系: ポートバインディングの値が正確であること', async () => { + await service.startSidecar(testSessionId, testConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + expect(callArgs.HostConfig.PortBindings['9222/tcp']).toEqual([ + { HostPort: '', HostIp: '127.0.0.1' }, + ]); + expect(callArgs.ExposedPorts['9222/tcp']).toEqual({}); + }); + + it('正常系: Image名がconfig.image:config.tagフォーマットであること', async () => { + const customConfig: ChromeSidecarConfig = { + enabled: true, + image: 'my-registry.com/chrome', + tag: 'v2.0.0', + }; + await service.startSidecar(testSessionId, customConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + expect(callArgs.Image).toBe('my-registry.com/chrome:v2.0.0'); + }); + + it('正常系: NetworkModeがネットワーク名と一致すること', async () => { + await service.startSidecar(testSessionId, testConfig); + + const callArgs = mockCreateContainer.mock.calls[0][0]; + expect(callArgs.HostConfig.NetworkMode).toBe(`cw-net-${testSessionId}`); + }); + + it('正常系: ポートマッピングからdebugPort取得', async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { + Ports: { + '9222/tcp': [{ HostIp: '127.0.0.1', HostPort: '55555' }], + }, + }, + }); + + const result = await service.startSidecar(testSessionId, testConfig); + expect(result.debugPort).toBe(55555); + expect(result.browserUrl).toBe(`http://cw-chrome-${testSessionId}:9222`); + }); + + it('正常系: CDPヘルスチェックURLが正確であること', async () => { + mockContainer.inspect.mockResolvedValueOnce({ + State: { Running: true }, + NetworkSettings: { + Ports: { + '9222/tcp': [{ HostIp: '127.0.0.1', HostPort: '49152' }], + }, + }, + }); + + await service.startSidecar(testSessionId, testConfig); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:49152/json/version', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it('正常系: browserUrlのフォーマットが正確であること', async () => { + const result = await service.startSidecar(testSessionId, testConfig); + // browserUrlは http://:9222 のフォーマット + expect(result.browserUrl).toMatch(/^http:\/\/cw-chrome-.+:9222$/); + expect(result.browserUrl).toBe('http://cw-chrome-test-session-123:9222'); + }); + + it('正常系: cdpTimeoutMsのデフォルト値が30000であること', async () => { + // fetchが成功するのでタイムアウトはしない + const result = await service.startSidecar(testSessionId, testConfig); + expect(result.success).toBe(true); + }); + + it('正常系: cdpTimeoutMsカスタム値が適用されること', async () => { + mockFetch.mockRejectedValue(new Error('Connection refused')); + + const result = await service.startSidecar(testSessionId, testConfig, { cdpTimeoutMs: 100 }); + + expect(result.success).toBe(false); + expect(result.error).toBe('CDP health check timed out after 100ms'); + }, 10000); + + // === 異常系 === + + it('異常系: ネットワーク作成失敗 - Errorインスタンスの場合', async () => { + mockCreateNetwork.mockRejectedValueOnce(new Error('Network creation failed')); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network creation failed'); + expect(mockCreateContainer).not.toHaveBeenCalled(); + }); + + it('異常系: ネットワーク作成失敗 - 非Errorオブジェクトの場合', async () => { + mockCreateNetwork.mockRejectedValueOnce('string error'); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network creation failed'); + expect(mockCreateContainer).not.toHaveBeenCalled(); + }); + + it('異常系: コンテナ作成失敗 - ネットワークロールバックが実行されること', async () => { + mockCreateContainer.mockRejectedValueOnce(new Error('Image not found')); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Image not found'); + // ネットワークのロールバック削除 + expect(mockNetwork.remove).toHaveBeenCalled(); + // コンテナは作成されていないのでstart/removeは呼ばれない + expect(mockContainer.start).not.toHaveBeenCalled(); + }); + + it('異常系: コンテナ作成失敗 + ネットワークロールバック失敗', async () => { + mockCreateContainer.mockRejectedValueOnce(new Error('Image not found')); + mockNetwork.remove.mockRejectedValueOnce(new Error('Network busy')); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Image not found'); + // ネットワーク削除が試行されたこと + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('異常系: コンテナ作成失敗 - 非Errorオブジェクトの場合', async () => { + mockCreateContainer.mockRejectedValueOnce({ code: 500 }); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Container creation failed'); + }); + + it('異常系: コンテナ起動失敗 - コンテナ削除とネットワーク削除が行われること', async () => { + mockContainer.start.mockRejectedValueOnce(new Error('Container start failed')); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Container start failed'); + // コンテナ強制削除が呼ばれること(AutoRemoveは未起動コンテナには効かない) + expect(mockContainer.remove).toHaveBeenCalledWith({ force: true }); + // ネットワーク削除が呼ばれること(ロールバック) + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('異常系: コンテナ起動失敗 + コンテナ削除失敗 + ネットワーク削除失敗', async () => { + mockContainer.start.mockRejectedValueOnce(new Error('Container start failed')); + mockContainer.remove.mockRejectedValueOnce(new Error('Remove failed')); + mockNetwork.remove.mockRejectedValueOnce(new Error('Network remove failed')); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Container start failed'); + }); + + it('異常系: コンテナ起動失敗 - 非Errorオブジェクトの場合', async () => { + mockContainer.start.mockRejectedValueOnce('unknown error'); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(false); + expect(result.error).toBe('Container start failed'); + }); + + it('正常系: ポートマッピング取得失敗時はCDPヘルスチェックをスキップし成功すること', async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { Ports: {} }, + }); + + const result = await service.startSidecar(testSessionId, testConfig, { cdpTimeoutMs: 100 }); + + expect(result.success).toBe(true); + expect(result.debugPort).toBeUndefined(); + expect(result.containerName).toBe(`cw-chrome-${testSessionId}`); + expect(result.networkName).toBe(`cw-net-${testSessionId}`); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('正常系: ポート情報がnullの場合もCDPヘルスチェックをスキップ', async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { Ports: { '9222/tcp': null } }, + }); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(true); + expect(result.debugPort).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('正常系: ポート情報が空配列の場合もCDPヘルスチェックをスキップ', async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { Ports: { '9222/tcp': [] } }, + }); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(true); + expect(result.debugPort).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('正常系: ポート情報のHostPortがundefinedの場合もCDPヘルスチェックをスキップ', async () => { + mockContainer.inspect.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { Ports: { '9222/tcp': [{ HostIp: '127.0.0.1' }] } }, + }); + + const result = await service.startSidecar(testSessionId, testConfig); + + expect(result.success).toBe(true); + expect(result.debugPort).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('異常系: ポートマッピング取得時のinspect失敗はwarnログだけで続行', async () => { + // 最初のinspect(ポート取得)が失敗 + mockContainer.inspect.mockRejectedValueOnce(new Error('inspect failed')); + + const result = await service.startSidecar(testSessionId, testConfig); + + // ポート取得失敗でもCDPチェックスキップで成功 + expect(result.success).toBe(true); + expect(result.debugPort).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('異常系: CDPヘルスチェックタイムアウト - fetchが常にエラー', async () => { + mockFetch.mockRejectedValue(new Error('Connection refused')); + + const result = await service.startSidecar(testSessionId, testConfig, { cdpTimeoutMs: 100 }); + + expect(result.success).toBe(false); + expect(result.error).toContain('CDP health check timed out'); + expect(result.error).toContain('100ms'); + }, 10000); + + it('異常系: CDPヘルスチェックタイムアウト - fetchがnon-ok応答', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 }); + + const result = await service.startSidecar(testSessionId, testConfig, { cdpTimeoutMs: 100 }); + + expect(result.success).toBe(false); + expect(result.error).toBe('CDP health check timed out after 100ms'); + }, 10000); + + it('異常系: CDPヘルスチェックタイムアウト時にコンテナ停止とネットワーク削除が行われること', async () => { + mockFetch.mockRejectedValue(new Error('Connection refused')); + + await service.startSidecar(testSessionId, testConfig, { cdpTimeoutMs: 100 }); + + // コンテナ停止が試行される + expect(mockContainer.stop).toHaveBeenCalled(); + // ネットワーク削除が試行される + expect(mockNetwork.remove).toHaveBeenCalled(); + }, 10000); + }); + + describe('stopSidecar', () => { + it('正常系: サイドカー停止成功', async () => { + const result = await service.stopSidecar( + testSessionId, + `cw-chrome-${testSessionId}` + ); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockContainer.stop).toHaveBeenCalled(); + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('正常系: networkNameを明示的に指定した場合', async () => { + const customNetworkName = 'custom-network'; + await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`, customNetworkName); + + expect(mockGetNetwork).toHaveBeenCalledWith(customNetworkName); + }); + + it('正常系: networkNameを省略した場合はセッションIDから生成', async () => { + await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + + expect(mockGetNetwork).toHaveBeenCalledWith(`cw-net-${testSessionId}`); + }); + + it('正常系: 接続中コンテナがある場合はdisconnectしてからネットワーク削除', async () => { + mockNetwork.inspect.mockResolvedValueOnce({ + Containers: { + 'container-id-1': { Name: 'container-1' }, + 'container-id-2': { Name: 'container-2' }, + }, + }); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + + expect(result.success).toBe(true); + expect(mockNetwork.disconnect).toHaveBeenCalledTimes(2); + expect(mockNetwork.disconnect).toHaveBeenCalledWith({ Container: 'container-id-1', Force: true }); + expect(mockNetwork.disconnect).toHaveBeenCalledWith({ Container: 'container-id-2', Force: true }); + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('正常系: disconnect失敗はbest-effortで無視される', async () => { + mockNetwork.inspect.mockResolvedValueOnce({ + Containers: { + 'container-id-1': { Name: 'container-1' }, + }, + }); + mockNetwork.disconnect.mockRejectedValueOnce(new Error('disconnect failed')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + + expect(result.success).toBe(true); + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('異常系: コンテナ停止失敗時にsuccess: falseを返すこと', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('Container stop failed')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(false); + expect(result.error).toContain('container stop'); + expect(result.error).toContain('Container stop failed'); + }); + + it('異常系: コンテナ停止失敗 + ネットワーク削除失敗時にエラーが結合されること', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('stop error')); + mockNetwork.inspect.mockRejectedValueOnce(new Error('network error')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(false); + expect(result.error).toContain('container stop: stop error'); + expect(result.error).toContain('network remove: network error'); + expect(result.error).toContain('; '); + }); + + it('異常系: 非Errorオブジェクトのエラーハンドリング', async () => { + mockContainer.stop.mockRejectedValueOnce('string error'); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(false); + expect(result.error).toContain('container stop: Unknown'); + }); + + it('正常系: AutoRemoveコンテナの404エラーは成功扱いになること', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('(HTTP code 404) no such container')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('正常系: AutoRemoveコンテナの304エラーは成功扱いになること', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('(HTTP code 304) container already stopped')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('正常系: "No such container"エラーは成功扱いになること', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('No such container: cw-chrome-test')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('正常系: "not running"エラーは成功扱いになること', async () => { + mockContainer.stop.mockRejectedValueOnce(new Error('Container is not running')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('異常系: ネットワーク削除失敗時にsuccess: falseを返すこと', async () => { + mockNetwork.inspect.mockRejectedValueOnce(new Error('Network inspect failed')); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(false); + expect(result.error).toContain('network remove'); + expect(result.error).toContain('Network inspect failed'); + }); + + it('異常系: Containersプロパティがundefinedの場合もネットワーク削除が行われること', async () => { + mockNetwork.inspect.mockResolvedValueOnce({}); + + const result = await service.stopSidecar(testSessionId, `cw-chrome-${testSessionId}`); + expect(result.success).toBe(true); + expect(mockNetwork.disconnect).not.toHaveBeenCalled(); + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + }); + + describe('connectClaudeContainer', () => { + it('正常系: Claude Codeコンテナをネットワークに接続', async () => { + const claudeContainerName = 'cw-session-test'; + const networkName = `cw-net-${testSessionId}`; + + await service.connectClaudeContainer(claudeContainerName, networkName); + + expect(mockNetwork.connect).toHaveBeenCalledWith({ + Container: claudeContainerName, + }); + }); + + it('異常系: 接続失敗時に例外がスローされること', async () => { + mockNetwork.connect.mockRejectedValueOnce(new Error('Connect failed')); + + await expect( + service.connectClaudeContainer('container', 'network') + ).rejects.toThrow('Connect failed'); + }); + }); + + describe('cleanupOrphaned', () => { + it('Phase 1: DBベースのクリーンアップ - 停止済みコンテナのDB更新', async () => { + mockDbAll.mockReturnValueOnce([ + { id: 'session-1', chrome_container_id: 'cw-chrome-session-1' }, + ]); + + // コンテナは存在しない(既にAutoRemoveで削除済み) + const deadContainer = { + inspect: vi.fn().mockRejectedValue(new Error('Not found')), + stop: vi.fn(), + }; + mockGetContainer.mockReturnValueOnce(deadContainer); + + await service.cleanupOrphaned(); + + // DB更新が呼ばれること + expect(mockDbRun).toHaveBeenCalled(); + // ネットワーク削除が試行されること + expect(mockGetNetwork).toHaveBeenCalledWith(`cw-net-session-1`); + }); + + it('Phase 1: 停止済みコンテナ (Running: false) もクリーンアップされること', async () => { + mockDbAll.mockReturnValueOnce([ + { id: 'session-1', chrome_container_id: 'cw-chrome-session-1' }, + ]); + + const stoppedContainer = { + inspect: vi.fn().mockResolvedValue({ + State: { Running: false }, + }), + stop: vi.fn(), + }; + mockGetContainer.mockReturnValueOnce(stoppedContainer); + + await service.cleanupOrphaned(); + + expect(mockDbRun).toHaveBeenCalled(); + }); + + it('Phase 1: chrome_container_idがnullのセッションはスキップ', async () => { + mockDbAll.mockReturnValueOnce([ + { id: 'session-1', chrome_container_id: null }, + ]); + + await service.cleanupOrphaned(); + + expect(mockGetContainer).not.toHaveBeenCalled(); + expect(mockDbRun).not.toHaveBeenCalled(); + }); + + it('Phase 1: 実行中コンテナはスキップすること', async () => { + mockDbAll.mockReturnValueOnce([ + { id: 'session-1', chrome_container_id: 'cw-chrome-session-1' }, + ]); + + const runningContainer = { + inspect: vi.fn().mockResolvedValue({ + State: { Running: true }, + }), + stop: vi.fn(), + }; + mockGetContainer.mockReturnValueOnce(runningContainer); + + await service.cleanupOrphaned(); + + // DB更新が呼ばれないこと + expect(mockDbRun).not.toHaveBeenCalled(); + }); + + it('Phase 1: ネットワーク削除失敗はbest-effortで無視される', async () => { + mockDbAll.mockReturnValueOnce([ + { id: 'session-1', chrome_container_id: 'cw-chrome-session-1' }, + ]); + + const deadContainer = { + inspect: vi.fn().mockRejectedValue(new Error('Not found')), + }; + mockGetContainer.mockReturnValueOnce(deadContainer); + mockNetwork.remove.mockRejectedValueOnce(new Error('Network not found')); + + await service.cleanupOrphaned(); + + // ネットワーク削除失敗でもDB更新は行われること + expect(mockDbRun).toHaveBeenCalled(); + }); + + it('Phase 2: ラベルベースのクリーンアップ - コンテナ停止とネットワーク削除', async () => { + // Phase 1: DBには何もない + mockDbAll.mockReturnValueOnce([]); + + // Phase 2: ラベル付きのorphanedコンテナが存在 + mockListContainers.mockResolvedValueOnce([ + { + Id: 'orphan-container-id', + Names: ['/cw-chrome-orphan'], + Labels: { + 'claude-work.session-id': 'non-existent-session', + 'claude-work.chrome-sidecar': 'true', + }, + }, + ]); + + // DBにセッションが存在しない + mockDbGet.mockReturnValueOnce(undefined); + + const orphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockGetContainer.mockReturnValueOnce(orphanContainer); + + await service.cleanupOrphaned(); + + // コンテナ停止が呼ばれること + expect(orphanContainer.stop).toHaveBeenCalled(); + // ネットワーク削除も呼ばれること + expect(mockNetwork.remove).toHaveBeenCalled(); + }); + + it('Phase 2: session-idラベルがないコンテナはスキップ', async () => { + mockDbAll.mockReturnValueOnce([]); + + mockListContainers.mockResolvedValueOnce([ + { + Id: 'orphan-container-id', + Names: ['/cw-chrome-orphan'], + Labels: { + 'claude-work.chrome-sidecar': 'true', + // session-id ラベルなし + }, + }, + ]); + + await service.cleanupOrphaned(); + + expect(mockDbGet).not.toHaveBeenCalled(); + }); + + it('Phase 2: DBにセッションが存在する場合はクリーンアップしない', async () => { + mockDbAll.mockReturnValueOnce([]); + + mockListContainers.mockResolvedValueOnce([ + { + Id: 'container-id', + Names: ['/cw-chrome-session-1'], + Labels: { + 'claude-work.session-id': 'existing-session', + 'claude-work.chrome-sidecar': 'true', + }, + }, + ]); + + // DBにセッションが存在する + mockDbGet.mockReturnValueOnce({ id: 'existing-session' }); + + await service.cleanupOrphaned(); + + // コンテナ停止は呼ばれないこと + expect(mockGetContainer).not.toHaveBeenCalled(); + }); + + it('Phase 2: コンテナ停止失敗はbest-effortで無視される', async () => { + mockDbAll.mockReturnValueOnce([]); + + mockListContainers.mockResolvedValueOnce([ + { + Id: 'orphan-container-id', + Labels: { + 'claude-work.session-id': 'non-existent-session', + 'claude-work.chrome-sidecar': 'true', + }, + }, + ]); + + mockDbGet.mockReturnValueOnce(undefined); + + const orphanContainer = { + stop: vi.fn().mockRejectedValue(new Error('already stopped')), + }; + mockGetContainer.mockReturnValueOnce(orphanContainer); + + // エラーがスローされないこと + await expect(service.cleanupOrphaned()).resolves.toBeUndefined(); + }); + + it('Phase 2: listContainers失敗時にエラーがスローされないこと', async () => { + mockDbAll.mockReturnValueOnce([]); + + mockListContainers.mockRejectedValueOnce(new Error('Docker daemon not responding')); + + await expect(service.cleanupOrphaned()).resolves.toBeUndefined(); + }); + + it('Phase 2b: 孤立ネットワークの回収 - コンテナ接続なしのネットワークを削除', async () => { + mockDbAll.mockReturnValueOnce([]); + mockListContainers.mockResolvedValueOnce([]); + + mockListNetworks.mockResolvedValueOnce([ + { + Id: 'network-id-1', + Name: 'cw-net-orphan-session', + Labels: { 'claude-work.managed-by': 'claude-work' }, + }, + ]); + + const orphanNetwork = { + inspect: vi.fn().mockResolvedValue({ Containers: {} }), + remove: vi.fn().mockResolvedValue(undefined), + }; + mockGetNetwork.mockReturnValueOnce(orphanNetwork); + + await service.cleanupOrphaned(); + + expect(orphanNetwork.inspect).toHaveBeenCalled(); + expect(orphanNetwork.remove).toHaveBeenCalled(); + }); + + it('Phase 2b: cw-net-プレフィックスのないネットワークはスキップ', async () => { + mockDbAll.mockReturnValueOnce([]); + mockListContainers.mockResolvedValueOnce([]); + + mockListNetworks.mockResolvedValueOnce([ + { + Id: 'network-id-1', + Name: 'other-network', + Labels: { 'claude-work.managed-by': 'claude-work' }, + }, + ]); + + await service.cleanupOrphaned(); + + // getNetworkはPhase 1で呼ばれるかもしれないが、Phase 2bの対象外ネットワークは呼ばれない + // inspectが呼ばれないことを確認 + expect(mockNetwork.inspect).not.toHaveBeenCalled(); + }); + + it('Phase 2b: コンテナが接続されているネットワークは削除しない', async () => { + mockDbAll.mockReturnValueOnce([]); + mockListContainers.mockResolvedValueOnce([]); + + mockListNetworks.mockResolvedValueOnce([ + { + Id: 'network-id-1', + Name: 'cw-net-active-session', + Labels: { 'claude-work.managed-by': 'claude-work' }, + }, + ]); + + const activeNetwork = { + inspect: vi.fn().mockResolvedValue({ + Containers: { 'container-1': { Name: 'some-container' } }, + }), + remove: vi.fn(), + }; + mockGetNetwork.mockReturnValueOnce(activeNetwork); + + await service.cleanupOrphaned(); + + expect(activeNetwork.inspect).toHaveBeenCalled(); + expect(activeNetwork.remove).not.toHaveBeenCalled(); + }); + + it('Phase 2b: listNetworks失敗はbest-effortで無視される', async () => { + mockDbAll.mockReturnValueOnce([]); + mockListContainers.mockResolvedValueOnce([]); + + mockListNetworks.mockRejectedValueOnce(new Error('Docker daemon error')); + + await expect(service.cleanupOrphaned()).resolves.toBeUndefined(); + }); + }); + + describe('getActiveSidecarCount', () => { + it('アクティブサイドカー数を正しく返すこと', async () => { + mockListContainers.mockResolvedValueOnce([ + { Id: 'container-1', Labels: { 'claude-work.chrome-sidecar': 'true' } }, + { Id: 'container-2', Labels: { 'claude-work.chrome-sidecar': 'true' } }, + ]); + + const count = await service.getActiveSidecarCount(); + expect(count).toBe(2); + }); + + it('サイドカーがない場合は0を返すこと', async () => { + mockListContainers.mockResolvedValueOnce([]); + + const count = await service.getActiveSidecarCount(); + expect(count).toBe(0); + }); + + it('正しいフィルタ条件でlistContainersが呼ばれること', async () => { + mockListContainers.mockResolvedValueOnce([]); + + await service.getActiveSidecarCount(); + + expect(mockListContainers).toHaveBeenCalledWith({ + filters: JSON.stringify({ + label: ['claude-work.chrome-sidecar=true'], + status: ['running'], + }), + }); + }); + }); +}); diff --git a/src/services/adapters/__tests__/docker-adapter-chrome-sidecar.test.ts b/src/services/adapters/__tests__/docker-adapter-chrome-sidecar.test.ts new file mode 100644 index 000000000..3b7501608 --- /dev/null +++ b/src/services/adapters/__tests__/docker-adapter-chrome-sidecar.test.ts @@ -0,0 +1,542 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.hoisted で変数をホイスティング +const { + mockDockerClient, + mockExistsSync, + mockStartSidecar, + mockStopSidecar, + mockConnectClaudeContainer, + mockIsFilterEnabled, + mockProxyHealthCheck, + mockDbUpdate, + mockDbSelectGet, +} = vi.hoisted(() => { + const mockDbUpdateRun = vi.fn(); + const mockDbUpdateWhere = vi.fn(() => ({ run: mockDbUpdateRun })); + const mockDbUpdateSet = vi.fn(() => ({ where: mockDbUpdateWhere })); + const mockDbUpdate = vi.fn(() => ({ set: mockDbUpdateSet })); + Object.assign(mockDbUpdate, { set: mockDbUpdateSet, where: mockDbUpdateWhere, run: mockDbUpdateRun }); + + const mockDbSelectGet = vi.fn(); + + return { + mockDockerClient: { + inspectContainer: vi.fn(), + getContainer: vi.fn(), + run: vi.fn(), + createContainer: vi.fn(), + }, + mockExistsSync: vi.fn().mockReturnValue(false), + mockStartSidecar: vi.fn(), + mockStopSidecar: vi.fn(), + mockConnectClaudeContainer: vi.fn(), + mockIsFilterEnabled: vi.fn().mockResolvedValue(false), + mockProxyHealthCheck: vi.fn(), + mockDbUpdate, + mockDbSelectGet, + }; +}); + +// ChromeSidecarService のモック(シングルトン対応) +vi.mock('../../chrome-sidecar-service', () => ({ + ChromeSidecarService: { + getInstance: vi.fn(() => ({ + startSidecar: mockStartSidecar, + stopSidecar: mockStopSidecar, + connectClaudeContainer: mockConnectClaudeContainer, + })), + }, +})); + +vi.mock('../../docker-client', () => ({ + DockerClient: { + getInstance: () => mockDockerClient, + }, +})); + +vi.mock('@/services/network-filter-service', () => ({ + networkFilterService: { + isFilterEnabled: mockIsFilterEnabled, + getRules: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('@/lib/proxy-sync', () => ({ + syncRulesForContainer: vi.fn(), +})); + +vi.mock('@/services/proxy-client', () => { + class MockProxyClient { + healthCheck = mockProxyHealthCheck; + deleteRules = vi.fn().mockResolvedValue(undefined); + } + class MockProxyConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProxyConnectionError'; + } + } + return { + ProxyClient: MockProxyClient, + ProxyConnectionError: MockProxyConnectionError, + }; +}); + +vi.mock('@/services/registry-firewall-client', () => ({ + RegistryFirewallClient: vi.fn().mockImplementation(() => ({ + getHealth: vi.fn().mockResolvedValue({ status: 'healthy' }), + })), +})); + +vi.mock('@/lib/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: mockDbSelectGet, + all: vi.fn().mockReturnValue([]), + })), + all: vi.fn().mockReturnValue([]), + })), + })), + update: mockDbUpdate, + }, + schema: { sessions: {} }, +})); + +vi.mock('@/services/developer-settings-service', () => ({ + DeveloperSettingsService: vi.fn().mockImplementation(function () { + return { + getEffectiveSettings: vi.fn().mockResolvedValue({}), + }; + }), +})); + +vi.mock('@/services/encryption-service', () => ({ + EncryptionService: vi.fn().mockImplementation(function () { + return { + decrypt: vi.fn().mockResolvedValue('decrypted'), + }; + }), +})); + +vi.mock('node-pty', () => ({ + spawn: vi.fn(), +})); + +vi.mock('tar-fs', () => ({ + default: { + pack: vi.fn().mockReturnValue({ pipe: vi.fn() }), + }, + pack: vi.fn().mockReturnValue({ pipe: vi.fn() }), +})); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: mockExistsSync, + writeFileSync: vi.fn(), + createReadStream: vi.fn(), + }; +}); + +vi.mock('fs/promises', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), + readdir: vi.fn().mockResolvedValue([]), + rm: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ isDirectory: () => true }), + access: vi.fn().mockResolvedValue(undefined), +})); + +// scrollbackBuffer mock +vi.mock('../../scrollback-buffer', () => ({ + scrollbackBuffer: { + append: vi.fn(), + clear: vi.fn(), + get: vi.fn().mockReturnValue(''), + }, +})); + +// ClaudeOptionsService mock +vi.mock('../../claude-options-service', () => ({ + ClaudeOptionsService: { + buildCliArgs: vi.fn().mockReturnValue([]), + validateEnvVarKey: vi.fn().mockReturnValue(true), + }, +})); + +import { DockerAdapter } from '../docker-adapter'; + +/** + * コンテナモックを設定するヘルパー + * DockerAdapter.createSession が内部で使用する Docker API をモック + */ +function setupContainerMock() { + const mockExec = { start: vi.fn().mockResolvedValue(undefined) }; + const mockContainer = { + attach: vi.fn().mockResolvedValue({ + on: vi.fn(), + pipe: vi.fn(), + write: vi.fn(), + }), + start: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(mockExec), + }; + mockDockerClient.createContainer.mockResolvedValue(mockContainer); + // waitForContainerReady用: Running=true を返す + mockDockerClient.inspectContainer.mockResolvedValue({ + State: { Running: true }, + NetworkSettings: { + Networks: {}, + }, + }); + mockDockerClient.getContainer.mockReturnValue({ + exec: vi.fn().mockResolvedValue(mockExec), + stop: vi.fn().mockResolvedValue(undefined), + }); + return mockContainer; +} + +describe('DockerAdapter Chrome Sidecar - DockerAdapter統合テスト', () => { + let adapter: DockerAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockIsFilterEnabled.mockResolvedValue(false); + mockDbSelectGet.mockReturnValue(undefined); + adapter = new DockerAdapter({ + environmentId: 'env-1', + imageName: 'test-image', + imageTag: 'latest', + }); + }); + + describe('createSession: サイドカー無効時', () => { + it('chromeSidecarオプション未指定の場合、ChromeSidecarServiceが呼ばれないこと', async () => { + setupContainerMock(); + + await adapter.createSession('session-no-sidecar', '/workspace'); + + expect(mockStartSidecar).not.toHaveBeenCalled(); + expect(mockConnectClaudeContainer).not.toHaveBeenCalled(); + }); + + it('chromeSidecar.enabled=falseの場合、startSidecarが呼ばれないこと', async () => { + setupContainerMock(); + + await adapter.createSession('session-disabled', '/workspace', undefined, { + chromeSidecar: { + enabled: false, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }); + + expect(mockStartSidecar).not.toHaveBeenCalled(); + }); + }); + + describe('createSession: サイドカー有効・成功時', () => { + it('startSidecar, injectBrowserUrl, connectClaudeContainer が順に呼ばれ、DB更新されること', async () => { + setupContainerMock(); + + mockStartSidecar.mockResolvedValue({ + success: true, + containerName: 'cw-chrome-session-ok', + networkName: 'cw-net-session-ok', + debugPort: 49152, + browserUrl: 'http://cw-chrome-session-ok:9222', + }); + mockConnectClaudeContainer.mockResolvedValue(undefined); + + await adapter.createSession('session-ok', '/workspace', undefined, { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }); + + // startSidecarが正しい引数で呼ばれること + expect(mockStartSidecar).toHaveBeenCalledWith('session-ok', { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }); + + // injectBrowserUrl: コンテナ作成時にブラウザURL環境変数が注入されること + const createContainerCall = mockDockerClient.createContainer.mock.calls[0][0]; + const env = createContainerCall.Env as string[]; + expect(env.some((e: string) => e.includes('__CHROME_BROWSER_URL=http://cw-chrome-session-ok:9222'))).toBe(true); + + // Entrypointがshell経由に変換されていること + expect(createContainerCall.Entrypoint).toEqual(['/bin/sh', '-c']); + + // connectClaudeContainerが呼ばれること(コンテナ名とネットワーク名) + expect(mockConnectClaudeContainer).toHaveBeenCalledWith( + expect.stringContaining('claude-env-'), + 'cw-net-session-ok' + ); + + // DB更新: chrome_container_id, chrome_debug_port + expect(mockDbUpdate.set).toHaveBeenCalledWith( + expect.objectContaining({ + chrome_container_id: 'cw-chrome-session-ok', + chrome_debug_port: 49152, + }) + ); + }); + }); + + describe('createSession: サイドカー失敗(CDPタイムアウト)時', () => { + it('サイドカー起動失敗時もClaude Codeセッションが作成されること(graceful degradation)', async () => { + setupContainerMock(); + + mockStartSidecar.mockResolvedValue({ + success: false, + error: 'CDP health check timed out', + }); + + // セッション作成自体は成功する + await expect( + adapter.createSession('session-sidecar-fail', '/workspace', undefined, { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }) + ).resolves.not.toThrow(); + + expect(mockStartSidecar).toHaveBeenCalled(); + + // サイドカー失敗のため、connectClaudeContainerは呼ばれない + expect(mockConnectClaudeContainer).not.toHaveBeenCalled(); + + // サイドカー失敗のため、ブラウザURL環境変数は注入されない + const createContainerCall = mockDockerClient.createContainer.mock.calls[0][0]; + const env = createContainerCall.Env as string[]; + expect(env.some((e: string) => e.includes('__CHROME_BROWSER_URL'))).toBe(false); + }); + }); + + describe('createSession: サイドカー成功後のconnectClaudeContainer失敗時', () => { + it('接続失敗時にサイドカーがクリーンアップされ、セッションは継続すること', async () => { + setupContainerMock(); + + mockStartSidecar.mockResolvedValue({ + success: true, + containerName: 'cw-chrome-session-connect-fail', + networkName: 'cw-net-session-connect-fail', + debugPort: 49153, + browserUrl: 'http://cw-chrome-session-connect-fail:9222', + }); + mockConnectClaudeContainer.mockRejectedValue(new Error('Network connect failed')); + mockStopSidecar.mockResolvedValue({ success: true }); + + // セッション作成自体は成功する + await expect( + adapter.createSession('session-connect-fail', '/workspace', undefined, { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }) + ).resolves.not.toThrow(); + + // 接続失敗後にstopSidecarでクリーンアップ + expect(mockStopSidecar).toHaveBeenCalledWith( + 'session-connect-fail', + 'cw-chrome-session-connect-fail', + 'cw-net-session-connect-fail' + ); + }); + }); + + describe('createSession: コンテナ作成失敗時のサイドカークリーンアップ', () => { + it('コンテナ作成失敗時に起動済みサイドカーがクリーンアップされること', async () => { + mockStartSidecar.mockResolvedValue({ + success: true, + containerName: 'cw-chrome-session-cleanup', + networkName: 'cw-net-session-cleanup', + debugPort: 49154, + browserUrl: 'http://cw-chrome-session-cleanup:9222', + }); + mockStopSidecar.mockResolvedValue({ success: true }); + + // コンテナ作成で失敗させる + mockDockerClient.createContainer.mockRejectedValue(new Error('Docker create failed')); + + await expect( + adapter.createSession('session-cleanup', '/workspace', undefined, { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }) + ).rejects.toThrow('Docker create failed'); + + // 失敗時にサイドカーがクリーンアップされること + expect(mockStopSidecar).toHaveBeenCalledWith( + 'session-cleanup', + 'cw-chrome-session-cleanup', + 'cw-net-session-cleanup' + ); + }); + }); + + describe('destroySession: サイドカー停止', () => { + it('chrome_container_idが存在する場合、stopSidecarが呼ばれること', async () => { + setupContainerMock(); + + // セッションをsessionsマップに直接設定 + const mockPty = { + kill: vi.fn(), + resize: vi.fn(), + write: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + }; + (adapter as any).sessions.set('session-with-chrome', { + ptyProcess: mockPty, + workingDir: '/workspace', + containerId: 'container-id-chrome', + errorBuffer: '', + hasReceivedOutput: false, + shellMode: false, + }); + + // DB select: chrome_container_id が存在する + mockDbSelectGet.mockReturnValue({ chrome_container_id: 'cw-chrome-session-with-chrome' }); + mockStopSidecar.mockResolvedValue({ success: true }); + + await adapter.destroySession('session-with-chrome'); + + expect(mockStopSidecar).toHaveBeenCalledWith( + 'session-with-chrome', + 'cw-chrome-session-with-chrome' + ); + + // 停止成功時はDB更新で chrome_container_id, chrome_debug_port をnullクリア + expect(mockDbUpdate.set).toHaveBeenCalledWith( + expect.objectContaining({ + chrome_container_id: null, + chrome_debug_port: null, + }) + ); + }); + + it('chrome_container_idがNULLの場合、stopSidecarが呼ばれないこと', async () => { + setupContainerMock(); + + const mockPty = { + kill: vi.fn(), + resize: vi.fn(), + write: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + }; + (adapter as any).sessions.set('session-no-chrome', { + ptyProcess: mockPty, + workingDir: '/workspace', + containerId: 'container-id-no-chrome', + errorBuffer: '', + hasReceivedOutput: false, + shellMode: false, + }); + + // DB select: chrome_container_id が存在しない + mockDbSelectGet.mockReturnValue({ chrome_container_id: null }); + + await adapter.destroySession('session-no-chrome'); + + expect(mockStopSidecar).not.toHaveBeenCalled(); + }); + + it('stopSidecar失敗時はDB参照が保持されること(孤立回収用)', async () => { + setupContainerMock(); + + const mockPty = { + kill: vi.fn(), + resize: vi.fn(), + write: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + }; + (adapter as any).sessions.set('session-stop-fail', { + ptyProcess: mockPty, + workingDir: '/workspace', + containerId: 'container-id-stop-fail', + errorBuffer: '', + hasReceivedOutput: false, + shellMode: false, + }); + + // DB select: chrome_container_id が存在する + mockDbSelectGet.mockReturnValue({ chrome_container_id: 'cw-chrome-session-stop-fail' }); + // stopSidecar が失敗を返す + mockStopSidecar.mockResolvedValue({ success: false, error: 'Chrome stop failed' }); + + await adapter.destroySession('session-stop-fail'); + + expect(mockStopSidecar).toHaveBeenCalled(); + + // 停止失敗時はchrome_container_id/chrome_debug_portのnullクリアが行われない + // (container_idのnullクリアは別途行われるが、chrome関連のクリアは行われない) + const setCalls = mockDbUpdate.set.mock.calls; + const chromeNullClearCall = setCalls.find( + (call: unknown[]) => + call[0] && + typeof call[0] === 'object' && + 'chrome_container_id' in (call[0] as Record) && + (call[0] as Record).chrome_container_id === null + ); + expect(chromeNullClearCall).toBeUndefined(); + }); + }); + + describe('injectBrowserUrl: Entrypoint/Cmd変換', () => { + it('新規Entrypoint: shell経由への変換が行われること', async () => { + setupContainerMock(); + + mockStartSidecar.mockResolvedValue({ + success: true, + containerName: 'cw-chrome-inject', + networkName: 'cw-net-inject', + debugPort: 49155, + browserUrl: 'http://cw-chrome-inject:9222', + }); + mockConnectClaudeContainer.mockResolvedValue(undefined); + + await adapter.createSession('session-inject', '/workspace', undefined, { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }); + + const createContainerCall = mockDockerClient.createContainer.mock.calls[0][0]; + + // Entrypointが /bin/sh -c に変換されていること + expect(createContainerCall.Entrypoint).toEqual(['/bin/sh', '-c']); + + // Cmdにmcpスクリプトが含まれていること + const cmd = createContainerCall.Cmd as string[]; + expect(cmd[0]).toContain('.mcp.json'); + expect(cmd[0]).toContain('__CHROME_BROWSER_URL'); + + // 環境変数にブラウザURLが含まれること + const env = createContainerCall.Env as string[]; + expect(env).toContain('__CHROME_BROWSER_URL=http://cw-chrome-inject:9222'); + }); + }); +}); diff --git a/src/services/adapters/docker-adapter.ts b/src/services/adapters/docker-adapter.ts index efde7fb73..7b2b0be76 100644 --- a/src/services/adapters/docker-adapter.ts +++ b/src/services/adapters/docker-adapter.ts @@ -23,6 +23,8 @@ import { networkFilterService } from '@/services/network-filter-service'; import { ProxyClient } from '@/services/proxy-client'; import { syncRulesForContainer } from '@/lib/proxy-sync'; import { RegistryFirewallClient } from '@/services/registry-firewall-client'; +import { ChromeSidecarService } from '../chrome-sidecar-service'; +import type { SidecarStartResult } from '../chrome-sidecar-service'; export interface DockerAdapterConfig { environmentId: string; @@ -836,12 +838,43 @@ export class DockerAdapter extends BasePTYAdapter { } } + // === サイドカー起動フェーズ === + let sidecarResult: SidecarStartResult | undefined; + if (options?.chromeSidecar?.enabled) { + const sidecarService = ChromeSidecarService.getInstance(); + sidecarResult = await sidecarService.startSidecar( + sessionId, + options.chromeSidecar + ); + + if (sidecarResult.success) { + logger.info('DockerAdapter: Chrome sidecar started', { + sessionId, + chromeContainer: sidecarResult.containerName, + networkName: sidecarResult.networkName, + debugPort: sidecarResult.debugPort, + }); + } else { + logger.warn('DockerAdapter: Chrome sidecar failed, continuing without sidecar', { + sessionId, + error: sidecarResult.error, + }); + // graceful degradation: サイドカーなしでClaude Codeを起動 + sidecarResult = undefined; + } + } + const { createOptions, containerName } = this.buildContainerOptions(workingDir, { ...options, filterEnabled, registryFirewallEnabled, }); + // === .mcp.json注入のためのEntrypoint拡張(サイドカー成功時) === + if (sidecarResult?.success && sidecarResult.browserUrl) { + this.injectBrowserUrl(createOptions, sidecarResult.browserUrl); + } + // クライアントから渡されたターミナルサイズを使用(未指定時はデフォルト80x24) const initialCols = options?.cols ?? 80; const initialRows = options?.rows ?? 24; @@ -948,6 +981,71 @@ export class DockerAdapter extends BasePTYAdapter { .where(eq(schema.sessions.id, sessionId)) .run(); + // === サイドカーネットワークへのClaude Code接続 === + if (sidecarResult?.success && sidecarResult.networkName) { + let sidecarConnected = false; + try { + const sidecarService = ChromeSidecarService.getInstance(); + await sidecarService.connectClaudeContainer( + containerName, + sidecarResult.networkName + ); + sidecarConnected = true; + } catch (error) { + logger.warn('DockerAdapter: Failed to connect Claude container to sidecar network, stopping sidecar', { + sessionId, + networkName: sidecarResult.networkName, + error: error instanceof Error ? error.message : 'Unknown', + }); + // 接続失敗時はサイドカーを停止(best-effort)してDB保存しない + if (sidecarResult.containerName) { + try { + const sidecarService = ChromeSidecarService.getInstance(); + await sidecarService.stopSidecar(sessionId, sidecarResult.containerName, sidecarResult.networkName); + logger.info('DockerAdapter: Cleaned up sidecar after connect failure', { sessionId }); + } catch (cleanupError) { + logger.warn('DockerAdapter: Failed to cleanup sidecar after connect failure', { + sessionId, + error: cleanupError instanceof Error ? cleanupError.message : 'Unknown', + }); + } + } + // Claude Code自体は起動済みなのでサイドカーなしで続行 + } + + // DB更新: chrome_container_id, chrome_debug_port(接続成功時のみ) + if (sidecarConnected) { + try { + db.update(schema.sessions) + .set({ + chrome_container_id: sidecarResult.containerName ?? null, + chrome_debug_port: sidecarResult.debugPort ?? null, + updated_at: new Date(), + }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } catch (error) { + logger.warn('DockerAdapter: Failed to update chrome sidecar info in DB', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + // DB保存失敗時、stopSidecarで回収できるようbest-effortでクリーンアップ + if (sidecarResult?.containerName) { + try { + const sidecarService = ChromeSidecarService.getInstance(); + await sidecarService.stopSidecar(sessionId, sidecarResult.containerName, sidecarResult.networkName); + logger.info('DockerAdapter: Cleaned up sidecar after DB write failure', { sessionId }); + } catch (cleanupError) { + logger.warn('DockerAdapter: Failed to cleanup sidecar after DB write failure', { + sessionId, + error: cleanupError instanceof Error ? cleanupError.message : 'Unknown', + }); + } + } + } + } + } + // イベント転送 ptyProcess.onData((data: string) => { const session = this.sessions.get(sessionId); @@ -1100,6 +1198,20 @@ export class DockerAdapter extends BasePTYAdapter { } } + // サイドカーが起動していた場合はクリーンアップ + if (sidecarResult?.success && sidecarResult.containerName) { + try { + const sidecarService = ChromeSidecarService.getInstance(); + await sidecarService.stopSidecar(sessionId, sidecarResult.containerName, sidecarResult.networkName); + logger.info('DockerAdapter: Chrome sidecar cleaned up on createSession failure', { sessionId }); + } catch (sidecarCleanupError) { + logger.warn('DockerAdapter: Failed to cleanup Chrome sidecar on createSession failure', { + sessionId, + error: sidecarCleanupError instanceof Error ? sidecarCleanupError.message : 'Unknown error', + }); + } + } + logger.error('DockerAdapter: Failed to create session', { sessionId, error: error instanceof Error ? error.message : 'Unknown error', @@ -1173,6 +1285,49 @@ export class DockerAdapter extends BasePTYAdapter { }); } + // === サイドカーChrome停止(best-effort) === + try { + const sessionRecord = db.select({ + chrome_container_id: schema.sessions.chrome_container_id, + }).from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .get(); + + if (sessionRecord?.chrome_container_id) { + const sidecarService = ChromeSidecarService.getInstance(); + const stopResult = await sidecarService.stopSidecar( + sessionId, + sessionRecord.chrome_container_id + ); + + if (stopResult.success) { + // DB更新: chromeカラムをクリア(停止成功時のみ) + db.update(schema.sessions) + .set({ + chrome_container_id: null, + chrome_debug_port: null, + updated_at: new Date(), + }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + + logger.info('DockerAdapter: Chrome sidecar stopped', { sessionId }); + } else { + // 停止失敗時はDB参照を保持(孤立回収のため) + logger.warn('DockerAdapter: Chrome sidecar stop failed, keeping DB refs for orphan cleanup', { + sessionId, + error: stopResult.error, + }); + } + } + } catch (error) { + logger.warn('DockerAdapter: Failed to stop Chrome sidecar', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + // Chrome停止失敗はセッション破棄を妨げない + } + // フィルタリングルールのクリーンアップ(コンテナ停止後にbest-effort、ブロックしない) if (containerIP) { void new ProxyClient().deleteRules(containerIP) @@ -1190,6 +1345,81 @@ export class DockerAdapter extends BasePTYAdapter { } } + /** + * .mcp.jsonにbrowserUrlを注入するためのEntrypoint拡張 + * + * 既存のregistry-firewallパターンと同様に、 + * Entrypointをshell経由に変更してセットアップスクリプトを挿入する。 + */ + private injectBrowserUrl( + createOptions: Docker.ContainerCreateOptions, + browserUrl: string + ): void { + // browserUrlを環境変数経由で渡す(シェルインジェクション防止) + if (!createOptions.Env) createOptions.Env = []; + createOptions.Env.push(`__CHROME_BROWSER_URL=${browserUrl}`); + + // .mcp.json更新スクリプト + const mcpPackage = '@anthropic-ai/chrome-devtools-mcp@0.12.1'; + const mcpInjectScript = [ + 'MCP_FILE="${MCP_FILE:-.mcp.json}"', + '[ -f "$MCP_FILE" ] || echo \'{"mcpServers":{}}\' > "$MCP_FILE"', + `node -e '` + + `const fs = require("fs");` + + `const f = process.env.MCP_FILE || ".mcp.json";` + + `const url = process.env.__CHROME_BROWSER_URL;` + + `if (!url) process.exit(0);` + + `let cfg = {};` + + `try { cfg = JSON.parse(fs.readFileSync(f, "utf8")); } catch {}` + + `if (!cfg.mcpServers) cfg.mcpServers = {};` + + `if (!cfg.mcpServers["chrome-devtools"]) {` + + `cfg.mcpServers["chrome-devtools"] = {` + + `"command": "npx",` + + `"args": ["-y", "${mcpPackage}", "--browserUrl=" + url]` + + `};` + + `} else {` + + `const args = cfg.mcpServers["chrome-devtools"].args || [];` + + `const idx = args.findIndex(a => a.startsWith("--browserUrl="));` + + `if (idx >= 0) args[idx] = "--browserUrl=" + url;` + + `else args.push("--browserUrl=" + url);` + + `cfg.mcpServers["chrome-devtools"].args = args;` + + `}` + + `fs.writeFileSync(f, JSON.stringify(cfg, null, 2) + "\\n");` + + `'`, + ].join(' && '); + + const originalEntrypoint = createOptions.Entrypoint || []; + const originalCmd = createOptions.Cmd || []; + + // 既にshell経由の場合(registry-firewall等で変換済み) + if ( + Array.isArray(originalEntrypoint) && + originalEntrypoint.length === 2 && + originalEntrypoint[0] === '/bin/sh' && + originalEntrypoint[1] === '-c' && + Array.isArray(originalCmd) && + originalCmd.length > 0 + ) { + (originalCmd as string[])[0] = mcpInjectScript + ' && ' + (originalCmd as string[])[0]; + } else { + // shell経由に変換 + const allArgs = [ + ...(Array.isArray(originalEntrypoint) ? originalEntrypoint : [originalEntrypoint]), + ...(Array.isArray(originalCmd) ? originalCmd : []), + ].filter(Boolean); + createOptions.Entrypoint = ['/bin/sh', '-c']; + createOptions.Cmd = [ + mcpInjectScript + ' && exec "$@"', + '--', + ...allArgs, + ]; + } + + logger.info('DockerAdapter: Injected browser URL into container entrypoint', { + browserUrl, + }); + } + /** * リサイズ情報を新セッションに復元する */ diff --git a/src/services/chrome-sidecar-service.ts b/src/services/chrome-sidecar-service.ts new file mode 100644 index 000000000..566e22c54 --- /dev/null +++ b/src/services/chrome-sidecar-service.ts @@ -0,0 +1,529 @@ +import { DockerClient } from './docker-client'; +import { logger } from '@/lib/logger'; +import { db, schema } from '@/lib/db'; +import { eq, isNotNull } from 'drizzle-orm'; +import type { ChromeSidecarConfig } from '@/types/environment'; + +/** Chromeコンテナのメモリ制限 (512MB) */ +const CHROME_MEMORY_LIMIT = 512 * 1024 * 1024; + +/** + * サイドカー起動結果 + */ +export interface SidecarStartResult { + /** 起動成功かどうか */ + success: boolean; + /** Chromeコンテナ名 (成功時) */ + containerName?: string; + /** ネットワーク名 (成功時) */ + networkName?: string; + /** ホスト側デバッグポート (ポートマッピング成功時) */ + debugPort?: number; + /** Chrome内部URL (Claude CodeからのCDP接続先) */ + browserUrl?: string; + /** 失敗理由 (失敗時) */ + error?: string; +} + +/** + * startSidecar のオプション + */ +export interface StartSidecarOptions { + /** CDPヘルスチェックのタイムアウト (ミリ秒)。デフォルト: 30000 */ + cdpTimeoutMs?: number; +} + +/** + * ChromeSidecarService + * + * セッション単位のChromeサイドカーコンテナを管理する。 + * DockerClientを使用してDocker APIと通信する。 + */ +export class ChromeSidecarService { + private static instance: ChromeSidecarService; + + public static getInstance(): ChromeSidecarService { + if (!ChromeSidecarService.instance) { + ChromeSidecarService.instance = new ChromeSidecarService(); + } + return ChromeSidecarService.instance; + } + + /** @internal テスト専用: シングルトンインスタンスをリセット */ + public static resetInstance(): void { + ChromeSidecarService.instance = undefined as unknown as ChromeSidecarService; + } + + private getContainerName(sessionId: string): string { + return `cw-chrome-${sessionId}`; + } + + private getNetworkName(sessionId: string): string { + return `cw-net-${sessionId}`; + } + + /** + * サイドカーChromeを起動する + */ + async startSidecar( + sessionId: string, + config: ChromeSidecarConfig, + options?: StartSidecarOptions + ): Promise { + const containerName = this.getContainerName(sessionId); + const networkName = this.getNetworkName(sessionId); + const cdpTimeoutMs = options?.cdpTimeoutMs ?? 30000; + const docker = DockerClient.getInstance(); + + // 1. ネットワーク作成 + let network; + try { + network = await docker.getDockerInstance().createNetwork({ + Name: networkName, + Driver: 'bridge', + Labels: { + 'claude-work.session-id': sessionId, + 'claude-work.managed-by': 'claude-work', + }, + }); + } catch (error) { + logger.error('ChromeSidecarService: Failed to create network', { + sessionId, + networkName, + error: error instanceof Error ? error.message : 'Unknown', + }); + return { + success: false, + error: error instanceof Error ? error.message : 'Network creation failed', + }; + } + + // 2. コンテナ作成 + let container; + try { + container = await docker.createContainer({ + name: containerName, + Image: `${config.image}:${config.tag}`, + // Entrypoint/Cmdを明示的に指定し、イメージのデフォルトCMD(claude等)を上書き + Entrypoint: ['/usr/bin/chromium'], + Cmd: [ + '--headless', + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--remote-debugging-address=0.0.0.0', + '--remote-debugging-port=9222', + ], + ExposedPorts: { + '9222/tcp': {}, + }, + Labels: { + 'claude-work.session-id': sessionId, + 'claude-work.chrome-sidecar': 'true', + 'claude-work.managed-by': 'claude-work', + }, + HostConfig: { + PortBindings: { + '9222/tcp': [{ HostPort: '', HostIp: '127.0.0.1' }], + }, + CapDrop: ['ALL'], + SecurityOpt: ['no-new-privileges'], + Memory: CHROME_MEMORY_LIMIT, + AutoRemove: true, + NetworkMode: networkName, + }, + }); + } catch (error) { + logger.error('ChromeSidecarService: Failed to create container', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + // ロールバック: ネットワーク削除 + try { + await network.remove(); + } catch (cleanupError) { + logger.warn('ChromeSidecarService: Failed to cleanup network after container creation failure', { + sessionId, + cleanupError: cleanupError instanceof Error ? cleanupError.message : 'Unknown', + }); + } + return { + success: false, + error: error instanceof Error ? error.message : 'Container creation failed', + }; + } + + // 3. コンテナ起動 + try { + await container.start(); + } catch (error) { + logger.error('ChromeSidecarService: Failed to start container', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + // ロールバック: コンテナ削除(AutoRemoveは起動後停止時にのみ効くため、未起動コンテナは手動削除が必要) + try { + await container.remove({ force: true }); + } catch (containerCleanupError) { + logger.warn('ChromeSidecarService: Failed to cleanup container after start failure', { + sessionId, + containerCleanupError: containerCleanupError instanceof Error ? containerCleanupError.message : 'Unknown', + }); + } + // ロールバック: ネットワーク削除 + try { + await network.remove(); + } catch (cleanupError) { + logger.warn('ChromeSidecarService: Failed to cleanup network after container start failure', { + sessionId, + cleanupError: cleanupError instanceof Error ? cleanupError.message : 'Unknown', + }); + } + return { + success: false, + error: error instanceof Error ? error.message : 'Container start failed', + }; + } + + // 4. ポートマッピング取得(CDPヘルスチェックに必要) + let debugPort: number | undefined; + try { + const containerRef = docker.getContainer(containerName); + const inspectInfo = await containerRef.inspect(); + const portBindings = inspectInfo.NetworkSettings?.Ports?.['9222/tcp']; + if (portBindings && portBindings.length > 0 && portBindings[0].HostPort) { + debugPort = parseInt(portBindings[0].HostPort, 10); + } + } catch (error) { + logger.warn('ChromeSidecarService: Failed to get port mapping', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown', + }); + } + + // 5. CDPヘルスチェック(ホスト側ポート経由でHTTP GETポーリング) + // debugPort未取得時はCDPヘルスチェックをスキップし、コンテナRunning確認のみで成功とする。 + // ポートマッピング失敗時もサイドカーは起動し、chrome_debug_port=NULLで動作継続する。 + if (debugPort) { + const cdpReady = await this.waitForCDP(debugPort, cdpTimeoutMs); + if (!cdpReady) { + logger.warn('ChromeSidecarService: CDP health check timed out', { sessionId, debugPort }); + // クリーンアップ + try { + const containerRef = docker.getContainer(containerName); + await containerRef.stop(); + } catch { + // AutoRemoveで既に消えている可能性あり + } + try { + await network.remove(); + } catch { + // best-effort + } + return { + success: false, + error: `CDP health check timed out after ${cdpTimeoutMs}ms`, + }; + } + } else { + logger.warn('ChromeSidecarService: No debug port available, skipping CDP health check', { + sessionId, + containerName, + }); + } + + // chrome-devtools-mcpがベースURLを受け取り、内部で/json/versionから完全なbrowserUrlを取得する + const browserUrl = `http://${containerName}:9222`; + + logger.info('ChromeSidecarService: Sidecar started successfully', { + sessionId, + containerName, + networkName, + debugPort, + browserUrl, + }); + + return { + success: true, + containerName, + networkName, + debugPort, + browserUrl, + }; + } + + /** + * サイドカーChromeを停止・削除する + * + * @returns 成功/失敗を示すオブジェクト。失敗時はerrorにメッセージを含む。 + */ + async stopSidecar( + sessionId: string, + containerName: string, + networkName?: string + ): Promise<{ success: boolean; error?: string }> { + const docker = DockerClient.getInstance(); + const resolvedNetworkName = networkName ?? this.getNetworkName(sessionId); + const errors: string[] = []; + + // コンテナ停止 + // AutoRemove=trueのコンテナは停止時に自動削除される。 + // 既に削除済みのコンテナに対するstop()は404/304/No such containerエラーを返すため、 + // これらは成功扱いとする(DockerAdapter.stopContainer()と同様のパターン)。 + try { + const container = docker.getContainer(containerName); + await container.stop(); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown'; + const isAlreadyGone = + msg.includes('404') || + msg.includes('304') || + msg.includes('No such container') || + msg.includes('not running'); + if (!isAlreadyGone) { + logger.warn('ChromeSidecarService: Failed to stop chrome container', { + sessionId, + containerName, + error: msg, + }); + errors.push(`container stop: ${msg}`); + } else { + logger.info('ChromeSidecarService: Chrome container already removed (AutoRemove)', { + sessionId, + containerName, + }); + } + } + + // ネットワーク: 接続中コンテナをbest-effortでdisconnect + try { + const network = docker.getDockerInstance().getNetwork(resolvedNetworkName); + const networkInfo = await network.inspect(); + const containers = networkInfo.Containers || {}; + for (const containerId of Object.keys(containers)) { + try { + await network.disconnect({ Container: containerId, Force: true }); + } catch { + // best-effort + } + } + await network.remove(); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown'; + logger.warn('ChromeSidecarService: Failed to remove network', { + sessionId, + networkName: resolvedNetworkName, + error: msg, + }); + errors.push(`network remove: ${msg}`); + } + + if (errors.length > 0) { + return { success: false, error: errors.join('; ') }; + } + return { success: true }; + } + + /** + * Claude Codeコンテナをサイドカーネットワークに接続する + */ + async connectClaudeContainer( + claudeContainerName: string, + networkName: string + ): Promise { + const docker = DockerClient.getInstance(); + const network = docker.getDockerInstance().getNetwork(networkName); + await network.connect({ + Container: claudeContainerName, + }); + } + + /** + * 孤立したChromeコンテナ・ネットワークをクリーンアップする + */ + async cleanupOrphaned(): Promise { + const docker = DockerClient.getInstance(); + + // Phase 1: DBベースのクリーンアップ + const sessionsWithChrome = db.select({ + id: schema.sessions.id, + chrome_container_id: schema.sessions.chrome_container_id, + }) + .from(schema.sessions) + .where(isNotNull(schema.sessions.chrome_container_id)) + .all(); + + for (const session of sessionsWithChrome) { + if (!session.chrome_container_id) continue; + + try { + const container = docker.getContainer(session.chrome_container_id); + const inspectInfo = await container.inspect(); + + if (inspectInfo.State?.Running) { + // 実行中のコンテナはスキップ + continue; + } + } catch { + // コンテナが存在しない(AutoRemoveで削除済み等) + } + + // 停止済みまたは存在しないコンテナ: DB更新とネットワーク削除 + try { + const networkName = this.getNetworkName(session.id); + const network = docker.getDockerInstance().getNetwork(networkName); + await network.remove(); + } catch { + // ネットワークも既に削除済みの場合がある + } + + db.update(schema.sessions) + .set({ + chrome_container_id: null, + chrome_debug_port: null, + updated_at: new Date(), + }) + .where(eq(schema.sessions.id, session.id)) + .run(); + + logger.info('ChromeSidecarService: Cleaned up orphaned sidecar from DB', { + sessionId: session.id, + chromeContainerId: session.chrome_container_id, + }); + } + + // Phase 2: ラベルベースのクリーンアップ + try { + const sidecarContainers = await docker.listContainers({ + all: true, + filters: JSON.stringify({ + label: ['claude-work.chrome-sidecar=true'], + }), + }); + + for (const containerInfo of sidecarContainers) { + const containerSessionId = containerInfo.Labels?.['claude-work.session-id']; + if (!containerSessionId) continue; + + // DBにセッションが存在するか確認 + const sessionExists = db.select({ id: schema.sessions.id }) + .from(schema.sessions) + .where(eq(schema.sessions.id, containerSessionId)) + .get(); + + if (!sessionExists) { + // 孤立コンテナを停止 + try { + const container = docker.getContainer(containerInfo.Id); + await container.stop(); + logger.info('ChromeSidecarService: Stopped orphaned sidecar container', { + containerId: containerInfo.Id, + sessionId: containerSessionId, + }); + } catch { + // 既に停止済みの場合がある + } + + // 孤立ネットワークも削除 + try { + const networkName = this.getNetworkName(containerSessionId); + const network = docker.getDockerInstance().getNetwork(networkName); + await network.remove(); + logger.info('ChromeSidecarService: Removed orphaned sidecar network', { + networkName, + sessionId: containerSessionId, + }); + } catch { + // ネットワークが既に削除済みの場合がある + } + } + } + } catch (error) { + logger.warn('ChromeSidecarService: Label-based cleanup failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + } + + // Phase 2b: 孤立ネットワークの回収 + try { + const networks = await docker.getDockerInstance().listNetworks({ + filters: JSON.stringify({ + label: ['claude-work.managed-by=claude-work'], + }), + }); + + for (const networkInfo of networks) { + // cw-net- プレフィックスのネットワークのみ対象 + if (!networkInfo.Name?.startsWith('cw-net-')) continue; + + try { + const network = docker.getDockerInstance().getNetwork(networkInfo.Id); + const detail = await network.inspect(); + const connectedContainers = Object.keys(detail.Containers || {}); + + if (connectedContainers.length === 0) { + await network.remove(); + logger.info('ChromeSidecarService: Removed orphaned network', { + networkName: networkInfo.Name, + networkId: networkInfo.Id, + }); + } + } catch { + // best-effort: ネットワークが既に削除済みの場合がある + } + } + } catch (error) { + logger.warn('ChromeSidecarService: Orphaned network cleanup failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + } + } + + /** + * 現在起動中のサイドカー数を取得する + */ + async getActiveSidecarCount(): Promise { + const docker = DockerClient.getInstance(); + const containers = await docker.listContainers({ + filters: JSON.stringify({ + label: ['claude-work.chrome-sidecar=true'], + status: ['running'], + }), + }); + return containers.length; + } + + /** + * CDPヘルスチェック(内部メソッド) + * + * ホスト側ポート経由で http://127.0.0.1:/json/version に + * HTTP GETポーリングし、CDPが実際に応答可能か検証する。 + */ + private async waitForCDP( + debugPort: number, + timeoutMs: number + ): Promise { + + const startTime = Date.now(); + const interval = 1000; + const url = `http://127.0.0.1:${debugPort}/json/version`; + + while (Date.now() - startTime < timeoutMs) { + try { + const controller = new AbortController(); + // 個々のfetchタイムアウト: ポーリング間隔以下に設定し重複を防ぐ + const fetchTimeout = setTimeout(() => controller.abort(), interval); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(fetchTimeout); + if (response.ok) { + return true; + } + } catch { + // CDPがまだ準備中 + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + return false; + } +} diff --git a/src/services/environment-adapter.ts b/src/services/environment-adapter.ts index 1bad52e8d..06265fbc7 100644 --- a/src/services/environment-adapter.ts +++ b/src/services/environment-adapter.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import type { ClaudeCodeOptions, CustomEnvVars } from './claude-options-service'; +import type { ChromeSidecarConfig } from '../types/environment'; /** * セッション作成オプション @@ -24,6 +25,8 @@ export interface CreateSessionOptions { filterEnabled?: boolean; /** registry-firewall有効フラグ(パッケージマネージャーのレジストリ設定注入用) */ registryFirewallEnabled?: boolean; + /** サイドカーChrome設定 */ + chromeSidecar?: ChromeSidecarConfig; } /** diff --git a/src/services/pty-session-manager.ts b/src/services/pty-session-manager.ts index b77d1afb5..461a4f458 100644 --- a/src/services/pty-session-manager.ts +++ b/src/services/pty-session-manager.ts @@ -7,6 +7,7 @@ import { logger } from '@/lib/logger' import { ScrollbackBuffer } from './scrollback-buffer' import { ClaudeOptionsService } from './claude-options-service' import type { ClaudeCodeOptions, CustomEnvVars } from './claude-options-service' +import type { ChromeSidecarConfig } from '@/types/environment' import { ensureConfigLoaded } from './config-service' import { ClaudeDefaultsResolver } from './claude-defaults-resolver' import type WebSocket from 'ws' @@ -220,6 +221,25 @@ export class PTYSessionManager extends EventEmitter implements IPTYSessionManage // Registry Firewall有効フラグをConfigServiceから取得 const registryFirewallEnabled = configService.getRegistryFirewallEnabled() + // Chrome Sidecar設定の読み取り(ランタイムバリデーション付き) + let chromeSidecar: ChromeSidecarConfig | undefined; + const rawSidecar = (envConfig as Record).chromeSidecar; + if (rawSidecar && typeof rawSidecar === 'object' && !Array.isArray(rawSidecar)) { + const s = rawSidecar as Record; + if ( + typeof s.enabled === 'boolean' && + typeof s.image === 'string' && + typeof s.tag === 'string' + ) { + chromeSidecar = s.enabled ? (s as unknown as ChromeSidecarConfig) : undefined; + } else { + logger.warn('PTYSessionManager: Invalid chromeSidecar config, ignoring', { + sessionId, + chromeSidecar: rawSidecar, + }); + } + } + // アダプター経由でセッション作成 await adapter.createSession( sessionId, @@ -234,6 +254,7 @@ export class PTYSessionManager extends EventEmitter implements IPTYSessionManage skipPermissions, dockerVolumeId: options.dockerVolumeId, registryFirewallEnabled, + chromeSidecar, } ) diff --git a/src/types/__tests__/chrome-sidecar-config.test.ts b/src/types/__tests__/chrome-sidecar-config.test.ts new file mode 100644 index 000000000..f8ef3a04c --- /dev/null +++ b/src/types/__tests__/chrome-sidecar-config.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import type { DockerEnvironmentConfig, ChromeSidecarConfig } from '../environment'; +import type { CreateSessionOptions } from '../../services/environment-adapter'; + +describe('Chrome Sidecar 型定義', () => { + describe('ChromeSidecarConfig型の検証', () => { + it('enabled, image, tagフィールドを持つオブジェクトが型に適合すること', () => { + const config: ChromeSidecarConfig = { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }; + expect(config.enabled).toBe(true); + expect(config.image).toBe('ghcr.io/windschord/claude-work-sandbox'); + expect(config.tag).toBe('chrome-devtools'); + }); + }); + + describe('DockerEnvironmentConfig後方互換性の検証', () => { + it('chromeSidecarキーなしのconfigが有効であること', () => { + const config: DockerEnvironmentConfig = { + imageName: 'test-image', + imageTag: 'latest', + }; + expect(config.chromeSidecar).toBeUndefined(); + }); + + it('chromeSidecarキーありのconfigが有効であること', () => { + const config: DockerEnvironmentConfig = { + imageName: 'test-image', + imageTag: 'latest', + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }; + expect(config.chromeSidecar).toBeDefined(); + expect(config.chromeSidecar!.enabled).toBe(true); + }); + + it('既存のimageName, imageTag等のフィールドが影響を受けないこと', () => { + const config: DockerEnvironmentConfig = { + imageSource: 'existing', + imageName: 'my-image', + imageTag: 'v1', + skipPermissions: true, + portMappings: [{ hostPort: 3000, containerPort: 3000 }], + volumeMounts: [{ hostPath: '/tmp', containerPath: '/data' }], + }; + expect(config.imageSource).toBe('existing'); + expect(config.imageName).toBe('my-image'); + expect(config.imageTag).toBe('v1'); + expect(config.skipPermissions).toBe(true); + expect(config.portMappings).toHaveLength(1); + expect(config.volumeMounts).toHaveLength(1); + }); + }); + + describe('CreateSessionOptions拡張の検証', () => { + it('chromeSidecarフィールドなしのオプションが有効であること', () => { + const options: CreateSessionOptions = { + resumeSessionId: 'test-id', + }; + expect(options.chromeSidecar).toBeUndefined(); + }); + + it('chromeSidecarフィールドありのオプションが有効であること', () => { + const options: CreateSessionOptions = { + chromeSidecar: { + enabled: true, + image: 'ghcr.io/windschord/claude-work-sandbox', + tag: 'chrome-devtools', + }, + }; + expect(options.chromeSidecar).toBeDefined(); + expect(options.chromeSidecar!.enabled).toBe(true); + }); + }); +}); diff --git a/src/types/environment.ts b/src/types/environment.ts index 1d1fc2b7e..840a7a543 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -17,6 +17,13 @@ export interface VolumeMount { sourceType?: 'bind' | 'volume'; // 省略時のデフォルト: 'bind' } +/** Chrome Sidecar設定 */ +export interface ChromeSidecarConfig { + enabled: boolean; + image: string; // デフォルト: 'ghcr.io/windschord/claude-work-sandbox' + tag: string; // 固定バージョン必須、'latest'禁止 +} + /** Docker環境の拡張config */ export interface DockerEnvironmentConfig { imageSource?: 'existing' | 'dockerfile'; @@ -27,4 +34,5 @@ export interface DockerEnvironmentConfig { skipPermissions?: boolean; portMappings?: PortMapping[]; volumeMounts?: VolumeMount[]; + chromeSidecar?: ChromeSidecarConfig; }