From b779a675a5448006d77ffe737295cb504256a448 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 19 Aug 2026 10:49:21 -0700 Subject: [PATCH] Improve Intern MCP setup diagnostics --- README.md | 12 +++- scripts/harness-install-smoke.mjs | 46 ++++++++---- src/api.test.ts | 33 +++++++++ src/api.ts | 113 +++++++++++++++++++++++++++--- src/index.ts | 28 +++++--- src/setup.test.ts | 43 ++++++++++-- src/setup.ts | 113 ++++++++++++++++++++++++++---- 7 files changed, 335 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 06ac790..6b7e50e 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,21 @@ user configuration and starts the package when a session needs the server. npx --yes @archastro/intern-mcp@0.1.0 setup --host claude ``` +Add `--verbose` to either setup command to print redacted request lifecycle +diagnostics on stderr. Verbose output includes the method, query-free endpoint, +status, duration, and allowlisted response metadata. It never prints the access +token, request headers, response body, or cookies. + The installer uses Claude Code's user scope, so Intern is available in every project. Run `/mcp` inside Claude Code to inspect the connection. Create a profile access token at , copy it, then run the command for your host. The installer validates the token, configures the host through its native CLI, verifies the saved registration, and prints the -Intern organization and role. Paste the token at the hidden terminal prompt so -it never enters shell history. Intern displays it only once. The host stores it -through the `intern-mcp launch` profile command. The bearer itself lives in +Intern organization and role. The terminal renders one `*` for every pasted +token character so the paste is visible without revealing the secret or adding +it to shell history. Intern displays it only once. The host stores it through +the `intern-mcp launch` profile command. The bearer itself lives in `~/.config/intern/access-token` with mode `0600`; it is never placed in child process arguments or host configuration. diff --git a/scripts/harness-install-smoke.mjs b/scripts/harness-install-smoke.mjs index 3468bdf..8a889ba 100644 --- a/scripts/harness-install-smoke.mjs +++ b/scripts/harness-install-smoke.mjs @@ -62,17 +62,24 @@ try { .end('{"error":"unauthorized"}'); return; } - response.writeHead(200, { "content-type": "application/json" }).end( - JSON.stringify({ - user: { - id: "usr_harness", - org: "org_harness", - org_name: "Harness", - org_role: "admin", - }, - org: { id: "intorg_harness", slug: "harness", state: "active" }, - }), - ); + response + .writeHead(200, { + "content-type": "application/json", + "set-cookie": "session=harness-response-secret", + "x-private-header": "harness-private-secret", + "x-request-id": "harness-request-id", + }) + .end( + JSON.stringify({ + user: { + id: "usr_harness", + org: "org_harness", + org_name: "Harness", + org_role: "admin", + }, + org: { id: "intorg_harness", slug: "harness", state: "active" }, + }), + ); }); await new Promise((resolve) => sessionServer.listen(0, "127.0.0.1", resolve)); const address = sessionServer.address(); @@ -90,10 +97,25 @@ try { ]; // Cross the packaged setup command and each real host configuration writer. - const codexSetup = await run("npx", [...setupCommand, "setup", "--host", "codex"]); + const codexSetup = await run("npx", [ + ...setupCommand, + "setup", + "--host", + "codex", + "--verbose", + ]); if (!codexSetup.stdout.includes("Intern connected to Codex as Harness ยท admin")) { throw new Error("packaged setup did not validate and configure Codex"); } + if ( + !codexSetup.stderr.includes("http.response") || + !codexSetup.stderr.includes('requestId="harness-request-id"') || + codexSetup.stderr.includes("harness-proof-token") || + codexSetup.stderr.includes("harness-response-secret") || + codexSetup.stderr.includes("harness-private-secret") + ) { + throw new Error("verbose setup diagnostics were missing or exposed a secret"); + } const codex = await run("codex", ["mcp", "get", "intern"]); if ( !codex.stdout.includes("intern-mcp") || diff --git a/src/api.test.ts b/src/api.test.ts index 8b029df..1326f61 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -67,3 +67,36 @@ test("sends IAP and ArchAstro credentials in separate headers when minting SSH c expect(request?.headers.get("proxy-authorization")).toBe("Bearer google-id-token"); expect(request?.headers.get("authorization")).toBe("Bearer archastro-token"); }); + +test("verbose diagnostics identify an IAP interception without exposing request secrets", async () => { + const diagnostics: string[] = []; + const api = new InternAPI( + { + internBaseURL: "https://tryintern.dev", + workspaceRoot: "/tmp/workspaces", + configRoot: "/tmp/config", + }, + { accessToken: async () => "secret-profile-token" } as never, + async () => + new Response("Invalid IAP credentials: secret-reflection", { + status: 401, + headers: { + "content-type": "text/html; charset=UTF-8", + "set-cookie": "session=secret-cookie", + "x-goog-iap-generated-response": "true", + "x-private-header": "secret-header", + }, + }), + (line) => diagnostics.push(line), + ); + + await expect(api.session()).rejects.toThrow("AUTH_REQUIRED"); + const output = diagnostics.join("\n"); + expect(output).toContain("http.response"); + expect(output).toContain("status=401"); + expect(output).toContain("iapGeneratedResponse=true"); + expect(output).not.toContain("secret-profile-token"); + expect(output).not.toContain("secret-reflection"); + expect(output).not.toContain("secret-cookie"); + expect(output).not.toContain("secret-header"); +}); diff --git a/src/api.ts b/src/api.ts index 680d8e8..8d32015 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,6 +3,8 @@ import type { AuthClient } from "./auth.js"; import type { InternConfig } from "./config.js"; import * as z from "zod/v4"; +export type DiagnosticSink = (line: string) => void; + export interface InternSession { user: { id: string; @@ -101,6 +103,7 @@ export class InternAPI { private readonly config: InternConfig, private readonly auth: AuthClient, private readonly fetchFn: typeof fetch = fetch, + private readonly diagnostics?: DiagnosticSink, ) {} session(): Promise { @@ -136,17 +139,42 @@ export class InternAPI { private async request(pathname: string, init: RequestInit = {}): Promise { const token = await this.auth.accessToken(); - const response = await this.fetchFn(`${this.config.internBaseURL}${pathname}`, { - ...init, - signal: AbortSignal.timeout(30_000), - headers: { - "content-type": "application/json", - authorization: `Bearer ${token}`, - ...(this.config.iapIDToken - ? { "proxy-authorization": `Bearer ${this.config.iapIDToken}` } - : {}), - ...init.headers, - }, + const method = init.method ?? "GET"; + const diagnosticFields = { + method, + origin: diagnosticOrigin(this.config.internBaseURL), + path: pathname, + }; + const startedAt = performance.now(); + this.log("http.start", diagnosticFields); + let response: Response; + try { + response = await this.fetchFn(`${this.config.internBaseURL}${pathname}`, { + ...init, + signal: AbortSignal.timeout(30_000), + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + ...(this.config.iapIDToken + ? { "proxy-authorization": `Bearer ${this.config.iapIDToken}` } + : {}), + ...init.headers, + }, + }); + } catch (error) { + this.log("http.error", { + ...diagnosticFields, + durationMs: Math.round(performance.now() - startedAt), + errorName: safeErrorName(error), + ...safeErrorCodes(error), + }); + throw error; + } + this.log("http.response", { + ...diagnosticFields, + status: response.status, + durationMs: Math.round(performance.now() - startedAt), + ...diagnosticResponseHeaders(response), }); const body = (await response.json().catch(() => ({}))) as Record; if (!response.ok) { @@ -160,4 +188,67 @@ export class InternAPI { } return body as T; } + + private log(event: string, fields: Record): void { + if (!this.diagnostics) return; + const values = Object.entries(fields) + .map(([name, value]) => `${name}=${JSON.stringify(value)}`) + .join(" "); + this.diagnostics(`[intern-mcp] ${event} ${values}`); + } +} + +function diagnosticOrigin(value: string): string { + try { + const url = new URL(value); + return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`; + } catch { + return "invalid-url"; + } +} + +function diagnosticResponseHeaders( + response: Response, +): Record { + const fields: Record = {}; + const contentType = response.headers.get("content-type"); + if ( + contentType && + /^[A-Za-z0-9!#$&^_.+/-]+(?:;\s*[A-Za-z0-9!#$&^_.+/-]+=[A-Za-z0-9!#$&^_.+/-]+)*$/.test( + contentType, + ) + ) { + fields.contentType = contentType; + } + const requestID = response.headers.get("x-request-id"); + if (requestID && /^[A-Za-z0-9._:-]{1,128}$/.test(requestID)) { + fields.requestId = requestID; + } + if (response.headers.get("x-goog-iap-generated-response") === "true") { + fields.iapGeneratedResponse = true; + } + return fields; +} + +function safeErrorName(error: unknown): string { + if (!(error instanceof Error)) return "UnknownError"; + return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(error.name) ? error.name : "Error"; +} + +function safeErrorCodes(error: unknown): Record { + const fields: Record = {}; + const code = errorCode(error); + if (code) fields.errorCode = code; + const cause = error instanceof Error && "cause" in error ? error.cause : undefined; + const causeCode = errorCode(cause); + if (causeCode) fields.causeCode = causeCode; + return fields; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const value = String(error.code); + return /^[A-Z0-9_]{1,40}$/.test(value) ? value : undefined; } diff --git a/src/index.ts b/src/index.ts index 734beeb..ab5faf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,26 +6,35 @@ import { loadConfig } from "./config.js"; import { buildServer } from "./server.js"; import { WorkspaceManager } from "./workspace.js"; import { SSHCredentialManager } from "./ssh.js"; -import { parseSetupHost, readStoredAccessToken, runSetup } from "./setup.js"; +import { parseSetupOptions, readStoredAccessToken, runSetup } from "./setup.js"; const config = loadConfig(); const command = process.argv[2] ?? "serve"; +const verbose = + process.argv.includes("--verbose") || process.env.INTERN_MCP_VERBOSE === "1"; +const diagnostics = verbose + ? (line: string) => process.stderr.write(`${line}\n`) + : undefined; switch (command) { case "serve": - await serveUntilClosed(new AuthClient()); + await serveUntilClosed(new AuthClient(), diagnostics); break; case "launch": - await serveUntilClosed(new AuthClient(await readStoredAccessToken(config))); + await serveUntilClosed( + new AuthClient(await readStoredAccessToken(config)), + diagnostics, + ); break; case "status": process.stdout.write( - `${JSON.stringify(await new InternAPI(config, new AuthClient()).session(), null, 2)}\n`, + `${JSON.stringify(await new InternAPI(config, new AuthClient(), fetch, diagnostics).session(), null, 2)}\n`, ); break; case "setup": try { - await runSetup(config, parseSetupHost(process.argv.slice(3))); + const options = parseSetupOptions(process.argv.slice(3)); + await runSetup(config, options.host, { verbose: options.verbose }); } catch (error) { process.stderr.write( `Intern setup failed: ${error instanceof Error ? error.message : "request failed"}\n`, @@ -35,13 +44,16 @@ switch (command) { break; default: process.stderr.write( - "Usage: intern-mcp serve|launch|status|setup --host codex|claude\n", + "Usage: intern-mcp serve|launch|status|setup --host codex|claude [--verbose]\n", ); process.exitCode = 2; } -async function serveUntilClosed(auth: AuthClient): Promise { - const api = new InternAPI(config, auth); +async function serveUntilClosed( + auth: AuthClient, + diagnosticSink?: (line: string) => void, +): Promise { + const api = new InternAPI(config, auth, fetch, diagnosticSink); const ssh = new SSHCredentialManager(config, api); const workspaces = new WorkspaceManager(config, ssh); const handle = serveStdio(() => buildServer(auth, api, workspaces)); diff --git a/src/setup.test.ts b/src/setup.test.ts index acb7cd5..ba99685 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -5,7 +5,7 @@ import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InternSession } from "./api.js"; import { - parseSetupHost, + parseSetupOptions, promptAccessToken, readStoredAccessToken, runSetup, @@ -235,10 +235,17 @@ describe("Intern MCP setup", () => { ).resolves.toBe("99999999\n"); }); - it("accepts only explicit supported hosts", () => { - expect(parseSetupHost(["--host", "codex"])).toBe("codex"); - expect(parseSetupHost(["--host=claude"])).toBe("claude"); - expect(() => parseSetupHost(["--host", "cursor"])).toThrow("Usage:"); + it("accepts supported hosts and opt-in verbose diagnostics", () => { + expect(parseSetupOptions(["--host", "codex"])).toEqual({ + host: "codex", + verbose: false, + }); + expect(parseSetupOptions(["--verbose", "--host=claude"])).toEqual({ + host: "claude", + verbose: true, + }); + expect(() => parseSetupOptions(["--host", "cursor"])).toThrow("Usage:"); + expect(() => parseSetupOptions(["--host", "codex", "--debug"])).toThrow("Usage:"); }); it("reads a piped token without echoing it", async () => { @@ -252,4 +259,30 @@ describe("Intern MCP setup", () => { expect(visible).toContain("Paste Intern access token"); expect(visible).not.toContain("secret-token"); }); + + it("renders one asterisk per pasted token character on a terminal", async () => { + const input = new PassThrough() as PassThrough & { isTTY: boolean }; + const output = new PassThrough() as PassThrough & { isTTY: boolean }; + input.isTTY = true; + output.isTTY = true; + let visible = ""; + output.on("data", (chunk) => (visible += chunk.toString())); + input.end("secret-token\n"); + + await expect(promptAccessToken(input, output)).resolves.toBe("secret-token"); + expect(visible).toContain("*".repeat("secret-token".length)); + expect(visible).not.toContain("secret-token"); + }); + + it("rejects terminal cancellation instead of leaving setup pending", async () => { + const input = new PassThrough() as PassThrough & { isTTY: boolean }; + const output = new PassThrough() as PassThrough & { isTTY: boolean }; + input.isTTY = true; + output.isTTY = true; + + const pending = promptAccessToken(input, output); + input.write(String.fromCharCode(3)); + + await expect(pending).rejects.toThrow("Token entry cancelled"); + }); }); diff --git a/src/setup.ts b/src/setup.ts index cdc7981..5f35c22 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,7 +4,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { createInterface } from "node:readline/promises"; +import { createInterface, type Interface } from "node:readline"; import { Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -39,16 +39,39 @@ interface SetupDependencies { run?: (command: string, args: string[]) => Promise; write?: (message: string) => void; env?: NodeJS.ProcessEnv; + verbose?: boolean; } class HostConfigurationCommittedError extends Error {} -export function parseSetupHost(args: string[]): SetupHost { - const equals = args.find((value) => value.startsWith("--host=")); - const index = args.indexOf("--host"); - const value = equals?.slice("--host=".length) ?? (index >= 0 ? args[index + 1] : ""); - if (value === "codex" || value === "claude") return value; - throw new Error("Usage: intern-mcp setup --host codex|claude"); +export function parseSetupOptions(args: string[]): { + host: SetupHost; + verbose: boolean; +} { + let host: SetupHost | undefined; + let verbose = false; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === "--verbose") { + verbose = true; + continue; + } + const hostValue = value.startsWith("--host=") + ? value.slice("--host=".length) + : value === "--host" + ? args[++index] + : undefined; + if ( + hostValue === undefined || + host !== undefined || + (hostValue !== "codex" && hostValue !== "claude") + ) { + throw new Error(setupUsage()); + } + host = hostValue; + } + if (!host) throw new Error(setupUsage()); + return { host, verbose }; } export async function runSetup( @@ -58,6 +81,7 @@ export async function runSetup( ): Promise { const promptToken = dependencies.promptToken ?? promptAccessToken; const env = dependencies.env ?? process.env; + const verbose = dependencies.verbose ?? false; const token = ( dependencies.token ?? env.INTERN_ACCESS_TOKEN ?? @@ -67,7 +91,7 @@ export async function runSetup( const session = dependencies.session ? await dependencies.session(token) - : await verifyMcp(token, env); + : await verifyMcp(token, env, verbose); const run = dependencies.run ?? runCommand; const packageSpec = dependencies.packageSpec ?? env.INTERN_MCP_PACKAGE ?? defaultPackage; @@ -177,13 +201,19 @@ async function configureHost( async function verifyMcp( token: string, env: NodeJS.ProcessEnv, + verbose: boolean, ): Promise { const entry = fileURLToPath(new URL("./index.js", import.meta.url)); const transport = new StdioClientTransport({ command: process.execPath, args: [entry, "serve"], - env: { ...process.env, ...env, INTERN_ACCESS_TOKEN: token }, - stderr: "pipe", + env: { + ...process.env, + ...env, + INTERN_ACCESS_TOKEN: token, + ...(verbose ? { INTERN_MCP_VERBOSE: "1" } : {}), + }, + stderr: verbose ? "inherit" : "pipe", }); const client = new Client({ name: "intern-setup", version: PACKAGE_VERSION }); await client.connect(transport); @@ -225,23 +255,78 @@ export async function promptAccessToken( input: NodeJS.ReadableStream = process.stdin, output: NodeJS.WritableStream = process.stderr, ): Promise { + const prompt = "Paste Intern access token: "; const hiddenOutput = new Writable({ write(_chunk, _encoding, callback) { callback(); }, }); const terminal = Boolean((input as { isTTY?: boolean }).isTTY); - const lines = createInterface({ input, output: hiddenOutput, terminal }); - output.write("Paste Intern access token: "); + const lines = createInterface({ + input, + output: terminal ? output : hiddenOutput, + terminal, + }); + if (terminal) installMaskedOutput(lines, output, prompt); + output.write(prompt); try { - const token = await lines.question(""); - output.write("\n"); + const token = await question(lines); + if (!terminal) output.write("\n"); return token; } finally { lines.close(); } } +function installMaskedOutput( + lines: Interface, + output: NodeJS.WritableStream, + prompt: string, +): void { + const masked = lines as Interface & { + line: string; + _writeToOutput(value: string): void; + }; + masked._writeToOutput = (value: string) => { + if (value.includes("\n")) { + output.write(value); + return; + } + if (!value) return; + output.write( + `\r${String.fromCharCode(27)}[2K${prompt}${"*".repeat(Array.from(masked.line).length)}`, + ); + }; +} + +function question(lines: Interface): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + lines.removeListener("SIGINT", cancel); + lines.removeListener("close", cancel); + }; + const cancel = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Token entry cancelled")); + }; + lines.once("SIGINT", cancel); + lines.once("close", cancel); + lines.question("", (answer) => { + if (settled) return; + settled = true; + cleanup(); + resolve(answer); + }); + }); +} + +function setupUsage(): string { + return "Usage: intern-mcp setup --host codex|claude [--verbose]"; +} + export async function readStoredAccessToken(config: InternConfig): Promise { const token = (await fs.readFile(accessTokenFile(config), "utf8")).trim(); if (!token) throw new Error("Intern access token profile is empty; run setup again");