diff --git a/docs/compatibility.md b/docs/compatibility.md index ab4a3e2..8743175 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -18,6 +18,12 @@ Supported platforms are Windows x64, macOS, and Linux with Node.js 24.10+. The i - A detached process survives ordinary MCP/CLI exit. OS session policies, containers, suspend, logout, or parent cgroup cleanup can still stop it. A later `watch` or `list` restarts it; no watchdog or login service is installed. - `stop` does not interrupt accepted turns. A request already in flight can complete after cancellation. +## Service runtime discovery + +T3 service installs can run a standalone `t3` executable (`t3.exe` on Windows) from `/runtime/versions//` without npm package metadata. t3poll recognizes that layout only when `.install-complete` matches the version directory, and still verifies the live process and its data home. Native npm installations continue to use their platform package identity. + +Verified September 21, 2026 against the local Linux service running `0.0.43-nightly.20260916.1825`: `list --threads` discovered the server and read the authenticated thread list. Regression tests cover credential issuance without package metadata and rejection of missing/mismatched markers and mismatched homes. + ## Stock release proof Install stock T3 in a disposable directory, outside this repository. Build its native dependencies if required by that release. Then run: diff --git a/src/setup.ts b/src/setup.ts index e901415..d657f69 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; 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 { basename, dirname, join, resolve } from "node:path"; import { readFileSync, realpathSync, @@ -107,21 +107,33 @@ export function inspectLocal(baseDir: string): LocalT3 | undefined { if (!hasOpenFile(state.pid, join(baseDir, "userdata/state.sqlite"))) return; } else if (args[1] === "serve") { - // Native npm distributions run `t3 serve`, with no JS entrypoint. - // Verify the live executable against its platform package before using - // that same executable to issue or revoke credentials. - const pkg = JSON.parse( - readFileSync(join(dirname(executable), "package.json"), "utf8"), - ); - // Setup's Node may run under emulation while T3 uses the host CPU. - if ( - !["x64", "arm64"].some( - (arch) => pkg.name === `@t3code/t3-${process.platform}-${arch}`, - ) - ) - return; + // Native npm packages carry package.json. Service installs instead unpack + // a release archive into this home's runtime/versions/ directory. const binary = process.platform === "win32" ? "t3.exe" : "t3"; if (executable !== join(dirname(executable), binary)) return; + const versionDir = dirname(executable); + const serviceRuntime = + existsSync(join(baseDir, "runtime", "versions")) && + realpathSync(dirname(versionDir)) === + realpathSync(join(baseDir, "runtime", "versions")); + if (serviceRuntime) { + if ( + readFileSync(join(versionDir, ".install-complete"), "utf8").trim() !== + basename(versionDir) + ) + return; + } else { + const pkg = JSON.parse( + readFileSync(join(versionDir, "package.json"), "utf8"), + ); + // Setup's Node may run under emulation while T3 uses the host CPU. + if ( + !["x64", "arm64"].some( + (arch) => pkg.name === `@t3code/t3-${process.platform}-${arch}`, + ) + ) + return; + } cli = executable; } else { cli = realpathSync(resolve(cwd, args[1])); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index a250c53..da176e4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -29,12 +29,22 @@ import { Service } from "../src/service.js"; const exec = promisify(execFile); async function fixture( t: { after: (fn: () => Promise) => void }, - launch: "direct" | "absolute-link" | "relative-link" | "native" = "direct", + launch: + | "direct" + | "absolute-link" + | "relative-link" + | "native" + | "service" = "direct", ) { const root = mkdtempSync(join(tmpdir(), "t3poll setup café space-")); const base = join(root, "t3"); const home = join(root, "poll"); - const pkg = join(root, "package"); + const native = launch === "native" || launch === "service"; + const version = "0.0.43-nightly.20260916.1825"; + const pkg = + launch === "service" + ? join(base, "runtime", "versions", version) + : join(root, "package"); mkdirSync(base); mkdirSync(join(pkg, "dist"), { recursive: true }); writeFileSync( @@ -44,7 +54,7 @@ async function fixture( const cli = join(pkg, "dist/bin.mjs"); copyFileSync(resolve("tests/fixtures/local-t3.mjs"), cli); const link = join(root, "t3-bin"); - if (launch !== "direct" && launch !== "native") { + if (launch !== "direct" && !native) { if (process.platform === "win32") symlinkSync(pkg, link, "junction"); else symlinkSync(cli, link); } @@ -55,7 +65,7 @@ async function fixture( ? join(command, "dist", "bin.mjs") : command; let executable = process.execPath; - if (launch === "native") { + if (native) { // A copied Node binary gives us a real process with the native `t3 serve` // argv layout, without requiring a T3 download or compiler in CI. executable = join(pkg, process.platform === "win32" ? "t3.exe" : "t3"); @@ -73,15 +83,15 @@ async function fixture( 'process.argv.splice(1, 0, "auth");\n' + readFileSync(cli, "utf8"), ); } - const child = spawn( - executable, - launch === "native" ? ["serve"] : [entry, "serve"], - { - cwd: root, - env: { ...process.env, T3CODE_HOME: base }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); + if (launch === "service") { + rmSync(join(pkg, "package.json")); + writeFileSync(join(pkg, ".install-complete"), `${version}\n`); + } + const child = spawn(executable, native ? ["serve"] : [entry, "serve"], { + cwd: root, + env: { ...process.env, T3CODE_HOME: base }, + stdio: ["ignore", "pipe", "pipe"], + }); t.after(async () => { if (child.exitCode === null) { const stopped = once(child, "exit"); @@ -524,3 +534,40 @@ test("native discovery accepts a supported package architecture independently of } assert.equal(f.issued(), 0); }); + +test("service runtime discovery and credential issuance without npm metadata", async (t) => { + const f = await fixture(t, "service"); + const server = discover(f.config); + assert.equal(server.origin, f.origin); + assert.equal(server.native, true); + const cwd = process.cwd(); + process.chdir(f.root); + try { + const c = await connection(f.config); + assert.deepEqual(await new T3(c.origin, c.tokenFile).threads(), []); + assert.equal(f.issued(), 1); + } finally { + process.chdir(cwd); + } +}); + +test("service discovery requires a matching install marker and home", async (t) => { + const f = await fixture(t, "service"); + const marker = join( + f.base, + "runtime/versions/0.0.43-nightly.20260916.1825/.install-complete", + ); + assert.ok(inspectLocal(f.base)); + const other = join(f.root, "other"); + mkdirSync(join(other, "userdata"), { recursive: true }); + copyFileSync( + join(f.base, "userdata/server-runtime.json"), + join(other, "userdata/server-runtime.json"), + ); + assert.equal(inspectLocal(other), undefined); + writeFileSync(marker, "wrong-version\n"); + assert.equal(inspectLocal(f.base), undefined); + rmSync(marker); + assert.equal(inspectLocal(f.base), undefined); + assert.equal(f.issued(), 0); +});