From f13e68ade062dd3efd0dca73eba52c837f9e6be6 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 16:08:44 -0700 Subject: [PATCH] Add native Windows x64 discovery, private credentials, and verification --- .github/workflows/ci.yml | 2 +- .plans/README.md | 1 + .plans/npm-distribution.md | 6 +- .plans/setup-and-release.md | 39 +++++ README.md | 2 +- docs/agent-setup.md | 2 +- docs/compatibility.md | 28 +++- docs/setup.md | 20 ++- scripts/prove-codex.mjs | 151 +++++++++++++++++++ scripts/prove-t3.mjs | 69 +++++++-- src/config.ts | 6 +- src/github.ts | 1 + src/local-process.ts | 6 +- src/private-files.ts | 18 +++ src/setup.ts | 46 +++--- src/store.ts | 6 +- src/windows.ts | 265 ++++++++++++++++++++++++++++++++++ src/worker.ts | 1 + tests/core.test.ts | 5 +- tests/fixtures/executable.mjs | 69 +++++++++ tests/fixtures/permissions.ts | 12 ++ tests/process.test.ts | 11 +- tests/setup.test.ts | 31 ++-- tests/windows.test.ts | 71 +++++++++ 24 files changed, 797 insertions(+), 71 deletions(-) create mode 100644 .plans/setup-and-release.md create mode 100644 scripts/prove-codex.mjs create mode 100644 src/private-files.ts create mode 100644 src/windows.ts create mode 100644 tests/fixtures/executable.mjs create mode 100644 tests/fixtures/permissions.ts create mode 100644 tests/windows.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 434bcff..03a3af8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ jobs: test: strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.plans/README.md b/.plans/README.md index 2fff59d..970b260 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -4,6 +4,7 @@ Handoff from the September 11, 2026 discussion. These are small implementation b ## Remaining work +- [Setup and release work order](setup-and-release.md): Windows validation first, a Codex-in-T3 setup wizard, automatic worker updates, and nightly/stable releases. - [T3-only MCP registration](t3-only-mcp.md): keep the tools out of ordinary Codex sessions through configuration. - [npm distribution](npm-distribution.md): install and launch without a source checkout. Publication was deferred until real-world testing is satisfactory. - [Updates during monitoring](worker-updates.md): load new code while retaining active watches and pending deliveries. diff --git a/.plans/npm-distribution.md b/.plans/npm-distribution.md index d451bb7..7a3230e 100644 --- a/.plans/npm-distribution.md +++ b/.plans/npm-distribution.md @@ -1,6 +1,6 @@ # npm distribution and MCP launch -Status: deferred until real-world testing is satisfactory. Nothing has been published by this plan. +Status: planned after setup and platform verification. Follow the [setup and release work order](setup-and-release.md), including native Windows support and testing. Nothing has been published by this plan. ## Intended experience @@ -12,7 +12,9 @@ npx --yes --prefer-online t3poll@latest mcp This is a proposed invocation. Confirm the available npm package name and publishing account first; use a scope if needed. Follow [T3-only registration](t3-only-mcp.md) when configuring Codex. -`@latest` with an online check picks up releases when MCP launches. It does not update an already-running MCP process or detached worker. An explicit version can provide a fixed installation. See [worker updates](worker-updates.md) for active monitoring. +`@latest` with an online check picks up stable releases when MCP launches. Offer `@nightly` as an explicit opt-in and preserve the selected channel. Ordinary installations should follow their channel rather than pinning the version used for setup. An explicit version can remain a troubleshooting option. + +Publish nightly builds automatically after checks. Trigger stable publication manually from a commit already shipped on nightly, following T3 Code's manual promotion approach. A channel change does not update an already-running MCP process or detached worker. Include [worker updates](worker-updates.md) in the release work so active monitoring survives version handoff. ## Work diff --git a/.plans/setup-and-release.md b/.plans/setup-and-release.md new file mode 100644 index 0000000..952aec4 --- /dev/null +++ b/.plans/setup-and-release.md @@ -0,0 +1,39 @@ +# Setup and release work order + +Status: Windows x64 support is implemented and tested on Gideon; see [verification](../docs/compatibility.md#windows-verification). The wizard, automatic update handoff, and release workflow remain planned from the September 11 setup discussion. + +## Order of work + +1. Establish a native Windows test environment and prove the connection and worker behavior before finalizing the wizard's platform assumptions. Add a Windows CI lane. WSL tests do not establish native Windows support. +2. Build the TypeScript `t3poll setup` wizard for Codex inside T3, reusing discovery and managed credentials. Implement Windows support alongside the shared setup code once the platform approach is proven. +3. Implement and test automatic worker handoff during updates. Preserve active watches and pending deliveries across versions. +4. Test packed installations on Windows, macOS, and Linux, including real T3 and fresh Codex sessions. Windows support is a release requirement. +5. Publish nightly builds automatically after checks, with manually triggered stable releases from a commit already published and tested on nightly. Use npm trusted publishing when configured. + +## Windows proof + +Current process inspection in `src/local-process.ts` explicitly supports Linux and macOS only. Check native T3 CLI and desktop layouts, process identity and ownership, command-line decoding, home discovery, and credential issuance. Retain verification of the selected live instance before issuing credentials. + +Exercise executable resolution, npm command shims, spaces and non-ASCII characters in paths, PowerShell invocation, user-private credential ACLs, SQLite locking, atomic replacement, and detached worker startup and shutdown. Do not assume Unix modes or signal behavior establish Windows correctness. + +Use a real Windows machine or VM for installation and end-to-end checks, plus repeatable Windows CI coverage. Record exactly which T3 distributions and architectures were exercised. + +## Wizard + +Provide instance selection and custom-location fallback, resolve the selected Codex provider configuration, preview changes, preserve unrelated settings, back up changed files, support dry runs, and make reruns repair or update an existing installation without duplicate entries. + +Default to T3-only tool availability. Update both Codex MCP registration and the selected T3 provider's launch arguments, checking environment overrides. Keep each installation's destination separate when multiple T3 instances share Codex configuration. See [T3 scoping](t3-only-mcp.md). + +Default to the stable update channel, with explicit nightly opt-in. Persist the selected channel independently of the exact package version used to run setup. Do not pin ordinary installations to the setup version. An explicit version may remain an advanced troubleshooting option. + +Verification must not send messages or restart existing watches as a side effect. Preserve running T3 conversations and state which checks require a fresh provider session. + +## Updates and release channels + +The MCP launch command should resolve the selected npm channel on startup. A new package does not replace JavaScript already loaded by an MCP process or detached worker. Complete [worker handoff](worker-updates.md) so new code can take over monitoring safely, including when old and new MCP clients coexist. + +Define update timing explicitly: startup-time channel resolution is the baseline; checking for updates during an uninterrupted MCP session is a separate decision. Include failed-download behavior, stored-state compatibility, and channel switching in the design. Avoid older clients or another channel repeatedly replacing the active worker. + +Reference inspected: the local T3 Code checkout's `.github/workflows/release.yml` and `.github/scripts/check-nightly-release.cjs`. Its scheduler checks twice hourly, requires new commits and a six-hour release gap, and publishes nightlies to the `nightly` npm tag. Manual stable releases build the latest published nightly's commit under a stable version. T3 also supports stable tag-push releases. Adopt the tested-commit promotion approach; its exact cadence is not a requirement for t3poll. + +Stable publication must be explicit and must not accidentally advance `latest` from a nightly job. Preserve source-commit traceability and serialize publishers so channel tags cannot move backward due to overlapping jobs. npm distribution tags select published versions; they do not update running processes. diff --git a/README.md b/README.md index 547c612..85b85b7 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 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). +You need Windows x64, 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 86374c4..17f62e2 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. 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. +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. On Windows x64, use the absolute Node executable path and ensure `gh.exe` is on PATH. A running packaged T3 desktop app on Windows x64 or macOS 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 0330539..889e362 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. -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. +Supported platforms are Windows x64, 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,14 +48,36 @@ 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 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. +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`, macOS native process inspection, and Windows x64 native process inspection for installed T3 `dist/bin.mjs` processes with `userdata` runtime state. Packaged Windows x64 and 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. +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 Ubuntu, macOS, and Windows. 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. + +## Windows verification + +Verified September 11, 2026 on Gideon, Windows 11 x64 build 26200, with Node 24.19.0. All 32 tests passed both over SSH and in a non-administrator interactive user context. Linux passes its 30 applicable tests; the two native Windows tests are skipped there. + +The isolated stock proof passed against the npm-installed T3 CLI `0.0.40` and the installed T3 desktop `0.0.41-nightly.20260911.1551`, using disposable homes and a scripted provider. It verified automatic credentials, worker renewal, delivery after MCP disconnection, running-turn steering, and command deduplication. Real Codex CLI `0.154.0` separately verified disabled-by-default MCP tools and the T3 launch override without model calls. The npm tarball installed in a Windows directory containing spaces and non-ASCII characters and passed the npm T3 and real Codex proofs. User T3/Codex configurations and conversations were not changed. + +Windows x64 support uses native process inspection and Windows ACLs through the existing Koffi dependency. Process inspection checks the token owner before reading parameters. It bounds remote reads and rejects inaccessible or unsupported processes. The x64 PEB/process-parameter layout is internal to Windows and may change; failures stop automatic discovery rather than falling back to trusting the runtime file. Windows ARM64 and 32-bit Node are not supported by this implementation. + +Packaged desktop discovery recognizes both `app.asar` and `server.asar`. Windows Restart Manager verifies that the selected process has that home's database open before t3poll invokes its auth CLI. Managed state and credentials receive protected current-user ACLs. Token reads reject grants to other users, while allowing SYSTEM and Administrators. This also works from an elevated account whose files default to Administrators ownership. + +Native test fixtures use .NET console executables rather than Unix shebangs. Their child processes are contained in Windows jobs so test cleanup cannot leave scripted providers running. These fixtures require the Windows .NET Framework C# compiler; the installed t3poll package does not. + +To run the isolated stock proof in PowerShell against a packaged app: + +```powershell +$env:T3POLL_TEST_T3_RUNTIME = "$env:LOCALAPPDATA/Programs/t3code/T3 Code (Nightly).exe" +$env:T3POLL_TEST_T3_BIN = "$env:LOCALAPPDATA/Programs/t3code/resources/server.asar/apps/server/dist/bin.mjs" +node scripts/prove-t3.mjs +``` + +For an npm-installed T3 CLI, set only `T3POLL_TEST_T3_BIN` to its `dist/bin.mjs`. To check real Codex tool scoping without a model call, set `T3POLL_TEST_CODEX_BIN` to the installed native `codex.exe` and run `node scripts/prove-codex.mjs`. This creates a disposable Codex home, verifies no t3poll tools are exposed by default, then verifies `list`, `stop`, and `watch` with `-c mcp_servers.t3poll.enabled=true`. It does not edit the user's Codex or T3 configuration. Both proof scripts accept `T3POLL_TEST_CLI` to exercise a separately installed tarball's `dist/cli.js` instead of the checkout build. diff --git a/docs/setup.md b/docs/setup.md index 75a014d..9f10c47 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,6 @@ # Manual setup -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. +Requires Windows x64, 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 @@ -32,6 +32,16 @@ Use an absolute Node path if the provider's PATH differs from your terminal. Thi PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" ``` +On Windows, use an absolute `node.exe` path and forward slashes in TOML paths, for example: + +```toml +[mcp_servers.t3poll] +command = "C:/Program Files/nodejs/node.exe" +args = ["C:/Users/you/code/t3poll/dist/cli.js", "mcp"] +``` + +`gh.exe` must be on the MCP process's PATH. Native Windows uses the same `~/.t3` home convention, with `~` resolving to your Windows user profile. Windows requires x64 Node; WSL is a separate Linux installation. + 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 @@ -48,11 +58,11 @@ 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. 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. +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. Windows x64 reads the process owner, executable, command line, environment, and working directory through native APIs using Koffi. 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. +On macOS and Windows x64, 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, using Windows Restart Manager on Windows, 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. +The matching T3 CLI issues a 30-day credential. t3poll verifies it before saving it with private permissions under `T3POLL_HOME/credentials`. Windows uses protected ACLs granting the current user access; Unix uses owner-only modes. Existing Windows token files must not grant access to other users, except SYSTEM and Administrators, and must be owned by the current user, SYSTEM, or Administrators. 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. @@ -67,7 +77,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 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: +Automatic setup supports installed T3 Node CLI processes on Windows x64, macOS, and Linux, and packaged Windows x64 and 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/scripts/prove-codex.mjs b/scripts/prove-codex.mjs new file mode 100644 index 0000000..3d040a6 --- /dev/null +++ b/scripts/prove-codex.mjs @@ -0,0 +1,151 @@ +// Verify real Codex MCP scoping without model calls or changes to user configuration. +// T3POLL_TEST_CODEX_BIN=/absolute/path/to/codex[.exe] node scripts/prove-codex.mjs +import { spawn, execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + copyFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import assert from "node:assert/strict"; + +const binary = process.env.T3POLL_TEST_CODEX_BIN; +if (!binary) + throw new Error( + "Set T3POLL_TEST_CODEX_BIN to an installed Codex executable.", + ); +const root = mkdtempSync(join(tmpdir(), "t3poll-codex-space-")); +const base = join(root, "t3"); +const pkg = join(root, "package"); +const home = join(root, "codex"); +for (const directory of [base, home, join(pkg, "dist")]) + mkdirSync(directory, { recursive: true }); +writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ name: "t3", type: "module" }), +); +copyFileSync(resolve("tests/fixtures/local-t3.mjs"), join(pkg, "dist/bin.mjs")); +const server = spawn(process.execPath, [join(pkg, "dist/bin.mjs"), "serve"], { + env: { ...process.env, T3CODE_HOME: base }, + stdio: ["ignore", "pipe", "pipe"], +}); +writeFileSync( + join(home, "config.toml"), + ` +[mcp_servers.t3poll] +enabled = false +command = ${JSON.stringify(process.execPath)} +args = [${JSON.stringify(resolve(process.env.T3POLL_TEST_CLI ?? "dist/cli.js"))}, "mcp"] +[mcp_servers.t3poll.env] +T3POLL_HOME = ${JSON.stringify(join(root, "poll"))} +T3POLL_BASE_DIR = ${JSON.stringify(base)} +`, +); +async function check(enabled) { + const child = spawn( + binary, + [ + "app-server", + ...(enabled ? ["-c", "mcp_servers.t3poll.enabled=true"] : []), + ], + { + env: { ...process.env, CODEX_HOME: home }, + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let errors = ""; + child.stderr.on("data", (chunk) => { + errors = (errors + chunk).slice(-3000); + }); + const lines = createInterface({ input: child.stdout }); + const pending = new Map(); + let id = 0; + lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.id !== undefined) pending.get(message.id)?.(message); + }); + const rpc = (method, params) => + new Promise((resolve, reject) => { + const request = ++id; + const timeout = setTimeout(() => { + pending.delete(request); + reject(new Error(`Codex timed out: ${method}; ${errors}`)); + }, 20000); + pending.set(request, (message) => { + clearTimeout(timeout); + pending.delete(request); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + }); + child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", id: request, method, params }) + "\n", + ); + }); + try { + await rpc("initialize", { + clientInfo: { name: "t3poll-proof", version: "1" }, + capabilities: { experimentalApi: true }, + }); + child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n", + ); + for (let attempt = 0; attempt < 40; attempt++) { + const result = await rpc("mcpServerStatus/list", {}); + const status = result.data.find((item) => item.name === "t3poll"); + if (!enabled) { + assert.deepEqual(Object.keys(status?.tools ?? {}), []); + return; + } + if (status && Object.keys(status.tools).length) { + assert.deepEqual( + Object.values(status.tools) + .map((tool) => tool.name) + .sort(), + ["list", "stop", "watch"], + ); + return; + } + await delay(250); + } + throw new Error(`Codex did not expose t3poll tools; ${errors}`); + } finally { + lines.close(); + if (child.exitCode === null) { + const exit = once(child, "exit"); + if (process.platform === "win32") { + // Stop only this proof's Codex tree, including its MCP children. + await promisify(execFile)( + join(process.env.SystemRoot, "System32", "taskkill.exe"), + ["/pid", String(child.pid), "/t", "/f"], + ); + } else child.kill(); + await exit; + } + } +} +try { + await once(server.stdout, "data"); + await check(false); + await check(true); + console.log( + "PASS: real Codex hides t3poll by default and exposes list/stop/watch with the T3 launch override. No model calls or user configuration changes.", + ); +} finally { + const exit = once(server, "exit"); + server.kill(); + await exit; + rmSync(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); +} diff --git a/scripts/prove-t3.mjs b/scripts/prove-t3.mjs index 0603731..c995ff3 100644 --- a/scripts/prove-t3.mjs +++ b/scripts/prove-t3.mjs @@ -13,7 +13,7 @@ import { rmSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve, dirname } from "node:path"; +import { join, resolve, dirname, delimiter } from "node:path"; import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import { setTimeout as delay } from "node:timers/promises"; @@ -24,6 +24,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { Store } from "../dist/store.js"; +import { executable } from "../tests/fixtures/executable.mjs"; +import { pathToFileURL } from "node:url"; +import { protectFile } from "../dist/private-files.js"; + const exec = promisify(execFile); const binary = process.env.T3POLL_TEST_T3_BIN; if (!binary) @@ -40,17 +44,17 @@ 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.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`, +const providerBinary = executable( + bin, + "codex", + `import(${JSON.stringify(pathToFileURL(join(bin, "codex.mjs")).href)});`, ); -chmodSync(join(bin, "codex"), 0o700); const phase = join(root, "phase"); writeFileSync(phase, "0"); -writeFileSync( - join(bin, "gh"), +executable( + bin, + "gh", `#!${node} const fs=require('node:fs');const path=process.argv.at(-1);let result=[[]]; if (/pulls\\/1$/.test(path)) result={state:'open',merged:false,head:{sha:'a'.repeat(40)}}; @@ -58,10 +62,25 @@ else if(path.includes('/check-runs?')) result=[{check_runs:[]}]; else if(path.includes('/issues/') && fs.readFileSync(${JSON.stringify(phase)},'utf8')>='1') result=[[{id:42,body:'New feedback '+fs.readFileSync(${JSON.stringify(phase)},'utf8'),html_url:'https://github.com/owner/repo/pull/1#issuecomment-42'}]]; process.stdout.write(JSON.stringify(result)); `, - { mode: 0o700 }, ); const env = { - PATH: `${bin}:${dirname(node)}:/usr/bin:/bin`, + ...(process.platform === "win32" + ? { + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + ComSpec: process.env.ComSpec, + TEMP: root, + TMP: root, + USERPROFILE: home, + APPDATA: join(home, "AppData/Roaming"), + LOCALAPPDATA: join(home, "AppData/Local"), + } + : {}), + PATH: [ + bin, + dirname(node), + process.platform === "win32" ? process.env.PATH : "/usr/bin:/bin", + ].join(delimiter), HOME: home, CODEX_HOME: join(home, ".codex"), XDG_CONFIG_HOME: join(home, ".config"), @@ -76,12 +95,12 @@ writeFileSync( join(base, "userdata/settings.json"), JSON.stringify({ providers: { - codex: { binaryPath: join(bin, "codex"), homePath: env.CODEX_HOME }, + codex: { binaryPath: providerBinary, homePath: env.CODEX_HOME }, }, providerInstances: { codex: { driver: "codex", - config: { binaryPath: join(bin, "codex"), homePath: env.CODEX_HOME }, + config: { binaryPath: providerBinary, homePath: env.CODEX_HOME }, environment: Object.entries(env).map(([name, value]) => ({ name, value, @@ -134,6 +153,7 @@ try { token = issued.stdout.trim(); const tokenFile = join(root, "token"); writeFileSync(tokenFile, token, { mode: 0o600 }); + protectFile(tokenFile); server = spawn( runtime, [ @@ -214,7 +234,7 @@ try { await client.connect( new StdioClientTransport({ command: node, - args: [resolve("dist/cli.js"), "mcp"], + args: [resolve(process.env.T3POLL_TEST_CLI ?? "dist/cli.js"), "mcp"], env: { ...env, T3POLL_HOME: pollHome, @@ -296,7 +316,11 @@ try { // A fresh CLI process uses the saved automatic connection and credential. const checked = await exec( node, - [resolve("dist/cli.js"), "list", "--threads"], + [ + resolve(process.env.T3POLL_TEST_CLI ?? "dist/cli.js"), + "list", + "--threads", + ], { env: { ...env, T3POLL_HOME: pollHome, T3POLL_BASE_DIR: base }, cwd: work, @@ -365,10 +389,23 @@ try { } if (server && server.exitCode === null) { const exited = new Promise((r) => server.once("exit", r)); - server.kill("SIGTERM"); + if (process.platform === "win32") { + // Native T3 can own resource-monitor and provider children on Windows. + await exec(join(process.env.SystemRoot, "System32", "taskkill.exe"), [ + "/pid", + String(server.pid), + "/t", + "/f", + ]); + } else server.kill("SIGTERM"); const kill = setTimeout(() => server.kill("SIGKILL"), 10000); await exited; clearTimeout(kill); } - rmSync(root, { recursive: true, force: true }); + rmSync(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); } diff --git a/src/config.ts b/src/config.ts index 6be5470..1c465d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { assertPrivateFile } from "./private-files.js"; import { homedir } from "node:os"; import { resolve, join } from "node:path"; import { readFileSync, statSync } from "node:fs"; @@ -46,14 +47,13 @@ export function readToken(path: string): string { try { const stat = statSync(path); if (!stat.isFile() || stat.size > 16_384) throw new Error("Invalid file"); - if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) - throw new Error("File permissions must be 600"); + assertPrivateFile(path); const token = readFileSync(path, "utf8").trim(); if (!token || /\s/.test(token)) throw new Error("Invalid token"); return token; } catch { throw new Error( - "Cannot read T3 credential. T3POLL_TOKEN_FILE must contain a bearer token in an owner-only file (chmod 600).", + "Cannot read T3 credential. T3POLL_TOKEN_FILE must contain a bearer token in a private file (chmod 600 on Unix; current-user-only ACL on Windows).", ); } } diff --git a/src/github.ts b/src/github.ts index e349a04..6123cd5 100644 --- a/src/github.ts +++ b/src/github.ts @@ -55,6 +55,7 @@ export const ghApi: GhApi = async (path, paginate = false) => { ], { timeout: 30_000, + windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1" }, }, diff --git a/src/local-process.ts b/src/local-process.ts index cd017bd..3e2e4ae 100644 --- a/src/local-process.ts +++ b/src/local-process.ts @@ -1,8 +1,9 @@ import { execFileSync } from "node:child_process"; import { readFileSync, readlinkSync, statSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; +import { windows } from "./windows.js"; -type LocalProcess = { +export type LocalProcess = { args: string[]; env: Record; cwd: string; @@ -63,6 +64,8 @@ function lsof(pid: number, descriptors?: string): string[] { } export function hasOpenFile(pid: number, path: string): boolean { + if (process.platform === "win32") + return windows().hasOpenFile(pid, realpathSync(path)); const expected = realpathSync(path); return lsof(pid).some((file) => { try { @@ -107,6 +110,7 @@ function readDarwin(pid: number) { } export function readLocalProcess(pid: number): LocalProcess { + if (process.platform === "win32") return windows().inspect(pid); if (process.platform === "linux") { const proc = `/proc/${pid}`; if (statSync(proc).uid !== process.getuid?.()) diff --git a/src/private-files.ts b/src/private-files.ts new file mode 100644 index 0000000..d9f53f5 --- /dev/null +++ b/src/private-files.ts @@ -0,0 +1,18 @@ +import { chmodSync, mkdirSync, statSync, realpathSync } from "node:fs"; +import { windows } from "./windows.js"; + +export function protectFile(path: string): void { + if (process.platform === "win32") windows().protect(path); + else chmodSync(path, 0o600); +} + +export function privateDirectory(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") windows().protect(path); +} + +export function assertPrivateFile(path: string): void { + if (process.platform === "win32") windows().assertPrivate(realpathSync(path)); + else if ((statSync(path).mode & 0o077) !== 0) + throw new Error("File permissions must be 600"); +} diff --git a/src/setup.ts b/src/setup.ts index d4247b6..84999cf 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,3 +1,4 @@ +import { privateDirectory, protectFile } from "./private-files.js"; import { createHash, randomUUID } from "node:crypto"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; @@ -6,8 +7,6 @@ import { dirname, join, resolve } from "node:path"; import { readFileSync, realpathSync, - mkdirSync, - chmodSync, writeFileSync, renameSync, rmSync, @@ -74,35 +73,41 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { const { args, env, cwd, executable } = readLocalProcess(state.pid); if (!args[1] || args.includes("auth")) return; let cli: string; + const resources = + process.platform === "darwin" + ? join(dirname(dirname(executable)), "Resources") + : join(dirname(executable), "resources"); + const archive = ["server.asar", "app.asar"] + .map((name) => join(resources, name)) + .find((path) => args[1] === join(path, "apps/server/dist/bin.mjs")); const electron = - process.platform === "darwin" && - executable.includes(".app/Contents/MacOS/"); + (process.platform === "darwin" || process.platform === "win32") && + env.ELECTRON_RUN_AS_NODE === "1" && + archive !== undefined; 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; + cli = join(archive!, "apps/server/dist/bin.mjs"); const name = execFileSync( executable, [ "-e", "process.stdout.write(require(process.argv[1]).name)", - join(archive, "package.json"), + join(archive!, "package.json"), ], { encoding: "utf8", timeout: 5000, + windowsHide: true, 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 (name !== "t3code" && name !== "t3code-server") return; + // Desktop bootstrap may pass its home through a pipe. Verify the live + // process has this home's database open before running its auth CLI. if (!hasOpenFile(state.pid, join(baseDir, "userdata/state.sqlite"))) return; } else { cli = realpathSync(resolve(cwd, args[1])); - if (!cli.endsWith("/dist/bin.mjs")) return; + if (!cli.endsWith(join("dist", "bin.mjs"))) return; const pkg = JSON.parse( readFileSync(join(dirname(cli), "../package.json"), "utf8"), ); @@ -116,7 +121,11 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { baseFlag ?? (flagIndex >= 0 ? args[flagIndex + 1] : undefined) ?? env.T3CODE_HOME ?? - join(env.HOME ?? homedir(), ".t3"); + join( + (process.platform === "win32" ? env.USERPROFILE : env.HOME) ?? + homedir(), + ".t3", + ); if ( !electron && realpathSync(resolve(cwd, processHome)) !== realpathSync(baseDir) @@ -159,6 +168,7 @@ function atomic(path: string, content: string) { const temp = `${path}.${randomUUID()}.tmp`; try { writeFileSync(temp, content, { mode: 0o600, flag: "wx" }); + protectFile(temp); renameSync(temp, path); } finally { rmSync(temp, { force: true }); @@ -169,10 +179,10 @@ async function withLock( directory: string, operation: () => Promise, ): Promise { - mkdirSync(directory, { recursive: true, mode: 0o700 }); + privateDirectory(directory); const path = join(directory, "setup.sqlite"); const db = new DatabaseSync(path); - chmodSync(path, 0o600); + protectFile(path); db.exec( "PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS setup_lock (id INTEGER PRIMARY KEY, owner TEXT, expires INTEGER)", ); @@ -235,7 +245,7 @@ async function issue( "--base-dir", server.baseDir, ], - { env, timeout: 30_000, maxBuffer: 64 * 1024 }, + { env, timeout: 30_000, maxBuffer: 64 * 1024, windowsHide: true }, ); rmSync(pendingPath, { force: true }); sessionId = undefined; @@ -262,7 +272,7 @@ async function issue( "30d", "--json", ], - { env, timeout: 30_000, maxBuffer: 64 * 1024 }, + { env, timeout: 30_000, maxBuffer: 64 * 1024, windowsHide: true }, ); const issued = z .object({ diff --git a/src/store.ts b/src/store.ts index abcc43f..bc4e5b2 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,4 @@ -import { mkdirSync, chmodSync } from "node:fs"; +import { privateDirectory, protectFile } from "./private-files.js"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import type { Watch } from "./model.js"; @@ -6,10 +6,10 @@ import type { Watch } from "./model.js"; export class Store { readonly db: DatabaseSync; constructor(readonly home: string) { - mkdirSync(home, { recursive: true, mode: 0o700 }); + privateDirectory(home); const path = join(home, "state.sqlite"); this.db = new DatabaseSync(path); - chmodSync(path, 0o600); + protectFile(path); this.db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;"); const version = this.db.prepare("PRAGMA user_version").get()?.user_version; if (version !== 0 && version !== 1) diff --git a/src/windows.ts b/src/windows.ts new file mode 100644 index 0000000..795d221 --- /dev/null +++ b/src/windows.ts @@ -0,0 +1,265 @@ +// Native Windows helpers. Loaded lazily so Unix installs never load Windows DLLs. +import { createRequire } from "node:module"; +import { join } from "node:path"; +import type { LocalProcess } from "./local-process.js"; + +function load() { + if (process.platform !== "win32" || process.arch !== "x64") + throw new Error("Windows automatic setup requires x64 Node.js"); + const k = createRequire(import.meta.url)("koffi") as typeof import("koffi"); + const dll = (name: string) => + k.load(join(process.env.SystemRoot ?? "C:\\Windows", "System32", name)); + const kernel = dll("kernel32.dll"); + const advapi = dll("advapi32.dll"); + const nt = dll("ntdll.dll"); + const shell = dll("shell32.dll"); + const close = kernel.func("int __stdcall CloseHandle(void *handle)"); + const free = kernel.func("void * __stdcall LocalFree(void *memory)"); + const current = kernel.func("void * __stdcall GetCurrentProcess()"); + const open = kernel.func( + "void * __stdcall OpenProcess(uint32_t access, int inherit, uint32_t pid)", + ); + const read = kernel.func( + "int __stdcall ReadProcessMemory(void *process, uintptr_t address, void *buffer, size_t size, _Out_ size_t *read)", + ); + const query = nt.func( + "int32_t __stdcall NtQueryInformationProcess(void *process, uint32_t kind, void *buffer, uint32_t size, _Out_ uint32_t *returned)", + ); + const image = kernel.func( + "int __stdcall QueryFullProcessImageNameW(void *process, uint32_t flags, void *buffer, _Inout_ uint32_t *size)", + ); + const wow64 = kernel.func( + "int __stdcall IsWow64Process(void *process, _Out_ int *result)", + ); + const argv = shell.func( + "void * __stdcall CommandLineToArgvW(str16 command, _Out_ int *argc)", + ); + const openToken = advapi.func( + "int __stdcall OpenProcessToken(void *process, uint32_t access, _Out_ void **token)", + ); + const tokenInfo = advapi.func( + "int __stdcall GetTokenInformation(void *token, uint32_t kind, void *buffer, uint32_t size, _Out_ uint32_t *needed)", + ); + const sidString = advapi.func( + "int __stdcall ConvertSidToStringSidW(void *sid, _Out_ void **text)", + ); + const getSecurity = advapi.func( + "uint32_t __stdcall GetNamedSecurityInfoW(str16 path, uint32_t type, uint32_t information, _Out_ void **owner, void *group, _Out_ void **dacl, void *sacl, _Out_ void **descriptor)", + ); + const getAce = advapi.func( + "int __stdcall GetAce(void *acl, uint32_t index, _Out_ void **ace)", + ); + const convert = advapi.func( + "int __stdcall ConvertStringSecurityDescriptorToSecurityDescriptorW(str16 text, uint32_t revision, _Out_ void **descriptor, void *size)", + ); + const getDacl = advapi.func( + "int __stdcall GetSecurityDescriptorDacl(void *descriptor, _Out_ int *present, _Out_ void **acl, _Out_ int *defaulted)", + ); + const setSecurity = advapi.func( + "uint32_t __stdcall SetNamedSecurityInfoW(str16 path, uint32_t type, uint32_t information, void *owner, void *group, void *dacl, void *sacl)", + ); + function sid(pointer: unknown): string { + const out = [null]; + if (!sidString(pointer, out)) throw new Error("Cannot read Windows owner"); + try { + return k.decode.string16(out[0]); + } finally { + free(out[0]); + } + } + function owner(handle: unknown): string { + const token = [null]; + if (!openToken(handle, 8, token)) + throw new Error("Cannot inspect process owner"); + try { + const needed = [0]; + tokenInfo(token[0], 1, null, 0, needed); + if (!needed[0] || needed[0] > 65536) + throw new Error("Invalid token information"); + const buffer = Buffer.alloc(needed[0]); + if (!tokenInfo(token[0], 1, buffer, buffer.length, needed)) + throw new Error("Cannot inspect process owner"); + return sid(k.decode(buffer, "void *")); + } finally { + close(token[0]); + } + } + const user = owner(current()); + function inspect(pid: number): LocalProcess { + // PROCESS_QUERY_INFORMATION | PROCESS_VM_READ. Never request write access. + const handle = open(0x410, 0, pid); + if (!handle) throw new Error("Cannot open local process"); + try { + if (owner(handle) !== user) throw new Error("Different process owner"); + const wow = [0]; + if (!wow64(handle, wow) || wow[0]) + throw new Error("Unsupported process architecture"); + function memory(address: bigint, size: number): Buffer { + if (!address || size < 0 || size > 1024 * 1024) + throw new Error("Invalid process memory range"); + const buffer = Buffer.alloc(size); + const count = [0]; + if ( + !read(handle, address, buffer, size, count) || + Number(count[0]) !== size + ) + throw new Error("Cannot inspect process parameters"); + return buffer; + } + const basic = Buffer.alloc(48); + if (query(handle, 0, basic, basic.length, null) !== 0) + throw new Error("Cannot inspect process"); + // x64 PEB.ProcessParameters and RTL_USER_PROCESS_PARAMETERS layout. + // These NT layouts are version-sensitive: malformed/unreadable data fails closed. + const peb = memory(basic.readBigUInt64LE(8), 40); + const parameters = memory(peb.readBigUInt64LE(32), 0x88); + function unicode(offset: number): string { + const length = parameters.readUInt16LE(offset); + if (length % 2 || length > parameters.readUInt16LE(offset + 2)) + throw new Error("Invalid process string"); + return length + ? memory(parameters.readBigUInt64LE(offset + 8), length).toString( + "utf16le", + ) + : ""; + } + const command = unicode(0x70); + if (!command) throw new Error("Empty process command line"); + const argc = [0]; + const pointers = argv(command, argc); + if (!pointers || !argc[0] || argc[0] > 32768) + throw new Error("Invalid process command line"); + let args: string[]; + try { + args = k.decode(pointers, k.array("str16", argc[0])) as string[]; + } finally { + free(pointers); + } + // Read page-bounded chunks until the UTF-16 environment's double NUL. + let address = parameters.readBigUInt64LE(0x80); + const chunks: Buffer[] = []; + let bytes = 0; + let environment = ""; + while (bytes < 1024 * 1024) { + const length = Math.min( + 4096 - Number(address % 4096n), + 1024 * 1024 - bytes, + ); + const chunk = memory(address, length); + chunks.push(chunk); + bytes += length; + address += BigInt(length); + environment = Buffer.concat(chunks).toString("utf16le"); + const end = environment.indexOf("\0\0"); + if (end >= 0) { + environment = environment.slice(0, end); + break; + } + } + if (bytes >= 1024 * 1024) + throw new Error("Process environment too large"); + const env: Record = {}; + for (const entry of environment.split("\0")) { + const index = entry.indexOf("="); + if (index > 0) + env[entry.slice(0, index).toUpperCase()] = entry.slice(index + 1); + } + const buffer = Buffer.alloc(65536); + const size = [32768]; + if (!image(handle, 0, buffer, size)) + throw new Error("Cannot inspect process executable"); + return { + args, + env, + cwd: unicode(0x38), + executable: buffer.toString("utf16le", 0, size[0]! * 2), + }; + } finally { + close(handle); + } + } + function protect(path: string) { + const descriptor = [null]; + if (!convert(`D:P(A;OICI;FA;;;${user})`, 1, descriptor, null)) + throw new Error("Cannot build private Windows ACL"); + try { + const acl = [null]; + if ( + !getDacl(descriptor[0], [0], acl, [0]) || + setSecurity(path, 1, 0x80000004, null, null, acl[0], null) !== 0 + ) + throw new Error("Cannot protect Windows credential path"); + } finally { + free(descriptor[0]); + } + } + function assertPrivate(path: string) { + const descriptor = [null], + fileOwner = [null], + acl = [null]; + if (getSecurity(path, 1, 5, fileOwner, null, acl, null, descriptor) !== 0) + throw new Error("Cannot read Windows file ACL"); + try { + if ( + ![user, "S-1-5-18", "S-1-5-32-544"].includes(sid(fileOwner[0])) || + !acl[0] + ) + throw new Error( + "Credential must have a trusted owner and a private ACL", + ); + const header = Buffer.from(k.view(acl[0], 8)); + for (let i = 0; i < header.readUInt16LE(4); i++) { + const ace = [null]; + if (!getAce(acl[0], i, ace)) + throw new Error("Cannot inspect Windows ACL"); + const head = Buffer.from(k.view(ace[0], 8)); + if (head[1]! & 8) continue; // INHERIT_ONLY does not grant access to this file. + if (head[0] === 1) continue; // Deny entries cannot disclose credentials. + if (head[0] !== 0 || head.readUInt16LE(2) < 16) + throw new Error("Unsupported credential ACL"); + const principal = sid(k.address(ace[0]) + 8n); + if (![user, "S-1-5-18", "S-1-5-32-544"].includes(principal)) + throw new Error("Credential ACL grants access to another user"); + } + } finally { + free(descriptor[0]); + } + } + function hasOpenFile(pid: number, path: string): boolean { + const rm = dll("rstrtmgr.dll"); + const start = rm.func( + "uint32_t __stdcall RmStartSession(_Out_ uint32_t *session, uint32_t flags, void *key)", + ); + const register = rm.func( + "uint32_t __stdcall RmRegisterResources(uint32_t session, uint32_t files, str16 *names, uint32_t apps, void *processes, uint32_t services, void *serviceNames)", + ); + const list = rm.func( + "uint32_t __stdcall RmGetList(uint32_t session, _Out_ uint32_t *needed, _Inout_ uint32_t *count, void *processes, _Out_ uint32_t *reasons)", + ); + const end = rm.func("uint32_t __stdcall RmEndSession(uint32_t session)"); + const session = [0]; + if (start(session, 0, Buffer.alloc(66)) !== 0) + throw new Error("Cannot inspect open Windows files"); + try { + if (register(session[0], 1, [path], 0, null, 0, null) !== 0) return false; + const needed = [0], + count = [0], + reasons = [0]; + const result = list(session[0], needed, count, null, reasons); + if (result === 0) return false; + if (result !== 234 || !needed[0] || needed[0] > 4096) return false; + count[0] = needed[0]; + // RM_PROCESS_INFO: DWORD pid, FILETIME start, WCHAR names[256+64], four DWORDs. + const buffer = Buffer.alloc(count[0] * 668); + if (list(session[0], needed, count, buffer, reasons) !== 0) return false; + for (let i = 0; i < count[0]!; i++) + if (buffer.readUInt32LE(i * 668) === pid) return true; + return false; + } finally { + end(session[0]); + } + } + return { inspect, protect, assertPrivate, hasOpenFile }; +} +let api: ReturnType | undefined; +export const windows = () => (api ??= load()); diff --git a/src/worker.ts b/src/worker.ts index d012354..ff897dd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -220,6 +220,7 @@ export async function ensureWorker( [fileURLToPath(new URL("./cli.js", import.meta.url)), "_worker"], { detached: true, + windowsHide: true, stdio: ["ignore", log, log], cwd: store.home, env: { ...process.env, T3POLL_HOME: store.home }, diff --git a/tests/core.test.ts b/tests/core.test.ts index 73d73b5..2855007 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,6 +1,7 @@ +import { makePublic } from "./fixtures/permissions.js"; import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -279,7 +280,7 @@ test("config rejects unsafe origins and credential permissions", (t) => { const token = join(f.home, "token"); writeFileSync(token, "test-secret", { mode: 0o600 }); assert.equal(readToken(token), "test-secret"); - chmodSync(token, 0o644); + makePublic(token); assert.throws(() => readToken(token)); assert.throws(() => parsePr("https://evil.com/a/b/pull/1")); assert.throws(() => parsePr("file:///a/b/pull/1")); diff --git a/tests/fixtures/executable.mjs b/tests/fixtures/executable.mjs new file mode 100644 index 0000000..62c0a0c --- /dev/null +++ b/tests/fixtures/executable.mjs @@ -0,0 +1,69 @@ +// A real executable fixture: Unix shell launcher or Windows .NET console launcher. +// Production gh execution stays shell-free on both platforms. +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +export function executable(directory, name, code) { + const script = join(directory, `${name}.cjs`); + writeFileSync(script, code); + if (process.platform !== "win32") { + const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; + const path = join(directory, name); + writeFileSync( + path, + `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`, + { mode: 0o700 }, + ); + return path; + } + const path = join(directory, `${name}.exe`); + const source = join(directory, `${name}.cs`); + const literal = (value) => '@"' + value.replaceAll('"', '""') + '"'; + writeFileSync( + source, + `using System; using System.Diagnostics; using System.Text; using System.Runtime.InteropServices; +class Launcher { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll")] static extern bool SetInformationJobObject(IntPtr job, int kind, IntPtr data, uint size); + [DllImport("kernel32.dll")] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static string Quote(string s) { + var b = new StringBuilder("\\\""); int slashes = 0; + foreach (char c in s) { + if (c == '\\\\') { slashes++; continue; } + if (c == '"') { b.Append('\\\\', slashes * 2 + 1); b.Append(c); } + else { b.Append('\\\\', slashes); b.Append(c); } + slashes = 0; + } + b.Append('\\\\', slashes * 2); b.Append('"'); return b.ToString(); + } + static int Main(string[] args) { + var a = new StringBuilder(Quote(${literal(script)})); + foreach (var arg in args) { a.Append(' '); a.Append(Quote(arg)); } + // Killing the fixture launcher must also stop its Node child on Windows. + var job = CreateJobObject(IntPtr.Zero, null); + var limits = Marshal.AllocHGlobal(144); + for (int i = 0; i < 144; i++) Marshal.WriteByte(limits, i, 0); + Marshal.WriteInt32(limits, 16, 0x2000); + if (job == IntPtr.Zero || !SetInformationJobObject(job, 9, limits, 144)) throw new Exception("Cannot create fixture job"); + Marshal.FreeHGlobal(limits); + var p = Process.Start(new ProcessStartInfo(${literal(process.execPath)}, a.ToString()) { UseShellExecute = false }); + if (!AssignProcessToJobObject(job, p.Handle)) { p.Kill(); throw new Exception("Cannot contain fixture process"); } + p.WaitForExit(); var result = p.ExitCode; CloseHandle(job); return result; + } +}`, + ); + execFileSync( + join( + process.env.SystemRoot, + "Microsoft.NET", + "Framework64", + "v4.0.30319", + "csc.exe", + ), + ["/nologo", "/target:exe", "/platform:x64", `/out:${path}`, source], + { windowsHide: true }, + ); + return path; +} diff --git a/tests/fixtures/permissions.ts b/tests/fixtures/permissions.ts new file mode 100644 index 0000000..c9bacaa --- /dev/null +++ b/tests/fixtures/permissions.ts @@ -0,0 +1,12 @@ +import { chmodSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +export function makePublic(path: string) { + if (process.platform === "win32") { + execFileSync( + join(process.env.SystemRoot!, "System32", "icacls.exe"), + [path, "/grant", "*S-1-5-32-545:(R)"], + { windowsHide: true }, + ); + } else chmodSync(path, 0o644); +} diff --git a/tests/process.test.ts b/tests/process.test.ts index b0818ca..f5831c2 100644 --- a/tests/process.test.ts +++ b/tests/process.test.ts @@ -1,8 +1,9 @@ +import { executable } from "./fixtures/executable.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, delimiter } from "node:path"; import { createServer } from "node:http"; import { setTimeout as delay } from "node:timers/promises"; import { execFile } from "node:child_process"; @@ -32,8 +33,9 @@ test( mkdirSync(bin); const phaseFile = join(home, "phase"); writeFileSync(phaseFile, "0"); - writeFileSync( - join(bin, "gh"), + executable( + bin, + "gh", `#!${process.execPath} const fs=require('node:fs'); const phase=Number(fs.readFileSync(${JSON.stringify(phaseFile)},'utf8')); @@ -44,7 +46,6 @@ else if(path.includes('/check-runs?')) result=[{check_runs:[]}]; else if(path.includes('/issues/') && phase) result=[[{id:phase,body:'Do not copy this untrusted text into the wakeup',html_url:'https://github.com/owner/repo/pull/1#issuecomment-'+phase}]]; process.stdout.write(JSON.stringify(result)); `, - { mode: 0o700 }, ); const tokenFile = join(home, "token"); writeFileSync(tokenFile, "fixture-token", { mode: 0o600 }); @@ -88,7 +89,7 @@ process.stdout.write(JSON.stringify(result)); assert.ok(address && typeof address !== "string"); const env = { ...process.env, - PATH: `${bin}:${process.env.PATH}`, + PATH: `${bin}${delimiter}${process.env.PATH}`, T3POLL_HOME: home, T3POLL_URL: `http://127.0.0.1:${address.port}`, T3POLL_TOKEN_FILE: tokenFile, diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 7a5c537..dd798bb 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -1,3 +1,5 @@ +import { assertPrivateFile } from "../src/private-files.js"; +import { makePublic } from "./fixtures/permissions.js"; import { test } from "node:test"; import assert from "node:assert/strict"; import { @@ -7,7 +9,6 @@ import { writeFileSync, readFileSync, rmSync, - statSync, existsSync, symlinkSync, realpathSync, @@ -30,7 +31,7 @@ async function fixture( t: { after: (fn: () => Promise) => void }, launch: "direct" | "absolute-link" | "relative-link" = "direct", ) { - const root = mkdtempSync(join(tmpdir(), "t3poll setup space-")); + const root = mkdtempSync(join(tmpdir(), "t3poll setup café space-")); const base = join(root, "t3"); const home = join(root, "poll"); const pkg = join(root, "package"); @@ -43,10 +44,17 @@ async function fixture( const cli = join(pkg, "dist/bin.mjs"); copyFileSync(resolve("tests/fixtures/local-t3.mjs"), cli); const link = join(root, "t3-bin"); - symlinkSync(cli, link); + if (launch !== "direct") { + if (process.platform === "win32") symlinkSync(pkg, link, "junction"); + else symlinkSync(cli, link); + } const command = launch === "direct" ? cli : launch === "absolute-link" ? link : "./t3-bin"; - const child = spawn(process.execPath, [command, "serve"], { + const entry = + process.platform === "win32" && launch !== "direct" + ? join(command, "dist", "bin.mjs") + : command; + const child = spawn(process.execPath, [entry, "serve"], { cwd: root, env: { ...process.env, T3CODE_HOME: base }, stdio: ["ignore", "pipe", "pipe"], @@ -77,7 +85,7 @@ test("first use discovers local T3, creates a private verified credential, and r ); 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.doesNotThrow(() => assertPrivateFile(results[0]!.tokenFile)); assert.deepEqual(await new T3(f.origin, results[0]!.tokenFile).threads(), []); const service = new Service(f.config); try { @@ -172,7 +180,11 @@ test("discovery reports ambiguity and accepts a URL selector", async (t) => { 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")); + symlinkSync( + second.base, + join(workspace, ".t3"), + process.platform === "win32" ? "junction" : "dir", + ); process.env.T3CODE_HOME = first.base; process.chdir(workspace); try { @@ -208,7 +220,7 @@ test("renewal starts before expiration, repairs a missing token, and recovers an rmSync(c.tokenFile); await new T3(f.origin, c.tokenFile).threads(); assert.equal(f.issued(), 3); - assert.equal(statSync(path).mode & 0o777, 0o600); + assert.doesNotThrow(() => assertPrivateFile(path)); }); test("a runtime file pointing at another T3 data directory cannot mint credentials", async (t) => { @@ -263,11 +275,10 @@ test("worker requests repair empty and insecure managed token files before expir 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); + makePublic(c.tokenFile); assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); assert.equal(f.issued(), 3); - assert.equal(statSync(c.tokenFile).mode & 0o777, 0o600); + assert.doesNotThrow(() => assertPrivateFile(c.tokenFile)); }); test("failed verification revokes its session and retries failed cleanup before issuing again", async (t) => { diff --git a/tests/windows.test.ts b/tests/windows.test.ts new file mode 100644 index 0000000..3a057e0 --- /dev/null +++ b/tests/windows.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { windows } from "../src/windows.js"; +import { readLocalProcess, hasOpenFile } from "../src/local-process.js"; +import { privateDirectory, assertPrivateFile } from "../src/private-files.js"; +import { makePublic } from "./fixtures/permissions.js"; + +test( + "Windows inspects Unicode paths, quoting, large environments, and exact open files", + { skip: process.platform !== "win32" }, + async () => { + const root = mkdtempSync(join(tmpdir(), "t3poll Windows café-")); + const file = join(root, "open database.sqlite"); + const unrelated = join(root, "unrelated.sqlite"); + writeFileSync(unrelated, "unused"); + const args = [ + "-e", + "const fs=require('node:fs'); fs.openSync(process.argv[1],'w'); console.log('ready'); setInterval(()=>{},1000)", + file, + 'quote"inside', + "trailing\\", + "", + ]; + const large = "value=with spaces ".repeat(1024); + const child = spawn(process.execPath, args, { + cwd: root, + env: { ...process.env, T3POLL_LARGE_ENV: large }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await once(child.stdout!, "data"); + const details = readLocalProcess(child.pid!); + assert.deepEqual(details.args.slice(1), args); + assert.equal(details.cwd.replace(/[\\/]$/, ""), root); + assert.equal(details.env.T3POLL_LARGE_ENV, large); + assert.equal(hasOpenFile(child.pid!, file), true); + assert.equal(hasOpenFile(child.pid!, unrelated), false); + assert.throws(() => windows().inspect(0)); + } finally { + const exit = once(child, "exit"); + child.kill(); + await exit; + rmSync(root, { recursive: true, force: true, maxRetries: 5 }); + } + }, +); + +test( + "Windows private directories protect new files and reject a Users grant", + { skip: process.platform !== "win32" }, + () => { + const root = mkdtempSync(join(tmpdir(), "t3poll ACL café-")); + try { + privateDirectory(root); + const file = join(root, "token"); + writeFileSync(file, "fixture-token"); + assert.doesNotThrow(() => assertPrivateFile(file)); + makePublic(file); + assert.throws(() => assertPrivateFile(file), /another user/); + windows().protect(file); + assert.doesNotThrow(() => assertPrivateFile(file)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +);