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`