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 4632bfc..c5f9903 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -678,9 +678,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..5e6de15 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) { + 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(); await this.waitForSettling(); } @@ -136,7 +160,53 @@ 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 null; + 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 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 null; + } catch (err) { + this.tokenFault = { code: "unauthorized", message: errorMessage(err) }; + return this.tokenFault; + } + } + + /** 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 +232,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 +283,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..b86542c 100644 --- a/gullet/src/config.ts +++ b/gullet/src/config.ts @@ -1,16 +1,35 @@ -// CLI/env parsing for the sidecar. Pure so the precedence rules are testable. +// CLI, environment, and global-file configuration for the sidecar. -import { isBridgePort } from "../../src/bridge-protocol.js"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { + asRecord, + CONFIG_DIR_NAME, + DEFAULT_TOKEN_FILE_NAME, + errorMessage, + 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 +37,120 @@ 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; + token?: string; +} + +interface FileConfig { + port?: string | number; + tokenFile?: string; + tokenCommand?: string; +} + +export 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; +} -export function parseConfig( +/** 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.port ?? firstDefined(env, "TABGLUTTON_PORT", "GULLET_PORT") ?? fileConfig.port; + const selection = parsePort(rawPort); + + // 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 token = firstDefined(parseDotEnv(dotEnv), "TABGLUTTON_TOKEN", "GULLET_TOKEN"); + if (token !== undefined) return assembleConfig(selection, token.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 { - // 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; + return selection === "auto" + ? { portMode: "auto", token, resolveToken } + : { portMode: "fixed", port: selection, token, resolveToken }; +} +function parseFlags(argv: readonly string[]): ParsedFlags { + const parsed: ParsedFlags = {}; 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]); break; case "--token": - token = inline ?? requireValue(flag, argv[++i]); + parsed.token = inline ?? requireValue(flag, argv[++i]); 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 +162,236 @@ 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 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, CONFIG_DIR_NAME); + return { + configFile: join(directory, "config.json"), + defaultTokenFile: join(directory, DEFAULT_TOKEN_FILE_NAME), + 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}: ${errorMessage(err)}`); + } + const parsedConfig = asRecord(parsed); + if (!parsedConfig) throw new ConfigError(`${path} must contain a JSON object.`); + if (Object.hasOwn(parsedConfig, "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(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 (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 = 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.`); + } + 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}: ${errorMessage(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}: ${errorMessage(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, + }; +} + +export 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 completed = Promise.all([subprocess.exited, stdout, stderr]).then( + ([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr, timedOut: false as const }), + ); + const outcome = await Promise.race([ + completed, + new Promise<{ timedOut: true }>((resolve) => { + timer = setTimeout(() => resolve({ timedOut: true }), options.timeoutMs); + }), + ]); + 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"); + } + const drained = await completed; + return { ...drained, exitCode: -1, timedOut: true }; +} + +function isNotFound(err: unknown): boolean { + return asRecord(err)?.code === "ENOENT"; +} diff --git a/gullet/src/main.ts b/gullet/src/main.ts index 99bf418..5bc03b7 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 { createObsidianVaultLookup } from "./obsidian-vaults.js"; import { createToolCaller, GULLET_INSTRUCTIONS, GULLET_TOOLS } from "./tools.js"; @@ -22,7 +22,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; @@ -35,6 +35,7 @@ export async function main( const backend = new Supervisor({ ...(config.portMode === "fixed" ? { port: config.port } : {}), token: config.token, + resolveToken: config.resolveToken, }); // Losing the port is no longer a failure. Whoever binds it serves the browser @@ -53,15 +54,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); @@ -84,7 +76,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(), knownObsidianVaults, 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 53b6f7b..0501e41 100644 --- a/gullet/src/tools.ts +++ b/gullet/src/tools.ts @@ -20,7 +20,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 type { ObsidianVaultLookup } from "./obsidian-vaults.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..c062968 100644 --- a/gullet/tests/config.test.ts +++ b/gullet/tests/config.test.ts @@ -1,29 +1,83 @@ import { describe, test, expect } from "bun:test"; -import { ConfigError, parseConfig } from "../src/config.js"; +import { + ConfigError, + loadConfig, + runTokenCommand, + TOKEN_COMMAND_TIMEOUT_MS, + type ConfigRuntime, + type GulletConfig, +} from "../src/config.js"; -describe("parseConfig()", () => { - test("defaults to automatic discovery and no token", () => { - expect(parseConfig([], {})).toEqual({ portMode: "auto", token: "" }); +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, + }; +} + +/** + * 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", @@ -32,73 +86,230 @@ 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(); + }); +}); + +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"/); + }); +}); + +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/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..6a24e80 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 - when the connection is live. + 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..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, @@ -43,6 +49,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 @@ -266,7 +273,12 @@ bridgeTokenCopy.addEventListener("click", () => { }); bridgeSnippetCopy.addEventListener("click", () => { - void copyText(bridgeSnippetText(false), "Config copied"); + const command = bridgeSnippetText(false); + if (command === null) { + flashStatus("No token yet"); + return; + } + void copyText(command, "Setup command copied"); }); async function copyText(text: string, okMessage: string): Promise { @@ -280,38 +292,43 @@ 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 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,
+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}/${CONFIG_DIR_NAME}"\n` +
+    'mkdir -p "$config_dir" && chmod 700 "$config_dir" &&\n' +
+    `(umask 077; printf '%s\\n' ${shellQuote(token)} > ${tokenPath}) && ` +
+    `chmod 600 ${tokenPath}`
   );
 }
 
+/** Single-quote arbitrary pasted tokens without giving the shell code to run. */
+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());
+  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/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",
diff --git a/src/bridge-protocol.ts b/src/bridge-protocol.ts
index 3ea3ef6..ca1db89 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