diff --git a/bin/test b/bin/test new file mode 100755 index 00000000..166495fe --- /dev/null +++ b/bin/test @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs the same checks as CI by parsing .github/workflows/ci.yml directly. +# If CI steps change, this script automatically picks them up. +# +# Local adaptations: +# - `npm ci` checks if node_modules is in sync with package-lock.json +# and runs a clean install if not (CI always does npm ci). +# - `npm run format:check` checks only git-tracked files because CI +# runs on a clean checkout but locally we have untracked x.* scratch +# files that fail prettier. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +ci_yaml=".github/workflows/ci.yml" + +if ! command -v yq &>/dev/null; then + echo "error: yq is required (brew install yq)" >&2 + exit 1 +fi + +# Extract run steps +mapfile -t names < <(yq '.jobs.build.steps[] | select(.run) | .name' "$ci_yaml") +mapfile -t commands < <(yq '.jobs.build.steps[] | select(.run) | .run' "$ci_yaml") + +for i in "${!commands[@]}"; do + cmd="${commands[$i]}" + name="${names[$i]}" + + echo "=== ${name} ===" + + if [[ "$cmd" == "npm ci" ]]; then + # Check if node_modules matches package-lock.json. If not, run + # npm ci to match what CI does. This catches stale-dependency bugs + # like sdk-tools.d.ts resolving locally but not in CI. + if npm ls --all >/dev/null 2>&1; then + echo "(node_modules in sync — skipping npm ci)" + else + echo "(node_modules out of sync — running npm ci)" + npm ci + fi + elif [[ "$cmd" == "npm run format:check" ]]; then + # Local override: format:check on git-tracked files only + git ls-files -z '*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.md' '*.yml' '*.yaml' '*.css' '*.html' \ + | xargs -0 npx prettier --check + else + eval "$cmd" + fi + + echo "" +done + +echo "=== All CI checks passed ===" diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 6c4fb342..27bdad0c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1565,6 +1565,104 @@ export class ClaudeAcpAgent implements Agent { throw error; } + // MCP OAuth: detect servers that need authentication and trigger the + // SDK's built-in OAuth flow. The Claude Code CLI subprocess handles + // the full PKCE flow (RFC 9728 discovery, dynamic client registration, + // localhost callback server, token exchange, keychain storage). + // + // The `mcp_authenticate` control message is an undocumented internal + // API of the Claude Code CLI. It triggers OAuth discovery for the + // named server and returns an `authUrl` for user consent. The CLI + // starts a localhost callback server to receive the authorization code. + if (!creationOpts?.resume && Object.keys(mcpServers).length > 0) { + // Give MCP connections time to attempt (they start during init) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + const mcpStatuses = await q.mcpServerStatus(); + for (const server of mcpStatuses) { + if (server.status === "needs-auth") { + this.logger.log( + `[MCP OAuth] Server "${server.name}" needs auth, triggering OAuth flow...`, + ); + try { + // @ts-expect-error — mcp_authenticate is not in the public SDK types + const authResponse = await q.request({ + subtype: "mcp_authenticate", + serverName: server.name, + }); + const result = authResponse?.response ?? authResponse; + + if (result?.authUrl && result?.requiresUserAction) { + const { execSync: execSyncCmd } = await import("child_process"); + + // Open the auth URL in the user's browser. Mirrors the + // approach used by the CLI's internal openUrl function + // (minified as $Y): respects $BROWSER, uses platform- + // specific commands, and detects headless environments. + let opened = false; + try { + const browserEnv = process.env.BROWSER; + if (process.platform === "win32") { + if (browserEnv) { + execSyncCmd(`${browserEnv} "${result.authUrl}"`, { stdio: "ignore" }); + } else { + execSyncCmd(`rundll32 url,OpenURL ${result.authUrl}`, { stdio: "ignore" }); + } + opened = true; + } else { + const cmd = browserEnv || (process.platform === "darwin" ? "open" : "xdg-open"); + execSyncCmd(`${cmd} "${result.authUrl}"`, { stdio: "ignore" }); + opened = true; + } + } catch { + opened = false; + } + + if (opened) { + this.logger.log(`[MCP OAuth] Opening browser for "${server.name}"...`); + } else { + this.logger.error( + `[MCP OAuth] Cannot open browser (headless environment?). ` + + `Server "${server.name}" requires OAuth. ` + + `Authenticate manually or provide Authorization headers. ` + + `Auth URL: ${result.authUrl}`, + ); + continue; + } + + // Poll until connected (up to 60s) + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const newStatuses = await q.mcpServerStatus(); + const newStatus = newStatuses.find((s) => s.name === server.name); + if (newStatus?.status === "connected") { + this.logger.log(`[MCP OAuth] Server "${server.name}" connected!`); + break; + } + if (newStatus?.status !== "needs-auth" && newStatus?.status !== "pending") { + this.logger.error( + `[MCP OAuth] Server "${server.name}" unexpected status: ${newStatus?.status}`, + ); + break; + } + } + } else if (result?.requiresUserAction === false) { + this.logger.log( + `[MCP OAuth] Server "${server.name}" authenticated automatically (cached tokens)`, + ); + } + } catch (authError) { + this.logger.error(`[MCP OAuth] Auth failed for "${server.name}": ${authError}`); + } + } + } + } catch (statusError) { + this.logger.error(`[MCP OAuth] mcpServerStatus() failed: ${statusError}`); + } + } + if ( shouldHideClaudeAuth() && initializationResult.account.subscriptionType &&