From c25118ba204ed5d014842ba70629860d7cfdc312 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 13:50:34 -0700 Subject: [PATCH] Add macOS T3 discovery and desktop app support --- .github/workflows/ci.yml | 5 +- README.md | 2 +- docs/agent-setup.md | 2 +- docs/compatibility.md | 14 +- docs/setup.md | 17 +- package-lock.json | 319 ++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/prove-t3.mjs | 50 +++++- src/local-process.ts | 132 +++++++++++++++ src/setup.ts | 73 ++++++--- tests/local-process.test.ts | 59 +++++++ tests/setup.test.ts | 5 +- 12 files changed, 637 insertions(+), 42 deletions(-) create mode 100644 src/local-process.ts create mode 100644 tests/local-process.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a71be5..434bcff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,10 @@ permissions: contents: read jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/README.md b/README.md index 73584b3..547c612 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ 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. 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). +You need macOS or 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). 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. diff --git a/docs/agent-setup.md b/docs/agent-setup.md index ca8007d..86374c4 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -4,7 +4,7 @@ Use this guide when asked to install or configure t3poll. For updates, follow [R 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. 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. +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. On macOS, use an absolute Node runtime path and include the GitHub CLI installation directory in the MCP PATH when needed. A running packaged T3 desktop app is supported without installing a separate T3 CLI. 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. diff --git a/docs/compatibility.md b/docs/compatibility.md index e857536..0330539 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -4,7 +4,7 @@ t3poll targets the authenticated orchestration API in stock T3 Code v0.0.40. It Verified September 11, 2026 on Node 24.21.0 and Linux: the stock-release proof passed with T3 0.0.40 and a scripted Codex provider. MCP disconnected before delivery; T3 recorded the notification, invoked the provider, completed the turn, and deduplicated a repeated command. A separate read-only GitHub smoke check parsed reviews, comments, and checks from a public upstream PR. No live T3 server or real model session was used. -The initial platform is Linux with Node.js 24.10+. The implementation uses Node's built-in SQLite, the MCP SDK, and Zod. It does not require Effect or a database server. MCP uses ordinary stdio tools; Tasks and unsolicited MCP wakeups are not dependencies. +Supported platforms are macOS and Linux with Node.js 24.10+. The implementation uses Node's built-in SQLite, the MCP SDK, and Zod. It does not require Effect or a database server. MCP uses ordinary stdio tools; Tasks and unsolicited MCP wakeups are not dependencies. ## Boundaries @@ -48,4 +48,14 @@ This proves the protocol and process flow without spending model tokens. It does 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. +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 supports Linux `/proc` and macOS native process inspection for installed T3 `dist/bin.mjs` processes with `userdata` runtime state. Packaged macOS desktop apps use their bundled Electron runtime for credential issuance and renewal. Other layouts retain the manual connection path. + +## macOS verification + +Verified September 11, 2026 on Apple Silicon with Node 24.21.0 and T3 Code Nightly `0.0.41-nightly.20260910.1507`. All 30 automated tests pass, including process discovery, paths containing spaces, symlinks, credential renewal, and worker survival after MCP exit. CI runs the suite on both Ubuntu and macOS. Intel Macs have not been tested locally. + +A read-only check of a running desktop installation discovered its server without connection overrides, issued a managed credential, and listed 142 threads through both the service and a real stdio MCP client. The MCP client exposed `watch`, `list`, and `stop`. The live installation had no watches, and no messages or watches were created there. + +The isolated stock proof passed against the same packaged app. It verified initial credential issuance, renewal from the detached worker, delivery after MCP disconnected, another delivery during a running scripted provider turn, turn completion, and command deduplication. No model calls were made. The proof pins the scripted provider's executable and environment because desktop startup can replace the inherited PATH. + +The stock proof also accepts a packaged macOS app. Use the app's executable as `T3POLL_TEST_T3_RUNTIME` and its `Contents/Resources/app.asar/apps/server/dist/bin.mjs` as `T3POLL_TEST_T3_BIN`. The proof selects only its disposable T3 home, so another running installation cannot be selected by accident. diff --git a/docs/setup.md b/docs/setup.md index 089a262..75a014d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,6 @@ # Manual setup -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. +Requires macOS or 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 @@ -25,7 +25,14 @@ command = "node" args = ["/absolute/path/to/t3poll/dist/cli.js", "mcp"] ``` -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. +Use an absolute Node path if the provider's PATH differs from your terminal. This is common with macOS desktop apps and Node version managers. `node -p process.execPath` prints the runtime path. GitHub CLI must also be on the MCP process's PATH. For Homebrew on Apple Silicon, an example is: + +```toml +[mcp_servers.t3poll.env] +PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +``` + +Merge optional settings into this same environment table. Load the entry in a new provider session or reconnect MCP. T3 itself does not need restarting. ## Verify @@ -41,7 +48,9 @@ Ask the agent to watch a PR and select its destination thread. Thread selection ## Automatic connection -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. +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. Linux uses `/proc`. macOS reads exact process arguments through native system APIs, using the bundled Koffi dependency, and uses the system `ps` and `lsof` commands for ownership and file checks. Paths containing spaces and symlinked installations are supported. + +On macOS, the packaged T3 Code desktop app is also supported. t3poll locates its bundled server and runs its auth CLI through Electron in Node mode. It checks that the server has the selected home's `userdata/state.sqlite` open, since desktop bootstrap can pass the home through a pipe. No separate global `t3` installation is needed. 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 successfully used sessions expire naturally. A newly issued session that fails verification is revoked. Failed revocation is recorded and retried before issuing another session. @@ -58,7 +67,7 @@ T3POLL_BASE_DIR = "/absolute/path/to/t3-home" `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. -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: +Automatic setup supports installed T3 Node CLI processes on macOS and Linux, and packaged macOS T3 desktop apps, with the `userdata` layout. Source runners, the older `dev` layout, and remote connections use explicit settings instead: ```toml [mcp_servers.t3poll.env] diff --git a/package-lock.json b/package-lock.json index 9a8ddad..1b3cfb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", + "koffi": "3.2.1", "zod": "4.6.2" }, "bin": { @@ -479,6 +480,294 @@ "hono": "^4" } }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.2.1.tgz", + "integrity": "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.2.1.tgz", + "integrity": "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.2.1.tgz", + "integrity": "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.2.1.tgz", + "integrity": "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.2.1.tgz", + "integrity": "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.2.1.tgz", + "integrity": "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.2.1.tgz", + "integrity": "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.2.1.tgz", + "integrity": "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.2.1.tgz", + "integrity": "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.2.1.tgz", + "integrity": "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.2.1.tgz", + "integrity": "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.2.1.tgz", + "integrity": "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.2.1.tgz", + "integrity": "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.2.1.tgz", + "integrity": "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.2.1.tgz", + "integrity": "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.2.1.tgz", + "integrity": "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.2.1.tgz", + "integrity": "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.2.1.tgz", + "integrity": "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -1546,6 +1835,36 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/koffi": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.2.1.tgz", + "integrity": "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.2.1", + "@koromix/koffi-android-x64": "3.2.1", + "@koromix/koffi-darwin-arm64": "3.2.1", + "@koromix/koffi-darwin-x64": "3.2.1", + "@koromix/koffi-freebsd-arm64": "3.2.1", + "@koromix/koffi-freebsd-ia32": "3.2.1", + "@koromix/koffi-freebsd-x64": "3.2.1", + "@koromix/koffi-linux-arm": "3.2.1", + "@koromix/koffi-linux-arm64": "3.2.1", + "@koromix/koffi-linux-ia32": "3.2.1", + "@koromix/koffi-linux-loong64": "3.2.1", + "@koromix/koffi-linux-riscv64": "3.2.1", + "@koromix/koffi-linux-x64": "3.2.1", + "@koromix/koffi-openbsd-ia32": "3.2.1", + "@koromix/koffi-openbsd-x64": "3.2.1", + "@koromix/koffi-win32-arm64": "3.2.1", + "@koromix/koffi-win32-ia32": "3.2.1", + "@koromix/koffi-win32-x64": "3.2.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 3e29625..c7225ca 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", + "koffi": "3.2.1", "zod": "4.6.2" }, "devDependencies": { diff --git a/scripts/prove-t3.mjs b/scripts/prove-t3.mjs index c6cd791..0603731 100644 --- a/scripts/prove-t3.mjs +++ b/scripts/prove-t3.mjs @@ -37,8 +37,15 @@ const work = join(root, "repo"); const bin = join(root, "bin"); for (const dir of [base, home, work, bin]) mkdirSync(dir, { mode: 0o700 }); const node = process.execPath; +const runtime = process.env.T3POLL_TEST_T3_RUNTIME ?? node; const providerLog = join(root, "provider.jsonl"); -copyFileSync(resolve("tests/fixtures/codex.mjs"), join(bin, "codex")); +copyFileSync(resolve("tests/fixtures/codex.mjs"), join(bin, "codex.mjs")); +const quote = (value) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; +const providerErrors = join(root, "provider-errors.log"); +writeFileSync( + join(bin, "codex"), + `#!/bin/sh\nexec ${quote(node)} ${quote(join(bin, "codex.mjs"))} "$@" 2>>${quote(providerErrors)}\n`, +); chmodSync(join(bin, "codex"), 0o700); const phase = join(root, "phase"); writeFileSync(phase, "0"); @@ -64,6 +71,29 @@ const env = { T3POLL_TEST_TURN_DELAY_MS: "5000", LANG: "C.UTF-8", }; +mkdirSync(join(base, "userdata"), { recursive: true }); +writeFileSync( + join(base, "userdata/settings.json"), + JSON.stringify({ + providers: { + codex: { binaryPath: join(bin, "codex"), homePath: env.CODEX_HOME }, + }, + providerInstances: { + codex: { + driver: "codex", + config: { binaryPath: join(bin, "codex"), homePath: env.CODEX_HOME }, + environment: Object.entries(env).map(([name, value]) => ({ + name, + value, + })), + }, + }, + }), +); +const serverEnv = { + ...env, + ...(runtime !== node ? { ELECTRON_RUN_AS_NODE: "1" } : {}), +}; await exec("git", ["init", work], { env }); const reservation = createServer(); await new Promise((r) => reservation.listen(0, "127.0.0.1", r)); @@ -87,7 +117,7 @@ async function until(check, timeout = 30000) { } try { const issued = await exec( - node, + runtime, [ binary, "auth", @@ -99,13 +129,13 @@ try { "1h", "--token-only", ], - { env, cwd: work, timeout: 30000 }, + { env: serverEnv, cwd: work, timeout: 30000 }, ); token = issued.stdout.trim(); const tokenFile = join(root, "token"); writeFileSync(tokenFile, token, { mode: 0o600 }); server = spawn( - node, + runtime, [ binary, "--base-dir", @@ -118,7 +148,7 @@ try { "--auto-bootstrap-project-from-cwd", work, ], - { env, cwd: work, stdio: ["ignore", log, log] }, + { env: serverEnv, cwd: work, stdio: ["ignore", log, log] }, ); closeSync(log); const request = async (path, body) => { @@ -188,7 +218,9 @@ try { env: { ...env, T3POLL_HOME: pollHome, + T3POLL_BASE_DIR: base, }, + cwd: work, stderr: "pipe", }), ); @@ -265,7 +297,11 @@ try { const checked = await exec( node, [resolve("dist/cli.js"), "list", "--threads"], - { env: { ...env, T3POLL_HOME: pollHome }, timeout: 30000 }, + { + env: { ...env, T3POLL_HOME: pollHome, T3POLL_BASE_DIR: base }, + cwd: work, + 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. @@ -310,6 +346,8 @@ try { .replaceAll(token || "\0", "[redacted]") .replace(/token=[^\s]+/g, "token=[redacted]"), ); + if (existsSync(providerErrors)) + console.error(readFileSync(providerErrors, "utf8").slice(-4000)); process.exitCode = 1; } finally { await client?.close(); diff --git a/src/local-process.ts b/src/local-process.ts new file mode 100644 index 0000000..cd017bd --- /dev/null +++ b/src/local-process.ts @@ -0,0 +1,132 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, readlinkSync, statSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; + +type LocalProcess = { + args: string[]; + env: Record; + cwd: string; + executable: string; +}; + +function environment(entries: string[]): Record { + return Object.fromEntries( + entries + .filter((s) => s.includes("=")) + .map((s) => { + const i = s.indexOf("="); + return [s.slice(0, i), s.slice(i + 1)]; + }), + ); +} + +// KERN_PROCARGS2 contains argc, executable, padding, argv, then environ. +// Keep the NUL boundaries: ps command text cannot distinguish spaces in paths. +export function parseProcArgs( + buffer: Buffer, +): Pick { + const argc = buffer.readInt32LE(0); + if (argc < 1 || argc > buffer.length) + throw new Error("Invalid process arguments"); + let offset = buffer.indexOf(0, 4); + if (offset < 0) throw new Error("Invalid process executable"); + while (buffer[offset] === 0) offset++; + const args: string[] = []; + for (let i = 0; i < argc; i++) { + const end = buffer.indexOf(0, offset); + if (end < 0) throw new Error("Truncated process arguments"); + args.push(buffer.toString("utf8", offset, end)); + offset = end + 1; + } + return { + args, + env: environment(buffer.toString("utf8", offset).split("\0")), + }; +} + +function lsof(pid: number, descriptors?: string): string[] { + return execFileSync( + "/usr/sbin/lsof", + [ + "-a", + "-p", + String(pid), + ...(descriptors ? ["-d", descriptors] : []), + "-F0n", + ], + { encoding: "utf8", timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, + ) + .split("\0") + .map((s) => s.replace(/^\n/, "")) + .filter((s) => s.startsWith("n")) + .map((s) => s.slice(1)); +} + +export function hasOpenFile(pid: number, path: string): boolean { + const expected = realpathSync(path); + return lsof(pid).some((file) => { + try { + return realpathSync(file) === expected; + } catch { + return false; + } + }); +} + +let darwin: + | ((pid: number) => Pick) + | undefined; +function readDarwin(pid: number) { + if (!darwin) { + // Loaded only on macOS; Linux retains its /proc implementation. + const koffi = createRequire(import.meta.url)( + "koffi", + ) as typeof import("koffi"); + const lib = koffi.load("/usr/lib/libSystem.B.dylib"); + const sysctl = lib.func( + "int sysctl(int *name, unsigned int namelen, void *oldp, _Inout_ size_t *oldlenp, void *newp, size_t newlen)", + ); + const pidpath = lib.func( + "int proc_pidpath(int pid, void *buffer, unsigned int buffersize)", + ); + darwin = (processId) => { + const buffer = Buffer.alloc(1024 * 1024); + const size = [buffer.length]; + if (sysctl([1, 49, processId], 3, buffer, size, null, 0) !== 0) + throw new Error("Cannot inspect process arguments"); + const executable = Buffer.alloc(4096); + if (pidpath(processId, executable, executable.length) <= 0) + throw new Error("Cannot inspect process executable"); + return { + ...parseProcArgs(buffer.subarray(0, Number(size[0]))), + executable: executable.toString("utf8", 0, executable.indexOf(0)), + }; + }; + } + return darwin(pid); +} + +export function readLocalProcess(pid: number): LocalProcess { + if (process.platform === "linux") { + const proc = `/proc/${pid}`; + if (statSync(proc).uid !== process.getuid?.()) + throw new Error("Different process owner"); + return { + args: readFileSync(`${proc}/cmdline`, "utf8").split("\0"), + env: environment(readFileSync(`${proc}/environ`, "utf8").split("\0")), + cwd: readlinkSync(`${proc}/cwd`), + executable: readlinkSync(`${proc}/exe`), + }; + } + if (process.platform !== "darwin") throw new Error("Unsupported platform"); + const uid = execFileSync("/bin/ps", ["-p", String(pid), "-o", "uid="], { + encoding: "utf8", + timeout: 5000, + }).trim(); + if (!uid || Number(uid) !== process.getuid?.()) + throw new Error("Different process owner"); + const details = readDarwin(pid); + const cwd = lsof(pid, "cwd")[0]; + if (!cwd) throw new Error("Cannot inspect process directory"); + return { ...details, cwd }; +} diff --git a/src/setup.ts b/src/setup.ts index 977ed77..d4247b6 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,13 +1,11 @@ import { createHash, randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; +import { execFile, execFileSync } 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, @@ -19,6 +17,7 @@ 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"; +import { readLocalProcess, hasOpenFile } from "./local-process.js"; const exec = promisify(execFile); const runtimeSchema = z.object({ @@ -40,6 +39,7 @@ export type LocalT3 = { origin: string; node: string; cli: string; + electron?: boolean; }; const renewalWindow = 24 * 60 * 60 * 1000; function canReuse(metadata: Managed): boolean { @@ -71,26 +71,43 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { 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 { args, env, cwd, executable } = readLocalProcess(state.pid); 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"), - ); - if (pkg.name !== "t3") return; + let cli: string; + const electron = + process.platform === "darwin" && + executable.includes(".app/Contents/MacOS/"); + if (electron) { + const contents = dirname(dirname(executable)); + const archive = join(contents, "Resources/app.asar"); + cli = join(archive, "apps/server/dist/bin.mjs"); + if (args[1] !== cli || env.ELECTRON_RUN_AS_NODE !== "1") return; + const name = execFileSync( + executable, + [ + "-e", + "process.stdout.write(require(process.argv[1]).name)", + join(archive, "package.json"), + ], + { + encoding: "utf8", + timeout: 5000, + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + }, + ); + if (name !== "t3code") return; + // Desktop bootstrap passes its home through a private pipe, not argv/env. + // Verify the selected database is actually open in this server process. + if (!hasOpenFile(state.pid, join(baseDir, "userdata/state.sqlite"))) + return; + } else { + cli = realpathSync(resolve(cwd, args[1])); + if (!cli.endsWith("/dist/bin.mjs")) 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=")) @@ -100,7 +117,10 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { (flagIndex >= 0 ? args[flagIndex + 1] : undefined) ?? env.T3CODE_HOME ?? join(env.HOME ?? homedir(), ".t3"); - if (realpathSync(resolve(cwd, processHome)) !== realpathSync(baseDir)) + if ( + !electron && + realpathSync(resolve(cwd, processHome)) !== realpathSync(baseDir) + ) return; // Automatic issuance currently targets the released userdata layout only. const origin = validateOrigin(state.origin); @@ -109,8 +129,9 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { return { baseDir: realpathSync(baseDir), origin, - node: readlinkSync(join(proc, "exe")), - cli: realpathSync(cli), + node: executable, + cli, + ...(electron ? { electron: true } : {}), }; } catch { return; @@ -198,6 +219,8 @@ async function issue( // Pin userdata and clear development settings inherited from an unrelated shell. const env = { ...process.env }; delete env.VITE_DEV_SERVER_URL; + delete env.ELECTRON_RUN_AS_NODE; + if (server.electron) env.ELECTRON_RUN_AS_NODE = "1"; env.T3CODE_HOME = server.baseDir; const cleanup = async () => { if (!sessionId) return; diff --git a/tests/local-process.test.ts b/tests/local-process.test.ts new file mode 100644 index 0000000..f1f869e --- /dev/null +++ b/tests/local-process.test.ts @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { realpathSync } from "node:fs"; +import { parseProcArgs, readLocalProcess } from "../src/local-process.js"; + +test("macOS argument decoding preserves spaces, empty arguments, and environment equals signs", () => { + const argc = Buffer.alloc(4); + argc.writeInt32LE(4); + const decoded = parseProcArgs( + Buffer.concat([ + argc, + Buffer.from( + "/Application With Spaces/runtime\0\0\0node\0/path with spaces/bin.mjs\0\0--base-dir=/home with spaces\0T3CODE_HOME=/home with spaces\0VALUE=a=b\0\0", + ), + ]), + ); + assert.deepEqual(decoded.args, [ + "node", + "/path with spaces/bin.mjs", + "", + "--base-dir=/home with spaces", + ]); + assert.deepEqual(decoded.env, { + T3CODE_HOME: "/home with spaces", + VALUE: "a=b", + }); + assert.throws(() => parseProcArgs(Buffer.from([1, 0, 0, 0, 65]))); +}); + +test("live process inspection preserves argument boundaries and selected environment values", async () => { + const args = [ + "-e", + "console.log('ready'); setInterval(() => {}, 1000)", + "space in argument", + "", + "a=b", + ]; + const child = spawn(process.execPath, args, { + env: { ...process.env, T3POLL_PROCESS_TEST: "value with spaces=too" }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await once(child.stdout!, "data"); + const inspected = readLocalProcess(child.pid!); + assert.deepEqual(inspected.args.slice(1, args.length + 1), args); + assert.equal(inspected.env.T3POLL_PROCESS_TEST, "value with spaces=too"); + assert.equal(realpathSync(inspected.cwd), realpathSync(process.cwd())); + assert.equal( + realpathSync(inspected.executable), + realpathSync(process.execPath), + ); + } finally { + const stopped = once(child, "exit"); + child.kill(); + await stopped; + } +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index aff0558..7a5c537 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -10,6 +10,7 @@ import { statSync, existsSync, symlinkSync, + realpathSync, } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -29,7 +30,7 @@ async function fixture( t: { after: (fn: () => Promise) => void }, launch: "direct" | "absolute-link" | "relative-link" = "direct", ) { - const root = mkdtempSync(join(tmpdir(), "t3poll-setup-")); + const root = mkdtempSync(join(tmpdir(), "t3poll setup space-")); const base = join(root, "t3"); const home = join(root, "poll"); const pkg = join(root, "package"); @@ -178,7 +179,7 @@ test("discovery reports ambiguity and accepts a URL selector", async (t) => { assert.throws(() => discover({ home: first.home }), /Multiple local T3/); assert.equal( discover({ home: first.home, origin: second.origin }).baseDir, - second.base, + realpathSync(second.base), ); } finally { process.chdir(cwd);