From b4c4793e5130c9ec7a46a2bc9d0b1e86346e932a Mon Sep 17 00:00:00 2001 From: 0libote <79215282+0libote@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:58:47 +0100 Subject: [PATCH 1/4] fix(runtime): install T3 Code from GitHub release archives --- CONTRIBUTING.md | 4 + Dockerfile | 27 ++--- docker/runtime/install.mjs | 42 +++++++ docker/runtime/manager.mjs | 66 +---------- docker/runtime/releases.mjs | 148 +++++++++++++++++++++++++ docs/t3code-updates.md | 20 +++- scripts/bump-versions.sh | 16 ++- scripts/smoke-test.sh | 8 ++ scripts/t3-runtime-smoke.mjs | 37 +++++++ tests/t3-releases.test.mjs | 206 +++++++++++++++++++++++++++++++++++ tests/t3-runtime.test.mjs | 39 +++---- 11 files changed, 503 insertions(+), 110 deletions(-) create mode 100644 docker/runtime/install.mjs create mode 100644 docker/runtime/releases.mjs create mode 100644 scripts/t3-runtime-smoke.mjs create mode 100644 tests/t3-releases.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3d67b8..2611d3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,10 @@ Issues and pull requests are welcome. Before opening one: notice if they broke. Run it against your build (`./scripts/smoke-test.sh t3code:browser`) and add a check for whatever you fixed. Most of its checks were added after something broke. + The T3 Code archive check stages a real current nightly in a disposable home, + verifies its checksum, Setup bridge, launcher protocol and native auth probes, + then deletes it without selecting or starting it. It can also run locally: + `node scripts/t3-runtime-smoke.mjs docker/runtime docker/t3-client/setup-bridge.js`. - **CI builds and tests both images on both architectures** before anything is published, so a change that only works on amd64 is caught. The slower agent lifecycle test runs on amd64 for every build. diff --git a/Dockerfile b/Dockerfile index 591544c..6fdfd45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -214,7 +214,7 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/* # Pinned so a rebuild is reproducible; `scripts/bump-versions.sh` refreshes it -# against the registry. The agent harnesses are not baked and therefore carry +# against published releases. The agent harnesses are not baked and therefore carry # no pins here: the harness installer resolves and records an exact version on # explicit install. ARG T3_VERSION=0.1.0 @@ -228,28 +228,16 @@ ENV T3_INFRA_PREFIX=/opt/t3 \ T3_INFRA_BINARY=/opt/t3/t3 \ T3_INFRA_LAUNCHER=/usr/local/bin/t3-admin -# The release is split into architecture-specific packages. Install the -# platform package explicitly instead of the tiny `t3` npm launcher, then -# flatten it into one architecture-independent immutable path. Its native -# modules and client assets must remain beside the executable. +# Upstream's npm platform packages are frozen. Install the exact GitHub CLI +# archive and verify SHA256SUMS with the same implementation Setup uses. +COPY docker/runtime/ /opt/t3-runtime/ RUN set -eux; \ case "$TARGETARCH" in \ - amd64) t3_package='@t3code/t3-linux-x64' ;; \ - arm64) t3_package='@t3code/t3-linux-arm64' ;; \ + amd64) t3_arch=x64 ;; \ + arm64) t3_arch=arm64 ;; \ *) echo "unsupported T3 architecture: $TARGETARCH" >&2; exit 1 ;; \ esac; \ - install_root=/tmp/t3-install; \ - mkdir -p "$T3_INFRA_PREFIX" "$install_root"; \ - npm install --no-audit --no-fund --ignore-scripts --prefix "$install_root" \ - "${t3_package}@${T3_VERSION}"; \ - package_root="$install_root/node_modules/$t3_package"; \ - cp -a "$package_root/." "$T3_INFRA_PREFIX/"; \ - # Keep the package manifest as local audit evidence; runtime does not need - # npm's surrounding node_modules layout. - test -x "$T3_INFRA_BINARY"; \ - test -f "$T3_INFRA_PREFIX/client/index.html"; \ - rm -rf "$install_root"; \ - npm cache clean --force; \ + "$T3_INFRA_NODE" /opt/t3-runtime/install.mjs "$T3_VERSION" "$t3_arch" "$T3_INFRA_PREFIX"; \ # Root-owned and not group/other writable: the t3 user runs the server and # must never be able to modify it. chown -R root:root "$T3_INFRA_PREFIX"; \ @@ -268,7 +256,6 @@ COPY docker/bin/ /usr/local/bin/ # helpers: the harness manager owns install/resolve, and the provider # integration turns its selection into T3's per-provider `binaryPath`. COPY docker/harness/ /opt/t3-harness/ -COPY docker/runtime/ /opt/t3-runtime/ COPY docker/provider-integration/ /opt/t3-provider/ COPY docker/setup/ /opt/t3-setup/ # The one-port router (T3_SINGLE_PORT): T3 Code and the setup page behind a diff --git a/docker/runtime/install.mjs b/docker/runtime/install.mjs new file mode 100644 index 0000000..0fac1ae --- /dev/null +++ b/docker/runtime/install.mjs @@ -0,0 +1,42 @@ +// Image-only installer; runtime updates continue through the container supervisor. +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { releaseForVersion, unpackRelease, executableVersion } from "./releases.mjs"; + +export async function installImageRelease(version, arch, destination, { fetcher = fetch, run = promisify(execFile) } = {}) { + let stage; + try { + if (!destination || !path.isAbsolute(destination)) throw new Error("Expected version, architecture and an absolute destination."); + const release = await releaseForVersion(version, { arch, fetcher }); + await fs.mkdir(path.dirname(destination), { recursive: true }); + stage = await fs.mkdtemp(path.join(path.dirname(destination), ".t3-install-")); + const unpacked = await unpackRelease(stage, release, { fetcher, run }); + // Cross builds verify the checksum and archive root; native CI smoke tests + // run the executable on both architectures. + if (arch === process.arch) { + const result = await run(path.join(unpacked, "t3"), ["--version"], { timeout: 30_000 }); + if (executableVersion(result.stdout) !== version) throw new Error("The downloaded executable reports a different version."); + } + // Staging on the state volume is private, but the root-owned image runtime + // and its generated audit manifest must be readable by the t3 user. + await fs.chmod(unpacked, 0o755); + await fs.chmod(path.join(unpacked, "package.json"), 0o644); + await fs.rename(unpacked, destination); + } finally { + if (stage) await fs.rm(stage, { recursive: true, force: true }); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const [version, arch, destination] = process.argv.slice(2); + try { + await installImageRelease(version, arch, destination); + console.log(`[t3code] installed ${version} from its verified GitHub CLI archive (${arch})`); + } catch (error) { + console.error(`[t3code] ${error.message}`); + process.exitCode = 1; + } +} diff --git a/docker/runtime/manager.mjs b/docker/runtime/manager.mjs index bfde50f..2d434c4 100644 --- a/docker/runtime/manager.mjs +++ b/docker/runtime/manager.mjs @@ -1,8 +1,9 @@ import { promises as fs, existsSync } from "node:fs"; import path from "node:path"; -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { packageRelease, unpackRelease, executableVersion } from "./releases.mjs"; import { CHANNELS, channelOf, exactVersion, databaseName, validScopes, runtimePaths, readJson, writeJson, readSelection, selectedRuntime, operationBusy } from "./state.mjs"; // The checkout and image deliberately have different infrastructure prefixes. const localHarness = new URL("../harness/", import.meta.url); @@ -13,14 +14,8 @@ const [{ compareVersions }, { acquireLock, liveHolder }, { createFs }] = await P export { databaseBusy, operationBusy } from "./state.mjs"; const execute = promisify(execFile); -const REGISTRY = "https://registry.npmjs.org"; -const LIMIT = 300 * 1024 * 1024; -export const platformPackage = (arch = process.arch) => { - if (!["x64", "arm64"].includes(arch) || process.platform !== "linux") throw new Error("T3 Code updates support Linux amd64 and arm64."); - return `@t3code/t3-linux-${arch}`; -}; +export { packageRelease, executableVersion, validateArchive } from "./releases.mjs"; const errorText = (error) => String(error?.message ?? error).slice(0, 400); -export const executableVersion = (output) => /^(?:t3\s+)?v?(\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?)$/i.exec(String(output).trim())?.[1] ?? null; /** Ask the release's own CLI which data store it opens, in a disposable home. */ export async function discoverRuntime(binary, paths, { run = execute, env = process.env } = {}) { @@ -37,26 +32,6 @@ export async function discoverRuntime(binary, paths, { run = execute, env = proc } finally { await fs.rm(probe, { recursive: true, force: true }); } } -export async function packageRelease(channel, { fetcher = fetch, arch = process.arch } = {}) { - if (!CHANNELS.includes(channel)) throw new Error("Choose Stable or Nightly."); - const name = platformPackage(arch); - const response = await fetcher(`${REGISTRY}/${name}/${channel === "stable" ? "latest" : "nightly"}`, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`Could not check ${channel} releases (HTTP ${response.status}).`); - const pkg = await response.json(); - if (pkg.name !== name || !exactVersion(pkg.version) || channelOf(pkg.version) !== channel) throw new Error(`The registry did not return a valid ${channel} release.`); - const url = new URL(pkg.dist?.tarball ?? ""); - if (url.origin !== REGISTRY || url.username || url.password || !/^sha512-[A-Za-z0-9+/]+=*$/.test(pkg.dist?.integrity ?? "")) throw new Error("The release has no supported download integrity information."); - return { version: pkg.version, name, url: url.href, integrity: pkg.dist.integrity, bytes: pkg.dist.unpackedSize ?? null }; -} - -/** Only normal files/directories under npm's package root may be extracted. */ -export function validateArchive(names, types) { - for (const name of names.trim().split("\n")) { - if (!name.startsWith("package/") || name.split("/").includes("..") || name.includes("\\")) throw new Error("The release archive contains an unsafe path."); - } - for (const row of types.trim().split("\n")) if (!/^[d-]/.test(row)) throw new Error("The release archive contains unsupported links or entries."); -} - export async function installRelease(paths, release, { fetcher = fetch, run = execute, env = process.env, bridge = "/usr/local/share/t3-client/setup-bridge.js", onPhase = async () => {} } = {}) { const destination = path.join(paths.versions, release.version); const preflight = async (binary) => { @@ -74,40 +49,11 @@ export async function installRelease(paths, release, { fetcher = fetch, run = ex } await fs.mkdir(paths.versions, { recursive: true, mode: 0o700 }); const stage = await fs.mkdtemp(path.join(paths.versions, ".staging-")); - const archive = path.join(stage, "download.tgz"); - const unpacked = path.join(stage, "package"); try { - const space = await fs.statfs(paths.root); - if (release.bytes && Number(space.bavail) * Number(space.bsize) < release.bytes * 2 + LIMIT) throw new Error("There is not enough free space to stage this T3 Code release."); await onPhase("downloading"); - const response = await fetcher(release.url, { redirect: "error", signal: AbortSignal.timeout(180_000) }); - if (!response.ok || !response.body) throw new Error(`Could not download T3 Code (HTTP ${response.status}).`); - const hash = createHash("sha512"); - let bytes = 0; - const file = await fs.open(archive, "wx", 0o600); - try { - for await (const chunk of response.body) { - bytes += chunk.length; - if (bytes > LIMIT) throw new Error("The release download exceeded the supported size."); - hash.update(chunk); - await file.writeFile(chunk); - } - await file.sync(); - } finally { await file.close(); } - const expected = Buffer.from(release.integrity.slice(7), "base64"); - const actual = hash.digest(); - if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new Error("T3 Code's download failed its integrity check. The running version was kept."); + const unpacked = await unpackRelease(stage, release, { fetcher, run }); await onPhase("verifying"); - const options = { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }; - const names = await run("/usr/bin/tar", ["-tzf", archive], options); - const types = await run("/usr/bin/tar", ["-tvzf", archive], options); - validateArchive(names.stdout, types.stdout); - await fs.mkdir(unpacked, { mode: 0o700 }); - await run("/usr/bin/tar", ["-xzf", archive, "--no-same-owner", "--no-same-permissions", "--strip-components=1", "-C", unpacked], options); - const pkg = await readJson(path.join(unpacked, "package.json")); - if (pkg?.name !== release.name || pkg.version !== release.version) throw new Error("The downloaded package does not match the requested release."); const binary = path.join(unpacked, "t3"); - await fs.chmod(binary, 0o755); await preflight(binary); const shellPath = path.join(unpacked, "client/index.html"); const shell = await fs.readFile(shellPath, "utf8"); @@ -138,7 +84,7 @@ export function createRuntimeManager({ env = process.env, fetcher = fetch, insta if (channel !== undefined && !CHANNELS.includes(channel)) throw new Error("Choose Stable or Nightly."); checking ??= (async () => { await load(); - const results = await Promise.allSettled(CHANNELS.map((channel) => packageRelease(channel, { fetcher }))); + const results = await Promise.allSettled(CHANNELS.map((channel) => packageRelease(channel, { fetcher, token: env.GITHUB_TOKEN }))); checkErrors = {}; results.forEach((result, index) => { if (result.status === "fulfilled") { @@ -192,7 +138,7 @@ export function createRuntimeManager({ env = process.env, fetcher = fetch, insta const phase = async (name) => { operation.phase = name; await writeJson(paths.operation, operation); }; void (async () => { try { - const release = await packageRelease(channel, { fetcher }); + const release = await packageRelease(channel, { fetcher, token: env.GITHUB_TOKEN }); if (release.version !== version) throw new Error("A newer release was published. Check for updates again before installing."); const directory = await install(paths, release, { fetcher, env, bridge: env.T3_SETUP_BRIDGE, onPhase: phase }); const installed = await readJson(path.join(directory, ".installed.json")); diff --git a/docker/runtime/releases.mjs b/docker/runtime/releases.mjs new file mode 100644 index 0000000..705c3cc --- /dev/null +++ b/docker/runtime/releases.mjs @@ -0,0 +1,148 @@ +// The same verified upstream CLI archives supply image builds and Setup updates. +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { CHANNELS, channelOf, exactVersion, readJson, writeJson } from "./state.mjs"; + +const execute = promisify(execFile); +const API = "https://api.github.com/repos/pingdotgg/t3code/releases"; +const DOWNLOAD = "https://github.com/pingdotgg/t3code/releases/download"; +const LIMIT = 300 * 1024 * 1024; +const UNPACKED_LIMIT = 1024 * 1024 * 1024; +const PAGE_SIZE = 20; +const downloadHosts = new Set(["github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]); +export const executableVersion = (output) => /^(?:t3\s+)?v?(\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?)$/i.exec(String(output).trim())?.[1] ?? null; + +export function archiveStem(version, arch = process.arch) { + if (!exactVersion(version)) throw new Error("Choose an exact Stable or Nightly release."); + if (!["x64", "arm64"].includes(arch) || process.platform !== "linux") throw new Error("T3 Code updates support Linux amd64 and arm64."); + return `t3-${version}-linux-${arch}`; +} + +// GitHub redirects release assets to signed storage URLs. Follow only its +// download hosts, without forwarding any authorization or accepting HTTP. +async function get(url, { fetcher, api = false, token, timeout = 15_000 }) { + for (let redirects = 0; redirects <= 4; redirects += 1) { + const headers = api ? { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", ...(token ? { authorization: `Bearer ${token}` } : {}) } : {}; + const response = await fetcher(url, { redirect: "manual", signal: AbortSignal.timeout(timeout), headers }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + const next = new URL(response.headers.get("location") ?? "", url); + if (api || !downloadHosts.has(next.hostname) || next.protocol !== "https:" || next.username || next.password || next.port) throw new Error("The release redirected to an unsupported download host."); + url = next.href; + continue; + } + if (!response.ok) { + await response.body?.cancel(); + const limited = response.status === 429 || (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0"); + throw new Error(limited ? "GitHub's release API rate limit was reached. Try checking again later; the running version was kept." : `Could not fetch T3 Code releases (HTTP ${response.status}).`); + } + return response; + } + throw new Error("The release download redirected too many times."); +} + +async function boundedText(response, limit) { + const chunks = []; + let bytes = 0; + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > limit) throw new Error("T3 Code's release metadata exceeded the supported size."); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export function archiveChecksum(text, name) { + const matches = text.split(/\r?\n/).map((line) => /^([a-fA-F0-9]{64}) [ *](\S+)$/.exec(line)).filter((line) => line?.[2] === name); + if (matches.length !== 1) throw new Error(`The release must list exactly one SHA-256 checksum for ${name}.`); + return matches[0][1].toLowerCase(); +} + +export async function releaseForVersion(version, { fetcher = fetch, arch = process.arch } = {}) { + const root = archiveStem(version, arch); + const name = `${root}.tar.gz`; + const base = `${DOWNLOAD}/v${version}`; + const sums = await boundedText(await get(`${base}/SHA256SUMS`, { fetcher }), 1024 * 1024); + return { version, name: "t3-runtime", archive: name, root, url: `${base}/${name}`, integrity: `sha256-${archiveChecksum(sums, name)}`, bytes: null }; +} + +export async function packageRelease(channel, { fetcher = fetch, arch = process.arch, token = process.env.GITHUB_TOKEN } = {}) { + if (!CHANNELS.includes(channel)) throw new Error("Choose Stable or Nightly."); + // Validate the architecture before making requests. + archiveStem("0.0.0", arch); + for (let page = 1; page <= 5; page += 1) { + const url = channel === "stable" ? `${API}/latest` : `${API}?per_page=${PAGE_SIZE}&page=${page}`; + const data = JSON.parse(await boundedText(await get(url, { fetcher, api: true, token }), 4 * 1024 * 1024)); + const entries = channel === "stable" ? [data] : data; + if (!Array.isArray(entries)) throw new Error("GitHub did not return a release list."); + const release = entries.find((entry) => { + const tag = typeof entry?.tag_name === "string" ? entry.tag_name : ""; + const version = tag.replace(/^v/, ""); + return !entry?.draft && exactVersion(version) && tag === `v${version}` && channelOf(version) === channel && (channel !== "stable" || entry.prerelease === false); + }); + if (release) return releaseForVersion(release.tag_name.replace(/^v/, ""), { fetcher, arch }); + if (channel === "stable" || entries.length < PAGE_SIZE) break; + } + throw new Error(`GitHub did not return a published ${channel} release. Try checking again later.`); +} + +/** Only ordinary files/directories under this exact archive root may extract. */ +export function validateArchive(names, types, root) { + if (!/^t3-\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?-linux-(?:x64|arm64)$/.test(root)) throw new Error("Invalid release archive root."); + const seen = new Set(); + for (const name of names.trimEnd().split("\n")) { + const parts = name.replace(/\/$/, "").split("/"); + if (parts[0] !== root || parts.some((part) => !part || part === "." || part === "..") || /[\\\x00-\x1f\x7f]/.test(name) || seen.has(name)) throw new Error("The release archive contains an unsafe path."); + seen.add(name); + } + let unpacked = 0; + for (const row of types.trimEnd().split("\n")) { + if (!/^[d-]/.test(row)) throw new Error("The release archive contains unsupported links or entries."); + const size = Number(row.trim().split(/\s+/)[2]); + if (!Number.isSafeInteger(size) || size < 0) throw new Error("Could not determine the release archive's unpacked size."); + unpacked += size; + } + if (unpacked > UNPACKED_LIMIT) throw new Error("The unpacked release exceeded the supported size."); + return unpacked; +} + +export async function unpackRelease(stage, release, { fetcher = fetch, run = execute } = {}) { + if (!["x64", "arm64"].some((arch) => archiveStem(release.version, arch) === release.root) + || release.url !== `${DOWNLOAD}/v${release.version}/${release.root}.tar.gz` + || !/^sha256-[a-f0-9]{64}$/.test(release.integrity)) throw new Error("Invalid release download or checksum information."); + const archive = path.join(stage, "download.tgz"); + const destination = path.join(stage, "contents"); + const response = await get(release.url, { fetcher, timeout: 180_000 }); + const hash = createHash("sha256"); + let bytes = 0; + const file = await fs.open(archive, "wx", 0o600); + try { + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > LIMIT) throw new Error("The release download exceeded the supported size."); + hash.update(chunk); + await file.writeFile(chunk); + } + await file.sync(); + } finally { await file.close(); } + if (!timingSafeEqual(hash.digest(), Buffer.from(release.integrity.slice(7), "hex"))) throw new Error("T3 Code's download failed its integrity check. The running version was kept."); + const options = { timeout: 60_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }; + const names = await run("/usr/bin/tar", ["-tzf", archive], options); + const types = await run("/usr/bin/tar", ["-tvzf", archive], options); + const unpacked = validateArchive(names.stdout, types.stdout, release.root); + const space = await fs.statfs(stage); + if (Number(space.bavail) * Number(space.bsize) < unpacked + 64 * 1024 * 1024) throw new Error("There is not enough free space to stage this T3 Code release."); + await fs.mkdir(destination, { mode: 0o700 }); + await run("/usr/bin/tar", ["-xzf", archive, "--no-same-owner", "--no-same-permissions", "--strip-components=1", "-C", destination], options); + const pkg = await readJson(path.join(destination, "package.json")); + if (pkg && (pkg.name !== "t3-runtime" || pkg.version !== release.version)) throw new Error("The downloaded archive does not match the requested release."); + // CLI archives omit the staging package.json. Keep a container-owned audit + // manifest so bundled version resolution also works with older supervisors. + if (!pkg) await writeJson(path.join(destination, "package.json"), { name: "t3-runtime", version: release.version, distribution: release.url, integrity: release.integrity }); + await fs.chmod(path.join(destination, "t3"), 0o755); + await fs.access(path.join(destination, "client/index.html")); + return destination; +} diff --git a/docs/t3code-updates.md b/docs/t3code-updates.md index aea5eaa..c8f06a4 100644 --- a/docs/t3code-updates.md +++ b/docs/t3code-updates.md @@ -1,7 +1,7 @@ # Upgrading T3 Code Open **Setup → Updates**. **Check for updates** checks T3 Code and every agent -and tool at once; for T3 Code it reads the official platform package's published +and tool at once; for T3 Code it reads the official GitHub releases for the selected channel, and **Update and restart** installs its exact release immediately. It does not use mise's waiting period. @@ -23,7 +23,8 @@ this setup console. Idle tracks console and client activity, not agent turns: a turn that outlasts the idle time with every client closed can still be interrupted. T3 Code keeps running while the release downloads, verifies -its SHA-512 integrity and checks compatibility with the container supervisor. +its SHA-256 digest against the release's `SHA256SUMS` and checks compatibility +with the container supervisor. Image builds use the same verified CLI archives. Once ready, only T3 Code restarts. Active agent turns and its terminals stop; clients reconnect. The Setup service and container keep running. A failed or timed-out startup restores the previous version and SQLite snapshot, and an @@ -37,6 +38,21 @@ after an image upgrade. Existing images need one image upgrade to gain this feature, and future releases which require a newer supervisor may still need a new image. +Upstream stopped publishing the `@t3code/t3-linux-*` npm packages after +`0.1.1-nightly.20261016.3190`. Images using that source need a new image with +the GitHub archive updater before they can find newer nightlies. Existing +downloaded runtimes, release selections and device credentials remain usable. +Pulling this repository alone does not change an already running container. + +Checks use GitHub's public release API without requiring an account or token. +If `GITHUB_TOKEN` is supplied (for example in CI), it authenticates only release +API requests; it is never sent to archive or checksum download hosts. +A GitHub outage, rate limit, missing archive or invalid checksum leaves the +running version selected and reports the check or installation error. Failed +checks retain the previous release information and its successful check time. +Nightly discovery searches up to 100 recent releases, skipping preview and draft +builds; it reports an error if no nightly can be found within that bound. + After a successful update, obsolete cached releases are removed. The active release, its predecessor and the latest known target for each channel are kept. diff --git a/scripts/bump-versions.sh b/scripts/bump-versions.sh index 03f1fe4..db074a0 100755 --- a/scripts/bump-versions.sh +++ b/scripts/bump-versions.sh @@ -21,12 +21,12 @@ YELLOW=$'\033[33m'; RESET=$'\033[0m' drift=0 changed=0 -# ARG name : npm package. The Dockerfile holds each as `ARG =`. +# ARG name : release source. T3 uses GitHub; the other pins use npm. # Agent harnesses are deliberately absent: they are installed at runtime # through mise, which resolves and records an exact version on explicit install # rather than baking one into the image. PINS=" -T3_VERSION:t3 +T3_VERSION:github-t3 CHROME_DEVTOOLS_MCP_VERSION:chrome-devtools-mcp PLAYWRIGHT_MCP_VERSION:@playwright/mcp " @@ -36,6 +36,14 @@ npm_latest() { | python3 -c 'import sys,json; print(json.load(sys.stdin)["version"])' } +latest_version() { + if [ "$1" = github-t3 ]; then + node --input-type=module -e 'import { packageRelease } from "./docker/runtime/releases.mjs"; console.log((await packageRelease("stable")).version);' + else + npm_latest "$1" + fi +} + printf '%s%-28s %-12s %-12s%s\n' "$BOLD" "PIN" "CURRENT" "LATEST" "$RESET" for entry in $PINS; do @@ -43,8 +51,8 @@ for entry in $PINS; do current="$(grep -m1 "^ARG ${arg}=" Dockerfile | cut -d= -f2)" [ -n "$current" ] || { echo "${RED}no ARG ${arg} in Dockerfile${RESET}" >&2; exit 1; } - if ! latest="$(npm_latest "$pkg")"; then - echo "${RED}could not reach the registry for ${pkg}${RESET}" >&2 + if ! latest="$(latest_version "$pkg")"; then + echo "${RED}could not check published releases for ${pkg}${RESET}" >&2 exit 1 fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index f557c70..2f7d03f 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -466,6 +466,14 @@ t3_update_access_is_gated() { } check "T3 Code upgrades require sign-in, same-site access and a public channel" t3_update_access_is_gated +# Catch a frozen release source or a changed upstream archive layout on each +# native architecture. This only stages a nightly in /tmp; the live server, +# selected release and its database are never changed. +t3_github_archive_stages() { + docker exec -i --user t3 "$NAME" node --input-type=module < "$SCRIPT_DIR/t3-runtime-smoke.mjs" >>"$UI_AUDIT_LOG" 2>&1 +} +check "a real GitHub nightly stages with Setup and supervisor compatibility" t3_github_archive_stages + # This container carries an older image's settings (see LEGACY_ENV). They are # reported, the image still names itself correctly, and nothing it started runs # with them. diff --git a/scripts/t3-runtime-smoke.mjs b/scripts/t3-runtime-smoke.mjs new file mode 100644 index 0000000..fd3c464 --- /dev/null +++ b/scripts/t3-runtime-smoke.mjs @@ -0,0 +1,37 @@ +// Stage a real nightly in a disposable home, without selecting it or restarting +// the container. CI runs this inside both images on native amd64 and arm64. +// Locally: node scripts/t3-runtime-smoke.mjs docker/runtime docker/t3-client/setup-bridge.js +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const directory = path.resolve(process.argv[2] || "/opt/t3-runtime"); +const bridge = path.resolve(process.argv[3] || "/usr/local/share/t3-client/setup-bridge.js"); +const { packageRelease, installRelease } = await import(pathToFileURL(path.join(directory, "manager.mjs"))); +const { runtimePaths, readJson } = await import(pathToFileURL(path.join(directory, "state.mjs"))); +const root = await fs.mkdtemp(path.join(os.tmpdir(), "t3-runtime-smoke-")); +try { + const env = { ...process.env, HOME: root, T3CODE_HOME: path.join(root, "home") }; + const paths = runtimePaths(env); + const release = await packageRelease("nightly"); + assert.match(release.integrity, /^sha256-[a-f0-9]{64}$/); + assert.ok(release.url.endsWith(`-linux-${process.arch}.tar.gz`)); + const staged = await installRelease(paths, release, { env, bridge }); + const metadata = await readJson(path.join(staged, ".installed.json")); + assert.equal(metadata.version, release.version); + assert.equal(metadata.integrity, release.integrity); + assert.ok(metadata.database && metadata.scopes.length, "native auth capabilities were probed"); + const output = await promisify(execFile)(path.join(staged, "t3"), ["--version"], { env, timeout: 30_000 }); + assert.ok(output.stdout.includes(release.version)); + const html = await fs.readFile(path.join(staged, "client/index.html"), "utf8"); + assert.equal(html.split("'], + ]) { + runtime.checkError = error; + await refreshStatus(); + await card.getByText(error + ' The last known releases are shown.', { exact: true }).waitFor(); + assert.equal(await card.locator('script').count(), 0, 'release errors are rendered as text'); + await refreshStatus(); + assert.equal(await card.getByText(error + ' The last known releases are shown.', { exact: true }).count(), 1, 'polling keeps the reason available'); + await check(`release ${name} failure`); + await host.screenshot({ path: path.join(output, `${tag}-release-${name}.png`) }); + } await check('selected channel failure'); await host.screenshot({ path: path.join(output, `${tag}-check-failed.png`) }); await card.getByRole('button', { name: 'Nightly', exact: true }).click(); @@ -337,6 +353,7 @@ const geometry = () => { }; await control().click(); await card.getByText('Stable update available', { exact: true }).waitFor(); + assert.equal(await card.locator('.tc-runtime-notes').count(), 0, 'a successful check clears the persistent warning'); assert.equal(await card.getByRole('button', { name: 'Update and restart', exact: true }).count(), 1); await check('recovered update'); await finishCheck(); diff --git a/tests/t3-releases.test.mjs b/tests/t3-releases.test.mjs index c332f32..aff514d 100644 --- a/tests/t3-releases.test.mjs +++ b/tests/t3-releases.test.mjs @@ -51,9 +51,100 @@ else if (process.argv[2] === 'auth') { const env = { ...process.env, T3CODE_HOME: path.join(root, "home") }; const bridge = path.join(root, "bridge.js"); await fs.writeFile(bridge, 'console.log("data-t3-setup-entry");'); - return { root, release, env, bridge, fetcher: async (url) => new Response(url.endsWith("/SHA256SUMS") ? `${release.integrity.slice(7)} ${stem}.tar.gz\n` : bytes), paths: runtimePaths(env) }; + return { root, release, env, bridge, bytes, fetcher: async (url) => new Response(url.endsWith("/SHA256SUMS") ? `${release.integrity.slice(7)} ${stem}.tar.gz\n` : bytes), paths: runtimePaths(env) }; } +const MiB = 1024 * 1024; +const space = (available) => ({ bavail: available, bsize: 1 }); + +test("a full state volume refuses staging before requesting an archive and keeps live state", async (t) => { + const f = await fixture(t); + const selection = { schema: 1, channel: "stable", version: null }; + await writeJson(f.paths.selection, selection); + await fs.mkdir(path.join(f.paths.home, "userdata"), { recursive: true }); + const live = path.join(f.paths.home, "userdata/state.sqlite"); + await fs.writeFile(live, "live database"); + let fetched = false; + t.mock.method(fs, "statfs", async () => space(64 * MiB)); + await assert.rejects(installRelease(f.paths, f.release, { ...f, fetcher: async () => { fetched = true; } }), /free space.*Free up storage/); + assert.equal(fetched, false); + assert.deepEqual(await fs.readdir(f.paths.versions), []); + assert.deepEqual(await readJson(f.paths.selection), selection); + assert.equal(await fs.readFile(live, "utf8"), "live database"); +}); + +test("a known archive size admits a small release without requiring the whole download limit", async (t) => { + const f = await fixture(t); + t.mock.method(fs, "statfs", async () => space(65 * MiB)); + const staged = await installRelease(f.paths, f.release, { ...f, + fetcher: async () => new Response(f.bytes, { headers: { "content-length": String(f.bytes.length) } }), + }); + assert.equal((await readJson(path.join(staged, ".installed.json"))).version, VERSION); +}); + +test("insufficient download space cancels the response without creating an archive", async (t) => { + const f = await fixture(t); + t.mock.method(fs, "statfs", async () => space(64 * MiB + f.bytes.length - 1)); + for (const header of [String(f.bytes.length), undefined, "invalid", "-1"]) { + const stage = await fs.mkdtemp(path.join(f.root, "stage-")); + let cancelled = false; + const fetcher = async () => new Response(new ReadableStream({ cancel() { cancelled = true; } }), { + headers: header === undefined ? {} : { "content-length": header }, + }); + await assert.rejects(unpackRelease(stage, f.release, { fetcher }), /free space/); + assert.equal(cancelled, true, `response cancelled for length ${header}`); + assert.deepEqual(await fs.readdir(stage), []); + } +}); + +test("unknown download lengths require space for the bounded download and reserved headroom", async (t) => { + const f = await fixture(t); + t.mock.method(fs, "statfs", async () => space(364 * MiB)); + const staged = await installRelease(f.paths, f.release, f); + assert.equal((await readJson(path.join(staged, ".installed.json"))).version, VERSION); +}); + +test("disk pressure during a stream stops before writing into the server's reserved space", async (t) => { + const f = await fixture(t); + const stage = await fs.mkdtemp(path.join(f.root, "stage-")); + let checks = 0, cancelled = false; + t.mock.method(fs, "statfs", async () => space(++checks < 3 ? 512 * MiB : 68 * MiB - 1)); + const fetcher = async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(Buffer.alloc(4 * MiB)); }, + cancel() { cancelled = true; }, + })); + await assert.rejects(unpackRelease(stage, f.release, { fetcher }), /free space/); + assert.equal((await fs.stat(path.join(stage, "download.tgz"))).size, 4 * MiB); + assert.equal(cancelled, true); + assert.equal(await readJson(f.paths.selection), null); +}); + +test("disk space is checked again before extraction and failed staging is removed", async (t) => { + const f = await fixture(t); + let checks = 0; + const commands = []; + t.mock.method(fs, "statfs", async () => space(++checks < 3 ? 512 * MiB : 64 * MiB)); + await assert.rejects(installRelease(f.paths, f.release, { ...f, run: async (command, args, options) => { + commands.push(args[0]); + return execute(command, args, options); + } }), /free space/); + assert.deepEqual(commands, ["-tzf", "-tvzf"], "an admitted download never implies extraction is safe"); + assert.deepEqual(await fs.readdir(f.paths.versions), []); +}); + +test("oversized or misleading content lengths never publish or execute a runtime", async (t) => { + const f = await fixture(t); + for (const length of [301 * MiB, f.bytes.length - 1, f.bytes.length + 1]) { + let executed = false; + await assert.rejects(installRelease(f.paths, f.release, { ...f, + fetcher: async () => new Response(f.bytes, { headers: { "content-length": String(length) } }), + run: async () => { executed = true; }, + }), /supported size|declared size/); + assert.equal(executed, false); + assert.deepEqual(await fs.readdir(f.paths.versions), []); + } +}); + test("both architectures resolve public GitHub releases and their exact checksums", async () => { for (const arch of ["x64", "arm64"]) for (const channel of ["stable", "nightly"]) { const release = await packageRelease(channel, { fetcher: source, arch }); From fad17f6e338d15727dd42e29e298976b5dfd0c1d Mon Sep 17 00:00:00 2001 From: Ziyang Zeng Date: Tue, 20 Oct 2026 16:04:03 +0000 Subject: [PATCH 4/4] fix(ci): authenticate and diagnose release pin lookups Use the existing CI token only for GitHub API requests, pass it through stdin instead of command arguments, and retry bounded transient metadata failures. Keep registry lookups unauthenticated and real version drift blocking. Retain pin-check output in smoke diagnostics. Add regressions for token isolation, public lookups, unavailable metadata and stale or invalid releases. --- scripts/bump-versions.sh | 17 ++++++-- scripts/smoke-test.sh | 12 +++--- tests/version-pins.test.mjs | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/version-pins.test.mjs diff --git a/scripts/bump-versions.sh b/scripts/bump-versions.sh index 29014ab..f5df3f8 100755 --- a/scripts/bump-versions.sh +++ b/scripts/bump-versions.sh @@ -32,13 +32,24 @@ PLAYWRIGHT_MCP_VERSION:@playwright/mcp " npm_latest() { - curl -fsS --max-time 30 "https://registry.npmjs.org/$1/latest" \ + curl -fsS --connect-timeout 5 --max-time 30 --retry 2 --retry-max-time 20 \ + "https://registry.npmjs.org/$1/latest" \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["version"])' } +github_latest() { + # CI already supplies a token. Keep it out of process arguments and send it + # only to api.github.com; registry and archive requests remain unauthenticated. + local headers="" + [ -z "${GITHUB_TOKEN:-}" ] || headers="Authorization: Bearer ${GITHUB_TOKEN}" + printf '%s\n' "$headers" | curl -fsS --connect-timeout 5 --max-time 30 \ + --retry 2 --retry-max-time 20 --header @- \ + "https://api.github.com/repos/$1/releases/latest" +} + latest_version() { if [ "$1" = github-t3 ]; then - curl -fsS --max-time 30 https://api.github.com/repos/pingdotgg/t3code/releases/latest \ + github_latest pingdotgg/t3code \ | python3 -c 'import sys,json,re; tag=json.load(sys.stdin)["tag_name"]; re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) or sys.exit("Expected a stable T3 release tag"); print(tag[1:])' else npm_latest "$1" @@ -86,7 +97,7 @@ echo "${DIM}T3 Code's own declared minimum is checked by the smoke test.${RESET} # Reported, never counted as drift and never bumped here: a new release also # needs its checksums and the idiomatic allowlist regenerated. mise_pin="$(grep -m1 '^ARG MISE_VERSION=' Dockerfile | cut -d= -f2)" -mise_latest="$(curl -fsS --max-time 30 https://api.github.com/repos/jdx/mise/releases/latest 2>/dev/null \ +mise_latest="$(github_latest jdx/mise 2>/dev/null \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"].lstrip("v"))' 2>/dev/null || echo "?")" if [ "$mise_pin" = "$mise_latest" ]; then printf '%smise%s %s is current\n' "$DIM" "$RESET" "$mise_pin" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2f7d03f..51145d5 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -63,8 +63,8 @@ retry() { local n=$1; shift; local i; for i in $(seq 1 "$n"); do STATE_MOUNT="" PAGE_HTML="" SETUP_COPY="" -# The layout audit runs with its output discarded, so record it here and print -# it with the failure summary. +# Keep verbose check output for the failure summary, including live pin +# lookups and the layout audits which otherwise have their output discarded. UI_AUDIT_LOG="${UI_AUDIT_LOG:-}" if [ -z "$UI_AUDIT_LOG" ]; then UI_AUDIT_LOG="$(mktemp "${TMPDIR:-/tmp}/t3-ui-audit.XXXXXX")" @@ -219,8 +219,10 @@ fi # A freshly built image that immediately asks you to upgrade an agent is a bug # in this repo, not in the agent. The pins are what go stale, so assert they # were current when the image was built. -check "the image's pinned versions were current at build time" \ - "./scripts/bump-versions.sh --check" +pinned_versions_current() { + "$SCRIPT_DIR/bump-versions.sh" --check >>"$UI_AUDIT_LOG" 2>&1 +} +check "the image's pinned versions were current at build time" pinned_versions_current printf '\nPairing\n' pair_out="$(docker exec "$NAME" t3-pair --no-qr 2>/dev/null || true)" @@ -1669,7 +1671,7 @@ docker rm -f "${NAME}-boot" >/dev/null 2>&1 || true printf '\n%d passed, %d failed\n\n' "$pass" "$fail" if [ "$fail" -gt 0 ] && [ -s "$UI_AUDIT_LOG" ]; then - printf 'Browser/native skill audit output:\n' + printf 'Smoke test diagnostics:\n' sed 's/^/ /' "$UI_AUDIT_LOG" printf '\n' fi diff --git a/tests/version-pins.test.mjs b/tests/version-pins.test.mjs new file mode 100644 index 0000000..67837fd --- /dev/null +++ b/tests/version-pins.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execute = promisify(execFile); +const TOKEN = "pin-check-test-token"; + +async function fixture(t, extra = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "t3-pin-test-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await fs.mkdir(path.join(root, "scripts")); + await fs.mkdir(path.join(root, "bin")); + await fs.copyFile(new URL("../scripts/bump-versions.sh", import.meta.url), path.join(root, "scripts/bump-versions.sh")); + const dockerfile = "ARG T3_VERSION=0.1.0\nARG CHROME_DEVTOOLS_MCP_VERSION=1.0.0\nARG PLAYWRIGHT_MCP_VERSION=1.0.0\nARG GH_MIN_VERSION=2.81.0\nARG MISE_VERSION=2026.9.10\n"; + await fs.writeFile(path.join(root, "Dockerfile"), dockerfile); + await fs.writeFile(path.join(root, "bin/curl"), `#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +url = next(arg for arg in args if arg.startswith("https://")) +headers = sys.stdin.read() if "@-" in args else "" +with open(os.environ["PIN_REQUESTS"], "a") as log: + log.write(json.dumps({"url": url, "args": args, "headers": headers}) + "\\n") +if os.environ.get("PIN_OFFLINE") and "pingdotgg/t3code" in url: + print("curl: (22) The requested URL returned error: 503", file=sys.stderr) + sys.exit(22) +if "pingdotgg/t3code" in url: + print(json.dumps({"tag_name": os.environ.get("PIN_T3_TAG", "v0.1.0")})) +elif "jdx/mise" in url: + print(json.dumps({"tag_name": "v2026.9.10"})) +else: + print(json.dumps({"version": "1.0.0"})) +`, { mode: 0o755 }); + const env = { ...process.env, PATH: `${path.join(root, "bin")}:${process.env.PATH}`, GITHUB_TOKEN: TOKEN, PIN_REQUESTS: path.join(root, "requests.jsonl"), ...extra }; + const run = () => execute("bash", [path.join(root, "scripts/bump-versions.sh"), "--check"], { env, timeout: 5000 }); + const requests = async () => (await fs.readFile(env.PIN_REQUESTS, "utf8")).trim().split("\n").map(JSON.parse); + return { root, run, requests, dockerfile }; +} + +test("pin checks authenticate only GitHub metadata and keep tokens out of arguments and output", async (t) => { + const f = await fixture(t); + const result = await f.run(); + assert.match(result.stdout, /every pin is current/); + assert.equal((result.stdout + result.stderr).includes(TOKEN), false); + const requests = await f.requests(); + assert.equal(requests.length, 4); + for (const request of requests) { + assert.equal(request.args.some((arg) => arg.includes(TOKEN)), false); + assert.equal(request.headers.trim(), request.url.startsWith("https://api.github.com/") ? `Authorization: Bearer ${TOKEN}` : ""); + } +}); + +test("public pin checks also work without a GitHub token", async (t) => { + const f = await fixture(t, { GITHUB_TOKEN: "" }); + assert.match((await f.run()).stdout, /every pin is current/); + assert.equal((await f.requests()).every((request) => !request.headers.trim()), true); +}); + +test("failed release lookups fail closed and preserve useful diagnostics", async (t) => { + const f = await fixture(t, { PIN_OFFLINE: "1" }); + await assert.rejects(f.run(), (error) => { + assert.equal(error.code, 1); + assert.match(error.stderr, /HTTP|503/); + assert.match(error.stderr, /could not check published releases for github-t3/); + assert.equal((error.stdout + error.stderr).includes(TOKEN), false); + return true; + }); + assert.equal(await fs.readFile(path.join(f.root, "Dockerfile"), "utf8"), f.dockerfile); +}); + +test("a newer or invalid upstream version still blocks the pin check without editing the image", async (t) => { + for (const tag of ["v0.1.1", "v0.1.1-preview.1"]) { + const f = await fixture(t, { PIN_T3_TAG: tag }); + await assert.rejects(f.run(), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout + error.stderr, tag === "v0.1.1" ? /pin\(s\) behind/ : /Expected a stable T3 release tag/); + return true; + }); + assert.equal(await fs.readFile(path.join(f.root, "Dockerfile"), "utf8"), f.dockerfile); + } +});