Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<baseDir>/runtime/versions/<version>/` 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:
Expand Down
40 changes: 26 additions & 14 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<version> 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]));
Expand Down
73 changes: 60 additions & 13 deletions tests/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,22 @@ import { Service } from "../src/service.js";
const exec = promisify(execFile);
async function fixture(
t: { after: (fn: () => Promise<void>) => 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(
Expand All @@ -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);
}
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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);
});
Loading