From 9aa7d997e0de37718188cacd4523a50da9d9673c Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Fri, 11 Sep 2026 01:24:13 +0000 Subject: [PATCH 1/3] fix: keep common tools with --no-ssh Run the shared container setup while gating only OpenSSH installation and startup behind the SSH mode. Update regression coverage and documentation for the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/cli.ts | 44 +++++++++++++++++++++---------------- src/core.ts | 2 +- src/runner/ssh-server.sh | 33 ++++++++++++++++++++++------ src/runtime.ts | 17 ++++++++++---- tests/examples.live.test.ts | 2 +- tests/examples.test.ts | 19 ++++++++++++++-- tests/runtime.test.ts | 8 +++++++ 8 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 92a2a3e..710b638 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ The complex example uses several devcontainer features, so the first `up` or `re - When Docker Desktop host services are available, `devbox` can share the SSH agent without relying on a host-shell `SSH_AUTH_SOCK`. - On Docker Desktop, `devbox` prefers the Docker-provided SSH agent socket over the host `SSH_AUTH_SOCK`, which avoids macOS launchd socket mount issues. - `--allow-missing-ssh` starts the workspace without mounting an SSH agent and prints a warning instead of failing. -- `--no-ssh` skips installation and startup of the bundled SSH server but still shares the SSH agent and configures the other host integrations. +- `--no-ssh` skips starting the bundled SSH server but still installs the common container tools, shares the SSH agent, and configures the other host integrations. - `--ssh` explicitly enables the bundled SSH server for a workspace whose saved state has SSH disabled. - `devbox` stages a snapshot of the host `~/.ssh/known_hosts` before startup and skips injection with a warning when that file is missing, unreadable, empty, symlinked, or not a regular file. - `devbox` tries to install the host public key from `~/.ssh/id_rsa.pub` for SSH key-based login inside the container; if that default file is missing, it simply skips that step. diff --git a/src/cli.ts b/src/cli.ts index dbaf71e..ca74b45 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -327,7 +327,7 @@ async function handleUpLike( console.log( sshEnabled ? "Configuring SSH access inside the devcontainer..." - : "Configuring devcontainer access without installing the bundled SSH server...", + : "Configuring devcontainer access without starting the bundled SSH server...", ); if (requiresSshAuthSockPermissionFix(environment.sshAuthSock)) { console.log("Making the forwarded SSH agent socket accessible to the container user..."); @@ -344,15 +344,32 @@ async function handleUpLike( console.log("Syncing Git author identity from the host into the devcontainer..."); await configureGitIdentity(upResult.containerId, environment.gitUserName, environment.gitUserEmail); } + if (!sshEnabled && existingInspects.length > 0) { + const previousPorts = new Set([ + ...getWorkspacePorts(state), + ...(getManagedPortFromContainerName(existingInspects[0]?.Name) !== undefined + ? [getManagedPortFromContainerName(existingInspects[0]?.Name)!] + : []), + ]); + for (const previousPort of previousPorts) { + await stopManagedSshd(upResult.containerId, previousPort); + } + } if (sshEnabled) { await stopManagedSshd(upResult.containerId, ports[0]); await restoreRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); - const runnerCredentials = await runStepWithHeartbeat({ - startMessage: "Installing and starting the SSH server inside the container (first run can take a bit)...", - heartbeatMessage: "Still installing and starting the SSH server", - successMessage: "SSH server is ready", - action: () => startRunner(upResult.containerId, ports[0], remoteWorkspaceFolder), - }); + } + const runnerCredentials = await runStepWithHeartbeat({ + startMessage: sshEnabled + ? "Installing and starting the SSH server inside the container (first run can take a bit)..." + : "Installing bundled development tools inside the container (first run can take a bit)...", + heartbeatMessage: sshEnabled + ? "Still installing and starting the SSH server" + : "Still installing bundled development tools", + successMessage: sshEnabled ? "SSH server is ready" : "Bundled development tools are ready", + action: () => startRunner(upResult.containerId, ports[0], remoteWorkspaceFolder, sshEnabled), + }); + if (sshEnabled) { if (resolvedSshPublicKey.publicKey) { const sshUser = runnerCredentials.user ?? upResult.remoteUser; if (!sshUser) { @@ -381,18 +398,7 @@ async function handleUpLike( console.log("Saving SSH server state for future runs..."); await persistRunnerHostKeys(upResult.containerId, remoteWorkspaceFolder); } else { - if (existingInspects.length > 0) { - const previousPorts = new Set([ - ...getWorkspacePorts(state), - ...(getManagedPortFromContainerName(existingInspects[0]?.Name) !== undefined - ? [getManagedPortFromContainerName(existingInspects[0]?.Name)!] - : []), - ]); - for (const previousPort of previousPorts) { - await stopManagedSshd(upResult.containerId, previousPort); - } - } - console.log("Bundled SSH server installation skipped; published ports are ready for the devcontainer service."); + console.log("Bundled SSH server remains disabled; common container tools were installed."); } const workspaceState = createWorkspaceState({ diff --git a/src/core.ts b/src/core.ts index 175ffe9..8681aaf 100644 --- a/src/core.ts +++ b/src/core.ts @@ -189,7 +189,7 @@ export function helpText(): string { " -p, --port Publish the same port on host and container.", " --ports Publish this many ports, auto-selecting later ports when needed.", " --allow-missing-ssh Continue without SSH agent sharing when unavailable.", - " --no-ssh Do not install or start devbox's bundled SSH server.", + " --no-ssh Do not start devbox's bundled SSH server; install the common container tools.", " --ssh Install and start devbox's bundled SSH server.", " --startup-command Run and persist a command after the container starts.", " --no-startup-command Clear the persisted post-start command.", diff --git a/src/runner/ssh-server.sh b/src/runner/ssh-server.sh index a9c0210..bd27220 100644 --- a/src/runner/ssh-server.sh +++ b/src/runner/ssh-server.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +START_SSH_SERVER="${START_SSH_SERVER:-1}" +if [[ "$START_SSH_SERVER" != "0" && "$START_SSH_SERVER" != "1" ]]; then + echo "ERROR: START_SSH_SERVER must be 0 or 1" >&2 + exit 1 +fi +export START_SSH_SERVER + # get the latest vscode-generated auth sock, if available VSCODE_SSH_AUTH_SOCK="" mapfile -t vscode_ssh_socks < <(compgen -G "/tmp/vscode-ssh*.sock" || true) @@ -57,7 +64,7 @@ as_root_bash() { fi if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then - sudo -n bash -lc "$cmd" + sudo -n env "START_SSH_SERVER=${START_SSH_SERVER}" bash -lc "$cmd" return fi @@ -76,13 +83,18 @@ resolve_path() { # Prefer the non-root invoker when using sudo CURRENT_USER="${SUDO_USER:-$(id -un)}" -# Install deps and prep sshd dirs +# Install common dependencies and prep sshd dirs when enabled as_root_bash ' set -euo pipefail export DEBIAN_FRONTEND=noninteractive missing_packages=() -for package in openssh-server uuid-runtime dtach tmux git; do +packages=(dtach tmux git) +if [[ "$START_SSH_SERVER" == "1" ]]; then + packages+=(openssh-server uuid-runtime) +fi + +for package in "${packages[@]}"; do if ! dpkg-query -W -f="\${db:Status-Status}" "$package" 2>/dev/null | grep -qx installed; then missing_packages+=("$package") fi @@ -96,10 +108,12 @@ else echo "All apt packages already installed; skipping apt-get install." fi -mkdir -p /var/run/sshd -chown root:root /var/run/sshd -chmod 0755 /var/run/sshd -mkdir -p /etc/ssh/sshd_config.d +if [[ "$START_SSH_SERVER" == "1" ]]; then + mkdir -p /var/run/sshd + chown root:root /var/run/sshd + chmod 0755 /var/run/sshd + mkdir -p /etc/ssh/sshd_config.d +fi ' # install GitHub CLI if missing @@ -152,6 +166,11 @@ echo "[install] minimumReleaseAge = 259200" > "$HOME/.bunfig.toml" append_unique_line "$HOME/.tmux.conf" "set -g mouse on" +if [[ "$START_SSH_SERVER" != "1" ]]; then + echo "Bundled SSH server disabled; common container tools installed." + exit 0 +fi + # Use existing password if present, otherwise create it once if [[ -f "$CRED_FILE" ]]; then PASS="$(tr -d '\r\n' < "$CRED_FILE")" diff --git a/src/runtime.ts b/src/runtime.ts index da70dc7..54dc794 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -843,8 +843,9 @@ export async function startRunner( containerId: string, port: number, remoteWorkspaceFolder: string, + startSshServer = true, ): Promise { - const script = buildStartRunnerScript(port, remoteWorkspaceFolder); + const script = buildStartRunnerScript(port, remoteWorkspaceFolder, startSshServer); const result = await devcontainerExec(containerId, script, { quiet: true, stdin: bundledRunnerScript }); const summaryLines = getRunnerSummaryLines(result.stdout); const parsedSummary = parseRunnerCredentials(summaryLines.join("\n")); @@ -854,7 +855,7 @@ export async function startRunner( for (const line of summaryLines) { console.log(` ${line}`); } - } else { + } else if (startSshServer) { const output = result.stdout.trim(); if (output) { console.log(output); @@ -864,7 +865,7 @@ export async function startRunner( return { user: parsedSummary.user, password: parsedSummary.password, - sshPort: parsedSummary.sshPort ?? port, + sshPort: parsedSummary.sshPort ?? (startSshServer ? port : null), permitRootLogin: parsedSummary.permitRootLogin, }; } @@ -882,7 +883,15 @@ export function buildStartupCommandScript(command: string): string { return trimmed; } -export function buildStartRunnerScript(port: number, remoteWorkspaceFolder: string): string { +export function buildStartRunnerScript( + port: number, + remoteWorkspaceFolder: string, + startSshServer = true, +): string { + if (!startSshServer) { + return `env START_SSH_SERVER=${quoteShell("0")} bash -s`; + } + return `env SSH_PORT=${quoteShell(String(port))} CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash -s`; } diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 5516fb2..cc44bbd 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -235,7 +235,7 @@ describe("example workspaces (real devcontainers)", () => { expect(up.exitCode).toBe(0); expect(up.stdout).toContain(`Using ports ${fixture.port},`); - expect(up.stdout).toContain("Bundled SSH server installation skipped"); + expect(up.stdout).toContain("Bundled SSH server remains disabled; common container tools were installed."); expect(up.stdout).not.toContain("SSH server:"); const state = await readJson(fixture.statePath); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index bba3518..94dd50c 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -321,6 +321,11 @@ function handleDevcontainer() { return; } + if (script.includes("START_SSH_SERVER='0'")) { + console.log("Bundled SSH server disabled; common container tools installed."); + return; + } + if (script.includes("SSH_PORT=") && script.includes("CRED_FILE=")) { const match = script.match(/SSH_PORT='([^']+)'/); const port = match ? match[1] : "22"; @@ -517,7 +522,7 @@ describe("example workspaces (simulated host tools)", () => { const withoutSsh = runCli(fixture, ["up", "6000", "--ports", "3", "--no-ssh", "--allow-missing-ssh"]); expect(withoutSsh.exitCode).toBe(0); expect(withoutSsh.stdout).toContain("Using ports 6000, 6001, 6002."); - expect(withoutSsh.stdout).toContain("Bundled SSH server installation skipped"); + expect(withoutSsh.stdout).toContain("Bundled SSH server remains disabled; common container tools were installed."); expect(withoutSsh.stdout).not.toContain("SSH server:"); expect(withoutSsh.stdout).toContain("Ready."); expect(withoutSsh.stdout).toContain("ports 6000, 6001, 6002"); @@ -540,6 +545,14 @@ describe("example workspaces (simulated host tools)", () => { expect(stateWithoutSsh.sshEnabled).toBe(false); const commandsWithoutSsh = await readCommandLog(fixture.commandLogPath); + expect( + commandsWithoutSsh.some( + (entry) => + entry.tool === "devcontainer" && + entry.args[0] === "exec" && + entry.script === "env START_SSH_SERVER='0' bash -s", + ), + ).toBe(true); expect( commandsWithoutSsh.some((entry) => typeof entry.script === "string" && entry.script.includes("SSH_PORT=")), ).toBe(false); @@ -572,7 +585,9 @@ describe("example workspaces (simulated host tools)", () => { const rebuiltWithoutSsh = runCli(fixture, ["rebuild", "--no-ssh", "--allow-missing-ssh"]); expect(rebuiltWithoutSsh.exitCode).toBe(0); - expect(rebuiltWithoutSsh.stdout).toContain("Bundled SSH server installation skipped"); + expect(rebuiltWithoutSsh.stdout).toContain( + "Bundled SSH server remains disabled; common container tools were installed.", + ); const rebuiltState = await readJson(fixture.statePath); expect(rebuiltState.ports).toEqual([6000, 6001, 6002]); expect(rebuiltState.sshEnabled).toBe(false); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index b091512..268f2c6 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -740,6 +740,12 @@ describe("buildStartRunnerScript", () => { expect(script).not.toContain("curl"); expect(script).not.toContain("http"); }); + + test("runs common setup without starting the SSH server when requested", () => { + expect(buildStartRunnerScript(5001, "/workspaces/example-project", false)).toBe( + "env START_SSH_SERVER='0' bash -s", + ); + }); }); describe("bundled runner script", () => { @@ -749,6 +755,8 @@ describe("bundled runner script", () => { expect(script).toContain("mkdir -p /var/run/sshd"); expect(script).toContain("chown root:root /var/run/sshd"); expect(script).toContain("chmod 0755 /var/run/sshd"); + expect(script).toContain("Bundled SSH server disabled; common container tools installed."); + expect(script).toContain("npm install -g @fresh-editor/fresh-editor"); }); }); From 3015ead160674716d3bf4601bb25893228b94b3c Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Fri, 11 Sep 2026 01:47:04 +0000 Subject: [PATCH 2/3] test: address no-ssh review feedback Explicitly pin the SSH runner mode and strengthen no-SSH coverage for common tool installation and listener cleanup. Add live assertions for the installed development tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/runtime.ts | 5 +- tests/examples.live.test.ts | 7 ++ tests/examples.test.ts | 185 +++++++++++++++++++++++++++++++++++- tests/runtime.test.ts | 4 +- 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/src/runtime.ts b/src/runtime.ts index 54dc794..f385f0f 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -892,7 +892,10 @@ export function buildStartRunnerScript( return `env START_SSH_SERVER=${quoteShell("0")} bash -s`; } - return `env SSH_PORT=${quoteShell(String(port))} CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash -s`; + return ( + `env START_SSH_SERVER=${quoteShell("1")} SSH_PORT=${quoteShell(String(port))} ` + + `CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash -s` + ); } export async function persistRunnerHostKeys( diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index cc44bbd..56715f6 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -244,6 +244,13 @@ describe("example workspaces (real devcontainers)", () => { expect(state.ports[0]).toBe(fixture.port); expect(state.sshEnabled).toBe(false); + const commonTools = execInContainer( + fixture, + containerId, + "command -v gh && node --version && npm --version && command -v fresh && git --version && tmux -V && dtach -V", + ); + expect(commonTools.exitCode).toBe(0); + const inspect = inspectContainer(fixture, containerId); for (const port of state.ports as number[]) { expect(getPublishedHostPort(inspect, port)).toBe(String(port)); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 94dd50c..ec3fa37 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -26,6 +26,7 @@ interface ExampleFixture { env: Record; generatedConfigPath: string; homeDir: string; + runnerSetupPath: string; sourceConfigPath: string | null; sshAuthSockPath: string | null; statePath: string; @@ -48,7 +49,15 @@ interface LoggedCommand { } const FAKE_HOST_TOOL = String.raw`#!/usr/bin/env bun -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; const tool = process.env.DEVBOX_FAKE_TOOL; @@ -142,6 +151,134 @@ function getPublishedPorts(runArgs) { return ports; } +function writeFakeExecutable(filePath, source) { + writeFileSync(filePath, "#!" + process.execPath + "\n" + source + "\n", "utf8"); + chmodSync(filePath, 0o755); +} + +function linkSystemCommand(binDir, name) { + const sourcePath = path.join("/bin", name); + if (!existsSync(sourcePath)) { + throw new Error("Missing system command for fake runner: " + sourcePath); + } + + symlinkSync(sourcePath, path.join(binDir, name)); +} + +function runNoSshRunner(container, runnerScript) { + const setupRoot = path.join(root, "runner-setup-" + Date.now() + "-" + Math.random().toString(16).slice(2)); + const binDir = path.join(setupRoot, "bin"); + const homeDir = path.join(setupRoot, "home"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + + for (const command of ["bash", "mkdir", "dirname", "pwd", "touch", "grep", "id", "chmod", "cat"]) { + linkSystemCommand(binDir, command); + } + + writeFakeExecutable(path.join(binDir, "dpkg-query"), 'console.log("not-installed");'); + writeFakeExecutable(path.join(binDir, "rm"), "process.exit(0);"); + writeFakeExecutable( + path.join(binDir, "node"), + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const root = process.env.FAKE_RUNNER_ROOT;', + 'fs.writeFileSync(path.join(root, "node-invoked"), "true\\n");', + 'console.log("v24.0.0");', + ].join("\n"), + ); + writeFakeExecutable( + path.join(binDir, "npm"), + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const root = process.env.FAKE_RUNNER_ROOT;', + 'const args = process.argv.slice(2);', + 'if (args[0] === "-v") {', + ' fs.writeFileSync(path.join(root, "npm-invoked"), "true\\n");', + ' console.log("10.0.0");', + ' process.exit(0);', + '}', + 'if (args[0] === "install" && args.includes("-g") && args.includes("@fresh-editor/fresh-editor")) {', + ' fs.writeFileSync(path.join(root, "fresh-editor-installed"), "true\\n");', + '}', + ].join("\n"), + ); + writeFakeExecutable( + path.join(binDir, "apt-get"), + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const { chmodSync } = require("node:fs");', + 'const root = process.env.FAKE_RUNNER_ROOT;', + 'const bin = process.env.FAKE_RUNNER_BIN;', + 'const args = process.argv.slice(2);', + 'if (args[0] === "update") {', + ' fs.appendFileSync(path.join(root, "apt-updates"), "update\\n");', + ' process.exit(0);', + '}', + 'if (args[0] !== "install") {', + ' process.exit(0);', + '}', + 'for (const pkg of args.filter((arg) => !arg.startsWith("-"))) {', + ' fs.writeFileSync(path.join(root, "apt-" + pkg), "installed\\n");', + ' if (["gh", "dtach", "tmux", "git"].includes(pkg)) {', + ' const commandPath = path.join(bin, pkg);', + ' fs.writeFileSync(commandPath, "#!" + process.execPath + "\\nprocess.exit(0);\\n");', + ' chmodSync(commandPath, 0o755);', + ' }', + '}', + ].join("\n"), + ); + + const result = Bun.spawnSync(["/bin/bash", "-lc", runnerScript], { + cwd: container?.workspacePath ?? setupRoot, + env: { + ...process.env, + HOME: homeDir, + PATH: binDir, + START_SSH_SERVER: "0", + FAKE_RUNNER_ROOT: setupRoot, + FAKE_RUNNER_BIN: binDir, + SSH_AUTH_SOCK: "", + }, + stderr: "pipe", + stdout: "pipe", + }); + const stdout = Buffer.from(result.stdout).toString("utf8"); + const stderr = Buffer.from(result.stderr).toString("utf8"); + const installedPackages = ["dtach", "tmux", "git", "gh", "openssh-server", "uuid-runtime"].filter((pkg) => + existsSync(path.join(setupRoot, "apt-" + pkg)), + ); + + writeFileSync( + path.join(root, "runner-setup.json"), + JSON.stringify( + { + exitCode: result.exitCode, + stdout, + stderr, + installedPackages, + ghInstalled: existsSync(path.join(binDir, "gh")), + nodeInvoked: existsSync(path.join(setupRoot, "node-invoked")), + npmInvoked: existsSync(path.join(setupRoot, "npm-invoked")), + freshEditorInstalled: existsSync(path.join(setupRoot, "fresh-editor-installed")), + }, + null, + 2, + ) + "\n", + "utf8", + ); + + if (result.exitCode !== 0) { + console.error(stderr || stdout || "Fake bundled runner failed."); + process.exit(result.exitCode ?? 1); + } + + console.log(stdout); +} + function buildInspectPayload(container) { const ports = {}; for (const port of container.ports ?? []) { @@ -280,16 +417,20 @@ function handleDevcontainer() { const config = JSON.parse(readFileSync(configPath, "utf8")); const runArgs = Array.isArray(config.runArgs) ? config.runArgs.map(String) : []; - const containerId = "fake-container-" + state.nextId; const containerName = getContainerName(runArgs, "devbox-fake-" + state.nextId); const ports = getPublishedPorts(runArgs); const labels = parseLabels(args); + const existingContainer = Object.values(state.containers).find( + (container) => + container.running && + Object.entries(labels).every(([key, value]) => container.labels?.[key] === value), + ); + const containerId = existingContainer?.id ?? "fake-container-" + state.nextId; const remoteWorkspaceFolder = typeof config.workspaceFolder === "string" && config.workspaceFolder.length > 0 ? config.workspaceFolder : path.posix.join("/workspaces", path.basename(workspacePath)); - state.nextId += 1; state.containers[containerId] = { id: containerId, labels, @@ -300,6 +441,9 @@ function handleDevcontainer() { running: true, workspacePath, }; + if (!existingContainer) { + state.nextId += 1; + } mkdirSync(userDataDir, { recursive: true }); saveState(state); log({ configPath, containerId, labels, workspacePath }); @@ -322,7 +466,9 @@ function handleDevcontainer() { } if (script.includes("START_SSH_SERVER='0'")) { - console.log("Bundled SSH server disabled; common container tools installed."); + const runnerScript = readFileSync(0, "utf8"); + const container = containerId ? state.containers[containerId] : null; + runNoSshRunner(container, runnerScript); return; } @@ -527,6 +673,14 @@ describe("example workspaces (simulated host tools)", () => { expect(withoutSsh.stdout).toContain("Ready."); expect(withoutSsh.stdout).toContain("ports 6000, 6001, 6002"); + const runnerSetup = await readJson(fixture.runnerSetupPath); + expect(runnerSetup.exitCode).toBe(0); + expect(runnerSetup.installedPackages).toEqual(["dtach", "tmux", "git", "gh"]); + expect(runnerSetup.ghInstalled).toBe(true); + expect(runnerSetup.nodeInvoked).toBe(true); + expect(runnerSetup.npmInvoked).toBe(true); + expect(runnerSetup.freshEditorInstalled).toBe(true); + const generatedConfig = await readJson(fixture.generatedConfigPath); expect(generatedConfig.runArgs).toEqual([ "--name", @@ -583,6 +737,28 @@ describe("example workspaces (simulated host tools)", () => { expect(stateWithSsh.ports).toEqual([6000, 6001, 6002]); expect(stateWithSsh.sshEnabled).toBe(true); + const commandsBeforeDisablingSsh = await readCommandLog(fixture.commandLogPath); + const switchedWithoutSsh = runCli(fixture, ["up", "--no-ssh", "--allow-missing-ssh"]); + expect(switchedWithoutSsh.exitCode).toBe(0); + expect(switchedWithoutSsh.stdout).toContain( + "Bundled SSH server remains disabled; common container tools were installed.", + ); + + const commandsAfterDisablingSsh = await readCommandLog(fixture.commandLogPath); + const cleanupCommands = commandsAfterDisablingSsh.slice(commandsBeforeDisablingSsh.length); + expect( + cleanupCommands.some( + (entry) => + entry.tool === "docker" && + entry.args[0] === "exec" && + entry.user === "root" && + typeof entry.script === "string" && + entry.script.includes("target_port=':1770'"), + ), + ).toBe(true); + const switchedState = await readJson(fixture.statePath); + expect(switchedState.sshEnabled).toBe(false); + const rebuiltWithoutSsh = runCli(fixture, ["rebuild", "--no-ssh", "--allow-missing-ssh"]); expect(rebuiltWithoutSsh.exitCode).toBe(0); expect(rebuiltWithoutSsh.stdout).toContain( @@ -899,6 +1075,7 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO env, generatedConfigPath: sourceConfigPath ? getGeneratedConfigPath(sourceConfigPath) : path.join(stateDir, ".devcontainer.json"), homeDir, + runnerSetupPath: path.join(fakeHostDir, "runner-setup.json"), sourceConfigPath, sshAuthSockPath, statePath: path.join(stateDir, "state.json"), diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 268f2c6..b4aaf34 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -736,7 +736,9 @@ describe("buildStartRunnerScript", () => { test("runs the bundled runner from stdin without downloading an external script", () => { const script = buildStartRunnerScript(5001, "/workspaces/example-project"); - expect(script).toBe("env SSH_PORT='5001' CRED_FILE='/workspaces/example-project/.devbox/ssh/credentials' bash -s"); + expect(script).toBe( + "env START_SSH_SERVER='1' SSH_PORT='5001' CRED_FILE='/workspaces/example-project/.devbox/ssh/credentials' bash -s", + ); expect(script).not.toContain("curl"); expect(script).not.toContain("http"); }); From 12adfca2b56e8f0c5f1a4609a9bd820ab1e05701 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Fri, 11 Sep 2026 02:07:01 +0000 Subject: [PATCH 3/3] fix: make CI no-ssh tests portable Use a portable dtach presence check in live integration coverage and keep the simulated runner from invoking the host sudo configuration when tests run as a non-root CI user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/examples.live.test.ts | 2 +- tests/examples.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 56715f6..de3496d 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -247,7 +247,7 @@ describe("example workspaces (real devcontainers)", () => { const commonTools = execInContainer( fixture, containerId, - "command -v gh && node --version && npm --version && command -v fresh && git --version && tmux -V && dtach -V", + "command -v gh && node --version && npm --version && command -v fresh && git --version && tmux -V && command -v dtach", ); expect(commonTools.exitCode).toBe(0); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index ec3fa37..d2a7fcf 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -231,6 +231,19 @@ function runNoSshRunner(container, runnerScript) { '}', ].join("\n"), ); + writeFakeExecutable( + path.join(binDir, "sudo"), + [ + 'const { spawnSync } = require("node:child_process");', + 'const args = process.argv.slice(2).filter((arg) => arg !== "-n");', + 'if (args.length === 1 && args[0] === "true") {', + ' process.exit(0);', + '}', + 'const executable = args[0] === "env" ? "/usr/bin/env" : args[0];', + 'const result = spawnSync(executable, args.slice(1), { env: process.env, stdio: "inherit" });', + 'process.exit(result.status ?? 1);', + ].join("\n"), + ); const result = Bun.spawnSync(["/bin/bash", "-lc", runnerScript], { cwd: container?.workspacePath ?? setupRoot,