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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/protected-paths.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ spec
eval
health-check
retro
t3
# --- Orchestrator scripts (.oh/scripts/) ---
.oh/scripts/cron-runtime.ts
.oh/scripts/sandbox-healthcheck.sh
Expand Down
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions .devcontainer/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .oh/cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions .oh/cli/src/__tests__/env-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
Expand All @@ -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/);
Expand Down
4 changes: 4 additions & 0 deletions .oh/cli/src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> =>
Expand All @@ -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);
Expand All @@ -432,6 +434,7 @@ describe("runInit", () => {
"GIT_USER_NAME",
"GIT_USER_EMAIL",
"INSTALL_AGENT_BROWSER",
"INSTALL_TAILSCALE",
"DOCKER_SOCKET",
]) {
expect(dotenv).not.toContain(nonSecret);
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions .oh/cli/src/__tests__/install-flag-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
103 changes: 100 additions & 3 deletions .oh/cli/src/__tests__/tool-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]);
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading