From d6ac895fdf405531a1dac57dbbbcf0e1f64fe984 Mon Sep 17 00:00:00 2001 From: Michael Simon Date: Mon, 3 Aug 2026 00:36:49 -0700 Subject: [PATCH 1/3] Make Gullet globally configurable and publishable (#13) --- .codex/config.toml | 11 +- .github/workflows/ci.yml | 3 + .mcp.json | 7 +- AGENTS.md | 6 +- README.md | 5 +- docs/BRIDGE.md | 8 +- docs/LAUNCH.md | 8 +- gullet/LICENSE | 21 ++ gullet/README.md | 104 ++++-- gullet/package.json | 29 +- gullet/src/backend.ts | 79 ++++- gullet/src/config.ts | 401 ++++++++++++++++++++-- gullet/src/main.ts | 18 +- {src => gullet/src}/tabs-view.ts | 4 +- gullet/src/tools.ts | 2 +- gullet/tests/backend.test.ts | 31 ++ gullet/tests/config.test.ts | 171 ++++++++- {tests => gullet/tests}/tabs-view.test.ts | 2 +- options/options.html | 5 +- options/options.ts | 38 +- package.json | 2 + 21 files changed, 831 insertions(+), 124 deletions(-) create mode 100644 gullet/LICENSE rename {src => gullet/src}/tabs-view.ts (97%) rename {tests => gullet/tests}/tabs-view.test.ts (98%) diff --git a/.codex/config.toml b/.codex/config.toml index abe6f0f..46c3cf5 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,15 +1,12 @@ # Project-local Codex config. Applies to sessions started from this repo, the # same scoping .mcp.json gives Claude Code — no flag required. # -# The token is NOT stored here. It is read at spawn time from the gitignored -# .env, so this file carries no credential and is safe to commit. +# The token is NOT stored here. Gullet resolves the global token file (or the +# gitignored .env compatibility fallback), so this file is safe to commit. [mcp_servers.tabglutton] -command = "bash" -args = [ - "-c", - "export TABGLUTTON_TOKEN=\"$(grep -m1 '^TABGLUTTON_TOKEN=' .env | cut -d= -f2-)\"; exec bun run ./gullet/gullet.ts", -] +command = "bun" +args = ["run", "./gullet/gullet.ts"] startup_timeout_sec = 30 # A first tool call can legitimately hold ~90s: up to 45s waiting for the # browser to dial in (BRIDGE_CONNECT_WAIT_MS) and up to 45s for the browser to diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f744a..4dbb206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Package run: bun run package + - name: Package Gullet + run: bun run package:gullet + - name: Upload extension zip uses: actions/upload-artifact@v7 with: diff --git a/.mcp.json b/.mcp.json index 179317d..dd9b360 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,11 +1,8 @@ { "mcpServers": { "tabglutton": { - "command": "bash", - "args": [ - "-c", - "export TABGLUTTON_TOKEN=\"$(grep -m1 '^TABGLUTTON_TOKEN=' .env | cut -d= -f2-)\"; exec bun run ./gullet/gullet.ts" - ] + "command": "bun", + "args": ["run", "./gullet/gullet.ts"] } } } diff --git a/AGENTS.md b/AGENTS.md index 1d7708d..8fc4215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ This is a Bun-powered TypeScript WebExtension for Zen Browser, Firefox, and Chro ## Gullet (agent bridge sidecar) -`gullet/` is a sibling package, not part of the extension bundle: an MCP server over stdio on one side, a loopback WebSocket hub on the other. It shares `src/bridge-protocol.ts` with the extension so both ends are typechecked against one definition, has its own `gullet/tsconfig.json` (`lib: ES2022`, `types: bun-types` — no DOM, no `browser`), and has zero dependencies. It is excluded from the extension `tsconfig.json`'s `include`, so it never reaches `dist-*`. Setup and troubleshooting live in `gullet/README.md`. +`gullet/` is a sibling package, not part of the extension bundle: an MCP server over stdio on one side, a loopback WebSocket hub on the other. It shares `src/bridge-protocol.ts` with the extension so both ends are typechecked against one definition; its agent-only listing renderer is `gullet/src/tabs-view.ts`, not extension source. It has its own `gullet/tsconfig.json` (`lib: ES2022`, `types: bun-types` — no DOM, no `browser`) and zero dependencies. It is excluded from the extension `tsconfig.json`'s `include`, so it never reaches `dist-*`; `bun run build:gullet` instead bundles the executable and shared modules for the `tabglutton-gullet` npm package. Setup and troubleshooting live in `gullet/README.md`. + +Global Gullet settings live at `${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton/config.json`, with the token in a separate `0600` file by default. The config is deliberately safe to commit: an inline `"token"` key is rejected even when a CLI or environment token would otherwise win. Keep the additive token precedence (`--token` → env → `./.env` → `tokenCommand` → `tokenFile` → default file). `tokenCommand` is bounded and lazy; its timeout/nonzero error, including stderr, goes through `Supervisor.fault()`, and the supervisor retries with backoff so unlocking a secret manager heals the existing MCP session. Do not move command execution ahead of that recoverable startup path. Tests that stand up a real socket bind to port 0 for an ephemeral port. Diagnostics in gullet go to **stderr only** — stdout is the MCP transport and a stray `console.log` corrupts the session. @@ -57,6 +59,7 @@ The election must **settle, or say why**. `main` awaits `backend.start()` before - `bun install`: install dependencies. - `bun run build`: build both `dist-firefox/` and `dist-chrome/`. +- `bun run build:gullet`: bundle the publishable `tabglutton-gullet` executable. - `bun run build:firefox` / `build:chrome`: single-target builds. - `bun run typecheck`: typecheck the extension (`typecheck:ext`, `tsconfig.test.json` over `src/` + `tests/`) then the sidecar (`typecheck:gullet`). - `bun run test`: run the Bun test suite under `tests/` and `gullet/tests/`. @@ -67,6 +70,7 @@ The election must **settle, or say why**. `main` awaits `backend.start()` before - `bun run start:firefox`: build firefox and launch regular Firefox with a persistent dev profile. - `bun run start:chrome`: build chrome and launch Chromium via `web-ext --target=chromium`. - `bun run package`: produce both `tabglutton-firefox-.zip` and `tabglutton-chrome-.zip` in `web-ext-artifacts/`. +- `bun run package:gullet`: build and dry-run the publishable `tabglutton-gullet` package. ## Coding Style & Naming Conventions diff --git a/README.md b/README.md index 82af6b3..a5abddf 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,9 @@ throughout — `j`/`k` to move, `space` to toggle, `d` to devour, `x` to close, This is the part that isn't like other tab extensions. -Tabglutton ships **Gullet**, a local MCP server. Turn the bridge on in settings, point -Claude Code or Codex at it, and your agent can work your actual open tabs: +Tabglutton ships **Gullet**, a local MCP server. Turn the bridge on in settings, run its +one-time token setup command, and point Claude Code or Codex at +`bunx tabglutton-gullet`; your agent can then work your actual open tabs: | Tool | What it does | | ------------ | ------------------------------------------------------- | diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 8b0e9be..bedc819 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -674,9 +674,11 @@ Strategy, in order: - No new host permissions (`*://*/*` already covers the clipper). - `gullet/` is a sibling package with its own `tsconfig.json`, sharing `src/bridge-protocol.ts` and the repo's check pipeline (`bun run typecheck` covers both - projects; `bun test` picks up `gullet/tests/`). It has no dependencies of its own — - Bun's built-in WebSocket server and a hand-rolled tools-only MCP server are enough, so - `bun run gullet/gullet.ts` works with nothing installed. + projects; `bun test` picks up `gullet/tests/`). Its agent-only tab renderer now lives in + `gullet/src/tabs-view.ts`, so it no longer compiles into either extension target. The + package has no dependencies of its own — Bun's built-in WebSocket server and a hand-rolled + tools-only MCP server are enough — and bundles those shared sources into the published + `tabglutton-gullet` executable for `bunx` / `npx` use without a checkout. - `build.ts` gains nothing target-specific: the bridge module is shared source; the only Chrome divergence is the keepalive note above. `gullet/` is outside the extension tsconfig's `include`, so it never lands in `dist-*`. diff --git a/docs/LAUNCH.md b/docs/LAUNCH.md index f3f0e1d..1dc367e 100644 --- a/docs/LAUNCH.md +++ b/docs/LAUNCH.md @@ -179,15 +179,15 @@ Several agent sessions can share one browser — the first process to bind the p the hub and later ones attach as peers and proxy through it, because nothing guarantees one sidecar per session (a single `codex` process was observed spawning two). -Setup is a token from the extension's settings page and one `.mcp.json` block: +Setup is a token from the extension's settings page, written once with its copyable setup +command, and one MCP entry: ```json { "mcpServers": { "tabglutton": { - "command": "bun", - "args": ["run", "/path/to/tabglutton/gullet/gullet.ts"], - "env": { "TABGLUTTON_TOKEN": "" } + "command": "bunx", + "args": ["tabglutton-gullet"] } } } diff --git a/gullet/LICENSE b/gullet/LICENSE new file mode 100644 index 0000000..f6f9d03 --- /dev/null +++ b/gullet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Michael Simon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gullet/README.md b/gullet/README.md index ec669a7..e763fb3 100644 --- a/gullet/README.md +++ b/gullet/README.md @@ -5,15 +5,16 @@ way to a coding agent. One side is an **MCP server over stdio**, spawned by what harness you use; the other is a **WebSocket server on loopback** that browsers running Tabglutton dial into. -Architecture, trust boundary, and phasing live in [`../docs/BRIDGE.md`](../docs/BRIDGE.md). This file -is the setup guide. +Architecture, trust boundary, and phasing live in +[`docs/BRIDGE.md`](https://github.com/mlsimon734/tabglutton/blob/main/docs/BRIDGE.md). +This file is the setup guide. ``` Claude Code ──MCP (stdio)──► Gullet ──WebSocket (automatic loopback port)──► browsers ``` -Zero dependencies: it runs on Bun's built-ins alone, so there is nothing to install beyond -having the repo checked out. +Zero dependencies: it runs on Bun's built-ins alone. Install Bun, then let your MCP client +launch the published package with `bunx tabglutton-gullet`; no checkout is required. **Two names, one product.** "Gullet" is the internal name for this sidecar; everything a user or an agent sees says **Tabglutton**. So the MCP server registers as `tabglutton`, the @@ -26,29 +27,49 @@ tools appear under that namespace, and the token is `TABGLUTTON_TOKEN`. `GULLET_ then **Generate** a token and copy it. The bridge is off until you do this, and no socket is opened while it is off. -2. **Register Gullet with your agent.** The settings page renders a ready-made config with - your token filled in — _Copy config_ and paste it into `.mcp.json` (or - `~/.claude.json`), replacing the placeholder path with wherever you cloned this repo: +2. **Write the global token file.** The settings page renders a shell command with your + token filled in. _Copy setup command_ and run it once. It creates the directory privately + and writes the secret to `~/.config/tabglutton/token` with mode `0600` (under + `$XDG_CONFIG_HOME` instead when set). The token does not go into an MCP config. + +3. **Register Gullet with your agent.** Pick the shape your client accepts. + + Claude Code, for every project: + + ```sh + claude mcp add --scope user tabglutton -- bunx tabglutton-gullet + ``` + + Codex CLI and Codex desktop, in `~/.codex/config.toml`: + + ```toml + [mcp_servers.tabglutton] + command = "bunx" + args = ["tabglutton-gullet"] + startup_timeout_sec = 30 + tool_timeout_sec = 120 + ``` + + Claude Desktop/Cowork and clients that accept the standard JSON shape: ```json { "mcpServers": { - "tabglutton": { - "command": "bun", - "args": ["run", "/path/to/tabglutton/gullet/gullet.ts"], - "env": { "TABGLUTTON_TOKEN": "" } - } + "tabglutton": { "command": "bunx", "args": ["tabglutton-gullet"] } } } ``` - For Claude Code specifically: + `npx -y tabglutton-gullet` is also supported when a client already standardizes on + `npx`; the package still requires Bun because its executable uses Bun's runtime. + + To run an unpublished checkout while developing, replace the command with: ```sh - claude mcp add tabglutton --env TABGLUTTON_TOKEN= -- bun run /path/to/tabglutton/gullet/gullet.ts + bun run /path/to/tabglutton/gullet/gullet.ts ``` -3. **Start a session.** The agent spawns Gullet, Gullet elects an approved port, and the +4. **Start a session.** The agent spawns Gullet, Gullet elects an approved port, and the extension's reconnect loop finds it — typically within a few seconds (it rotates probes every 3s while the browser's extension page is awake), worst case ~30 seconds (the alarm cadence, when the page had suspended). The toolbar badge shows a terracotta dot while the @@ -61,10 +82,44 @@ once — a Zen window and a Chrome profile, say — and each tool call picks one ## Configuration -| Flag | Env | Default | Notes | -| --------- | ------------------ | --------- | ---------------------------------------------------------------------------------- | -| `--port` | `TABGLUTTON_PORT` | automatic | Use `auto` or omit it; a number pins one port and must match fixed browser mode. | -| `--token` | `TABGLUTTON_TOKEN` | — | Required. Prefer the env var: process arguments are readable by other local users. | +Gullet reads settings from `${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton/config.json`. +The file is safe to keep in a dotfiles repository because Gullet rejects an inline +`"token"` key; it may contain only settings and a pointer to the secret. + +```jsonc +{ + "port": "auto", + "tokenFile": "token", +} +``` + +`tokenFile` defaults to `${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton/token`. Relative +paths are resolved from the directory containing `config.json`. To read from a secret +manager instead, use `tokenCommand` in place of `tokenFile`: + +```jsonc +{ + "port": "auto", + "tokenCommand": "op read op://Private/Tabglutton/token", +} +``` + +The command runs through `sh` from the config directory and its trimmed stdout is the +token. Gullet bounds each attempt at five seconds. A timeout or nonzero exit is reported +to the MCP client with stderr attached while the process keeps retrying with backoff; +unlocking the secret manager heals the same MCP session. + +Resolution is additive, so existing setups keep working. The first configured token wins: + +```text +--token -> TABGLUTTON_TOKEN / GULLET_TOKEN -> ./.env + -> tokenCommand -> tokenFile -> the default global token file +``` + +Port selection uses `--port`, then `TABGLUTTON_PORT` / `GULLET_PORT`, then the global +config, then automatic discovery. A fixed number must match the browser's fixed-port +setting. Process arguments are visible to other local users, so `--token` is best kept for +temporary diagnosis; prefer the global file, a secret-manager command, or the environment. Automatic mode uses the ordered candidate set shared with the extension: `4589`, `20317`, `17483`, `27613`, and `24193`. It discovers an existing same-token hub before binding, so @@ -85,7 +140,9 @@ Diagnostics go to **stderr**; stdout is the MCP transport and carries nothing el Deliberately absent: navigate, click, type, evaluate. The agent can read what you already chose to open, file it, and clean up — it cannot act as you. Adding anything richer means -revisiting the prompt-injection posture in `../docs/BRIDGE.md` first. +revisiting the prompt-injection posture in +[`docs/BRIDGE.md`](https://github.com/mlsimon734/tabglutton/blob/main/docs/BRIDGE.md) +first. `tabs_load` is the one tool that acts on a page rather than observing it, so it has its own switch — **Agent bridge → "Let agents load unloaded tabs"** in Tabglutton's settings — and @@ -164,7 +221,7 @@ pass that number with `--port`. by hand to watch it: ```sh -TABGLUTTON_TOKEN= bun run gullet/gullet.ts +TABGLUTTON_TOKEN= bunx tabglutton-gullet ``` Then poke the selected socket directly (stderr prints the chosen port; `4589` is shown here): @@ -184,5 +241,6 @@ bun run typecheck:gullet # from the repo root bun test # protocol, config, selection, MCP, and a live-socket hub test ``` -The wire contract lives in [`../src/bridge-protocol.ts`](../src/bridge-protocol.ts) and is -imported by both halves, so extension and sidecar are typechecked against one definition. +The wire contract lives in +[`src/bridge-protocol.ts`](https://github.com/mlsimon734/tabglutton/blob/main/src/bridge-protocol.ts) +and is imported by both halves, so extension and sidecar are typechecked against one definition. diff --git a/gullet/package.json b/gullet/package.json index 1cbe10a..81d5c34 100644 --- a/gullet/package.json +++ b/gullet/package.json @@ -1,13 +1,36 @@ { - "name": "gullet", + "name": "tabglutton-gullet", "version": "0.1.0", - "private": true, "description": "Tabglutton's agent bridge: an MCP server over stdio, a WebSocket hub on loopback.", + "keywords": [ + "browser", + "mcp", + "obsidian", + "tabglutton", + "tabs" + ], + "homepage": "https://github.com/mlsimon734/tabglutton/tree/main/gullet#readme", + "bugs": "https://github.com/mlsimon734/tabglutton/issues", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/mlsimon734/tabglutton.git", + "directory": "gullet" + }, "bin": { - "gullet": "./gullet.ts" + "tabglutton-gullet": "./dist/gullet.js" }, + "files": [ + "dist", + "README.md" + ], "type": "module", + "publishConfig": { + "access": "public" + }, "scripts": { + "build": "bun build ./gullet.ts --target=bun --outfile=dist/gullet.js", + "prepack": "bun run build", "typecheck": "bunx tsc --noEmit -p tsconfig.json" } } diff --git a/gullet/src/backend.ts b/gullet/src/backend.ts index c922769..0cbe7b5 100644 --- a/gullet/src/backend.ts +++ b/gullet/src/backend.ts @@ -20,6 +20,7 @@ import { type BridgeProbeIdentity, } from "../../src/bridge-protocol.js"; import { delay } from "../../src/serialize.js"; +import type { TokenResolver } from "./config.js"; import { Hub } from "./hub.js"; import { PeerClient } from "./peer.js"; import type { ConnectionSummary } from "./select.js"; @@ -52,6 +53,10 @@ const ELECTION_RETRY_MS = 400; */ const ELECTION_RETRY_MAX_MS = 5_000; +/** Secret-manager and token-file retries back off independently of election. */ +const TOKEN_RETRY_MS = 1_000; +const TOKEN_RETRY_MAX_MS = 30_000; + /** * How long `start()` waits for the first election before reporting a fault. * @@ -73,6 +78,8 @@ export interface SupervisorOptions { /** Automatic candidates; injectable so socket tests use ephemeral ports. */ candidates?: readonly number[]; token: string; + /** A global file or command source. Retried after a transient startup failure. */ + resolveToken?: TokenResolver; /** Surfaced in logs only; the MCP half is deliberately unaware of the role. */ onRoleChange?: (role: BackendRole) => void; /** Overrides ELECTION_START_TIMEOUT_MS. Exists so the give-up path is testable. */ @@ -88,6 +95,8 @@ export class Supervisor implements BridgeBackend { private activePort: number | null = null; private role: BackendRole = "electing"; private stopped = false; + private token: string; + private tokenFault: BridgeError | null = null; /** Resolves when the current election settles; awaited by calls that arrive mid-swap. */ private settling: Promise = Promise.resolve(); /** @@ -99,6 +108,7 @@ export class Supervisor implements BridgeBackend { constructor(options: SupervisorOptions) { this.options = options; + this.token = options.token.trim(); if (this.candidatePorts().length === 0) { throw new Error("Gullet needs at least one valid bridge port candidate."); } @@ -109,6 +119,20 @@ export class Supervisor implements BridgeBackend { * settled by then — the election keeps going, and `fault()` tracks it. */ async start(): Promise { + // Preserve the original synchronous handoff to `settling` for an already + // resolved token. Calls may arrive concurrently with start(); none may see + // the constructor's placeholder resolved promise and mistake it for an + // election that finished with no backend. + if (!this.token) { + if (!(await this.acquireToken())) { + void this.retryToken(); + const fault = this.tokenFault; + throw new BridgeRequestError( + fault?.code ?? "unauthorized", + fault?.message ?? "Tabglutton's bridge has no token.", + ); + } + } this.settling = this.elect(); await this.waitForSettling(); } @@ -136,7 +160,56 @@ export class Supervisor implements BridgeBackend { } fault(): BridgeError | null { - return this.electionFault; + return this.tokenFault ?? this.electionFault; + } + + /** + * Resolve one configured token source. A command may be waiting on a locked + * secret manager, or the token file may not have been created yet; neither is + * a reason to kill the MCP transport before it can explain the problem. + */ + private async acquireToken(): Promise { + if (this.token) return true; + if (!this.options.resolveToken) { + this.tokenFault = { + code: "unauthorized", + message: + "Tabglutton's bridge has no token. Open Tabglutton's settings, enable the " + + "agent bridge, generate a token, and copy the setup command.", + }; + return false; + } + try { + const token = (await this.options.resolveToken()).trim(); + if (!token) throw new Error("The configured token source returned an empty token."); + this.token = token; + this.tokenFault = null; + return true; + } catch (err) { + this.tokenFault = { + code: "unauthorized", + message: errorMessage(err), + }; + return false; + } + } + + /** Keep trying after initialize is unblocked; success starts election in place. */ + private async retryToken(): Promise { + let gap = TOKEN_RETRY_MS; + while (!this.stopped) { + await delay(gap); + if (this.stopped) return; + if (await this.acquireToken()) { + console.error("[gullet] token source became available; starting bridge election"); + this.settling = this.elect(); + void this.settling.catch((err) => + console.error(`[gullet] election after token recovery failed: ${errorMessage(err)}`), + ); + return; + } + gap = Math.min(gap * 2, TOKEN_RETRY_MAX_MS); + } } private async elect(): Promise { @@ -162,7 +235,7 @@ export class Supervisor implements BridgeBackend { if (this.stopped) return; // Binding is still the atomic election. The only new rule is that a // loser re-checks the exact port it lost before considering the next. - const hub = new Hub({ port, token: this.options.token }); + const hub = new Hub({ port, token: this.token }); try { hub.listen(); this.hub = hub; @@ -213,7 +286,7 @@ export class Supervisor implements BridgeBackend { const peer = new PeerClient({ port, - token: this.options.token, + token: this.token, onLost: () => this.reelect(), }); try { diff --git a/gullet/src/config.ts b/gullet/src/config.ts index ac64821..0cca627 100644 --- a/gullet/src/config.ts +++ b/gullet/src/config.ts @@ -1,16 +1,29 @@ -// CLI/env parsing for the sidecar. Pure so the precedence rules are testable. +// CLI, environment, and global-file configuration for the sidecar. +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { isBridgePort } from "../../src/bridge-protocol.js"; +export type TokenResolver = () => Promise; + +type TokenConfig = { + /** Already-resolved CLI, environment, or .env token. */ + token: string; + /** File or command source, retried by Supervisor when it is temporarily unavailable. */ + resolveToken?: TokenResolver; +}; + export type GulletConfig = - | { portMode: "auto"; token: string } - | { portMode: "fixed"; port: number; token: string }; + | ({ portMode: "auto" } & TokenConfig) + | ({ portMode: "fixed"; port: number } & TokenConfig); export class ConfigError extends Error {} -export const USAGE = `gullet — Tabglutton's agent bridge sidecar +export const TOKEN_COMMAND_TIMEOUT_MS = 5_000; + +export const USAGE = `tabglutton-gullet — Tabglutton's agent bridge sidecar - bun run gullet/gullet.ts [--port ] [--token ] + bunx tabglutton-gullet [--port ] [--token ] --port automatic discovery by default, or a fixed loopback port (env TABGLUTTON_PORT) --token shared token from Tabglutton's options page (env TABGLUTTON_TOKEN) @@ -18,45 +31,142 @@ export const USAGE = `gullet — Tabglutton's agent bridge sidecar GULLET_PORT / GULLET_TOKEN are accepted as aliases — users know this as Tabglutton, "gullet" is only the sidecar's internal name. -The token is required before any browser may connect. Prefer the environment -variable: process arguments are visible to other local users, environments are not.`; +With no token flag or environment variable, Gullet checks ./.env and then +~/.config/tabglutton/config.json. The global config may name tokenFile or +tokenCommand, but may never contain the token itself. Its default token file is +~/.config/tabglutton/token. XDG_CONFIG_HOME replaces ~/.config when set. + +The token is required before any browser may connect. Prefer the token file or +environment: process arguments are visible to other local users.`; + +interface ParsedFlags { + port?: string; + portSet: boolean; + token?: string; + tokenSet: boolean; +} +interface FileConfig { + port?: string | number; + tokenFile?: string; + tokenCommand?: string; +} + +interface TokenCommandResult { + exitCode: number; + stdout: string; + stderr: string; + timedOut: boolean; +} + +export interface ConfigRuntime { + cwd: string; + readFile: (path: string) => Promise; + runTokenCommand: ( + command: string, + options: { + cwd: string; + env: Readonly>; + timeoutMs: number; + }, + ) => Promise; +} + +/** + * The old pure CLI/env surface remains useful to callers and unit tests. File + * access lives in loadConfig(), which main uses. + */ export function parseConfig( argv: readonly string[], env: Readonly>, ): GulletConfig { - // Either spelling works, so a user who only ever sees "Tabglutton" in the - // options page never has to learn that the process is called gullet. - let port: string | undefined = env.TABGLUTTON_PORT ?? env.GULLET_PORT; - let token: string | undefined = env.TABGLUTTON_TOKEN ?? env.GULLET_TOKEN; + const flags = parseFlags(argv); + const port = flags.portSet ? flags.port : firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT"); + const token = flags.tokenSet + ? flags.token + : firstDefined(env, "TABGLUTTON_TOKEN", "GULLET_TOKEN"); + return assembleConfig(parsePort(port), (token ?? "").trim()); +} + +/** Read the global settings and select a token source without executing it. */ +export async function loadConfig( + argv: readonly string[], + env: Readonly>, + runtime: ConfigRuntime = defaultRuntime(), +): Promise { + const flags = parseFlags(argv); + const paths = configPaths(env, runtime.cwd); + const fileConfig = await readFileConfig(paths.configFile, runtime); + + const rawPort = flags.portSet + ? flags.port + : (firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT") ?? fileConfig.port); + const selection = parsePort(rawPort); + + const directToken = flags.tokenSet + ? { set: true, value: flags.token } + : definedEnv(env, "TABGLUTTON_TOKEN", "GULLET_TOKEN"); + if (directToken.set) return assembleConfig(selection, (directToken.value ?? "").trim()); + + const dotEnv = await readOptionalFile(join(runtime.cwd, ".env"), runtime); + if (dotEnv !== null) { + const values = parseDotEnv(dotEnv); + const token = definedEnv(values, "TABGLUTTON_TOKEN", "GULLET_TOKEN"); + if (token.set) return assembleConfig(selection, (token.value ?? "").trim()); + } + + if (fileConfig.tokenCommand !== undefined) { + const command = fileConfig.tokenCommand; + return assembleConfig( + selection, + "", + tokenCommandResolver(command, dirname(paths.configFile), env, runtime), + ); + } + + const tokenFile = resolveConfigPath( + fileConfig.tokenFile ?? paths.defaultTokenFile, + dirname(paths.configFile), + paths.home, + ); + return assembleConfig(selection, "", tokenFileResolver(tokenFile, runtime)); +} + +function assembleConfig( + selection: "auto" | number, + token: string, + resolveToken?: TokenResolver, +): GulletConfig { + const tokenConfig = resolveToken === undefined ? { token } : { token, resolveToken }; + return selection === "auto" + ? { portMode: "auto", ...tokenConfig } + : { portMode: "fixed", port: selection, ...tokenConfig }; +} +function parseFlags(argv: readonly string[]): ParsedFlags { + const parsed: ParsedFlags = { portSet: false, tokenSet: false }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const [flag, inline] = splitFlag(arg); switch (flag) { case "--port": - port = inline ?? requireValue(flag, argv[++i]); + parsed.port = inline ?? requireValue(flag, argv[++i]); + parsed.portSet = true; break; case "--token": - token = inline ?? requireValue(flag, argv[++i]); + parsed.token = inline ?? requireValue(flag, argv[++i]); + parsed.tokenSet = true; break; default: throw new ConfigError(`Unknown argument ${arg}.\n\n${USAGE}`); } } - - const selection = parsePort(port); - const cleanToken = (token ?? "").trim(); - return selection === "auto" - ? { portMode: "auto", token: cleanToken } - : { portMode: "fixed", port: selection, token: cleanToken }; + return parsed; } /** - * A trailing `--port` or `--token` with nothing after it. Rejected rather than - * defaulted: silently changing a requested fixed port into automatic mode (or - * a token into empty) turns a typo into a sidecar serving somewhere the user - * never named, with an error naming neither. + * A trailing flag with nothing after it is rejected rather than silently + * changing a requested port or token into a default. */ function requireValue(flag: string, value: string | undefined): string { if (value === undefined) throw new ConfigError(`${flag} needs a value.\n\n${USAGE}`); @@ -68,17 +178,246 @@ function splitFlag(arg: string): [string, string | undefined] { return eq === -1 ? [arg, undefined] : [arg.slice(0, eq), arg.slice(eq + 1)]; } -function parsePort(raw: string | undefined): "auto" | number { - const value = raw?.trim() ?? ""; +function parsePort(raw: string | number | undefined): "auto" | number { + const value = String(raw ?? "").trim(); if (value === "" || value === "auto") return "auto"; - // The whole string or nothing. `Number.parseInt` stops at the first character - // it does not like and keeps what it has, so `4589oops` and `4589.5` both read - // as 4589 — a typo would bind a port the user never named, and then every - // browser that dials the port they *did* name is refused by a sidecar whose - // error message mentions neither. const port = /^\d+$/.test(value) ? Number(value) : Number.NaN; if (!isBridgePort(port)) { - throw new ConfigError(`Invalid port "${raw}" — expected an integer in 1024-65535.`); + throw new ConfigError( + `Invalid port "${String(raw)}" — expected auto or an integer in 1024-65535.`, + ); } return port; } + +function firstDefined( + values: Readonly>, + primary: string, + alias: string, +): string | undefined { + return values[primary] !== undefined ? values[primary] : values[alias]; +} + +function definedEnv( + values: Readonly>, + primary: string, + alias: string, +): { set: boolean; value?: string } { + if (values[primary] !== undefined) return { set: true, value: values[primary] }; + if (values[alias] !== undefined) return { set: true, value: values[alias] }; + return { set: false }; +} + +function configPaths( + env: Readonly>, + cwd: string, +): { configFile: string; defaultTokenFile: string; home: string } { + const home = env.HOME?.trim() || homedir(); + const configuredRoot = env.XDG_CONFIG_HOME?.trim(); + const root = configuredRoot + ? resolveConfigPath(configuredRoot, cwd, home) + : join(home, ".config"); + const directory = join(root, "tabglutton"); + return { + configFile: join(directory, "config.json"), + defaultTokenFile: join(directory, "token"), + home, + }; +} + +function resolveConfigPath(path: string, base: string, home: string): string { + if (path === "~") return home; + if (path.startsWith("~/")) return join(home, path.slice(2)); + return isAbsolute(path) ? path : resolve(base, path); +} + +async function readFileConfig(path: string, runtime: ConfigRuntime): Promise { + const text = await readOptionalFile(path, runtime); + if (text === null) return {}; + + let parsed: unknown; + try { + // The documented file is JSONC so a committed settings file can explain a + // secret-manager command or keep a trailing comma without a preprocessor. + parsed = Bun.JSONC.parse(text); + } catch (err) { + throw new ConfigError(`Could not parse ${path}: ${messageOf(err)}`); + } + if (!isRecord(parsed)) throw new ConfigError(`${path} must contain a JSON object.`); + if (Object.hasOwn(parsed, "token")) { + throw new ConfigError( + `${path} may not contain "token". Put the secret in the default token file, ` + + `or configure "tokenFile" or "tokenCommand" instead.`, + ); + } + + const allowed = new Set(["port", "tokenFile", "tokenCommand"]); + const unknown = Object.keys(parsed).find((key) => !allowed.has(key)); + if (unknown !== undefined) throw new ConfigError(`Unknown key "${unknown}" in ${path}.`); + + const config: FileConfig = {}; + if (parsed.port !== undefined) { + if (typeof parsed.port !== "string" && typeof parsed.port !== "number") { + throw new ConfigError(`"port" in ${path} must be "auto" or a number.`); + } + config.port = parsed.port; + } + if (parsed.tokenFile !== undefined) { + if (typeof parsed.tokenFile !== "string" || parsed.tokenFile.trim() === "") { + throw new ConfigError(`"tokenFile" in ${path} must be a non-empty path.`); + } + config.tokenFile = parsed.tokenFile; + } + if (parsed.tokenCommand !== undefined) { + if (typeof parsed.tokenCommand !== "string" || parsed.tokenCommand.trim() === "") { + throw new ConfigError(`"tokenCommand" in ${path} must be a non-empty command.`); + } + config.tokenCommand = parsed.tokenCommand; + } + if (config.tokenFile !== undefined && config.tokenCommand !== undefined) { + throw new ConfigError(`${path} must choose either "tokenFile" or "tokenCommand", not both.`); + } + return config; +} + +function tokenFileResolver(path: string, runtime: ConfigRuntime): TokenResolver { + return async () => { + let token: string; + try { + token = (await runtime.readFile(path)).trim(); + } catch (err) { + throw new ConfigError( + `Could not read Tabglutton's token file at ${path}: ${messageOf(err)}. ` + + `Open Tabglutton's settings and copy the setup command again.`, + ); + } + if (!token) { + throw new ConfigError( + `Tabglutton's token file at ${path} is empty. ` + + `Open Tabglutton's settings and copy the setup command again.`, + ); + } + return token; + }; +} + +function tokenCommandResolver( + command: string, + cwd: string, + env: Readonly>, + runtime: ConfigRuntime, +): TokenResolver { + return async () => { + const result = await runtime.runTokenCommand(command, { + cwd, + env, + timeoutMs: TOKEN_COMMAND_TIMEOUT_MS, + }); + const stderr = result.stderr.trim(); + const detail = stderr ? ` Stderr: ${stderr}` : ""; + if (result.timedOut) { + throw new ConfigError( + `Tabglutton tokenCommand timed out after ${TOKEN_COMMAND_TIMEOUT_MS}ms.${detail}`, + ); + } + if (result.exitCode !== 0) { + throw new ConfigError(`Tabglutton tokenCommand exited ${result.exitCode}.${detail}`); + } + const token = result.stdout.trim(); + if (!token) throw new ConfigError(`Tabglutton tokenCommand returned an empty token.${detail}`); + return token; + }; +} + +async function readOptionalFile(path: string, runtime: ConfigRuntime): Promise { + try { + return await runtime.readFile(path); + } catch (err) { + if (isNotFound(err)) return null; + throw new ConfigError(`Could not read ${path}: ${messageOf(err)}`); + } +} + +function parseDotEnv(text: string): Record { + const values: Record = {}; + for (const line of text.split(/\r?\n/)) { + const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line); + if (!match) continue; + const key = match[1]; + let value = match[2] ?? ""; + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + values[key] = value; + } + return values; +} + +function defaultRuntime(): ConfigRuntime { + return { + cwd: process.cwd(), + readFile: async (path) => Bun.file(path).text(), + runTokenCommand: runTokenCommand, + }; +} + +async function runTokenCommand( + command: string, + options: { + cwd: string; + env: Readonly>; + timeoutMs: number; + }, +): Promise { + const env = Object.fromEntries( + Object.entries(options.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + const subprocess = Bun.spawn(["sh", "-c", command], { + cwd: options.cwd, + // A command is allowed to run children (op, rage, a credential helper). + // Give the shell its own process group so the deadline can stop the whole + // pipeline instead of killing only sh and then hanging on inherited pipes. + detached: true, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(subprocess.stdout).text(); + const stderr = new Response(subprocess.stderr).text(); + let timer: ReturnType | undefined; + const outcome = await Promise.race([ + subprocess.exited.then((exitCode) => ({ exitCode, timedOut: false })), + new Promise<{ exitCode: number; timedOut: true }>((resolve) => { + timer = setTimeout(() => resolve({ exitCode: -1, timedOut: true }), options.timeoutMs); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + if (outcome.timedOut) { + try { + process.kill(-subprocess.pid, "SIGKILL"); + } catch { + subprocess.kill("SIGKILL"); + } + await subprocess.exited; + } + return { ...outcome, stdout: await stdout, stderr: await stderr }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotFound(err: unknown): boolean { + return isRecord(err) && err.code === "ENOENT"; +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/gullet/src/main.ts b/gullet/src/main.ts index d22d6b2..71b7987 100644 --- a/gullet/src/main.ts +++ b/gullet/src/main.ts @@ -1,9 +1,9 @@ // Wires the two halves together: MCP on stdio facing the agent, WebSocket hub // on loopback facing the browsers. -import { errorMessage, type BridgeError } from "../../src/bridge-protocol.js"; +import { errorMessage } from "../../src/bridge-protocol.js"; import { Supervisor } from "./backend.js"; -import { ConfigError, parseConfig, USAGE } from "./config.js"; +import { ConfigError, loadConfig, USAGE } from "./config.js"; import { serveStdio } from "./mcp.js"; import { createToolCaller, GULLET_INSTRUCTIONS, GULLET_TOOLS } from "./tools.js"; @@ -21,7 +21,7 @@ export async function main( let config; try { - config = parseConfig(argv, env); + config = await loadConfig(argv, env); } catch (err) { console.error(err instanceof ConfigError ? err.message : String(err)); return 1; @@ -34,6 +34,7 @@ export async function main( const backend = new Supervisor({ ...(config.portMode === "fixed" ? { port: config.port } : {}), token: config.token, + ...(config.resolveToken ? { resolveToken: config.resolveToken } : {}), }); // Losing the port is no longer a failure. Whoever binds it serves the browser @@ -52,15 +53,6 @@ export async function main( console.error(`[gullet] ${errorMessage(err)}`); } - let tokenError: BridgeError | null = null; - if (!config.token) { - const message = - "Tabglutton's bridge has no token. Open Tabglutton's settings, enable the agent bridge, " + - "generate a token, and set TABGLUTTON_TOKEN to it."; - console.error(`[gullet] ${message}`); - tokenError = { code: "unauthorized", message }; - } - const shutdown = (): void => { backend.stop(); process.exit(0); @@ -81,7 +73,7 @@ export async function main( request: (connectionId, method, params) => backend.request(connectionId, method, params), // A port we never bound is the more proximate problem, and fixing the // token would not make this process serve anything either way. - startupError: () => backend.fault() ?? tokenError, + startupError: () => backend.fault(), rivalHubs: () => backend.rivalHubs(), }), }); diff --git a/src/tabs-view.ts b/gullet/src/tabs-view.ts similarity index 97% rename from src/tabs-view.ts rename to gullet/src/tabs-view.ts index 73e30eb..1d69d62 100644 --- a/src/tabs-view.ts +++ b/gullet/src/tabs-view.ts @@ -3,8 +3,8 @@ // with Gullet, which is the only caller: see below for why this runs once at the // end rather than in the extension's pass. -import type { BridgeTab } from "./bridge-protocol.js"; -import { isTrackingParam } from "./normalize.js"; +import type { BridgeTab } from "../../src/bridge-protocol.js"; +import { isTrackingParam } from "../../src/normalize.js"; /** * Titles are clipped, not summarised. 120 is where the curve turns: measured diff --git a/gullet/src/tools.ts b/gullet/src/tools.ts index bb19e79..89571a7 100644 --- a/gullet/src/tools.ts +++ b/gullet/src/tools.ts @@ -19,7 +19,7 @@ import { type BridgeMethod, type BridgeTab, } from "../../src/bridge-protocol.js"; -import { renderTabs, TAB_TITLE_MAX } from "../../src/tabs-view.js"; +import { renderTabs, TAB_TITLE_MAX } from "./tabs-view.js"; import type { McpTool, McpToolResult } from "./mcp.js"; import { selectAll, selectOne, type ConnectionSummary } from "./select.js"; diff --git a/gullet/tests/backend.test.ts b/gullet/tests/backend.test.ts index deac563..cb2af61 100644 --- a/gullet/tests/backend.test.ts +++ b/gullet/tests/backend.test.ts @@ -274,6 +274,37 @@ describe("hub/peer election", () => { expect(await sup.connections()).toEqual([]); }, 10_000); + test("a token source failure is published and heals without a restart", async () => { + const port = freePort(); + let attempts = 0; + const sup = track( + new Supervisor({ + port, + token: "", + connectWaitMs: 0, + resolveToken: async () => { + attempts += 1; + if (attempts === 1) throw new Error("1Password is locked (stderr from op)"); + return TOKEN; + }, + }), + ); + + await expect(sup.start()).rejects.toThrow("1Password is locked"); + expect(sup.fault()).toEqual({ + code: "unauthorized", + message: "1Password is locked (stderr from op)", + }); + + for (let i = 0; i < 30 && sup.fault() !== null; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(sup.fault()).toBeNull(); + const browser = await fakeBrowser(port, null); + expect(await sup.connections()).toHaveLength(1); + browser.close(); + }, 5_000); + test("automatic exhaustion heals when any candidate becomes free", async () => { const candidates = [freePort(), freePort()]; const strangers = candidates.map((port, index) => diff --git a/gullet/tests/config.test.ts b/gullet/tests/config.test.ts index 8debbe1..ebfe580 100644 --- a/gullet/tests/config.test.ts +++ b/gullet/tests/config.test.ts @@ -1,5 +1,35 @@ import { describe, test, expect } from "bun:test"; -import { ConfigError, parseConfig } from "../src/config.js"; +import { + ConfigError, + loadConfig, + parseConfig, + TOKEN_COMMAND_TIMEOUT_MS, + type ConfigRuntime, +} from "../src/config.js"; + +function missing(path: string): Error & { code: string } { + return Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" }); +} + +function runtime( + files: Readonly>, + runTokenCommand: ConfigRuntime["runTokenCommand"] = async () => ({ + exitCode: 0, + stdout: "command-token\n", + stderr: "", + timedOut: false, + }), +): ConfigRuntime { + return { + cwd: "/workspace", + readFile: async (path) => { + const value = files[path]; + if (value === undefined) throw missing(path); + return value; + }, + runTokenCommand, + }; +} describe("parseConfig()", () => { test("defaults to automatic discovery and no token", () => { @@ -102,3 +132,142 @@ describe("flags with no value", () => { expect(parseConfig(["--token="], { TABGLUTTON_TOKEN: "inherited" }).token).toBe(""); }); }); + +describe("loadConfig()", () => { + test("uses XDG_CONFIG_HOME for settings and the default token file", async () => { + const config = await loadConfig( + [], + { HOME: "/home/michael", XDG_CONFIG_HOME: "/xdg" }, + runtime({ + "/xdg/tabglutton/config.json": '{"port": 5003}', + "/xdg/tabglutton/token": " file-token\n", + }), + ); + expect(config).toMatchObject({ portMode: "fixed", port: 5003, token: "" }); + expect(await config.resolveToken?.()).toBe("file-token"); + }); + + test("resolves a relative tokenFile from the config directory", async () => { + const config = await loadConfig( + [], + { HOME: "/home/michael" }, + runtime({ + "/home/michael/.config/tabglutton/config.json": '{"tokenFile":"secret/token"}', + "/home/michael/.config/tabglutton/secret/token": "abc", + }), + ); + expect(await config.resolveToken?.()).toBe("abc"); + }); + + test("accepts comments and trailing commas in the global settings", async () => { + const config = await loadConfig( + [], + { HOME: "/home/michael" }, + runtime({ + "/home/michael/.config/tabglutton/config.json": `{ + // Safe to commit: the secret stays in the referenced file. + "port": "auto", + "tokenFile": "token", + }`, + "/home/michael/.config/tabglutton/token": "abc", + }), + ); + expect(await config.resolveToken?.()).toBe("abc"); + }); + + test("uses CLI, environment, and .env tokens in precedence order", async () => { + const files = { + "/workspace/.env": "GULLET_TOKEN=dot-env\nTABGLUTTON_TOKEN='dot-primary'\n", + "/home/michael/.config/tabglutton/config.json": + '{"tokenCommand":"secret command","port":5004}', + }; + const fromDotEnv = await loadConfig([], { HOME: "/home/michael" }, runtime(files)); + expect(fromDotEnv).toEqual({ portMode: "fixed", port: 5004, token: "dot-primary" }); + + const fromEnv = await loadConfig( + [], + { HOME: "/home/michael", TABGLUTTON_TOKEN: "env" }, + runtime(files), + ); + expect(fromEnv.token).toBe("env"); + + const fromFlag = await loadConfig( + ["--token", "flag"], + { HOME: "/home/michael", TABGLUTTON_TOKEN: "env" }, + runtime(files), + ); + expect(fromFlag.token).toBe("flag"); + }); + + test("executes tokenCommand lazily with a bounded wait and config-directory cwd", async () => { + let call: { command: string; cwd: string; timeoutMs: number } | undefined; + const config = await loadConfig( + [], + { HOME: "/home/michael" }, + runtime( + { + "/home/michael/.config/tabglutton/config.json": + '{"tokenCommand":"op read op://Private/Tabglutton/token"}', + }, + async (command, options) => { + call = { command, cwd: options.cwd, timeoutMs: options.timeoutMs }; + return { exitCode: 0, stdout: "from-op\n", stderr: "", timedOut: false }; + }, + ), + ); + + expect(call).toBeUndefined(); + expect(await config.resolveToken?.()).toBe("from-op"); + expect(call).toEqual({ + command: "op read op://Private/Tabglutton/token", + cwd: "/home/michael/.config/tabglutton", + timeoutMs: TOKEN_COMMAND_TIMEOUT_MS, + }); + }); + + test("attaches tokenCommand stderr to timeout and exit errors", async () => { + const config = await loadConfig( + [], + { HOME: "/home/michael" }, + runtime( + { + "/home/michael/.config/tabglutton/config.json": '{"tokenCommand":"op read item"}', + }, + async () => ({ + exitCode: -1, + stdout: "", + stderr: "1Password is locked", + timedOut: true, + }), + ), + ); + await expect(config.resolveToken?.()).rejects.toThrow( + `timed out after ${TOKEN_COMMAND_TIMEOUT_MS}ms. Stderr: 1Password is locked`, + ); + }); + + test("rejects an inline token even when a higher-precedence token is present", async () => { + await expect( + loadConfig( + ["--token", "safe-elsewhere"], + { HOME: "/home/michael" }, + runtime({ + "/home/michael/.config/tabglutton/config.json": '{"token":"must-not-be-here"}', + }), + ), + ).rejects.toThrow(/may not contain "token".*tokenFile.*tokenCommand/); + }); + + test("rejects simultaneous tokenFile and tokenCommand sources", async () => { + await expect( + loadConfig( + [], + { HOME: "/home/michael" }, + runtime({ + "/home/michael/.config/tabglutton/config.json": + '{"tokenFile":"token","tokenCommand":"op read item"}', + }), + ), + ).rejects.toThrow(/either "tokenFile" or "tokenCommand"/); + }); +}); diff --git a/tests/tabs-view.test.ts b/gullet/tests/tabs-view.test.ts similarity index 98% rename from tests/tabs-view.test.ts rename to gullet/tests/tabs-view.test.ts index e3202dd..86c099c 100644 --- a/tests/tabs-view.test.ts +++ b/gullet/tests/tabs-view.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import type { BridgeTab } from "../src/bridge-protocol.js"; +import type { BridgeTab } from "../../src/bridge-protocol.js"; import { displayUrl, renderTabs, TAB_TITLE_MAX, TAB_URL_MAX } from "../src/tabs-view.js"; function makeTab(fields: Partial & Pick): BridgeTab { diff --git a/options/options.html b/options/options.html index f8844c6..5e8fcb5 100644 --- a/options/options.html +++ b/options/options.html @@ -335,14 +335,15 @@

Agent bridge

Agent setup - Add Gullet to your agent's MCP config, then start a session — the badge shows a dot + Run this command once to store the token globally with private permissions, then add + bunx tabglutton-gullet to your agent's MCP config. The badge shows a dot when the connection is live.
diff --git a/options/options.ts b/options/options.ts index 955c2da..d9899b0 100644 --- a/options/options.ts +++ b/options/options.ts @@ -266,7 +266,11 @@ bridgeTokenCopy.addEventListener("click", () => { }); bridgeSnippetCopy.addEventListener("click", () => { - void copyText(bridgeSnippetText(false), "Config copied"); + if (!bridgeToken.value) { + flashStatus("No token yet"); + return; + } + void copyText(bridgeSnippetText(false), "Setup command copied"); }); async function copyText(text: string, okMessage: string): Promise { @@ -281,34 +285,24 @@ async function copyText(text: string, okMessage: string): Promise { /** * @param masked render the token as dots rather than the secret itself. The - * displayed snippet is masked unless the eye is open; "Copy config" always - * passes `false`, so the clipboard gets a config that actually works. + * displayed snippet is masked unless the eye is open; "Copy setup command" + * always passes `false`, so the clipboard gets a command that actually works. */ function bridgeSnippetText(masked: boolean): string { - const port = parsePort(bridgePort.value); const real = bridgeToken.value || ""; const token = masked && bridgeToken.value ? "•".repeat(24) : real; - // Named "tabglutton" rather than "gullet": this key becomes the tool - // namespace the agent sees, and users know the product by one name. - return JSON.stringify( - { - mcpServers: { - tabglutton: { - command: "bun", - args: [ - "run", - "/path/to/tabglutton/gullet/gullet.ts", - ...(selectedBridgePortMode() === "fixed" ? ["--port", String(port)] : []), - ], - env: { TABGLUTTON_TOKEN: token }, - }, - }, - }, - null, - 2, + return ( + 'config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton"\n' + + 'mkdir -p "$config_dir" && chmod 700 "$config_dir" &&\n' + + `(umask 077; printf '%s\\n' ${shellQuote(token)} > "$config_dir/token")` ); } +/** Single-quote arbitrary pasted tokens without giving the shell code to run. */ +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + function updateBridgeSnippet(): void { const code = bridgeSnippet.querySelector("code"); if (code) code.textContent = bridgeSnippetText(!tokenRevealed()); diff --git a/package.json b/package.json index f5a698e..a77a704 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "bun run build:firefox && bun run build:chrome", + "build:gullet": "bun run --cwd gullet build", "build:firefox": "bun build.ts --target=firefox", "build:chrome": "bun build.ts --target=chrome", "watch": "bunx tsc --watch --preserveWatchOutput", @@ -26,6 +27,7 @@ "start:firefox": "bun run build:firefox && bunx web-ext run --source-dir=dist-firefox --firefox-profile=./.dev-profile-firefox --profile-create-if-missing --keep-profile-changes --pref=network.protocol-handler.warn-external.obsidian=false --pref=network.protocol-handler.external.obsidian=true", "start:chrome": "bun run build:chrome && bunx web-ext run --target=chromium --source-dir=dist-chrome --chromium-profile=./.dev-profile-chrome --keep-profile-changes", "package": "bun run package:firefox && bun run package:chrome && bun scripts/package-source.ts", + "package:gullet": "bun --cwd=gullet pm pack --dry-run", "package:firefox": "bun run build:firefox && bunx web-ext build --source-dir=dist-firefox --artifacts-dir=web-ext-artifacts --filename=tabglutton-firefox-{version}.zip --overwrite-dest", "package:chrome": "bun run build:chrome && bunx web-ext build --source-dir=dist-chrome --artifacts-dir=web-ext-artifacts --filename=tabglutton-chrome-{version}.zip --overwrite-dest", "sign": "bun scripts/sign.ts", From 20fe92dcec82f104787e4ddf6a48c7a716584dee Mon Sep 17 00:00:00 2001 From: Michael Simon Date: Mon, 3 Aug 2026 09:23:05 -0700 Subject: [PATCH 2/3] Fix Gullet timeout and fixed-port setup guidance --- gullet/src/config.ts | 36 ++++++++++++++++++++++-------------- gullet/tests/config.test.ts | 16 ++++++++++++++++ options/options.html | 4 ++-- options/options.ts | 8 +++++++- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/gullet/src/config.ts b/gullet/src/config.ts index 0cca627..704ad84 100644 --- a/gullet/src/config.ts +++ b/gullet/src/config.ts @@ -52,7 +52,7 @@ interface FileConfig { tokenCommand?: string; } -interface TokenCommandResult { +export interface TokenCommandResult { exitCode: number; stdout: string; stderr: string; @@ -365,7 +365,7 @@ function defaultRuntime(): ConfigRuntime { }; } -async function runTokenCommand( +export async function runTokenCommand( command: string, options: { cwd: string; @@ -392,22 +392,30 @@ async function runTokenCommand( const stdout = new Response(subprocess.stdout).text(); const stderr = new Response(subprocess.stderr).text(); let timer: ReturnType | undefined; + const completed = Promise.all([subprocess.exited, stdout, stderr]).then( + ([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr, timedOut: false as const }), + ); const outcome = await Promise.race([ - subprocess.exited.then((exitCode) => ({ exitCode, timedOut: false })), - new Promise<{ exitCode: number; timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ exitCode: -1, timedOut: true }), options.timeoutMs); + completed, + new Promise<{ timedOut: true }>((resolve) => { + timer = setTimeout(() => resolve({ timedOut: true }), options.timeoutMs); }), ]); - if (timer !== undefined) clearTimeout(timer); - if (outcome.timedOut) { - try { - process.kill(-subprocess.pid, "SIGKILL"); - } catch { - subprocess.kill("SIGKILL"); - } - await subprocess.exited; + if (!outcome.timedOut) { + if (timer !== undefined) clearTimeout(timer); + return outcome; + } + + // The shell can exit while a background child keeps its output pipes open. + // The deadline therefore covers process exit *and* pipe drain; kill the + // detached process group so those inherited descriptors close as well. + try { + process.kill(-subprocess.pid, "SIGKILL"); + } catch { + subprocess.kill("SIGKILL"); } - return { ...outcome, stdout: await stdout, stderr: await stderr }; + const drained = await completed; + return { ...drained, exitCode: -1, timedOut: true }; } function isRecord(value: unknown): value is Record { diff --git a/gullet/tests/config.test.ts b/gullet/tests/config.test.ts index ebfe580..5646c63 100644 --- a/gullet/tests/config.test.ts +++ b/gullet/tests/config.test.ts @@ -3,6 +3,7 @@ import { ConfigError, loadConfig, parseConfig, + runTokenCommand, TOKEN_COMMAND_TIMEOUT_MS, type ConfigRuntime, } from "../src/config.js"; @@ -271,3 +272,18 @@ describe("loadConfig()", () => { ).rejects.toThrow(/either "tokenFile" or "tokenCommand"/); }); }); + +describe("runTokenCommand()", () => { + test("keeps the deadline active while a background child holds the output pipes", async () => { + const started = performance.now(); + const result = await runTokenCommand("sleep 30 & printf token", { + cwd: process.cwd(), + env: { PATH: Bun.env.PATH }, + timeoutMs: 100, + }); + + expect(result.timedOut).toBeTrue(); + expect(result.stdout).toBe("token"); + expect(performance.now() - started).toBeLessThan(1_500); + }, 2_000); +}); diff --git a/options/options.html b/options/options.html index 5e8fcb5..6a24e80 100644 --- a/options/options.html +++ b/options/options.html @@ -336,8 +336,8 @@

Agent bridge

Agent setup Run this command once to store the token globally with private permissions, then add - bunx tabglutton-gullet to your agent's MCP config. The badge shows a dot - when the connection is live. + bunx tabglutton-gullet to your agent's MCP + config. The badge shows a dot when the connection is live.
diff --git a/options/options.ts b/options/options.ts index d9899b0..61f80a3 100644 --- a/options/options.ts +++ b/options/options.ts @@ -43,6 +43,7 @@ const bridgeTokenGenerate = document.getElementById("bridgeTokenGenerate") as HT const bridgeStatusEl = document.getElementById("bridgeStatus") as HTMLSpanElement; const bridgeSnippet = document.getElementById("bridgeSnippet") as HTMLPreElement; const bridgeSnippetCopy = document.getElementById("bridgeSnippetCopy") as HTMLButtonElement; +const bridgeLaunchCommand = document.getElementById("bridgeLaunchCommand") as HTMLElement; function parseParams(text: string): string[] { return text @@ -294,7 +295,8 @@ function bridgeSnippetText(masked: boolean): string { return ( 'config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton"\n' + 'mkdir -p "$config_dir" && chmod 700 "$config_dir" &&\n' + - `(umask 077; printf '%s\\n' ${shellQuote(token)} > "$config_dir/token")` + `(umask 077; printf '%s\\n' ${shellQuote(token)} > "$config_dir/token") && ` + + 'chmod 600 "$config_dir/token"' ); } @@ -306,6 +308,10 @@ function shellQuote(value: string): string { function updateBridgeSnippet(): void { const code = bridgeSnippet.querySelector("code"); if (code) code.textContent = bridgeSnippetText(!tokenRevealed()); + bridgeLaunchCommand.textContent = + selectedBridgePortMode() === "fixed" + ? `bunx tabglutton-gullet --port ${parsePort(bridgePort.value)}` + : "bunx tabglutton-gullet"; } const BRIDGE_STATUS_LABELS: Record = { From 9c888a6034075ecdae97ac2d38de617fcf1a4fca Mon Sep 17 00:00:00 2001 From: Michael Simon Date: Mon, 3 Aug 2026 10:53:35 -0700 Subject: [PATCH 3/3] Deduplicate Gullet's config layer against the shared protocol module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality cleanup over the global-config work, no behavior change intended. - Delete `parseConfig`. It had no production caller left — `main` uses `loadConfig` — and re-implemented the CLI→env precedence in a second idiom, so the rules had two homes and the tested one was not the one that shipped. Its assertions now drive `loadConfig` through the existing `ConfigRuntime` seam with every file absent. - Use the shared `errorMessage` and `asRecord` from `bridge-protocol` instead of the local `messageOf` / `isRecord` copies. - Collapse `definedEnv` into `firstDefined`: the `{set, value?}` box carried nothing a `!== undefined` check doesn't, and forced three `?? ""` fallbacks that could never fire. - Drop `portSet` / `tokenSet` — `requireValue` throws rather than returning undefined, so a set flag always carries a string. - Drop the conditional spreads in `assembleConfig` and `main`; `exactOptionalPropertyTypes` is false, so they guarded an optional property that already accepts undefined. - Fold the twin tokenFile/tokenCommand validation blocks into one local. `Supervisor.acquireToken` returns `BridgeError | null`, which removes the unreachable `fault?.code ?? …` fallbacks in `start()` and with them a second copy of the no-token message. `start()` also stops arming `retryToken()` when no `resolveToken` is configured: that branch does no I/O and re-derives a constant, so it was a 1s→30s backoff loop waking the event loop for the process lifetime — reachable via `--token=` or an empty TABGLUTTON_TOKEN. The options page spelled `~/.config/tabglutton/token` as shell text while `configPaths()` computed it in TS, with nothing enforcing agreement; renaming either would typecheck cleanly and silently break the only documented way to install a token. Both now build from CONFIG_DIR_NAME and DEFAULT_TOKEN_FILE_NAME in `bridge-protocol.ts`, the module the two halves already share for this class of constant. `bridgeSnippetText` returns null with no token rather than wrapping a runnable command around the `` placeholder — the copy button's guard never covered selecting the `
` by hand — and the copy handler
now shares that one check.

Verified: bun run check.
---
 gullet/src/backend.ts       |  31 +++++-----
 gullet/src/config.ts        | 120 +++++++++++++-----------------------
 gullet/src/main.ts          |   2 +-
 gullet/tests/config.test.ts | 102 ++++++++++++++++++------------
 options/options.ts          |  45 +++++++++-----
 src/bridge-protocol.ts      |  10 +++
 6 files changed, 163 insertions(+), 147 deletions(-)

diff --git a/gullet/src/backend.ts b/gullet/src/backend.ts
index 0cbe7b5..5e6de15 100644
--- a/gullet/src/backend.ts
+++ b/gullet/src/backend.ts
@@ -124,13 +124,13 @@ export class Supervisor implements BridgeBackend {
     // the constructor's placeholder resolved promise and mistake it for an
     // election that finished with no backend.
     if (!this.token) {
-      if (!(await this.acquireToken())) {
-        void this.retryToken();
-        const fault = this.tokenFault;
-        throw new BridgeRequestError(
-          fault?.code ?? "unauthorized",
-          fault?.message ?? "Tabglutton's bridge has no token.",
-        );
+      const fault = await this.acquireToken();
+      if (fault) {
+        // Only a configured source can heal; with none, acquireToken's answer is
+        // fixed at construction time and a retry loop would wake the event loop
+        // forever to re-derive it.
+        if (this.options.resolveToken) void this.retryToken();
+        throw new BridgeRequestError(fault.code, fault.message);
       }
     }
     this.settling = this.elect();
@@ -168,8 +168,8 @@ export class Supervisor implements BridgeBackend {
    * secret manager, or the token file may not have been created yet; neither is
    * a reason to kill the MCP transport before it can explain the problem.
    */
-  private async acquireToken(): Promise {
-    if (this.token) return true;
+  private async acquireToken(): Promise {
+    if (this.token) return null;
     if (!this.options.resolveToken) {
       this.tokenFault = {
         code: "unauthorized",
@@ -177,20 +177,17 @@ export class Supervisor implements BridgeBackend {
           "Tabglutton's bridge has no token. Open Tabglutton's settings, enable the " +
           "agent bridge, generate a token, and copy the setup command.",
       };
-      return false;
+      return this.tokenFault;
     }
     try {
       const token = (await this.options.resolveToken()).trim();
       if (!token) throw new Error("The configured token source returned an empty token.");
       this.token = token;
       this.tokenFault = null;
-      return true;
+      return null;
     } catch (err) {
-      this.tokenFault = {
-        code: "unauthorized",
-        message: errorMessage(err),
-      };
-      return false;
+      this.tokenFault = { code: "unauthorized", message: errorMessage(err) };
+      return this.tokenFault;
     }
   }
 
@@ -200,7 +197,7 @@ export class Supervisor implements BridgeBackend {
     while (!this.stopped) {
       await delay(gap);
       if (this.stopped) return;
-      if (await this.acquireToken()) {
+      if (!(await this.acquireToken())) {
         console.error("[gullet] token source became available; starting bridge election");
         this.settling = this.elect();
         void this.settling.catch((err) =>
diff --git a/gullet/src/config.ts b/gullet/src/config.ts
index 704ad84..b86542c 100644
--- a/gullet/src/config.ts
+++ b/gullet/src/config.ts
@@ -2,7 +2,13 @@
 
 import { homedir } from "node:os";
 import { dirname, isAbsolute, join, resolve } from "node:path";
-import { isBridgePort } from "../../src/bridge-protocol.js";
+import {
+  asRecord,
+  CONFIG_DIR_NAME,
+  DEFAULT_TOKEN_FILE_NAME,
+  errorMessage,
+  isBridgePort,
+} from "../../src/bridge-protocol.js";
 
 export type TokenResolver = () => Promise;
 
@@ -41,9 +47,7 @@ environment: process arguments are visible to other local users.`;
 
 interface ParsedFlags {
   port?: string;
-  portSet: boolean;
   token?: string;
-  tokenSet: boolean;
 }
 
 interface FileConfig {
@@ -72,22 +76,6 @@ export interface ConfigRuntime {
   ) => Promise;
 }
 
-/**
- * The old pure CLI/env surface remains useful to callers and unit tests. File
- * access lives in loadConfig(), which main uses.
- */
-export function parseConfig(
-  argv: readonly string[],
-  env: Readonly>,
-): GulletConfig {
-  const flags = parseFlags(argv);
-  const port = flags.portSet ? flags.port : firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT");
-  const token = flags.tokenSet
-    ? flags.token
-    : firstDefined(env, "TABGLUTTON_TOKEN", "GULLET_TOKEN");
-  return assembleConfig(parsePort(port), (token ?? "").trim());
-}
-
 /** Read the global settings and select a token source without executing it. */
 export async function loadConfig(
   argv: readonly string[],
@@ -98,21 +86,20 @@ export async function loadConfig(
   const paths = configPaths(env, runtime.cwd);
   const fileConfig = await readFileConfig(paths.configFile, runtime);
 
-  const rawPort = flags.portSet
-    ? flags.port
-    : (firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT") ?? fileConfig.port);
+  const rawPort =
+    flags.port ?? firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT") ?? fileConfig.port;
   const selection = parsePort(rawPort);
 
-  const directToken = flags.tokenSet
-    ? { set: true, value: flags.token }
-    : definedEnv(env, "TABGLUTTON_TOKEN", "GULLET_TOKEN");
-  if (directToken.set) return assembleConfig(selection, (directToken.value ?? "").trim());
+  // A flag or variable that is present but empty still counts as "the token was
+  // configured here", so it stops the search rather than falling through to the
+  // file sources — hence `!== undefined` rather than a truthiness check.
+  const directToken = flags.token ?? firstDefined(env, "TABGLUTTON_TOKEN", "GULLET_TOKEN");
+  if (directToken !== undefined) return assembleConfig(selection, directToken.trim());
 
   const dotEnv = await readOptionalFile(join(runtime.cwd, ".env"), runtime);
   if (dotEnv !== null) {
-    const values = parseDotEnv(dotEnv);
-    const token = definedEnv(values, "TABGLUTTON_TOKEN", "GULLET_TOKEN");
-    if (token.set) return assembleConfig(selection, (token.value ?? "").trim());
+    const token = firstDefined(parseDotEnv(dotEnv), "TABGLUTTON_TOKEN", "GULLET_TOKEN");
+    if (token !== undefined) return assembleConfig(selection, token.trim());
   }
 
   if (fileConfig.tokenCommand !== undefined) {
@@ -137,25 +124,22 @@ function assembleConfig(
   token: string,
   resolveToken?: TokenResolver,
 ): GulletConfig {
-  const tokenConfig = resolveToken === undefined ? { token } : { token, resolveToken };
   return selection === "auto"
-    ? { portMode: "auto", ...tokenConfig }
-    : { portMode: "fixed", port: selection, ...tokenConfig };
+    ? { portMode: "auto", token, resolveToken }
+    : { portMode: "fixed", port: selection, token, resolveToken };
 }
 
 function parseFlags(argv: readonly string[]): ParsedFlags {
-  const parsed: ParsedFlags = { portSet: false, tokenSet: false };
+  const parsed: ParsedFlags = {};
   for (let i = 0; i < argv.length; i++) {
     const arg = argv[i];
     const [flag, inline] = splitFlag(arg);
     switch (flag) {
       case "--port":
         parsed.port = inline ?? requireValue(flag, argv[++i]);
-        parsed.portSet = true;
         break;
       case "--token":
         parsed.token = inline ?? requireValue(flag, argv[++i]);
-        parsed.tokenSet = true;
         break;
       default:
         throw new ConfigError(`Unknown argument ${arg}.\n\n${USAGE}`);
@@ -198,16 +182,6 @@ function firstDefined(
   return values[primary] !== undefined ? values[primary] : values[alias];
 }
 
-function definedEnv(
-  values: Readonly>,
-  primary: string,
-  alias: string,
-): { set: boolean; value?: string } {
-  if (values[primary] !== undefined) return { set: true, value: values[primary] };
-  if (values[alias] !== undefined) return { set: true, value: values[alias] };
-  return { set: false };
-}
-
 function configPaths(
   env: Readonly>,
   cwd: string,
@@ -217,10 +191,10 @@ function configPaths(
   const root = configuredRoot
     ? resolveConfigPath(configuredRoot, cwd, home)
     : join(home, ".config");
-  const directory = join(root, "tabglutton");
+  const directory = join(root, CONFIG_DIR_NAME);
   return {
     configFile: join(directory, "config.json"),
-    defaultTokenFile: join(directory, "token"),
+    defaultTokenFile: join(directory, DEFAULT_TOKEN_FILE_NAME),
     home,
   };
 }
@@ -241,10 +215,11 @@ async function readFileConfig(path: string, runtime: ConfigRuntime): Promise !allowed.has(key));
+  const unknown = Object.keys(parsedConfig).find((key) => !allowed.has(key));
   if (unknown !== undefined) throw new ConfigError(`Unknown key "${unknown}" in ${path}.`);
 
+  const nonEmptyString = (key: "tokenFile" | "tokenCommand", noun: string): string | undefined => {
+    const value = parsedConfig[key];
+    if (value === undefined) return undefined;
+    if (typeof value !== "string" || value.trim() === "") {
+      throw new ConfigError(`"${key}" in ${path} must be a non-empty ${noun}.`);
+    }
+    return value;
+  };
+
   const config: FileConfig = {};
-  if (parsed.port !== undefined) {
-    if (typeof parsed.port !== "string" && typeof parsed.port !== "number") {
+  if (parsedConfig.port !== undefined) {
+    if (typeof parsedConfig.port !== "string" && typeof parsedConfig.port !== "number") {
       throw new ConfigError(`"port" in ${path} must be "auto" or a number.`);
     }
-    config.port = parsed.port;
-  }
-  if (parsed.tokenFile !== undefined) {
-    if (typeof parsed.tokenFile !== "string" || parsed.tokenFile.trim() === "") {
-      throw new ConfigError(`"tokenFile" in ${path} must be a non-empty path.`);
-    }
-    config.tokenFile = parsed.tokenFile;
-  }
-  if (parsed.tokenCommand !== undefined) {
-    if (typeof parsed.tokenCommand !== "string" || parsed.tokenCommand.trim() === "") {
-      throw new ConfigError(`"tokenCommand" in ${path} must be a non-empty command.`);
-    }
-    config.tokenCommand = parsed.tokenCommand;
+    config.port = parsedConfig.port;
   }
+  config.tokenFile = nonEmptyString("tokenFile", "path");
+  config.tokenCommand = nonEmptyString("tokenCommand", "command");
   if (config.tokenFile !== undefined && config.tokenCommand !== undefined) {
     throw new ConfigError(`${path} must choose either "tokenFile" or "tokenCommand", not both.`);
   }
@@ -287,7 +261,7 @@ function tokenFileResolver(path: string, runtime: ConfigRuntime): TokenResolver
       token = (await runtime.readFile(path)).trim();
     } catch (err) {
       throw new ConfigError(
-        `Could not read Tabglutton's token file at ${path}: ${messageOf(err)}. ` +
+        `Could not read Tabglutton's token file at ${path}: ${errorMessage(err)}. ` +
           `Open Tabglutton's settings and copy the setup command again.`,
       );
     }
@@ -334,7 +308,7 @@ async function readOptionalFile(path: string, runtime: ConfigRuntime): Promise {
-  return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
 function isNotFound(err: unknown): boolean {
-  return isRecord(err) && err.code === "ENOENT";
-}
-
-function messageOf(err: unknown): string {
-  return err instanceof Error ? err.message : String(err);
+  return asRecord(err)?.code === "ENOENT";
 }
diff --git a/gullet/src/main.ts b/gullet/src/main.ts
index 71b7987..b29fedb 100644
--- a/gullet/src/main.ts
+++ b/gullet/src/main.ts
@@ -34,7 +34,7 @@ export async function main(
   const backend = new Supervisor({
     ...(config.portMode === "fixed" ? { port: config.port } : {}),
     token: config.token,
-    ...(config.resolveToken ? { resolveToken: config.resolveToken } : {}),
+    resolveToken: config.resolveToken,
   });
 
   // Losing the port is no longer a failure. Whoever binds it serves the browser
diff --git a/gullet/tests/config.test.ts b/gullet/tests/config.test.ts
index 5646c63..c062968 100644
--- a/gullet/tests/config.test.ts
+++ b/gullet/tests/config.test.ts
@@ -2,10 +2,10 @@ import { describe, test, expect } from "bun:test";
 import {
   ConfigError,
   loadConfig,
-  parseConfig,
   runTokenCommand,
   TOKEN_COMMAND_TIMEOUT_MS,
   type ConfigRuntime,
+  type GulletConfig,
 } from "../src/config.js";
 
 function missing(path: string): Error & { code: string } {
@@ -32,29 +32,52 @@ function runtime(
   };
 }
 
-describe("parseConfig()", () => {
-  test("defaults to automatic discovery and no token", () => {
-    expect(parseConfig([], {})).toEqual({ portMode: "auto", token: "" });
+/**
+ * The CLI/env half of loadConfig, with every file absent so the runtime is pure.
+ * These used to run against a separate `parseConfig`, which meant the precedence
+ * rules had two implementations and the tested one was not the one that shipped.
+ */
+function parsed(
+  argv: readonly string[],
+  env: Readonly> = {},
+): Promise {
+  return loadConfig(argv, env, runtime({}));
+}
+
+/** Port and token only: with no token configured, loadConfig also attaches a resolver. */
+async function selection(
+  argv: readonly string[],
+  env: Readonly> = {},
+): Promise<{ portMode: string; port?: number; token: string }> {
+  const config = await parsed(argv, env);
+  return config.portMode === "fixed"
+    ? { portMode: "fixed", port: config.port, token: config.token }
+    : { portMode: "auto", token: config.token };
+}
+
+describe("CLI and environment precedence", () => {
+  test("defaults to automatic discovery and no token", async () => {
+    expect(await selection([])).toEqual({ portMode: "auto", token: "" });
   });
 
-  test("reads the token and port from the environment", () => {
-    expect(parseConfig([], { GULLET_TOKEN: "abc", GULLET_PORT: "5000" })).toEqual({
+  test("reads the token and port from the environment", async () => {
+    expect(await selection([], { GULLET_TOKEN: "abc", GULLET_PORT: "5000" })).toEqual({
       portMode: "fixed",
       port: 5000,
       token: "abc",
     });
   });
 
-  test("accepts TABGLUTTON_* as the primary spelling", () => {
-    expect(parseConfig([], { TABGLUTTON_TOKEN: "abc", TABGLUTTON_PORT: "5000" })).toEqual({
+  test("accepts TABGLUTTON_* as the primary spelling", async () => {
+    expect(await selection([], { TABGLUTTON_TOKEN: "abc", TABGLUTTON_PORT: "5000" })).toEqual({
       portMode: "fixed",
       port: 5000,
       token: "abc",
     });
   });
 
-  test("prefers TABGLUTTON_* when both spellings are set", () => {
-    const config = parseConfig([], {
+  test("prefers TABGLUTTON_* when both spellings are set", async () => {
+    const config = await selection([], {
       TABGLUTTON_TOKEN: "new",
       GULLET_TOKEN: "old",
       TABGLUTTON_PORT: "5002",
@@ -63,74 +86,77 @@ describe("parseConfig()", () => {
     expect(config).toEqual({ portMode: "fixed", port: 5002, token: "new" });
   });
 
-  test("flags override the environment", () => {
-    const config = parseConfig(["--port", "5001", "--token", "flag"], {
+  test("flags override the environment", async () => {
+    const config = await selection(["--port", "5001", "--token", "flag"], {
       GULLET_PORT: "5000",
       GULLET_TOKEN: "env",
     });
     expect(config).toEqual({ portMode: "fixed", port: 5001, token: "flag" });
   });
 
-  test("accepts --flag=value form", () => {
-    expect(parseConfig(["--port=5002", "--token=xyz"], {})).toEqual({
+  test("accepts --flag=value form", async () => {
+    expect(await selection(["--port=5002", "--token=xyz"])).toEqual({
       portMode: "fixed",
       port: 5002,
       token: "xyz",
     });
   });
 
-  test("trims surrounding whitespace off a pasted token", () => {
-    expect(parseConfig([], { GULLET_TOKEN: "  abc\n" }).token).toBe("abc");
+  test("trims surrounding whitespace off a pasted token", async () => {
+    expect((await parsed([], { GULLET_TOKEN: "  abc\n" })).token).toBe("abc");
   });
 
-  test("rejects a port outside the bindable range", () => {
-    expect(() => parseConfig(["--port", "80"], {})).toThrow(ConfigError);
-    expect(() => parseConfig(["--port", "70000"], {})).toThrow(ConfigError);
+  test("rejects a port outside the bindable range", async () => {
+    expect(parsed(["--port", "80"])).rejects.toThrow(ConfigError);
+    expect(parsed(["--port", "70000"])).rejects.toThrow(ConfigError);
   });
 
-  test("rejects a non-numeric port instead of silently defaulting", () => {
-    expect(() => parseConfig(["--port", "abc"], {})).toThrow(ConfigError);
+  test("rejects a non-numeric port instead of silently defaulting", async () => {
+    expect(parsed(["--port", "abc"])).rejects.toThrow(ConfigError);
   });
 
-  test("rejects a port that is only partly a number", () => {
+  test("rejects a port that is only partly a number", async () => {
     // `parseInt` keeps the digits it managed to read and discards the rest, so
     // each of these used to bind 4589 — a port the user never asked for, while
     // every browser dialling the one they did ask for is refused.
     for (const raw of ["4589oops", "4589.5", "4589 4590", "0x4589", "+4589"]) {
-      expect(() => parseConfig(["--port", raw], {})).toThrow(ConfigError);
-      expect(() => parseConfig([], { TABGLUTTON_PORT: raw })).toThrow(ConfigError);
+      expect(parsed(["--port", raw])).rejects.toThrow(ConfigError);
+      expect(parsed([], { TABGLUTTON_PORT: raw })).rejects.toThrow(ConfigError);
     }
   });
 
-  test("uses automatic discovery for an empty or explicit auto port", () => {
-    expect(parseConfig([], { GULLET_PORT: "" })).toEqual({ portMode: "auto", token: "" });
-    expect(parseConfig(["--port", "auto"], {})).toEqual({ portMode: "auto", token: "" });
-    expect(parseConfig([], { TABGLUTTON_PORT: "auto" })).toEqual({
+  test("uses automatic discovery for an empty or explicit auto port", async () => {
+    expect(await selection([], { GULLET_PORT: "" })).toEqual({ portMode: "auto", token: "" });
+    expect(await selection(["--port", "auto"])).toEqual({ portMode: "auto", token: "" });
+    expect(await selection([], { TABGLUTTON_PORT: "auto" })).toEqual({
       portMode: "auto",
       token: "",
     });
   });
 
-  test("rejects unknown arguments with usage text", () => {
-    expect(() => parseConfig(["--daemon"], {})).toThrow(/Unknown argument --daemon/);
+  test("rejects unknown arguments with usage text", async () => {
+    expect(parsed(["--daemon"])).rejects.toThrow(/Unknown argument --daemon/);
   });
 });
 
 describe("flags with no value", () => {
-  test("rejects a trailing --port rather than silently defaulting", () => {
+  test("rejects a trailing --port rather than silently defaulting", async () => {
     // Defaulting turns a typo into a sidecar that binds the wrong port and then
     // reports a failure naming neither the flag nor the port.
-    expect(() => parseConfig(["--port"], {})).toThrow(ConfigError);
-    expect(() => parseConfig(["--port"], {})).toThrow("--port needs a value");
+    expect(parsed(["--port"])).rejects.toThrow(ConfigError);
+    expect(parsed(["--port"])).rejects.toThrow("--port needs a value");
   });
 
-  test("rejects a trailing --token rather than starting with none", () => {
-    expect(() => parseConfig(["--token"], {})).toThrow("--token needs a value");
+  test("rejects a trailing --token rather than starting with none", async () => {
+    expect(parsed(["--token"])).rejects.toThrow("--token needs a value");
   });
 
-  test("still accepts an explicitly empty value", () => {
-    // `--token=` is a deliberate override of an inherited environment variable.
-    expect(parseConfig(["--token="], { TABGLUTTON_TOKEN: "inherited" }).token).toBe("");
+  test("still accepts an explicitly empty value", async () => {
+    // `--token=` is a deliberate override of an inherited environment variable,
+    // and stops the search rather than falling through to the file sources.
+    const config = await parsed(["--token="], { TABGLUTTON_TOKEN: "inherited" });
+    expect(config.token).toBe("");
+    expect(config.resolveToken).toBeUndefined();
   });
 });
 
diff --git a/options/options.ts b/options/options.ts
index 61f80a3..fe2f18a 100644
--- a/options/options.ts
+++ b/options/options.ts
@@ -1,6 +1,12 @@
 import type { BridgeStatusChangedMessage, GetBridgeStatusResponse } from "../src/background.js";
 import type { BridgeStatus } from "../src/bridge-client.js";
-import { DEFAULT_BRIDGE_PORT, generateToken, isBridgePort } from "../src/bridge-protocol.js";
+import {
+  CONFIG_DIR_NAME,
+  DEFAULT_BRIDGE_PORT,
+  DEFAULT_TOKEN_FILE_NAME,
+  generateToken,
+  isBridgePort,
+} from "../src/bridge-protocol.js";
 import { BRIDGE_ORIGINS, requestOrigins } from "../src/permissions.js";
 import {
   loadSettings,
@@ -267,11 +273,12 @@ bridgeTokenCopy.addEventListener("click", () => {
 });
 
 bridgeSnippetCopy.addEventListener("click", () => {
-  if (!bridgeToken.value) {
+  const command = bridgeSnippetText(false);
+  if (command === null) {
     flashStatus("No token yet");
     return;
   }
-  void copyText(bridgeSnippetText(false), "Setup command copied");
+  void copyText(command, "Setup command copied");
 });
 
 async function copyText(text: string, okMessage: string): Promise {
@@ -285,18 +292,24 @@ async function copyText(text: string, okMessage: string): Promise {
 }
 
 /**
+ * Null when there is no token yet — one notion of "nothing to install", shared
+ * by the rendered snippet and the copy button. Rendering a runnable command
+ * around a placeholder invited someone to select the `
` by hand and write
+ * that placeholder into the token file, which the button's guard never covered.
+ *
  * @param masked render the token as dots rather than the secret itself. The
  * displayed snippet is masked unless the eye is open; "Copy setup command"
  * always passes `false`, so the clipboard gets a command that actually works.
  */
-function bridgeSnippetText(masked: boolean): string {
-  const real = bridgeToken.value || "";
-  const token = masked && bridgeToken.value ? "•".repeat(24) : real;
+function bridgeSnippetText(masked: boolean): string | null {
+  if (!bridgeToken.value) return null;
+  const token = masked ? "•".repeat(24) : bridgeToken.value;
+  const tokenPath = `"$config_dir/${DEFAULT_TOKEN_FILE_NAME}"`;
   return (
-    'config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton"\n' +
+    `config_dir="\${XDG_CONFIG_HOME:-$HOME/.config}/${CONFIG_DIR_NAME}"\n` +
     'mkdir -p "$config_dir" && chmod 700 "$config_dir" &&\n' +
-    `(umask 077; printf '%s\\n' ${shellQuote(token)} > "$config_dir/token") && ` +
-    'chmod 600 "$config_dir/token"'
+    `(umask 077; printf '%s\\n' ${shellQuote(token)} > ${tokenPath}) && ` +
+    `chmod 600 ${tokenPath}`
   );
 }
 
@@ -305,13 +318,17 @@ function shellQuote(value: string): string {
   return `'${value.replaceAll("'", `'"'"'`)}'`;
 }
 
+const GULLET_LAUNCH_COMMAND = "bunx tabglutton-gullet";
+
 function updateBridgeSnippet(): void {
   const code = bridgeSnippet.querySelector("code");
-  if (code) code.textContent = bridgeSnippetText(!tokenRevealed());
-  bridgeLaunchCommand.textContent =
-    selectedBridgePortMode() === "fixed"
-      ? `bunx tabglutton-gullet --port ${parsePort(bridgePort.value)}`
-      : "bunx tabglutton-gullet";
+  if (code) {
+    code.textContent =
+      bridgeSnippetText(!tokenRevealed()) ??
+      "Generate or paste a token above to get the setup command.";
+  }
+  const port = selectedBridgePortMode() === "fixed" ? ` --port ${parsePort(bridgePort.value)}` : "";
+  bridgeLaunchCommand.textContent = `${GULLET_LAUNCH_COMMAND}${port}`;
 }
 
 const BRIDGE_STATUS_LABELS: Record = {
diff --git a/src/bridge-protocol.ts b/src/bridge-protocol.ts
index 1f2a6d1..1be6cad 100644
--- a/src/bridge-protocol.ts
+++ b/src/bridge-protocol.ts
@@ -62,6 +62,16 @@ export const DEFAULT_BRIDGE_PORT = 4589;
  */
 export const BRIDGE_PORT_CANDIDATES = [4589, 20317, 17483, 27613, 24193] as const;
 
+/**
+ * Where Gullet keeps its global settings, under `$XDG_CONFIG_HOME` (or
+ * `~/.config`). Shared because the options page renders the setup command that
+ * *creates* this file while `gullet/src/config.ts` is what reads it — the two
+ * must agree exactly, and renaming either one otherwise typechecks cleanly
+ * while silently breaking the only documented way to install a token.
+ */
+export const CONFIG_DIR_NAME = "tabglutton";
+export const DEFAULT_TOKEN_FILE_NAME = "token";
+
 /**
  * Marker Gullet returns on any non-upgrade request, so a probe can tell "the
  * sidecar is here" from "something else owns this port". Both are HTTP