diff --git a/.claude/protected-paths.txt b/.claude/protected-paths.txt index 6dcb0c45..ba458893 100644 --- a/.claude/protected-paths.txt +++ b/.claude/protected-paths.txt @@ -30,6 +30,7 @@ spec eval health-check retro +t3 # --- Orchestrator scripts (.oh/scripts/) --- .oh/scripts/cron-runtime.ts .oh/scripts/sandbox-healthcheck.sh diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 796f73b3..72ddf66a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -60,6 +60,7 @@ services: - GH_TOKEN=${GH_TOKEN:-} - XAI_API_KEY=${XAI_API_KEY:-} - INSTALL_AGENT_BROWSER=${INSTALL_AGENT_BROWSER:-false} + - INSTALL_TAILSCALE=${INSTALL_TAILSCALE:-false} - SKIP_PNPM_INSTALL=${SKIP_PNPM_INSTALL:-0} - INSTALL_HERMES=${INSTALL_HERMES:-false} - HERMES_HOME=/home/sandbox/harness/.hermes diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index f1d13501..5b0346fa 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -550,6 +550,65 @@ if [ "${INSTALL_AGENT_BROWSER:-false}" = "true" ] && ! command -v agent-browser || echo "[entrypoint] agent-browser install failed — skipping" fi +# tailscaled defaults its control socket to /var/run/tailscale/tailscaled.sock, +# and t3-code.sh calls a bare `tailscale status` that expects exactly that path. +# Only root can create it, so the entrypoint must — unconditionally, not behind +# the guard below: `oh tool install tailscale` promises the tool is usable in the +# already-running container, and gating this on INSTALL_TAILSCALE would make an +# install-now/use-now flow wait for a reboot. An empty directory costs nothing. +install -d -o sandbox -g sandbox -m 0755 /var/run/tailscale 2>/dev/null || true + +if [ "${INSTALL_TAILSCALE:-false}" = "true" ]; then + install -d -o sandbox -g sandbox -m 0700 /home/sandbox/.tailscale 2>/dev/null || true + + if ! gosu sandbox bash -lc 'command -v tailscale' >/dev/null 2>&1; then + case "$(dpkg --print-architecture)" in + amd64) + ts_tarball=tailscale_1.102.3_amd64.tgz + ts_sha=36ddd9b51be57ffc2990cf76323cfa13643bfbb1b8a969f6183fa164741cdef5 + ;; + arm64) + ts_tarball=tailscale_1.102.3_arm64.tgz + ts_sha=a0fa1b154af8c61f862a2259f559f7396d96c0225f4a863eae2333e1546bbe25 + ;; + *) + ts_tarball="" + ts_sha="" + ;; + esac + + if [ -z "$ts_tarball" ]; then + echo "[entrypoint] WARNING: no pinned Tailscale build for $(dpkg --print-architecture) — skipping" >&2 + else + echo "[entrypoint] Installing ${ts_tarball%.tgz} (INSTALL_TAILSCALE=true)..." + # Install into the home mount as the sandbox user, matching the tool + # catalog. /usr/local/bin is an image-layer path: it is lost on every + # container recreate, so the old location re-downloaded Tailscale on every + # fresh container, and left a root-owned binary no running sandbox could + # upgrade in place. + ts_tmp="$(mktemp -d)" + chown sandbox:sandbox "$ts_tmp" + if gosu sandbox bash -lc " + set -e + prefix=\"\${NPM_USER_PREFIX:-\$HOME/.local}\" + curl -fsSL 'https://pkgs.tailscale.com/stable/${ts_tarball}' -o '$ts_tmp/$ts_tarball' + echo '${ts_sha} $ts_tmp/$ts_tarball' | sha256sum -c - + tar -xzf '$ts_tmp/$ts_tarball' -C '$ts_tmp' + install -d \"\$prefix/bin\" + install -m 0755 '$ts_tmp/${ts_tarball%.tgz}/tailscale' \"\$prefix/bin/tailscale\" + install -m 0755 '$ts_tmp/${ts_tarball%.tgz}/tailscaled' \"\$prefix/bin/tailscaled\" + "; then + echo "[entrypoint] ${ts_tarball%.tgz} installed into the home mount" + else + echo "[entrypoint] WARNING: Tailscale install failed — skipping" >&2 + fi + rm -rf "$ts_tmp" + unset ts_tmp + fi + unset ts_tarball ts_sha + fi +fi + for hook in /usr/local/bin/*-entrypoint-hook.sh; do [ -x "$hook" ] && "$hook" done diff --git a/.oh/cli/package-lock.json b/.oh/cli/package-lock.json index 7d96d6b9..7783093d 100644 --- a/.oh/cli/package-lock.json +++ b/.oh/cli/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@mifune/openharness", "version": "0.5.1", + "license": "Apache-2.0", "bin": { "oh": "dist/oh.js" }, @@ -14,6 +15,9 @@ "@types/node": "^22.0.0", "esbuild": "^0.28.1", "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/.oh/cli/src/__tests__/env-file.test.ts b/.oh/cli/src/__tests__/env-file.test.ts index 3998bc75..d90097df 100644 --- a/.oh/cli/src/__tests__/env-file.test.ts +++ b/.oh/cli/src/__tests__/env-file.test.ts @@ -104,6 +104,7 @@ describe("install flags", () => { expect(installFieldPath("opencode")).toBe("install.opencode"); expect(installFieldPath("grok_build")).toBe("install.grokBuild"); expect(installFieldPath("agent_browser")).toBe("install.agentBrowser"); + expect(installFieldPath("tailscale")).toBe("install.tailscale"); }); it("writes the flag to oh.json and never to a dotenv", () => { @@ -117,6 +118,17 @@ describe("install flags", () => { expectNoDotenv(root); }); + it("writes install.tailscale from the tailscale flag", () => { + const root = makeRepo(); + expect(isInstallFlagEnabled(root, "tailscale")).toBe(false); + + expect(setInstallFlag(root, "tailscale")).toBe("updated"); + + expect(readConfig(root)).toMatchObject({ install: { tailscale: true } }); + expect(isInstallFlagEnabled(root, "tailscale")).toBe(true); + expectNoDotenv(root); + }); + it("is idempotent — a second call rewrites nothing", () => { const root = makeRepo(); setInstallFlag(root, "opencode"); @@ -139,6 +151,13 @@ describe("setEnvValue", () => { expectNoDotenv(root); }); + it("routes INSTALL_TAILSCALE to install.tailscale", () => { + const root = makeRepo(); + expect(setEnvValue(root, "INSTALL_TAILSCALE", "true")).toBe("updated"); + expect(readConfig(root)).toMatchObject({ install: { tailscale: true } }); + expectNoDotenv(root); + }); + it("refuses a key that has no oh.json field rather than falling back to a dotenv", () => { const root = makeRepo(); expect(() => setEnvValue(root, "GH_TOKEN", "ghp_example")).toThrow(/oh secret set/); diff --git a/.oh/cli/src/__tests__/init.test.ts b/.oh/cli/src/__tests__/init.test.ts index a06ab547..05cea50b 100644 --- a/.oh/cli/src/__tests__/init.test.ts +++ b/.oh/cli/src/__tests__/init.test.ts @@ -394,6 +394,7 @@ describe("runInit", () => { if (q.includes("Git user name")) return "Ada Lovelace"; if (q.includes("Git user email")) return "ada@example.com"; if (q.includes("agent_browser")) return "y"; + if (q.includes("tailscale")) return "y"; return ""; }; const askSecret = async (q: string): Promise => @@ -416,6 +417,7 @@ describe("runInit", () => { expect(config.timezone).toBe("America/New_York"); expect(config.git).toEqual({ userName: "Ada Lovelace", userEmail: "ada@example.com" }); expect(config.install.agentBrowser).toBe(true); + expect(config.install.tailscale).toBe(true); expect(config.install.hermes).toBe(false); expect(config.access.ssh).toBe(false); expect(config.access.dockerSocket).toBe(false); @@ -432,6 +434,7 @@ describe("runInit", () => { "GIT_USER_NAME", "GIT_USER_EMAIL", "INSTALL_AGENT_BROWSER", + "INSTALL_TAILSCALE", "DOCKER_SOCKET", ]) { expect(dotenv).not.toContain(nonSecret); @@ -478,6 +481,7 @@ describe("runInit", () => { grokBuild: false, hermes: false, agentBrowser: false, + tailscale: false, }); expect(existsSync(join(t, ".env"))).toBe(false); expect(lstatSync(join(t, ".devcontainer/.env")).isSymbolicLink()).toBe(true); diff --git a/.oh/cli/src/__tests__/install-flag-persistence.test.ts b/.oh/cli/src/__tests__/install-flag-persistence.test.ts index 34d61240..5bfe1d2e 100644 --- a/.oh/cli/src/__tests__/install-flag-persistence.test.ts +++ b/.oh/cli/src/__tests__/install-flag-persistence.test.ts @@ -65,6 +65,20 @@ describe("install flags never reach the secrets dotenv", () => { expect(lstatSync(link).isSymbolicLink()).toBe(true); }); + it("`oh tool install tailscale --persist-only` writes oh.json and leaves .env byte-identical", async () => { + const root = makeRepo(); + const before = readFileSync(secretsFilePath(root), "utf8"); + const { io, out } = makeIo(); + + expect(await runToolInstall("tailscale", { cwd: root, persistOnly: true }, io)).toBe(0); + + expect(readConfig(root)).toMatchObject({ install: { tailscale: true } }); + expect(readFileSync(secretsFilePath(root), "utf8")).toBe(before); + expect(before).not.toMatch(/INSTALL_/); + expect(out.join("")).toContain("oh.json: set install.tailscale=true"); + expect(out.join("")).not.toContain(".devcontainer/.env"); + }); + it("`oh harness install --persist-only` writes oh.json and leaves .env byte-identical", async () => { const root = makeRepo(); const before = readFileSync(secretsFilePath(root), "utf8"); diff --git a/.oh/cli/src/__tests__/tool-catalog.test.ts b/.oh/cli/src/__tests__/tool-catalog.test.ts index 3057999b..66d0adbd 100644 --- a/.oh/cli/src/__tests__/tool-catalog.test.ts +++ b/.oh/cli/src/__tests__/tool-catalog.test.ts @@ -15,18 +15,24 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".." const read = (p: string): string => readFileSync(join(REPO_ROOT, p), "utf8"); describe("tool catalog shape", () => { - it("lists the five known tools", () => { + it("lists the six known tools", () => { expect(toolIds()).toEqual([ "agent-browser", "herdr", "cloudflared", "docker-cli", "gh", + "tailscale", ]); }); it("makes exactly the default and opt-in tools installable", () => { - expect(installableToolIds()).toEqual(["agent-browser", "herdr", "cloudflared"]); + expect(installableToolIds()).toEqual([ + "agent-browser", + "herdr", + "cloudflared", + "tailscale", + ]); for (const t of TOOL_CATALOG) { // A kind:"default" tool is provisioned at boot through `oh tool install`, // so it MUST be installable; a baked-in one must not be. @@ -68,7 +74,13 @@ describe("tool catalog shape", () => { it("declares a version probe only where the flag is a safe standard", () => { const withVersion = TOOL_CATALOG.filter((t) => t.versionArgv !== undefined).map((t) => t.id); - expect(withVersion).toEqual(["herdr", "cloudflared", "docker-cli", "gh"]); + expect(withVersion).toEqual([ + "herdr", + "cloudflared", + "docker-cli", + "gh", + "tailscale", + ]); for (const t of TOOL_CATALOG) { if (t.versionArgv) expect(t.versionArgv, t.id).toEqual([t.binary, "--version"]); } @@ -169,6 +181,91 @@ describe("agent-browser matches the entrypoint that really installs it", () => { }); }); +describe("tailscale matches the entrypoint that really installs it", () => { + const ts = findTool("tailscale")!; + const ENTRYPOINT = read(".devcontainer/entrypoint.sh"); + const VERSION = "1.102.3"; + const SHA_AMD64 = "36ddd9b51be57ffc2990cf76323cfa13643bfbb1b8a969f6183fa164741cdef5"; + const SHA_ARM64 = "a0fa1b154af8c61f862a2259f559f7396d96c0225f4a863eae2333e1546bbe25"; + + it("carries the entrypoint guard, not a build arg", () => { + expect(ts.entrypointGuard).toBe("INSTALL_TAILSCALE"); + expect(Object.keys(ts)).not.toContain("buildArg"); + expect(ts.toolKey).toBe("tailscale"); + expect(ts.kind).toBe("opt-in"); + }); + + it("is installed by the entrypoint and is ABSENT from the Dockerfile", () => { + expect(ENTRYPOINT).toContain("INSTALL_TAILSCALE"); + expect(read(".devcontainer/Dockerfile")).not.toContain("INSTALL_TAILSCALE"); + }); + + it("pins the same version the entrypoint pins", () => { + expect(ts.installArgv!.join(" ")).toContain(`tailscale_${VERSION}_`); + expect(ENTRYPOINT).toContain(`tailscale_${VERSION}_`); + }); + + it("verifies the same per-arch sha256 the entrypoint verifies", () => { + const argv = ts.installArgv!.join(" "); + for (const sha of [SHA_AMD64, SHA_ARM64]) { + expect(argv, sha).toContain(sha); + expect(ENTRYPOINT, sha).toContain(sha); + } + expect(argv).toContain("sha256sum -c -"); + expect(ENTRYPOINT).toContain("sha256sum -c -"); + }); + + it("downloads from the pinned stable base the entrypoint uses", () => { + const base = "https://pkgs.tailscale.com/stable/"; + expect(ts.installArgv!.join(" ")).toContain(base); + expect(ENTRYPOINT).toContain(base); + }); + + // tailscaled runs unprivileged under --tun=userspace-networking, so nothing + // here needs root. A root install would hang `oh tool install tailscale` on a + // sudo password prompt (commands/tool.ts uses stdio:"inherit", and + // /etc/sudoers.d/sandbox has no NOPASSWD), and would put the binaries in an + // image-layer path discarded on every container recreate. + it("installs as the sandbox user into the home mount", () => { + expect(ts.installUser).toBe("sandbox"); + const argv = ts.installArgv!.join(" "); + expect(argv).toContain("NPM_USER_PREFIX"); + expect(argv).not.toContain("/usr/local/bin/tailscale"); + expect(argv).not.toContain("/usr/local/bin/tailscaled"); + }); + + // /var/run/tailscale is tailscaled's default socket directory and only root + // can create it, so it belongs to the entrypoint, not to an install that runs + // as the sandbox user. + it("leaves the root-owned socket directory to the entrypoint", () => { + expect(ts.installArgv!.join(" ")).not.toContain("/var/run/tailscale"); + expect(ENTRYPOINT).toContain("/var/run/tailscale"); + }); + + it("never joins a tailnet — installation is not authentication", () => { + const argv = ts.installArgv!.join(" "); + expect(argv).not.toContain("tailscale up"); + expect(argv).not.toMatch(/(^|[^d])tailscaled\s+--tun/); + }); + + it("drops the entrypoint's log cosmetics, which would eat the exit code", () => { + const argv = ts.installArgv!.join(" "); + expect(argv).not.toContain("[entrypoint]"); + expect(argv).not.toContain("tail -"); + }); + + it("arms no download gate — the tarball is small", () => { + expect(ts.downloadSize).toBeUndefined(); + }); + + it("keeps the env plumbing wired end to end", () => { + expect(read(".devcontainer/docker-compose.yml")).toContain("INSTALL_TAILSCALE"); + expect(read("docs/configuration.md")).toMatch( + /^\| `install\.tailscale` \|.*`INSTALL_TAILSCALE`/m, + ); + }); +}); + describe("baked-in tools", () => { it("declare no install key — the installer must not invent one", () => { for (const t of TOOL_CATALOG) { diff --git a/.oh/cli/src/__tests__/tool.test.ts b/.oh/cli/src/__tests__/tool.test.ts index d135b98f..f2369d4b 100644 --- a/.oh/cli/src/__tests__/tool.test.ts +++ b/.oh/cli/src/__tests__/tool.test.ts @@ -107,6 +107,19 @@ const agentBrowserFlag = (root: string): unknown => (JSON.parse(readFileSync(ohConfigPath(root), "utf8")) as { install?: Record }) .install?.agentBrowser; +const tailscaleFlag = (root: string): unknown => + (JSON.parse(readFileSync(ohConfigPath(root), "utf8")) as { install?: Record }) + .install?.tailscale; + +const absentTailscale = (cmd: string, args: string[]): RunResult | undefined => + isExecOf(cmd, args, "command -v tailscale") ? { status: 1, stdout: "", stderr: "" } : undefined; + +const isTailscaleVersionExec = (cmd: string, args: string[]): boolean => + cmd === "docker" && args[0] === "exec" && args.join(" ").includes("tailscale --version"); + +const isTailscaleInstallCall = (c: RecordedCall): boolean => + c.cmd === "docker" && c.args[0] === "exec" && c.args.some((a) => a.includes("sha256sum -c -")); + describe("oh tool — argument parsing", () => { it("shows help with no args", () => { const r = parseToolArgs([]); @@ -163,7 +176,7 @@ describe("oh tool list / status", () => { const { io, out } = makeIo(); expect(await runToolList({ cwd: root, run: liveHost().run }, io)).toBe(0); const text = out.join(""); - for (const id of ["agent-browser", "herdr", "cloudflared", "docker-cli", "gh"]) { + for (const id of ["agent-browser", "herdr", "cloudflared", "docker-cli", "gh", "tailscale"]) { expect(text, id).toContain(id); } expect(text).toContain("baked-in"); @@ -342,6 +355,94 @@ describe("oh tool install — the other exits", () => { }); }); +describe("oh tool install tailscale", () => { + it("--persist-only writes the flag and execs nothing", async () => { + const root = makeRepo(); + const { calls, run } = liveHost(absentTailscale); + const { io, out } = makeIo(); + expect(await runToolInstall("tailscale", { cwd: root, run, persistOnly: true }, io)).toBe(0); + expect(tailscaleFlag(root)).toBe(true); + expect(calls.length).toBe(0); + expect(out.join("")).toContain("oh.json: set install.tailscale=true"); + }); + + it("execs the pinned install argv as the sandbox user, with no download prompt", async () => { + const root = makeRepo(); + const { calls, run } = liveHost(absentTailscale); + const { io, asked, out } = makeIo(true); + expect(await runToolInstall("tailscale", { cwd: root, run }, io)).toBe(0); + expect(asked).toEqual([]); + const install = calls.find(isTailscaleInstallCall); + expect(install).toBeDefined(); + // #858/#908: a root install becomes an interactive `sudo` inside the sandbox. + expect(install!.args.join(" ")).toContain("-u sandbox"); + expect(install!.args.join(" ")).not.toContain("-u root"); + expect(install!.args.join(" ")).toContain("pkgs.tailscale.com/stable/"); + expect(tailscaleFlag(root)).toBe(true); + expect(out.join("")).toContain("installed"); + }); + + it("is idempotent — an already-present binary short-circuits", async () => { + const root = makeRepo(); + const { calls, run } = liveHost((cmd, args) => + isExecOf(cmd, args, "command -v tailscale") + ? { status: 0, stdout: "", stderr: "" } + : undefined, + ); + const { io, out } = makeIo(true); + expect(await runToolInstall("tailscale", { cwd: root, run }, io)).toBe(0); + expect(calls.some(isTailscaleInstallCall)).toBe(false); + expect(out.join("")).toContain("already installed"); + }); + + it("keeps the flag set when the installer fails", async () => { + const root = makeRepo(); + const { run } = liveHost((cmd, args) => { + if (isExecOf(cmd, args, "sha256sum -c -")) return { status: 9, stdout: "", stderr: "" }; + return absentTailscale(cmd, args); + }); + const { io, err } = makeIo(true); + expect(await runToolInstall("tailscale", { cwd: root, run }, io)).toBe(9); + expect(tailscaleFlag(root)).toBe(true); + expect(err.join("")).toContain("next container start"); + }); +}); + +describe("oh tool status tailscale", () => { + it("reports enabled, installed and version as JSON", async () => { + const root = makeRepo(); + const { run } = liveHost((cmd, args) => + isTailscaleVersionExec(cmd, args) + ? { status: 0, stdout: "1.102.3\n tailscale commit: abc\n", stderr: "" } + : undefined, + ); + const { io: persistIo } = makeIo(); + await runToolInstall("tailscale", { cwd: root, run, persistOnly: true }, persistIo); + + const { io, out } = makeIo(); + expect(await runToolStatus("tailscale", { cwd: root, run, json: true }, io)).toBe(0); + const status = JSON.parse(out.join("")); + expect(status.id).toBe("tailscale"); + expect(status.kind).toBe("opt-in"); + expect(status.enabled).toBe(true); + expect(status.installed).toBe(true); + expect(status.version).toBe("1.102.3"); + expect(status.installable).toBe(true); + }); + + it("reports not-installed and no version when the binary is absent", async () => { + const root = makeRepo(); + const { calls, run } = liveHost(absentTailscale); + const { io, out } = makeIo(); + await runToolStatus("tailscale", { cwd: root, run, json: true }, io); + const status = JSON.parse(out.join("")); + expect(status.enabled).toBe(false); + expect(status.installed).toBe(false); + expect(status.version).toBeNull(); + expect(calls.some((c) => isTailscaleVersionExec(c.cmd, c.args))).toBe(false); + }); +}); + describe("oh tool — inside the sandbox", () => { const INSIDE: NodeJS.ProcessEnv = { OH_EXECUTION_TARGET: "local" }; diff --git a/.oh/cli/src/commands/init.ts b/.oh/cli/src/commands/init.ts index d313a55b..40a8cab3 100644 --- a/.oh/cli/src/commands/init.ts +++ b/.oh/cli/src/commands/init.ts @@ -659,6 +659,9 @@ const ENV_TO_CONFIG: Record = { INSTALL_AGENT_BROWSER: (c, v) => { section(c, "install").agentBrowser = asBool(v); }, + INSTALL_TAILSCALE: (c, v) => { + section(c, "install").tailscale = asBool(v); + }, SANDBOX_SSH: (c, v) => { section(c, "access").ssh = asBool(v); }, @@ -777,6 +780,11 @@ async function runWizard( { key: "hermes", field: "hermes", desc: "Hermes CLI + runtime (build arg + runtime)" }, { key: "grok_build", field: "grokBuild", desc: "Grok build tooling" }, { key: "agent_browser", field: "agentBrowser", desc: "agent-browser + Chromium (~1 GB)" }, + { + key: "tailscale", + field: "tailscale", + desc: "Tailscale (userspace) — private remote access for T3 Code", + }, ]; for (const inst of installs) { const yes = await confirmWith(askFn, `Install ${inst.key} — ${inst.desc}?`, false); diff --git a/.oh/cli/src/lib/__tests__/config-render.test.ts b/.oh/cli/src/lib/__tests__/config-render.test.ts index a8df288f..7bd4b70e 100644 --- a/.oh/cli/src/lib/__tests__/config-render.test.ts +++ b/.oh/cli/src/lib/__tests__/config-render.test.ts @@ -61,6 +61,7 @@ describe("renderComposeEnv", () => { expect(text).not.toContain("INSTALL_DEEPAGENTS"); expect(text).toContain("INSTALL_HERMES=false"); expect(text).toContain("INSTALL_AGENT_BROWSER=false"); + expect(text).toContain("INSTALL_TAILSCALE=false"); expect(text).toContain("DOCKER_SOCKET=true"); expect(text).toContain("SANDBOX_SSH=true"); expect(text).toContain("SANDBOX_SSH_PORT=2022"); diff --git a/.oh/cli/src/lib/__tests__/oh-config.test.ts b/.oh/cli/src/lib/__tests__/oh-config.test.ts index d0e571f4..ca25e80d 100644 --- a/.oh/cli/src/lib/__tests__/oh-config.test.ts +++ b/.oh/cli/src/lib/__tests__/oh-config.test.ts @@ -128,6 +128,11 @@ describe("validateOhConfig", () => { { install: { agentBrowser: 1 } }, /^oh\.json: install\.agentBrowser must be a boolean$/, ], + [ + "install.tailscale", + { install: { tailscale: 1 } }, + /^oh\.json: install\.tailscale must be a boolean$/, + ], ["access.ssh", { access: { ssh: "yes" } }, /^oh\.json: access\.ssh must be a boolean$/], ["access.sshPort", { access: { sshPort: "2222" } }, /^oh\.json: access\.sshPort must be a number$/], [ diff --git a/.oh/cli/src/lib/config-render.ts b/.oh/cli/src/lib/config-render.ts index 44a1f590..3e789790 100644 --- a/.oh/cli/src/lib/config-render.ts +++ b/.oh/cli/src/lib/config-render.ts @@ -32,6 +32,7 @@ export function renderComposeVars(config: OhConfig): RenderedVar[] { put("INSTALL_GROK_BUILD", config.install?.grokBuild); put("INSTALL_HERMES", config.install?.hermes); put("INSTALL_AGENT_BROWSER", config.install?.agentBrowser); + put("INSTALL_TAILSCALE", config.install?.tailscale); put("DOCKER_SOCKET", config.access?.dockerSocket); put("SANDBOX_SSH", config.access?.ssh); diff --git a/.oh/cli/src/lib/env-file.ts b/.oh/cli/src/lib/env-file.ts index e1e09d75..1e9805ee 100644 --- a/.oh/cli/src/lib/env-file.ts +++ b/.oh/cli/src/lib/env-file.ts @@ -27,6 +27,7 @@ export const INSTALL_FIELDS: Record = { grok_build: "install.grokBuild", hermes: "install.hermes", agent_browser: "install.agentBrowser", + tailscale: "install.tailscale", }; export const CONFIG_FIELD_BY_ENV_KEY: Record = { @@ -35,6 +36,7 @@ export const CONFIG_FIELD_BY_ENV_KEY: Record = { INSTALL_GROK_BUILD: INSTALL_FIELDS.grok_build, INSTALL_HERMES: INSTALL_FIELDS.hermes, INSTALL_AGENT_BROWSER: INSTALL_FIELDS.agent_browser, + INSTALL_TAILSCALE: INSTALL_FIELDS.tailscale, }; export function installFieldPath(key: string): string { diff --git a/.oh/cli/src/lib/oh-config.ts b/.oh/cli/src/lib/oh-config.ts index 4014f8d4..7783f203 100644 --- a/.oh/cli/src/lib/oh-config.ts +++ b/.oh/cli/src/lib/oh-config.ts @@ -38,6 +38,7 @@ export interface InstallFlags { grokBuild?: boolean; hermes?: boolean; agentBrowser?: boolean; + tailscale?: boolean; } export interface AccessSettings { @@ -127,6 +128,7 @@ export function defaultOhConfig(name: string): OhConfig { grokBuild: false, hermes: false, agentBrowser: false, + tailscale: false, }, access: { ssh: false, @@ -220,7 +222,13 @@ export function validateOhConfig(value: unknown): OhConfig { const install = expectSection(record, "install"); if (install) { - for (const key of ["opencode", "grokBuild", "hermes", "agentBrowser"]) { + for (const key of [ + "opencode", + "grokBuild", + "hermes", + "agentBrowser", + "tailscale", + ]) { expectBoolean(install, key, "install."); } } @@ -349,6 +357,7 @@ export const OH_CONFIG_FIELDS: readonly OhConfigField[] = [ { path: "install.grokBuild", type: "boolean" }, { path: "install.hermes", type: "boolean" }, { path: "install.agentBrowser", type: "boolean" }, + { path: "install.tailscale", type: "boolean" }, { path: "access.ssh", type: "boolean" }, { path: "access.sshPort", type: "port" }, { path: "access.sshPasswordAuth", type: "boolean" }, diff --git a/.oh/cli/src/lib/tools/catalog.ts b/.oh/cli/src/lib/tools/catalog.ts index fac0d638..5c5b1916 100644 --- a/.oh/cli/src/lib/tools/catalog.ts +++ b/.oh/cli/src/lib/tools/catalog.ts @@ -125,6 +125,23 @@ export const TOOL_CATALOG: readonly ToolEntry[] = Object.freeze([ "The GitHub CLI is installed in the base image. Run `gh auth login` inside the sandbox to authenticate it.", docsPath: TOOLS_DOC, }), + Object.freeze({ + id: "tailscale", + title: "Tailscale", + kind: "opt-in", + binary: "tailscale", + verifyArgv: Object.freeze(["bash", "-lc", "command -v tailscale >/dev/null"]), + versionArgv: Object.freeze(["tailscale", "--version"]), + toolKey: "tailscale", + entrypointGuard: "INSTALL_TAILSCALE", + installArgv: Object.freeze([ + "bash", + "-lc", + "set -e\narch=\"$(dpkg --print-architecture)\"\ncase \"$arch\" in\n amd64) tarball=tailscale_1.102.3_amd64.tgz; sha=36ddd9b51be57ffc2990cf76323cfa13643bfbb1b8a969f6183fa164741cdef5 ;;\n arm64) tarball=tailscale_1.102.3_arm64.tgz; sha=a0fa1b154af8c61f862a2259f559f7396d96c0225f4a863eae2333e1546bbe25 ;;\n *) echo \"no pinned Tailscale build for $arch\" >&2; exit 1 ;;\nesac\nprefix=\"${NPM_USER_PREFIX:-$HOME/.local}\"\ntmp=\"$(mktemp -d)\"\ntrap 'rm -rf \"$tmp\"' EXIT\ncurl -fsSL \"https://pkgs.tailscale.com/stable/$tarball\" -o \"$tmp/$tarball\"\necho \"$sha $tmp/$tarball\" | sha256sum -c -\ntar -xzf \"$tmp/$tarball\" -C \"$tmp\"\ninstall -d \"$prefix/bin\"\ninstall -m 0755 \"$tmp/tailscale_1.102.3_$arch/tailscale\" \"$prefix/bin/tailscale\"\ninstall -m 0755 \"$tmp/tailscale_1.102.3_$arch/tailscaled\" \"$prefix/bin/tailscaled\"\ninstall -d -m 0700 \"$HOME/.tailscale\"", + ]), + installUser: "sandbox", + docsPath: TOOLS_DOC, + }), ]); export function findTool(id: string): ToolEntry | undefined { diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index 25105ddf..5275d737 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,108 +6,110 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| advisor-monitored-loop | A | 2026-08-31 20:04 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | -| agent-browser-cli | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | -| agents-identity-contract | A | 2026-08-31 20:04 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | -| artifact-contract-audit | A | 2026-08-31 20:04 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-dispatcher-contract | A | 2026-08-31 20:04 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-31 20:04 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-31 20:04 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-31 20:04 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-31 20:04 | PASS | issue #645 — executable immutable audit root/run correlation | -| audit-shellcheck-coverage | A | 2026-08-31 20:04 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-31 20:04 | PASS | issue #645 — clean-breaking audit migration | -| boot-lint-glob | A | 2026-08-31 20:04 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-31 20:04 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-31 20:04 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-31 20:04 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| changelog-entry-length | A | 2026-08-31 20:04 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | -| cleanup-tasks-scoped-guard | A | 2026-08-31 20:04 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-31 20:04 | PASS | issue #168; issue #327 | -| cli-publish-typecheck-scope | A | 2026-08-31 20:04 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | -| close-issues-on-development | A | 2026-08-31 20:04 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | -| codex-stale-response-retry | A | 2026-08-31 20:04 | PASS | issue #506 — Codex previous_response_not_found RCA | -| compose-config-path-parity | A | 2026-08-31 20:04 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | -| config-schema-parity | A | 2026-08-31 20:04 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | -| context-tier-size-budget | A | 2026-08-31 20:04 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | -| cron-claude-codex-fallback | A | 2026-08-31 20:04 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-31 20:04 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| crons-directory-guide | A | 2026-08-31 20:04 | PASS | issue #874 | -| curl-bash-safe-alternatives | A | 2026-08-31 20:04 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-31 20:04 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-31 20:04 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| default-provisioning | A | 2026-08-31 20:04 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | -| delegate-model-effort-policy | A | 2026-08-31 20:04 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | -| docker-inspect-env-guard | A | 2026-08-31 20:04 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | -| docs-build-fast-path | A | 2026-08-31 20:04 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-31 20:04 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 20:04 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-31 20:04 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-31 20:04 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | -| eval-runs-once-per-cycle | A | 2026-08-31 20:04 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | -| execution-target-contract | A | 2026-08-31 20:04 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | -| get-oh-bootstrap | A | 2026-08-31 20:04 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-31 20:04 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-31 20:04 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-ci-core-paths | A | 2026-08-31 20:04 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-31 20:04 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| harness-yaml-migration | A | 2026-08-31 20:04 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | -| health-check-docker-stats | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | -| health-check-socket-degrade | A | 2026-08-31 20:04 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | -| heartbeat-logging-contract | A | 2026-08-31 20:04 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| image-seed-hygiene | A | 2026-08-31 20:04 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | -| markitdown-wiki-ingest | A | 2026-08-31 20:04 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| next-dev-prod | A | 2026-08-31 20:04 | SKIPPED | retro lesson 2026-06-04 | -| oh-compose-env-wiring | A | 2026-08-31 20:04 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | -| oh-config-surfaces | A | 2026-08-31 20:04 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | -| oh-destroy-guard | A | 2026-08-31 20:04 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | -| oh-devcontainer-restructure | A | 2026-08-31 20:04 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-home-mount | A | 2026-08-31 20:04 | PASS | issue #898 (single $HOME mount) 2026-08-30 | -| oh-image-only-deploy | A | 2026-08-31 20:04 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-headless-config | A | 2026-08-31 20:04 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | -| oh-init-scaffold | A | 2026-08-31 20:04 | PASS | issue #531 Phase 2 | -| oh-lifecycle-surface | A | 2026-08-31 20:04 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | -| oh-npm-package | A | 2026-08-31 20:04 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-31 20:04 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-31 20:04 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-31 20:04 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-31 20:04 | PASS | issue #564 | -| oh-update | A | 2026-08-31 20:04 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| operator-config-guard | A | 2026-08-31 20:04 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | -| pnpm-audit-ci-gate | A | 2026-08-31 20:04 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-31 20:04 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-19 | -| prompt-miner-schema-compat | A | 2026-08-31 20:04 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-31 20:04 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-31 20:04 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| protected-path-deletion | A | 2026-08-31 20:04 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | -| protected-paths-resolve | A | 2026-08-31 20:04 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | -| registry-portability-gate | A | 2026-08-31 20:04 | PASS | issue #758 | -| registry-portability | A | 2026-08-31 20:04 | SKIPPED | issue #758 | -| retro-deterministic-contract | A | 2026-08-31 20:04 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-31 20:04 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-31 20:04 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| runtime-preflight-gate | A | 2026-08-31 20:04 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | -| sandbox-boot-guard-ci | A | 2026-08-31 20:04 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | -| sandbox-node-base | A | 2026-08-31 20:04 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | -| skill-paths | A | 2026-08-31 20:04 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | -| skills-dir-clean | A | 2026-08-31 20:04 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-task-tool-coupling | A | 2026-08-31 20:04 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | -| skills-vendored | A | 2026-08-31 20:04 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-31 20:04 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-31 20:04 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | -| spec-ready-finalization | A | 2026-08-31 20:04 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | -| ste-checker-contract | A | 2026-08-31 20:04 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | -| submitted-by-trailers | A | 2026-08-31 20:04 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | -| sync-skill-contract | A | 2026-08-31 20:04 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| tool-catalog-boundary | A | 2026-08-31 20:04 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | -| version-parity | A | 2026-08-31 20:04 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | -| weigh-scorer-contract | A | 2026-08-31 20:04 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-31 20:04 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-31 20:04 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | -| worktrees-layout | A | 2026-08-31 20:04 | PASS | issue #872 | +| advisor-monitored-loop | A | 2026-08-31 21:00 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | +| agent-browser-cli | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | +| agents-identity-contract | A | 2026-08-31 21:00 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | +| artifact-contract-audit | A | 2026-08-31 21:00 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-dispatcher-contract | A | 2026-08-31 21:00 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-31 21:00 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-31 21:00 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-31 21:00 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-31 21:00 | PASS | issue #645 — executable immutable audit root/run correlation | +| audit-shellcheck-coverage | A | 2026-08-31 21:00 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-31 21:00 | PASS | issue #645 — clean-breaking audit migration | +| boot-lint-glob | A | 2026-08-31 21:00 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-31 21:00 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-31 21:00 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-31 21:00 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| changelog-entry-length | A | 2026-08-31 21:00 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | +| cleanup-tasks-scoped-guard | A | 2026-08-31 21:00 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-31 21:00 | PASS | issue #168; issue #327 | +| cli-publish-typecheck-scope | A | 2026-08-31 21:00 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | +| close-issues-on-development | A | 2026-08-31 21:00 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | +| codex-stale-response-retry | A | 2026-08-31 21:00 | PASS | issue #506 — Codex previous_response_not_found RCA | +| compose-config-path-parity | A | 2026-08-31 21:00 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | +| config-schema-parity | A | 2026-08-31 21:00 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | +| context-tier-size-budget | A | 2026-08-31 21:00 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | +| cron-claude-codex-fallback | A | 2026-08-31 21:00 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-31 21:00 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| crons-directory-guide | A | 2026-08-31 21:00 | PASS | issue #874 | +| curl-bash-safe-alternatives | A | 2026-08-31 21:00 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-31 21:00 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-31 21:00 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| default-provisioning | A | 2026-08-31 21:00 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | +| delegate-model-effort-policy | A | 2026-08-31 21:00 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-31 21:00 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-31 21:00 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-31 21:00 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 21:00 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-31 21:00 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-31 21:00 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | +| eval-runs-once-per-cycle | A | 2026-08-31 21:00 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | +| execution-target-contract | A | 2026-08-31 21:00 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | +| get-oh-bootstrap | A | 2026-08-31 21:00 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-31 21:00 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-31 21:00 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-ci-core-paths | A | 2026-08-31 21:00 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-31 21:00 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| harness-yaml-migration | A | 2026-08-31 21:00 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | +| health-check-docker-stats | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | +| health-check-socket-degrade | A | 2026-08-31 21:00 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | +| heartbeat-logging-contract | A | 2026-08-31 21:00 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| image-seed-hygiene | A | 2026-08-31 21:00 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | +| markitdown-wiki-ingest | A | 2026-08-31 21:00 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| next-dev-prod | A | 2026-08-31 21:00 | SKIPPED | retro lesson 2026-06-04 | +| oh-compose-env-wiring | A | 2026-08-31 21:00 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | +| oh-config-surfaces | A | 2026-08-31 21:00 | PASS | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | +| oh-destroy-guard | A | 2026-08-31 21:00 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | +| oh-devcontainer-restructure | A | 2026-08-31 21:00 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-home-mount | A | 2026-08-31 21:00 | PASS | issue #898 (single $HOME mount) 2026-08-30 | +| oh-image-only-deploy | A | 2026-08-31 21:00 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-headless-config | A | 2026-08-31 21:00 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | +| oh-init-scaffold | A | 2026-08-31 21:00 | PASS | issue #531 Phase 2 | +| oh-lifecycle-surface | A | 2026-08-31 21:00 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | +| oh-npm-package | A | 2026-08-31 21:00 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-31 21:00 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-31 21:00 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-31 21:00 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-31 21:00 | PASS | issue #564 | +| oh-update | A | 2026-08-31 21:00 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-31 21:00 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| pnpm-audit-ci-gate | A | 2026-08-31 21:00 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-31 21:00 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-19 | +| prompt-miner-schema-compat | A | 2026-08-31 21:00 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-31 21:00 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-31 21:00 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| protected-path-deletion | A | 2026-08-31 21:00 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | +| protected-paths-resolve | A | 2026-08-31 21:00 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | +| registry-portability-gate | A | 2026-08-31 21:00 | PASS | issue #758 | +| registry-portability | A | 2026-08-31 21:00 | SKIPPED | issue #758 | +| retro-deterministic-contract | A | 2026-08-31 21:00 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-31 21:00 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-31 21:00 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| runtime-preflight-gate | A | 2026-08-31 21:00 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | +| sandbox-boot-guard-ci | A | 2026-08-31 21:00 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | +| sandbox-node-base | A | 2026-08-31 21:00 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | +| skill-paths | A | 2026-08-31 21:00 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | +| skills-dir-clean | A | 2026-08-31 21:00 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-task-tool-coupling | A | 2026-08-31 21:00 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | +| skills-vendored | A | 2026-08-31 21:00 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-31 21:00 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| spec-family-contract | A | 2026-08-31 21:00 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | +| spec-ready-finalization | A | 2026-08-31 21:00 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | +| ste-checker-contract | A | 2026-08-31 21:00 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | +| submitted-by-trailers | A | 2026-08-31 21:00 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | +| sync-skill-contract | A | 2026-08-31 21:00 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| t3-headless-launch | A | 2026-08-31 21:00 | PASS | issue #858 — /t3 launched a bare `npx --yes t3`, which is the local GUI and | +| tailscale-tool-boundary | A | 2026-08-31 21:00 | PASS | issue #858 — Tailscale mobile access for T3 Code. There is no tailnet, no | +| tool-catalog-boundary | A | 2026-08-31 21:00 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | +| version-parity | A | 2026-08-31 21:00 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | +| weigh-scorer-contract | A | 2026-08-31 21:00 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-31 21:00 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-31 21:00 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | +| worktrees-layout | A | 2026-08-31 21:00 | PASS | issue #872 | diff --git a/.oh/evals/probes/t3-headless-launch.sh b/.oh/evals/probes/t3-headless-launch.sh new file mode 100755 index 00000000..8d51ad6e --- /dev/null +++ b/.oh/evals/probes/t3-headless-launch.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# tier: A +# source: issue #858 — /t3 launched a bare `npx --yes t3`, which is the local GUI and +# never prints a pairing URL, and docs claimed T3 listens on 0.0.0.0:3773. +# Mobile access has to come from the tailnet, not from a wide bind, and the +# server has to survive the operator's terminal going away. +# desc: .oh/skills/t3/scripts/t3-code.sh launches the headless `t3 serve` inside +# `tmux new-session -d`, maps --tailscale to --tailscale-serve, offers `t3 pair`, +# checks the Node floor, never binds 0.0.0.0, and refuses the tailscale path with +# an actionable error when the tailscale binary is absent. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"; cd "$ROOT" +SCRIPT=".oh/skills/t3/scripts/t3-code.sh" + +[ -f "$SCRIPT" ] || { echo "SKIPPED: $SCRIPT absent" >&2; exit 2; } + +BASH_BIN="$(command -v bash)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +missing=() + +mapfile -t invocations < <(grep -oE 'npx --yes t3([[:space:]]+[a-z-]+)?' "$SCRIPT" | sort -u) +if ((${#invocations[@]} == 0)); then + missing+=("$SCRIPT: no 'npx --yes t3' invocation at all") +fi +for inv in "${invocations[@]}"; do + case "$inv" in + "npx --yes t3 serve"|"npx --yes t3 pair") ;; + *) missing+=("$SCRIPT: '$inv' is not a headless subcommand — bare 't3' is the local GUI and never prints a pairing URL") ;; + esac +done +grep -qF 'npx --yes t3 serve' "$SCRIPT" \ + || missing+=("$SCRIPT: no 'npx --yes t3 serve' — the headless server is how a phone pairs") +grep -qF 'npx --yes t3 pair' "$SCRIPT" \ + || missing+=("$SCRIPT: no 'npx --yes t3 pair' — a second device cannot be added without a restart") +grep -qF -- '--tailscale-serve' "$SCRIPT" \ + || missing+=("$SCRIPT: --tailscale is not mapped to t3's --tailscale-serve") +grep -qE 'tmux new-session -d' "$SCRIPT" \ + || missing+=("$SCRIPT: the server is not launched with 'tmux new-session -d' — it would die with the terminal") +grep -qF '0.0.0.0' "$SCRIPT" \ + && missing+=("$SCRIPT: mentions 0.0.0.0 — T3 Code must stay on loopback and be reached through the tailnet") + +stub_absent="$WORK/stub-absent" +mkdir -p "$stub_absent" +printf '#!/bin/sh\necho v22.16.0\n' > "$stub_absent/node" +printf '#!/bin/sh\nexit 0\n' > "$stub_absent/npx" +printf '#!/bin/sh\nexit 1\n' > "$stub_absent/tmux" +chmod 0755 "$stub_absent"/* + +if command -v tailscale >/dev/null 2>&1 && PATH="$stub_absent" command -v tailscale >/dev/null 2>&1; then + echo "SKIPPED: a real tailscale binary is unavoidable on PATH; the absent-binary branch cannot be exercised" >&2 + exit 2 +fi + +set +e +doctor_out="$(env -i PATH="$stub_absent" "$BASH_BIN" "$SCRIPT" doctor --tailscale 2>&1)" +doctor_code=$? +set -e +if ((doctor_code == 0)); then + missing+=("$SCRIPT: 'doctor --tailscale' succeeded with no tailscale binary on PATH — the preflight is not load-bearing") +fi +grep -qF 'oh tool install tailscale' <<<"$doctor_out" \ + || missing+=("$SCRIPT: 'doctor --tailscale' does not name 'oh tool install tailscale' as the fix (got: ${doctor_out//$'\n'/ })") + +stub_oldnode="$WORK/stub-oldnode" +mkdir -p "$stub_oldnode" +printf '#!/bin/sh\necho v22.15.0\n' > "$stub_oldnode/node" +printf '#!/bin/sh\nexit 0\n' > "$stub_oldnode/npx" +printf '#!/bin/sh\nexit 0\n' > "$stub_oldnode/tmux" +chmod 0755 "$stub_oldnode"/* + +set +e +oldnode_out="$(env -i PATH="$stub_oldnode" "$BASH_BIN" "$SCRIPT" doctor 2>&1)" +oldnode_code=$? +set -e +if ((oldnode_code == 0)); then + missing+=("$SCRIPT: 'doctor' accepted Node v22.15.0 — the Node floor is not enforced at runtime") +fi +grep -qF 'v22.15.0' <<<"$oldnode_out" \ + || missing+=("$SCRIPT: 'doctor' on Node v22.15.0 does not report the offending version (got: ${oldnode_out//$'\n'/ })") +grep -qF '^22.16 || ^23.11 || >=24.10' <<<"$oldnode_out" \ + || missing+=("$SCRIPT: 'doctor' on Node v22.15.0 does not name the supported range (got: ${oldnode_out//$'\n'/ })") + +stub_present="$WORK/stub-present" +mkdir -p "$stub_present" +printf '#!/usr/bin/env bash\necho v22.16.0\n' > "$stub_present/node" +printf '#!/usr/bin/env bash\nexit 0\n' > "$stub_present/npx" +printf '#!/usr/bin/env bash\necho "{\\"BackendState\\":\\"Running\\"}"\n' > "$stub_present/tailscale" +cat > "$stub_present/tmux" <<'STUB' +#!/usr/bin/env bash +case "${1:-}" in + has-session) [ -f "$T3_PROBE_STATE/session" ] ;; + new-session) shift; printf '%s\n' "$*" > "$T3_PROBE_STATE/launch"; : > "$T3_PROBE_STATE/session" ;; + capture-pane) echo "pairing url: https://box.example-tailnet.ts.net/pair?token=probe" ;; + kill-session) rm -f "$T3_PROBE_STATE/session" ;; + *) : ;; +esac +STUB +chmod 0755 "$stub_present"/* + +state="$WORK/state" +mkdir -p "$state" +set +e +T3_PROBE_STATE="$state" PATH="$stub_present:$PATH" "$BASH_BIN" "$SCRIPT" start --tailscale \ + --session t3-probe --log "$WORK/t3-probe.log" >"$WORK/start.out" 2>&1 +start_code=$? +set -e +if ((start_code != 0)); then + missing+=("$SCRIPT: 'start --tailscale' failed under stubbed tmux/node/npx/tailscale (${start_code}): $(tr '\n' ' ' < "$WORK/start.out")") +elif [ ! -f "$state/launch" ]; then + missing+=("$SCRIPT: 'start --tailscale' never reached 'tmux new-session -d'") +else + launch="$(cat "$state/launch")" + grep -qE '^-d -s t3-probe ' <<<"$launch" \ + || missing+=("$SCRIPT: the tmux session is not detached and named (got: $launch)") + grep -qF 'npx --yes t3 serve --tailscale-serve' <<<"$launch" \ + || missing+=("$SCRIPT: 'start --tailscale' does not launch 'npx --yes t3 serve --tailscale-serve' (got: $launch)") + grep -qF '0.0.0.0' <<<"$launch" \ + && missing+=("$SCRIPT: the launch command binds 0.0.0.0 (got: $launch)") +fi + +if ((${#missing[@]})); then + printf 'REGRESSION: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +echo "PASS: /t3 launches detached 't3 serve', maps --tailscale to --tailscale-serve, offers 't3 pair', and never binds 0.0.0.0" >&2 diff --git a/.oh/evals/probes/tailscale-tool-boundary.sh b/.oh/evals/probes/tailscale-tool-boundary.sh new file mode 100755 index 00000000..66c78f96 --- /dev/null +++ b/.oh/evals/probes/tailscale-tool-boundary.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# tier: A +# source: issue #858 — Tailscale mobile access for T3 Code. There is no tailnet, no +# auth key, and no phone in CI, so the acceptance criterion "a phone outside +# the tailnet cannot reach the backend" cannot be executed. It is discharged +# structurally instead: the sandbox gains no capability, no tun device and no +# published port, the boot path installs a pinned checksummed binary without +# ever joining a tailnet, and no Funnel command or reusable auth key ships. +# #908 additionally proved a root-installed tool is unusable from inside the +# sandbox: commands/tool.ts uses stdio:"inherit", so a root install becomes an +# interactive `sudo` and /etc/sudoers.d/sandbox has no NOPASSWD. +# desc: the Tailscale optional tool stays a zero-privilege, zero-exposure install — +# entrypointGuard (not buildArg) ground truth, version and both sha256 pins +# agreeing between the entrypoint and the tool catalog, no cap_add/devices/ +# privileged/3773 in any compose file, no tailscaled or `tailscale up` on boot, +# no Funnel, no committed auth key. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"; cd "$ROOT" +ENTRY=".devcontainer/entrypoint.sh" +DOCKERFILE=".devcontainer/Dockerfile" +CATALOG=".oh/cli/src/lib/tools/catalog.ts" + +for f in "$ENTRY" "$DOCKERFILE" "$CATALOG"; do + [ -f "$f" ] || { echo "SKIPPED: $f absent" >&2; exit 2; } +done + +shopt -s nullglob +COMPOSE=(.devcontainer/docker-compose*.yml) +if ((${#COMPOSE[@]} == 0)); then + echo "SKIPPED: no .devcontainer/docker-compose*.yml to check" >&2 + exit 2 +fi + +missing=() + +grep -qF 'INSTALL_TAILSCALE' "$ENTRY" \ + || missing+=("$ENTRY: no INSTALL_TAILSCALE guard — the tool catalog's ground truth moved") +grep -qF 'INSTALL_TAILSCALE' "$DOCKERFILE" \ + && missing+=("$DOCKERFILE: INSTALL_TAILSCALE appeared — a Dockerfile guard means the catalog field must be buildArg, not entrypointGuard") + +mapfile -t pins < <(grep -oE 'tailscale_[0-9]+\.[0-9]+\.[0-9]+_' "$ENTRY" | sed 's/^tailscale_//; s/_$//' | sort -u) +if ((${#pins[@]} == 0)); then + missing+=("$ENTRY: no pinned tailscale__ tarball — the install is unpinned") +elif ((${#pins[@]} > 1)); then + missing+=("$ENTRY: per-architecture version pins disagree (${pins[*]})") +else + grep -qF "tailscale_${pins[0]}_" "$CATALOG" \ + || missing+=("$CATALOG: version pin disagrees with $ENTRY (${pins[0]})") +fi + +grep -qF 'sha256sum -c' "$ENTRY" \ + || missing+=("$ENTRY: no 'sha256sum -c' verification of the Tailscale tarball") + +mapfile -t entry_shas < <(grep -iE 'tailscale|ts_sha' "$ENTRY" | grep -oE '\b[0-9a-f]{64}\b' | sort -u) +mapfile -t catalog_shas < <(grep -iE 'tailscale' "$CATALOG" | grep -oE '\b[0-9a-f]{64}\b' | sort -u) +if ((${#entry_shas[@]} < 2)); then + missing+=("$ENTRY: expected a sha256 literal per supported architecture, found ${#entry_shas[@]}") +elif [ "${entry_shas[*]}" != "${catalog_shas[*]}" ]; then + missing+=("$CATALOG: sha256 literals disagree with $ENTRY (entrypoint: ${entry_shas[*]:-none} / catalog: ${catalog_shas[*]:-none})") +fi + +if grep -qE '(^|[;&|]|&&|\|\||\bthen |\bdo |\bexec |\bnohup |\bsudo )[[:space:]]*("?[^[:space:]"]*/)?tailscaled\b' "$ENTRY"; then + missing+=("$ENTRY: invokes tailscaled — the boot path installs the binary, it never starts the daemon") +fi +if grep -qE '\btailscale[[:space:]]+(-[^[:space:]]+[[:space:]]+)*up([[:space:]]|$)' "$ENTRY"; then + missing+=("$ENTRY: runs 'tailscale up' — joining a tailnet must stay an explicit human act") +fi +if grep -qE 'TS_AUTHKEY|--authkey' "$ENTRY"; then + missing+=("$ENTRY: reads an auth key — the documented path is interactive 'tailscale up'") +fi + +for f in "${COMPOSE[@]}"; do + if grep -qE '^[[:space:]]*cap_add:' "$f"; then + missing+=("$f: cap_add — userspace networking needs no capability grant") + fi + if grep -qE '^[[:space:]]*devices:' "$f"; then + missing+=("$f: devices: — /dev/net/tun must never be handed to the sandbox") + fi + if grep -qE '^[[:space:]]*privileged:[[:space:]]*true' "$f"; then + missing+=("$f: privileged: true — the Tailscale path grants no privilege") + fi + published=$(awk ' + /^[[:space:]]*ports:[[:space:]]*$/ { indent = match($0, /[^ ]/); inports = 1; next } + inports { + if ($0 ~ /^[[:space:]]*$/) next + if (match($0, /[^ ]/) <= indent) { inports = 0; next } + if ($0 ~ /3773/) print + } + ' "$f") + if [ -n "$published" ]; then + missing+=("$f: publishes 3773 — T3 Code must stay on container loopback and be reachable only through the tailnet") + fi +done + +if ! grep -qE 'id:[[:space:]]*"tailscale"' "$CATALOG"; then + missing+=("$CATALOG: no tool entry with id \"tailscale\"") +else + entry_block=$(awk '/id:[[:space:]]*"tailscale"/{found=1} found{print; if (/\}\)/) exit}' "$CATALOG") + grep -qE 'kind:[[:space:]]*"opt-in"' <<<"$entry_block" \ + || missing+=("$CATALOG: the tailscale entry is not kind \"opt-in\" — it must never install by default") + grep -qE 'toolKey:[[:space:]]*"tailscale"' <<<"$entry_block" \ + || missing+=("$CATALOG: the tailscale entry has no toolKey \"tailscale\" — the oh.json opt-in is not wired") + grep -qE 'entrypointGuard:[[:space:]]*"INSTALL_TAILSCALE"' <<<"$entry_block" \ + || missing+=("$CATALOG: the tailscale entry has no entrypointGuard \"INSTALL_TAILSCALE\"") + # tailscaled runs fine unprivileged with --tun=userspace-networking, so nothing + # here needs root. A root install would hang `oh tool install tailscale` on a + # sudo password prompt no agent can answer, and would put the binary in an + # image-layer path that no running sandbox can upgrade and every container + # recreate discards. + grep -qE 'installUser:[[:space:]]*"root"' <<<"$entry_block" \ + && missing+=("$CATALOG: the tailscale entry installs as root — commands/tool.ts uses stdio:\"inherit\", so that becomes an interactive \`sudo\`, and /etc/sudoers.d/sandbox has no NOPASSWD") + grep -qE 'installUser:[[:space:]]*"sandbox"' <<<"$entry_block" \ + || missing+=("$CATALOG: the tailscale entry does not declare installUser \"sandbox\"") + grep -qF 'NPM_USER_PREFIX' <<<"$entry_block" \ + || missing+=("$CATALOG: the tailscale entry does not install into NPM_USER_PREFIX — the binary must land in the home mount, not an image-layer path") + grep -qE '/usr/local/bin/tailscale' <<<"$entry_block" \ + && missing+=("$CATALOG: the tailscale entry writes to /usr/local/bin — that needs root and is discarded on container recreate") +fi + +# The boot path must agree with the catalog: same destination, same user. +grep -qE 'install -m 0755 [^ ]+ /usr/local/bin/tailscaled?' "$ENTRY" \ + && missing+=("$ENTRY: installs Tailscale into /usr/local/bin — the catalog installs it into the home mount, and an image-layer copy is lost on every recreate") + +# tailscaled's default control socket is /var/run/tailscale/tailscaled.sock and +# t3-code.sh calls a bare `tailscale status`. Only root can create that directory, +# so the entrypoint must, and it must not be gated behind INSTALL_TAILSCALE — +# `oh tool install tailscale` is supposed to leave the tool usable immediately. +socket_dir_line=$(grep -nE 'install -d .*-o sandbox .*/var/run/tailscale' "$ENTRY" | head -1 | cut -d: -f1) +if [ -z "$socket_dir_line" ]; then + missing+=("$ENTRY: never creates /var/run/tailscale — tailscaled's default socket path is unwritable, so a bare \`tailscale status\` cannot work") +else + guard_line=$(grep -nE '^if \[ "\$\{INSTALL_TAILSCALE:-false\}" = "true" \]' "$ENTRY" | head -1 | cut -d: -f1) + if [ -n "$guard_line" ] && [ "$socket_dir_line" -gt "$guard_line" ]; then + missing+=("$ENTRY: creates /var/run/tailscale inside the INSTALL_TAILSCALE guard — a later \`oh tool install tailscale\` would then need a reboot before the socket path exists") + fi +fi + +funnel=$(grep -rniE '\bfunnel\b' .oh/skills/t3 .devcontainer 2>/dev/null || true) +if [ -n "$funnel" ]; then + missing+=("Funnel appears in .oh/skills/t3 or .devcontainer — Funnel is public exposure and the harness ships no Funnel command") +fi + +authkeys=$(grep -rIlE 'tskey[-](auth|client|api)[-]' . --exclude-dir=.git 2>/dev/null || true) +if [ -n "$authkeys" ]; then + missing+=("a Tailscale auth key literal is committed in: $(tr '\n' ' ' <<<"$authkeys")") +fi + +if ((${#missing[@]})); then + printf 'REGRESSION: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +echo "PASS: Tailscale installs pinned and checksummed into the home mount as the sandbox user, grants no capability, publishes no port, joins no tailnet on boot, and ships no Funnel or auth key" >&2 diff --git a/.oh/scripts/install.sh b/.oh/scripts/install.sh index 765835fd..654b9e80 100644 --- a/.oh/scripts/install.sh +++ b/.oh/scripts/install.sh @@ -119,7 +119,7 @@ Env vars: sandbox is never overwritten) INSTALL_HERMES=true Enable an optional agent non-interactively. Also: INSTALL_OPENCODE, INSTALL_GROK_BUILD, - INSTALL_AGENT_BROWSER + INSTALL_AGENT_BROWSER, INSTALL_TAILSCALE DOCKER_SOCKET=true Mount the host Docker socket into the sandbox non-interactively. OFF by default (socket access is effectively host root). Otherwise you're prompted (TTY), @@ -440,6 +440,7 @@ _opt_install HERMES install.hermes "Hermes — Nous self-improving a _opt_install OPENCODE install.opencode "OpenCode — OpenAI-OAuth terminal agent" _opt_install GROK_BUILD install.grokBuild "Grok Build — xAI terminal agent" _opt_install AGENT_BROWSER install.agentBrowser "agent-browser + Chromium (~1 GB)" +_opt_install TAILSCALE install.tailscale "Tailscale — private remote access for T3 Code (userspace)" banner "Host Docker socket (off by default)" if [ "$(_config_get access.dockerSocket)" = "true" ]; then @@ -493,6 +494,7 @@ printf " oh harness install hermes — Hermes agent (then 'hermes s printf " oh harness install opencode — OpenCode terminal agent\n" printf " oh harness install grok-build — xAI Grok Build\n" printf " oh tool install agent-browser — headless Chromium for screenshots / previews (~1 GB)\n" +printf " oh tool install tailscale — private tailnet access for remote / mobile T3 Code\n" printf " (each flips the matching install.* flag in oh.json)\n" printf "\n" printf " ${CYAN}Messaging gateways${NC}\n" diff --git a/.oh/scripts/migrate-harness-yaml.sh b/.oh/scripts/migrate-harness-yaml.sh index c45b073b..f2279094 100755 --- a/.oh/scripts/migrate-harness-yaml.sh +++ b/.oh/scripts/migrate-harness-yaml.sh @@ -28,6 +28,7 @@ BEGIN { envmap["install.grok_build"] = "INSTALL_GROK_BUILD" envmap["install.hermes"] = "INSTALL_HERMES" envmap["install.agent_browser"] = "INSTALL_AGENT_BROWSER" + envmap["install.tailscale"] = "INSTALL_TAILSCALE" envmap["hermes.dashboard"] = "HERMES_DASHBOARD" envmap["hermes.dashboard_port"] = "HERMES_DASHBOARD_PORT" envmap["ssh.enabled"] = "SANDBOX_SSH" @@ -141,6 +142,7 @@ _field_for() { INSTALL_GROK_BUILD) printf 'install.grokBuild boolean\n' ;; INSTALL_HERMES) printf 'install.hermes boolean\n' ;; INSTALL_AGENT_BROWSER) printf 'install.agentBrowser boolean\n' ;; + INSTALL_TAILSCALE) printf 'install.tailscale boolean\n' ;; HERMES_DASHBOARD) printf 'hermesDashboard.enabled boolean\n' ;; HERMES_DASHBOARD_PORT) printf 'hermesDashboard.port number\n' ;; SANDBOX_SSH) printf 'access.ssh boolean\n' ;; diff --git a/.oh/skills/t3/SKILL.md b/.oh/skills/t3/SKILL.md index b2a3b9f5..d3980496 100644 --- a/.oh/skills/t3/SKILL.md +++ b/.oh/skills/t3/SKILL.md @@ -1,47 +1,62 @@ --- name: t3 description: | - Start, inspect, or stop T3 Code (`npx t3`) in the Open Harness sandbox. - Use this for the browser-based T3 Code harness on port 3773, including tmux - launch, pairing URL discovery, logs, status, and shutdown. T3 Code wraps an - already-authenticated Claude Code, Codex, or OpenCode backend. - TRIGGER when: user asks to run T3 Code, start `npx t3`, open the T3 browser - UI, get the T3 pairing URL, check T3 Code status/logs, or stop T3 Code. -argument-hint: "[start|status|url|logs|stop|attach|help] [--session ] [--port ] [--log ]" + Start, inspect, pair, or stop T3 Code in the Open Harness sandbox, locally or + over a private Tailscale tailnet for phone access. Use this for the headless + `t3 serve` harness on port 3773, including tmux launch, preflight diagnosis, + pairing URL discovery, minting a pairing URL for a second device, logs, + status, and shutdown. T3 Code wraps an already-authenticated Claude Code, + Codex, or OpenCode backend. + TRIGGER when: user asks to run T3 Code, start `t3 serve`, reach T3 Code from a + phone or another machine, pair a device, get the T3 pairing URL, diagnose why + T3 Code or the tailnet will not start, check T3 Code status/logs, or stop + T3 Code. +argument-hint: "[start|status|url|pair|logs|stop|attach|doctor|help] [--session ] [--port ] [--log ] [--tailscale] [--tailscale-port

]" allowed-tools: Bash, Read disable-model-invocation: true --- # T3 Code -Run T3 Code as a sandbox-local browser harness. Treat it as a long-running -process: start it in tmux, report the pairing URL, and leave the session running -for the operator to open at `localhost:3773` through their host/VS Code port -forwarding. +Run T3 Code as a long-running sandbox process: start `t3 serve` in tmux, report +the pairing URL, and leave the session running. The operator opens it at +`localhost:3773` through host/VS Code port forwarding, or — with `--tailscale` — +from a phone on the same private tailnet. + +`npx t3` (no subcommand) is the desktop GUI launcher and is not what this skill +runs. Headless and remote access use `npx t3 serve`; a new device is added to an +already-running server with `npx t3 pair`. ## Arguments Arguments received: `$ARGUMENTS` - `ACTION`: optional first positional argument; default `start` - - `start`: start T3 Code in tmux, or report the existing session + - `start`: run the preflight, then start `t3 serve` in tmux, or report the existing session - `status`: show whether the tmux session is running and print recent output - `url`: print the latest pairing URL from the log/pane if present + - `pair`: mint a fresh one-time pairing URL against the running server, without restarting it - `logs`: print recent log lines - `stop`: kill the tmux session - `attach`: print the attach command; do not attach from an agent run + - `doctor`: run the preflight checks and print one actionable line per failure - `help`: print script usage - `--session`: tmux session name; default `agent-t3code` -- `--port`: expected UI port; default `3773` +- `--port`: expected T3 Code port; default `3773` - `--log`: log file; default `/tmp/.log` +- `--tailscale`: publish over Tailscale Serve on the tailnet (`t3 serve --tailscale-serve`, `t3 pair --tailscale`) +- `--tailscale-port`: alternate Tailscale Serve HTTPS port; default `443` If the user does not specify an action, use `start`. ## Preconditions -Before launch, remind the user that T3 Code is a UI over an existing provider. +T3 pairing is **not** provider auth. Pairing a phone does not log any provider +in, and a provider login does not pair a device. They are two separate +credentials on two separate lifecycles. + At least one backend must already be installed and authenticated inside the -sandbox: +sandbox before T3 Code is useful: ```bash claude # complete OAuth on first launch @@ -49,9 +64,19 @@ codex login opencode auth login ``` -Do not treat T3 Code itself as replacing provider login. It starts a browser UI -and prints a single-use pairing URL such as -`http://localhost:3773/pair#token=...`. +T3 Code itself prints a single-use pairing URL such as +`http://localhost:3773/pair#token=...`, or an +`https://..ts.net/...` URL in Tailscale mode. Treat that URL +and its token as a secret: never paste it into an issue, a PR, a tracked file, +or a persistent log. + +The preflight (`doctor`, and the first step of `start`) checks: + +- `tmux`, `npx`, and `node` on `PATH` +- Node satisfies `^22.16 || ^23.11 || >=24.10` (the T3 server's `engines.node`) +- with `--tailscale`: the `tailscale` binary is installed, `tailscaled` is + running and reachable, and the tailnet backend state is `Running` +- the T3 port answers on loopback when a session is already up ## Run @@ -61,27 +86,58 @@ Run the bundled script with the received arguments: bash "$CLAUDE_SKILL_DIR/scripts/t3-code.sh" $ARGUMENTS ``` -The script verifies `tmux` and `npx`, starts `npx --yes t3` in tmux for the -`start` action, waits briefly for a pairing URL, and prints follow-up commands. +Launch commands the script emits, verbatim: + +| Invocation | Command | +| --- | --- | +| `/t3 start` | `npx --yes t3 serve` | +| `/t3 start --tailscale` | `npx --yes t3 serve --tailscale-serve` | +| `/t3 start --tailscale --tailscale-port 8443` | `npx --yes t3 serve --tailscale-serve --tailscale-serve-port 8443` | +| `/t3 pair` | `npx --yes t3 pair` | +| `/t3 pair --tailscale` | `npx --yes t3 pair --tailscale` | + +The server always runs under the sandbox tmux convention +(`tmux new-session -d -s agent-t3code '... 2>&1 | tee /tmp/agent-t3code.log'`), +so it survives a terminal disconnect. It stays bound to container loopback; the +skill never binds T3 Code to a public interface. + +For the phone-side recipe and the tailnet session layout, read +[`references/tailscale-mobile.md`](references/tailscale-mobile.md). For the tmux +rules, read [`references/sandbox-processes.md`](references/sandbox-processes.md). ## Report After `start`, report: -- tmux session name -- log path +- tmux session name and log path +- the exact launch command that was used - pairing URL if found, otherwise the command to inspect logs -- local UI URL, normally `http://localhost:3773` -- reminder: if running over SSH/remote host, use VS Code port forwarding or see - `docs/connecting.md` - -For public sharing beyond the attached host, use `/cloudflared 3773` only after -confirming the operator wants a public bearer URL. +- local URL, normally `http://localhost:3773` +- in Tailscale mode, the tailnet HTTPS port and that the URL is the node's + MagicDNS name +- that `/t3 pair` adds a second device without restarting the server +- reminder: over SSH/remote host without a tailnet, use VS Code port forwarding + or see `docs/connecting.md` +- revocation paths: + - `t3 auth` — issue, inspect, and revoke T3 sessions and credentials + - `tailscale serve --https=443 off` — withdraw the Serve mapping (it persists + until you do) + - `tailscale logout`, or delete the node in the Tailscale admin console — + remove the device from the tailnet + +Never echo a pairing URL into a file the repository tracks. + +For public sharing beyond a private tailnet, use `/cloudflared 3773` only after +confirming the operator wants a public bearer URL. Tailscale is private; a +Cloudflared tunnel is not. ## Examples ```bash /t3 +/t3 doctor --tailscale +/t3 start --tailscale +/t3 pair --tailscale /t3 status /t3 logs --session agent-t3code /t3 stop diff --git a/.oh/skills/t3/references/sandbox-processes.md b/.oh/skills/t3/references/sandbox-processes.md index d175665b..d1905f2d 100644 --- a/.oh/skills/t3/references/sandbox-processes.md +++ b/.oh/skills/t3/references/sandbox-processes.md @@ -25,7 +25,7 @@ Format: `-` (kebab-case inside each segment). |----------|---------|---------| | `app-` | `app-docs`, `app-api` | User dev servers | | `cloudflared-` | `cloudflared-3000` | Cloudflare tunnels for shared previews | -| `agent-` | `agent-watcher`, `agent-batch` | Headless / long-running agent processes. Interactive CLIs (`claude`, `codex`, `opencode`) are normally foreground in a terminal or VS Code, not detached in tmux. | +| `agent-` | `agent-watcher`, `agent-batch`, `agent-t3code`, `agent-tailscaled` | Headless / long-running agent processes, including the T3 Code server (`t3 serve`) and the userspace `tailscaled` that fronts it. Interactive CLIs (`claude`, `codex`, `opencode`) are normally foreground in a terminal or VS Code, not detached in tmux. | | `client-` | `client-slack-pi`, `client-slack-hermes`, `client-discord` | External-surface clients that bridge an in-sandbox agent to a third-party UI | | `cron-` | `cron-heartbeat`, `cron-cleanup-tasks-0613-1805`, `cron-system` | Scheduled cron jobs and the cron runtime. | diff --git a/.oh/skills/t3/references/tailscale-mobile.md b/.oh/skills/t3/references/tailscale-mobile.md new file mode 100644 index 00000000..51ae9f2f --- /dev/null +++ b/.oh/skills/t3/references/tailscale-mobile.md @@ -0,0 +1,98 @@ +# T3 Code on a phone, over a private tailnet + +Reaching T3 Code from a phone without exposing it publicly. The tailnet node is +the **sandbox container**, running `tailscaled` in userspace-networking mode as +the unprivileged `sandbox` user. No container capability is added, no host port +is published, and T3 Code stays bound to container loopback. Tailscale Serve +inside the container terminates HTTPS on the tailnet and proxies to +`127.0.0.1:3773`. + +A device that is not on the tailnet cannot reach the backend at all. + +## Sessions + +Both processes follow the sandbox tmux convention (see +[`sandbox-processes.md`](sandbox-processes.md)). + +| Session | Process | Log | +| --- | --- | --- | +| `agent-tailscaled` | `tailscaled` in userspace-networking mode | `/tmp/agent-tailscaled.log` | +| `agent-t3code` | `npx --yes t3 serve --tailscale-serve` | `/tmp/agent-t3code.log` | + +Neither is started by the container entrypoint. Joining a tailnet is an explicit +human act. + +## One-time setup + +```bash +oh tool install tailscale + +tmux new-session -d -s agent-tailscaled \ + 'tailscaled --tun=userspace-networking \ + --statedir=$HOME/.tailscale 2>&1 | tee /tmp/agent-tailscaled.log' + +tailscale up +``` + +`tailscale up` prints a login URL. Open it in a browser and approve the node. +State lives in `$HOME/.tailscale`, which is a named Docker volume, so a container +recreate does not force a re-login. + +## Start the server and pair the phone + +```bash +/t3 doctor --tailscale # confirm Node, binary, daemon, and tailnet state first +/t3 start --tailscale +``` + +The server prints a connection string, a one-time pairing token, a pairing URL, +and a QR code. Install the T3 Code mobile app, then scan the QR code or paste the +`https://..ts.net/...` URL. + +The pairing token is one-time. To add a second device later, do **not** restart +the server: + +```bash +/t3 pair --tailscale +``` + +## Ports + +Tailscale Serve defaults to HTTPS on **443**. Use another port with: + +```bash +/t3 start --tailscale --tailscale-port 8443 +``` + +The hosted `https://app.t3.codes` page cannot talk to a plain-HTTP backend +(mixed content). Tailscale Serve gives you real HTTPS, so it works with both the +hosted page and the native mobile app. + +## Teardown and revocation + +```bash +/t3 stop # kill the T3 Code session +tailscale serve --https=443 off # withdraw the Serve mapping; it persists until you do +t3 auth # inspect and revoke T3 sessions and credentials +tailscale logout # remove this node's tailnet identity +``` + +Delete the node in the Tailscale admin console to revoke it from the other side. + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `tailscale not found in PATH` | `oh tool install tailscale`, and set `install.tailscale: true` in `oh.json` so a rebuild keeps it | +| `tailscaled is not running` | start the `agent-tailscaled` session above; check `/tmp/agent-tailscaled.log` | +| backend state is `NeedsLogin` / `Stopped` | run `tailscale up` interactively and finish the browser login | +| Node does not satisfy the range | the T3 server needs `^22.16 \|\| ^23.11 \|\| >=24.10`; raise the Node pin in `.devcontainer/Dockerfile` and rebuild | +| port 3773 not answering | read `/t3 logs`; the provider backend may have failed to start | +| phone shows the page but cannot connect | confirm the phone is on the same tailnet and the MagicDNS name resolves | + +## Secrets + +Pairing URLs and tokens are credentials. They belong in the terminal and the +`/tmp` session log only — never in a tracked file, an issue, a PR body, or a +persistent log. Tailscale auth keys are never printed or committed; the supported +path is interactive `tailscale up`. diff --git a/.oh/skills/t3/scripts/t3-code.sh b/.oh/skills/t3/scripts/t3-code.sh index d051cd5a..087fc5d8 100755 --- a/.oh/skills/t3/scripts/t3-code.sh +++ b/.oh/skills/t3/scripts/t3-code.sh @@ -3,21 +3,25 @@ set -euo pipefail usage() { cat <<'USAGE' -Usage: t3-code.sh [start|status|url|logs|stop|attach|help] [options] +Usage: t3-code.sh [start|status|url|pair|logs|stop|attach|doctor|help] [options] Actions: - start Start T3 Code in tmux, or report the existing session (default) + start Run the preflight, then start `t3 serve` in tmux, or report the existing session (default) status Show session status and recent output url Print the latest pairing URL found in the log/pane + pair Mint a fresh pairing URL against the already-running server (no restart) logs Print recent log lines stop Kill the tmux session attach Print the tmux attach command (does not attach) + doctor Run the preflight checks and report actionable errors help Show this help Options: - --session tmux session name (default: agent-t3code) - --port expected T3 Code UI port (default: 3773) - --log log path (default: /tmp/.log) + --session tmux session name (default: agent-t3code) + --port expected T3 Code port (default: 3773) + --log log path (default: /tmp/.log) + --tailscale publish over Tailscale Serve (HTTPS on the tailnet) + --tailscale-port

