diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5d3e26..e7f94d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,7 @@ jobs: - run: bun install --frozen-lockfile - run: bun run check - # `check` only ever sees the source tree. This packs the tarball, installs it elsewhere, and drives - # the installed binary — the only way to catch a `files` allowlist or import-resolution regression. - smoke: + package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -26,4 +24,39 @@ jobs: with: bun-version: latest - run: bun install --frozen-lockfile - - run: bun run smoke + - run: mkdir artifacts + - run: bun pm pack --destination artifacts + - run: bun build scripts/smoke.ts --target=node --format=esm --outfile=artifacts/smoke.mjs + - uses: actions/upload-artifact@v4 + with: + name: package + path: artifacts + + smoke: + name: smoke (${{ matrix.runtime }}) + needs: package + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - runtime: Bun + runner: bunx + command: bun + - runtime: Node + runner: npx + command: node + steps: + - uses: oven-sh/setup-bun@v2 + if: matrix.runner == 'bunx' + with: + bun-version: latest + - uses: actions/setup-node@v7 + if: matrix.runner == 'npx' + with: + node-version: "22" + - uses: actions/download-artifact@v4 + with: + name: package + path: artifacts + - run: ${{ matrix.command }} artifacts/smoke.mjs ${{ matrix.runner }} artifacts diff --git a/.gitignore b/.gitignore index 41097d2..cc4a986 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +dist/ .env *.log .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index d55ba73..bbf9ca7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ src/ session.ts on-disk session store (mode 0600) cookies.ts cookie-jar merge + cURL parsing ingest.ts normalize creds → verify → persist (+ FORKABLE_COOKIE provisioning) - chrome.ts macOS Chrome cookie decryption + chrome.ts browser cookie import cli.ts `bun run auth` login.ts email/password `createSession` login order/ ordering domain (pure) @@ -51,18 +51,11 @@ a session one of: - **Email/password** (`auth/login.ts`): `bun run auth --login` (`--email`/`--password`/`--mfa`) or `FORKABLE_EMAIL`/`FORKABLE_PASSWORD` (+ `FORKABLE_MFA`) env. Logs in via the `createSession` mutation; works headless. A public `identities` pre-check fails fast on SSO-only accounts. Password-capable only. -- **Browser cookie**: `bun run auth --chrome` (macOS Keychain-decrypts the local browser cookie; `--browser` - picks any value in `SUPPORTED_BROWSERS`), `FORKABLE_COOKIE` env, or `bun run auth --file ` / - `pbpaste | bun run auth`. - - `chrome.ts` searches **every** profile, not just `Default`: `discoverProfiles` unions `Default`, the - dirs in `Local State`'s `profile.info_cache`, any sibling dir holding a `Cookies` DB, and the - user-data root itself (Opera keeps `Cookies` there). Labels come from `info_cache` or the profile's - own `Preferences` (`profile.name`) — Arc doesn't keep `info_cache` current. `pickProfileJar` then - takes the profile whose `_easyorder_session` has the newest `last_access_utc`, so a logged-out - `Default` can't shadow a live `Profile 1`; `--profile ` pins one. Note Arc nests profiles one - level deeper (`Arc/User Data/`), and all Google Chrome channels share the single - `Chrome Safe Storage` Keychain account, so `BrowserSpec.label` carries the display name separately. +- **Browser cookie**: `bun run auth --chrome` uses `@steipete/sweet-cookie` to read Chrome and Edge + profiles on macOS, Linux, and Windows. Arc targeting is macOS-only; Brave and Chromium on Linux or + Windows may need an explicit `--profile` path. Matching session candidates are verified until one + succeeds, and the operating system may prompt for credential-store access. `FORKABLE_COOKIE`, + `bun run auth --file `, and `pbpaste | bun run auth` provide manual alternatives. On startup with no session, `provisionFromEnvIfNeeded` establishes one from env (cookie first, else email/password). The session is stored at `~/.forkable-mcp/session.json` (mode `0600`, never logged); the diff --git a/bun.lock b/bun.lock index aeb8d25..e4df983 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,9 @@ "dependencies": { "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", + "@steipete/sweet-cookie": "^0.4.1", + "cookie": "^2.0.1", + "set-cookie-parser": "^3.1.2", "zod": "^4.4.3", }, "devDependencies": { @@ -101,6 +104,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@steipete/sweet-cookie": ["@steipete/sweet-cookie@0.4.1", "", { "bin": { "sweet-cookie": "dist/cli.js" } }, "sha512-6cuWTGeblwzMw4/3uMzBEmgH1B+crCkJJlmTVu4vzbhG2NhAH8sMWv57fQ8JZY0nqW2ldM0/c2JM0UeQQFyJ3g=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], @@ -147,6 +152,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -165,6 +172,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/package.json b/package.json index b77e6a9..9fa1f01 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,17 @@ "bugs": { "url": "https://github.com/colinds/forkable-mcp/issues" }, - "keywords": ["mcp", "model-context-protocol", "forkable", "lunch", "bun"], + "keywords": ["mcp", "model-context-protocol", "forkable", "lunch", "bun", "node"], "bin": { - "forkable-mcp": "src/index.ts" + "forkable-mcp": "dist/index.js" }, - "files": ["src", "skills", "tsconfig.json", "README.md", "LICENSE"], + "files": ["dist", "skills", "README.md", "LICENSE"], "engines": { - "bun": ">=1.3.0" + "node": ">=22" }, "scripts": { + "build": "bun build src/index.ts --target=node --format=esm --packages=external --outfile=dist/index.js", + "prepack": "bun run build", "start": "bun run src/index.ts", "dev": "bun --watch src/index.ts", "auth": "bun run src/index.ts --auth", @@ -38,6 +40,9 @@ "dependencies": { "@modelcontextprotocol/server": "^2.0.0", "@modelcontextprotocol/core": "^2.0.0", + "@steipete/sweet-cookie": "^0.4.1", + "cookie": "^2.0.1", + "set-cookie-parser": "^3.1.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 43b1bfb..21e0cdf 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -11,11 +11,15 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { mkdtemp, rm } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { access, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; import pkg from "../package.json" with { type: "json" }; +const exec = promisify(execFile); + const EXPECTED_TOOLS = [ "confirm_delivery", "explain_pick", @@ -32,6 +36,7 @@ const EXPECTED_TOOLS = [ ]; const log = (msg: string) => console.log(` ${msg}`); +type Runner = "bunx" | "npx"; function fail(msg: string): never { console.error(`\n✗ ${msg}`); @@ -39,74 +44,105 @@ function fail(msg: string): never { } async function run(cmd: string[], cwd: string): Promise { - const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([ - new Response(p.stdout).text(), - new Response(p.stderr).text(), - p.exited, - ]); - if (code !== 0) fail(`\`${cmd.join(" ")}\` exited ${code}\n${err || out}`); + const [command, ...args] = cmd; + try { + await exec(command!, args, { cwd }); + } catch (error) { + const result = error as Error & { code?: number; stdout?: string; stderr?: string }; + fail( + `\`${cmd.join(" ")}\` exited ${result.code ?? "unknown"}\n${result.stderr || result.stdout || result.message}`, + ); + } } -const tmp = await mkdtemp(join(tmpdir(), "forkable-smoke-")); -try { - log(`packing ${pkg.name}@${pkg.version}`); - await run(["bun", "pm", "pack", "--destination", tmp], process.cwd()); - const tgz = [...new Bun.Glob("*.tgz").scanSync(tmp)][0]; - if (!tgz) fail("bun pm pack produced no tarball"); - - const consumer = join(tmp, "consumer"); - await Bun.write( - join(consumer, "package.json"), - JSON.stringify({ name: "smoke-consumer", private: true }), +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false, ); +} - log(`installing ${tgz} into a scratch project`); - await run(["bun", "add", join(tmp, tgz)], consumer); - - const bin = join(consumer, "node_modules", ".bin", "forkable-mcp"); - if (!(await Bun.file(bin).exists())) fail(`no binary at ${bin} — check package.json "bin"`); +async function findTarball(path: string): Promise { + const input = resolve(path); + if ((await stat(input)).isFile()) return input; + const file = (await readdir(input)).find((name) => name.endsWith(".tgz")); + if (!file) fail(`no package tarball found in ${input}`); + return join(input, file); +} - log("connecting a real MCP client to the installed binary"); - const client = new Client({ name: "smoke", version: "0" }); +async function checkInstalled(runner: Runner, cwd: string, home: string): Promise { + log(`connecting with ${runner}`); + const client = new Client({ name: `smoke-${runner}`, version: "0" }); const transport = new StdioClientTransport({ - command: bin, - cwd: consumer, - env: { PATH: process.env.PATH ?? "", FORKABLE_MCP_HOME: join(tmp, "home") }, + command: runner, + args: runner === "bunx" ? ["--bun", "forkable-mcp"] : ["forkable-mcp"], + cwd, + env: { PATH: process.env.PATH ?? "", FORKABLE_MCP_HOME: home }, stderr: "pipe", }); - // A startup crash surfaces as a bare "Connection closed", so keep the child's stderr to report - // the actual cause. The stream only exists once connect() has started the transport. const connecting = client.connect(transport); let childErr = ""; transport.stderr?.on("data", (d: Buffer) => { childErr += d.toString(); }); - const timer = setTimeout(() => fail("timed out connecting — the server never came up"), 30_000); - await connecting.catch((e: Error) => fail(`connect failed: ${e.message}\n${childErr.trim()}`)); + const timer = setTimeout(() => fail(`timed out connecting with ${runner}`), 30_000); + await connecting.catch((e: Error) => + fail(`${runner} connect failed: ${e.message}\n${childErr.trim()}`), + ); clearTimeout(timer); const version = client.getServerVersion()?.version; if (version !== pkg.version) - fail(`server reports v${version}, package.json says v${pkg.version}`); - log(`serverInfo.version = ${version}`); - - const names = (await client.listTools()).tools.map((t) => t.name).toSorted(); - const missing = EXPECTED_TOOLS.filter((t) => !names.includes(t)); - const extra = names.filter((t) => !EXPECTED_TOOLS.includes(t)); - if (missing.length) fail(`missing tools: ${missing.join(", ")}`); - if (extra.length) fail(`unexpected tools (update EXPECTED_TOOLS?): ${extra.join(", ")}`); - log(`${names.length} tools registered`); - - // Prove a tool actually dispatches. Unauthenticated, so the re-auth message is the pass condition — - // what matters is that the handler ran instead of the process falling over. + fail(`${runner} server reports v${version}, package.json says v${pkg.version}`); + log(`${runner} serverInfo.version = ${version}`); + + const names = (await client.listTools()).tools.map((tool) => tool.name).toSorted(); + const missing = EXPECTED_TOOLS.filter((tool) => !names.includes(tool)); + const extra = names.filter((tool) => !EXPECTED_TOOLS.includes(tool)); + if (missing.length) fail(`${runner} missing tools: ${missing.join(", ")}`); + if (extra.length) fail(`${runner} unexpected tools: ${extra.join(", ")}`); + log(`${runner} registered ${names.length} tools`); + const res: any = await client.callTool({ name: "get_profile", arguments: {} }); - const text = (res.content ?? []).map((c: any) => c.text ?? "").join(""); - if (!text.trim()) fail("get_profile returned no content"); - log(`get_profile responded (${text.split("\n")[0].slice(0, 60)}…)`); + const text = (res.content ?? []).map((content: any) => content.text ?? "").join(""); + if (!text.trim()) fail(`${runner}: get_profile returned no content`); + log(`${runner} get_profile responded (${text.split("\n")[0].slice(0, 60)}…)`); await client.close(); - console.log("\n✓ packaged install works"); +} + +async function checkPackage(runner: Runner, root: string, tarball: string): Promise { + const consumer = join(root, `consumer-${runner}`); + await mkdir(consumer, { recursive: true }); + await writeFile(join(consumer, "package.json"), JSON.stringify({ private: true })); + + log(`installing with ${runner === "bunx" ? "bun" : "npm"}`); + if (runner === "bunx") await run(["bun", "add", tarball], consumer); + else await run(["npm", "install", "--cache", join(root, "npm-cache"), tarball], consumer); + + const bin = join(consumer, "node_modules", ".bin", "forkable-mcp"); + if (!(await exists(bin))) fail(`no binary at ${bin} — check package.json "bin"`); + await checkInstalled(runner, consumer, join(root, `home-${runner}`)); +} + +const tmp = await mkdtemp(join(tmpdir(), "forkable-smoke-")); +try { + const requested = process.argv[2]; + if (requested && requested !== "bunx" && requested !== "npx") { + fail(`unknown runner ${requested}; expected bunx or npx`); + } + const runners: Runner[] = + requested === "bunx" || requested === "npx" ? [requested] : ["bunx", "npx"]; + let tarball: string; + if (process.argv[3]) { + tarball = await findTarball(process.argv[3]); + } else { + log(`packing ${pkg.name}@${pkg.version}`); + await run(["bun", "pm", "pack", "--destination", tmp], process.cwd()); + tarball = await findTarball(tmp); + } + await Promise.all(runners.map((runner) => checkPackage(runner, tmp, tarball))); + console.log(`\n✓ packaged install works with ${runners.join(" and ")}`); } finally { await rm(tmp, { recursive: true, force: true }); } diff --git a/skills/forkable-setup/SKILL.md b/skills/forkable-setup/SKILL.md index 49d2c3c..6f8b622 100644 --- a/skills/forkable-setup/SKILL.md +++ b/skills/forkable-setup/SKILL.md @@ -16,30 +16,30 @@ It acts on the user's behalf with their own Forkable session — there is no API `forkable-lunch` skill. If a call fails with a re-auth message, redo step 1 alone — the session expired, nothing needs reinstalling. -Requires [Bun](https://bun.sh) 1.3+ (`bun --version`). `bunx` runs the server; there's nothing to -clone or install globally. +Use Bun or Node.js. The package runs without a clone or global install. ## 1. Authenticate — the user runs this themselves -These commands take their password or unlock their macOS Keychain, so hand them the line to run in a -real terminal rather than running it for them. Never put their password in a +These commands take their password or may unlock their operating system's credential store, so hand +them the line to run in a real terminal rather than running it for them. Never put their password in a command you execute or in a file you write. -**macOS, already logged into Forkable in a browser:** +**Already logged into Forkable in a browser:** ```bash -bunx forkable-mcp@latest --auth --chrome +bunx --bun forkable-mcp@latest --auth --chrome # Node: npx forkable-mcp@latest --auth --chrome ``` -Approve the Keychain prompt. Every Chrome profile is searched and the most recently used Forkable -session wins. Other browsers: `--browser arc` (also brave, edge, vivaldi, opera, chromium, -chrome-beta, chrome-dev, chrome-canary); pin a profile by its *directory* name with -`--profile "Profile 1"`. +Browser import is best-effort on macOS, Linux, and Windows. macOS may prompt for Keychain access once +per scanned profile; use `--profile` to limit the scan. Linux may use its system keyring. Chrome and +Edge profiles are discovered automatically. Arc targeting is macOS-only; Brave and Chromium on Linux +or Windows may need an explicit profile path. Use `--browser arc` (also brave, edge, chromium) and pin +a profile with `--profile "Profile 1"`. **Email + password** — the only method that survives expiry (the server re-logs in on a 401): ```bash -bunx forkable-mcp@latest --auth --login --email you@company.com --password '…' +bunx --bun forkable-mcp@latest --auth --login --email you@company.com --password '…' ``` Add `--mfa ` if their account asks for one. A password typed as an argument lands in shell @@ -47,7 +47,7 @@ history — `FORKABLE_EMAIL` / `FORKABLE_PASSWORD` in the environment does the s **SSO / Okta accounts can't use password login** and the command says so immediately. Those need a cookie: forkable.com → DevTools → Network → filter `graphql` → right-click a `POST .../api/v2/graphql` -row → *Copy → Copy as cURL*, then `pbpaste | bunx forkable-mcp@latest --auth`. The whole blob is +row → _Copy → Copy as cURL_, then `pbpaste | bunx --bun forkable-mcp@latest --auth`. The whole blob is fine; only the `cookie:` header is read, and it must contain `_easyorder_session`. Cookie sessions can't self-refresh — expect to repeat this when it expires. @@ -55,17 +55,17 @@ The session lands in `~/.forkable-mcp/session.json` (mode `0600`) and is never l ## 2. Register the server -| Client | Command / config | -|---|---| -| Claude Code | `claude mcp add forkable -- bunx forkable-mcp@latest` | -| Codex | `codex mcp add forkable -- bunx forkable-mcp@latest` | -| Claude Desktop / Cursor | the JSON below, under `mcpServers` | -| VS Code | the JSON below in `.vscode/mcp.json`, under `servers` | +| Client | Command / config | +| ----------------------- | ----------------------------------------------------------- | +| Claude Code | `claude mcp add forkable -- bunx --bun forkable-mcp@latest` | +| Codex | `codex mcp add forkable -- bunx --bun forkable-mcp@latest` | +| Claude Desktop / Cursor | the JSON below, under `mcpServers` | +| VS Code | the JSON below in `.vscode/mcp.json`, under `servers` | ```json { "mcpServers": { - "forkable": { "command": "bunx", "args": ["forkable-mcp@latest"] } + "forkable": { "command": "bunx", "args": ["--bun", "forkable-mcp@latest"] } } } ``` @@ -81,12 +81,12 @@ The client spawns the server at startup, so a restart is required before the too All optional; set them in the client's MCP config `env` block, not in the shell. -| | | -|---|---| -| `FORKABLE_EMAIL` / `FORKABLE_PASSWORD` | headless login, and auto-relogin when the session expires | -| `FORKABLE_MAX_TOTAL` | hard spend cap in dollars — a write over it is refused outright | -| `FORKABLE_COOKIE` | a full Cookie header, for headless SSO accounts | -| `FORKABLE_MCP_HOME` | where the session lives (default `~/.forkable-mcp`) | +| | | +| -------------------------------------- | --------------------------------------------------------------- | +| `FORKABLE_EMAIL` / `FORKABLE_PASSWORD` | headless login, and auto-relogin when the session expires | +| `FORKABLE_MAX_TOTAL` | hard spend cap in dollars — a write over it is refused outright | +| `FORKABLE_COOKIE` | a full Cookie header, for headless SSO accounts | +| `FORKABLE_MCP_HOME` | where the session lives (default `~/.forkable-mcp`) | ## Troubleshooting diff --git a/src/auth/chrome.ts b/src/auth/chrome.ts index 4e83863..c173d69 100644 --- a/src/auth/chrome.ts +++ b/src/auth/chrome.ts @@ -1,366 +1,100 @@ -// Read + decrypt the current Forkable session cookies from a local Chromium-family browser on -// macOS (Chrome, Brave, Edge, Arc, Vivaldi, Opera, …). This powers `bun run auth --chrome`: log -// into forkable.com in the browser once, then import the session with no cURL paste. Produces a -// Cookie header string; the caller passes it to ingestCredentials. -// -// macOS Chromium cookie encryption: -// key = PBKDF2-SHA1(Keychain "Chrome Safe Storage" secret, salt="saltysalt", iter=1003, len=16) -// value = AES-128-CBC(key, iv=16×0x20) over the "v10"-prefixed ciphertext, PKCS7-padded. -// Modern Chrome prepends a 32-byte SHA256(host_key) domain hash to the plaintext. - -import { pbkdf2Sync, createDecipheriv, createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, copyFileSync, rmSync, readdirSync, readFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; -import { Database } from "bun:sqlite"; - -/** The Forkable session cookie. Its presence is what makes a profile's jar usable. */ -const SESSION_COOKIE = "_easyorder_session"; - -/** Chromium-family browsers we know how to read cookies from on macOS. */ -export const SUPPORTED_BROWSERS = [ - "chrome", - "chrome-beta", - "chrome-dev", - "chrome-canary", - "chromium", - "brave", - "edge", - "arc", - "vivaldi", - "opera", -] as const; - +import { + ALL_PROFILES, + getCookies, + toCookieHeader, + type Cookie, + type GetCookiesOptions, +} from "@steipete/sweet-cookie"; +import { hasSessionCookie } from "./cookies.ts"; + +const FORKABLE_GRAPHQL_URL = "https://forkable.com/api/v2/graphql"; +const BROWSER_HELPER_TIMEOUT_MS = 30_000; + +export const SUPPORTED_BROWSERS = ["chrome", "brave", "arc", "chromium", "edge"] as const; export type SupportedBrowser = (typeof SUPPORTED_BROWSERS)[number]; export interface ChromeReadOptions { - /** Profile directory name (e.g. `Default`, `Profile 3`). Omit to auto-pick across all profiles. */ profile?: string; browser?: SupportedBrowser; } -export interface ChromeReadResult { - /** `name=value; name=value` Cookie header. */ +export interface BrowserCookieCandidate { cookie: string; - /** Human label of the profile it came from, e.g. `Profile 3 (Work)`. */ profile: string; } -interface BrowserPaths { - userData: string; // …/Application Support/ - keychainService: string; - keychainAccount: string; - label: string; // what to call the browser in messages -} - -interface BrowserSpec { - /** Path segments under ~/Library/Application Support holding the profile dirs. */ - dir: string[]; - /** Keychain generic-password service + account for the "Safe Storage" key. */ - service: string; - account: string; - /** Display name, when it differs from the Keychain account (Chrome's side channels). */ - label?: string; -} - -// Per-browser macOS profile dir + login-Keychain "Safe Storage" entry. All are Chromium forks and -// share the same v10/AES-128-CBC cookie scheme; only the data dir and Keychain names differ. -const BROWSER_PATHS: Record = { - // Every Google Chrome channel shares the one "Chrome Safe Storage" Keychain entry, so the - // channels differ only in data dir. - chrome: { dir: ["Google", "Chrome"], service: "Chrome Safe Storage", account: "Chrome" }, - "chrome-beta": { - dir: ["Google", "Chrome Beta"], - service: "Chrome Safe Storage", - account: "Chrome", - label: "Chrome Beta", - }, - "chrome-dev": { - dir: ["Google", "Chrome Dev"], - service: "Chrome Safe Storage", - account: "Chrome", - label: "Chrome Dev", - }, - "chrome-canary": { - dir: ["Google", "Chrome Canary"], - service: "Chrome Safe Storage", - account: "Chrome", - label: "Chrome Canary", - }, - chromium: { dir: ["Chromium"], service: "Chromium Safe Storage", account: "Chromium" }, - brave: { - dir: ["BraveSoftware", "Brave-Browser"], - service: "Brave Safe Storage", - account: "Brave", - }, - edge: { - dir: ["Microsoft Edge"], - service: "Microsoft Edge Safe Storage", - account: "Microsoft Edge", - }, - // Arc nests its profiles one level deeper than the other forks: Arc/User Data//Cookies. - arc: { dir: ["Arc", "User Data"], service: "Arc Safe Storage", account: "Arc" }, - vivaldi: { dir: ["Vivaldi"], service: "Vivaldi Safe Storage", account: "Vivaldi" }, - opera: { - dir: ["com.operasoftware.Opera"], - service: "Opera Safe Storage", - account: "Opera", - }, -}; - -/** Resolve a browser's data dir + Keychain coordinates. Exported for testing. */ -export function browserPaths(browser: SupportedBrowser = "chrome"): BrowserPaths { - const support = join(homedir(), "Library", "Application Support"); - const p = BROWSER_PATHS[browser] ?? BROWSER_PATHS.chrome; - return { - userData: join(support, ...p.dir), - keychainService: p.service, - keychainAccount: p.account, - label: p.label ?? p.account, - }; -} - -/** Throw a clear error on non-macOS platforms. Exported for testing. */ -export function assertDarwin(platform: NodeJS.Platform = process.platform): void { - if (platform !== "darwin") { - throw new Error( - `--chrome import is only supported on macOS (this is ${platform}). ` + - `Set FORKABLE_COOKIE, or pipe a DevTools "Copy as cURL" into \`bun run auth\` instead.`, - ); - } -} - -/** PBKDF2 key derivation for Chrome's macOS cookie encryption. Exported for testing. */ -export function deriveKey(secret: string): Buffer { - return pbkdf2Sync(secret, "saltysalt", 1003, 16, "sha1"); -} - -function isPrintableAscii(buf: Buffer): boolean { - for (const b of buf) if (b < 0x20 || b > 0x7e) return false; - return true; -} - -/** - * Decrypt a Chrome cookie `encrypted_value`. Handles the `v10` scheme (AES-128-CBC) and the - * optional 32-byte SHA256(host) domain-hash prefix; falls back to raw bytes for legacy plaintext. - * Exported for testing. - */ -export function decryptCookieValue(encrypted: Buffer, key: Buffer, hostKey: string): string { - if (encrypted.length >= 3 && encrypted.subarray(0, 3).toString("latin1") === "v10") { - const iv = Buffer.alloc(16, 0x20); - const decipher = createDecipheriv("aes-128-cbc", key, iv); - decipher.setAutoPadding(true); - const ct = encrypted.subarray(3); - let plain = Buffer.concat([decipher.update(ct), decipher.final()]); - // Strip the 32-byte domain hash if present (compare against SHA256(host_key)). - if (plain.length >= 32) { - const domainHash = createHash("sha256").update(hostKey).digest(); - if (plain.subarray(0, 32).equals(domainHash)) plain = plain.subarray(32); - else if (!isPrintableAscii(plain) && isPrintableAscii(plain.subarray(32))) - plain = plain.subarray(32); - } - return plain.toString("utf8"); - } - // Legacy: value stored as plaintext. - return encrypted.toString("utf8"); -} - -/** Fetch the Chrome Safe Storage key from the login Keychain (prompts for access). */ -function keychainSecret(paths: BrowserPaths): string { - try { - const out = execFileSync( - "security", - ["find-generic-password", "-w", "-s", paths.keychainService, "-a", paths.keychainAccount], - { encoding: "utf8" }, - ); - return out.replace(/\n$/, ""); - } catch { - throw new Error( - `Could not read "${paths.keychainService}" from your Keychain ` + - `(approve the prompt, or unlock your login keychain). ` + - `Make sure ${paths.label} is installed.`, - ); - } -} - -export interface Profile { - /** Directory name under the user-data dir; `.` when cookies sit in the root (Opera). */ - dir: string; - /** Display label for messages — the browser's profile name when we can read it. */ - label: string; +export interface ChromeReadResult { + candidates: BrowserCookieCandidate[]; + warnings: string[]; } -/** - * Enumerate every profile of a browser that might hold cookies. Multi-profile installs (common in - * Arc, where each account gets its own profile) put the Forkable session in exactly one of them, so - * we look at all of them rather than assuming `Default`. Exported for testing. - */ -export function discoverProfiles(userData: string, profile?: string): Profile[] { - if (profile) return [{ dir: profile, label: profile }]; +type CookieReader = typeof getCookies; - // `Local State` maps profile dirs → user-visible names ("Work", "Personal", …). - const names = new Map(); - try { - const localState = JSON.parse(readFileSync(join(userData, "Local State"), "utf8")) as { - profile?: { info_cache?: Record }; +function readOptions(options: ChromeReadOptions): GetCookiesOptions { + const browser = options.browser ?? "chrome"; + const profile = options.profile ?? ALL_PROFILES; + if (browser === "edge") { + return { + url: FORKABLE_GRAPHQL_URL, + browsers: ["edge"], + edgeProfile: profile, + timeoutMs: BROWSER_HELPER_TIMEOUT_MS, }; - for (const [dir, info] of Object.entries(localState.profile?.info_cache ?? {})) { - if (info?.name) names.set(dir, info.name); - } - } catch { - /* no Local State (or unreadable) — per-profile Preferences below still gives us names */ } - - // Per-profile fallback: each profile's own Preferences file carries `profile.name`. Arc doesn't - // always keep info_cache current, so this is what names its extra profiles. - const nameOf = (dir: string): string | undefined => { - const cached = names.get(dir); - if (cached) return cached; - try { - const prefs = JSON.parse(readFileSync(join(userData, dir, "Preferences"), "utf8")) as { - profile?: { name?: string }; - }; - return prefs.profile?.name || undefined; - } catch { - return undefined; - } - }; - - const out: Profile[] = []; - const seen = new Set(); - const add = (dir: string) => { - if (seen.has(dir)) return; - seen.add(dir); - const name = nameOf(dir); - out.push({ dir, label: name && name !== dir ? `${dir} (${name})` : dir }); + return { + url: FORKABLE_GRAPHQL_URL, + browsers: ["chrome"], + chromeProfile: profile, + chromiumBrowser: browser, + timeoutMs: BROWSER_HELPER_TIMEOUT_MS, }; - - add("Default"); - for (const dir of names.keys()) add(dir); - // Any other directory holding a Cookies DB (profiles the browser hasn't listed yet). - try { - for (const entry of readdirSync(userData, { withFileTypes: true })) { - if (entry.isDirectory() && existsSync(join(userData, entry.name, "Cookies"))) add(entry.name); - } - } catch { - /* ignore */ - } - // Opera keeps Cookies directly in the user-data dir, with no profile subdirectory. - if (existsSync(join(userData, "Cookies"))) add("."); - return out; -} - -interface CookieRow { - host_key: string; - name: string; - encrypted_value: Uint8Array; - last_access_utc: number; -} - -/** Copy a (possibly locked/WAL) Cookies DB to temp and read forkable.com rows out of it. */ -function readForkableRows(cookiesDbPath: string): CookieRow[] { - if (!existsSync(cookiesDbPath)) return []; - const dir = mkdtempSync(join(tmpdir(), "forkable-cookies-")); - try { - const tmpDb = join(dir, "Cookies"); - copyFileSync(cookiesDbPath, tmpDb); - for (const ext of ["-wal", "-shm"]) { - if (existsSync(cookiesDbPath + ext)) copyFileSync(cookiesDbPath + ext, tmpDb + ext); - } - const db = new Database(tmpDb, { readonly: true }); - try { - return db - .query( - "SELECT host_key, name, encrypted_value, last_access_utc FROM cookies " + - "WHERE host_key LIKE '%forkable.com'", - ) - .all() as CookieRow[]; - } finally { - db.close(); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } } -export interface ProfileJar { - profile: Profile; - jar: Map; - /** Chrome timestamp of the session cookie's last use; 0 when absent. */ - lastAccess: number; +function pathApplies(cookiePath = "/", requestPath = "/api/v2/graphql"): boolean { + if (!cookiePath.startsWith("/") || !requestPath.startsWith(cookiePath)) return false; + return ( + cookiePath.endsWith("/") || + requestPath.length === cookiePath.length || + requestPath[cookiePath.length] === "/" + ); } -/** - * Of the profiles that carry a Forkable session, pick the one whose session cookie was used most - * recently — with several logged-in profiles, that's the account the user is actually on. - * Exported for testing. - */ -export function pickProfileJar(jars: ProfileJar[]): ProfileJar | undefined { - return jars - .filter((j) => j.jar.has(SESSION_COOKIE)) - .toSorted((a, b) => b.lastAccess - a.lastAccess)[0]; +function appliesToForkableApi(cookie: Cookie): boolean { + const domain = cookie.domain?.replace(/^\./, "").toLowerCase(); + return domain === "forkable.com" && pathApplies(cookie.path); } -/** - * Read all forkable.com cookies from a local browser and return a Cookie header string - * (`name=value; name=value`), decrypted, plus the profile it came from. Every profile is searched - * and the one with the freshest session wins. Throws with an actionable message on failure. - */ -export async function readForkableCookieHeader( - opts: ChromeReadOptions = {}, -): Promise { - assertDarwin(); - const browser = opts.browser ?? "chrome"; - const paths = browserPaths(browser); - const who = paths.label; - if (!existsSync(paths.userData)) { - throw new Error(`${who} profile dir not found at ${paths.userData}. Is ${who} installed?`); +function candidatesFrom(cookies: Cookie[]): BrowserCookieCandidate[] { + const profiles = new Map(); + for (const cookie of cookies) { + if (!appliesToForkableApi(cookie)) continue; + const profile = cookie.source?.profile ?? "Default"; + const group = profiles.get(profile) ?? []; + group.push(cookie); + profiles.set(profile, group); } - const profiles = discoverProfiles(paths.userData, opts.profile); - const withRows = profiles - .map((profile) => ({ - profile, - rows: readForkableRows(join(paths.userData, profile.dir, "Cookies")), - })) - .filter((p) => p.rows.length > 0); - - if (!withRows.length) { - throw new Error( - `No forkable.com cookies found in ${who} (checked ${profiles.map((p) => p.label).join(", ")}). ` + - `Log in to forkable.com in ${who} first, or pass --profile "" if your profile isn't listed.`, + return [...profiles.entries()].flatMap(([profile, profileCookies]) => { + const cookie = toCookieHeader( + profileCookies.toSorted((a, b) => (b.path?.length ?? 1) - (a.path?.length ?? 1)), + { dedupeByName: true, sort: "none" }, ); - } - - // Decrypt only now — reading the Keychain prompts, so don't do it when there's nothing to read. - const key = deriveKey(keychainSecret(paths)); - const jars: ProfileJar[] = withRows.map(({ profile, rows }) => { - const jar = new Map(); // last write wins - let lastAccess = 0; - for (const row of rows) { - try { - const value = decryptCookieValue(Buffer.from(row.encrypted_value), key, row.host_key); - if (!value) continue; - jar.set(row.name, value); - if (row.name === SESSION_COOKIE) - lastAccess = Math.max(lastAccess, row.last_access_utc ?? 0); - } catch { - /* skip an undecryptable cookie rather than fail the whole import */ - } - } - return { profile, jar, lastAccess }; + return hasSessionCookie(cookie) ? [{ cookie, profile }] : []; }); +} - const chosen = pickProfileJar(jars); - if (!chosen) { +export async function readForkableCookieHeaders( + options: ChromeReadOptions = {}, + readCookies: CookieReader = getCookies, +): Promise { + const result = await readCookies(readOptions(options)); + const candidates = candidatesFrom(result.cookies); + if (!candidates.length) { + const browser = options.browser ?? "chrome"; + const details = result.warnings.length ? ` ${result.warnings.join(" ")}` : ""; throw new Error( - `Found forkable.com cookies in ${who} (${withRows.map((p) => p.profile.label).join(", ")}) ` + - `but no ${SESSION_COOKIE}, so you're probably logged out. Log in to forkable.com in ${who} and try again.`, + `No logged-in Forkable session was found in ${browser}. Log in to forkable.com and try again.${details}`, ); } - return { - cookie: [...chosen.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; "), - profile: chosen.profile.label, - }; + return { candidates, warnings: result.warnings }; } diff --git a/src/auth/cli.ts b/src/auth/cli.ts index fc4a92c..06e5d24 100644 --- a/src/auth/cli.ts +++ b/src/auth/cli.ts @@ -5,6 +5,14 @@ import { loginWithPassword } from "./login.ts"; import { readSession, redact } from "./session.ts"; import { ReauthRequiredError } from "@/net/errors.ts"; import { type SupportedBrowser } from "./chrome.ts"; +import { readFile } from "node:fs/promises"; + +async function readStdin(input: NodeJS.ReadStream = process.stdin): Promise { + input.setEncoding("utf8"); + let value = ""; + for await (const chunk of input) value += chunk; + return value; +} export async function runAuthCli(argv: string[]): Promise { const flag = (name: string) => { @@ -32,8 +40,7 @@ export async function runAuthCli(argv: string[]): Promise { const { me } = await loginWithPassword({ email, password, mfaCode }); console.error(`✓ Logged in as ${me.fullName || me.email || `user ${me.id}`}.`); } else if (useChrome) { - // Lazy-load: chrome.ts pulls in bun:sqlite and is only needed on the --chrome path. - const { readForkableCookieHeader, SUPPORTED_BROWSERS } = await import("./chrome.ts"); + const { readForkableCookieHeaders, SUPPORTED_BROWSERS } = await import("./chrome.ts"); if (browserArg && !(SUPPORTED_BROWSERS as readonly string[]).includes(browserArg)) { console.error( `Unknown --browser "${browserArg}". Supported: ${SUPPORTED_BROWSERS.join(", ")}.`, @@ -42,14 +49,35 @@ export async function runAuthCli(argv: string[]): Promise { } const browser = browserArg as SupportedBrowser | undefined; const profileArg = flag("--profile"); - const { cookie, profile } = await readForkableCookieHeader({ + const { candidates, warnings } = await readForkableCookieHeaders({ ...(browser ? { browser } : {}), ...(profileArg ? { profile: profileArg } : {}), }); - const { me } = await ingestCredentials({ cookie }); + for (const warning of warnings) console.error(`Browser import warning: ${warning}`); + + let imported: { profile: string; user: string } | undefined; + let lastError: unknown; + for (const candidate of candidates) { + try { + // Profiles are verified serially so only one valid session is persisted. + // eslint-disable-next-line no-await-in-loop + const { me } = await ingestCredentials({ cookie: candidate.cookie }); + imported = { + profile: candidate.profile, + user: me.fullName || me.email || `user ${me.id}`, + }; + break; + } catch (error) { + lastError = error; + } + } + if (!imported) { + throw lastError instanceof Error + ? lastError + : new Error("No browser profile contained a valid Forkable session."); + } console.error( - `✓ Imported ${browser ?? "chrome"} session (profile ${profile}) for ` + - `${me.fullName || me.email || `user ${me.id}`}.`, + `✓ Imported ${browser ?? "chrome"} session (profile ${imported.profile}) for ${imported.user}.`, ); } else if (fileIdx < 0 && process.env.FORKABLE_COOKIE) { // Headless: cookie provided via env (no browser, no terminal paste). @@ -63,25 +91,27 @@ export async function runAuthCli(argv: string[]): Promise { } else { const blob = fileIdx >= 0 && argv[fileIdx + 1] - ? await Bun.file(argv[fileIdx + 1]!).text() - : await Bun.stdin.text(); + ? await readFile(argv[fileIdx + 1]!, "utf8") + : await readStdin(); if (!blob.trim()) { - // Lazy-load only for the browser list — this branch exits, so pulling in bun:sqlite is fine. const { SUPPORTED_BROWSERS } = await import("./chrome.ts"); console.error( "No session provided. Pick whichever is easiest:\n" + "\n" + " 1. Email + password (works headless, auto-refreshes):\n" + - " bun run auth --login --email you@co.com --password '…'\n" + + " forkable-mcp --auth --login --email you@co.com --password '…'\n" + "\n" + - " 2. Import from your logged-in browser (macOS only):\n" + - " bun run auth --chrome\n" + - " bun run auth --chrome --browser arc [--profile 'Profile 1']\n" + + " 2. Import from your logged-in browser:\n" + + " forkable-mcp --auth --chrome\n" + + " forkable-mcp --auth --chrome --browser arc [--profile 'Profile 1']\n" + ` --browser: ${SUPPORTED_BROWSERS.join(", ")}\n` + - " all profiles are searched; the freshest session wins\n" + + " matching profiles are verified until one succeeds\n" + + " macOS may prompt once per profile; --profile limits the scan\n" + + " Arc targeting is macOS-only; Brave/Chromium on Linux or Windows may\n" + + " need --profile /path/to/profile\n" + "\n" + - " 3. Paste a cookie header (SSO accounts, or any non-macOS machine):\n" + - " FORKABLE_COOKIE='_easyorder_session=…; …' bun run auth\n" + + " 3. Paste a cookie header (SSO accounts, or when browser import is unavailable):\n" + + " FORKABLE_COOKIE='_easyorder_session=…; …' forkable-mcp --auth\n" + " Get it from forkable.com → DevTools (⌥⌘I) → Network → filter for\n" + " `graphql` → click a POST /api/v2/graphql request → Headers → Request Headers\n" + " → copy the whole `cookie:` value (must include _easyorder_session).\n" + @@ -90,7 +120,7 @@ export async function runAuthCli(argv: string[]): Promise { " for a request, cookies and all; we just parse the cookie out of it.\n" + " forkable.com → DevTools → Network → filter for `graphql` →\n" + " right-click a POST /api/v2/graphql request → Copy → Copy as cURL, then:\n" + - " pbpaste | bun run auth # or: bun run auth --file ./forkable.curl", + " pbpaste | forkable-mcp --auth # or: forkable-mcp --auth --file ./forkable.curl", ); process.exit(1); } diff --git a/src/auth/cookies.ts b/src/auth/cookies.ts index 9b20703..b84f317 100644 --- a/src/auth/cookies.ts +++ b/src/auth/cookies.ts @@ -1,50 +1,26 @@ -// Cookie-jar helpers + cURL parsing. Pure string manipulation, no I/O. -// -// Keeps ALL cookies (including the AWSALBTG/AWSALBTGCORS load-balancer stickiness cookies — -// dropping them can route a later request to a node that rejects the CSRF token). +import { parseCookie, stringifyCookie } from "cookie"; +import { parseSetCookie } from "set-cookie-parser"; -/** Parse a `Cookie:` header into an ordered name→value map. */ -function parseCookieHeader(header: string): Map { - const map = new Map(); - for (const part of header.split(";")) { - const s = part.trim(); - if (!s) continue; - const eq = s.indexOf("="); - if (eq <= 0) continue; - map.set(s.slice(0, eq).trim(), s.slice(eq + 1).trim()); - } - return map; -} +const identity = (value: string) => value; -/** - * Merge `Set-Cookie` response headers into an existing Cookie header, keeping ALL cookies - * (including the AWSALBTG/AWSALBTGCORS load-balancer stickiness cookies — dropping them can - * route a later request to a node that rejects the CSRF token). - */ +/** Merge response cookies without dropping load-balancer affinity cookies. */ export function mergeSetCookies(existingCookieHeader: string, setCookies: string[]): string { - const jar = parseCookieHeader(existingCookieHeader); - for (const sc of setCookies) { - const first = sc.split(";", 1)[0]?.trim(); - if (!first) continue; - const eq = first.indexOf("="); - if (eq <= 0) continue; - const name = first.slice(0, eq).trim(); - const value = first.slice(eq + 1).trim(); - // A Set-Cookie with an empty/"deleted" value expires the cookie. - if (value === "" || value === "deleted") jar.delete(name); - else jar.set(name, value); + const jar = parseCookie(existingCookieHeader, { decode: identity }); + for (const cookie of parseSetCookie(setCookies, { decodeValues: false })) { + const expired = + cookie.maxAge !== undefined + ? cookie.maxAge <= 0 + : cookie.expires !== undefined && cookie.expires.getTime() <= Date.now(); + if (expired) delete jar[cookie.name]; + else jar[cookie.name] = cookie.value; } - return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; "); + return stringifyCookie(jar, { encode: identity }); } export function hasSessionCookie(cookieHeader: string): boolean { - return parseCookieHeader(cookieHeader).has("_easyorder_session"); + return Boolean(parseCookie(cookieHeader, { decode: identity })["_easyorder_session"]); } -// --------------------------------------------------------------------------- -// cURL parsing -// --------------------------------------------------------------------------- - function matchQuoted(blob: string, re: RegExp): string | undefined { const m = re.exec(blob); return m ? m[2]?.trim() : undefined; @@ -52,7 +28,7 @@ function matchQuoted(blob: string, re: RegExp): string | undefined { /** Extract cookie + csrf from a browser "Copy as cURL" blob. */ export function parseCurl(blob: string): { cookie?: string; csrf?: string } { - // Cookie is usually a `-b '...'`/`--cookie '...'` flag, but may be an `-H 'cookie: ...'` header. + // DevTools may emit the cookie as either a flag or a header. let cookie = matchQuoted(blob, /(?:-b|--cookie)\s+(['"])([\s\S]*?)\1/) ?? matchQuoted(blob, /-H\s+(['"])cookie:\s*([\s\S]*?)\1/i); diff --git a/src/auth/login.ts b/src/auth/login.ts index 05baf61..ef897a9 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -39,7 +39,7 @@ async function assertPasswordLoginAllowed(email: string, fetchImpl: FetchImpl): if (identities.length && !identities.some((i) => i.integration?.allowSsoPasswordLogin)) { throw new Error( "This account uses SSO and doesn't allow password login. Import a browser cookie instead " + - "(bun run auth --chrome / --file, or set FORKABLE_COOKIE).", + "(forkable-mcp --auth --chrome / --file, or set FORKABLE_COOKIE).", ); } } diff --git a/src/auth/session.ts b/src/auth/session.ts index 3bab04e..b3d3a79 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -3,7 +3,7 @@ // Lives on disk (0600) so it survives restarts and is shared by the writers: the `--auth` CLI, // the FORKABLE_COOKIE env provisioning, and the client's cookie rotation. -import { mkdir, rename, chmod, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, chmod, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { randomBytes } from "node:crypto"; @@ -37,10 +37,8 @@ export function storePath(): string { // --------------------------------------------------------------------------- export async function readSession(): Promise { - const f = Bun.file(storePath()); - if (!(await f.exists())) return null; try { - return (await f.json()) as SessionRecord; + return JSON.parse(await readFile(storePath(), "utf8")) as SessionRecord; } catch { return null; } diff --git a/src/index.ts b/src/index.ts index d49b564..2a7f8b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env node // Entry point. Two modes: // `bun run src/index.ts --auth [--chrome|--file ]` → import a session, then exit. // `bun run src/index.ts` → serve MCP over stdio (client-spawned). diff --git a/src/net/client.ts b/src/net/client.ts index 0f8d38c..58a01ad 100644 --- a/src/net/client.ts +++ b/src/net/client.ts @@ -20,6 +20,7 @@ import { import { buildQuery, buildMutation, type LiteralArgs } from "./gql.ts"; import { type SessionRecord, patchSession, requireSession } from "@/auth/session.ts"; import { mergeSetCookies } from "@/auth/cookies.ts"; +import { setTimeout as delay } from "node:timers/promises"; // --------------------------------------------------------------------------- // CSRF + verification (low-level; the full client adds retries/error mapping) @@ -168,7 +169,7 @@ export class ForkableClient { } if (res.status >= 500 && retried < 1) { - await Bun.sleep(250); + await delay(250); return this.gqlRaw(query, variables, { public: isPublic, retried: retried + 1 }); } diff --git a/src/tools.ts b/src/tools.ts index fd43717..82a2d74 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -70,9 +70,9 @@ function reauthResult(e: ReauthRequiredError): CallToolResult { content: text( `Forkable session ${e.reason}. The server can't log in for you — provide a fresh browser cookie, ` + `then retry:\n` + - ` • headless: set FORKABLE_COOKIE to a fresh forkable.com cookie (or run \`bun run auth\` with it set), or\n` + - ` • \`bun run auth --file \` / \`pbpaste | bun run auth\`, or\n` + - ` • \`bun run auth --chrome\` on a machine logged into forkable.com in Chrome.`, + ` • headless: set FORKABLE_COOKIE to a fresh forkable.com cookie, or\n` + + ` • run \`forkable-mcp --auth --file \`, or\n` + + ` • run \`forkable-mcp --auth --chrome\` on a machine logged into forkable.com in Chrome.`, ), structuredContent: { error: "forkable_reauth_required", reason: e.reason }, }; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 8e33aa3..0d7aeff 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -41,15 +41,34 @@ describe("mergeSetCookies", () => { expect(merged).not.toContain("_easyorder_session=old"); }); - test("deletes on empty value", () => { - const merged = mergeSetCookies("a=1; b=2", ["a=; path=/"]); - expect(merged).toBe("b=2"); + test("keeps empty values unless the response expires them", () => { + expect(mergeSetCookies("a=1; b=2", ["a=; path=/"])).toBe("a=; b=2"); + expect(mergeSetCookies("a=1; b=2", ["a=; Max-Age=0; path=/"])).toBe("b=2"); + expect(mergeSetCookies("a=1; b=2", ["a=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT"])).toBe( + "b=2", + ); + }); + + test("Max-Age takes precedence over Expires", () => { + expect( + mergeSetCookies("a=old", ["a=new; Max-Age=60; Expires=Thu, 01 Jan 1970 00:00:00 GMT"]), + ).toBe("a=new"); + expect( + mergeSetCookies("a=old", ["a=new; Max-Age=0; Expires=Thu, 01 Jan 2100 00:00:00 GMT"]), + ).toBe(""); + }); + + test("preserves opaque cookie values", () => { + expect(mergeSetCookies("token=a=b; encoded=%2F", ["next=x=y; Path=/"])).toBe( + "token=a=b; encoded=%2F; next=x=y", + ); }); }); describe("hasSessionCookie", () => { test("detects _easyorder_session", () => { expect(hasSessionCookie("_easyorder_session=x; y=z")).toBe(true); + expect(hasSessionCookie("_easyorder_session=; y=z")).toBe(false); expect(hasSessionCookie("y=z")).toBe(false); }); }); diff --git a/tests/chrome.test.ts b/tests/chrome.test.ts index 5d47bf7..7f9ef49 100644 --- a/tests/chrome.test.ts +++ b/tests/chrome.test.ts @@ -1,190 +1,88 @@ -import { expect, test, describe } from "bun:test"; -import { createCipheriv, createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; import { - deriveKey, - decryptCookieValue, - assertDarwin, - discoverProfiles, - pickProfileJar, - browserPaths, - type ProfileJar, -} from "@/auth/chrome.ts"; - -// Encrypt a value the way macOS Chrome does: "v10" + AES-128-CBC(key, iv=16×0x20), PKCS7. -function encryptV10(plaintext: Buffer, key: Buffer): Buffer { - const iv = Buffer.alloc(16, 0x20); - const cipher = createCipheriv("aes-128-cbc", key, iv); - cipher.setAutoPadding(true); - const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); - return Buffer.concat([Buffer.from("v10"), ct]); -} - -const KEY = deriveKey("peanuts"); -const HOST = ".forkable.com"; - -describe("deriveKey", () => { - test("is deterministic and 16 bytes", () => { - expect(deriveKey("peanuts").equals(deriveKey("peanuts"))).toBe(true); - expect(deriveKey("peanuts").length).toBe(16); - expect(deriveKey("peanuts").equals(deriveKey("other"))).toBe(false); - }); -}); - -describe("decryptCookieValue", () => { - test("recovers a v10 value without domain-hash prefix", () => { - const enc = encryptV10(Buffer.from("session-abc-123"), KEY); - expect(decryptCookieValue(enc, KEY, HOST)).toBe("session-abc-123"); - }); - - test("strips the 32-byte SHA256(host) domain-hash prefix", () => { - const domainHash = createHash("sha256").update(HOST).digest(); // 32 bytes - const enc = encryptV10(Buffer.concat([domainHash, Buffer.from("real-value")]), KEY); - expect(decryptCookieValue(enc, KEY, HOST)).toBe("real-value"); - }); - - test("handles PKCS7 padding across block boundaries", () => { - const exactBlock = "0123456789ABCDEF"; // 16 bytes → full extra pad block - expect(decryptCookieValue(encryptV10(Buffer.from(exactBlock), KEY), KEY, HOST)).toBe( - exactBlock, - ); - const longer = "x".repeat(40); - expect(decryptCookieValue(encryptV10(Buffer.from(longer), KEY), KEY, HOST)).toBe(longer); - }); - - test("passes through legacy plaintext (no v10 prefix)", () => { - expect(decryptCookieValue(Buffer.from("plainval"), KEY, HOST)).toBe("plainval"); - }); -}); - -// Build a fake Chromium user-data dir: profiles[dir] = display name (or null for no Preferences). -function fakeUserData( - profiles: Record, - opts: { localState?: boolean; rootCookies?: boolean } = {}, -): string { - const root = mkdtempSync(join(tmpdir(), "forkable-profiles-")); - for (const [dir, name] of Object.entries(profiles)) { - mkdirSync(join(root, dir), { recursive: true }); - writeFileSync(join(root, dir, "Cookies"), ""); - if (name !== null) { - writeFileSync(join(root, dir, "Preferences"), JSON.stringify({ profile: { name } })); - } - } - if (opts.localState) { - const info_cache = Object.fromEntries( - Object.entries(profiles).map(([dir, name]) => [dir, { name: name ?? dir }]), - ); - writeFileSync(join(root, "Local State"), JSON.stringify({ profile: { info_cache } })); - } - if (opts.rootCookies) writeFileSync(join(root, "Cookies"), ""); - return root; -} - -describe("discoverProfiles", () => { - test("finds every profile in a multi-profile install, not just Default", () => { - const root = fakeUserData({ Default: "Personal", "Profile 1": "Work", "Profile 2": "Side" }); - const dirs = discoverProfiles(root).map((p) => p.dir); - expect(dirs).toContain("Default"); - expect(dirs).toContain("Profile 1"); - expect(dirs).toContain("Profile 2"); - }); - - test("labels profiles with their display name from Preferences", () => { - const root = fakeUserData({ "Profile 1": "Work" }); - const found = discoverProfiles(root).find((p) => p.dir === "Profile 1"); - expect(found?.label).toBe("Profile 1 (Work)"); - }); - - test("labels profiles from Local State info_cache too", () => { - const root = fakeUserData({ "Profile 3": null }, { localState: true }); - // info_cache carries the dir name itself here, so the label stays unadorned. - expect(discoverProfiles(root).map((p) => p.dir)).toContain("Profile 3"); - }); - - test("includes Default even when it has no Cookies DB yet", () => { - const root = fakeUserData({ "Profile 1": "Work" }); - expect(discoverProfiles(root)[0]?.dir).toBe("Default"); - }); - - test("handles arbitrarily named profile dirs (Arc-style)", () => { - const root = fakeUserData({ Default: "Personal", "Profile 7": "acme.com" }); - const found = discoverProfiles(root).find((p) => p.dir === "Profile 7"); - expect(found?.label).toBe("Profile 7 (acme.com)"); - }); - - test("adds the user-data root when Cookies sits there (Opera-style)", () => { - const root = fakeUserData({}, { rootCookies: true }); - expect(discoverProfiles(root).map((p) => p.dir)).toContain("."); - }); - - test("an explicit profile short-circuits discovery", () => { - const root = fakeUserData({ Default: "Personal", "Profile 1": "Work" }); - expect(discoverProfiles(root, "Profile 1")).toEqual([{ dir: "Profile 1", label: "Profile 1" }]); - }); - - test("does not throw on a missing user-data dir", () => { - expect(discoverProfiles(join(tmpdir(), "definitely-not-here-forkable"))).toEqual([ - { dir: "Default", label: "Default" }, - ]); - }); + ALL_PROFILES, + type Cookie, + type GetCookiesOptions, + type GetCookiesResult, +} from "@steipete/sweet-cookie"; +import { readForkableCookieHeaders } from "@/auth/chrome.ts"; + +const cookie = ( + name: string, + value: string, + profile: string, + extra: Partial = {}, +): Cookie => ({ + name, + value, + domain: "forkable.com", + path: "/", + source: { browser: "chrome", profile }, + ...extra, }); -const jarOf = (dir: string, cookies: Record, lastAccess: number): ProfileJar => ({ - profile: { dir, label: dir }, - jar: new Map(Object.entries(cookies)), - lastAccess, -}); - -describe("pickProfileJar", () => { - test("skips profiles without a session cookie", () => { - const chosen = pickProfileJar([ - jarOf("Default", { _ga: "GA1.2" }, 900), - jarOf("Profile 1", { _easyorder_session: "live" }, 100), +describe("browser cookie import", () => { + test("reads every Chrome profile and keeps only cookies applicable to the Forkable API", async () => { + let options: GetCookiesOptions | undefined; + const read = async (input: GetCookiesOptions): Promise => { + options = input; + return { + warnings: ["one profile could not be read"], + cookies: [ + cookie("_easyorder_session", "root", "Profile 1"), + cookie("_easyorder_session", "api", "Profile 1", { path: "/api/v2/" }), + cookie("AWSALBTG", "affinity", "Profile 1"), + cookie("admin", "private", "Profile 1", { path: "/admin/" }), + cookie("foreign", "wrong", "Profile 1", { domain: "notforkable.com" }), + cookie("_easyorder_session", "second", "Profile 2"), + ], + }; + }; + + const result = await readForkableCookieHeaders({}, read); + + expect(options).toEqual({ + url: "https://forkable.com/api/v2/graphql", + browsers: ["chrome"], + chromeProfile: ALL_PROFILES, + chromiumBrowser: "chrome", + timeoutMs: 30_000, + }); + expect(result.warnings).toEqual(["one profile could not be read"]); + expect(result.candidates).toEqual([ + { + profile: "Profile 1", + cookie: "_easyorder_session=api; AWSALBTG=affinity", + }, + { profile: "Profile 2", cookie: "_easyorder_session=second" }, ]); - expect(chosen?.profile.dir).toBe("Profile 1"); }); - test("prefers the most recently used session when several are logged in", () => { - const chosen = pickProfileJar([ - jarOf("Default", { _easyorder_session: "stale" }, 10), - jarOf("Profile 1", { _easyorder_session: "fresh" }, 99), - jarOf("Profile 2", { _easyorder_session: "older" }, 50), - ]); - expect(chosen?.jar.get("_easyorder_session")).toBe("fresh"); - }); + test("uses the Edge backend and an explicit profile", async () => { + let options: GetCookiesOptions | undefined; + const read = async (input: GetCookiesOptions): Promise => { + options = input; + return { cookies: [cookie("_easyorder_session", "live", "Work")], warnings: [] }; + }; - test("returns undefined when no profile is logged in", () => { - expect(pickProfileJar([jarOf("Default", { _ga: "GA1.2" }, 900)])).toBeUndefined(); - }); -}); + await readForkableCookieHeaders({ browser: "edge", profile: "Work" }, read); -describe("browserPaths", () => { - test("Arc profiles live under User Data", () => { - expect(browserPaths("arc").userData).toMatch(/Application Support\/Arc\/User Data$/); + expect(options).toEqual({ + url: "https://forkable.com/api/v2/graphql", + browsers: ["edge"], + edgeProfile: "Work", + timeoutMs: 30_000, + }); }); - test("other forks keep profiles directly in the data dir", () => { - expect(browserPaths("chrome").userData).toMatch(/Application Support\/Google\/Chrome$/); - expect(browserPaths("brave").userData).toMatch(/BraveSoftware\/Brave-Browser$/); - }); + test("requires a nonempty session cookie", async () => { + const read = async (): Promise => ({ + cookies: [cookie("_easyorder_session", "", "Default"), cookie("other", "value", "Default")], + warnings: ["Chrome cookies database not found."], + }); - test("every Chrome channel shares one Keychain entry but keeps its own label", () => { - const beta = browserPaths("chrome-beta"); - expect(beta.keychainService).toBe("Chrome Safe Storage"); - expect(beta.keychainAccount).toBe("Chrome"); - expect(beta.label).toBe("Chrome Beta"); - expect(beta.userData).toMatch(/Google\/Chrome Beta$/); - }); -}); - -describe("assertDarwin", () => { - test("throws on non-macOS platforms", () => { - expect(() => assertDarwin("win32")).toThrow(/only supported on macOS/); - expect(() => assertDarwin("linux")).toThrow(); - }); - test("allows darwin", () => { - expect(() => assertDarwin("darwin")).not.toThrow(); + await expect(readForkableCookieHeaders({}, read)).rejects.toThrow( + /No logged-in Forkable session.*Chrome cookies database not found/, + ); }); });