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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ dist/
coverage/
.sshcred
.devbox-ssh-host-keys/
.devbox/
.planning/
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 the [`ssh-server-runner`](https://github.com/PabloZaiden/ssh-server-runner) one-liner inside the devcontainer.
- Persists the runner password on the mounted workspace as a local `.sshcred` file, stores devbox-owned SSH metadata in `.devbox-ssh.json`, and keeps SSH host keys in `.devbox-ssh-host-keys/`, so they survive `down` / `rebuild`.
- 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

Expand Down Expand Up @@ -86,7 +86,7 @@ devbox status
# Find stopped managed devbox containers and rerun `devbox up` for each recoverable workspace
devbox arise

# Stop and remove the managed container while preserving the workspace-mounted SSH password, metadata, and host keys
# Stop and remove the managed container while preserving workspace-local devbox state and SSH artifacts
devbox down
```

Expand Down Expand Up @@ -141,7 +141,7 @@ Example:
}
```

The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, and the stored/generated config paths.
The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, and the stored/generated config paths. Devbox-owned state paths point inside the current workspace's `.devbox/` directory.

When available, the status payload also includes `publicKeyConfigured` and `publicKeySource` so automation can tell whether devbox installed a host SSH public key for key-based login.

Expand Down Expand Up @@ -192,15 +192,16 @@ The complex example uses several devcontainer features, so the first `up` or `re
## Notes

- 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 into devbox-managed state instead of creating a source devcontainer definition inside the repo.
- 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.
- `--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 plus the persisted `.sshcred` password file and `.devbox-ssh.json` metadata when the container is stopped or Docker is unavailable.
- `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/`.
- 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 state 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/` plus the workspace `.sshcred`, `.devbox-ssh.json`, and `.devbox-ssh-host-keys/`, so rebuilds can reuse the last selected port/config source/template.
- 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: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getManagedPortFromContainerName,
getManagedLabels,
prepareKnownHostsMount,
getWorkspaceStateDir,
getWorkspaceUserDataDir,
hashWorkspacePath,
helpText,
Expand Down Expand Up @@ -199,6 +200,7 @@ async function handleUpLike(
if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) {
await ensureGeneratedConfigIgnored(workspacePath, generatedConfigPath);
}
await ensurePathIgnored(workspacePath, getWorkspaceStateDir(workspacePath));
if (resolvedConfig.legacyGeneratedConfigPath) {
await removeGeneratedConfig(resolvedConfig.legacyGeneratedConfigPath);
}
Expand Down
45 changes: 7 additions & 38 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,21 +396,8 @@ export function getManagedLabels(workspaceHash: string): Record<string, string>
};
}

export function getStateRoot(): string {
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support", CLI_NAME);
}

const xdgStateHome = process.env.XDG_STATE_HOME;
if (xdgStateHome) {
return path.join(xdgStateHome, CLI_NAME);
}

return path.join(os.homedir(), ".local", "state", CLI_NAME);
}

export function getWorkspaceStateDir(workspacePath: string): string {
return path.join(getStateRoot(), "workspaces", hashWorkspacePath(workspacePath));
return path.join(workspacePath, ".devbox");
}

export function getWorkspaceStateFile(workspacePath: string): string {
Expand All @@ -425,22 +412,6 @@ export function getTemplateGeneratedConfigPath(workspacePath: string): string {
return path.join(getWorkspaceStateDir(workspacePath), ".devcontainer.json");
}

export function getLegacyTemplateGeneratedConfigPath(workspacePath: string): string {
return path.join(getWorkspaceStateDir(workspacePath), "template.devcontainer.json");
}

function resolveTemplateGeneratedConfigPath(workspacePath: string, generatedConfigPath?: string | null): string {
if (!generatedConfigPath) {
return getTemplateGeneratedConfigPath(workspacePath);
}

if (path.resolve(generatedConfigPath) === path.resolve(getLegacyTemplateGeneratedConfigPath(workspacePath))) {
return getTemplateGeneratedConfigPath(workspacePath);
}

return generatedConfigPath;
}

export function getDefaultRemoteWorkspaceFolder(workspacePath: string): string {
return path.posix.join("/workspaces", path.basename(workspacePath));
}
Expand Down Expand Up @@ -709,7 +680,7 @@ export async function resolveWorkspaceConfig(input: {
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template,
};
}
Expand All @@ -731,8 +702,8 @@ export async function resolveWorkspaceConfig(input: {
config: structuredClone(input.state.template.config),
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath),
legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath),
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template: cloneTemplateState(input.state.template),
};
}
Expand All @@ -754,8 +725,8 @@ export async function resolveWorkspaceConfig(input: {
config: structuredClone(input.state.template.config),
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: resolveTemplateGeneratedConfigPath(input.workspacePath, input.state.generatedConfigPath),
legacyGeneratedConfigPath: getLegacyTemplateGeneratedConfigPath(input.workspacePath),
generatedConfigPath: getTemplateGeneratedConfigPath(input.workspacePath),
legacyGeneratedConfigPath: null,
template: cloneTemplateState(input.state.template),
};
}
Expand Down Expand Up @@ -1114,9 +1085,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null {
configSource: record.configSource,
sourceConfigPath: record.sourceConfigPath,
generatedConfigPath:
record.configSource === "template"
? resolveTemplateGeneratedConfigPath(record.workspacePath, record.generatedConfigPath)
: record.generatedConfigPath,
record.configSource === "template" ? getTemplateGeneratedConfigPath(record.workspacePath) : record.generatedConfigPath,
labels: record.labels as Record<string, string>,
userDataDir: record.userDataDir,
template: (record.template as WorkspaceTemplateState | null | undefined) ?? null,
Expand Down
77 changes: 32 additions & 45 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { dirname } from "node:path";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand All @@ -14,7 +13,6 @@ import {
getContainerSshAuthSockPath,
getGeneratedConfigPath,
getLegacyGeneratedConfigPath,
getLegacyTemplateGeneratedConfigPath,
getManagedContainerName,
getManagedPortFromContainerName,
getManagedLabels,
Expand Down Expand Up @@ -223,55 +221,44 @@ describe("resolvePort", () => {
});

describe("loadWorkspaceState", () => {
test("migrates a version 1 workspace state file", async () => {
test("loads a version 1 workspace state file from local workspace state", async () => {
const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-"));
const stateHome = await mkdtemp(path.join(os.tmpdir(), "devbox-state-home-"));
tempPaths.push(workspacePath, stateHome);

const previousStateHome = process.env.XDG_STATE_HOME;
process.env.XDG_STATE_HOME = stateHome;

try {
const statePath = getWorkspaceStateFile(workspacePath);
await mkdir(dirname(statePath), { recursive: true });
await writeFile(
statePath,
`${JSON.stringify({
version: 1,
workspacePath,
workspaceHash: "hash",
port: 5001,
sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"),
generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"),
labels: { managed: "true" },
userDataDir: path.join(stateHome, "user-data"),
lastContainerId: "container-123",
updatedAt: "2026-04-23T00:00:00.000Z",
}, null, 2)}\n`,
"utf8",
);

await expect(loadWorkspaceState(workspacePath)).resolves.toEqual({
version: STATE_VERSION,
tempPaths.push(workspacePath);

const statePath = getWorkspaceStateFile(workspacePath);
expect(statePath).toBe(path.join(workspacePath, ".devbox", "state.json"));
await mkdir(path.dirname(statePath), { recursive: true });
await writeFile(
statePath,
`${JSON.stringify({
version: 1,
workspacePath,
workspaceHash: "hash",
port: 5001,
configSource: "repo",
sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"),
generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"),
labels: { managed: "true" },
userDataDir: path.join(stateHome, "user-data"),
template: null,
userDataDir: path.join(workspacePath, ".devbox", "user-data"),
lastContainerId: "container-123",
updatedAt: "2026-04-23T00:00:00.000Z",
});
} finally {
if (previousStateHome === undefined) {
delete process.env.XDG_STATE_HOME;
} else {
process.env.XDG_STATE_HOME = previousStateHome;
}
}
}, null, 2)}\n`,
"utf8",
);

await expect(loadWorkspaceState(workspacePath)).resolves.toEqual({
version: STATE_VERSION,
workspacePath,
workspaceHash: "hash",
port: 5001,
configSource: "repo",
sourceConfigPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"),
generatedConfigPath: path.join(workspacePath, ".devcontainer", ".devbox.generated.devcontainer.json"),
labels: { managed: "true" },
userDataDir: path.join(workspacePath, ".devbox", "user-data"),
template: null,
lastContainerId: "container-123",
updatedAt: "2026-04-23T00:00:00.000Z",
});
});
});

Expand Down Expand Up @@ -455,7 +442,7 @@ describe("resolveWorkspaceConfig", () => {
});
});

test("normalizes legacy template generated config paths from saved state", async () => {
test("uses the local template generated config path instead of saved stale paths", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-"));
tempPaths.push(tempDir);
const template = getTemplateDefinition("ubuntu");
Expand All @@ -471,7 +458,7 @@ describe("resolveWorkspaceConfig", () => {
port: 5001,
configSource: "template",
sourceConfigPath: null,
generatedConfigPath: getLegacyTemplateGeneratedConfigPath(tempDir),
generatedConfigPath: path.join(os.tmpdir(), "old-devbox-state", ".devcontainer.json"),
labels: {},
userDataDir: path.join(tempDir, ".devbox", "user-data"),
template,
Expand All @@ -485,7 +472,7 @@ describe("resolveWorkspaceConfig", () => {
});

expect(resolution.generatedConfigPath).toBe(getTemplateGeneratedConfigPath(tempDir));
expect(resolution.legacyGeneratedConfigPath).toBe(getLegacyTemplateGeneratedConfigPath(tempDir));
expect(resolution.legacyGeneratedConfigPath).toBeNull();
});
});

Expand Down
20 changes: 3 additions & 17 deletions tests/examples.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import {
CLI_NAME,
DEVBOX_SSH_METADATA_FILENAME,
DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE,
MANAGED_LABEL_KEY,
Expand Down Expand Up @@ -433,7 +432,6 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions
env.HOME = homeDir;
env.PATH = `${wrappersDir}${path.delimiter}${env.PATH}`;
env.SSH_AUTH_SOCK = sshAuthSockPath ?? "";
env.XDG_STATE_HOME = path.join(tempRoot, "state");

const fixture: LiveFixture = {
env,
Expand All @@ -458,7 +456,7 @@ async function setupLiveFixture(exampleName: string, options: LiveFixtureOptions
runnerHostKeyMarker,
sampleFilePath: path.join(workspacePath, "sample-file.txt"),
sshAuthSockPath,
statePath: getStatePath(homeDir, workspacePath, env.XDG_STATE_HOME),
statePath: getStatePath(workspacePath),
workspacePath,
};

Expand Down Expand Up @@ -996,20 +994,8 @@ function baseEnv(): Record<string, string> {
return env;
}

function getStatePath(homeDir: string, workspacePath: string, xdgStateHome?: string): string {
return path.join(getStateRoot(homeDir, xdgStateHome), "workspaces", hashWorkspacePath(workspacePath), "state.json");
}

function getStateRoot(homeDir: string, xdgStateHome?: string): string {
if (process.platform === "darwin") {
return path.join(homeDir, "Library", "Application Support", CLI_NAME);
}

if (xdgStateHome) {
return path.join(xdgStateHome, CLI_NAME);
}

return path.join(homeDir, ".local", "state", CLI_NAME);
function getStatePath(workspacePath: string): string {
return path.join(workspacePath, ".devbox", "state.json");
}

function findExecutable(command: string): string | null {
Expand Down
18 changes: 4 additions & 14 deletions tests/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { KNOWN_HOSTS_SNAPSHOT_FILENAME, SSH_AUTH_SOCK_TARGET } from "../src/constants";
import { getGeneratedConfigPath, hashWorkspacePath } from "../src/core";
import { getGeneratedConfigPath } from "../src/core";
import { buildInteractiveShellScript } from "../src/runtime";

const repoRoot = process.cwd();
Expand Down Expand Up @@ -618,7 +618,6 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO
await writeFile(sshAuthSockPath, "fake-agent\n", "utf8");
}

const xdgStateHome = path.join(tempDir, "state");
const env = baseEnv();
env.DEVBOX_FAKE_GH_MODE = options.ghToken ? "token" : "unauthenticated";
env.DEVBOX_FAKE_GH_TOKEN = options.ghToken ?? "";
Expand All @@ -627,9 +626,8 @@ async function setupExampleFixture(exampleName: string, options: ExampleFixtureO
env.HOME = homeDir;
env.PATH = `${path.join(fakeHostDir, "bin")}${path.delimiter}${env.PATH}`;
env.SSH_AUTH_SOCK = sshAuthSockPath ?? "";
env.XDG_STATE_HOME = xdgStateHome;

const stateDir = getStateDir(homeDir, workspacePath, xdgStateHome);
const stateDir = getStateDir(workspacePath);
const sourceConfigPath = resolveExampleSourceConfigPath(workspacePath);
return {
commandLogPath,
Expand Down Expand Up @@ -713,16 +711,8 @@ function baseEnv(): Record<string, string> {
return env;
}

function getStateDir(homeDir: string, workspacePath: string, xdgStateHome?: string): string {
let root: string;
if (process.platform === "darwin") {
root = path.join(homeDir, "Library", "Application Support", "devbox");
} else if (xdgStateHome) {
root = path.join(xdgStateHome, "devbox");
} else {
root = path.join(homeDir, ".local", "state", "devbox");
}
return path.join(root, "workspaces", hashWorkspacePath(workspacePath));
function getStateDir(workspacePath: string): string {
return path.join(workspacePath, ".devbox");
}

async function readJson(filePath: string): Promise<any> {
Expand Down
Loading