alternate Tailscale Serve HTTPS port (default: 443) USAGE } @@ -25,10 +29,14 @@ ACTION="start" SESSION="agent-t3code" PORT="3773" LOG="" +TAILSCALE="false" +TAILSCALE_PORT="" +TAILSCALED_SESSION="agent-tailscaled" +NODE_RANGE="^22.16 || ^23.11 || >=24.10" if [[ $# -gt 0 ]]; then case "$1" in - start|status|url|logs|stop|attach|help) + start|status|url|pair|logs|stop|attach|doctor|help) ACTION="$1" shift ;; @@ -52,6 +60,16 @@ while [[ $# -gt 0 ]]; do LOG="$2" shift 2 ;; + --tailscale) + TAILSCALE="true" + shift + ;; + --tailscale-port) + [[ $# -ge 2 ]] || { echo "ERROR: --tailscale-port requires a value" >&2; exit 2; } + TAILSCALE_PORT="$2" + TAILSCALE="true" + shift 2 + ;; -h|--help) ACTION="help" shift @@ -72,6 +90,102 @@ has_session() { tmux has-session -t "$SESSION" 2>/dev/null } +node_version_ok() { + local raw major minor + raw="$(node -v 2>/dev/null || true)" + raw="${raw#v}" + [[ "$raw" =~ ^([0-9]+)\.([0-9]+)\. ]] || return 1 + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + if (( major == 22 )); then (( minor >= 16 )); return; fi + if (( major == 23 )); then (( minor >= 11 )); return; fi + if (( major == 24 )); then (( minor >= 10 )); return; fi + (( major > 24 )) +} + +tailscaled_hint() { + cat </dev/null 2>&1; then + echo "ERROR: tmux not found in PATH; every long-running sandbox process runs in tmux" >&2 + failures=$((failures + 1)) + fi + + if ! command -v npx >/dev/null 2>&1; then + echo "ERROR: npx not found in PATH; install Node.js in the sandbox image" >&2 + failures=$((failures + 1)) + fi + + if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found in PATH; T3 Code requires Node ${NODE_RANGE}" >&2 + failures=$((failures + 1)) + elif ! node_version_ok; then + echo "ERROR: Node $(node -v 2>/dev/null) does not satisfy ${NODE_RANGE}; raise the harness Node pin in .devcontainer/Dockerfile and rebuild with 'oh rebuild'" >&2 + failures=$((failures + 1)) + fi + + if [[ "$TAILSCALE" == "true" ]]; then + if ! command -v tailscale >/dev/null 2>&1; then + echo "ERROR: tailscale not found in PATH; run 'oh tool install tailscale'" >&2 + failures=$((failures + 1)) + elif ! tailscale status --json >/dev/null 2>&1; then + echo "ERROR: tailscaled is not running or its socket is unreachable" >&2 + tailscaled_hint >&2 + failures=$((failures + 1)) + else + local state + state="$(tailscale status --json 2>/dev/null | sed -n 's/.*"BackendState"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" + if [[ "$state" != "Running" ]]; then + echo "ERROR: tailnet backend state is '${state:-unknown}', not 'Running'; run 'tailscale up' interactively and complete the browser login" >&2 + failures=$((failures + 1)) + fi + fi + fi + + if [[ "$ACTION" == "pair" || "$ACTION" == "doctor" ]]; then + if command -v tmux >/dev/null 2>&1 && has_session; then + if command -v curl >/dev/null 2>&1 && ! curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/" 2>/dev/null; then + echo "ERROR: T3 Code port ${PORT} is not answering on loopback; check '/t3 logs --session ${SESSION}' or restart with '/t3 stop' then '/t3 start'" >&2 + failures=$((failures + 1)) + fi + fi + fi + + if (( failures > 0 )); then + return 1 + fi + echo "Preflight OK (Node $(node -v 2>/dev/null), tailscale mode: ${TAILSCALE})" + return 0 +} + +serve_argv() { + local -a argv=(npx --yes t3 serve) + if [[ "$TAILSCALE" == "true" ]]; then + argv+=(--tailscale-serve) + if [[ -n "$TAILSCALE_PORT" ]]; then + argv+=(--tailscale-serve-port "$TAILSCALE_PORT") + fi + fi + printf '%s\n' "${argv[*]}" +} + +pair_argv() { + local -a argv=(npx --yes t3 pair) + if [[ "$TAILSCALE" == "true" ]]; then + argv+=(--tailscale) + if [[ -n "$TAILSCALE_PORT" ]]; then + argv+=(--tailscale-serve-port "$TAILSCALE_PORT") + fi + fi + printf '%s\n' "${argv[*]}" +} + recent_output() { if has_session; then tmux capture-pane -t "$SESSION" -p -S -160 2>/dev/null || true @@ -83,7 +197,7 @@ recent_output() { pairing_url() { recent_output \ - | grep -Eoi 'https?://[^[:space:]]*(pairingUrl|pair|token)[^[:space:]]*|pairingUrl[^[:space:]]*[[:space:]]*[:=][[:space:]]*https?://[^[:space:]]+' \ + | grep -Eoi 'https?://[^[:space:]]*\.ts\.net[^[:space:]]*|https?://[^[:space:]]*(pairingUrl|pair|token)[^[:space:]]*|pairingUrl[^[:space:]]*[[:space:]]*[:=][[:space:]]*https?://[^[:space:]]+' \ | sed 's/^[Pp]airing[Uu]rl[^:=]*[:=][[:space:]]*//' \ | tail -n 1 } @@ -93,13 +207,17 @@ print_summary() { url="$(pairing_url || true)" echo "T3 Code session: $SESSION" echo "Log: $LOG" - echo "UI: http://localhost:${PORT}" + echo "Local: http://localhost:${PORT}" + if [[ "$TAILSCALE" == "true" ]]; then + echo "Tailnet: served over HTTPS on port ${TAILSCALE_PORT:-443} at the node's MagicDNS name" + fi if [[ -n "$url" ]]; then echo "Pairing URL: $url" else echo "Pairing URL: not found yet" echo "Inspect: tmux capture-pane -t ${SESSION} -p | grep -iE 'pair|token|url'" fi + echo "Pair another device: /t3 pair$([[ "$TAILSCALE" == "true" ]] && echo ' --tailscale')" echo "Attach: tmux attach -t ${SESSION}" echo "Stop: tmux kill-session -t ${SESSION}" } @@ -109,6 +227,10 @@ case "$ACTION" in usage exit 0 ;; + doctor) + doctor || exit 1 + exit 0 + ;; attach) if has_session; then echo "Attach from an interactive terminal with: tmux attach -t ${SESSION}" @@ -144,11 +266,34 @@ case "$ACTION" in echo "$url" else echo "No pairing URL found yet for session '${SESSION}'." - echo "Try: /t3 logs --session ${SESSION}" + echo "Mint a fresh one against the running server with: /t3 pair" + echo "Or inspect: /t3 logs --session ${SESSION}" exit 1 fi exit 0 ;; + pair) + command -v tmux >/dev/null 2>&1 || { echo "ERROR: tmux not found in PATH" >&2; exit 1; } + if ! has_session; then + echo "ERROR: T3 Code session '${SESSION}' is not running; 'pair' needs a live server. Start it with: /t3 start" >&2 + exit 1 + fi + doctor >/dev/null || exit 1 + echo "Minting a one-time pairing URL against the running server (no restart)." + echo "Command: $(pair_argv)" + if [[ "$TAILSCALE" == "true" ]]; then + if [[ -n "$TAILSCALE_PORT" ]]; then + npx --yes t3 pair --tailscale --tailscale-serve-port "$TAILSCALE_PORT" + else + npx --yes t3 pair --tailscale + fi + else + npx --yes t3 pair + fi + echo + echo "Treat that URL and token as a secret. Do not paste it into an issue, PR, or tracked file." + exit 0 + ;; status) command -v tmux >/dev/null 2>&1 || { echo "ERROR: tmux not found in PATH" >&2; exit 1; } if has_session; then @@ -165,23 +310,27 @@ case "$ACTION" in exit 0 ;; start) - command -v tmux >/dev/null 2>&1 || { echo "ERROR: tmux not found in PATH" >&2; exit 1; } - command -v npx >/dev/null 2>&1 || { echo "ERROR: npx not found in PATH" >&2; exit 1; } + doctor || { echo "Preflight failed. Fix the errors above, then run '/t3 doctor' again." >&2; exit 1; } - echo "T3 Code requires at least one authenticated backend: Claude Code, Codex, or OpenCode." - echo "If none is authenticated yet, run one of: claude | codex login | opencode auth login" + echo "T3 Code is a UI over a provider backend. Provider auth (Claude Code, Codex, OpenCode) is" + echo "separate from T3 pairing: pairing a phone does NOT log you into a provider, and a provider" + echo "login does NOT pair a device. Authenticate a backend first with one of:" + echo " claude | codex login | opencode auth login" echo if has_session; then echo "T3 Code session already running: $SESSION" + echo "To add a device without restarting, use: /t3 pair$([[ "$TAILSCALE" == "true" ]] && echo ' --tailscale')" print_summary exit 0 fi mkdir -p "$(dirname "$LOG")" - : > "$LOG" - tmux new-session -d -s "$SESSION" "npx --yes t3 2>&1 | tee $(printf '%q' "$LOG")" + (umask 077; : > "$LOG") + chmod 600 "$LOG" 2>/dev/null || true + tmux new-session -d -s "$SESSION" "$(serve_argv) 2>&1 | tee $(printf '%q' "$LOG")" echo "Started T3 Code in tmux session: $SESSION" + echo "Launch command: $(serve_argv)" for _ in $(seq 1 40); do if ! has_session; then @@ -196,6 +345,12 @@ case "$ACTION" in done print_summary + echo + echo "Revoke: 't3 auth' manages T3 sessions and credentials." + if [[ "$TAILSCALE" == "true" ]]; then + echo "Withdraw the Serve mapping: tailscale serve --https=${TAILSCALE_PORT:-443} off" + echo "Remove the device: tailscale logout, or delete the node in the Tailscale admin console." + fi ;; *) echo "ERROR: unknown action: $ACTION" >&2 diff --git a/CHANGELOG.md b/CHANGELOG.md index f79e816f..a5122aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m - Add `oh tool list --defaults` and generalize the boot provisioner over both catalogs as `provision-defaults.sh` (`OH_PROVISION_DEFAULTS`) ([#906](https://github.com/mifunedev/openharness/issues/906)). - Fix `oh harness install` hanging on a sudo password prompt inside the sandbox: every harness now installs as the sandbox user, so no install path needs root ([#908](https://github.com/mifunedev/openharness/issues/908)). - Add `skills-task-tool-coupling.sh`, a tier-A probe holding the canonical skill pack and the sandbox in agreement about the Claude-Code-only task tools ([#886](https://github.com/mifunedev/openharness/issues/886)). +- Add `install.tailscale` and `oh tool install tailscale`, an opt-in userspace Tailscale client installed into `~/.local/bin` as the sandbox user, granting no capability ([#858](https://github.com/mifunedev/openharness/issues/858)). + +### Changed +- `/t3` launches the headless `t3 serve` instead of the local-GUI `t3`, and gains `--tailscale`, a `pair` action for a second device, and a `doctor` preflight ([#858](https://github.com/mifunedev/openharness/issues/858)). ### Fixed - Give the five probes that shipped without one a `# source:` header, so every probe records the lesson it closes and the `source` column in `RESULTS.md` is fully populated ([#889](https://github.com/mifunedev/openharness/issues/889)). diff --git a/docs/configuration.md b/docs/configuration.md index a4def2e1..39468d1c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,8 @@ to; `—` means the field is consumed by the `oh` CLI itself and never rendered. All off by default. `oh harness install ` flips the matching field and installs into the running sandbox with no rebuild. The four harness fields map to `oh harness` names: `opencode`, `grok-build`, `hermes`. -`agentBrowser` is not a harness — `oh tool install agent-browser` manages it. +`agentBrowser` and `tailscale` are not harnesses — `oh tool install agent-browser` +and `oh tool install tailscale` manage them. | Field | Type | Default | Compose variable | What it does | | --- | --- | --- | --- | --- | @@ -65,6 +66,7 @@ to `oh harness` names: `opencode`, `grok-build`, `hermes`. | `install.grokBuild` | boolean | `false` | `INSTALL_GROK_BUILD` | Install the Grok Build CLI into `~/.local` at boot. `oh harness install grok-build` sets it and installs now. | | `install.hermes` | boolean | `false` | `INSTALL_HERMES` | Install the Hermes CLI into `~/.local` at boot and enable its runtime wiring (skill vendoring, `auth.json`). | | `install.agentBrowser` | boolean | `false` | `INSTALL_AGENT_BROWSER` | Install agent-browser and Chromium (about 1 GB). | +| `install.tailscale` | boolean | `false` | `INSTALL_TAILSCALE` | Install the Tailscale client for private remote access (userspace networking; no container capabilities). | ### Access diff --git a/docs/connecting.md b/docs/connecting.md index 1da84d35..eba61a79 100644 --- a/docs/connecting.md +++ b/docs/connecting.md @@ -100,12 +100,181 @@ This is NOT the default; you opt in explicitly. Be aware that binding to `0.0.0. **2. External tunnel** -The harness ships no built-in tunnel tool. For public access, bring your own: `cloudflared`, `ngrok`, `tailscale funnel`, or an nginx/Caddy reverse proxy. Start the tunnel inside the sandbox in a named tmux session (see [tmux conventions](#tmux-session-naming)). +For **public** access, use `cloudflared` (shipped in the image, see the `/cloudflared` skill), `ngrok`, or an nginx/Caddy reverse proxy. Start the tunnel inside the sandbox in a named tmux session (see [tmux conventions](#tmux-session-naming)). + +For **private** access from your own devices — including a phone — use Tailscale instead of a public tunnel. See [Mobile access over Tailscale](#mobile-access-over-tailscale). Tailscale Funnel would make a tailnet service public; it is never enabled by default and the harness ships no Funnel command. **3. Direct SSH + nginx multi-tenant routing** To SSH straight into the container — and to route several tenants' containers through one nginx reverse proxy on a single VM — enable the opt-in `sshd` overlay. See [Integrations → SSH](/docs/integrations/sshd). +## Mobile access over Tailscale + +This is the supported path for reaching T3 Code from a phone, and the supported path for reaching it from a remote sandbox at all without publishing a port. Access stays **private to your tailnet**. + +### Where Tailscale runs, and why + +`tailscaled` runs **inside the sandbox container**, in userspace-networking mode, as the unprivileged `sandbox` user. The container is the tailnet node. + +- No `NET_ADMIN`, no `/dev/net/tun`, no `privileged: true`, no host socket mount. Userspace networking needs none of them, and Tailscale Serve is fully supported in that mode. +- **No host port is published.** T3 Code stays on container loopback `127.0.0.1:3773`. Tailscale Serve inside the container proxies tailnet HTTPS to that loopback address. A device outside the tailnet has nothing to reach. +- The only compose change is one environment variable (`INSTALL_TAILSCALE`). Node identity and daemon state live in `/home/sandbox/.tailscale`, inside the single `/home/sandbox` mount, so the node does not re-authenticate on every container recreate without any per-tool volume. +- Because the container is the node, the MagicDNS name your phone saved does not change when you move the workspace to another VM. + +Installing the binary does **not** join a tailnet. The entrypoint never runs `tailscaled` and never runs `tailscale up`. Joining is an explicit human act. + +### Prerequisites + +On the remote host: + +- The sandbox is running (`oh ps`). +- Node in the sandbox satisfies T3 Code's range `^22.16 || ^23.11 || >=24.10` (`node -v`). +- A provider is authenticated in the sandbox (`claude`, `codex login`, or `opencode auth login`). +- A Tailscale account and a tailnet you control. + +On the phone: + +- The Tailscale app, signed in to the **same tailnet**. +- The T3 Code mobile app. + +The phone and the sandbox must share one tailnet. There is no other reachability path. + +### Step 1 — Install Tailscale in the sandbox + +```bash +oh tool install tailscale +``` + +This persists `install.tailscale: true` in the tracked `oh.json` so the opt-in survives container recreation, and installs the binary into a running sandbox when one is up. It is idempotent. + +If the sandbox was not running, the flag is persisted only. Run `oh sandbox` to recreate the container and let the entrypoint install the binary. No rebuild of the image is required — the install is an entrypoint step gated on `INSTALL_TAILSCALE`, not a build layer. Nothing about networking activates until you start the daemon in the next step. + +Check the state at any time: + +```bash +oh tool status tailscale +``` + +### Step 2 — Start the daemon + +Run it in a named tmux session so it survives a disconnect: + +```bash +tmux new-session -d -s agent-tailscaled \ + 'tailscaled --tun=userspace-networking \ + --statedir=$HOME/.tailscale' +``` + +### Step 3 — Join the tailnet + +```bash +tailscale up +``` + +`tailscale up` prints a login URL. Open it in a browser and approve the node. This is the supported setup: an interactive human login. **Never commit a reusable Tailscale auth key**, and never print one into a log or a tracked file. + +Confirm the node is up and note its MagicDNS name: + +```bash +tailscale status +``` + +### Step 4 — Start T3 Code in Tailscale mode + +```text +/t3 start --tailscale +``` + +This runs `npx --yes t3 serve --tailscale-serve` in the `agent-t3code` tmux session. T3 Code configures Tailscale Serve on HTTPS 443 and advertises `https://..ts.net/`. It prints a pairing URL and a QR code. + +Add `--tailscale-port 8443` if HTTPS 443 is already claimed on that node. + +Reprint the current pairing URL at any time: + +```text +/t3 url +``` + +### Step 5 — Pair the phone + +1. Open the T3 Code mobile app. +2. Scan the QR code printed in the `agent-t3code` session, or paste the `https://..ts.net/...` pairing URL. +3. The app binds to the running server. + +The pairing token is single-use. The paired session persists, so the phone reconnects later without pairing again — as long as the phone is on the tailnet and the server is running. + +### Adding another device + +Do not restart the server. Mint a fresh token against the running one: + +```text +/t3 pair --tailscale +``` + +Then scan or paste the new URL on the second device. + +### Lifecycle + +Two tmux sessions carry this setup: + +| Session | Process | +|---------|---------| +| `agent-tailscaled` | the Tailscale daemon | +| `agent-t3code` | `npx t3 serve --tailscale-serve` | + +Both survive a shell or SSH disconnect. Inspect them with `tmux ls`, attach with `tmux attach -t `, detach with `Ctrl-b d`. `/t3 status` and `/t3 logs` read the T3 session without attaching. + +After a container recreate, the tailnet identity is still in `~/.tailscale` inside the home mount, but the daemon is not running: repeat steps 2 and 4. `tailscale up` is not needed again unless you logged out. + +### Revoking access + +T3 pairing credentials and Tailscale device access are **separate**. Revoke both. + +```bash +t3 auth # inspect and revoke T3 sessions and pairing credentials +tailscale serve --https=443 off # withdraw the Serve mapping (it persists until you do) +tailscale logout # sign the sandbox node out of the tailnet +``` + +Then delete the device in the Tailscale admin console. Revoking a phone's own tailnet access is done there too. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `/t3 start --tailscale` reports Tailscale missing | binary not installed | `oh tool install tailscale`, then `oh sandbox` if the sandbox was down | +| `tailscale status` fails to reach the daemon | `tailscaled` not running | repeat step 2; check `tmux ls` for `agent-tailscaled` | +| Backend state is not `Running` / "logged out" | node never joined, or was logged out | `tailscale up` and complete the browser login | +| No `ts.net` URL in the T3 output | Serve was not configured | confirm `tailscale status` is `Running`, then restart with `/t3 start --tailscale` | +| Serve still answers after T3 Code stops | the Serve mapping persists | `tailscale serve --https=443 off` | +| T3 Code refuses to start with an engine error | Node outside `^22.16 \|\| ^23.11 \|\| >=24.10` | check `node -v`; the sandbox image pins Node 22.x, so upgrade past 22.16 | +| Phone cannot reach the URL at all | phone not on the tailnet | sign the phone's Tailscale app in to the same tailnet and confirm it appears in `tailscale status` | +| Phone is on the tailnet but the URL times out | Serve mapping on a different port, or the server stopped | `tailscale serve status`; `/t3 status` | +| `https://app.t3.codes` cannot connect | mixed content: the hosted page is HTTPS and a plain-HTTP tailnet endpoint is blocked | use `--tailscale-serve` (HTTPS) or the native mobile app | + +`/t3 doctor` runs the Tailscale, Node, and tooling checks in one pass and prints an actionable line per failure. + +### Operator-owned fallback: Tailscale on the host + +If your host policy forbids a daemon inside the container, you can instead run `tailscaled` on the remote host and route the sandbox port through it. This is **not** the supported path and the harness does not manage it: + +- It requires publishing `3773` from the container to the host, which widens exposure on any multi-tenant or internet-facing VM. +- `oh tool install tailscale` installs and versions the binary *inside* the sandbox, so `oh tool status tailscale` would not describe the host daemon. +- The tailnet node becomes the host, so the MagicDNS name changes when you move the workspace to another machine. + +You own the configuration and the exposure in that layout. + +### Tailscale versus Cloudflared + +| | Tailscale Serve | Cloudflared | +|---|---|---| +| Audience | your tailnet only | anyone with the URL | +| Auth | tailnet device identity | none — the URL is the bearer credential | +| Use it for | phones, remote laptops, your own devices | a public preview shared with someone off your tailnet | +| Command | `/t3 start --tailscale` | `/cloudflared 3773` | + +Cloudflared remains the right tool for public preview sharing. It is not the mobile path. Tailscale **Funnel** — which would make a tailnet service public — is never enabled by default and the harness ships no Funnel command. + ## tmux session naming All long-running processes inside the sandbox run in named tmux sessions. The naming convention is `-`: @@ -113,7 +282,7 @@ All long-running processes inside the sandbox run in named tmux sessions. The na | Category | Example | Purpose | |----------|---------|---------| | `client-` | `client-slack-pi`, `client-discord` | External-surface clients bridging an in-sandbox agent | -| `agent-` | `agent-watcher`, `agent-batch` | Headless / long-running agent processes (interactive CLIs are foreground, not tmux) | +| `agent-` | `agent-watcher`, `agent-batch`, `agent-t3code`, `agent-tailscaled` | Headless / long-running agent processes (interactive CLIs are foreground, not tmux) | | `app-` | `app-api` | Dev servers | For the full convention see [`.oh/skills/t3/references/sandbox-processes.md`](https://github.com/mifunedev/openharness/blob/development/.oh/skills/t3/references/sandbox-processes.md). @@ -148,11 +317,15 @@ T3 Code is not preinstalled; the first invocation downloads it via `npx`. If an Manual terminal fallback: ```bash -tmux new-session -d -s agent-t3code 'npx t3 2>&1 | tee /tmp/agent-t3code.log' +tmux new-session -d -s agent-t3code 'npx --yes t3 serve 2>&1 | tee /tmp/agent-t3code.log' tmux attach -t agent-t3code ``` -Watch the session output — T3 Code prints a pairing URL. Open that URL in your browser to complete the browser-based pairing step. After pairing, the UI is available at `localhost:3773` on your laptop (via VSCode auto-forwarding). +Watch the session output — T3 Code prints a pairing URL and a QR code. Open that URL in your browser to complete pairing. After pairing, the UI is available at `localhost:3773` on your laptop (via VSCode auto-forwarding). + +To pair a second device later, run `/t3 pair` — do not restart the server. + +To reach T3 Code from a phone, follow [Mobile access over Tailscale](#mobile-access-over-tailscale) instead. Detach from the tmux session without stopping it: `Ctrl-b d`. @@ -170,6 +343,6 @@ If a port is missing, confirm the tmux session is running (`tmux ls`) and that y ## Quick-reference: reach `localhost` from your laptop -| App | Container port | Laptop URL (VSCode attached) | -|-----|---------------|------------------------------| -| T3 Code UI | 3773 | `http://localhost:3773` | +| App | Container port | Laptop URL (VSCode attached) | Tailnet URL (Tailscale Serve) | +|-----|---------------|------------------------------|-------------------------------| +| T3 Code UI | 3773 (loopback only) | `http://localhost:3773` | `https://..ts.net/` | diff --git a/docs/harnesses/overview.md b/docs/harnesses/overview.md index 16b7bc15..03afb587 100644 --- a/docs/harnesses/overview.md +++ b/docs/harnesses/overview.md @@ -117,8 +117,8 @@ Web UI on `http://localhost:3773` over an already-authenticated provider. Prefer Manual terminal fallback: ```bash -tmux new-session -d -s harness-t3code 'npx t3 2>&1 | tee /tmp/harness-t3code.log' -tmux capture-pane -t harness-t3code -p | grep -i pairingUrl +tmux new-session -d -s agent-t3code 'npx --yes t3 serve 2>&1 | tee /tmp/agent-t3code.log' +tmux capture-pane -t agent-t3code -p | grep -iE 'pair|token|url' ``` Open the printed pairing URL (`http://localhost:3773/pair#token=…`) in the Simple Browser tab. Full setup: [T3 Code](./t3code.md). diff --git a/docs/harnesses/t3code.md b/docs/harnesses/t3code.md index 4f2f0a58..933deb37 100644 --- a/docs/harnesses/t3code.md +++ b/docs/harnesses/t3code.md @@ -4,24 +4,28 @@ title: "T3 Code" # T3 Code -T3 Code is a web-based coding agent harness from Theo Browne / ping.gg. Unlike the other harnesses listed here, T3 Code is **not a CLI you talk to in a terminal** — it runs a local web UI on port `3773` and orchestrates an underlying provider (Claude Code, Codex, or OpenCode) as the actual coding agent. You bring your own already-authenticated provider and T3 Code drives it from a browser. +T3 Code is a web-based coding agent harness from Theo Browne / ping.gg. Unlike the other harnesses listed here, T3 Code is **not a CLI you talk to in a terminal** — it runs a web UI backed by a server on port `3773` and orchestrates an underlying provider (Claude Code, Codex, or OpenCode) as the actual coding agent. You bring your own already-authenticated provider and T3 Code drives it from a browser or from the T3 Code mobile app. ## Purpose -Use T3 Code when you want a browser UI over the same providers the other harnesses run from the terminal — multi-thread sessions, conversational history, and a UI for review/approval flows, while reusing whatever provider auth you already have set up in the sandbox. +Use T3 Code when you want a browser or phone UI over the same providers the other harnesses run from the terminal — multi-thread sessions, conversational history, and a UI for review/approval flows, while reusing whatever provider auth you already have set up in the sandbox. -## Install +## Requirements -T3 Code is **not preinstalled** in the sandbox image. The `/t3` skill starts it on demand via `npx --yes t3` and keeps it in tmux: +T3 Code's server package requires Node `^22.16 || ^23.11 || >=24.10`. The sandbox base image is `node:22-trixie-slim`, so a 22.x older than 22.16 is the realistic failure. Check before you launch: -```text -/t3 +```bash +node -v ``` -For a direct shell launch, run: +`/t3 doctor` runs the same check and reports an actionable error when the version is out of range. -```bash -npx t3 +## Install + +T3 Code is **not preinstalled** in the sandbox image. The `/t3` skill starts it on demand via `npx --yes t3 serve` and keeps it in tmux: + +```text +/t3 ``` The first launch downloads the package and starts the server. No global install is required, but you can install it for faster subsequent starts: @@ -36,6 +40,20 @@ Verify: npx t3 --version ``` +## Which command to run + +| Command | Use it when | What it does | +|---------|-------------|--------------| +| `npx t3` | You are on the machine with the browser and want the normal local launch | Starts the server and opens the local UI flow | +| `npx t3 serve` | The server runs headless in the sandbox and you connect from elsewhere | Starts the server only, prints the connection string, a pairing token, a pairing URL, and a QR code | +| `npx t3 serve --tailscale-serve` | You want a phone or another tailnet device to reach the server privately | Same as `serve`, plus configures Tailscale Serve on HTTPS 443 and advertises `https://..ts.net/` | +| `npx t3 pair` | A server is already running and you want to add a device | Mints a fresh one-time pairing token without restarting the server | +| `npx t3 pair --tailscale` | A server is already running and the new device is on the tailnet | Publishes over Tailscale Serve HTTPS and pairs through the MagicDNS URL | + +Inside the sandbox, prefer the `/t3` skill over calling `npx` by hand — it owns the tmux session and the preflight checks. + +Use `--tailscale-serve-port ` (on `serve`) or `--tailscale-serve-port ` (on `pair --tailscale`) when HTTPS 443 is already taken on that tailnet node. `pair --tailscale` also accepts `--ttl` and `--base-dir`. + ## Authentication T3 Code currently supports Codex, Claude, and OpenCode as backends. Install and authenticate **at least one provider** in the sandbox before launching T3 Code (see the per-provider pages for details): @@ -44,42 +62,81 @@ T3 Code currently supports Codex, Claude, and OpenCode as backends. Install and - **[Claude Code](./claude-code.md)**: run `claude` and complete OAuth - **[OpenCode](./opencode.md)**: run `opencode auth login` -T3 Code itself uses a **pairing-URL** auth model: on first start it logs a one-time URL like `http://localhost:3773/pair#token=...` to stdout. Open that URL in your browser to bind the UI to the running server. The token is single-use; restart T3 Code to mint a fresh one. +Provider authentication is **separate** from T3 pairing. Pairing binds a client (browser or phone) to your running T3 server; it grants no provider credentials and does not replace `claude` / `codex login` / `opencode auth login`. + +T3 Code itself uses a **pairing-URL** auth model: on start it prints a one-time URL like `http://localhost:3773/pair#token=...` plus a QR code. Open the URL, or scan the QR from the T3 Code mobile app, to bind the client to the running server. The token is single-use. To add a second device, run `npx t3 pair` against the running server — **do not restart T3 Code**. + +Treat pairing URLs and tokens as secrets. Do not paste them into issues, pull requests, or chat. ## Run in tmux -Per [`.oh/skills/t3/references/sandbox-processes.md`](https://github.com/mifunedev/openharness/blob/development/.oh/skills/t3/references/sandbox-processes.md), long-running processes inside the sandbox go in named tmux sessions. T3 Code listens on `0.0.0.0:3773` so it can be reached from the host. Prefer the `/t3` skill when an agent is available: +Per [`.oh/skills/t3/references/sandbox-processes.md`](https://github.com/mifunedev/openharness/blob/development/.oh/skills/t3/references/sandbox-processes.md), long-running processes inside the sandbox go in named tmux sessions. T3 Code stays bound to **container loopback** (`127.0.0.1:3773`); the harness publishes no host port for it. Reach it through VSCode port forwarding, an SSH tunnel, or Tailscale Serve — see [Connecting to the Sandbox](/docs/connecting). + +Prefer the `/t3` skill when an agent is available: ```text -/t3 start # launch in tmux and print the pairing URL when available -/t3 status # inspect the tmux session and recent output -/t3 url # print the latest pairing URL found in logs -/t3 stop # stop the tmux session +/t3 doctor # preflight: tmux, npx, Node range, and Tailscale state +/t3 start # launch `npx t3 serve` in tmux and print the pairing URL +/t3 start --tailscale # launch `npx t3 serve --tailscale-serve` +/t3 status # inspect the tmux session and recent output +/t3 url # print the latest pairing URL found in logs +/t3 pair # mint a fresh pairing token for a running server +/t3 pair --tailscale # pair a new device through the MagicDNS HTTPS URL +/t3 stop # stop the tmux session ``` Manual terminal fallback: ```bash -tmux new-session -d -s agent-t3code 'npx t3 2>&1 | tee /tmp/agent-t3code.log' +tmux new-session -d -s agent-t3code 'npx --yes t3 serve 2>&1 | tee /tmp/agent-t3code.log' tmux capture-pane -t agent-t3code -p | grep -i pairingUrl ``` -Open the printed pairing URL in your host browser. Reattach to the session at any time: +Reattach to the session at any time: ```bash tmux attach -t agent-t3code ``` -If you need to share the T3 Code UI beyond your attached host session, use `/cloudflared 3773` to start a Cloudflared tunnel for the local port. +The session survives a shell or SSH disconnect. Detach with `Ctrl-b d`. + +## Mobile access over Tailscale + +`--tailscale-serve` configures Tailscale Serve on HTTPS **443** and advertises `https://..ts.net/`. The phone must be signed in to the **same tailnet** as the sandbox. Full end-to-end recipe, prerequisites, and troubleshooting: [Connecting → Mobile access over Tailscale](/docs/connecting#mobile-access-over-tailscale). + +The Serve mapping persists after T3 Code stops. Withdraw it explicitly: + +```bash +tailscale serve --https=443 off +``` + +Use `--tailscale-serve-port 8443` to publish on an alternate HTTPS port; withdraw it with `tailscale serve --https=8443 off`. + +## Revoking access + +Two independent credentials exist. Revoke both when you retire a device. + +```bash +t3 auth # issue, inspect, and revoke T3 sessions and pairing credentials +tailscale serve --https=443 off # withdraw the Serve mapping +tailscale logout # sign this node out of the tailnet +``` + +Remove the device from the tailnet in the Tailscale admin console as well — `tailscale logout` signs out the node, the admin console deletes it. + +## Sharing publicly + +Tailscale is the **private** path and the supported mobile path. If you need a genuinely public preview URL for someone who is not on your tailnet, use `/cloudflared 3773` instead. That is public bearer-URL exposure — anyone with the link reaches the port. Tailscale Funnel is **not** enabled by default and the harness ships no Funnel command. See [Security considerations](../security-considerations.md). ## Tips - T3 Code is a UI over the providers — installing T3 Code does **not** replace `claude login` / `codex login` / `opencode auth login`. Authenticate the provider first, then start T3 Code. -- The pairing token is regenerated on every server restart. Treat it as ephemeral — don't bookmark the URL. +- The hosted page at `https://app.t3.codes` is HTTPS, so it cannot talk to a plain-HTTP tailnet endpoint (mixed content). Use `--tailscale-serve`, which is HTTPS, or the native mobile app. - T3 Code uses Node's experimental SQLite at startup; the warning in the log is expected. ## Upstream documentation - [`pingdotgg/t3code` on GitHub](https://github.com/pingdotgg/t3code) +- [T3 Code remote access](https://github.com/pingdotgg/t3code/blob/main/docs/user/remote-access.md) [Connecting to the Sandbox](/docs/connecting) diff --git a/docs/installation.md b/docs/installation.md index 9de2ea32..11d4aece 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -314,6 +314,24 @@ Optional CLIs are excluded from the default image; `oh harness install ` f | Hermes | `hermes` | Nous Research's self-improving agent CLI | optional: `oh harness install hermes` | | Grok Build | `grok` | xAI's proprietary Grok Build CLI (`@xai-official/grok@0.2.39`, Node >=20) | optional: `oh harness install grok-build` | | agent-browser | `agent-browser` | Headless Chromium for web-capable agents | optional: `oh tool install agent-browser` | +| Tailscale | `tailscale` | Private tailnet access for remote/mobile T3 Code (userspace networking; no container capabilities) | optional: `oh tool install tailscale` | + +Two tools are **not** baked in and install on demand with `oh tool install `: +`agent-browser` and `tailscale`. `oh tool install` persists the opt-in in the +tracked `oh.json` (`install.agentBrowser`, `install.tailscale`) so it survives +container recreation, and installs into a running sandbox when one is up. Both +installs are idempotent. If no sandbox is running, only the flag is persisted — +run `oh sandbox` and the entrypoint installs the tool on boot. Neither needs an +image rebuild. + +Installing `tailscale` places the `tailscale` and `tailscaled` binaries in +`~/.local/bin` and nothing more. It starts no daemon and joins no tailnet. +Networking activates only when a human starts `tailscaled` in +userspace-networking mode and runs `tailscale up` interactively — see +[Connecting → Mobile access over Tailscale](connecting.md#mobile-access-over-tailscale). +Its node identity and daemon state live in `~/.tailscale`, inside the single +`/home/sandbox` mount, so the node does not re-authenticate on every container +recreate. ### Runtimes & package managers diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 605772aa..ddae5963 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -148,6 +148,42 @@ expose to whichever trust level you choose. can't silently clobber another tenant's port. Setup + the nginx multi-tenant recipe: [Integrations → SSH](integrations/sshd.md). +- **Caveat 4 — the optional Tailscale tool (opt-in, private-by-default).** Installing + `tailscale` (`oh tool install tailscale`, persisted as `install.tailscale` in the + tracked `oh.json`) adds **no container capability**: `tailscaled` runs inside the + sandbox in **userspace-networking** mode as the unprivileged `sandbox` user, so + there is no `NET_ADMIN`, no `/dev/net/tun`, no `privileged: true`, and no host + socket mount. The only compose addition is one environment variable + (`INSTALL_TAILSCALE`); daemon state lives in `/home/sandbox/.tailscale`, + inside the single `/home/sandbox` mount. **No host port is published** — T3 Code stays on + container loopback `127.0.0.1:3773` and Tailscale Serve proxies tailnet HTTPS to + it, so a device outside the tailnet has nothing to reach. The posture: + - **Private tailnet only. Tailscale Funnel is never enabled by default and the + harness ships no Funnel command or flag.** Funnel would publish a tailnet + service to the internet; if you want that, you are configuring it yourself, + outside this tree. For a deliberately *public* preview, use `cloudflared` + instead and understand that the URL is the only credential. + - **Installation never joins a tailnet.** The entrypoint installs the binaries and + stops. It never runs `tailscaled` and never runs `tailscale up`. Joining is an + explicit interactive human act. + - **Never print or commit a reusable Tailscale auth key.** The documented and + supported setup is interactive `tailscale up` with a browser login. If an + operator insists on auth-key automation, the key is a secret and belongs in the + gitignored mode-`0600` root `.env` via the §1 secret channel — never in + `oh.json`, a script, or a log. + - **Pairing URLs and tokens are secrets.** T3 Code's pairing URL carries a + single-use token in its fragment. The `/t3` skill keeps the server log under + `/tmp/agent-t3code.log` and writes no URL into a tracked file. Do not paste a + pairing URL into an issue, a pull request, a commit message, or chat. + - **Two revocation paths, both required.** `t3 auth` issues, inspects, and revokes + T3 sessions and pairing credentials. `tailscale serve --https=443 off` withdraws + the Serve mapping, which otherwise persists after T3 Code stops. + `tailscale logout` signs the node out, and the Tailscale admin console deletes + the device. Revoking one does not revoke the other. + + Setup, lifecycle, and troubleshooting: [Connecting → Mobile access over + Tailscale](connecting.md#mobile-access-over-tailscale). + ## 5. Human merge gate / no auto-merge — **ENFORCED (process) · RECOMMENDED (hard gate)** No agent merges its own work to the trunk. diff --git a/oh.json b/oh.json index 91f571b9..c889f765 100644 --- a/oh.json +++ b/oh.json @@ -8,7 +8,8 @@ "opencode": false, "grokBuild": false, "hermes": false, - "agentBrowser": false + "agentBrowser": false, + "tailscale": false }, "access": { "ssh": false,