From cc0c702755845d2e07ea2aa24685bbffdc8aea32 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Tue, 18 Aug 2026 16:12:08 -0700 Subject: [PATCH] Add one-command access token setup --- README.md | 111 ++++---- package-lock.json | 13 +- package.json | 2 +- scripts/access-token-e2e.mjs | 49 ++++ scripts/device-flow-e2e.mjs | 116 -------- scripts/harness-install-smoke.mjs | 76 ++++-- scripts/run-local.sh | 32 +-- src/auth.test.ts | 69 +---- src/auth.ts | 280 +------------------- src/config.test.ts | 22 +- src/config.ts | 10 - src/index.ts | 44 ++-- src/launcher.test.ts | 11 +- src/server.test.ts | 84 +++--- src/server.ts | 156 +++-------- src/setup.test.ts | 255 ++++++++++++++++++ src/setup.ts | 425 ++++++++++++++++++++++++++++++ 17 files changed, 975 insertions(+), 780 deletions(-) create mode 100644 scripts/access-token-e2e.mjs delete mode 100644 scripts/device-flow-e2e.mjs create mode 100644 src/setup.test.ts create mode 100644 src/setup.ts diff --git a/README.md b/README.md index 8186892..06ac790 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,12 @@ Local stdio MCP server for working on Intern-hosted sites through guarded Git ch Intern MCP is packaged for distribution as the public `@archastro/intern-mcp` npm package. -It requires Node.js 22 or newer. The explicit scoped registry override keeps -installation working on machines that route `@archastro` elsewhere. +It requires Node.js 22 or newer. ### Codex ```sh -codex mcp add intern -- \ - npx --yes --@archastro:registry=https://registry.npmjs.org \ - @archastro/intern-mcp@0.1.0 serve -codex mcp get intern +npx --yes @archastro/intern-mcp@0.1.0 setup --host codex ``` Restart Codex after adding the server. Codex stores the stdio command in its @@ -24,34 +20,43 @@ user configuration and starts the package when a session needs the server. ### Claude Code ```sh -claude mcp add --transport stdio --scope user intern -- \ - npx --yes --@archastro:registry=https://registry.npmjs.org \ - @archastro/intern-mcp@0.1.0 serve -claude mcp get intern +npx --yes @archastro/intern-mcp@0.1.0 setup --host claude ``` -The user scope makes Intern available in every Claude Code project. Use -`--scope local` instead if it should only be available in the current project. -Run `/mcp` inside Claude Code to inspect the connection. +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 +`~/.config/intern/access-token` with mode `0600`; it is never placed in child +process arguments or host configuration. + +The two harnesses launch the same local stdio executable. Intern MCP does not +run an OAuth flow or accept a token through a tool call. To rotate access, run +setup with a new token, restart the host, and revoke the old token on the +Connect page. Revocation blocks new API calls and SSH certificates immediately; +a Git certificate already issued can remain valid until its five-minute expiry. + +Upgrades are explicit: rerun the corresponding setup command with the reviewed +version replacing `0.1.0`. The installer updates only the user-level `intern` +registration. The host never executes a newly published package version merely +because it restarted. -The two harnesses launch the same local stdio executable. Authentication still -uses `intern_login` and the TryIntern device page; no OAuth secret or service -credential is placed in harness configuration. +The repository is private and the npm package has not been published yet. The +commands above become available after the first package release. -Upgrades are explicit: remove the existing registration and add it again with -the reviewed version replacing `0.1.0`: +If a developer machine maps the `@archastro` scope to another registry, override +that local mapping for this public package: ```sh -codex mcp remove intern -claude mcp remove intern +npx --yes --@archastro:registry=https://registry.npmjs.org \ + @archastro/intern-mcp@0.1.0 setup --host codex ``` -Then rerun the corresponding command above. The harness never executes a newly -published package version merely because it restarted. - -The repository is private and the npm package has not been published yet. The -commands above become available after the first package release. - Maintainers run the manual **release** workflow to publish the version in `package.json`. The first release uses a short-lived `NPM_TOKEN` repository secret in the protected `npm-release` environment because npm cannot attach a @@ -63,25 +68,25 @@ authenticate with GitHub OIDC. ## Configure the server -Production public values are built in: - -- TryIntern origin: `https://tryintern.dev` -- ArchAstro API: `https://platform.archastro.ai` -- OAuth client: `cc_vuMmqN4VbAKy8zsWRYorUg`, with only `profile` scope -- TryIntern's public ArchAstro publishable key - -The OAuth client secret is not used by this device-flow public client. Do not put it in MCP or TryIntern configuration. +The production TryIntern origin is built in. `INTERN_ACCESS_TOKEN` is required +for authenticated API calls and should be a profile-scoped token created on the +Connect page. `intern-mcp serve` reads it directly from the environment for +manual and CI configurations. `intern-mcp launch` reads the mode-0600 profile +written by setup, then supplies the same token contract internally. These optional environment values override the defaults for local testing or custom workspace setup: -- `INTERN_BASE_URL` — Intern frontend origin. -- `ARCHASTRO_API_URL` — ArchAstro API origin. -- `ARCHASTRO_PUBLISHABLE_KEY` — Intern app publishable key. -- `INTERN_OAUTH_CLIENT_ID` — alternate device-flow client registered with only `profile` scope. +- `INTERN_BASE_URL` — Intern frontend/API origin. - `INTERN_WORKSPACE_ROOT` — parent directory for `/` checkouts. +- `INTERN_CONFIG_ROOT` — directory for the access-token profile and SSH material. - `INTERN_GIT_SSH_COMMAND` — optional per-process SSH command for development or custom SSH setup. - `INTERN_IAP_ID_TOKEN` (or `IAP_ID_TOKEN`) — Google ID token for the IAP-protected production frontend. It is sent through `Proxy-Authorization` while the ArchAstro bearer remains in `Authorization`. +When the base URL, workspace root, config root, or Git SSH command is present +while `setup` runs, the installer saves that nonsecret override in the selected +host's MCP environment. IAP tokens remain manual/CI-only credentials and are +not copied into host configuration by setup. + For development from this repository, install and build with: ```sh @@ -98,12 +103,15 @@ node /absolute/path/to/intern-mcp/dist/index.js serve For Codex, the checkout-based equivalent is: ```sh -codex mcp add intern \ - --env INTERN_WORKSPACE_ROOT=/absolute/path/to/Intern \ - -- node /absolute/path/to/intern-mcp/dist/index.js serve +INTERN_MCP_PACKAGE=/absolute/path/to/intern-mcp \ + INTERN_WORKSPACE_ROOT=/absolute/path/to/Intern \ + node /absolute/path/to/intern-mcp/dist/index.js setup --host codex ``` -Use `intern_login`, approve the browser prompt, and call `intern_complete_login`. Then use `intern_prepare_site` and edit the returned absolute path with the coding host's normal filesystem tools. The prepare result includes validation against the authenticated Intern runtime contract. +Restart Codex after adding the server. Call `intern_auth_status`, then use +`intern_prepare_site` and edit the returned absolute path with the coding +host's normal filesystem tools. The prepare result includes validation against +the authenticated Intern runtime contract. ## Run against a local Firstlanding stack @@ -131,27 +139,24 @@ one atomic Aster port allocation. The launcher uses TryIntern, or Git gateway SSH ports. Both Intern topologies expose the same API and Git contract to the MCP; only the control plane's site provider changes. For local Git, the launcher routes `git.intern.dev` SSH traffic to the reported -loopback gateway port. The launcher loads the local publishable key and profile-only OAuth client from -`.env.intern.local`, builds the MCP, and keeps its local credentials and site -checkouts under this repository's ignored `tmp/` directory. `INTERN_CONFIG_ROOT` -and `INTERN_WORKSPACE_ROOT` still override those paths. Test harnesses can set -`INTERN_LOCAL_ENV_FILE` to load a different local public-client configuration. -If the worktree's ports changed, the launcher prints the exact command that -reconciles the local OAuth client's device verification URI before starting. +loopback gateway port. The launcher requires `INTERN_ACCESS_TOKEN`, builds the +MCP, and keeps its SSH material and site checkouts under this repository's +ignored `tmp/` directory. Create the token from the local Intern Connect page, +export it in the terminal that starts the launcher, and keep it out of shell +scripts and source control. `INTERN_CONFIG_ROOT` and `INTERN_WORKSPACE_ROOT` +still override the default paths. The same launcher exposes the standalone commands for manual checks: ```sh -INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh login INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh status -INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh logout ``` -After edits, call `intern_test_site` before committing. It validates tracked and untracked working-tree files, excludes ignored files, and returns an ephemeral `http://127.0.0.1:` preview URL. The URL serves a temporary snapshot, so call the tool again after further edits. `intern_stop_test` stops it without needing the backend or a current login. Logout and stdio shutdown also stop every preview and remove its snapshot. +After edits, call `intern_test_site` before committing. It validates tracked and untracked working-tree files, excludes ignored files, and returns an ephemeral `http://127.0.0.1:` preview URL. The URL serves a temporary snapshot, so call the tool again after further edits. `intern_stop_test` stops it without needing the backend or a current token. Stdio shutdown also stops every preview and removes its snapshot. Once the local result is correct, commit the change and call `intern_validate_site`. It checks the exact committed tree: required and protected runtime files, dependencies the backend does not install, JavaScript syntax, production-style startup, and an HTTP probe. `intern_publish_site` reruns the same commit validation and refuses invalid or dirty worktrees. The MCP never stages or commits files. -For each SSH clone or push, Intern MCP creates or reuses one local Ed25519 key and sends only its public half to Intern. Intern returns a five-minute user certificate plus the pinned `git.tryintern.dev` host key. MCP supplies those files only to that Git process; it never edits global Git config, `~/.ssh/config`, or the user's `known_hosts`. Logout removes the current certificate but retains the private key for future short-lived certificates. +For each SSH clone or push, Intern MCP creates or reuses one local Ed25519 key and sends only its public half to Intern. Intern returns a five-minute user certificate plus the pinned `git.tryintern.dev` host key. MCP supplies those files only to that Git process; it never edits global Git config, `~/.ssh/config`, or the user's `known_hosts`. The MCP replaces the current certificate as needed and retains the private key for future short-lived certificates. The current runtime contract is deliberately narrow: Intern runs its protected `server.mjs` through `run-site.sh`, supplies `PORT`, and does not install package dependencies. A model can edit HTML, CSS, browser JavaScript, and assets. Runtime launcher changes are rejected because the current Git publish path does not restart the site process. diff --git a/package-lock.json b/package-lock.json index 6186f43..4427300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@archastro/intern-mcp", "version": "0.1.0", "dependencies": { + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "4.4.3" }, @@ -16,7 +17,6 @@ }, "devDependencies": { "@eslint/js": "9.39.2", - "@modelcontextprotocol/client": "2.0.0", "@types/node": "^22", "eslint": "9.39.2", "globals": "16.5.0", @@ -263,7 +263,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", - "dev": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "2.0.0", @@ -1160,7 +1159,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1394,7 +1392,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -1407,7 +1404,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1628,14 +1624,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jose": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2152,7 +2146,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2189,7 +2182,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -2320,7 +2312,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2333,7 +2324,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2693,7 +2683,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 4ed411c..31f5d8a 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,12 @@ "start": "node dist/index.js serve" }, "dependencies": { + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "4.4.3" }, "devDependencies": { "@eslint/js": "9.39.2", - "@modelcontextprotocol/client": "2.0.0", "@types/node": "^22", "eslint": "9.39.2", "globals": "16.5.0", diff --git a/scripts/access-token-e2e.mjs b/scripts/access-token-e2e.mjs new file mode 100644 index 0000000..fc082fe --- /dev/null +++ b/scripts/access-token-e2e.mjs @@ -0,0 +1,49 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import path from "node:path"; + +const packageRoot = path.resolve(import.meta.dirname, ".."); +const expectUnauthorized = process.argv.includes("--expect-unauthorized"); +if (!process.env.INTERN_ACCESS_TOKEN) { + throw new Error("INTERN_ACCESS_TOKEN is required for the live MCP proof"); +} + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(packageRoot, "dist/index.js"), "serve"], + env: process.env, + stderr: "pipe", +}); +const client = new Client({ name: "intern-access-token-proof", version: "1.0.0" }); + +await client.connect(transport); +try { + const status = await client.callTool({ name: "intern_auth_status", arguments: {} }); + const authorized = status.structuredContent?.authorized; + const org = status.structuredContent?.session?.org?.slug; + if (expectUnauthorized) { + if (authorized !== false) { + throw new Error( + `Intern MCP still accepted the revoked token: ${JSON.stringify(status.structuredContent)}`, + ); + } + process.stdout.write( + "PASS Intern MCP rejected the revoked access token over stdio\n", + ); + process.exitCode = 0; + } else { + if (authorized !== true || typeof org !== "string" || !org) { + throw new Error( + `Intern MCP did not authorize the profile token: ${JSON.stringify(status.structuredContent)}`, + ); + } + + const listed = await client.callTool({ name: "intern_list_sites", arguments: {} }); + if (!Array.isArray(listed.structuredContent?.sites)) { + throw new Error("Intern MCP did not return a site list"); + } + process.stdout.write(`PASS Intern MCP access token over stdio for ${org}\n`); + } +} finally { + await client.close(); +} diff --git a/scripts/device-flow-e2e.mjs b/scripts/device-flow-e2e.mjs deleted file mode 100644 index 57b7afe..0000000 --- a/scripts/device-flow-e2e.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Client } from "@modelcontextprotocol/client"; -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; - -const frontend = required("INTERN_BASE_URL"); -const publicOrigin = required("INTERN_PUBLIC_ORIGIN"); -const root = await fs.mkdtemp(path.join(os.tmpdir(), "intern-device-e2e-")); -const transport = new StdioClientTransport({ - command: process.execPath, - args: [path.resolve("dist/index.js"), "serve"], - env: { - ...process.env, - INTERN_CONFIG_ROOT: path.join(root, "config"), - INTERN_WORKSPACE_ROOT: path.join(root, "workspaces"), - }, -}); -const client = new Client({ name: "intern-device-e2e", version: "1.0.0" }); - -try { - await client.connect(transport); - - // Start authorization through the real local MCP stdio process. - const started = await client.callTool({ - name: "intern_login", - arguments: { openBrowser: false }, - }); - const instructions = started.structuredContent; - assert.equal(instructions.verificationURI, `${publicOrigin}/device`); - assert.equal( - instructions.verificationURIComplete, - `${publicOrigin}/device?code=${encodeURIComponent(instructions.userCode)}`, - ); - assert.doesNotMatch( - instructions.verificationURIComplete, - /client_id|api_key|user_code/, - ); - - // Cross the signed-out browser boundary and preserve the generated code. - const signedOut = await fetch(instructions.verificationURIComplete, { - redirect: "manual", - }); - assert.equal(signedOut.status, 307); - const loginLocation = signedOut.headers.get("location") ?? ""; - const loginURL = new URL(loginLocation, frontend); - assert.equal(loginURL.pathname, "/login"); - assert.equal( - loginURL.searchParams.get("returnTo"), - `/device?code=${instructions.userCode}`, - ); - - // Establish a real TryIntern session, return to the same code, and POST approval. - const login = await fetch(`${frontend}/api/dev/session`, { - method: "POST", - headers: { "content-type": "application/json", origin: frontend }, - body: JSON.stringify({ - email: required("INTERN_E2E_ADMIN_EMAIL"), - password: required("INTERN_E2E_ADMIN_PASSWORD"), - }), - }); - assert.equal(login.status, 200); - const cookie = login.headers.get("set-cookie")?.split(";", 1)[0]; - assert.ok(cookie, "TryIntern login did not set its encrypted session cookie"); - - const approvalPage = await fetch( - `${frontend}/device?code=${encodeURIComponent(instructions.userCode)}`, - { - headers: { cookie }, - }, - ); - const approvalHTML = await approvalPage.text(); - const approvalText = approvalHTML.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "); - assert.equal(approvalPage.status, 200); - assert.match(approvalText, /Connect Intern Local MCP/); - assert.match(approvalText, /View your TryIntern identity and organization/); - assert.match(approvalText, new RegExp(instructions.userCode)); - - const approval = await fetch(`${frontend}/device/decision`, { - method: "POST", - redirect: "manual", - headers: { - cookie, - origin: frontend, - "content-type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: instructions.userCode, - decision: "approve", - }), - }); - assert.equal(approval.status, 303); - assert.match(approval.headers.get("location") ?? "", /\/device\?result=approved$/); - - // Poll the real platform token endpoint, then cross TryIntern HTTP and control gRPC. - const completed = await client.callTool({ - name: "intern_complete_login", - arguments: { timeoutSeconds: 30 }, - }); - assert.equal(completed.isError, undefined, JSON.stringify(completed)); - const authorized = completed.structuredContent; - assert.equal(authorized.authorized, true); - assert.equal(authorized.session.user.id, required("INTERN_E2E_ADMIN_USER_ID")); - assert.equal(authorized.session.user.org, required("INTERN_E2E_ADMIN_ORG_ID")); - assert.equal(authorized.session.org.slug, "local"); -} finally { - await client.close().catch(() => {}); - await fs.rm(root, { recursive: true, force: true }); -} - -function required(name) { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required`); - return value; -} diff --git a/scripts/harness-install-smoke.mjs b/scripts/harness-install-smoke.mjs index 5dc0f5d..3468bdf 100644 --- a/scripts/harness-install-smoke.mjs +++ b/scripts/harness-install-smoke.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -14,6 +15,7 @@ for (const variable of ["CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME"]) { delete environment[variable]; } const publicRegistry = "https://registry.npmjs.org"; +let sessionServer; async function run(command, args) { return exec(command, args, { @@ -50,45 +52,79 @@ try { const [packed] = JSON.parse(stdout); if (!packed?.filename) throw new Error("npm pack did not return a tarball"); const tarball = path.join(packDirectory, packed.filename); - const packagedCommand = [ - "npx", + sessionServer = http.createServer((request, response) => { + if ( + request.url !== "/api/v1/mcp/session" || + request.headers.authorization !== "Bearer harness-proof-token" + ) { + response + .writeHead(401, { "content-type": "application/json" }) + .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" }, + }), + ); + }); + await new Promise((resolve) => sessionServer.listen(0, "127.0.0.1", resolve)); + const address = sessionServer.address(); + if (!address || typeof address === "string") { + throw new Error("setup proof API did not bind TCP"); + } + environment.INTERN_ACCESS_TOKEN = "harness-proof-token"; + environment.INTERN_BASE_URL = `http://127.0.0.1:${address.port}`; + environment.INTERN_MCP_PACKAGE = tarball; + const setupCommand = [ "--yes", `--@archastro:registry=${publicRegistry}`, `--package=${tarball}`, "intern-mcp", - "serve", ]; - // Cross each harness's real configuration writer in an isolated home. - await run("codex", ["mcp", "add", "intern", "--", ...packagedCommand]); + // Cross the packaged setup command and each real host configuration writer. + const codexSetup = await run("npx", [...setupCommand, "setup", "--host", "codex"]); + if (!codexSetup.stdout.includes("Intern connected to Codex as Harness · admin")) { + throw new Error("packaged setup did not validate and configure Codex"); + } const codex = await run("codex", ["mcp", "get", "intern"]); - if (!codex.stdout.includes("intern-mcp") || !codex.stdout.includes("serve")) { - throw new Error("Codex did not persist the packaged Intern MCP command"); + if ( + !codex.stdout.includes("intern-mcp") || + !codex.stdout.includes("launch") || + codex.stdout.includes("harness-proof-token") + ) { + throw new Error("Codex did not persist the packaged Intern MCP profile launcher"); } - await run("claude", [ - "mcp", - "add", - "--transport", - "stdio", - "--scope", - "user", - "intern", - "--", - ...packagedCommand, - ]); + const claudeSetup = await run("npx", [...setupCommand, "setup", "--host", "claude"]); + if ( + !claudeSetup.stdout.includes("Intern connected to Claude Code as Harness · admin") + ) { + throw new Error("packaged setup did not validate and configure Claude Code"); + } const claude = await run("claude", ["mcp", "get", "intern"]); if ( !claude.stdout.includes("intern-mcp") || - !claude.stdout.includes("serve") || + !claude.stdout.includes("launch") || + claude.stdout.includes("harness-proof-token") || !claude.stdout.includes("Connected") ) { throw new Error("Claude did not connect to the packaged Intern MCP command"); } process.stdout.write( - `Codex registered and Claude connected to ${packed.filename} from isolated homes.\n`, + `Packaged setup validated Intern and configured Codex and Claude for ${packed.filename} in isolated homes.\n`, ); } finally { + if (sessionServer) { + await new Promise((resolve) => sessionServer.close(() => resolve())); + } await fs.rm(temporary, { recursive: true, force: true }); } diff --git a/scripts/run-local.sh b/scripts/run-local.sh index dd356a2..16056c1 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -10,9 +10,9 @@ aster_bin=${ASTER_BIN:-aster} command=${1:-serve} case "$command" in - serve|login|logout|status) ;; + serve|status) ;; *) - echo "usage: $0 [serve|login|logout|status]" >&2 + echo "usage: $0 [serve|status]" >&2 exit 2 ;; esac @@ -81,24 +81,7 @@ ports=$(printf '%s' "$ports_json" | node --input-type=module -e ' } read -r platform_port frontend_port gateway_ssh_port supervisor_pid <<<"$ports" -local_env=${INTERN_LOCAL_ENV_FILE:-"$workspace/.env.intern.local"} -if [[ ! -f "$local_env" ]]; then - echo "$local_env is missing." >&2 - echo "Provision the local Intern app and OAuth client once with:" >&2 - printf ' ARCHASTRO_API_URL=http://127.0.0.1:%s INTERN_PUBLIC_ORIGIN=http://127.0.0.1:%s INTERN_SERVICE_ENV_FILE=%q %q\n' \ - "$platform_port" "$frontend_port" "$local_env" "$platform_workspace/scripts/intern/setup-archastro-local.sh" >&2 - exit 1 -fi - -# This file is generated by Firstlanding's setup-archastro-local.sh with mode -# 0600 and contains -# only local public app/client identifiers plus the local org ID. -set -a -source "$local_env" -set +a - -: "${ARCHASTRO_PUBLISHABLE_KEY:?ARCHASTRO_PUBLISHABLE_KEY is missing from .env.intern.local}" -: "${INTERN_OAUTH_CLIENT_ID:?INTERN_OAUTH_CLIENT_ID is missing from .env.intern.local}" +: "${INTERN_ACCESS_TOKEN:?Create a profile token in the local Intern Connect page and export it as INTERN_ACCESS_TOKEN}" export ARCHASTRO_API_URL="http://127.0.0.1:$platform_port" export INTERN_BASE_URL="http://127.0.0.1:$frontend_port" @@ -115,15 +98,6 @@ if [[ -z ${INTERN_GIT_SSH_COMMAND:-} ]]; then export INTERN_GIT_SSH_COMMAND="ssh -i $local_ssh_key_quoted -o IdentitiesOnly=yes -p $gateway_ssh_port -o HostName=127.0.0.1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" fi -expected_device_uri="$INTERN_BASE_URL/device" -if [[ ${INTERN_DEVICE_VERIFICATION_URI:-} != "$expected_device_uri" ]]; then - echo "The local OAuth client is not registered for this worktree's TryIntern port." >&2 - echo "Reconcile it with:" >&2 - printf ' ARCHASTRO_API_URL=%q INTERN_PUBLIC_ORIGIN=%q INTERN_SERVICE_ENV_FILE=%q %q\n' \ - "$ARCHASTRO_API_URL" "$INTERN_BASE_URL" "$local_env" "$platform_workspace/scripts/intern/setup-archastro-local.sh" >&2 - exit 1 -fi - curl --noproxy '*' --fail --silent --show-error \ "$ARCHASTRO_API_URL/healthz" >/dev/null || { echo "Platform is not healthy at $ARCHASTRO_API_URL (Aster supervisor $supervisor_pid)." >&2 diff --git a/src/auth.test.ts b/src/auth.test.ts index c9f8d15..33935a9 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -1,67 +1,20 @@ import { describe, expect, it } from "vitest"; -import { assertHttpUrl, AuthClient } from "./auth.js"; +import { AuthClient } from "./auth.js"; -const config = { - internBaseURL: "https://tryintern.dev", - archAstroBaseURL: "https://platform.archastro.ai", - publishableKey: "pk_test", - oauthClientID: "cc_test", - workspaceRoot: "/tmp/intern-auth-test/sites", - configRoot: "/tmp/intern-auth-test/config", -}; +describe("AuthClient", () => { + it("returns the access token supplied by the MCP host", async () => { + const auth = new AuthClient(" atk_intern_test "); -describe("assertHttpUrl", () => { - it("accepts HTTPS and loopback HTTP device pages", () => { - expect( - assertHttpUrl("https://tryintern.dev/device?code=ABCD", "verification URL"), - ).toBe("https://tryintern.dev/device?code=ABCD"); - expect(assertHttpUrl("http://127.0.0.1:3100/device", "verification URL")).toBe( - "http://127.0.0.1:3100/device", - ); - }); - - it("rejects non-HTTP schemes, credentials, and non-loopback HTTP", () => { - expect(() => assertHttpUrl("javascript:alert(1)", "verification URL")).toThrow( - "non-HTTP", - ); - expect(() => assertHttpUrl("file:///etc/passwd", "verification URL")).toThrow( - "non-HTTP", - ); - expect(() => - assertHttpUrl("https://user:pass@tryintern.dev/device", "verification URL"), - ).toThrow("embedded credentials"); - expect(() => - assertHttpUrl("http://evil.example/device", "verification URL"), - ).toThrow("non-HTTPS"); + await expect(auth.hasCredentials()).resolves.toBe(true); + await expect(auth.accessToken()).resolves.toBe("atk_intern_test"); }); -}); -describe("AuthClient.startLogin", () => { - it("refuses a device-approval URL that is not http(s)", async () => { - const auth = new AuthClient(config, async () => - Response.json({ - device_code: "dc", - user_code: "ABCD-EFGH", - verification_uri: "javascript:alert(1)", - verification_uri_complete: "javascript:alert(1)", - expires_in: 600, - interval: 1, - }), - ); - await expect(auth.startLogin(false)).rejects.toThrow("non-HTTP"); - }); + it("gives an actionable setup error without accepting credentials in-band", async () => { + const auth = new AuthClient(" "); - it("refuses verification URLs on different origins", async () => { - const auth = new AuthClient(config, async () => - Response.json({ - device_code: "dc", - user_code: "ABCD-EFGH", - verification_uri: "https://tryintern.dev/device", - verification_uri_complete: "https://evil.example/device?code=ABCD-EFGH", - expires_in: 600, - interval: 1, - }), + await expect(auth.hasCredentials()).resolves.toBe(false); + await expect(auth.accessToken()).rejects.toThrow( + "create a profile access token at https://tryintern.dev/connect", ); - await expect(auth.startLogin(false)).rejects.toThrow("different origins"); }); }); diff --git a/src/auth.ts b/src/auth.ts index e91d659..04202e6 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,285 +1,19 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import type { InternConfig } from "./config.js"; - -const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"; - -interface Tokens { - accessToken: string; - refreshToken?: string; - expiresAt?: number; - scope?: string; -} - -interface PendingAuthorization { - deviceCode: string; - userCode: string; - verificationURI: string; - verificationURIComplete: string; - expiresAt: number; - intervalSeconds: number; -} - -interface StoredAuth { - version: 1; - tokens?: Tokens; - pending?: PendingAuthorization; -} - -export interface LoginInstructions { - userCode: string; - verificationURI: string; - verificationURIComplete: string; - expiresAt: number; -} +const SETUP_URL = "https://tryintern.dev/connect"; export class AuthClient { - private refreshInFlight?: Promise; - - constructor( - private readonly config: InternConfig, - private readonly fetchFn: typeof fetch = fetch, - ) {} - - async startLogin(open = true): Promise { - this.requireOAuthConfig(); - const response = await this.fetchFn( - `${this.config.archAstroBaseURL}/oauth/device/authorize`, - { - method: "POST", - headers: this.oauthHeaders(), - body: JSON.stringify({ - client: this.config.oauthClientID, - scope: "profile", - }), - }, - ); - const body = await json(response); - if (!response.ok) - throw new Error(`authorization start failed: ${errorMessage(body)}`); - const verificationURI = assertHttpUrl( - requiredString(body, "verification_uri"), - "verification URL", - ); - const verificationURIComplete = assertHttpUrl( - requiredString(body, "verification_uri_complete"), - "verification URL", - ); - if (new URL(verificationURI).origin !== new URL(verificationURIComplete).origin) { - throw new Error("Intern returned verification URLs on different origins"); - } - const pending: PendingAuthorization = { - deviceCode: requiredString(body, "device_code"), - userCode: requiredString(body, "user_code"), - verificationURI, - verificationURIComplete, - expiresAt: Date.now() + requiredNumber(body, "expires_in") * 1000, - intervalSeconds: Math.max(requiredNumber(body, "interval"), 1), - }; - const existing = await this.read(); - await this.write({ version: 1, tokens: existing?.tokens, pending }); - if (open) openBrowser(pending.verificationURIComplete); - return { - userCode: pending.userCode, - verificationURI: pending.verificationURI, - verificationURIComplete: pending.verificationURIComplete, - expiresAt: pending.expiresAt, - }; - } - - async completeLogin(timeoutMs = 300_000): Promise { - this.requireOAuthConfig(); - const stored = await this.read(); - const pending = stored?.pending; - if (!pending) - throw new Error("no pending Intern authorization; call intern_login first"); - const deadline = Math.min(Date.now() + timeoutMs, pending.expiresAt); - let interval = pending.intervalSeconds; - while (Date.now() < deadline) { - const response = await this.fetchFn( - `${this.config.archAstroBaseURL}/oauth/token`, - { - method: "POST", - headers: this.oauthHeaders(), - body: JSON.stringify({ - grant_type: deviceGrant, - device_code: pending.deviceCode, - client: this.config.oauthClientID, - }), - }, - ); - const body = await json(response); - if (response.ok) { - await this.write({ version: 1, tokens: tokensFrom(body) }); - return; - } - const code = errorMessage(body); - if (code === "slow_down") interval += 5; - else if (code !== "authorization_pending") - throw new Error(`authorization failed: ${code}`); - await delay(interval * 1000); - } - throw new Error("authorization timed out; call intern_login to start again"); - } + constructor(private readonly token = process.env.INTERN_ACCESS_TOKEN) {} async accessToken(): Promise { - const envToken = process.env.INTERN_ACCESS_TOKEN; - if (envToken) return envToken; - const stored = await this.read(); - if (!stored?.tokens) + const token = this.token?.trim(); + if (!token) { throw new Error( - "AUTH_REQUIRED: call intern_login, approve it, then call intern_complete_login", + `AUTH_REQUIRED: create a profile access token at ${SETUP_URL}, add it to this MCP server as INTERN_ACCESS_TOKEN, then restart the MCP host`, ); - if (!stored.tokens.expiresAt || stored.tokens.expiresAt > Date.now() + 60_000) - return stored.tokens.accessToken; - if (!stored.tokens.refreshToken) - throw new Error("AUTH_REQUIRED: Intern session expired; sign in again"); - if (!this.refreshInFlight) { - this.refreshInFlight = this.refresh(stored.tokens.refreshToken).finally(() => { - this.refreshInFlight = undefined; - }); } - return this.refreshInFlight; - } - - async logout(): Promise { - await fs.rm(this.file(), { force: true }); + return token; } async hasCredentials(): Promise { - return Boolean(process.env.INTERN_ACCESS_TOKEN || (await this.read())?.tokens); - } - - private async refresh(refreshToken: string): Promise { - this.requireOAuthConfig(false); - const response = await this.fetchFn(`${this.config.archAstroBaseURL}/oauth/token`, { - method: "POST", - headers: this.oauthHeaders(), - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - }); - const body = await json(response); - if (!response.ok) - throw new Error(`AUTH_REQUIRED: refresh failed: ${errorMessage(body)}`); - const tokens = tokensFrom(body); - await this.write({ version: 1, tokens }); - return tokens.accessToken; + return Boolean(this.token?.trim()); } - - private oauthHeaders(): Record { - return { - "content-type": "application/json", - "x-archastro-api-key": this.config.publishableKey!, - }; - } - - private requireOAuthConfig(requireClient = true): void { - if (!this.config.publishableKey || (requireClient && !this.config.oauthClientID)) { - throw new Error( - "Intern OAuth is not configured; provide the public app key and OAuth client ID", - ); - } - } - - private file(): string { - return path.join(this.config.configRoot, "credentials.json"); - } - - private async read(): Promise { - try { - return JSON.parse(await fs.readFile(this.file(), "utf8")) as StoredAuth; - } catch { - return null; - } - } - - private async write(value: StoredAuth): Promise { - await fs.mkdir(this.config.configRoot, { recursive: true, mode: 0o700 }); - const temporary = path.join( - this.config.configRoot, - `.credentials.${process.pid}.${Date.now()}`, - ); - await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { - mode: 0o600, - }); - await fs.rename(temporary, this.file()); - await fs.chmod(this.file(), 0o600); - } -} - -function tokensFrom(body: Record): Tokens { - return { - accessToken: requiredString(body, "access_token"), - refreshToken: optionalString(body, "refresh_token"), - expiresAt: Date.now() + requiredNumber(body, "expires_in") * 1000, - scope: optionalString(body, "scope"), - }; -} - -async function json(response: Response): Promise> { - try { - return (await response.json()) as Record; - } catch { - return {}; - } -} - -function requiredString(value: Record, key: string): string { - const result = value[key]; - if (typeof result !== "string" || !result) - throw new Error(`OAuth response missing ${key}`); - return result; -} -function optionalString( - value: Record, - key: string, -): string | undefined { - return typeof value[key] === "string" ? value[key] : undefined; -} -function requiredNumber(value: Record, key: string): number { - const result = value[key]; - if (typeof result !== "number" || !Number.isFinite(result)) - throw new Error(`OAuth response missing ${key}`); - return result; -} -function errorMessage(value: Record): string { - return typeof value.error === "string" ? value.error : "request_failed"; -} -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function assertHttpUrl(value: string, label: string): string { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - throw new Error(`Intern returned an invalid ${label}`); - } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") - throw new Error(`Intern returned a non-HTTP ${label}`); - if (parsed.username || parsed.password) - throw new Error(`Intern returned a ${label} with embedded credentials`); - if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) - throw new Error(`Intern returned a non-HTTPS ${label}`); - return parsed.href; -} - -function isLoopbackHost(hostname: string): boolean { - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; -} - -function openBrowser(url: string): void { - const href = assertHttpUrl(url, "verification URL"); - const child = - process.platform === "darwin" - ? spawn("open", [href], { detached: true, stdio: "ignore" }) - : process.platform === "win32" - ? spawn("explorer.exe", [href], { detached: true, stdio: "ignore" }) - : spawn("xdg-open", [href], { detached: true, stdio: "ignore" }); - child.on("error", () => {}); - child.unref(); } diff --git a/src/config.test.ts b/src/config.test.ts index 0c4fb05..2f857af 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,14 +1,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { - DEFAULT_ARCHASTRO_API_URL, - DEFAULT_ARCHASTRO_PUBLISHABLE_KEY, - DEFAULT_INTERN_BASE_URL, - DEFAULT_INTERN_OAUTH_CLIENT_ID, - loadConfig, - PACKAGE_VERSION, -} from "./config.js"; +import { DEFAULT_INTERN_BASE_URL, loadConfig, PACKAGE_VERSION } from "./config.js"; describe("loadConfig", () => { it("uses the published package version for MCP server identity", () => { @@ -18,29 +11,20 @@ describe("loadConfig", () => { expect(PACKAGE_VERSION).toBe(packed.version); }); - it("uses TryIntern production public values by default", () => { + it("uses the TryIntern production API by default", () => { const config = loadConfig({ HOME: "/tmp/intern-config-test" }); expect(config.internBaseURL).toBe(DEFAULT_INTERN_BASE_URL); - expect(config.archAstroBaseURL).toBe(DEFAULT_ARCHASTRO_API_URL); - expect(config.publishableKey).toBe(DEFAULT_ARCHASTRO_PUBLISHABLE_KEY); - expect(config.oauthClientID).toBe(DEFAULT_INTERN_OAUTH_CLIENT_ID); }); - it("allows every public production value to be overridden for local testing", () => { + it("allows the Intern API to be overridden for local testing", () => { const config = loadConfig({ HOME: "/tmp/intern-config-test", INTERN_BASE_URL: "http://127.0.0.1:3100/", - ARCHASTRO_API_URL: "http://127.0.0.1:4000/", - ARCHASTRO_PUBLISHABLE_KEY: "pk_local", - INTERN_OAUTH_CLIENT_ID: "cc_local", }); expect(config).toMatchObject({ internBaseURL: "http://127.0.0.1:3100", - archAstroBaseURL: "http://127.0.0.1:4000", - publishableKey: "pk_local", - oauthClientID: "cc_local", }); }); }); diff --git a/src/config.ts b/src/config.ts index 92c6d17..5f08f7f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,16 +9,9 @@ export const PACKAGE_VERSION = ( ).version; export const DEFAULT_INTERN_BASE_URL = "https://tryintern.dev"; -export const DEFAULT_ARCHASTRO_API_URL = "https://platform.archastro.ai"; -export const DEFAULT_ARCHASTRO_PUBLISHABLE_KEY = - "pk_dap_0344yXHSZ9tsm9NOpMQ6Y3_vGwdlFjMxYdiN0jtVN3wcsZ3krjKk_S4"; -export const DEFAULT_INTERN_OAUTH_CLIENT_ID = "cc_vuMmqN4VbAKy8zsWRYorUg"; export interface InternConfig { internBaseURL: string; - archAstroBaseURL: string; - publishableKey?: string; - oauthClientID?: string; workspaceRoot: string; configRoot: string; gitSSHCommand?: string; @@ -28,9 +21,6 @@ export interface InternConfig { export function loadConfig(env: NodeJS.ProcessEnv = process.env): InternConfig { return { internBaseURL: cleanURL(env.INTERN_BASE_URL ?? DEFAULT_INTERN_BASE_URL), - archAstroBaseURL: cleanURL(env.ARCHASTRO_API_URL ?? DEFAULT_ARCHASTRO_API_URL), - publishableKey: env.ARCHASTRO_PUBLISHABLE_KEY ?? DEFAULT_ARCHASTRO_PUBLISHABLE_KEY, - oauthClientID: env.INTERN_OAUTH_CLIENT_ID ?? DEFAULT_INTERN_OAUTH_CLIENT_ID, workspaceRoot: path.resolve( env.INTERN_WORKSPACE_ROOT ?? path.join(os.homedir(), "Intern"), ), diff --git a/src/index.ts b/src/index.ts index 9793b48..734beeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,40 +6,44 @@ 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"; const config = loadConfig(); -const auth = new AuthClient(config); -const api = new InternAPI(config, auth); -const ssh = new SSHCredentialManager(config, api); -const workspaces = new WorkspaceManager(config, ssh); const command = process.argv[2] ?? "serve"; switch (command) { case "serve": - await serveUntilClosed(); + await serveUntilClosed(new AuthClient()); break; - case "login": { - const instructions = await auth.startLogin(true); - process.stderr.write( - `Open ${instructions.verificationURIComplete}\nCode: ${instructions.userCode}\n`, - ); - await auth.completeLogin(); - process.stdout.write(`${JSON.stringify(await api.session(), null, 2)}\n`); - break; - } - case "logout": - await auth.logout(); - process.stdout.write("Logged out of Intern.\n"); + case "launch": + await serveUntilClosed(new AuthClient(await readStoredAccessToken(config))); break; case "status": - process.stdout.write(`${JSON.stringify(await api.session(), null, 2)}\n`); + process.stdout.write( + `${JSON.stringify(await new InternAPI(config, new AuthClient()).session(), null, 2)}\n`, + ); + break; + case "setup": + try { + await runSetup(config, parseSetupHost(process.argv.slice(3))); + } catch (error) { + process.stderr.write( + `Intern setup failed: ${error instanceof Error ? error.message : "request failed"}\n`, + ); + process.exitCode = 1; + } break; default: - process.stderr.write("Usage: intern-mcp serve|login|logout|status\n"); + process.stderr.write( + "Usage: intern-mcp serve|launch|status|setup --host codex|claude\n", + ); process.exitCode = 2; } -async function serveUntilClosed(): Promise { +async function serveUntilClosed(auth: AuthClient): Promise { + const api = new InternAPI(config, auth); + const ssh = new SSHCredentialManager(config, api); + const workspaces = new WorkspaceManager(config, ssh); const handle = serveStdio(() => buildServer(auth, api, workspaces)); await new Promise((resolve) => { let closing = false; diff --git a/src/launcher.test.ts b/src/launcher.test.ts index 7af4d80..ac7dd1d 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -75,15 +75,6 @@ process.stdout.write(JSON.stringify({ `, ); await chmod(fakeAster, 0o755); - const localEnv = path.join(root, "intern.env"); - await writeFile( - localEnv, - `ARCHASTRO_PUBLISHABLE_KEY=pk_test -INTERN_OAUTH_CLIENT_ID=cc_test -INTERN_DEVICE_VERIFICATION_URI=http://127.0.0.1:${frontendPort}/device -`, - ); - // Cross the executable shell, Aster JSON, HTTP health, build, and MCP stdio boundaries. const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const transport = new StdioClientTransport({ @@ -93,7 +84,7 @@ INTERN_DEVICE_VERIFICATION_URI=http://127.0.0.1:${frontendPort}/device ...process.env, ASTER_BIN: fakeAster, INTERN_PLATFORM_WORKSPACE: platformWorkspace, - INTERN_LOCAL_ENV_FILE: localEnv, + INTERN_ACCESS_TOKEN: "launcher-proof-token", INTERN_CONFIG_ROOT: path.join(root, "config"), INTERN_WORKSPACE_ROOT: path.join(root, "sites"), }, diff --git a/src/server.test.ts b/src/server.test.ts index 713662a..e72968c 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -41,12 +41,10 @@ test("advertises MCP titles, instructions, field descriptions, and workflow prom expect(client.getInstructions()).toMatch(/never stages or commits/i); const tools = await client.listTools(); - expect(tools.tools.map((tool) => tool.name)).toEqual( + const toolNames = tools.tools.map((tool) => tool.name); + expect(toolNames).toEqual( expect.arrayContaining([ "intern_auth_status", - "intern_login", - "intern_complete_login", - "intern_logout", "intern_list_sites", "intern_prepare_site", "intern_site_status", @@ -56,6 +54,17 @@ test("advertises MCP titles, instructions, field descriptions, and workflow prom "intern_publish_site", ]), ); + expect(toolNames).not.toEqual( + expect.arrayContaining([ + "intern_login", + "intern_complete_login", + "intern_logout", + ]), + ); + expect(client.getInstructions()).toContain("INTERN_ACCESS_TOKEN"); + expect(client.getInstructions()).toContain("intern-mcp launch"); + expect(client.getInstructions()).toContain("rerunning setup"); + expect(client.getInstructions()).toContain("https://tryintern.dev/connect"); for (const tool of tools.tools) expect(tool.title, tool.name).toBeTruthy(); const prepare = tools.tools.find((tool) => tool.name === "intern_prepare_site"); expect(prepare?.inputSchema).toMatchObject({ @@ -70,9 +79,9 @@ test("advertises MCP titles, instructions, field descriptions, and workflow prom }); const prompts = await client.listPrompts(); - expect(prompts.prompts.map((prompt) => prompt.name)).toEqual( - expect.arrayContaining(["intern_sign_in", "intern_work_on_site"]), - ); + const promptNames = prompts.prompts.map((prompt) => prompt.name); + expect(promptNames).toEqual(expect.arrayContaining(["intern_work_on_site"])); + expect(promptNames).not.toContain("intern_sign_in"); const workflow = await client.getPrompt({ name: "intern_work_on_site", arguments: { site: "docs" }, @@ -93,6 +102,35 @@ test("advertises MCP titles, instructions, field descriptions, and workflow prom } }); +test("reports a configured but rejected access token as unauthorized", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = buildServer( + { hasCredentials: async () => true } as never, + { + session: async () => { + throw new Error("AUTH_REQUIRED: invalid_token"); + }, + } as never, + {} as never, + ); + const client = new Client({ name: "intern-auth-contract", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const status = await client.callTool({ + name: "intern_auth_status", + arguments: {}, + }); + expect(status.structuredContent).toEqual({ + authorized: false, + setupURL: "https://tryintern.dev/connect", + }); + } finally { + await client.close(); + await server.close(); + } +}); + async function previewTemporaryDirectories(): Promise> { const temporaryRoot = await fs.realpath(os.tmpdir()); return new Set( @@ -466,37 +504,7 @@ test("an authorized MCP client prepares and publishes an Intern checkout over st ).rejects.toThrow(); expect(await previewTemporaryDirectories()).toEqual(concurrentTempsBefore); - // Logout removes an active preview before credentials are cleared. - const logoutTempsBefore = await previewTemporaryDirectories(); - let markLogoutSiteListStarted = () => {}; - const logoutSiteListStarted = new Promise((resolve) => { - markLogoutSiteListStarted = resolve; - }); - let releaseLogoutSiteList = () => {}; - const logoutSiteListWait = new Promise((resolve) => { - releaseLogoutSiteList = resolve; - }); - blockedSiteList = { started: markLogoutSiteListStarted, wait: logoutSiteListWait }; - const logoutTestPromise = client.callTool({ - name: "intern_test_site", - arguments: { site: "docs" }, - }); - await logoutSiteListStarted; - const logoutPromise = client.callTool({ name: "intern_logout", arguments: {} }); - releaseLogoutSiteList(); - const [logoutTest] = await Promise.all([logoutTestPromise, logoutPromise]); - const logoutURL = (logoutTest.structuredContent as { test: { url: string } }).test - .url; - const logoutTemps = await previewTemporaryDirectories(); - const logoutCreatedTemps = [...logoutTemps].filter( - (directory) => !logoutTempsBefore.has(directory), - ); - expect(logoutCreatedTemps).toHaveLength(0); - await expect( - fetch(logoutURL, { signal: AbortSignal.timeout(1_000) }), - ).rejects.toThrow(); - - // Closing stdio stops previews that were not explicitly stopped. The test's environment token remains available after file-based logout. + // Closing stdio stops previews that were not explicitly stopped. const finalTempsBefore = await previewTemporaryDirectories(); const finalTest = await client.callTool({ name: "intern_test_site", diff --git a/src/server.ts b/src/server.ts index f377d6f..5755a1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,21 +5,26 @@ import type { InternAPI } from "./api.js"; import { PACKAGE_VERSION } from "./config.js"; import type { WorkspaceManager } from "./workspace.js"; -const siteSlug = z - .string() - .regex(/^[a-z][a-z0-9-]{0,62}$/) - .describe( - "Intern site slug: a lowercase letter, then lowercase letters, digits, or hyphens", - ); +function siteSlugSchema() { + return z + .string() + .regex(/^[a-z][a-z0-9-]{0,62}$/) + .describe( + "Intern site slug: a lowercase letter, then lowercase letters, digits, or hyphens", + ); +} + +const siteSlug = siteSlugSchema(); const SERVER_INSTRUCTIONS = [ "Work on Intern-hosted sites in guarded local Git checkouts. Intern never stages or commits files.", - "1. intern_auth_status — if unauthorized, intern_login then intern_complete_login after the user approves the device page.", + "Authentication comes from the mode-0600 profile used by intern-mcp launch, or from INTERN_ACCESS_TOKEN in a manual stdio configuration. If it is missing, ask the user to create a profile token at https://tryintern.dev/connect and run the setup command shown there. Never ask the user to paste a token into chat or a tool call.", + "1. intern_auth_status — confirm the configured token resolves to the expected user and organization.", "2. intern_prepare_site — clone or reuse the checkout; edit files at the returned absolute path with the host's filesystem tools.", "3. intern_test_site — preview the working tree (untracked included, ignored excluded) at a loopback URL. Call it again after further edits. intern_stop_test stops it.", "4. Commit with the host's git, then intern_validate_site against Intern's runtime contract.", "5. intern_publish_site — pushes only a clean, committed HEAD that passed validation.", - "Use intern_list_sites and intern_site_status to inspect. intern_logout removes credentials and stops previews; it does not delete sites or files.", + "Use intern_list_sites and intern_site_status to inspect. Setup users rotate access by rerunning setup and restarting the host; manual users update INTERN_ACCESS_TOKEN and restart it.", ].join("\n"); const sessionSchema = z.object({ @@ -102,94 +107,26 @@ export function buildServer( outputSchema: z.object({ authorized: z.boolean(), session: sessionSchema.optional(), + setupURL: z.string().optional(), }), annotations: { readOnlyHint: true, openWorldHint: true }, }, - async () => - result( - (await auth.hasCredentials()) - ? { authorized: true, session: await api.session() } - : { authorized: false }, - ), - ); - - server.registerTool( - "intern_login", - { - title: "Start Intern sign-in", - description: - "Start Intern browser authorization. After approval, call intern_complete_login before using site tools.", - inputSchema: z.object({ - openBrowser: z - .boolean() - .default(true) - .describe("Open the device-approval page in the user's browser"), - }), - outputSchema: z.object({ - userCode: z.string(), - verificationURI: z.string(), - verificationURIComplete: z.string(), - expiresAt: z.number(), - }), - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, - }, - async ({ openBrowser }) => result(await auth.startLogin(openBrowser)), - ); - - server.registerTool( - "intern_complete_login", - { - title: "Finish Intern sign-in", - description: - "Finish a pending Intern browser authorization after the user approves it.", - inputSchema: z.object({ - timeoutSeconds: z - .number() - .int() - .min(1) - .max(300) - .default(120) - .describe("Seconds to wait for the user to approve the device page"), - }), - outputSchema: z.object({ authorized: z.literal(true), session: sessionSchema }), - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, - }, - async ({ timeoutSeconds }) => { - await auth.completeLogin(timeoutSeconds * 1000); - return result({ authorized: true as const, session: await api.session() }); - }, - ); - - server.registerTool( - "intern_logout", - { - title: "Sign out of Intern", - description: - "Remove this profile's local Intern credentials. This does not delete sites or files.", - outputSchema: z.object({ authorized: z.literal(false) }), - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, async () => { - await workspaces.stopAllTestsThen(async () => { - await workspaces.clearSSHCertificate(); - await auth.logout(); - }); - return result({ authorized: false as const }); + if (!(await auth.hasCredentials())) { + return result({ + authorized: false, + setupURL: "https://tryintern.dev/connect", + }); + } + try { + return result({ authorized: true, session: await api.session() }); + } catch (error) { + if (!isAuthRequired(error)) throw error; + return result({ + authorized: false, + setupURL: "https://tryintern.dev/connect", + }); + } }, ); @@ -437,40 +374,13 @@ export function buildServer( }, ); - server.registerPrompt( - "intern_sign_in", - { - title: "Sign in to Intern", - description: - "Authorize this local MCP with Intern using the device-approval page.", - }, - () => ({ - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: [ - "Authorize this local Intern MCP.", - "1. Call intern_login with openBrowser true.", - "2. Ask the user to open verificationURIComplete (or enter userCode at verificationURI) and approve the device page.", - "3. Call intern_complete_login and wait for authorized: true.", - "4. Confirm with intern_auth_status.", - "Do not put tokens or credentials in chat.", - ].join("\n"), - }, - }, - ], - }), - ); - server.registerPrompt( "intern_work_on_site", { title: "Work on an Intern site", description: "Prepare, preview, validate, and publish one Intern site.", argsSchema: z.object({ - site: completable(siteSlug, (value) => completeSiteSlugs(api, value)), + site: completable(siteSlugSchema(), (value) => completeSiteSlugs(api, value)), }), }, ({ site }) => ({ @@ -481,7 +391,7 @@ export function buildServer( type: "text" as const, text: [ `Work on Intern site "${site}".`, - "1. Call intern_auth_status. If unauthorized, follow the Intern sign-in flow first.", + "1. Call intern_auth_status. If unauthorized, ask the user to create a token at https://tryintern.dev/connect, run the setup command shown there, and restart the MCP host. Manual configurations instead update INTERN_ACCESS_TOKEN. Never ask them to paste the token into chat.", `2. Call intern_prepare_site with site "${site}". Edit files at the returned workspace.path using this host's filesystem tools.`, "3. Intern never stages or commits files. After edits, call intern_test_site to preview the working tree (untracked included, ignored excluded). Call it again after further edits. intern_stop_test stops the preview.", "4. Commit with this host's git, then call intern_validate_site.", @@ -521,6 +431,10 @@ function result(value: T) { }; } +function isAuthRequired(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("AUTH_REQUIRED:"); +} + function jsonResource(uri: URL, value: object) { return { contents: [ diff --git a/src/setup.test.ts b/src/setup.test.ts new file mode 100644 index 0000000..acb7cd5 --- /dev/null +++ b/src/setup.test.ts @@ -0,0 +1,255 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InternSession } from "./api.js"; +import { + parseSetupHost, + promptAccessToken, + readStoredAccessToken, + runSetup, +} from "./setup.js"; + +let root: string; +let config: { + internBaseURL: string; + workspaceRoot: string; + configRoot: string; +}; +let env: NodeJS.ProcessEnv; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "intern-setup-test-")); + config = { + internBaseURL: "https://tryintern.dev", + workspaceRoot: path.join(root, "sites"), + configRoot: path.join(root, "config"), + }; + env = { + HOME: root, + CODEX_HOME: path.join(root, "codex"), + CLAUDE_CONFIG_DIR: path.join(root, "claude"), + INTERN_WORKSPACE_ROOT: "/tmp/Intern", + }; +}); + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); +}); + +const session: InternSession = { + user: { + id: "usr_1", + org: "org_1", + org_name: "Acme", + org_role: "admin", + }, + org: { id: "intorg_1", slug: "acme", state: "active" }, +}; + +describe("Intern MCP setup", () => { + it("validates the token, stores it privately, then configures Codex", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const validate = vi.fn(async () => session); + const output: string[] = []; + await runSetup(config, "codex", { + token: "secret-token", + packageSpec: "/tmp/intern-mcp.tgz", + env, + session: validate, + run: async (command, args) => { + calls.push({ command, args }); + return { + status: 0, + stdout: args.includes("get") + ? '{"command": "npx", "args": ["intern-mcp", "launch"]}' + : "ok", + }; + }, + write: (message) => output.push(message), + }); + + expect(validate).toHaveBeenCalledWith("secret-token"); + await expect(readStoredAccessToken(config)).resolves.toBe("secret-token"); + expect( + (await fs.stat(path.join(config.configRoot, "access-token"))).mode & 0o777, + ).toBe(0o600); + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ + command: "codex", + args: expect.arrayContaining([ + "add", + "INTERN_WORKSPACE_ROOT=/tmp/Intern", + "--package=/tmp/intern-mcp.tgz", + "launch", + ]), + }); + expect(calls[0].args.join(" ")).not.toContain("secret-token"); + expect(calls[1]).toEqual({ + command: "codex", + args: ["mcp", "get", "intern", "--json"], + }); + expect(output.join("")).toContain("Intern connected to Codex as Acme · admin"); + expect(output.join("")).not.toContain("secret-token"); + }); + + it("replaces only Claude's user-scoped Intern entry", async () => { + const calls: string[][] = []; + await runSetup(config, "claude", { + token: "secret-token", + env, + session: async () => session, + run: async (command, args) => { + calls.push([command, ...args]); + return { + status: 0, + stdout: args.includes("get") + ? "Scope: User config\nStatus: ✓ Connected\nCommand: npx\nArgs: intern-mcp launch" + : "ok", + }; + }, + write: () => {}, + }); + + expect(calls[0]).toEqual(["claude", "mcp", "remove", "--scope", "user", "intern"]); + expect(calls[1]).toEqual( + expect.arrayContaining(["claude", "add", "--scope", "user", "launch"]), + ); + expect(calls[1].join(" ")).not.toContain("secret-token"); + expect(calls[2]).toEqual(["claude", "mcp", "get", "intern"]); + }); + + it("rejects a connected Claude entry from a shadowing local scope", async () => { + await expect( + runSetup(config, "claude", { + token: "secret-token", + env, + session: async () => session, + run: async (_command, args) => ({ + status: 0, + stdout: args.includes("get") + ? "Scope: Local config\nStatus: ✓ Connected\nCommand: npx\nArgs: Scope: User intern-mcp launch" + : "ok", + }), + }), + ).rejects.toThrow("remove any local/project intern entry"); + }); + + it("keeps the new token when Codex commits but readback verification fails", async () => { + await expect( + runSetup(config, "codex", { + token: "new-token", + env, + session: async () => session, + run: async (_command, args) => ({ + status: args.includes("get") ? 1 : 0, + stdout: "", + }), + }), + ).rejects.toThrow("new profile was retained"); + await expect(readStoredAccessToken(config)).resolves.toBe("new-token"); + }); + + it("restores the prior token and host config when replacement fails", async () => { + await fs.mkdir(config.configRoot, { recursive: true }); + await fs.writeFile(path.join(config.configRoot, "access-token"), "old-token\n", { + mode: 0o600, + }); + await fs.mkdir(env.CLAUDE_CONFIG_DIR!, { recursive: true }); + const configFile = path.join(env.CLAUDE_CONFIG_DIR!, ".claude.json"); + const previous = '{"mcpServers":{"intern":{"command":"old"}}}\n'; + await fs.writeFile(configFile, previous, { mode: 0o600 }); + + await expect( + runSetup(config, "claude", { + token: "new-token", + env, + session: async () => session, + run: async (_command, args) => { + if (args.includes("remove")) { + const document = JSON.parse(await fs.readFile(configFile, "utf8")); + delete document.mcpServers.intern; + await fs.writeFile(configFile, `${JSON.stringify(document)}\n`); + } + return { status: args.includes("add") ? 1 : 0, stdout: "" }; + }, + }), + ).rejects.toThrow("Could not configure Claude Code"); + + await expect(readStoredAccessToken(config)).resolves.toBe("old-token"); + expect(JSON.parse(await fs.readFile(configFile, "utf8"))).toEqual( + JSON.parse(previous), + ); + }); + + it("fails without changing host config when token validation fails", async () => { + const run = vi.fn(); + await expect( + runSetup(config, "codex", { + token: "rejected-token", + env, + session: async () => { + throw new Error("AUTH_REQUIRED: invalid_token"); + }, + run, + }), + ).rejects.toThrow("AUTH_REQUIRED"); + expect(run).not.toHaveBeenCalled(); + await expect( + fs.stat(path.join(config.configRoot, "access-token")), + ).rejects.toThrow(); + }); + + it("refuses a parallel setup before changing the stored token", async () => { + await fs.mkdir(config.configRoot, { recursive: true }); + await fs.writeFile(path.join(config.configRoot, "setup.lock"), `${process.pid}\n`); + + await expect( + runSetup(config, "codex", { + token: "new-token", + env, + session: async () => session, + run: vi.fn(), + }), + ).rejects.toThrow("Another Intern setup is already running"); + await expect( + fs.stat(path.join(config.configRoot, "access-token")), + ).rejects.toThrow(); + }); + + it("fails closed on a stale setup lock", async () => { + await fs.mkdir(config.configRoot, { recursive: true }); + await fs.writeFile(path.join(config.configRoot, "setup.lock"), "99999999\n"); + + await expect( + runSetup(config, "codex", { + token: "new-token", + env, + session: async () => session, + run: vi.fn(), + }), + ).rejects.toThrow("stale Intern setup lock"); + await expect( + fs.readFile(path.join(config.configRoot, "setup.lock"), "utf8"), + ).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("reads a piped token without echoing it", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + 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("Paste Intern access token"); + expect(visible).not.toContain("secret-token"); + }); +}); diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..cdc7981 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,425 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +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 { Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { InternSession } from "./api.js"; +import { PACKAGE_VERSION, type InternConfig } from "./config.js"; + +const exec = promisify(execFile); +const defaultPackage = `@archastro/intern-mcp@${PACKAGE_VERSION}`; + +export type SetupHost = "codex" | "claude"; + +interface CommandResult { + status: number; + stdout: string; +} + +interface FileSnapshot { + contents: Buffer; + mode: number; +} + +interface ClaudeEntrySnapshot { + exists: boolean; + value?: unknown; +} + +interface SetupDependencies { + token?: string; + packageSpec?: string; + promptToken?: () => Promise; + session?: (token: string) => Promise; + run?: (command: string, args: string[]) => Promise; + write?: (message: string) => void; + env?: NodeJS.ProcessEnv; +} + +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 async function runSetup( + config: InternConfig, + host: SetupHost, + dependencies: SetupDependencies = {}, +): Promise { + const promptToken = dependencies.promptToken ?? promptAccessToken; + const env = dependencies.env ?? process.env; + const token = ( + dependencies.token ?? + env.INTERN_ACCESS_TOKEN ?? + (await promptToken()) + ).trim(); + if (!token) throw new Error("An Intern access token is required"); + + const session = dependencies.session + ? await dependencies.session(token) + : await verifyMcp(token, env); + const run = dependencies.run ?? runCommand; + const packageSpec = + dependencies.packageSpec ?? env.INTERN_MCP_PACKAGE ?? defaultPackage; + const releaseLock = await acquireSetupLock(config.configRoot); + try { + const tokenFile = accessTokenFile(config); + const previousToken = await snapshotFile(tokenFile); + await writeAccessToken(config, token); + try { + await configureHost(host, packageSpec, env, run); + } catch (error) { + if (!(error instanceof HostConfigurationCommittedError)) { + await restoreFile(tokenFile, previousToken); + } + throw error; + } + + const write = + dependencies.write ?? ((message: string) => process.stdout.write(message)); + const hostName = host === "codex" ? "Codex" : "Claude Code"; + write( + `Intern connected to ${hostName} as ${session.user.org_name} · ${session.user.org_role}.\nRestart ${hostName}, then ask it to run intern_auth_status.\n`, + ); + return session; + } finally { + await releaseLock(); + } +} + +async function configureHost( + host: SetupHost, + packageSpec: string, + env: NodeJS.ProcessEnv, + run: (command: string, args: string[]) => Promise, +): Promise { + const launcher = ["npx", "--yes", `--package=${packageSpec}`, "intern-mcp", "launch"]; + const environmentArgs = [ + "INTERN_BASE_URL", + "INTERN_WORKSPACE_ROOT", + "INTERN_CONFIG_ROOT", + "INTERN_GIT_SSH_COMMAND", + ].flatMap((name) => (env[name] ? ["--env", `${name}=${env[name]}`] : [])); + + if (host === "codex") { + const added = await run("codex", [ + "mcp", + "add", + "intern", + ...environmentArgs, + "--", + ...launcher, + ]); + if (added.status !== 0) throw new Error("Could not configure Codex"); + const verified = await run("codex", ["mcp", "get", "intern", "--json"]); + const healthy = + verified.status === 0 && + verified.stdout.includes('"command": "npx"') && + verified.stdout.includes('"launch"'); + if (!healthy) { + throw new HostConfigurationCommittedError( + "Codex saved Intern but could not verify its registration; the new profile was retained", + ); + } + return; + } + + const configFile = claudeConfigFile(env); + const previousEntry = await readClaudeEntry(configFile); + let installedEntry: ClaudeEntrySnapshot | undefined; + try { + await run("claude", ["mcp", "remove", "--scope", "user", "intern"]); + const added = await run("claude", [ + "mcp", + "add", + "--transport", + "stdio", + "--scope", + "user", + "intern", + ...environmentArgs, + "--", + ...launcher, + ]); + if (added.status !== 0) throw new Error("Could not configure Claude Code"); + installedEntry = await readClaudeEntry(configFile); + + const verified = await run("claude", ["mcp", "get", "intern"]); + const output = stripANSI(verified.stdout); + const healthy = + verified.status === 0 && + /^\s*Scope:\s*User config(?:\s+\([^\n]*\))?\s*$/m.test(output) && + /^\s*Status:\s*.*Connected\s*$/m.test(output) && + !/^\s*Status:\s*.*Failed to connect\s*$/m.test(output) && + /^\s*Command:\s*npx\s*$/m.test(output) && + /^\s*Args:\s*.*\bintern-mcp\b.*\blaunch\b.*$/m.test(output); + if (!healthy) { + throw new Error( + "Claude Code did not select the user-level Intern launcher; remove any local/project intern entry and run setup again", + ); + } + } catch (error) { + await restoreClaudeEntry(configFile, previousEntry, installedEntry); + throw error; + } +} + +async function verifyMcp( + token: string, + env: NodeJS.ProcessEnv, +): 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", + }); + const client = new Client({ name: "intern-setup", version: PACKAGE_VERSION }); + await client.connect(transport); + try { + const response = await client.callTool({ + name: "intern_auth_status", + arguments: {}, + }); + const result = response.structuredContent as + | { authorized?: boolean; session?: InternSession } + | undefined; + if (result?.authorized !== true || !result.session) { + throw new Error("Intern rejected this access token"); + } + return result.session; + } finally { + await client.close(); + } +} + +async function runCommand(command: string, args: string[]): Promise { + try { + const result = await exec(command, args, { + env: process.env, + timeout: 120_000, + maxBuffer: 4 * 1024 * 1024, + }); + return { status: 0, stdout: result.stdout }; + } catch (error) { + const status = + typeof error === "object" && error !== null && "code" in error + ? Number(error.code) || 1 + : 1; + return { status, stdout: "" }; + } +} + +export async function promptAccessToken( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stderr, +): Promise { + 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: "); + try { + const token = await lines.question(""); + output.write("\n"); + return token; + } finally { + lines.close(); + } +} + +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"); + return token; +} + +async function writeAccessToken(config: InternConfig, token: string): Promise { + await fs.mkdir(config.configRoot, { recursive: true, mode: 0o700 }); + const file = accessTokenFile(config); + const temporary = path.join( + config.configRoot, + `.access-token.${process.pid}.${Date.now()}`, + ); + await fs.writeFile(temporary, `${token}\n`, { mode: 0o600 }); + await fs.rename(temporary, file); + await fs.chmod(file, 0o600); +} + +function accessTokenFile(config: InternConfig): string { + return path.join(config.configRoot, "access-token"); +} + +function claudeConfigFile(env: NodeJS.ProcessEnv): string { + const home = env.HOME ?? os.homedir(); + return path.join(env.CLAUDE_CONFIG_DIR ?? home, ".claude.json"); +} + +async function readClaudeEntry(file: string): Promise { + try { + const document = JSON.parse(await fs.readFile(file, "utf8")) as { + mcpServers?: Record; + }; + if (!Object.hasOwn(document.mcpServers ?? {}, "intern")) return { exists: false }; + return { exists: true, value: structuredClone(document.mcpServers!.intern) }; + } catch (error) { + if (isMissing(error)) return { exists: false }; + throw error; + } +} + +async function restoreClaudeEntry( + file: string, + previous: ClaudeEntrySnapshot, + installed: ClaudeEntrySnapshot | undefined, +): Promise { + let document: { mcpServers?: Record; [key: string]: unknown }; + let mode = 0o600; + try { + const [contents, stat] = await Promise.all([ + fs.readFile(file, "utf8"), + fs.stat(file), + ]); + document = JSON.parse(contents) as typeof document; + mode = stat.mode & 0o777; + } catch (error) { + if (!isMissing(error)) throw error; + document = {}; + } + const servers = (document.mcpServers ??= {}); + const currentExists = Object.hasOwn(servers, "intern"); + const current = currentExists ? servers.intern : undefined; + if ( + !installed && + currentExists === previous.exists && + (!currentExists || JSON.stringify(current) === JSON.stringify(previous.value)) + ) { + return; + } + if ( + installed && + (currentExists !== installed.exists || + (currentExists && JSON.stringify(current) !== JSON.stringify(installed.value))) + ) { + throw new Error( + "Claude Code's Intern configuration changed during setup; refusing to overwrite it", + ); + } + if (!installed && currentExists) { + throw new Error( + "Claude Code's Intern configuration changed during setup; refusing to overwrite it", + ); + } + if (previous.exists) servers.intern = previous.value; + else delete servers.intern; + await writeJSONAtomically(file, document, mode); +} + +async function writeJSONAtomically( + file: string, + value: object, + mode: number, +): Promise { + await fs.mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.intern-setup-${process.pid}-${Date.now()}`; + await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode }); + await fs.rename(temporary, file); + await fs.chmod(file, mode); +} + +async function acquireSetupLock(configRoot: string): Promise<() => Promise> { + await fs.mkdir(configRoot, { recursive: true, mode: 0o700 }); + const lockFile = path.join(configRoot, "setup.lock"); + try { + const handle = await fs.open(lockFile, "wx", 0o600); + await handle.writeFile(`${process.pid}\n`); + return async () => { + await handle.close(); + await fs.rm(lockFile, { force: true }); + }; + } catch (error) { + if (!isExists(error)) throw error; + const owner = Number.parseInt( + await fs.readFile(lockFile, "utf8").catch(() => ""), + 10, + ); + if (owner > 0 && processIsAlive(owner)) { + throw new Error("Another Intern setup is already running"); + } + throw new Error( + `A stale Intern setup lock exists at ${lockFile}; remove it and retry`, + ); + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ); + } +} + +async function snapshotFile(file: string): Promise { + try { + const [contents, stat] = await Promise.all([fs.readFile(file), fs.stat(file)]); + return { contents, mode: stat.mode & 0o777 }; + } catch (error) { + if (isMissing(error)) return null; + throw error; + } +} + +async function restoreFile(file: string, snapshot: FileSnapshot | null): Promise { + if (!snapshot) { + await fs.rm(file, { force: true }); + return; + } + await fs.mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.intern-setup-${process.pid}-${Date.now()}`; + await fs.writeFile(temporary, snapshot.contents, { mode: snapshot.mode }); + await fs.rename(temporary, file); + await fs.chmod(file, snapshot.mode); +} + +function isMissing(error: unknown): boolean { + return hasCode(error, "ENOENT"); +} + +function isExists(error: unknown): boolean { + return hasCode(error, "EEXIST"); +} + +function hasCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +function stripANSI(value: string): string { + const colorSequence = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); + return value.replace(colorSequence, ""); +}