Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://tryintern.dev/connect>, 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.

Expand Down
46 changes: 34 additions & 12 deletions scripts/harness-install-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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") ||
Expand Down
33 changes: 33 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
113 changes: 102 additions & 11 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InternSession> {
Expand Down Expand Up @@ -136,17 +139,42 @@ export class InternAPI {

private async request<T>(pathname: string, init: RequestInit = {}): Promise<T> {
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<string, unknown>;
if (!response.ok) {
Expand All @@ -160,4 +188,67 @@ export class InternAPI {
}
return body as T;
}

private log(event: string, fields: Record<string, string | number | boolean>): 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<string, string | boolean> {
const fields: Record<string, string | boolean> = {};
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<string, string> {
const fields: Record<string, string> = {};
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;
}
28 changes: 20 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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<void> {
const api = new InternAPI(config, auth);
async function serveUntilClosed(
auth: AuthClient,
diagnosticSink?: (line: string) => void,
): Promise<void> {
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));
Expand Down
43 changes: 38 additions & 5 deletions src/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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");
});
});
Loading
Loading