diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000000..a416089ca12a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +.git +.github +.repos +**/.t3 +**/.env +**/.env.* +**/.codex/auth.json +**/.codex/*.sqlite +**/.codex/*.sqlite-* +**/.codex/*.db +**/.codex/*.db-* +**/.claude.json +**/.claude/.credentials.json +**/.cursor/cli-config.json +**/.config/opencode +**/.local/share/opencode +**/.ssh +**/.npmrc +**/.pnpmrc +**/.yarnrc +**/.yarnrc.yml +**/node_modules +**/dist +**/.vite-plus +**/.turbo +**/.tanstack +**/coverage +**/playwright-report +**/*.log +**/*.tsbuildinfo +scripts/docker-config.test.ts +scripts/docker-e2e.ts +artifacts +build +dist-electron +release +release-mock +native/**/target diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000000..523a249c33f8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,63 @@ +name: Docker E2E + +on: + pull_request: + paths: + - .dockerignore + - .gitignore + - Dockerfile + - compose.yaml + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - patches/** + - apps/server/** + - apps/web/** + - packages/** + - scripts/docker-config.test.ts + - scripts/docker-e2e.ts + - .github/workflows/docker.yml + push: + branches: + - main + paths: + - .dockerignore + - .gitignore + - Dockerfile + - compose.yaml + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - patches/** + - apps/server/** + - apps/web/** + - packages/** + - scripts/docker-config.test.ts + - scripts/docker-e2e.ts + - .github/workflows/docker.yml + +concurrency: + group: docker-e2e-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + docker_e2e: + name: Build and exercise image + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Run Docker end-to-end test + run: node scripts/docker-e2e.ts diff --git a/.gitignore b/.gitignore index 07793efe9b52..1ca542a5bed1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,18 @@ node_modules/ *.log .env* !.env.example +.docker-e2e-canary/ + +# Machine-local credentials. Project configuration under .codex, .claude, and +# .cursor may be tracked, but provider login state must never enter the repo. +/.codex/auth.json +/.codex/*.sqlite +/.codex/*.sqlite-* +/.codex/*.db +/.codex/*.db-* +/.claude.json +/.claude/.credentials.json +/.cursor/cli-config.json +/.config/opencode/ +/.local/share/opencode/ +/.ssh/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000000..e6e4c79dc2a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,110 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_VERSION=24.13.1 + +FROM node:${NODE_VERSION}-bookworm AS builder + +ENV CI=true + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends g++ make python3 \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable + +WORKDIR /src + +# Keep dependency installation cacheable when application source changes. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches ./patches +COPY apps/server/package.json ./apps/server/package.json +COPY apps/web/package.json ./apps/web/package.json +COPY packages/client-runtime/package.json ./packages/client-runtime/package.json +COPY packages/contracts/package.json ./packages/contracts/package.json +COPY packages/effect-acp/package.json ./packages/effect-acp/package.json +COPY packages/effect-codex-app-server/package.json ./packages/effect-codex-app-server/package.json +COPY packages/shared/package.json ./packages/shared/package.json +COPY packages/tailscale/package.json ./packages/tailscale/package.json + +RUN --mount=type=cache,id=t3-pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --filter t3... --ignore-scripts + +COPY . . + +# Defense in depth: these paths must be removed from the context by +# .dockerignore, even when a developer has authenticated locally. +RUN test ! -e .env \ + && test ! -e .codex/auth.json \ + && test ! -e .claude.json \ + && test ! -e .claude/.credentials.json \ + && test ! -e .cursor/cli-config.json \ + && test ! -e .config/opencode \ + && test ! -e .local/share/opencode \ + && test ! -e .ssh \ + && test -z "$(find .docker-e2e-canary -type f -print -quit 2> /dev/null)" + +RUN pnpm rebuild esbuild msgpackr-extract node-pty +RUN pnpm --filter @t3tools/web exec vp build +RUN pnpm --filter t3 run build:bundle \ + && cp -R apps/web/dist apps/server/dist/client +RUN pnpm --filter t3 deploy --prod --legacy /out/t3 + +FROM node:${NODE_VERSION}-bookworm-slim AS runtime + +ARG T3CODE_PROVIDER_PACKAGES="@openai/codex@latest @anthropic-ai/claude-code@latest opencode-ai@latest" +ARG T3CODE_INSTALL_PROVIDERS=1 +ARG T3CODE_INSTALL_CURSOR=1 + +LABEL org.opencontainers.image.source="https://github.com/pingdotgg/t3code" + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl gh git openssh-client procps ripgrep \ + && rm -rf /var/lib/apt/lists/* + +# Provider CLIs must live in the container; host-installed binaries are not +# visible here. Set T3CODE_INSTALL_PROVIDERS=0 for a server-only image, or +# replace this argument with a pinned/custom package list. +RUN if [ "${T3CODE_INSTALL_PROVIDERS}" = "1" ] && [ -n "${T3CODE_PROVIDER_PACKAGES}" ]; then \ + npm install --global ${T3CODE_PROVIDER_PACKAGES}; \ + fi \ + && npm cache clean --force + +# Cursor distributes its Linux CLI through its own installer rather than npm. +# Keep the executable outside HOME so it remains available when /home/node is +# backed by an existing volume. Provider updates happen by rebuilding the image. +RUN if [ "${T3CODE_INSTALL_CURSOR}" = "1" ]; then \ + mkdir -p /opt/cursor-home \ + && curl --fail --silent --show-error --location https://cursor.com/install --output /tmp/install-cursor.sh \ + && HOME=/opt/cursor-home bash /tmp/install-cursor.sh \ + && ln -s /opt/cursor-home/.local/bin/cursor-agent /usr/local/bin/cursor-agent \ + && ln -s /opt/cursor-home/.local/bin/agent /usr/local/bin/agent \ + && rm /tmp/install-cursor.sh; \ + fi + +COPY --from=builder --chown=node:node /out/t3 /opt/t3 + +RUN chmod +x /opt/t3/dist/bin.mjs \ + && ln -s /opt/t3/dist/bin.mjs /usr/local/bin/t3 \ + && mkdir -p /home/node/.local /home/node/.t3 /workspace \ + && chown -R node:node /home/node /workspace + +ENV HOME=/home/node \ + NODE_ENV=production \ + NPM_CONFIG_PREFIX=/home/node/.local \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + PATH=/home/node/.local/bin:/usr/local/bin:/usr/bin:/bin \ + T3CODE_HOME=/home/node/.t3 \ + T3CODE_HOST=0.0.0.0 \ + T3CODE_NO_BROWSER=true \ + T3CODE_PORT=3773 + +WORKDIR /workspace +USER node + +EXPOSE 3773 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:3773/ > /dev/null || exit 1 + +ENTRYPOINT ["t3"] +CMD ["serve", "/workspace"] diff --git a/README.md b/README.md index 8ec101387f67..c207e1e661a5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Keyboard shortcuts](./docs/user/keybindings.md) - [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) +- [Run T3 Code with Docker](./docs/user/docker.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) ยท [Claude](./docs/user/providers-claude.md) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000000..fe1f29220159 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + t3: + build: + context: . + args: + T3CODE_INSTALL_CURSOR: ${T3CODE_INSTALL_CURSOR:-1} + T3CODE_INSTALL_PROVIDERS: ${T3CODE_INSTALL_PROVIDERS:-1} + T3CODE_PROVIDER_PACKAGES: ${T3CODE_PROVIDER_PACKAGES-@openai/codex@latest @anthropic-ai/claude-code@latest opencode-ai@latest} + image: ${T3_IMAGE:-t3-code:local} + hostname: ${T3_HOSTNAME:-t3-code} + init: true + restart: unless-stopped + ports: + - "${T3_BIND_ADDRESS:-127.0.0.1}:${T3_PORT:-3773}:3773" + environment: + T3CODE_HOME: /home/node/.t3 + T3CODE_HOST: 0.0.0.0 + T3CODE_NO_BROWSER: "true" + T3CODE_PORT: 3773 + volumes: + - type: volume + source: t3-home + target: /home/node + - type: bind + source: ${T3_WORKSPACE_PATH:-.} + target: /workspace + +volumes: + t3-home: diff --git a/docs/README.md b/docs/README.md index f1698a66e179..4a41ad2b55af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ - [Customize a project icon](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) - [Remote access](./user/remote-access.md) +- [Docker](./user/docker.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) diff --git a/docs/user/docker.md b/docs/user/docker.md new file mode 100644 index 000000000000..507aaf19aa39 --- /dev/null +++ b/docs/user/docker.md @@ -0,0 +1,95 @@ +# Docker + +The Docker image runs T3 Code, its web client, and the Codex, Claude Code, Cursor, and OpenCode +CLIs in one isolated environment. It can only work with directories mounted into the container; +tools and credentials installed on the host are not automatically available inside it. + +## Start the container + +Set the workspace path to the directory the agents should be able to edit, then start the Compose +service from the T3 Code repository: + +```bash +T3_WORKSPACE_PATH=/absolute/path/to/code docker compose up --build +``` + +In PowerShell: + +```powershell +$env:T3_WORKSPACE_PATH = "C:\path\to\code" +docker compose up --build +``` + +The first build installs T3 Code and the supported provider CLIs. Later starts reuse the `t3-home` +volume, which contains T3 Code data, provider logins, Git configuration, and other files in the +container user's home directory. The container has the stable hostname `t3-code` by default, so +remote clients do not display a generated container ID as the environment name. + +## Pair the browser + +The startup log prints a one-time pairing URL. Because T3 Code detects the container's internal +address, replace only that URL's origin with `http://localhost:3773` and keep the +`/pair#token=...` portion unchanged. + +For example: + +```text +Printed: http://172.18.0.2:3773/pair#token=... +Open: http://localhost:3773/pair#token=... +``` + +If the link expired or was already used, create another one: + +```bash +docker compose exec t3 t3 pair +``` + +Then add `/workspace` as a project in T3 Code. + +## Sign in to a provider + +Provider authentication happens inside the container and remains in the `t3-home` volume: + +```bash +docker compose exec t3 codex login --device-auth +docker compose exec t3 claude auth login +docker compose exec t3 agent login +docker compose exec t3 opencode auth login +``` + +These are subscription login sessions, not API keys baked into the image. Do not copy provider +credential files into the repository, pass them as Docker build arguments, or commit an exported +`t3-home` volume. Build arguments and image layers are not secret storage. + +Grok is not installed by the default image. To use it, install its Linux CLI in a derived image and +select that executable in T3 Code's provider settings. + +## Configuration + +The Compose setup supports these environment variables: + +- `T3_WORKSPACE_PATH`: host directory mounted at `/workspace`; defaults to the T3 Code repository. +- `T3_IMAGE`: image name used by Compose; defaults to `t3-code:local`. +- `T3_HOSTNAME`: stable environment name reported by the container; defaults to `t3-code`. +- `T3_PORT`: published host port; defaults to `3773`. +- `T3_BIND_ADDRESS`: host interface used for the published port; defaults to `127.0.0.1`. +- `T3CODE_INSTALL_CURSOR`: set to `0` to omit Cursor Agent; defaults to `1`. +- `T3CODE_INSTALL_PROVIDERS`: set to `0` for a server-only image; defaults to `1`. +- `T3CODE_PROVIDER_PACKAGES`: space-separated npm packages installed in the image. Provide pinned + versions for reproducible builds. + +Runtime-only settings, including the public T3 Connect configuration, can be added to the Compose +service's `environment` section. Keep private values in a local ignored environment file or Docker +secret and pass them only at runtime. + +To make the server reachable from another device on a trusted network, set +`T3_BIND_ADDRESS=0.0.0.0` before starting it. Pairing is still required. Prefer an HTTPS endpoint +for access from `https://app.t3.codes`; browsers block connections from that hosted app to a plain +HTTP backend. + +The container runs as UID/GID `1000:1000`. On Linux, the mounted workspace must be readable and +writable by that user. The image does not mount the Docker socket; add it only if an agent +explicitly needs Docker access, since doing so grants broad control over the host. + +To remove the container while retaining state, run `docker compose down`. Adding `--volumes` also +deletes the persistent T3 and provider state. diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..27c708bb4272 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -17,6 +17,12 @@ npx t3@latest This starts the T3 Code server on your machine and opens the local web app. Use `npx t3@latest --help` for the full CLI reference. +## Docker + +For an isolated, headless environment, use the [Docker setup](./docker.md). It keeps T3 Code +and provider credentials in a persistent volume while exposing only the workspace you mount into +the container. + ## Desktop App Download the latest release from diff --git a/package.json b/package.json index 3fc66d0dd021..3087a97da0fe 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test": "vp run -r test", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", + "test:docker": "node scripts/docker-e2e.ts", "fmt": "vp fmt", "fmt:check": "vp fmt --check", "build:contracts": "vp run --filter @t3tools/contracts build", diff --git a/scripts/docker-config.test.ts b/scripts/docker-config.test.ts new file mode 100644 index 000000000000..807d1169d5f7 --- /dev/null +++ b/scripts/docker-config.test.ts @@ -0,0 +1,110 @@ +// @effect-diagnostics nodeBuiltinImport:off - validates repository-owned Docker files directly. +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vite-plus/test"; +import { parse } from "yaml"; + +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const readRepositoryFile = (path: string) => readFileSync(join(repositoryRoot, path), "utf8"); + +const expectedCredentialIgnores = [ + "**/.t3", + "**/.env", + "**/.env.*", + "**/.codex/auth.json", + "**/.claude.json", + "**/.claude/.credentials.json", + "**/.cursor/cli-config.json", + "**/.config/opencode", + "**/.local/share/opencode", + "**/.ssh", +] as const; + +describe("Docker distribution", () => { + it("keeps credential-bearing paths out of the build context", () => { + const ignored = new Set( + readRepositoryFile(".dockerignore") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")), + ); + + for (const pattern of expectedCredentialIgnores) { + expect(ignored.has(pattern), `missing .dockerignore rule: ${pattern}`).toBe(true); + } + }); + + it("keeps machine-local provider logins out of git", () => { + const gitignore = readRepositoryFile(".gitignore"); + for (const pattern of [ + ".docker-e2e-canary/", + "/.codex/auth.json", + "/.claude.json", + "/.claude/.credentials.json", + "/.cursor/cli-config.json", + "/.config/opencode/", + "/.local/share/opencode/", + "/.ssh/", + ]) { + expect(gitignore).toContain(pattern); + } + }); + + it("runs Docker E2E when direct build inputs change", () => { + const workflow = readRepositoryFile(".github/workflows/docker.yml"); + + expect(workflow.match(/- pnpm-workspace\.yaml/gu)).toHaveLength(2); + expect(workflow.match(/- patches\/\*\*/gu)).toHaveLength(2); + }); + + it("exercises the default provider-enabled image", () => { + const e2e = readRepositoryFile("scripts/docker-e2e.ts"); + + expect(e2e).not.toContain('T3CODE_INSTALL_CURSOR: "0"'); + expect(e2e).not.toContain('T3CODE_INSTALL_PROVIDERS: "0"'); + expect(e2e).toContain('["codex", "claude", "opencode", "cursor-agent", "agent"]'); + }); + + it("builds a non-root runtime without secret-valued build arguments", () => { + const dockerfile = readRepositoryFile("Dockerfile"); + + expect(dockerfile).toContain("USER node"); + expect(dockerfile).toContain('ENTRYPOINT ["t3"]'); + expect(dockerfile).toContain('CMD ["serve", "/workspace"]'); + expect(dockerfile).not.toMatch( + /^\s*(?:ARG|ENV)\s+[^\n]*(?:ACCESS_TOKEN|AUTH_TOKEN|API_KEY|PASSWORD|PRIVATE_KEY|SECRET_KEY)/imu, + ); + }); + + it("mounts state and source separately without exposing the Docker socket", () => { + const compose = parse(readRepositoryFile("compose.yaml")) as { + readonly services: { + readonly t3: { + readonly hostname: string; + readonly environment: Readonly>; + readonly volumes: ReadonlyArray<{ + readonly type: string; + readonly source: string; + readonly target: string; + }>; + }; + }; + }; + const service = compose.services.t3; + + expect(service.hostname).toBe("${T3_HOSTNAME:-t3-code}"); + expect(service.environment).toEqual({ + T3CODE_HOME: "/home/node/.t3", + T3CODE_HOST: "0.0.0.0", + T3CODE_NO_BROWSER: "true", + T3CODE_PORT: 3773, + }); + expect(service.volumes).toEqual([ + { type: "volume", source: "t3-home", target: "/home/node" }, + { type: "bind", source: "${T3_WORKSPACE_PATH:-.}", target: "/workspace" }, + ]); + expect(JSON.stringify(service)).not.toContain("/var/run/docker.sock"); + }); +}); diff --git a/scripts/docker-e2e.ts b/scripts/docker-e2e.ts new file mode 100644 index 000000000000..8884d7ad9bd0 --- /dev/null +++ b/scripts/docker-e2e.ts @@ -0,0 +1,349 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off globalFetch:off globalConsole:off - Host-side Docker automation owns subprocesses, timing, HTTP probes, and terminal reporting. +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface CommandOptions { + readonly env?: NodeJS.ProcessEnv; + readonly inherit?: boolean; + readonly tolerateFailure?: boolean; +} + +interface CommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const composeFile = join(repositoryRoot, "compose.yaml"); +const runSuffix = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`.toLowerCase(); +const projectName = `t3docker${runSuffix}`; +const imageName = `t3-code-e2e:${runSuffix}`; +const stableHostname = "t3-code-e2e"; +const syntheticCredential = `synthetic-docker-e2e-${runSuffix}`; +const buildContextCanaryRoot = join(repositoryRoot, ".docker-e2e-canary"); +const buildContextCanaries = [ + join(buildContextCanaryRoot, ".codex", "auth.json"), + join(buildContextCanaryRoot, ".claude.json"), + join(buildContextCanaryRoot, ".cursor", "cli-config.json"), + join(buildContextCanaryRoot, ".config", "opencode", "auth.json"), +] as const; + +function redact(value: string): string { + return value + .replace(/(pair#token=)[A-Za-z0-9_-]+/giu, "$1") + .replace(/^(Token:\s*).+$/gimu, "$1") + .replace(/(authorization:\s*bearer\s+)[A-Za-z0-9._-]+/giu, "$1"); +} + +async function run( + command: string, + args: ReadonlyArray, + options: CommandOptions = {}, +): Promise { + return await new Promise((complete, reject) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env: options.env ?? process.env, + shell: false, + stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", reject); + child.once("close", (code) => { + const result = { code: code ?? 1, stdout, stderr }; + if (result.code === 0 || options.tolerateFailure) { + complete(result); + return; + } + reject( + new Error( + redact( + `${command} ${args.join(" ")} failed with exit code ${result.code}\n${stdout}${stderr}`, + ), + ), + ); + }); + }); +} + +async function freeTcpPort(): Promise { + return await new Promise((complete, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + reject(new Error("Docker E2E could not reserve a TCP port.")); + return; + } + server.close((error) => (error ? reject(error) : complete(address.port))); + }); + }); +} + +async function waitForDescriptor(port: number): Promise> { + const deadline = Date.now() + 120_000; + const url = `http://127.0.0.1:${port}/.well-known/t3/environment`; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (response.ok) { + return (await response.json()) as Record; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((complete) => setTimeout(complete, 500)); + } + throw new Error(`T3 Code did not become ready at ${url}.`, { cause: lastError }); +} + +async function assertWebClient(port: number): Promise { + const response = await fetch(`http://127.0.0.1:${port}/`, { + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`T3 Code web client returned HTTP ${response.status}.`); + } + const contentType = response.headers.get("content-type") ?? ""; + const html = await response.text(); + if (!contentType.includes("text/html") || !html.includes('
')) { + throw new Error("T3 Code did not serve the expected web client HTML."); + } +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error( + `${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); + } +} + +function assertSafeCleanupPath(path: string): void { + const resolvedPath = resolve(path); + const resolvedTemp = resolve(tmpdir()); + if (!resolvedPath.startsWith(`${resolvedTemp}${sep}t3code-docker-e2e-`)) { + throw new Error(`Refusing to remove unexpected Docker E2E path: ${resolvedPath}`); + } +} + +function assertSafeBuildCanaryPath(path: string): void { + const resolvedPath = resolve(path); + if (resolvedPath !== join(repositoryRoot, ".docker-e2e-canary")) { + throw new Error(`Refusing to remove unexpected build-context canary path: ${resolvedPath}`); + } +} + +async function main(): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), "t3code-docker-e2e-")); + const workspace = join(tempRoot, "workspace"); + await mkdir(workspace, { mode: 0o777 }); + await chmod(workspace, 0o777); + const port = await freeTcpPort(); + const composeEnvironment = { + ...process.env, + T3_BIND_ADDRESS: "127.0.0.1", + T3_HOSTNAME: stableHostname, + T3_IMAGE: imageName, + T3_PORT: String(port), + T3_WORKSPACE_PATH: workspace, + } satisfies NodeJS.ProcessEnv; + const compose = (args: ReadonlyArray, options: CommandOptions = {}) => + run("docker", ["compose", "-f", composeFile, "-p", projectName, ...args], { + ...options, + env: composeEnvironment, + }); + let ownsBuildContextCanary = false; + + console.log(`Docker E2E project: ${projectName}`); + try { + try { + await mkdir(buildContextCanaryRoot); + ownsBuildContextCanary = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`Refusing to replace existing path: ${buildContextCanaryRoot}`, { + cause: error, + }); + } + throw error; + } + for (const path of buildContextCanaries) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${syntheticCredential}\n`, { encoding: "utf8", mode: 0o600 }); + } + + await run("docker", ["version"]); + await run("docker", ["compose", "version"]); + await compose(["config", "--quiet"]); + + console.log("Building the default Docker image..."); + await compose(["build"], { inherit: true }); + + const imageInspection = JSON.parse( + (await run("docker", ["image", "inspect", imageName])).stdout, + ) as ReadonlyArray<{ + readonly Config?: { readonly Env?: ReadonlyArray; readonly User?: string }; + }>; + const imageConfig = imageInspection[0]?.Config; + assertEqual(imageConfig?.User, "node", "runtime image user"); + for (const entry of imageConfig?.Env ?? []) { + if (/(?:ACCESS_TOKEN|AUTH_TOKEN|API_KEY|PASSWORD|PRIVATE_KEY|SECRET_KEY)=/iu.test(entry)) { + throw new Error( + `Runtime image config contains a credential-like environment entry: ${entry.split("=")[0]}`, + ); + } + } + + const imageHistory = await run("docker", ["image", "history", "--no-trunc", imageName]); + if (`${imageHistory.stdout}${imageHistory.stderr}`.includes(syntheticCredential)) { + throw new Error("Synthetic credential leaked into the image history."); + } + await run("docker", [ + "run", + "--rm", + "--entrypoint", + "sh", + imageName, + "-lc", + [ + "test ! -e /home/node/.codex/auth.json", + "test ! -e /home/node/.claude.json", + "test ! -e /home/node/.cursor/cli-config.json", + "test ! -e /home/node/.config/opencode/auth.json", + ].join(" && "), + ]); + await run("docker", [ + "run", + "--rm", + "--entrypoint", + "sh", + imageName, + "-lc", + ["codex", "claude", "opencode", "cursor-agent", "agent"] + .map((binary) => `command -v ${binary} > /dev/null`) + .join(" && "), + ]); + + console.log("Starting the Compose service..."); + await compose(["up", "-d", "--no-build"]); + const firstDescriptor = await waitForDescriptor(port); + await assertWebClient(port); + assertEqual(firstDescriptor.label, stableHostname, "environment label"); + + const containerId = (await compose(["ps", "-q", "t3"])).stdout.trim(); + if (!containerId) throw new Error("Compose did not return a T3 container ID."); + const containerInspection = JSON.parse( + (await run("docker", ["container", "inspect", containerId])).stdout, + ) as ReadonlyArray<{ + readonly Config?: { readonly Hostname?: string; readonly User?: string }; + readonly HostConfig?: { readonly Binds?: ReadonlyArray | null }; + readonly Mounts?: ReadonlyArray<{ readonly Destination?: string; readonly Type?: string }>; + }>; + const runningContainer = containerInspection[0]; + assertEqual(runningContainer?.Config?.Hostname, stableHostname, "container hostname"); + assertEqual(runningContainer?.Config?.User, "node", "container user"); + if (JSON.stringify(runningContainer).includes("/var/run/docker.sock")) { + throw new Error("The Compose service unexpectedly mounts the Docker socket."); + } + + const identity = await compose([ + "exec", + "-T", + "t3", + "sh", + "-lc", + 'printf \'%s:%s\' "$(id -u)" "$(id -g)"', + ]); + assertEqual(identity.stdout, "1000:1000", "container uid:gid"); + await compose([ + "exec", + "-T", + "t3", + "sh", + "-lc", + "printf 'workspace-ok' > /workspace/.t3-docker-e2e", + ]); + assertEqual( + await readFile(join(workspace, ".t3-docker-e2e"), "utf8"), + "workspace-ok", + "workspace write-through", + ); + + const writeSyntheticCredentials = [ + "set -eu", + "mkdir -p /home/node/.codex /home/node/.cursor /home/node/.config/opencode", + `printf '%s' '{\"test\":\"${syntheticCredential}\"}' > /home/node/.codex/auth.json`, + `printf '%s' '{\"test\":\"${syntheticCredential}\"}' > /home/node/.claude.json`, + `printf '%s' '{\"test\":\"${syntheticCredential}\"}' > /home/node/.cursor/cli-config.json`, + `printf '%s' '{\"test\":\"${syntheticCredential}\"}' > /home/node/.config/opencode/auth.json`, + "chmod 600 /home/node/.codex/auth.json /home/node/.claude.json /home/node/.cursor/cli-config.json /home/node/.config/opencode/auth.json", + ].join("; "); + await compose(["exec", "-T", "t3", "sh", "-lc", writeSyntheticCredentials]); + const credentialHashesCommand = [ + "sha256sum", + "/home/node/.codex/auth.json", + "/home/node/.claude.json", + "/home/node/.cursor/cli-config.json", + "/home/node/.config/opencode/auth.json", + ].join(" "); + const originalHashes = ( + await compose(["exec", "-T", "t3", "sh", "-lc", credentialHashesCommand]) + ).stdout; + const originalEnvironmentId = ( + await compose(["exec", "-T", "t3", "sh", "-lc", "cat /home/node/.t3/userdata/environment-id"]) + ).stdout.trim(); + + console.log("Recreating the container to verify durable state..."); + await compose(["up", "-d", "--no-build", "--force-recreate"]); + const secondDescriptor = await waitForDescriptor(port); + assertEqual(secondDescriptor.environmentId, originalEnvironmentId, "persisted environment ID"); + assertEqual(secondDescriptor.label, stableHostname, "stable environment label"); + const recreatedHashes = ( + await compose(["exec", "-T", "t3", "sh", "-lc", credentialHashesCommand]) + ).stdout; + assertEqual(recreatedHashes, originalHashes, "provider credential volume contents"); + + console.log( + "Docker E2E passed: build, startup, isolation, persistence, and credential checks.", + ); + } catch (error) { + const logs = await compose(["logs", "--no-color", "--tail", "200"], { + tolerateFailure: true, + }); + const diagnosticLogs = redact(`${logs.stdout}${logs.stderr}`).trim(); + if (diagnosticLogs) console.error(`Sanitized container logs:\n${diagnosticLogs}`); + throw error; + } finally { + if (!/^t3docker[a-z0-9]+$/u.test(projectName)) { + throw new Error(`Refusing to clean unexpected Compose project: ${projectName}`); + } + await compose(["down", "--volumes", "--remove-orphans"], { tolerateFailure: true }); + await run("docker", ["image", "rm", imageName], { tolerateFailure: true }); + if (ownsBuildContextCanary) { + assertSafeBuildCanaryPath(buildContextCanaryRoot); + await rm(buildContextCanaryRoot, { recursive: true, force: true }); + } + assertSafeCleanupPath(tempRoot); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +await main();