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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 7 additions & 20 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"; \
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions docker/runtime/install.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
66 changes: 6 additions & 60 deletions docker/runtime/manager.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 } = {}) {
Expand All @@ -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) => {
Expand All @@ -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");
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading