From 8759aa7fb807f3f824714a6badf02aea72e95f48 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 12:58:04 -0700 Subject: [PATCH 1/4] feat: automatically discover local T3 and manage credentials --- README.md | 12 +- docs/agent-setup.md | 45 +----- docs/compatibility.md | 6 + docs/setup.md | 80 +++++----- scripts/prove-t3.mjs | 24 ++- src/cli.ts | 3 +- src/config.ts | 13 +- src/mcp.ts | 4 +- src/service.ts | 22 ++- src/setup.ts | 304 ++++++++++++++++++++++++++++++++++++ src/t3.ts | 2 + tests/fixtures/local-t3.mjs | 50 ++++++ tests/setup.test.ts | 215 +++++++++++++++++++++++++ 13 files changed, 667 insertions(+), 113 deletions(-) create mode 100644 src/setup.ts create mode 100644 tests/fixtures/local-t3.mjs create mode 100644 tests/setup.test.ts diff --git a/README.md b/README.md index 9a68ebb..73584b3 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Copy this into your coding agent: ```text Set up t3poll from https://github.com/eimexdev/t3poll. -Use an existing checkout if available; otherwise clone into ~/code/t3poll. +Use an existing checkout if available; otherwise choose an appropriate local installation folder. Read docs/agent-setup.md in that checkout and follow it. -Find my local T3 connection settings where possible and configure MCP. +Configure MCP to launch t3poll; it discovers local T3 and manages its credential. Preserve my running T3 server and ongoing conversations. Verify setup without sending a message to a thread or starting a watch. ``` @@ -21,15 +21,15 @@ The [agent setup guide](docs/agent-setup.md) covers discovery, credentials, conf ## Manual setup -You need Linux, Node.js 24.10+, GitHub CLI signed in, and a compatible T3 server. Setup consists of building this checkout, supplying a T3 address and credential file, and adding one MCP configuration entry. Follow the [manual steps](docs/setup.md). +You need Linux, Node.js 24.10+, GitHub CLI signed in, and a compatible T3 server. Build this checkout and add one MCP configuration entry. The first thread-listing or watch call finds local T3 and creates its credential. Follow the [manual steps](docs/setup.md). -The address identifies your T3 instance; the credential lets t3poll read thread state and deliver notifications. These are one-time settings. Local addresses can often be discovered during setup, but automatic discovery is not built into t3poll yet. A destination thread is selected when registering each watch. +No URL or token settings are needed for a standard local installation. If multiple instances are found, select one with `T3POLL_BASE_DIR`. Choose the destination thread when registering each watch. ## Daily use Ask your agent to watch a PR, list watches, or stop one. It can find destination IDs with `list { "threads": true }`. -The CLI provides the same operations. From the checkout, with your connection variables set: +The CLI provides the same operations. From the checkout, with T3 running: ```sh node dist/cli.js watch https://github.com/owner/repo/pull/123 --thread THREAD_ID @@ -55,7 +55,7 @@ Reconnect the t3poll MCP server or use a new provider session to load the new co If you need to update sooner, record your watch destinations, stop those watches, wait for the worker to exit, then update and register them again. Re-registering starts a fresh baseline, so changes during the gap will not generate notifications. State lives outside the checkout in `~/.local/share/t3poll` by default. -Credentials have their own expiration. The manual example issues a 30-day token; replacing its file renews access without restarting active watches. +Automatically created credentials last 30 days. t3poll replaces them on use during their last day or after expiration, including from the background worker. Explicitly supplied token files remain your responsibility. ## Development diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 03f4634..ca8007d 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -1,43 +1,14 @@ # Agent setup -Use this guide when asked to install or configure t3poll. For an existing installation update, follow [README updates](../README.md#updates). +Use this guide when asked to install or configure t3poll. For updates, follow [README updates](../README.md#updates). -Keep the running T3 server and ongoing conversations intact. Setup does not require restarting T3, interrupting a provider, registering a watch, or sending a test message. Follow the user's session constraints on configuration and credential changes. +Keep the running T3 server and ongoing conversations intact. Setup creates a credential but does not require a watch, a test message, or a T3 restart. -## 1. Locate and build +1. Reuse an existing checkout of `https://github.com/eimexdev/t3poll`, or choose a suitable installation directory. Follow [installation](setup.md#install). Verify Node and authenticated GitHub CLI are available to the MCP process. Build successfully before configuring it. +2. Add the minimal entry from [MCP registration](setup.md#register-mcp) to the Codex configuration home used by T3's provider. Preserve unrelated entries. Use absolute executable/script paths where needed. Existing explicit credentials can remain configured; they stay user-managed. A new standard installation needs no connection variables. +3. Run [verification](setup.md#verify). `list` with `threads=true` automatically discovers T3 and creates a credential. If discovery reports multiple instances, ask the user to select the reported home and set `T3POLL_BASE_DIR`. For unsupported layouts, follow [manual overrides](setup.md#select-an-instance-or-use-manual-credentials). Do not guess the destination conversation. +4. Verify the client exposes `watch`, `list`, and `stop` after a reconnect or new provider session. Preserve ongoing work while it loads. Report the installation directory, changed config file, and verification result. If only the CLI was tested, say that MCP verification is still pending. -Reuse an existing checkout of `https://github.com/eimexdev/t3poll`; otherwise clone into `~/code/t3poll`. Preserve local changes. Check the prerequisites and run the build from [manual setup](setup.md#manual-setup). Verify GitHub authentication with `gh auth status`; ask the user to sign in only if needed. MCP does not require `npm link`. +If saved watches exist, `list` may restart monitoring. Use an isolated `T3POLL_HOME` for testing when restarting those watches is outside the user's requested scope. Such a test creates credentials in the isolated directory and does not configure the production state directory. -Done when `dist/cli.js` exists and Node and authenticated GitHub CLI are available to the intended MCP process. - -## 2. Find the T3 connection - -Reuse an existing t3poll MCP entry when it targets the user's intended server. Otherwise inspect the selected T3 installation's runtime state. Recent T3 source writes `server-runtime.json` beneath its state directory. Common candidates are: - -- `$T3CODE_HOME/userdata/server-runtime.json`, if that home is configured. -- `~/.t3/userdata/server-runtime.json` for the default installation. -- A T3 worktree's `.t3/userdata/server-runtime.json`, or a home with `dev/server-runtime.json`, for development instances. - -Read `origin` and `pid`. Verify the process is alive and belongs to the selected T3 instance; a leftover file or reused PID is not sufficient. For development instances, inspect the matching version's configuration to distinguish the backend origin from the browser URL. Ask the user to choose if multiple instances are plausible. For a remote server, or an installation without runtime state, obtain the URL from its existing configuration or the user. Do not guess the port. - -Set the discovered origin as `T3POLL_URL` in the eventual MCP entry. Discovery is a setup step; t3poll itself currently requires that variable. - -Reuse a suitable credential file if one is already configured. Otherwise follow [credential issuance](setup.md#connect-t3) using the CLI matching that T3 installation and its actual data directory. The documented command targets `userdata`; a development instance using `dev` requires the matching CLI's supported directory selection. Resolve that before issuing a token. Keep token contents out of chat, logs, and Git. Preserve any existing credential until its replacement succeeds. - -Done when the intended origin and an owner-only credential file are identified. Record token expiration for the user. - -## 3. Register MCP - -Use the entry in [manual MCP setup](setup.md#register-mcp), with absolute paths to the built script and credential file. Use an absolute Node path if the provider's PATH differs from the interactive shell. - -Merge only the t3poll entry into the Codex configuration home used by T3's provider. Preserve unrelated settings. If the provider uses a different account, container, or configuration home, configure that environment rather than assuming the interactive shell's defaults. Leave `T3POLL_THREAD_ID` unset unless the user wants one fixed destination for all watches. - -Done when the intended client's configuration points to this checkout and the selected T3 instance. Let the client load it through an MCP reconnect or a new provider session; do not interrupt an ongoing conversation to force a reload. - -## 4. Verify and report - -Run the [verification steps](setup.md#verify) using the same connection settings. If an existing state directory contains active watches, remember that `list` may restart monitoring. Use a temporary `T3POLL_HOME` for connection verification when monitoring is outside the authorized scope, then remove that temporary directory. - -If the client can load MCP now, verify it exposes exactly `watch`, `list`, and `stop`. Otherwise report that MCP verification awaits a reconnect or new session. A successful CLI connection alone does not prove the client loaded MCP. - -Report the checkout path, selected T3 address, changed configuration file, credential path and expiration, and verification result. Never include the token. Explain any remaining user action in one sentence. Setup is complete when the client exposes the tools and a connection check lists T3 threads without sending a message. +Setup is complete when the client exposes the tools and a connection check lists T3 threads without sending a message. Managed credentials remain in t3poll's private state directory and renew on use; never include their contents in the report. diff --git a/docs/compatibility.md b/docs/compatibility.md index 698afc8..e857536 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -43,3 +43,9 @@ This proves the protocol and process flow without spending model tokens. It does ## Steering evidence [Codex 0.153.2's active-turn test](https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/app-server/tests/suite/v2/turn_start.rs) includes `turn_start_steers_active_turn_and_returns_active_turn_id`. T3's `CodexSessionRuntime.sendTurn` forwards normal message commands to that operation. The isolated proof also sends a second update while its scripted provider is running. That verifies T3 forwarding; the upstream Codex test establishes same-turn semantics. + +## Automatic setup + +Tested against an isolated copy of T3 `0.0.41-nightly.20260910.1507` on Linux. The proof starts MCP without URL/token configuration, discovers the instance, creates and verifies its credential, and forces credential renewal from the detached worker before a second delivery. A fresh CLI process then reuses the connection. No running user server or real model is used. + +Unit/process tests additionally cover simultaneous first use across processes, expired credential replacement, failed issuance/verification preserving the token, stale process state, ambiguous instances, and manual credential overrides. Discovery requires Linux `/proc` and an installed T3 `dist/bin.mjs` process with `userdata` runtime state. Other layouts retain the manual connection path. diff --git a/docs/setup.md b/docs/setup.md index 0e79150..4ca936d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,10 @@ # Manual setup -Requires Node.js 24.10 or newer, [GitHub CLI](https://cli.github.com/), and a T3 server with the orchestration HTTP API. Linux is the initial supported platform. +Requires Linux, Node.js 24.10+, [GitHub CLI](https://cli.github.com/) signed in, and a running local T3 installation. Nothing is published to npm yet. + +## Install + +Reuse your checkout, or clone into a directory of your choosing: ```sh git clone https://github.com/eimexdev/t3poll.git @@ -9,67 +13,61 @@ npm ci npm run build ``` -Run `gh auth login` if GitHub CLI is not already signed in. Only GitHub reads are used. +Run `gh auth login` if needed. t3poll only reads GitHub. -## Connect T3 +## Register MCP -Set the T3 server origin and the path to a file containing its bearer token: +Add this entry to the Codex configuration used by T3's provider: -```sh -export T3POLL_URL="http://127.0.0.1:3773" -export T3POLL_TOKEN_FILE="$HOME/.config/t3poll/token" +```toml +[mcp_servers.t3poll] +command = "node" +args = ["/absolute/path/to/t3poll/dist/cli.js", "mcp"] ``` -Use your server's actual port. A local setup agent can often discover it; see [agent setup](agent-setup.md). t3poll currently requires this setting explicitly. Remote origins require HTTPS; a local tunnel can use HTTP on loopback. Tokens stay in the protected file, never in MCP arguments or watch output. +Use an absolute Node path if the provider's PATH differs from your terminal. Load the entry in a new provider session or reconnect MCP. T3 itself does not need restarting. -If you need a token, T3 v0.0.40 can issue one using the following command. Set `T3_BASE_DIR` to the **existing T3 data directory**, the directory containing `userdata`. This command creates a credential in that environment. Its token has administrative scopes in this T3 release, although t3poll only uses orchestration read/operate access. +## Verify + +Call `list` with `threads=true` through MCP, or run from the checkout: ```sh -export T3_BASE_DIR="/path/to/your/existing/t3-home" -mkdir -p "$HOME/.config/t3poll" -chmod 700 "$HOME/.config/t3poll" -umask 077 -t3 auth session issue --base-dir "$T3_BASE_DIR" --label t3poll --ttl 30d --token-only > "$T3POLL_TOKEN_FILE" -chmod 600 "$T3POLL_TOKEN_FILE" +node dist/cli.js list --threads ``` -Use the CLI matching your installed T3 release. If you already have a credential with `orchestration:read` and `orchestration:operate`, use that instead. Replacing the token file rotates credentials for active watches without restarting them. +This discovers T3 and creates a credential if needed, then lists destination threads. It sends no messages. `list` can also restart existing saved watches. The MCP client should expose `watch`, `list`, and `stop`. -## Register MCP +Ask the agent to watch a PR and select its destination thread. Thread selection remains explicit; cwd does not identify a conversation. -Add this to your Codex MCP configuration, using absolute paths. T3's Codex provider must use the same Codex configuration home. Load the configuration in a new provider session, or reconnect MCP if your client supports it. Keep ongoing work intact. +## Automatic connection -```toml -[mcp_servers.t3poll] -command = "node" -args = ["/absolute/path/to/t3poll/dist/cli.js", "mcp"] +Discovery checks `T3CODE_HOME`, or `~/.t3` by default, and `.t3` directories in the current directory and its parents. It reads `userdata/server-runtime.json` and verifies the live process, its owner, installed T3 CLI, and data directory. Stale files are ignored. -[mcp_servers.t3poll.env] -T3POLL_URL = "http://127.0.0.1:3773" -T3POLL_TOKEN_FILE = "/absolute/path/to/.config/t3poll/token" -``` +The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with owner-only permissions under `T3POLL_HOME/credentials`. It replaces managed credentials on use within one day of expiration, or after expiration. MCP and the worker coordinate replacement across processes. Failed replacement preserves the previous token; no other service needs to run. Previous sessions expire naturally. -After the client loads the tools, ask the agent to watch your PR. Tool calls look like this: +This T3 CLI issues administrative scopes. t3poll uses orchestration read/operate access. Manual token files are neither adopted nor renewed automatically. -```text -list { "threads": true } -watch { "pr": "https://github.com/owner/repo/pull/123", "threadId": "" } -list {} -stop { "id": "" } -``` +## Select an instance or use manual credentials -`list` with `threads=true` shows available thread IDs and titles. Select the destination explicitly. Neither MCP nor cwd reliably identifies the current T3 thread. For a dedicated installation you can set `T3POLL_THREAD_ID` to a fixed destination, then omit `threadId` from calls. +For multiple local instances, add the selected home, the directory containing `userdata`: -The MCP call returns after the initial GitHub/T3 checks and worker startup. It does not stay open while monitoring. The worker continues when the MCP client disconnects. A skill is unnecessary because the tool descriptions explain the workflow. +```toml +[mcp_servers.t3poll.env] +T3POLL_BASE_DIR = "/absolute/path/to/t3-home" +``` -## Verify +`T3POLL_URL` can also select a discovered instance by origin. Credentials for different homes/origins are stored separately. Saved watches stay attached to their original origin; a server port change requires registering the watch again. -From the checkout, with the two environment variables above set: +Automatic setup supports installed T3 Node CLI processes with the `userdata` layout. Source runners, the older `dev` layout, and remote connections use explicit settings instead: -```sh -node dist/cli.js list --threads +```toml +[mcp_servers.t3poll.env] +T3POLL_URL = "http://127.0.0.1:3773" +T3POLL_TOKEN_FILE = "/absolute/path/to/token" ``` -This checks the credential and lists available destinations without sending a message. `list` also restarts any saved active watches; on a fresh installation there are none. Verify that the MCP client exposes `watch`, `list`, and `stop` after loading its configuration. +For a standard `userdata` installation, the matching CLI can issue a manual token with `t3 auth session issue --base-dir /path/to/t3-home --label t3poll --ttl 30d --token-only`. Capture stdout directly into an owner-only file, never chat or Git. Other layouts require that version's directory options. Remote origins require HTTPS. + +## Optional terminal command -For a global `t3poll` terminal command, optionally run `npm link`. MCP uses the absolute script path and does not need it. Your terminal needs the connection variables too; the MCP configuration only supplies them to MCP. +Run `npm link` if you want a global `t3poll` command. MCP does not need it. Optional connection overrides must also be set in the terminal when using the CLI. diff --git a/scripts/prove-t3.mjs b/scripts/prove-t3.mjs index 8ab1cae..c6cd791 100644 --- a/scripts/prove-t3.mjs +++ b/scripts/prove-t3.mjs @@ -188,12 +188,16 @@ try { env: { ...env, T3POLL_HOME: pollHome, - T3POLL_URL: origin, - T3POLL_TOKEN_FILE: tokenFile, }, stderr: "pipe", }), ); + const connected = await client.callTool({ + name: "list", + arguments: { threads: true }, + }); + assert.ok(!connected.isError, JSON.stringify(connected)); + assert.ok(connected.structuredContent.threads.some((t) => t.id === threadId)); const registered = await client.callTool({ name: "watch", arguments: { pr: "https://github.com/owner/repo/pull/1", threadId }, @@ -229,6 +233,12 @@ try { ); writeFileSync(phase, "2"); const activeWatch = store.get(id); + // Force renewal inside the detached worker without a setup tool or manual token. + const managedPath = `${activeWatch.tokenFile}.managed.json`; + const oldToken = readFileSync(activeWatch.tokenFile, "utf8"); + const metadata = JSON.parse(readFileSync(managedPath, "utf8")); + metadata.expiresAt = new Date(0).toISOString(); + writeFileSync(managedPath, JSON.stringify(metadata)); activeWatch.nextPoll = 0; activeWatch.nextDelivery = 0; assert.ok(store.save(activeWatch)); @@ -250,6 +260,14 @@ try { .testWasRunning, true, ); + assert.notEqual(readFileSync(activeWatch.tokenFile, "utf8"), oldToken); + // A fresh CLI process uses the saved automatic connection and credential. + const checked = await exec( + node, + [resolve("dist/cli.js"), "list", "--threads"], + { env: { ...env, T3POLL_HOME: pollHome }, timeout: 30000 }, + ); + assert.ok(JSON.parse(checked.stdout).threads.some((t) => t.id === threadId)); // Exact same dispatch must not append another message or invoke another provider turn. const body = { type: "thread.turn.start", @@ -277,7 +295,7 @@ try { store.stop(id); await until(() => !store.worker()); console.log( - "PASS: stock T3 accepted MCP-started background delivery after MCP disconnect, delivered another update during a running scripted Codex turn, completed the turn, and deduplicated an identical command.", + "PASS: automatic discovery, initial credential creation, worker renewal, and CLI reuse succeeded; stock T3 accepted MCP-started background delivery after MCP disconnect, delivered another update during a running scripted Codex turn, completed the turn, and deduplicated an identical command.", ); } catch (error) { console.error(String(error).replaceAll(token || "\0", "[redacted]")); diff --git a/src/cli.ts b/src/cli.ts index 6d16e6b..3d96a37 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,8 @@ const help = `t3poll — watch a GitHub PR and wake a T3 thread t3poll stop Stop a watch Options: --interval (15–3600, default 60) -Environment: T3POLL_URL, T3POLL_TOKEN_FILE, optional T3POLL_THREAD_ID +Local T3 connection and credentials are set up automatically. +Overrides: T3POLL_BASE_DIR, T3POLL_URL, T3POLL_TOKEN_FILE, T3POLL_THREAD_ID State: T3POLL_HOME (default ~/.local/share/t3poll) watch automatically starts a background worker. No skill or service setup required. diff --git a/src/config.ts b/src/config.ts index a9ccb02..6be5470 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ import { readFileSync, statSync } from "node:fs"; export type Config = { home: string; + baseDir?: string; origin?: string; tokenFile?: string; threadId?: string; @@ -19,6 +20,7 @@ export function configFromEnv(env = process.env): Config { ? resolve(env.T3POLL_TOKEN_FILE) : undefined, threadId: env.T3POLL_THREAD_ID, + baseDir: env.T3POLL_BASE_DIR ? resolve(env.T3POLL_BASE_DIR) : undefined, }; } @@ -40,17 +42,6 @@ export function validateOrigin(value: string): string { return url.origin; } -export function connection(config: Config): { - origin: string; - tokenFile: string; -} { - if (!config.origin || !config.tokenFile) - throw new Error( - "Set T3POLL_URL and T3POLL_TOKEN_FILE before watching. See README setup.", - ); - return { origin: config.origin, tokenFile: config.tokenFile }; -} - export function readToken(path: string): string { try { const stat = statSync(path); diff --git a/src/mcp.ts b/src/mcp.ts index 2ce1908..f0f09e4 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -32,7 +32,7 @@ export function createMcp(service: Service): McpServer { "watch", { description: - "Watch a GitHub PR for new comments, reviews, CI results, commits, or closure. Starts a background worker and returns after registration, without holding a tool call open. Updates wake the explicitly selected T3 thread. Use list with threads=true if you need to select a thread; never guess from cwd. Does not act on the PR or replay existing feedback. Watches expire after 24 hours.", + "Automatically connects to local T3 and creates a credential on first use. Watch a GitHub PR for new comments, reviews, CI results, commits, or closure. Starts a background worker and returns after registration, without holding a tool call open. Updates wake the explicitly selected T3 thread. Use list with threads=true if you need to select a thread; never guess from cwd. Does not act on the PR or replay existing feedback. Watches expire after 24 hours.", inputSchema: watchInput.shape, annotations: { readOnlyHint: false, @@ -47,7 +47,7 @@ export function createMcp(service: Service): McpServer { "list", { description: - "List t3poll watches, errors, pending deliveries, and worker status. Restarts a missing worker for saved active watches. Set threads=true to list destination T3 thread IDs and titles.", + "List t3poll watches, errors, pending deliveries, and worker status. Restarts a missing worker for saved active watches. Set threads=true to list destination T3 thread IDs and titles; this automatically discovers local T3 and creates a credential on first use.", inputSchema: { threads: z.boolean().default(false) }, annotations: { readOnlyHint: false, diff --git a/src/service.ts b/src/service.ts index 28c5fd5..1eb1543 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { connection, type Config } from "./config.js"; +import type { Config } from "./config.js"; +import { connection } from "./setup.js"; import { Store } from "./store.js"; import { T3 } from "./t3.js"; import { readGithub } from "./github.js"; @@ -37,7 +38,7 @@ export class Service { throw new Error( "Choose the destination threadId explicitly. Call list with threads=true to see T3 threads.", ); - const { origin, tokenFile } = connection(this.config); + const { origin, tokenFile } = await connection(this.config); const key = JSON.stringify([origin, threadId, pr.toLowerCase()]); let watch = this.store.all().find((watch) => watch.key === key); if (watch && ["watching", "finishing"].includes(watch.status)) { @@ -103,16 +104,13 @@ export class Service { error: error instanceof Error ? error.message : "Worker startup failed.", })); const watches = this.store.all().map(publicWatch); - const threads = includeThreads - ? ( - await new T3( - connection(this.config).origin, - connection(this.config).tokenFile, - ).threads() - ) - .filter((t) => !t.archivedAt && !t.deletedAt) - .map((t) => ({ id: t.id, title: t.title })) - : undefined; + let threads; + if (includeThreads) { + const { origin, tokenFile } = await connection(this.config); + threads = (await new T3(origin, tokenFile).threads()) + .filter((t) => !t.archivedAt && !t.deletedAt) + .map((t) => ({ id: t.id, title: t.title })); + } return { watches, worker, ...(threads ? { threads } : {}) }; } stop(id: string) { diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..e767273 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,304 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + readFileSync, + readlinkSync, + realpathSync, + statSync, + mkdirSync, + chmodSync, + writeFileSync, + renameSync, + rmSync, + existsSync, +} from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { setTimeout as delay } from "node:timers/promises"; +import { z } from "zod"; +import { readToken, validateOrigin, type Config } from "./config.js"; + +const exec = promisify(execFile); +const runtimeSchema = z.object({ + version: z.literal(1), + pid: z.number().int().positive(), + origin: z.string(), + devUrl: z.string().optional(), +}); +const managedSchema = z.object({ + baseDir: z.string(), + origin: z.string(), + expiresAt: z.string().datetime(), + sessionId: z.string(), +}); +type Managed = z.infer; +export type LocalT3 = { + baseDir: string; + origin: string; + node: string; + cli: string; +}; +const renewalWindow = 24 * 60 * 60 * 1000; + +function candidateHomes(config: Config): string[] { + if (config.baseDir) return [config.baseDir]; + const homes = new Set([ + resolve(process.env.T3CODE_HOME ?? join(homedir(), ".t3")), + ]); + for (let dir = process.cwd(); ; dir = dirname(dir)) { + homes.add(join(dir, ".t3")); + if (dir === dirname(dir)) break; + } + return [...homes]; +} + +// Verify the live process and its data directory, not just a possibly stale port file. +export function inspectLocal(baseDir: string): LocalT3 | undefined { + try { + const state = runtimeSchema.parse( + JSON.parse( + readFileSync(join(baseDir, "userdata/server-runtime.json"), "utf8"), + ), + ); + const proc = `/proc/${state.pid}`; + if (statSync(proc).uid !== process.getuid?.()) return; + const args = readFileSync(join(proc, "cmdline"), "utf8").split("\0"); + const env = Object.fromEntries( + readFileSync(join(proc, "environ"), "utf8") + .split("\0") + .filter(Boolean) + .map((pair) => { + const i = pair.indexOf("="); + return [pair.slice(0, i), pair.slice(i + 1)]; + }), + ); + const cwd = readlinkSync(join(proc, "cwd")); + const cli = args[1]; + if (!cli || !cli.endsWith("/dist/bin.mjs") || args.includes("auth")) return; + const pkg = JSON.parse( + readFileSync(join(dirname(cli), "../package.json"), "utf8"), + ); + if (pkg.name !== "t3") return; + const flagIndex = args.indexOf("--base-dir"); + const baseFlag = args + .find((arg) => arg.startsWith("--base-dir=")) + ?.slice(11); + const processHome = + baseFlag ?? + (flagIndex >= 0 ? args[flagIndex + 1] : undefined) ?? + env.T3CODE_HOME ?? + join(env.HOME ?? homedir(), ".t3"); + if (realpathSync(resolve(cwd, processHome)) !== realpathSync(baseDir)) + return; + // Automatic issuance currently targets the released userdata layout only. + const origin = validateOrigin(state.origin); + if (!["localhost", "127.0.0.1", "[::1]"].includes(new URL(origin).hostname)) + return; + return { + baseDir: realpathSync(baseDir), + origin, + node: readlinkSync(join(proc, "exe")), + cli: realpathSync(cli), + }; + } catch { + return; + } +} + +export function discover(config: Config): LocalT3 { + const candidates = new Map(); + for (const home of candidateHomes(config)) { + const server = inspectLocal(home); + if (server && (!config.origin || server.origin === config.origin)) + candidates.set(server.baseDir, server); + } + if (candidates.size === 1) return [...candidates.values()][0]!; + if (!candidates.size) + throw new Error( + "No supported local T3 server found. Start T3, or set T3POLL_BASE_DIR to its home. Development/other layouts can use T3POLL_URL and T3POLL_TOKEN_FILE.", + ); + throw new Error( + `Multiple local T3 servers found. Set T3POLL_BASE_DIR to one of: ${[...candidates.keys()].join(", ")}`, + ); +} + +function atomic(path: string, content: string) { + const temp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(temp, content, { mode: 0o600, flag: "wx" }); + renameSync(temp, path); + } finally { + rmSync(temp, { force: true }); + } +} + +async function withLock( + directory: string, + operation: () => Promise, +): Promise { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const path = join(directory, "setup.sqlite"); + const db = new DatabaseSync(path); + chmodSync(path, 0o600); + db.exec( + "PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS setup_lock (id INTEGER PRIMARY KEY, owner TEXT, expires INTEGER)", + ); + const owner = randomUUID(); + const deadline = Date.now() + 60_000; + try { + while ( + !db + .prepare( + "INSERT INTO setup_lock VALUES (1,?,?) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,expires=excluded.expires WHERE expires < ?", + ) + .run(owner, Date.now() + 120_000, Date.now()).changes + ) { + if (Date.now() > deadline) + throw new Error("T3 setup is already in progress. Retry shortly."); + await delay(100); + } + return await operation(); + } finally { + db.prepare("DELETE FROM setup_lock WHERE owner=?").run(owner); + db.close(); + } +} + +async function verify(origin: string, token: string): Promise { + const response = await fetch(`${origin}/api/orchestration/shell`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error("Credential verification failed."); + } + z.object({ threads: z.array(z.unknown()) }).parse(await response.json()); +} + +async function issue( + server: LocalT3, +): Promise<{ token: string; metadata: Managed }> { + try { + // Pin userdata and clear development settings inherited from an unrelated shell. + const env = { ...process.env }; + delete env.VITE_DEV_SERVER_URL; + env.T3CODE_HOME = server.baseDir; + const { stdout } = await exec( + server.node, + [ + server.cli, + "auth", + "session", + "issue", + "--base-dir", + server.baseDir, + "--label", + "t3poll", + "--ttl", + "30d", + "--json", + ], + { env, timeout: 30_000, maxBuffer: 64 * 1024 }, + ); + const issued = z + .object({ + token: z.string().min(1).regex(/^\S+$/), + sessionId: z.string(), + expiresAt: z.string().datetime(), + }) + .parse(JSON.parse(stdout)); + if (Date.parse(issued.expiresAt) <= Date.now() + renewalWindow) + throw new Error("Invalid lifetime"); + await verify(server.origin, issued.token); + return { + token: issued.token, + metadata: { + baseDir: server.baseDir, + origin: server.origin, + sessionId: issued.sessionId, + expiresAt: issued.expiresAt, + }, + }; + } catch { + throw new Error( + "Could not create and verify a T3 credential. Check the local T3 installation and its auth CLI. Existing credentials were preserved.", + ); + } +} + +async function ensureCredential( + tokenFile: string, + origin: string, + initial?: LocalT3, +): Promise { + await withLock(dirname(tokenFile), async () => { + const metaPath = `${tokenFile}.managed.json`; + let metadata: Managed | undefined; + if (existsSync(metaPath)) + metadata = managedSchema.parse( + JSON.parse(readFileSync(metaPath, "utf8")), + ); + if (metadata && metadata.origin !== origin) + throw new Error("Managed T3 credential belongs to a different server."); + if ( + metadata && + Date.parse(metadata.expiresAt) > Date.now() + renewalWindow + ) { + try { + readToken(tokenFile); + return; + } catch { + /* Replace missing or unreadable managed credentials. */ + } + } + const server = + initial ?? (metadata ? inspectLocal(metadata.baseDir) : undefined); + if (!server || server.origin !== origin) + throw new Error( + "Cannot renew credential: the selected local T3 server is unavailable or its address changed.", + ); + const issued = await issue(server); + atomic(tokenFile, `${issued.token}\n`); + atomic(metaPath, `${JSON.stringify(issued.metadata)}\n`); + }); +} + +// Called by both MCP and detached workers. Explicit credential files remain user-managed. +export async function renewManaged( + tokenFile: string, + origin: string, +): Promise { + if (!existsSync(`${tokenFile}.managed.json`)) return; + const metadata = managedSchema.parse( + JSON.parse(readFileSync(`${tokenFile}.managed.json`, "utf8")), + ); + if (metadata.origin !== origin) + throw new Error("Managed T3 credential belongs to a different server."); + if ( + Date.parse(metadata.expiresAt) > Date.now() + renewalWindow && + existsSync(tokenFile) + ) + return; + await ensureCredential(tokenFile, origin); +} + +export async function connection( + config: Config, +): Promise<{ origin: string; tokenFile: string }> { + if (config.tokenFile) { + const origin = config.origin ?? discover(config).origin; + return { origin, tokenFile: config.tokenFile }; + } + const server = discover(config); + const key = createHash("sha256") + .update(`${server.baseDir}\n${server.origin}`) + .digest("hex") + .slice(0, 24); + const tokenFile = join(config.home, "credentials", key, "token"); + await ensureCredential(tokenFile, server.origin, server); + return { origin: server.origin, tokenFile }; +} diff --git a/src/t3.ts b/src/t3.ts index 56d3063..c391951 100644 --- a/src/t3.ts +++ b/src/t3.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { renewManaged } from "./setup.js"; import { readToken } from "./config.js"; import type { Command } from "./model.js"; @@ -74,6 +75,7 @@ export class T3 { readonly tokenFile: string, ) {} async request(path: string, command?: Command): Promise { + await renewManaged(this.tokenFile, this.origin); const response = await fetch(`${this.origin}${path}`, { method: command ? "POST" : "GET", headers: { diff --git a/tests/fixtures/local-t3.mjs b/tests/fixtures/local-t3.mjs new file mode 100644 index 0000000..4561ee1 --- /dev/null +++ b/tests/fixtures/local-t3.mjs @@ -0,0 +1,50 @@ +// Local process/credential fixture. Copied into a temporary t3 package by setup tests. +import { createServer } from "node:http"; +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + appendFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +const base = process.env.T3CODE_HOME; +if (process.argv.includes("auth")) { + if (existsSync(join(base, "fail"))) { + console.error("secret-that-must-not-escape"); + process.exit(1); + } + const sessionId = randomUUID(); + const token = `test-${sessionId}`; + appendFileSync(join(base, "issued"), `${token}\n`); + console.log( + JSON.stringify({ + token, + sessionId, + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + }), + ); +} else { + mkdirSync(join(base, "userdata"), { recursive: true }); + const server = createServer((req, res) => { + const tokens = existsSync(join(base, "issued")) + ? readFileSync(join(base, "issued"), "utf8").trim().split("\n") + : []; + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!tokens.includes(token) || existsSync(join(base, "reject"))) { + res.writeHead(401).end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ threads: [] })); + }); + server.listen(0, "127.0.0.1", () => { + const origin = `http://127.0.0.1:${server.address().port}`; + writeFileSync( + join(base, "userdata/server-runtime.json"), + JSON.stringify({ version: 1, pid: process.pid, origin }), + ); + console.log(origin); + }); +} diff --git a/tests/setup.test.ts b/tests/setup.test.ts new file mode 100644 index 0000000..7b17904 --- /dev/null +++ b/tests/setup.test.ts @@ -0,0 +1,215 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + copyFileSync, + writeFileSync, + readFileSync, + rmSync, + statSync, + existsSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn, execFile } from "node:child_process"; +import { once } from "node:events"; +import { promisify } from "node:util"; +import { + connection, + discover, + inspectLocal, + renewManaged, +} from "../src/setup.js"; +import { T3 } from "../src/t3.js"; +import { Service } from "../src/service.js"; +const exec = promisify(execFile); +async function fixture(t: { after: (fn: () => Promise) => void }) { + const root = mkdtempSync(join(tmpdir(), "t3poll-setup-")); + const base = join(root, "t3"); + const home = join(root, "poll"); + const pkg = join(root, "package"); + mkdirSync(base); + mkdirSync(join(pkg, "dist"), { recursive: true }); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ name: "t3", type: "module" }), + ); + const cli = join(pkg, "dist/bin.mjs"); + copyFileSync(resolve("tests/fixtures/local-t3.mjs"), cli); + const child = spawn(process.execPath, [cli, "serve"], { + env: { ...process.env, T3CODE_HOME: base }, + stdio: ["ignore", "pipe", "pipe"], + }); + t.after(async () => { + if (child.exitCode === null) { + const stopped = once(child, "exit"); + child.kill(); + await stopped; + } + rmSync(root, { recursive: true, force: true }); + }); + const [output] = await once(child.stdout!, "data"); + const origin = output.toString().trim(); + const config = { home, baseDir: base }; + const issued = () => + existsSync(join(base, "issued")) + ? readFileSync(join(base, "issued"), "utf8").trim().split("\n").length + : 0; + return { root, base, home, origin, config, issued }; +} + +test("first use discovers local T3, creates a private verified credential, and reuses it", async (t) => { + const f = await fixture(t); + assert.equal(discover(f.config).origin, f.origin); + const results = await Promise.all( + Array.from({ length: 4 }, () => connection(f.config)), + ); + assert.equal(f.issued(), 1); + assert.ok(results.every((r) => r.tokenFile === results[0]!.tokenFile)); + assert.equal(statSync(results[0]!.tokenFile).mode & 0o777, 0o600); + assert.deepEqual(await new T3(f.origin, results[0]!.tokenFile).threads(), []); + const service = new Service(f.config); + try { + assert.deepEqual((await service.list(true)).threads, []); + } finally { + service.close(); + } + assert.equal(f.issued(), 1); +}); + +test("separate processes serialize first-time issuance and recover expired credentials on requests", async (t) => { + const f = await fixture(t); + const code = `import {connection} from './dist/setup.js'; await connection(JSON.parse(process.env.TEST_CONFIG));`; + const options = { + cwd: process.cwd(), + env: { ...process.env, TEST_CONFIG: JSON.stringify(f.config) }, + }; + await Promise.all([ + exec(process.execPath, ["--input-type=module", "-e", code], options), + exec(process.execPath, ["--input-type=module", "-e", code], options), + ]); + assert.equal(f.issued(), 1); + const c = await connection(f.config); + const path = `${c.tokenFile}.managed.json`; + const meta = JSON.parse(readFileSync(path, "utf8")); + meta.expiresAt = new Date(0).toISOString(); + writeFileSync(path, JSON.stringify(meta)); + const old = readFileSync(c.tokenFile, "utf8"); + await Promise.all([ + new T3(f.origin, c.tokenFile).threads(), + new T3(f.origin, c.tokenFile).threads(), + ]); + assert.equal(f.issued(), 2); + assert.notEqual(readFileSync(c.tokenFile, "utf8"), old); +}); + +test("failed renewal and failed verification preserve the old credential without leaking CLI output", async (t) => { + const f = await fixture(t); + const c = await connection(f.config); + const path = `${c.tokenFile}.managed.json`; + const meta = JSON.parse(readFileSync(path, "utf8")); + meta.expiresAt = new Date(0).toISOString(); + writeFileSync(path, JSON.stringify(meta)); + const before = readFileSync(c.tokenFile, "utf8"); + writeFileSync(join(f.base, "fail"), ""); + await assert.rejects( + renewManaged(c.tokenFile, f.origin), + (e) => + e instanceof Error && + !e.message.includes("secret-that") && + e.message.includes("preserved"), + ); + assert.equal(readFileSync(c.tokenFile, "utf8"), before); + rmSync(join(f.base, "fail")); + writeFileSync(join(f.base, "reject"), ""); + await assert.rejects(renewManaged(c.tokenFile, f.origin), /preserved/); + assert.equal(readFileSync(c.tokenFile, "utf8"), before); +}); + +test("stale or mismatched process state is rejected; manual configuration never issues credentials", async (t) => { + const f = await fixture(t); + const path = join(f.base, "userdata/server-runtime.json"); + const state = JSON.parse(readFileSync(path, "utf8")); + state.pid = process.pid; + writeFileSync(path, JSON.stringify(state)); + assert.equal(inspectLocal(f.base), undefined); + await assert.rejects(connection(f.config), /No supported local/); + assert.deepEqual( + await connection({ + ...f.config, + origin: f.origin, + tokenFile: "/manual/token", + }), + { origin: f.origin, tokenFile: "/manual/token" }, + ); + await renewManaged("/manual/token", f.origin); + const service = new Service(f.config); + try { + await service.list(); + } finally { + service.close(); + } + assert.equal(f.issued(), 0); +}); + +test("discovery reports ambiguity and accepts a URL selector", async (t) => { + const first = await fixture(t); + const second = await fixture(t); + const original = process.env.T3CODE_HOME; + const cwd = process.cwd(); + const workspace = join(second.root, "workspace"); + mkdirSync(workspace); + // A second independently configured instance appears in an ancestor .t3 directory. + const { symlinkSync } = await import("node:fs"); + symlinkSync(second.base, join(workspace, ".t3")); + process.env.T3CODE_HOME = first.base; + process.chdir(workspace); + try { + assert.throws(() => discover({ home: first.home }), /Multiple local T3/); + assert.equal( + discover({ home: first.home, origin: second.origin }).baseDir, + second.base, + ); + } finally { + process.chdir(cwd); + if (original === undefined) delete process.env.T3CODE_HOME; + else process.env.T3CODE_HOME = original; + } +}); + +test("renewal starts before expiration, repairs a missing token, and recovers an expired setup lock", async (t) => { + const f = await fixture(t); + const c = await connection(f.config); + const path = `${c.tokenFile}.managed.json`; + const meta = JSON.parse(readFileSync(path, "utf8")); + meta.expiresAt = new Date(Date.now() + 3600000).toISOString(); + writeFileSync(path, JSON.stringify(meta)); + const { DatabaseSync } = await import("node:sqlite"); + const { dirname } = await import("node:path"); + const db = new DatabaseSync(join(dirname(c.tokenFile), "setup.sqlite")); + db.prepare("INSERT OR REPLACE INTO setup_lock VALUES(1,?,?)").run( + "crashed-owner", + 0, + ); + db.close(); + await renewManaged(c.tokenFile, f.origin); + assert.equal(f.issued(), 2); + rmSync(c.tokenFile); + await new T3(f.origin, c.tokenFile).threads(); + assert.equal(f.issued(), 3); + assert.equal(statSync(path).mode & 0o777, 0o600); +}); + +test("a runtime file pointing at another T3 data directory cannot mint credentials", async (t) => { + const first = await fixture(t); + const second = await fixture(t); + copyFileSync( + join(first.base, "userdata/server-runtime.json"), + join(second.base, "userdata/server-runtime.json"), + ); + assert.equal(inspectLocal(second.base), undefined); + await assert.rejects(connection(second.config), /No supported local/); + assert.equal(first.issued(), 0); + assert.equal(second.issued(), 0); +}); From c3b8aaccc6543db11dd6d1e1074ebb9871f3be08 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 13:17:49 -0700 Subject: [PATCH 2/4] fix: discover symlinked T3 and preserve valid tokens on renewal failure --- docs/setup.md | 2 +- src/setup.ts | 52 ++++++++++++++++++++++++++++----------------- tests/setup.test.ts | 46 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 4ca936d..269aeab 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -43,7 +43,7 @@ Ask the agent to watch a PR and select its destination thread. Thread selection Discovery checks `T3CODE_HOME`, or `~/.t3` by default, and `.t3` directories in the current directory and its parents. It reads `userdata/server-runtime.json` and verifies the live process, its owner, installed T3 CLI, and data directory. Stale files are ignored. -The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with owner-only permissions under `T3POLL_HOME/credentials`. It replaces managed credentials on use within one day of expiration, or after expiration. MCP and the worker coordinate replacement across processes. Failed replacement preserves the previous token; no other service needs to run. Previous sessions expire naturally. +The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with owner-only permissions under `T3POLL_HOME/credentials`. It replaces managed credentials on use within one day of expiration, or after expiration. MCP and the worker coordinate replacement across processes. Failed replacement preserves the previous token and continues using it until expiration, retrying renewal after five minutes; no other service needs to run. Previous sessions expire naturally. This T3 CLI issues administrative scopes. t3poll uses orchestration read/operate access. Manual token files are neither adopted nor renewed automatically. diff --git a/src/setup.ts b/src/setup.ts index e767273..da5b75b 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -32,6 +32,7 @@ const managedSchema = z.object({ origin: z.string(), expiresAt: z.string().datetime(), sessionId: z.string(), + retryAfter: z.number().optional(), }); type Managed = z.infer; export type LocalT3 = { @@ -41,6 +42,14 @@ export type LocalT3 = { cli: string; }; const renewalWindow = 24 * 60 * 60 * 1000; +function canReuse(metadata: Managed): boolean { + const now = Date.now(); + return ( + Date.parse(metadata.expiresAt) > now && + (Date.parse(metadata.expiresAt) > now + renewalWindow || + (metadata.retryAfter ?? 0) > now) + ); +} function candidateHomes(config: Config): string[] { if (config.baseDir) return [config.baseDir]; @@ -75,8 +84,9 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { }), ); const cwd = readlinkSync(join(proc, "cwd")); - const cli = args[1]; - if (!cli || !cli.endsWith("/dist/bin.mjs") || args.includes("auth")) return; + if (!args[1] || args.includes("auth")) return; + const cli = realpathSync(resolve(cwd, args[1])); + if (!cli.endsWith("/dist/bin.mjs")) return; const pkg = JSON.parse( readFileSync(join(dirname(cli), "../package.json"), "utf8"), ); @@ -244,10 +254,7 @@ async function ensureCredential( ); if (metadata && metadata.origin !== origin) throw new Error("Managed T3 credential belongs to a different server."); - if ( - metadata && - Date.parse(metadata.expiresAt) > Date.now() + renewalWindow - ) { + if (metadata && canReuse(metadata)) { try { readToken(tokenFile); return; @@ -255,15 +262,26 @@ async function ensureCredential( /* Replace missing or unreadable managed credentials. */ } } - const server = - initial ?? (metadata ? inspectLocal(metadata.baseDir) : undefined); - if (!server || server.origin !== origin) - throw new Error( - "Cannot renew credential: the selected local T3 server is unavailable or its address changed.", + try { + const server = + initial ?? (metadata ? inspectLocal(metadata.baseDir) : undefined); + if (!server || server.origin !== origin) + throw new Error( + "Cannot renew credential: the selected local T3 server is unavailable or its address changed.", + ); + const issued = await issue(server); + atomic(tokenFile, `${issued.token}\n`); + atomic(metaPath, `${JSON.stringify(issued.metadata)}\n`); + } catch (error) { + if (!metadata || Date.parse(metadata.expiresAt) <= Date.now()) + throw error; + // Proactive renewal must not interrupt delivery through a still-valid token. + readToken(tokenFile); + atomic( + metaPath, + `${JSON.stringify({ ...metadata, retryAfter: Date.now() + 5 * 60_000 })}\n`, ); - const issued = await issue(server); - atomic(tokenFile, `${issued.token}\n`); - atomic(metaPath, `${JSON.stringify(issued.metadata)}\n`); + } }); } @@ -278,11 +296,7 @@ export async function renewManaged( ); if (metadata.origin !== origin) throw new Error("Managed T3 credential belongs to a different server."); - if ( - Date.parse(metadata.expiresAt) > Date.now() + renewalWindow && - existsSync(tokenFile) - ) - return; + if (canReuse(metadata) && existsSync(tokenFile)) return; await ensureCredential(tokenFile, origin); } diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 7b17904..41c150b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -9,6 +9,7 @@ import { rmSync, statSync, existsSync, + symlinkSync, } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -24,7 +25,10 @@ import { import { T3 } from "../src/t3.js"; import { Service } from "../src/service.js"; const exec = promisify(execFile); -async function fixture(t: { after: (fn: () => Promise) => void }) { +async function fixture( + t: { after: (fn: () => Promise) => void }, + launch: "direct" | "absolute-link" | "relative-link" = "direct", +) { const root = mkdtempSync(join(tmpdir(), "t3poll-setup-")); const base = join(root, "t3"); const home = join(root, "poll"); @@ -37,7 +41,12 @@ async function fixture(t: { after: (fn: () => Promise) => void }) { ); const cli = join(pkg, "dist/bin.mjs"); copyFileSync(resolve("tests/fixtures/local-t3.mjs"), cli); - const child = spawn(process.execPath, [cli, "serve"], { + const link = join(root, "t3-bin"); + symlinkSync(cli, link); + const command = + launch === "direct" ? cli : launch === "absolute-link" ? link : "./t3-bin"; + const child = spawn(process.execPath, [command, "serve"], { + cwd: root, env: { ...process.env, T3CODE_HOME: base }, stdio: ["ignore", "pipe", "pipe"], }); @@ -213,3 +222,36 @@ test("a runtime file pointing at another T3 data directory cannot mint credentia assert.equal(first.issued(), 0); assert.equal(second.issued(), 0); }); + +for (const launch of ["absolute-link", "relative-link"] as const) { + test(`discovery and issuance work through a ${launch}`, async (t) => { + const f = await fixture(t, launch); + const c = await connection(f.config); + assert.equal(c.origin, f.origin); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + assert.equal(f.issued(), 1); + }); +} + +test("proactive renewal failure keeps serving a valid token, backs off, and never bypasses expiration", async (t) => { + const f = await fixture(t); + const c = await connection(f.config); + const path = `${c.tokenFile}.managed.json`; + const metadata = JSON.parse(readFileSync(path, "utf8")); + metadata.expiresAt = new Date(Date.now() + 3600000).toISOString(); + writeFileSync(path, JSON.stringify(metadata)); + const token = readFileSync(c.tokenFile, "utf8"); + writeFileSync(join(f.base, "fail"), ""); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + const failed = JSON.parse(readFileSync(path, "utf8")); + assert.ok(failed.retryAfter > Date.now()); + assert.equal(readFileSync(c.tokenFile, "utf8"), token); + assert.deepEqual(await connection(f.config), c); + assert.equal(readFileSync(path, "utf8"), JSON.stringify(failed) + "\n"); + failed.expiresAt = new Date(0).toISOString(); + writeFileSync(path, JSON.stringify(failed)); + await assert.rejects(new T3(c.origin, c.tokenFile).threads(), /preserved/); + rmSync(join(f.base, "fail")); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + assert.notEqual(readFileSync(c.tokenFile, "utf8"), token); +}); From a2b7cecdf3877ee62a4e25514351144c0acecb44 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 13:20:41 -0700 Subject: [PATCH 3/4] fix: repair invalid managed credentials before requests --- src/setup.ts | 9 ++++++++- tests/setup.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/setup.ts b/src/setup.ts index da5b75b..33e1a1b 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -296,7 +296,14 @@ export async function renewManaged( ); if (metadata.origin !== origin) throw new Error("Managed T3 credential belongs to a different server."); - if (canReuse(metadata) && existsSync(tokenFile)) return; + if (canReuse(metadata)) { + try { + readToken(tokenFile); + return; + } catch { + /* Repair invalid managed files below. */ + } + } await ensureCredential(tokenFile, origin); } diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 41c150b..c85f32a 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -255,3 +255,16 @@ test("proactive renewal failure keeps serving a valid token, backs off, and neve assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); assert.notEqual(readFileSync(c.tokenFile, "utf8"), token); }); + +test("worker requests repair empty and insecure managed token files before expiry", async (t) => { + const f = await fixture(t); + const c = await connection(f.config); + writeFileSync(c.tokenFile, ""); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + assert.equal(f.issued(), 2); + const { chmodSync } = await import("node:fs"); + chmodSync(c.tokenFile, 0o644); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + assert.equal(f.issued(), 3); + assert.equal(statSync(c.tokenFile).mode & 0o777, 0o600); +}); From 975391e15f37a7f6bcc7b87f082ee06f41ab403e Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 13:26:32 -0700 Subject: [PATCH 4/4] fix: clean up credentials that fail verification --- docs/setup.md | 2 +- src/setup.ts | 42 ++++++++++++++++++++++++++++++++----- tests/fixtures/local-t3.mjs | 8 +++++++ tests/setup.test.ts | 32 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 269aeab..089a262 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -43,7 +43,7 @@ Ask the agent to watch a PR and select its destination thread. Thread selection Discovery checks `T3CODE_HOME`, or `~/.t3` by default, and `.t3` directories in the current directory and its parents. It reads `userdata/server-runtime.json` and verifies the live process, its owner, installed T3 CLI, and data directory. Stale files are ignored. -The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with owner-only permissions under `T3POLL_HOME/credentials`. It replaces managed credentials on use within one day of expiration, or after expiration. MCP and the worker coordinate replacement across processes. Failed replacement preserves the previous token and continues using it until expiration, retrying renewal after five minutes; no other service needs to run. Previous sessions expire naturally. +The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with owner-only permissions under `T3POLL_HOME/credentials`. It replaces managed credentials on use within one day of expiration, or after expiration. MCP and the worker coordinate replacement across processes. Failed replacement preserves the previous token and continues using it until expiration, retrying renewal after five minutes; no other service needs to run. Previous successfully used sessions expire naturally. A newly issued session that fails verification is revoked. Failed revocation is recorded and retried before issuing another session. This T3 CLI issues administrative scopes. t3poll uses orchestration read/operate access. Manual token files are neither adopted nor renewed automatically. diff --git a/src/setup.ts b/src/setup.ts index 33e1a1b..977ed77 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -191,12 +191,39 @@ async function verify(origin: string, token: string): Promise { async function issue( server: LocalT3, + tokenFile: string, ): Promise<{ token: string; metadata: Managed }> { + const pendingPath = `${tokenFile}.pending-session.json`; + let sessionId: string | undefined; + // Pin userdata and clear development settings inherited from an unrelated shell. + const env = { ...process.env }; + delete env.VITE_DEV_SERVER_URL; + env.T3CODE_HOME = server.baseDir; + const cleanup = async () => { + if (!sessionId) return; + await exec( + server.node, + [ + server.cli, + "auth", + "session", + "revoke", + sessionId, + "--base-dir", + server.baseDir, + ], + { env, timeout: 30_000, maxBuffer: 64 * 1024 }, + ); + rmSync(pendingPath, { force: true }); + sessionId = undefined; + }; try { - // Pin userdata and clear development settings inherited from an unrelated shell. - const env = { ...process.env }; - delete env.VITE_DEV_SERVER_URL; - env.T3CODE_HOME = server.baseDir; + if (existsSync(pendingPath)) { + sessionId = z + .object({ sessionId: z.string().min(1) }) + .parse(JSON.parse(readFileSync(pendingPath, "utf8"))).sessionId; + await cleanup(); + } const { stdout } = await exec( server.node, [ @@ -221,6 +248,8 @@ async function issue( expiresAt: z.string().datetime(), }) .parse(JSON.parse(stdout)); + sessionId = issued.sessionId; + atomic(pendingPath, JSON.stringify({ sessionId })); if (Date.parse(issued.expiresAt) <= Date.now() + renewalWindow) throw new Error("Invalid lifetime"); await verify(server.origin, issued.token); @@ -234,6 +263,8 @@ async function issue( }, }; } catch { + // Keep the record when revocation fails; the next attempt cleans up before issuing. + await cleanup().catch(() => {}); throw new Error( "Could not create and verify a T3 credential. Check the local T3 installation and its auth CLI. Existing credentials were preserved.", ); @@ -269,9 +300,10 @@ async function ensureCredential( throw new Error( "Cannot renew credential: the selected local T3 server is unavailable or its address changed.", ); - const issued = await issue(server); + const issued = await issue(server, tokenFile); atomic(tokenFile, `${issued.token}\n`); atomic(metaPath, `${JSON.stringify(issued.metadata)}\n`); + rmSync(`${tokenFile}.pending-session.json`, { force: true }); } catch (error) { if (!metadata || Date.parse(metadata.expiresAt) <= Date.now()) throw error; diff --git a/tests/fixtures/local-t3.mjs b/tests/fixtures/local-t3.mjs index 4561ee1..4f27979 100644 --- a/tests/fixtures/local-t3.mjs +++ b/tests/fixtures/local-t3.mjs @@ -15,6 +15,14 @@ if (process.argv.includes("auth")) { console.error("secret-that-must-not-escape"); process.exit(1); } + if (process.argv.includes("revoke")) { + if (existsSync(join(base, "fail-revoke"))) process.exit(1); + appendFileSync( + join(base, "revoked"), + process.argv[process.argv.indexOf("revoke") + 1] + "\n", + ); + process.exit(0); + } const sessionId = randomUUID(); const token = `test-${sessionId}`; appendFileSync(join(base, "issued"), `${token}\n`); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index c85f32a..aff0558 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -268,3 +268,35 @@ test("worker requests repair empty and insecure managed token files before expir assert.equal(f.issued(), 3); assert.equal(statSync(c.tokenFile).mode & 0o777, 0o600); }); + +test("failed verification revokes its session and retries failed cleanup before issuing again", async (t) => { + const f = await fixture(t); + const c = await connection(f.config); + const path = `${c.tokenFile}.managed.json`; + const metadata = JSON.parse(readFileSync(path, "utf8")); + metadata.expiresAt = new Date(0).toISOString(); + writeFileSync(path, JSON.stringify(metadata)); + writeFileSync(join(f.base, "reject"), ""); + await assert.rejects(renewManaged(c.tokenFile, f.origin), /preserved/); + const issuedId = readFileSync(join(f.base, "issued"), "utf8") + .trim() + .split("\n")[1]! + .slice(5); + assert.equal(readFileSync(join(f.base, "revoked"), "utf8").trim(), issuedId); + assert.equal(existsSync(`${c.tokenFile}.pending-session.json`), false); + writeFileSync(join(f.base, "fail-revoke"), ""); + await assert.rejects(renewManaged(c.tokenFile, f.origin), /preserved/); + assert.equal(f.issued(), 3); + assert.ok(existsSync(`${c.tokenFile}.pending-session.json`)); + await assert.rejects(renewManaged(c.tokenFile, f.origin), /preserved/); + assert.equal(f.issued(), 3); + rmSync(join(f.base, "fail-revoke")); + rmSync(join(f.base, "reject")); + await renewManaged(c.tokenFile, f.origin); + assert.equal(f.issued(), 4); + assert.equal( + readFileSync(join(f.base, "revoked"), "utf8").trim().split("\n").length, + 2, + ); + assert.equal(existsSync(`${c.tokenFile}.pending-session.json`), false); +});