From c346e6237697d92f18023dff497c13573fcdb64f Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sun, 7 Jun 2026 14:10:56 +0000 Subject: [PATCH 1/3] embedded ssh-server-runner --- README.md | 4 +- package.json | 2 +- src/constants.ts | 2 - src/core.ts | 4 +- src/runner/ssh-server.sh | 213 ++++++++++++++++++++++++++++++++++++ src/runtime.ts | 24 ++-- tests/examples.live.test.ts | 123 ++------------------- tests/runtime.test.ts | 11 ++ 8 files changed, 254 insertions(+), 129 deletions(-) create mode 100644 src/runner/ssh-server.sh diff --git a/README.md b/README.md index 787231d..a340bed 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der - Shares a usable SSH agent socket with the container and copies a validated, non-empty `known_hosts` snapshot into the container. - Exposes the SSH service on the chosen host port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer. - Seeds the container user's global Git `user.name` and `user.email` from the host when available. -- Runs the [`ssh-server-runner`](https://github.com/PabloZaiden/ssh-server-runner) one-liner inside the devcontainer. +- Runs devbox's bundled SSH server setup script inside the devcontainer. - Stores devbox-owned state in the workspace-local `.devbox/` directory, and persists the runner password as `.sshcred`, SSH metadata in `.devbox-ssh.json`, and SSH host keys in `.devbox-ssh-host-keys/`, so they survive `down` / `rebuild`. ## Installation @@ -104,7 +104,7 @@ When you run `devbox rebuild`, omitting the port reuses the last stored port for `devbox status` always prints JSON so it can be used directly from scripts and automation. -`devbox templates` always prints JSON. Each entry includes the template name, description, pinned image/reference, runtime version, language tags, and whether the template is compatible with `ssh-server-runner`. +`devbox templates` always prints JSON. Each entry includes the template name, description, pinned image/reference, runtime version, language tags, and whether the template is compatible with the bundled devbox SSH runner. Built-in templates: diff --git a/package.json b/package.json index c7f99a3..94ac82b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@pablozaiden/devbox", "version": "0.1.0", "type": "module", - "description": "CLI to run and expose a devcontainer with SSH agent sharing and a forwarded ssh-server-runner port.", + "description": "CLI to run and expose a devcontainer with SSH agent sharing and a bundled SSH server.", "repository": { "type": "git", "url": "git+https://github.com/PabloZaiden/devbox.git" diff --git a/src/constants.ts b/src/constants.ts index 6408fdc..de6e02d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,4 @@ export const KNOWN_HOSTS_SNAPSHOT_FILENAME = "known_hosts"; export const RUNNER_CRED_FILENAME = ".sshcred"; export const DEVBOX_SSH_METADATA_FILENAME = ".devbox-ssh.json"; export const RUNNER_HOST_KEYS_DIRNAME = ".devbox-ssh-host-keys"; -export const RUNNER_URL = - "https://raw.githubusercontent.com/PabloZaiden/ssh-server-runner/main/ssh-server.sh"; export const STATE_VERSION = 2; diff --git a/src/core.ts b/src/core.ts index c97cbc9..f303941 100644 --- a/src/core.ts +++ b/src/core.ts @@ -124,7 +124,7 @@ export class UserError extends Error { } export function helpText(): string { - return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus ssh-server-runner\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath ]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer.\n rebuild Recreate the managed devcontainer.\n shell Open an interactive shell in the running managed container.\n status Print JSON describing the managed devbox for this workspace.\n templates Print JSON describing the built-in templates.\n arise Restart stopped managed workspaces discovered from existing containers.\n down Stop and remove the managed container for this workspace.\n help Show this help.\n\nOptions:\n -p, --port Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath Use .devcontainer//devcontainer.json.\n --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template Use a built-in template instead of a repo devcontainer.\n -h, --help Show this help.`; + return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--template ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath ]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer.\n rebuild Recreate the managed devcontainer.\n shell Open an interactive shell in the running managed container.\n status Print JSON describing the managed devbox for this workspace.\n templates Print JSON describing the built-in templates.\n arise Restart stopped managed workspaces discovered from existing containers.\n down Stop and remove the managed container for this workspace.\n help Show this help.\n\nOptions:\n -p, --port Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath Use .devcontainer//devcontainer.json.\n --ssh-public-key Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template Use a built-in template instead of a repo devcontainer.\n -h, --help Show this help.`; } export function parseArgs(argv: string[]): ParsedArgs { @@ -978,7 +978,7 @@ function resolveBuiltInTemplate(name: string): WorkspaceTemplateState { } if (!definition.runnerCompatible) { - throw new UserError(`Template ${name} is not compatible with ssh-server-runner.`); + throw new UserError(`Template ${name} is not compatible with the bundled devbox SSH runner.`); } validateSupportedDevcontainerConfig(definition.config); diff --git a/src/runner/ssh-server.sh b/src/runner/ssh-server.sh new file mode 100644 index 0000000..34976b9 --- /dev/null +++ b/src/runner/ssh-server.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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) +if [[ ${#vscode_ssh_socks[@]} -gt 0 ]]; then + VSCODE_SSH_AUTH_SOCK=$(ls -1t "${vscode_ssh_socks[@]}" | head -n 1) +fi + +if [ -n "${VSCODE_SSH_AUTH_SOCK}" ]; then + export SSH_AUTH_SOCK=${VSCODE_SSH_AUTH_SOCK} +fi + +if [ -n "${SSH_AUTH_SOCK:-}" ]; then + echo "export SSH_AUTH_SOCK=\"${SSH_AUTH_SOCK}\"" >> ~/.profile + echo "export SSH_AUTH_SOCK=\"${SSH_AUTH_SOCK}\"" >> ~/.bashrc + echo "export SSH_AUTH_SOCK=\"${SSH_AUTH_SOCK}\"" >> ~/.zshenv +fi + +CRED_FILE="${CRED_FILE:-.sshcred}" +SSH_PORT="${SSH_PORT:-5001}" + +as_root() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + return + fi + + if command -v sudo >/dev/null 2>&1; then + sudo -n "$@" 2>/dev/null || sudo "$@" + return + fi + + echo "ERROR: need root privileges (run as root or install/configure sudo)" >&2 + exit 1 +} + +as_root_bash() { + local cmd="$1" + + if [[ "$(id -u)" -eq 0 ]]; then + bash -lc "$cmd" + return + fi + + if command -v sudo >/dev/null 2>&1; then + sudo -n bash -lc "$cmd" 2>/dev/null || sudo bash -lc "$cmd" + return + fi + + echo "ERROR: need root privileges (run as root or install/configure sudo)" >&2 + exit 1 +} + +resolve_path() { + local path="$1" + local dir_path + + dir_path="$(cd "$(dirname "$path")" && pwd -P)" + printf '%s/%s\n' "$dir_path" "$(basename "$path")" +} + +# Prefer the non-root invoker when using sudo +CURRENT_USER="${SUDO_USER:-$(id -un)}" + +# Install deps and prep sshd dirs +as_root_bash ' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +missing_packages=() +for package in openssh-server uuid-runtime dtach tmux git; do + if ! dpkg-query -W -f="\${db:Status-Status}" "$package" 2>/dev/null | grep -qx installed; then + missing_packages+=("$package") + fi +done + +if [[ ${#missing_packages[@]} -gt 0 ]]; then + apt-get update + apt-get install -y --no-install-recommends "${missing_packages[@]}" + rm -rf /var/lib/apt/lists/* +else + echo "All apt packages already installed; skipping apt-get install." +fi + +mkdir -p /var/run/sshd +mkdir -p /etc/ssh/sshd_config.d +' + +# install fresh editor +curl https://raw.githubusercontent.com/sinelaw/fresh/refs/heads/master/scripts/install.sh | sh + +# install GitHub CLI if missing +if command -v gh >/dev/null 2>&1; then + echo "GitHub CLI already installed; skipping." +else + as_root_bash ' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +apt-get update +if ! apt-get install -y --no-install-recommends gh; then + echo "Default apt sources do not provide gh; configuring the official GitHub CLI repository." + apt-get install -y --no-install-recommends ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list + apt-get update + apt-get install -y --no-install-recommends gh +fi +rm -rf /var/lib/apt/lists/* +' +fi + +# install nvm and Node.js 24 if Node.js is missing +if command -v node >/dev/null 2>&1; then + echo "Node.js already installed; skipping nvm and Node.js installation." +else + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash + if [ ! -s "$NVM_DIR/nvm.sh" ]; then + echo "ERROR: nvm was not installed at $NVM_DIR" >&2 + exit 1 + fi + \. "$NVM_DIR/nvm.sh" + nvm install 24 +fi +node -v +npm -v + +# set $HOME/.npmrc and $HOME/.bunfig.toml to have minimum release date to 3 days +echo "min-release-age=3" > "$HOME/.npmrc" +echo "[install] +minimumReleaseAge = 259200" > "$HOME/.bunfig.toml" + +# Use existing password if present, otherwise create it once +if [[ -f "$CRED_FILE" ]]; then + PASS="$(cat "$CRED_FILE")" + if [[ -z "${PASS}" ]]; then + echo "ERROR: ${CRED_FILE} exists but is empty" >&2 + exit 1 + fi +else + PASS="$(uuidgen | tr "[:upper:]" "[:lower:]")" + umask 077 + printf '%s' "$PASS" > "$CRED_FILE" +fi + +# If CURRENT_USER is root, allow root SSH. Otherwise, keep root SSH disabled. +if [[ "$CURRENT_USER" == "root" ]]; then + PERMIT_ROOT_LOGIN="yes" +else + PERMIT_ROOT_LOGIN="no" +fi + +# Ensure user exists + set password + configure sshd +as_root_bash " +set -euo pipefail + +if ! id -u '${CURRENT_USER}' >/dev/null 2>&1; then + useradd -m -s /bin/bash '${CURRENT_USER}' +fi + +echo '${CURRENT_USER}:${PASS}' | chpasswd + +cat >/etc/ssh/sshd_config.d/99-local.conf <> /etc/ssh/sshd_config +fi +" + +# If git exists and this is a repo, ignore locally without touching .gitignore +if command -v git >/dev/null 2>&1; then + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + REPO_ROOT="$(cd "$(git rev-parse --show-toplevel)" && pwd -P)" + CRED_FILE_ABS="$(resolve_path "$CRED_FILE")" + + if [[ "$CRED_FILE_ABS" == "$REPO_ROOT"/* ]]; then + EXCLUDE_FILE="${REPO_ROOT}/.git/info/exclude" + RELATIVE_CRED_PATH="${CRED_FILE_ABS#"$REPO_ROOT"/}" + PATTERN="/${RELATIVE_CRED_PATH}" + + mkdir -p "${REPO_ROOT}/.git/info" + touch "$EXCLUDE_FILE" + if ! grep -qxF "$PATTERN" "$EXCLUDE_FILE"; then + printf '\n%s\n' "$PATTERN" >> "$EXCLUDE_FILE" + fi + fi + fi +fi + +echo "SSH user: ${CURRENT_USER}" +echo "SSH pass: ${PASS}" +echo "SSH port: ${SSH_PORT}" +echo "PermitRootLogin: ${PERMIT_ROOT_LOGIN}" + +# Start sshd in background (reads Port from config, but we also pass -p to be explicit) +as_root /usr/sbin/sshd -p "$SSH_PORT" + +echo "To stop SSH server, run:" +echo "pkill -TERM -x sshd || true" diff --git a/src/runtime.ts b/src/runtime.ts index f686668..4245277 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -18,10 +18,10 @@ import { MANAGED_LABEL_KEY, RUNNER_CRED_FILENAME, RUNNER_HOST_KEYS_DIRNAME, - RUNNER_URL, WORKSPACE_LABEL_KEY, } from "./constants"; import { parseRunnerCredentials, type RunnerCredentials } from "./runnerState"; +import bundledRunnerScript from "./runner/ssh-server.sh" with { type: "text" }; interface ExecResult { stdout: string; @@ -42,6 +42,7 @@ class CommandError extends Error { interface ExecOptions { cwd?: string; env?: Record; + stdin?: string | Uint8Array; stdoutMode?: "capture" | "raw" | "devcontainer-json"; stderrMode?: "capture" | "raw" | "devcontainer-json"; allowFailure?: boolean; @@ -802,8 +803,8 @@ export async function startRunner( port: number, remoteWorkspaceFolder: string, ): Promise { - const script = `curl -fsSL ${quoteShell(getRunnerUrl())} | env SSH_PORT=${quoteShell(String(port))} CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash`; - const result = await devcontainerExec(containerId, script, { quiet: true }); + const script = buildStartRunnerScript(port, remoteWorkspaceFolder); + const result = await devcontainerExec(containerId, script, { quiet: true, stdin: bundledRunnerScript }); const summaryLines = getRunnerSummaryLines(result.stdout); const parsedSummary = parseRunnerCredentials(summaryLines.join("\n")); @@ -827,6 +828,10 @@ export async function startRunner( }; } +export function buildStartRunnerScript(port: number, remoteWorkspaceFolder: string): string { + return `env SSH_PORT=${quoteShell(String(port))} CRED_FILE=${quoteShell(getRunnerCredFile(remoteWorkspaceFolder))} bash -s`; +} + export async function persistRunnerHostKeys( containerId: string, remoteWorkspaceFolder: string, @@ -837,11 +842,6 @@ export async function persistRunnerHostKeys( }); } -function getRunnerUrl(): string { - const override = process.env.DEVBOX_RUNNER_URL?.trim(); - return override && override.length > 0 ? override : RUNNER_URL; -} - export function resolveShellContainerId(input: { containers: DockerInspect[]; preferredContainerId?: string; @@ -934,10 +934,11 @@ async function isDockerRootlessEngine(): Promise { async function devcontainerExec( containerId: string, script: string, - options: { quiet: boolean }, + options: { quiet: boolean; stdin?: string | Uint8Array }, ): Promise { const args = ["devcontainer", "exec", "--container-id", containerId, "sh", "-lc", script]; return execute(args, { + stdin: options.stdin, stdoutMode: options.quiet ? "capture" : "raw", stderrMode: options.quiet ? "capture" : "raw", }); @@ -1325,8 +1326,11 @@ async function execute(command: string[], options: ExecOptions): Promise { ); expect(featureChecks.exitCode).toBe(0); - expect(await readTrimmedFile(fixture.runnerArtifacts.sshAuthSock)).toBe("missing"); - expect(existsSync(fixture.runnerArtifacts.ghToken)).toBe(false); - expect(existsSync(fixture.runnerArtifacts.gitUserName)).toBe(false); - expect(existsSync(fixture.runnerArtifacts.gitUserEmail)).toBe(false); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKeyPub)).toBe(`${fixture.runnerHostKeyMarker}.pub`); - expect(await readLines(fixture.runnerArtifacts.runnerInvocations)).toEqual([String(fixture.port)]); + expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); const runnerCredContent = await readFile(fixture.runnerCredPath, "utf8"); - expect(runnerCredContent.trim()).toBe("password"); + expect(runnerCredContent.trim().length).toBeGreaterThan(0); const runnerMetadata = await readJson(fixture.runnerMetadataPath); expect(runnerMetadata.sshUser).toBe("root"); expect(runnerMetadata.sshPort).toBe(fixture.port); @@ -187,7 +173,7 @@ describe("example workspaces (real devcontainers)", () => { expect(await listManagedContainerIds(fixture)).toEqual([]); expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); + expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 8 * 60_000 }, @@ -243,13 +229,7 @@ describe("example workspaces (real devcontainers)", () => { ); expect(featureChecks.exitCode).toBe(0); - expect(await readTrimmedFile(fixture.runnerArtifacts.ghToken)).toBe("ghs_live_example_token"); - expect(await readTrimmedFile(fixture.runnerArtifacts.gitUserName)).toBe("Example Author"); - expect(await readTrimmedFile(fixture.runnerArtifacts.gitUserEmail)).toBe("example@author.test"); - expect(await readTrimmedFile(fixture.runnerArtifacts.sshAuthSock)).toBe(fixture.expectedContainerSshAuthSockPath); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKeyPub)).toBe(`${fixture.runnerHostKeyMarker}.pub`); - expect(await readLines(fixture.runnerArtifacts.runnerInvocations)).toEqual([String(fixture.port)]); + expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); const ghTokenInContainer = execInContainer(fixture, firstContainerId, 'printf "%s" "${GH_TOKEN:-}"'); expect(ghTokenInContainer.stdout).toBe("ghs_live_example_token"); @@ -271,11 +251,11 @@ describe("example workspaces (real devcontainers)", () => { expect(sshAuthSockInContainer.exitCode).toBe(0); expect(sshAuthSockInContainer.stdout).toBe(fixture.expectedContainerSshAuthSockPath); - const hostKeyInContainer = execInContainerAsRoot(fixture, firstContainerId, "cat /etc/ssh/ssh_host_devbox_test_key"); - expect(hostKeyInContainer.stdout.trim()).toBe(fixture.runnerHostKeyMarker); + const hostKeyInContainer = execInContainerAsRoot(fixture, firstContainerId, "find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*_key' | head -n 1"); + expect(hostKeyInContainer.stdout.trim().length).toBeGreaterThan(0); const runnerCredContent = await readFile(fixture.runnerCredPath, "utf8"); - expect(runnerCredContent.trim()).toBe("password"); + expect(runnerCredContent.trim().length).toBeGreaterThan(0); const runnerMetadata = await readJson(fixture.runnerMetadataPath); expect(runnerMetadata.sshUser).toBe("root"); expect(runnerMetadata.sshPort).toBe(fixture.port); @@ -290,13 +270,8 @@ describe("example workspaces (real devcontainers)", () => { const rebuiltContainerId = String(rebuiltState.lastContainerId); expect(rebuiltState.port).toBe(fixture.port); expect(rebuiltContainerId).not.toBe(firstContainerId); - expect(await readLines(fixture.runnerArtifacts.runnerInvocations)).toEqual([ - String(fixture.port), - String(fixture.port), - ]); - - const restoredHostKey = execInContainerAsRoot(fixture, rebuiltContainerId, "cat /etc/ssh/ssh_host_devbox_test_key"); - expect(restoredHostKey.stdout.trim()).toBe(fixture.runnerHostKeyMarker); + const restoredHostKey = execInContainerAsRoot(fixture, rebuiltContainerId, "find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*_key' | head -n 1"); + expect(restoredHostKey.stdout.trim().length).toBeGreaterThan(0); const down = runCli(fixture, ["down"]); expect(down.exitCode).toBe(0); @@ -304,7 +279,7 @@ describe("example workspaces (real devcontainers)", () => { expect(await listManagedContainerIds(fixture)).toEqual([]); expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); - expect(await readTrimmedFile(fixture.runnerArtifacts.hostKey)).toBe(fixture.runnerHostKeyMarker); + expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 12 * 60_000 }, @@ -359,10 +334,7 @@ describe("example workspaces (real devcontainers)", () => { mount.Destination === fixture.remoteWorkspaceFolder, ), ).toBe(true); - expect(await readLines(fixture.runnerArtifacts.runnerInvocations)).toEqual([ - String(fixture.port), - String(fixture.port), - ]); + expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); const sample = execInContainer( fixture, @@ -422,12 +394,8 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions } const remoteWorkspaceFolder = getDefaultRemoteWorkspaceFolder(workspacePath); - const runnerHostKeyMarker = `devbox-test-host-key-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const runnerScriptPath = path.join(workspacePath, ".devbox-test-runner.sh"); - await writeFile(runnerScriptPath, buildFakeRunnerScript(runnerHostKeyMarker), "utf8"); const env = baseEnv(); - env.DEVBOX_RUNNER_URL = `file://${path.posix.join(remoteWorkspaceFolder, ".devbox-test-runner.sh")}`; env.DEVBOX_TEST_GH_TOKEN = options.ghToken ?? ""; env.HOME = homeDir; env.PATH = `${wrappersDir}${path.delimiter}${env.PATH}`; @@ -442,18 +410,10 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions port: await findAvailablePort(), remoteWorkspaceFolder, runnerArtifacts: { - ghToken: path.join(workspacePath, ".devbox-test-gh-token"), - gitUserEmail: path.join(workspacePath, ".devbox-test-git-user-email"), - gitUserName: path.join(workspacePath, ".devbox-test-git-user-name"), - hostKey: path.join(workspacePath, RUNNER_HOST_KEYS_DIRNAME, "ssh_host_devbox_test_key"), - hostKeyPub: path.join(workspacePath, RUNNER_HOST_KEYS_DIRNAME, "ssh_host_devbox_test_key.pub"), knownHosts: path.join(workspacePath, ".devbox-test-known-hosts"), - runnerInvocations: path.join(workspacePath, ".devbox-test-runner-invocations"), - sshAuthSock: path.join(workspacePath, ".devbox-test-ssh-auth-sock"), }, runnerCredPath: path.join(workspacePath, RUNNER_CRED_FILENAME), runnerMetadataPath: path.join(workspacePath, DEVBOX_SSH_METADATA_FILENAME), - runnerHostKeyMarker, sampleFilePath: path.join(workspacePath, "sample-file.txt"), sshAuthSockPath, statePath: getStatePath(workspacePath), @@ -711,55 +671,6 @@ exit 1 await chmod(ghWrapperPath, 0o755); } -function buildFakeRunnerScript(hostKeyMarker: string): string { - return `#!/usr/bin/env bash -set -euo pipefail - -workspace_root="$(dirname "$CRED_FILE")" -printf '%s\\n' "$SSH_PORT" >> "$workspace_root/.devbox-test-runner-invocations" - -if [ -n "\${GH_TOKEN:-}" ]; then - printf '%s\\n' "$GH_TOKEN" > "$workspace_root/.devbox-test-gh-token" -fi - -git_user_name="" -git_user_email="" -if command -v git >/dev/null 2>&1; then - git_user_name="$(git config --global --get user.name 2>/dev/null || true)" - git_user_email="$(git config --global --get user.email 2>/dev/null || true)" -fi - -if [ -n "$git_user_name" ]; then - printf '%s\\n' "$git_user_name" > "$workspace_root/.devbox-test-git-user-name" -fi - -if [ -n "$git_user_email" ]; then - printf '%s\\n' "$git_user_email" > "$workspace_root/.devbox-test-git-user-email" -fi - -if [ -f "$HOME/.ssh/known_hosts" ]; then - cp "$HOME/.ssh/known_hosts" "$workspace_root/.devbox-test-known-hosts" -fi - -if [ -n "\${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ]; then - printf '%s\\n' "$SSH_AUTH_SOCK" > "$workspace_root/.devbox-test-ssh-auth-sock" -else - printf '%s\\n' 'missing' > "$workspace_root/.devbox-test-ssh-auth-sock" -fi - -printf 'password\\n' > "$CRED_FILE" - -root_script="mkdir -p /etc/ssh && if [ ! -f /etc/ssh/ssh_host_devbox_test_key ]; then printf '%s\\\\n' '${hostKeyMarker}' > /etc/ssh/ssh_host_devbox_test_key && chmod 600 /etc/ssh/ssh_host_devbox_test_key; fi && if [ ! -f /etc/ssh/ssh_host_devbox_test_key.pub ]; then printf '%s\\\\n' '${hostKeyMarker}.pub' > /etc/ssh/ssh_host_devbox_test_key.pub && chmod 644 /etc/ssh/ssh_host_devbox_test_key.pub; fi" -if [ "$(id -u)" -eq 0 ]; then - sh -lc "$root_script" -else - sudo sh -lc "$root_script" -fi - - printf 'SSH user: root\\nSSH pass: password\\nSSH port: %s\\nPermitRootLogin: yes\\n' "$SSH_PORT" -`; -} - function buildFallbackDevcontainerWrapper(realDockerPath: string, image: string): string { return `#!${process.execPath} const fs = require("node:fs"); @@ -1017,15 +928,3 @@ function findExecutable(command: string): string | null { async function readJson(filePath: string): Promise { return JSON.parse(await readFile(filePath, "utf8")); } - -async function readTrimmedFile(filePath: string): Promise { - return (await readFile(filePath, "utf8")).trim(); -} - -async function readLines(filePath: string): Promise { - const content = await readFile(filePath, "utf8"); - return content - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); -} diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index c1ff5ed..f85f4a3 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -16,6 +16,7 @@ import { findFirstAvailablePort, buildPersistRunnerHostKeysScript, buildRestoreRunnerHostKeysScript, + buildStartRunnerScript, buildStopManagedSshdScript, formatDevcontainerProgressLine, getRunnerCredFile, @@ -654,6 +655,16 @@ describe("getRunnerCredFile", () => { }); }); +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/.sshcred' bash -s"); + expect(script).not.toContain("curl"); + expect(script).not.toContain("http"); + }); +}); + describe("isDockerRootlessSecurityOptions", () => { test("detects a rootless docker engine", () => { expect(isDockerRootlessSecurityOptions(["name=seccomp,profile=builtin", "name=rootless"])).toBe(true); From 10887cdbd6ff854d7009f504fd7d56f8c269499e Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sun, 7 Jun 2026 14:21:36 +0000 Subject: [PATCH 2/3] fresh --- src/runner/ssh-server.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runner/ssh-server.sh b/src/runner/ssh-server.sh index 34976b9..59b281c 100644 --- a/src/runner/ssh-server.sh +++ b/src/runner/ssh-server.sh @@ -88,9 +88,6 @@ mkdir -p /var/run/sshd mkdir -p /etc/ssh/sshd_config.d ' -# install fresh editor -curl https://raw.githubusercontent.com/sinelaw/fresh/refs/heads/master/scripts/install.sh | sh - # install GitHub CLI if missing if command -v gh >/dev/null 2>&1; then echo "GitHub CLI already installed; skipping." @@ -132,6 +129,9 @@ fi node -v npm -v +# install fresh editor +npm install -g @fresh-editor/fresh-editor + # set $HOME/.npmrc and $HOME/.bunfig.toml to have minimum release date to 3 days echo "min-release-age=3" > "$HOME/.npmrc" echo "[install] From 8d3d862d234f3d13d72c4988d759ebf27da21e4d Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sun, 7 Jun 2026 14:49:12 +0000 Subject: [PATCH 3/3] unify --- .gitignore | 2 - README.md | 10 ++--- src/arise.ts | 18 +++++---- src/cli.ts | 12 ++---- src/constants.ts | 7 ++-- src/core.ts | 20 ++++++++++ src/runner/ssh-server.sh | 3 +- src/runtime.ts | 12 +++--- src/status.ts | 7 ++-- tests/arise.test.ts | 11 +++--- tests/examples.live.test.ts | 75 +++++++++++++++++++++++++------------ tests/examples.test.ts | 2 +- tests/runtime.test.ts | 27 ++++++------- tests/status.test.ts | 16 ++++---- 14 files changed, 136 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index f56a5ab..ed4d9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules/ dist/ coverage/ -.sshcred -.devbox-ssh-host-keys/ .devbox/ .planning/ diff --git a/README.md b/README.md index a340bed..35d9056 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der - Exposes the SSH service on the chosen host port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer. - Seeds the container user's global Git `user.name` and `user.email` from the host when available. - Runs devbox's bundled SSH server setup script inside the devcontainer. -- Stores devbox-owned state in the workspace-local `.devbox/` directory, and persists the runner password as `.sshcred`, SSH metadata in `.devbox-ssh.json`, and SSH host keys in `.devbox-ssh-host-keys/`, so they survive `down` / `rebuild`. +- Stores devbox-owned state, SSH credentials, SSH metadata, and SSH host keys under the workspace-local `.devbox/` directory so they survive `down` / `rebuild`. ## Installation @@ -193,15 +193,15 @@ The complex example uses several devcontainer features, so the first `up` or `re - When `devbox` uses a repo devcontainer, the generated config is written next to the original devcontainer config, using the alternate accepted devcontainer filename so relative Dockerfile paths keep working. - When `devbox` uses `--template`, it writes the generated config to `.devbox/.devcontainer.json` instead of creating a source devcontainer definition inside the repo. -- `.devbox/` contains devbox-owned local state (`state.json`, `user-data/`, and template generated configs) and should stay ignored by version control. +- `.devbox/` contains all devbox-owned local state (`state.json`, `user-data/`, template generated configs, and `ssh/`) and should stay ignored by version control. - `--devcontainer-subpath services/api` tells `devbox` to use `.devcontainer/services/api/devcontainer.json`. - `--template ` explicitly chooses a built-in template, even if the repo already has a devcontainer definition. - `devbox shell` opens an interactive shell inside the running managed container for the current workspace. -- `devbox status` reports live container state when available and falls back to saved workspace state in `.devbox/state.json` plus the persisted `.sshcred` password file and `.devbox-ssh.json` metadata when the container is stopped or Docker is unavailable. -- `devbox arise` only attempts workspaces it can recover from stopped managed containers and that still have at least one persisted devbox leftover, such as saved state, `.sshcred`, `.devbox-ssh.json`, or `.devbox-ssh-host-keys/`. +- `devbox status` reports live container state when available and falls back to saved workspace state in `.devbox/state.json` plus the persisted `.devbox/ssh/credentials` password file and `.devbox/ssh/metadata.json` metadata when the container is stopped or Docker is unavailable. +- `devbox arise` only attempts workspaces it can recover from stopped managed containers and that still have at least one persisted devbox leftover, such as saved state, `.devbox/ssh/credentials`, `.devbox/ssh/metadata.json`, or `.devbox/ssh/host-keys/`. - For workspaces that pass the restart-readiness checks and are actually attempted, if there is more than one stopped managed container, `devbox arise` keeps the newest stopped container as the source of truth, removes the older stopped duplicates, and then reruns `devbox up`. Skipped or unrecoverable workspaces may retain older stopped duplicates. - `devbox up` prints the chosen port near the start of execution, before the longer devcontainer setup steps. -- `down` removes managed containers but keeps `.devbox/` plus the workspace `.sshcred`, `.devbox-ssh.json`, and `.devbox-ssh-host-keys/`, so rebuilds can reuse the last selected port/config source/template. +- `down` removes managed containers but keeps `.devbox/`, so rebuilds can reuse the last selected port/config source/template and SSH artifacts. - Re-running `devbox up` after a host restart recreates the desired state: container up, port published, SSH runner started again. - 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. diff --git a/src/arise.ts b/src/arise.ts index b3fcfef..88636ae 100644 --- a/src/arise.ts +++ b/src/arise.ts @@ -2,11 +2,13 @@ import { access, lstat } from "node:fs/promises"; import path from "node:path"; import { getManagedPortFromContainerName, + getWorkspaceRunnerCredentialFile, + getWorkspaceRunnerHostKeysDir, + getWorkspaceSshMetadataFile, getWorkspaceStateFile, type WorkspaceState, type DockerInspect, } from "./core"; -import { DEVBOX_SSH_METADATA_FILENAME, RUNNER_CRED_FILENAME, RUNNER_HOST_KEYS_DIRNAME } from "./constants"; export interface RecoveredWorkspaceMount { destination: string; @@ -193,9 +195,9 @@ export async function inspectWorkspaceRestartReadiness( } const statePath = getWorkspaceStateFile(workspacePath); - const credentialPath = path.join(workspacePath, RUNNER_CRED_FILENAME); - const sshMetadataPath = path.join(workspacePath, DEVBOX_SSH_METADATA_FILENAME); - const hostKeysPath = path.join(workspacePath, RUNNER_HOST_KEYS_DIRNAME); + const credentialPath = getWorkspaceRunnerCredentialFile(workspacePath); + const sshMetadataPath = getWorkspaceSshMetadataFile(workspacePath); + const hostKeysPath = getWorkspaceRunnerHostKeysDir(workspacePath); const hasStateFile = await pathExists(statePath, accessFile); const hasCredentialFile = await pathExists(credentialPath, accessFile); @@ -207,19 +209,19 @@ export async function inspectWorkspaceRestartReadiness( foundArtifacts.push("saved state"); } if (hasCredentialFile) { - foundArtifacts.push(RUNNER_CRED_FILENAME); + foundArtifacts.push(".devbox/ssh/credentials"); } if (hasSshMetadataFile) { - foundArtifacts.push(DEVBOX_SSH_METADATA_FILENAME); + foundArtifacts.push(".devbox/ssh/metadata.json"); } if (hasHostKeysDir) { - foundArtifacts.push(`${RUNNER_HOST_KEYS_DIRNAME}/`); + foundArtifacts.push(".devbox/ssh/host-keys/"); } if (reasons.length === 0 && foundArtifacts.length === 0) { reasons.push( `No devbox restart leftovers were found in ${workspacePath}. Expected at least one of: saved state, ` + - `${RUNNER_CRED_FILENAME}, ${DEVBOX_SSH_METADATA_FILENAME}, or ${RUNNER_HOST_KEYS_DIRNAME}/.`, + `.devbox/ssh/credentials, .devbox/ssh/metadata.json, or .devbox/ssh/host-keys/.`, ); } diff --git a/src/cli.ts b/src/cli.ts index b6a0f9e..fc8a048 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { realpath, writeFile } from "node:fs/promises"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; import path from "node:path"; import { buildManagedConfig, @@ -11,6 +11,7 @@ import { getManagedPortFromContainerName, getManagedLabels, prepareKnownHostsMount, + getWorkspaceSshMetadataFile, getWorkspaceStateDir, getWorkspaceUserDataDir, hashWorkspacePath, @@ -57,11 +58,8 @@ import { } from "./runtime"; import { DEFAULT_UP_AUTO_PORT_START, - DEVBOX_SSH_METADATA_FILENAME, DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, MANAGED_LABEL_KEY, - RUNNER_CRED_FILENAME, - RUNNER_HOST_KEYS_DIRNAME, } from "./constants"; import { createRunnerMetadata, serializeRunnerMetadata } from "./runnerState"; import { getDevboxStatus } from "./status"; @@ -249,10 +247,7 @@ async function handleUpLike( const remoteWorkspaceFolder = upResult.remoteWorkspaceFolder ?? getDefaultRemoteWorkspaceFolder(workspacePath); console.log("Configuring SSH access inside the devcontainer..."); - await ensurePathIgnored(workspacePath, path.join(workspacePath, RUNNER_HOST_KEYS_DIRNAME)); - const runnerMetadataPath = path.join(workspacePath, DEVBOX_SSH_METADATA_FILENAME); - await ensurePathIgnored(workspacePath, runnerMetadataPath); - await ensurePathIgnored(workspacePath, path.join(workspacePath, RUNNER_CRED_FILENAME)); + const runnerMetadataPath = getWorkspaceSshMetadataFile(workspacePath); if (requiresSshAuthSockPermissionFix(environment.sshAuthSock)) { console.log("Making the forwarded SSH agent socket accessible to the container user..."); await ensureSshAuthSockAccessible(upResult.containerId, environment.sshAuthSock); @@ -286,6 +281,7 @@ async function handleUpLike( console.log("Installing SSH public key for key-based login..."); await configureAuthorizedKeys(upResult.containerId, sshUser, resolvedSshPublicKey.publicKey); } + await mkdir(path.dirname(runnerMetadataPath), { recursive: true }); await writeFile( runnerMetadataPath, serializeRunnerMetadata( diff --git a/src/constants.ts b/src/constants.ts index de6e02d..bd00724 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,7 +7,8 @@ export const SSH_AUTH_SOCK_TARGET = "/run/devbox-ssh-auth.sock"; export const DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE = "/run/host-services/ssh-auth.sock"; export const KNOWN_HOSTS_TARGET = "/run/devbox-known_hosts"; export const KNOWN_HOSTS_SNAPSHOT_FILENAME = "known_hosts"; -export const RUNNER_CRED_FILENAME = ".sshcred"; -export const DEVBOX_SSH_METADATA_FILENAME = ".devbox-ssh.json"; -export const RUNNER_HOST_KEYS_DIRNAME = ".devbox-ssh-host-keys"; +export const DEVBOX_SSH_DIRNAME = "ssh"; +export const RUNNER_CRED_FILENAME = "credentials"; +export const DEVBOX_SSH_METADATA_FILENAME = "metadata.json"; +export const RUNNER_HOST_KEYS_DIRNAME = "host-keys"; export const STATE_VERSION = 2; diff --git a/src/core.ts b/src/core.ts index f303941..87d09a9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -9,10 +9,14 @@ import pkg from "../package.json"; import { CLI_NAME, DEFAULT_UP_AUTO_PORT_START, + DEVBOX_SSH_DIRNAME, + DEVBOX_SSH_METADATA_FILENAME, DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, KNOWN_HOSTS_SNAPSHOT_FILENAME, LEGACY_GENERATED_CONFIG_BASENAME, MANAGED_LABEL_KEY, + RUNNER_CRED_FILENAME, + RUNNER_HOST_KEYS_DIRNAME, SSH_AUTH_SOCK_TARGET, STATE_VERSION, WORKSPACE_LABEL_KEY, @@ -408,6 +412,22 @@ export function getWorkspaceUserDataDir(workspacePath: string): string { return path.join(getWorkspaceStateDir(workspacePath), "user-data"); } +export function getWorkspaceSshDir(workspacePath: string): string { + return path.join(getWorkspaceStateDir(workspacePath), DEVBOX_SSH_DIRNAME); +} + +export function getWorkspaceRunnerCredentialFile(workspacePath: string): string { + return path.join(getWorkspaceSshDir(workspacePath), RUNNER_CRED_FILENAME); +} + +export function getWorkspaceSshMetadataFile(workspacePath: string): string { + return path.join(getWorkspaceSshDir(workspacePath), DEVBOX_SSH_METADATA_FILENAME); +} + +export function getWorkspaceRunnerHostKeysDir(workspacePath: string): string { + return path.join(getWorkspaceSshDir(workspacePath), RUNNER_HOST_KEYS_DIRNAME); +} + export function getTemplateGeneratedConfigPath(workspacePath: string): string { return path.join(getWorkspaceStateDir(workspacePath), ".devcontainer.json"); } diff --git a/src/runner/ssh-server.sh b/src/runner/ssh-server.sh index 59b281c..72188b4 100644 --- a/src/runner/ssh-server.sh +++ b/src/runner/ssh-server.sh @@ -18,7 +18,7 @@ if [ -n "${SSH_AUTH_SOCK:-}" ]; then echo "export SSH_AUTH_SOCK=\"${SSH_AUTH_SOCK}\"" >> ~/.zshenv fi -CRED_FILE="${CRED_FILE:-.sshcred}" +CRED_FILE="${CRED_FILE:-.devbox/ssh/credentials}" SSH_PORT="${SSH_PORT:-5001}" as_root() { @@ -147,6 +147,7 @@ if [[ -f "$CRED_FILE" ]]; then else PASS="$(uuidgen | tr "[:upper:]" "[:lower:]")" umask 077 + mkdir -p "$(dirname "$CRED_FILE")" printf '%s' "$PASS" > "$CRED_FILE" fi diff --git a/src/runtime.ts b/src/runtime.ts index 4245277..6362ee0 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -14,6 +14,7 @@ import { } from "./core"; import { DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, + DEVBOX_SSH_DIRNAME, KNOWN_HOSTS_TARGET, MANAGED_LABEL_KEY, RUNNER_CRED_FILENAME, @@ -113,10 +114,7 @@ export function buildStopManagedSshdScript(port: number): string { } export function getRunnerCredFile(remoteWorkspaceFolder: string): string { - const trimmed = remoteWorkspaceFolder.endsWith("/") - ? remoteWorkspaceFolder.slice(0, -1) - : remoteWorkspaceFolder; - return `${trimmed}/${RUNNER_CRED_FILENAME}`; + return `${getRemoteWorkspaceSshDir(remoteWorkspaceFolder)}/${RUNNER_CRED_FILENAME}`; } export function getRunnerSummaryLines(output: string): string[] { @@ -309,10 +307,14 @@ export function buildInteractiveShellScript(): string { } export function getRunnerHostKeysDir(remoteWorkspaceFolder: string): string { + return `${getRemoteWorkspaceSshDir(remoteWorkspaceFolder)}/${RUNNER_HOST_KEYS_DIRNAME}`; +} + +function getRemoteWorkspaceSshDir(remoteWorkspaceFolder: string): string { const trimmed = remoteWorkspaceFolder.endsWith("/") ? remoteWorkspaceFolder.slice(0, -1) : remoteWorkspaceFolder; - return `${trimmed}/${RUNNER_HOST_KEYS_DIRNAME}`; + return `${trimmed}/.devbox/${DEVBOX_SSH_DIRNAME}`; } export function buildRestoreRunnerHostKeysScript(remoteWorkspaceFolder: string): string { diff --git a/src/status.ts b/src/status.ts index 24e3a29..581a549 100644 --- a/src/status.ts +++ b/src/status.ts @@ -8,11 +8,12 @@ import { getDefaultRemoteWorkspaceFolder, getManagedLabels, getManagedPortFromContainerName, + getWorkspaceRunnerCredentialFile, + getWorkspaceSshMetadataFile, getWorkspaceStateFile, hashWorkspacePath, loadWorkspaceState, } from "./core"; -import { DEVBOX_SSH_METADATA_FILENAME, RUNNER_CRED_FILENAME } from "./constants"; import { parseRunnerCredentials, parseRunnerMetadata, type RunnerCredentials, type RunnerMetadata } from "./runnerState"; import { formatCommandError, inspectContainers, isCommandError, isExecutableAvailable, listManagedContainers } from "./runtime"; @@ -111,9 +112,9 @@ export async function getDevboxStatus( warnings.push(`Found ${containers.length} managed containers for this workspace; reporting the preferred container.`); } - const credentialPath = path.join(input.workspacePath, RUNNER_CRED_FILENAME); + const credentialPath = getWorkspaceRunnerCredentialFile(input.workspacePath); const credentialFile = await readRunnerCredentialsFile(credentialPath, readFile); - const sshMetadataPath = path.join(input.workspacePath, DEVBOX_SSH_METADATA_FILENAME); + const sshMetadataPath = getWorkspaceSshMetadataFile(input.workspacePath); const sshMetadataFile = await readRunnerMetadataFile(sshMetadataPath, readFile, warnings); const configHints = state?.template ? readConfigHintsFromConfig({ diff --git a/tests/arise.test.ts b/tests/arise.test.ts index 83e305d..20e889b 100644 --- a/tests/arise.test.ts +++ b/tests/arise.test.ts @@ -171,12 +171,13 @@ describe("inspectWorkspaceRestartReadiness", () => { test("accepts workspaces with persisted devbox leftovers", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-arise-")); tempPaths.push(workspacePath); - await writeFile(path.join(workspacePath, ".sshcred"), "password\n", "utf8"); + await mkdir(path.join(workspacePath, ".devbox", "ssh"), { recursive: true }); + await writeFile(path.join(workspacePath, ".devbox", "ssh", "credentials"), "password\n", "utf8"); const readiness = await inspectWorkspaceRestartReadiness(workspacePath); expect(readiness.eligible).toBe(true); - expect(readiness.foundArtifacts).toEqual([".sshcred"]); + expect(readiness.foundArtifacts).toEqual([".devbox/ssh/credentials"]); expect(readiness.reasons).toEqual([]); expect(readiness.hasCredentialFile).toBe(true); }); @@ -184,12 +185,12 @@ describe("inspectWorkspaceRestartReadiness", () => { test("accepts workspaces with saved state or host key leftovers", async () => { const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-arise-")); tempPaths.push(workspacePath); - await mkdir(path.join(workspacePath, ".devbox-ssh-host-keys")); + await mkdir(path.join(workspacePath, ".devbox", "ssh", "host-keys"), { recursive: true }); const readiness = await inspectWorkspaceRestartReadiness(workspacePath); expect(readiness.eligible).toBe(true); - expect(readiness.foundArtifacts).toEqual([".devbox-ssh-host-keys/"]); + expect(readiness.foundArtifacts).toEqual([".devbox/ssh/host-keys/"]); expect(readiness.hasHostKeysDir).toBe(true); }); @@ -311,7 +312,7 @@ describe("ariseManagedWorkspaces", () => { eligible: true, workspacePath, reasons: [], - foundArtifacts: workspacePath === "/tmp/ok" ? [".sshcred"] : ["saved state"], + foundArtifacts: workspacePath === "/tmp/ok" ? [".devbox/ssh/credentials"] : ["saved state"], statePath: "/tmp/state", credentialPath: "/tmp/cred", sshMetadataPath: "/tmp/meta", diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 854bac9..fce29e3 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -5,15 +5,21 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { - DEVBOX_SSH_METADATA_FILENAME, DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE, MANAGED_LABEL_KEY, - RUNNER_CRED_FILENAME, - RUNNER_HOST_KEYS_DIRNAME, SSH_AUTH_SOCK_TARGET, WORKSPACE_LABEL_KEY, } from "../src/constants"; -import { getDefaultRemoteWorkspaceFolder, getManagedContainerName, hashWorkspacePath, quoteShell, type DockerInspect } from "../src/core"; +import { + getDefaultRemoteWorkspaceFolder, + getManagedContainerName, + getWorkspaceRunnerCredentialFile, + getWorkspaceRunnerHostKeysDir, + getWorkspaceSshMetadataFile, + hashWorkspacePath, + quoteShell, + type DockerInspect, +} from "../src/core"; setDefaultTimeout(15 * 60_000); @@ -158,14 +164,12 @@ describe("example workspaces (real devcontainers)", () => { ); expect(featureChecks.exitCode).toBe(0); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); const runnerCredContent = await readFile(fixture.runnerCredPath, "utf8"); expect(runnerCredContent.trim().length).toBeGreaterThan(0); const runnerMetadata = await readJson(fixture.runnerMetadataPath); - expect(runnerMetadata.sshUser).toBe("root"); - expect(runnerMetadata.sshPort).toBe(fixture.port); - expect(runnerMetadata.permitRootLogin).toBe(true); + expectRunnerMetadata(runnerMetadata, fixture.port); const down = runCli(fixture, ["down"]); expect(down.exitCode).toBe(0); @@ -173,7 +177,7 @@ describe("example workspaces (real devcontainers)", () => { expect(await listManagedContainerIds(fixture)).toEqual([]); expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 8 * 60_000 }, @@ -229,7 +233,7 @@ describe("example workspaces (real devcontainers)", () => { ); expect(featureChecks.exitCode).toBe(0); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); const ghTokenInContainer = execInContainer(fixture, firstContainerId, 'printf "%s" "${GH_TOKEN:-}"'); expect(ghTokenInContainer.stdout).toBe("ghs_live_example_token"); @@ -257,9 +261,7 @@ describe("example workspaces (real devcontainers)", () => { const runnerCredContent = await readFile(fixture.runnerCredPath, "utf8"); expect(runnerCredContent.trim().length).toBeGreaterThan(0); const runnerMetadata = await readJson(fixture.runnerMetadataPath); - expect(runnerMetadata.sshUser).toBe("root"); - expect(runnerMetadata.sshPort).toBe(fixture.port); - expect(runnerMetadata.permitRootLogin).toBe(true); + expectRunnerMetadata(runnerMetadata, fixture.port); const rebuild = runCli(fixture, ["rebuild"]); expect(rebuild.exitCode).toBe(0); @@ -277,9 +279,8 @@ describe("example workspaces (real devcontainers)", () => { expect(down.exitCode).toBe(0); expect(down.stdout).toContain("Removed 1 managed container(s)."); expect(await listManagedContainerIds(fixture)).toEqual([]); - expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); expect(existsSync(fixture.statePath)).toBe(true); }, { timeout: 12 * 60_000 }, @@ -306,9 +307,8 @@ describe("example workspaces (real devcontainers)", () => { const stoppedInspect = inspectContainer(fixture, initialContainerId); expect(stoppedInspect.State?.Running).toBe(false); - expect(existsSync(fixture.runnerCredPath)).toBe(true); expect(existsSync(fixture.runnerMetadataPath)).toBe(true); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); const arise = runCommand([process.execPath, "run", cliPath, "arise"], { cwd: repoRoot, @@ -334,7 +334,7 @@ describe("example workspaces (real devcontainers)", () => { mount.Destination === fixture.remoteWorkspaceFolder, ), ).toBe(true); - expect(existsSync(path.join(fixture.workspacePath, RUNNER_HOST_KEYS_DIRNAME))).toBe(true); + expect(existsSync(getWorkspaceRunnerHostKeysDir(fixture.workspacePath))).toBe(true); const sample = execInContainer( fixture, @@ -400,6 +400,10 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions env.HOME = homeDir; env.PATH = `${wrappersDir}${path.delimiter}${env.PATH}`; env.SSH_AUTH_SOCK = sshAuthSockPath ?? ""; + const dockerConfig = resolveHostDockerConfig(); + if (dockerConfig) { + env.DOCKER_CONFIG = dockerConfig; + } const fixture: LiveFixture = { env, @@ -412,8 +416,8 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions runnerArtifacts: { knownHosts: path.join(workspacePath, ".devbox-test-known-hosts"), }, - runnerCredPath: path.join(workspacePath, RUNNER_CRED_FILENAME), - runnerMetadataPath: path.join(workspacePath, DEVBOX_SSH_METADATA_FILENAME), + runnerCredPath: getWorkspaceRunnerCredentialFile(workspacePath), + runnerMetadataPath: getWorkspaceSshMetadataFile(workspacePath), sampleFilePath: path.join(workspacePath, "sample-file.txt"), sshAuthSockPath, statePath: getStatePath(workspacePath), @@ -531,11 +535,13 @@ function runCommand( } function canRunLivePrerequisites(): boolean { - return canRunCommand(["docker", "info"]) && canRunCommand(["devcontainer", "--version"]); + return canRunCommand(["docker", "info"]) && canRunCommand(["docker", "ps", "-q"]) && canRunCommand(["devcontainer", "--version"]); } function canRunAriseLivePrerequisites(): boolean { - return canRunCommand(["docker", "info"]) && (canRunCommand(["devcontainer", "--version"]) || resolveFallbackDevcontainerImage("smoke-workspace") !== null); + return canRunCommand(["docker", "info"]) && + canRunCommand(["docker", "ps", "-q"]) && + (canRunCommand(["devcontainer", "--version"]) || resolveFallbackDevcontainerImage("smoke-workspace") !== null); } function isDockerDesktopHost(): boolean { @@ -602,8 +608,8 @@ function resolveFallbackDevcontainerImage(exampleName: string): string | null { } async function resetWorkspaceArtifacts(workspacePath: string): Promise { - await rm(path.join(workspacePath, RUNNER_CRED_FILENAME), { force: true }); - await rm(path.join(workspacePath, RUNNER_HOST_KEYS_DIRNAME), { force: true, recursive: true }); + await rm(getWorkspaceRunnerCredentialFile(workspacePath), { force: true }); + await rm(getWorkspaceRunnerHostKeysDir(workspacePath), { force: true, recursive: true }); await rm(path.join(workspacePath, ".devcontainer", ".devcontainer.json"), { force: true }); } @@ -925,6 +931,27 @@ function findExecutable(command: string): string | null { return null; } +function resolveHostDockerConfig(): string | null { + if (process.env.DOCKER_CONFIG && existsSync(process.env.DOCKER_CONFIG)) { + return process.env.DOCKER_CONFIG; + } + + const home = process.env.HOME; + if (!home) { + return null; + } + + const dockerConfig = path.join(home, ".docker"); + return existsSync(dockerConfig) ? dockerConfig : null; +} + async function readJson(filePath: string): Promise { return JSON.parse(await readFile(filePath, "utf8")); } + +function expectRunnerMetadata(metadata: any, port: number): void { + expect(typeof metadata.sshUser).toBe("string"); + expect(metadata.sshUser.length).toBeGreaterThan(0); + expect(metadata.sshPort).toBe(port); + expect(metadata.permitRootLogin).toBe(metadata.sshUser === "root"); +} diff --git a/tests/examples.test.ts b/tests/examples.test.ts index e0b2e48..7d7a87a 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -415,7 +415,7 @@ describe("example workspaces (simulated host tools)", () => { const excludeContent = await readFile(path.join(fixture.workspacePath, ".git", "info", "exclude"), "utf8"); expect(excludeContent).toContain("/.devcontainer/.devcontainer.json"); - expect(excludeContent).toContain("/.devbox-ssh.json"); + expect(excludeContent).toContain("/.devbox"); const shell = runCli(fixture, ["shell"]); expect(shell.exitCode).toBe(0); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index f85f4a3..1a6aaed 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -550,7 +550,7 @@ describe("ensurePathIgnored", () => { expect(excludeContent).toContain("/.devcontainer/.devcontainer.json"); }); - test("locally ignores .sshcred when workspace is inside a git repo", async () => { + test("locally ignores .devbox when workspace is inside a git repo", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-runtime-test-")); tempPaths.push(tempDir); const repoDir = path.join(tempDir, "repo"); @@ -562,7 +562,8 @@ describe("ensurePathIgnored", () => { run(["git", "-C", repoDir, "add", "README.md"]); run(["git", "-C", repoDir, "commit", "-m", "init"]); - const credFilePath = path.join(repoDir, ".sshcred"); + const credFilePath = path.join(repoDir, ".devbox", "ssh", "credentials"); + await mkdir(path.dirname(credFilePath), { recursive: true }); await writeFile(credFilePath, "user=devbox\npassword=secret\n", "utf8"); await ensurePathIgnored(repoDir, credFilePath); @@ -573,16 +574,16 @@ describe("ensurePathIgnored", () => { expect(excludePathResult.exitCode).toBe(0); const excludePath = Buffer.from(excludePathResult.stdout).toString("utf8").trim(); const excludeContent = await readFile(excludePath, "utf8"); - expect(excludeContent).toContain("/.sshcred"); + expect(excludeContent).toContain("/.devbox"); - // Verify .sshcred is ignored by git (does not appear in git status output) + // Verify .devbox is ignored by git (does not appear in git status output) const statusResult = Bun.spawnSync(["git", "-C", repoDir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe", }); expect(statusResult.exitCode).toBe(0); const statusOutput = Buffer.from(statusResult.stdout).toString("utf8"); - expect(statusOutput).not.toContain(".sshcred"); + expect(statusOutput).not.toContain(".devbox"); }); }); @@ -647,10 +648,10 @@ describe("redactSensitiveOutput", () => { describe("getRunnerCredFile", () => { test("stores runner credentials on the mounted workspace", () => { expect(getRunnerCredFile("/workspaces/example-project")).toBe( - "/workspaces/example-project/.sshcred", + "/workspaces/example-project/.devbox/ssh/credentials", ); expect(getRunnerCredFile("/workspaces/example-project/")).toBe( - "/workspaces/example-project/.sshcred", + "/workspaces/example-project/.devbox/ssh/credentials", ); }); }); @@ -659,7 +660,7 @@ 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/.sshcred' bash -s"); + expect(script).toBe("env SSH_PORT='5001' CRED_FILE='/workspaces/example-project/.devbox/ssh/credentials' bash -s"); expect(script).not.toContain("curl"); expect(script).not.toContain("http"); }); @@ -678,7 +679,7 @@ describe("isDockerRootlessSecurityOptions", () => { describe("getRunnerHostKeysDir", () => { test("stores host keys on the mounted workspace", () => { expect(getRunnerHostKeysDir("/workspaces/example-project")).toBe( - "/workspaces/example-project/.devbox-ssh-host-keys", + "/workspaces/example-project/.devbox/ssh/host-keys", ); }); }); @@ -686,16 +687,16 @@ describe("getRunnerHostKeysDir", () => { describe("runner host key scripts", () => { test("restores host keys from the mounted workspace", () => { const script = buildRestoreRunnerHostKeysScript("/workspaces/example-project"); - expect(script).toContain("/workspaces/example-project/.devbox-ssh-host-keys"); - expect(script).toContain("find '/workspaces/example-project/.devbox-ssh-host-keys'"); + expect(script).toContain("/workspaces/example-project/.devbox/ssh/host-keys"); + expect(script).toContain("find '/workspaces/example-project/.devbox/ssh/host-keys'"); expect(script).toContain("cp {} /etc/ssh/"); }); test("persists host keys back to the mounted workspace", () => { const script = buildPersistRunnerHostKeysScript("/workspaces/example-project"); - expect(script).toContain("mkdir -p '/workspaces/example-project/.devbox-ssh-host-keys'"); + expect(script).toContain("mkdir -p '/workspaces/example-project/.devbox/ssh/host-keys'"); expect(script).toContain("find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*'"); - expect(script).toContain("cp {} '/workspaces/example-project/.devbox-ssh-host-keys'/"); + expect(script).toContain("cp {} '/workspaces/example-project/.devbox/ssh/host-keys'/"); }); }); diff --git a/tests/status.test.ts b/tests/status.test.ts index f054469..b456b32 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -83,10 +83,10 @@ describe("getDevboxStatus", () => { listManagedContainers: async () => containers.map((container) => container.Id), inspectContainers: async () => containers, readFile: async (filePath) => { - if (filePath === "/tmp/ws/.sshcred") { + if (filePath === "/tmp/ws/.devbox/ssh/credentials") { return "secret\n"; } - if (filePath === "/tmp/ws/.devbox-ssh.json") { + if (filePath === "/tmp/ws/.devbox/ssh/metadata.json") { return serializeRunnerMetadata( createRunnerMetadata({ sshUser: "vscode", @@ -136,10 +136,10 @@ describe("getDevboxStatus", () => { listManagedContainers: async () => [], inspectContainers: async () => [], readFile: async (filePath) => { - if (filePath === "/tmp/no-state/.sshcred") { + if (filePath === "/tmp/no-state/.devbox/ssh/credentials") { return "password\n"; } - if (filePath === "/tmp/no-state/.devbox-ssh.json") { + if (filePath === "/tmp/no-state/.devbox/ssh/metadata.json") { return serializeRunnerMetadata( createRunnerMetadata({ sshUser: "root", @@ -327,7 +327,7 @@ describe("getDevboxStatus", () => { }, ], readFile: async (filePath) => { - if (filePath === "/tmp/password-only/.sshcred") { + if (filePath === "/tmp/password-only/.devbox/ssh/credentials") { return "password\n"; } if (filePath === "/tmp/password-only/.devcontainer/devcontainer.json") { @@ -346,7 +346,7 @@ describe("getDevboxStatus", () => { expect(status.permitRootLogin).toBeNull(); expect(status.hasSshMetadataFile).toBe(false); expect(status.warnings).toContain( - "Devbox SSH metadata file was not found: /tmp/password-only/.devbox-ssh.json. `sshUser` and `permitRootLogin` are unavailable. Start the workspace again with this devbox version to persist them.", + "Devbox SSH metadata file was not found: /tmp/password-only/.devbox/ssh/metadata.json. `sshUser` and `permitRootLogin` are unavailable. Start the workspace again with this devbox version to persist them.", ); expect(status.warnings).toContain( "`remoteUser` is unavailable because the devcontainer config does not set `remoteUser` or `containerUser`.", @@ -416,10 +416,10 @@ describe("getDevboxStatus", () => { throw new Error("inspectContainers should not be called when docker is unavailable"); }, readFile: async (filePath) => { - if (filePath === "/tmp/docker-missing/.sshcred") { + if (filePath === "/tmp/docker-missing/.devbox/ssh/credentials") { return "password\n"; } - if (filePath === "/tmp/docker-missing/.devbox-ssh.json") { + if (filePath === "/tmp/docker-missing/.devbox/ssh/metadata.json") { return serializeRunnerMetadata( createRunnerMetadata({ sshUser: "root",