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..0023965 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 } 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..c48f10e --- /dev/null +++ b/docker/runtime/releases.mjs @@ -0,0 +1,181 @@ +// 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 HEADROOM = 64 * 1024 * 1024; +const SPACE_CHECK_BYTES = 8 * 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", root, url: `${base}/${name}`, integrity: `sha256-${archiveChecksum(sums, name)}` }; +} + +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; +} + +const spaceError = () => new Error("There is not enough free space to stage this T3 Code release. Free up storage and try again."); +async function writableSpace(directory, required) { + const space = await fs.statfs(directory); + const available = Number(space.bavail) * Number(space.bsize) - HEADROOM; + if (available < required) throw spaceError(); + return available; +} + +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"); + // Refuse a full volume before requesting the archive. Once headers arrive, + // admit its actual size (or the download limit when no length is supplied). + await writableSpace(stage, 1); + const response = await get(release.url, { fetcher, timeout: 180_000 }); + const hash = createHash("sha256"); + let bytes = 0; + let file; + try { + const header = response.headers.get("content-length"); + const length = /^\d+$/.test(header ?? "") && Number.isSafeInteger(Number(header)) ? Number(header) : null; + if (length !== null && length > LIMIT) throw new Error("The release download exceeded the supported size."); + let writable = await writableSpace(stage, length ?? LIMIT); + let checkedAt = 0; + file = await fs.open(archive, "wx", 0o600); + for await (const chunk of response.body) { + if (bytes + chunk.length > LIMIT) throw new Error("The release download exceeded the supported size."); + if (length !== null && bytes + chunk.length > length) throw new Error("T3 Code's download did not match its declared size."); + // Other processes keep using this volume while T3 runs. Recheck in + // bounded batches, and before a chunk could consume the reserved space. + if (bytes + chunk.length - checkedAt >= SPACE_CHECK_BYTES || chunk.length > writable) { + writable = await writableSpace(stage, length === null ? chunk.length : length - bytes); + checkedAt = bytes; + } + writable -= chunk.length; + hash.update(chunk); + await file.writeFile(chunk); + bytes += chunk.length; + } + if (length !== null && bytes !== length) throw new Error("T3 Code's download did not match its declared size."); + await file.sync(); + } catch (error) { + if (error.code === "ENOSPC" || error.code === "EDQUOT") throw spaceError(); + throw error; + } finally { + try { await file?.close(); } + finally { await response.body?.cancel().catch(() => {}); } + } + 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); + await writableSpace(stage, unpacked); + 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/docker/setup/app.js b/docker/setup/app.js index 0dced3e..d0876c7 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -1473,7 +1473,7 @@ ${view.error || view.checkError ? html`
${view.error ? notice('danger', 'circle-alert', 'The last T3 Code update didn’t finish', view.error) : ''} - ${view.checkError ? notice('warn', 'globe', 'Couldn’t check for T3 Code updates', 'The last known releases are shown. Check again once the registry is reachable.') : ''} + ${view.checkError ? notice('warn', 'globe', 'Couldn’t check for T3 Code updates', view.checkError + ' The last known releases are shown.') : ''}
` : ''} ${!view.supported || view.separateHistory ? html`
${icon('info', 'tc-icon--sm')}${!view.supported ? 'Updating in place needs the container’s T3 Code supervisor. Pull a newer image once to turn it on.' diff --git a/docs/t3code-updates.md b/docs/t3code-updates.md index aea5eaa..9ab4dd0 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,26 @@ 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. + +Staging checks free space before downloading and extracting, reserving +64 MiB of headroom for the running server. Downloads without a declared size +need room for the 300 MiB download limit as well. Space is checked again during +the download so other activity on the volume can stop staging safely. + 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/auto-update-ui-audit.js b/scripts/auto-update-ui-audit.js index 8f13297..477ecd8 100644 --- a/scripts/auto-update-ui-audit.js +++ b/scripts/auto-update-ui-audit.js @@ -71,7 +71,7 @@ const geometry = () => { let holdStatus = null; const runtime = { ...fixture.t3?.runtime, channel: 'stable', version: '0.1.0', releases: { stable: { version: '0.1.0' }, nightly: { version: '0.1.1-nightly.20261017.1' } }, checkedAt: 1, checking: false, - checkError: 'registry offline', operation: null, supported: true }; + checkError: "GitHub's release API rate limit was reached. Try checking again later; the running version was kept.", operation: null, supported: true }; let requestedChannel; await host.route('**/*', async (route) => { @@ -317,6 +317,22 @@ const geometry = () => { const card = page.getByRole('region', { name: 'T3 Code', exact: true }); await card.getByText('Couldn’t check for T3 Code updates', { exact: true }).waitFor(); assert.equal(await card.getByText('Up to date', { exact: true }).count(), 0); + // Background checks have no toast. Their reason must remain visible + // after ordinary polls, with remote text escaped and long names wrapped. + for (const [name, error] of [ + ['rate-limit', runtime.checkError], + ['checksum', 'The release must list exactly one SHA-256 checksum for t3-0.1.1-nightly.20261019.3373-linux-arm64.tar.gz.'], + ['metadata', 'GitHub returned invalid metadata: '], + ]) { + 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/scripts/bump-versions.sh b/scripts/bump-versions.sh index 03f1fe4..f5df3f8 100755 --- a/scripts/bump-versions.sh +++ b/scripts/bump-versions.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Refresh the pinned tool versions in the Dockerfile against their registries. +# Refresh the pinned tool versions in the Dockerfile against published releases. # # The pins exist so a rebuild is reproducible. The cost of pinning is that an # image goes stale the moment upstream ships, and a freshly built image that @@ -21,21 +21,41 @@ 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 " 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 + 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" + fi +} + printf '%s%-28s %-12s %-12s%s\n' "$BOLD" "PIN" "CURRENT" "LATEST" "$RESET" for entry in $PINS; do @@ -43,8 +63,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 @@ -77,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 f557c70..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)" @@ -466,6 +468,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. @@ -1661,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/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("