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
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
node_modules/
dist/
coverage/
.sshcred
.devbox-ssh-host-keys/
.devbox/
.planning/
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ 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.
- 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`.
- Runs devbox's bundled SSH server setup script inside the devcontainer.
- Stores devbox-owned state, SSH credentials, SSH metadata, and SSH host keys under the workspace-local `.devbox/` directory so they survive `down` / `rebuild`.

## Installation

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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 <name>` 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 10 additions & 8 deletions src/arise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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/.`,
);
}

Expand Down
12 changes: 4 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,6 +11,7 @@ import {
getManagedPortFromContainerName,
getManagedLabels,
prepareKnownHostsMount,
getWorkspaceSshMetadataFile,
getWorkspaceStateDir,
getWorkspaceUserDataDir,
hashWorkspacePath,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 4 additions & 5 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +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 RUNNER_URL =
"https://raw.githubusercontent.com/PabloZaiden/ssh-server-runner/main/ssh-server.sh";
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;
24 changes: 22 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -124,7 +128,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 <subpath>] [--ssh-public-key <path>] [--template <name>]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath <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 <port> Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.\n --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template <name> 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 <subpath>] [--ssh-public-key <path>] [--template <name>]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath <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 <port> Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.\n --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template <name> Use a built-in template instead of a repo devcontainer.\n -h, --help Show this help.`;
}

export function parseArgs(argv: string[]): ParsedArgs {
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -978,7 +998,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);
Expand Down
Loading
Loading