From 8fc9bf186f18db7c54cc0ac40f54ba776ede811a Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 30 Jun 2026 14:34:39 +0000 Subject: [PATCH 1/2] Persist GitHub CLI account selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 ++++++- src/cli.ts | 40 ++++++++++++++- src/core.ts | 114 +++++++++++++++++++++++++++++++++++++----- src/runtime.ts | 19 +++++-- src/status.ts | 3 ++ tests/arise.test.ts | 1 + tests/core.test.ts | 58 +++++++++++++++++++++ tests/runtime.test.ts | 19 +++++++ tests/status.test.ts | 7 +++ 9 files changed, 259 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 35d9056..62e3e52 100644 --- a/README.md +++ b/README.md @@ -14,6 +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. +- Injects `GH_TOKEN` from the GitHub CLI when available, optionally using a persisted per-workspace `gh` account. - 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`. @@ -65,6 +66,12 @@ devbox up --allow-missing-ssh # Override the public key installed for SSH login inside the container devbox up --ssh-public-key ~/.ssh/work-devbox.pub +# Use a specific GitHub CLI account for GH_TOKEN injection and persist it for this workspace +devbox up --gh-user work-account + +# Use an account on a non-default GitHub host +devbox up --gh-user work-account --gh-host github.example.com + # Use a specific devcontainer under .devcontainer/services/api devbox up --devcontainer-subpath services/api @@ -100,6 +107,8 @@ When you run `devbox rebuild`, omitting the port reuses the last stored port for `devbox rebuild` reuses the previously selected source for the workspace. If the workspace was started from `--template`, rebuild uses that saved template again. `rebuild --template ...` is intentionally not supported. +GitHub CLI authentication for `GH_TOKEN` injection can be pinned per workspace with `--gh-user ` and optional `--gh-host `. Devbox stores only the selected account metadata in `.devbox/state.json` as `githubAuth`; it does not store the token. Later `up`, `rebuild`, and `arise` runs reuse that account by calling `gh auth token --hostname --user `. The selection precedence is: explicit flags, `DEVBOX_GH_USER` / `DEVBOX_GH_HOST`, saved `.devbox/state.json`, then the currently active `gh` account. + `devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first. `devbox status` always prints JSON so it can be used directly from scripts and automation. @@ -134,6 +143,10 @@ Example: "sshUser": "root", "sshPort": 5001, "remoteUser": "vscode", + "githubAuth": { + "host": "github.com", + "user": "work-account" + }, "hasStateFile": true, "hasCredentialFile": true, "hasSshMetadataFile": true, @@ -141,7 +154,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. Devbox-owned state paths point inside the current workspace's `.devbox/` directory. +The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, `githubAuth`, 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. @@ -194,8 +207,10 @@ 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 all devbox-owned local state (`state.json`, `user-data/`, template generated configs, and `ssh/`) and should stay ignored by version control. +- `.devbox/state.json` may include `githubAuth: { "host": "...", "user": "..." }` so tools can detect or preserve the GitHub CLI account devbox will use for future `GH_TOKEN` injection. - `--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. +- `--gh-user ` and `--gh-host ` select the GitHub CLI account used for `GH_TOKEN` injection without changing the globally active `gh` account. - `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 `.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/`. diff --git a/src/cli.ts b/src/cli.ts index fc8a048..77bfb00 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,7 @@ import { resolveUpPortPreference, saveWorkspaceState, type DockerInspect, + type GithubAuthPreference, UserError, writeManagedConfig, } from "./core"; @@ -110,6 +111,8 @@ async function main(): Promise { parsed.devcontainerSubpath, parsed.sshPublicKeyPath, parsed.templateName, + parsed.githubUser, + parsed.githubHost, ); } @@ -122,8 +125,16 @@ async function handleUpLike( devcontainerSubpath: string | undefined, sshPublicKeyPath?: string, templateName?: string, + explicitGithubUser?: string, + explicitGithubHost?: string, ): Promise { - const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath }); + const githubAuth = resolveGithubAuthPreference({ + explicitUser: explicitGithubUser, + explicitHost: explicitGithubHost, + state, + env: process.env, + }); + const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath, githubAuth }); const resolvedSshPublicKey = await resolveSshPublicKey({ overridePath: sshPublicKeyPath }); const workspaceHash = hashWorkspacePath(workspacePath); const labels = getManagedLabels(workspaceHash); @@ -192,7 +203,11 @@ async function handleUpLike( console.log(`Using host SSH agent socket from ${environment.sshAuthSock}.`); } if (environment.githubToken) { - console.log("Using host GitHub authentication from gh."); + if (environment.githubAuth) { + console.log(`Using host GitHub authentication from gh account ${environment.githubAuth.user}@${environment.githubAuth.host}.`); + } else { + console.log("Using host GitHub authentication from gh."); + } } if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) { @@ -308,6 +323,7 @@ async function handleUpLike( userDataDir, labels, template: resolvedConfig.template, + githubAuth: environment.githubAuth, containerId: upResult.containerId, }), ); @@ -435,6 +451,26 @@ async function handleArise(): Promise { } } +function resolveGithubAuthPreference(input: { + explicitUser?: string; + explicitHost?: string; + state: Awaited>; + env: Record; +}): GithubAuthPreference | null { + const envUser = input.env.DEVBOX_GH_USER?.trim() || undefined; + const envHost = input.env.DEVBOX_GH_HOST?.trim() || undefined; + const user = input.explicitUser ?? envUser ?? input.state?.githubAuth?.user; + + if (!user) { + return null; + } + + return { + user, + host: input.explicitHost ?? envHost ?? input.state?.githubAuth?.host ?? "github.com", + }; +} + function getPublishedHostPorts(container: DockerInspect): number[] { const ports = container.NetworkSettings?.Ports ?? {}; const values = new Set(); diff --git a/src/core.ts b/src/core.ts index 87d09a9..d5771ef 100644 --- a/src/core.ts +++ b/src/core.ts @@ -32,6 +32,8 @@ export interface ParsedArgs { devcontainerSubpath?: string; sshPublicKeyPath?: string; templateName?: string; + githubUser?: string; + githubHost?: string; } export type DevcontainerConfig = Record; @@ -66,10 +68,16 @@ export interface WorkspaceState { labels: Record; userDataDir: string; template: WorkspaceTemplateState | null; + githubAuth: GithubAuthPreference | null; lastContainerId?: string; updatedAt: string; } +export interface GithubAuthPreference { + host: string; + user: string; +} + export interface WorkspaceTemplateState { name: string; description: string; @@ -128,7 +136,7 @@ export class UserError extends Error { } export function helpText(): string { - 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.`; + 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 ] [--gh-user ] [--gh-host ]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath ] [--ssh-public-key ] [--gh-user ] [--gh-host ]\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 --gh-user Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-host GitHub host for --gh-user. Defaults to github.com.\n -h, --help Show this help.`; } export function parseArgs(argv: string[]): ParsedArgs { @@ -165,6 +173,8 @@ export function parseArgs(argv: string[]): ParsedArgs { let devcontainerSubpath: string | undefined; let sshPublicKeyPath: string | undefined; let templateName: string | undefined; + let githubUser: string | undefined; + let githubHost: string | undefined; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -214,6 +224,36 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } + if (arg === "--gh-user") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --gh-user."); + } + githubUser = parseGithubUser(value); + index += 1; + continue; + } + + if (arg.startsWith("--gh-user=")) { + githubUser = parseGithubUser(arg.slice("--gh-user=".length)); + continue; + } + + if (arg === "--gh-host") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --gh-host."); + } + githubHost = parseGithubHost(value); + index += 1; + continue; + } + + if (arg.startsWith("--gh-host=")) { + githubHost = parseGithubHost(arg.slice("--gh-host=".length)); + continue; + } + if (arg.startsWith("--template=")) { templateName = parseTemplateName(arg.slice("--template=".length)); continue; @@ -322,6 +362,14 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --ssh-public-key."); } + if (command !== "up" && command !== "rebuild" && githubUser !== undefined) { + throw new UserError(`The ${command} command does not accept --gh-user.`); + } + + if (command !== "up" && command !== "rebuild" && githubHost !== undefined) { + throw new UserError(`The ${command} command does not accept --gh-host.`); + } + if (command === "status" && allowMissingSsh) { throw new UserError("The status command does not accept --allow-missing-ssh."); } @@ -362,18 +410,16 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --template."); } - if (devcontainerSubpath || sshPublicKeyPath || templateName) { - return { - command, - port, - allowMissingSsh, - ...(devcontainerSubpath ? { devcontainerSubpath } : {}), - ...(sshPublicKeyPath ? { sshPublicKeyPath } : {}), - ...(templateName ? { templateName } : {}), - }; - } - - return { command, port, allowMissingSsh }; + return { + command, + port, + allowMissingSsh, + ...(devcontainerSubpath ? { devcontainerSubpath } : {}), + ...(sshPublicKeyPath ? { sshPublicKeyPath } : {}), + ...(templateName ? { templateName } : {}), + ...(githubUser ? { githubUser } : {}), + ...(githubHost ? { githubHost } : {}), + }; } export function parsePort(raw: string): number { @@ -389,6 +435,24 @@ export function parsePort(raw: string): number { return port; } +function parseGithubUser(raw: string): string { + const value = raw.trim(); + if (!value || /\s/.test(value)) { + throw new UserError(`Invalid GitHub user: ${raw}`); + } + + return value; +} + +function parseGithubHost(raw: string): string { + const value = raw.trim(); + if (!value || /\s/.test(value) || value.includes("/") || value.includes(":")) { + throw new UserError(`Invalid GitHub host: ${raw}`); + } + + return value; +} + export function hashWorkspacePath(workspacePath: string): string { return createHash("sha256").update(workspacePath).digest("hex").slice(0, 16); } @@ -668,6 +732,7 @@ export function createWorkspaceState(input: { userDataDir: string; labels: Record; template: WorkspaceTemplateState | null; + githubAuth: GithubAuthPreference | null; containerId?: string; }): WorkspaceState { return { @@ -681,6 +746,7 @@ export function createWorkspaceState(input: { labels: input.labels, userDataDir: input.userDataDir, template: input.template ? cloneTemplateState(input.template) : null, + githubAuth: input.githubAuth ? { ...input.githubAuth } : null, lastContainerId: input.containerId, updatedAt: new Date().toISOString(), }; @@ -1063,6 +1129,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(); const lastContainerId = typeof record.lastContainerId === "string" ? record.lastContainerId : undefined; + const githubAuth = normalizeGithubAuthPreference(record.githubAuth); if (record.version === 1) { if (typeof record.sourceConfigPath !== "string") { @@ -1080,6 +1147,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { labels: record.labels as Record, userDataDir: record.userDataDir, template: null, + githubAuth: null, lastContainerId, updatedAt, }; @@ -1109,11 +1177,31 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null { labels: record.labels as Record, userDataDir: record.userDataDir, template: (record.template as WorkspaceTemplateState | null | undefined) ?? null, + githubAuth, lastContainerId, updatedAt, }; } +function normalizeGithubAuthPreference(value: unknown): GithubAuthPreference | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const record = value as Record; + if (typeof record.host !== "string" || typeof record.user !== "string") { + return null; + } + + const host = record.host.trim(); + const user = record.user.trim(); + if (!host || !user) { + return null; + } + + return { host, user }; +} + function cloneTemplateState(template: WorkspaceTemplateState): WorkspaceTemplateState { return { ...template, diff --git a/src/runtime.ts b/src/runtime.ts index 6362ee0..4224692 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { type DockerInspect, + type GithubAuthPreference, type UpResult, getContainerSshAuthSockPath, UserError, @@ -66,6 +67,7 @@ export interface ResolvedHostEnvironment extends ResolvedSshAuthSock { gitUserName: string | null; gitUserEmail: string | null; githubToken: string | null; + githubAuth: GithubAuthPreference | null; githubTokenWarning?: string; } @@ -440,6 +442,7 @@ export function resolveSshAuthSockSource(input: { export async function ensureHostEnvironment(options: { allowMissingSsh: boolean; workspacePath: string; + githubAuth: GithubAuthPreference | null; }): Promise { if (process.platform !== "darwin" && process.platform !== "linux") { throw new UserError(`Unsupported platform: ${process.platform}. macOS and Linux are supported in v1.`); @@ -460,7 +463,7 @@ export async function ensureHostEnvironment(options: { isDockerRootlessEngine(), tryGetGitConfig(options.workspacePath, "user.name"), tryGetGitConfig(options.workspacePath, "user.email"), - tryGetGhCliToken(), + tryGetGhCliToken(options.githubAuth), ]); return { @@ -474,6 +477,7 @@ export async function ensureHostEnvironment(options: { gitUserName, gitUserEmail, githubToken: ghCliToken.token, + githubAuth: options.githubAuth, githubTokenWarning: ghCliToken.warning, }; } @@ -1192,12 +1196,21 @@ export function looksLikeGhUnauthenticatedError(stderr: string): boolean { ); } -async function tryGetGhCliToken(): Promise { +export function buildGhCliTokenArgs(githubAuth: GithubAuthPreference | null): string[] { + const args = ["gh", "auth", "token"]; + if (githubAuth) { + args.push("--hostname", githubAuth.host, "--user", githubAuth.user); + } + + return args; +} + +async function tryGetGhCliToken(githubAuth: GithubAuthPreference | null): Promise { if (!isExecutableAvailable("gh")) { return { token: null }; } - const result = await execute(["gh", "auth", "token"], { + const result = await execute(buildGhCliTokenArgs(githubAuth), { stdoutMode: "capture", stderrMode: "capture", allowFailure: true, diff --git a/src/status.ts b/src/status.ts index 581a549..78d4bc7 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,6 +4,7 @@ import { parse as parseJsonc } from "jsonc-parser/lib/esm/main.js"; import type { ParseError } from "jsonc-parser"; import { type DockerInspect, + type GithubAuthPreference, type WorkspaceState, getDefaultRemoteWorkspaceFolder, getManagedLabels, @@ -57,6 +58,7 @@ export interface DevboxStatus { sourceConfigPath: string | null; generatedConfigPath: string | null; userDataDir: string | null; + githubAuth: GithubAuthPreference | null; updatedAt: string | null; warnings: string[]; } @@ -194,6 +196,7 @@ export async function getDevboxStatus( sourceConfigPath: state?.sourceConfigPath ?? configHints.sourceConfigPath, generatedConfigPath: state?.generatedConfigPath ?? null, userDataDir: state?.userDataDir ?? null, + githubAuth: state?.githubAuth ? { ...state.githubAuth } : null, updatedAt: state?.updatedAt ?? null, warnings, }; diff --git a/tests/arise.test.ts b/tests/arise.test.ts index 20e889b..192fd7e 100644 --- a/tests/arise.test.ts +++ b/tests/arise.test.ts @@ -246,6 +246,7 @@ describe("ariseManagedWorkspaces", () => { labels: { "devbox.managed": "true", "devbox.workspace": "hash-ok" }, userDataDir: "/tmp/state-ok", template: null, + githubAuth: null, updatedAt: "2026-03-20T00:00:00.000Z", }, ], diff --git a/tests/core.test.ts b/tests/core.test.ts index 1be5d86..9ad4682 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -109,6 +109,20 @@ describe("parseArgs", () => { }); }); + test("supports selecting a GitHub CLI account for up and rebuild", () => { + expect(parseArgs(["up", "--gh-user", "work-account"])).toEqual({ + command: "up", + allowMissingSsh: false, + githubUser: "work-account", + }); + expect(parseArgs(["rebuild", "--gh-user=personal", "--gh-host=github.example.com"])).toEqual({ + command: "rebuild", + allowMissingSsh: false, + githubUser: "personal", + githubHost: "github.example.com", + }); + }); + test("requires an explicit command for options or ports", () => { expect(() => parseArgs(["5001"])).toThrow("A command is required."); expect(() => parseArgs(["--devcontainer-subpath=python"])).toThrow("A command is required."); @@ -145,6 +159,9 @@ describe("parseArgs", () => { expect(() => parseArgs(["status", "--ssh-public-key", "/tmp/id_rsa.pub"])).toThrow( "The status command does not accept --ssh-public-key.", ); + expect(() => parseArgs(["status", "--gh-user", "work"])).toThrow( + "The status command does not accept --gh-user.", + ); }); test("arise rejects ports and unrelated options", () => { @@ -169,6 +186,12 @@ describe("parseArgs", () => { "--template cannot be combined with --devcontainer-subpath.", ); }); + + test("rejects invalid GitHub auth options", () => { + expect(() => parseArgs(["up", "--gh-user", ""])).toThrow("Expected a value after --gh-user."); + expect(() => parseArgs(["up", "--gh-user", "not valid"])).toThrow("Invalid GitHub user:"); + expect(() => parseArgs(["up", "--gh-host", "https://github.com"])).toThrow("Invalid GitHub host:"); + }); }); describe("helpText", () => { @@ -214,6 +237,7 @@ describe("resolvePort", () => { labels: { managed: "true" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, updatedAt: new Date().toISOString(), }), ).toBe(5003); @@ -256,10 +280,41 @@ describe("loadWorkspaceState", () => { labels: { managed: "true" }, userDataDir: path.join(workspacePath, ".devbox", "user-data"), template: null, + githubAuth: null, lastContainerId: "container-123", updatedAt: "2026-04-23T00:00:00.000Z", }); }); + + test("loads a persisted GitHub auth preference", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); + tempPaths.push(workspacePath); + + const statePath = getWorkspaceStateFile(workspacePath); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + `${JSON.stringify({ + 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, + githubAuth: { host: "github.com", user: "work-account" }, + updatedAt: "2026-04-23T00:00:00.000Z", + }, null, 2)}\n`, + "utf8", + ); + + await expect(loadWorkspaceState(workspacePath)).resolves.toMatchObject({ + githubAuth: { host: "github.com", user: "work-account" }, + }); + }); }); describe("resolveUpPortPreference", () => { @@ -274,6 +329,7 @@ describe("resolveUpPortPreference", () => { labels: { managed: "true" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, updatedAt: new Date().toISOString(), }; @@ -414,6 +470,7 @@ describe("resolveWorkspaceConfig", () => { labels: {}, userDataDir: path.join(tempDir, ".devbox", "user-data"), template, + githubAuth: null, updatedAt: new Date().toISOString(), }; @@ -462,6 +519,7 @@ describe("resolveWorkspaceConfig", () => { labels: {}, userDataDir: path.join(tempDir, ".devbox", "user-data"), template, + githubAuth: null, updatedAt: new Date().toISOString(), }; diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index e35b19d..7c81a51 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -9,6 +9,7 @@ import { buildConfigureAuthorizedKeysScript, buildCopyKnownHostsScript, buildConfigureGitIdentityScript, + buildGhCliTokenArgs, buildDevcontainerShellCommand, buildEnsureSshAuthSockAccessibleScript, ensurePathIgnored, @@ -630,6 +631,24 @@ describe("resolveGhCliToken", () => { }); }); +describe("buildGhCliTokenArgs", () => { + test("uses the active GitHub CLI account by default", () => { + expect(buildGhCliTokenArgs(null)).toEqual(["gh", "auth", "token"]); + }); + + test("targets a persisted GitHub CLI account when provided", () => { + expect(buildGhCliTokenArgs({ host: "github.com", user: "work-account" })).toEqual([ + "gh", + "auth", + "token", + "--hostname", + "github.com", + "--user", + "work-account", + ]); + }); +}); + describe("looksLikeGhUnauthenticatedError", () => { test("recognizes common gh authentication prompts", () => { expect(looksLikeGhUnauthenticatedError("Run gh auth login to authenticate.")).toBe(true); diff --git a/tests/status.test.ts b/tests/status.test.ts index b456b32..000d44a 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -53,6 +53,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/ws-state", template: null, + githubAuth: { host: "github.com", user: "work-account" }, lastContainerId: "container-2", updatedAt: "2026-03-16T00:00:00.000Z", }; @@ -121,6 +122,7 @@ describe("getDevboxStatus", () => { expect(status.containerId).toBe("container-2"); expect(status.containerCount).toBe(2); expect(status.hasSshMetadataFile).toBe(true); + expect(status.githubAuth).toEqual({ host: "github.com", user: "work-account" }); expect(status.warnings).toEqual([ "Found 2 managed containers for this workspace; reporting the preferred container.", ]); @@ -189,6 +191,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, lastContainerId: null, updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -231,6 +234,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, lastContainerId: null, updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -273,6 +277,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, lastContainerId: "container-stale", updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -308,6 +313,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, lastContainerId: "container-1", updatedAt: "2026-03-16T00:00:00.000Z", }, @@ -368,6 +374,7 @@ describe("getDevboxStatus", () => { labels: { "devbox.managed": "true", "devbox.workspace": "workspace-hash" }, userDataDir: "/tmp/state", template: null, + githubAuth: null, lastContainerId: "container-1", updatedAt: "2026-03-16T00:00:00.000Z", }, From 8a5f7f4b3b3b3f55554e6408101ef88cdb80c8b9 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Tue, 30 Jun 2026 14:46:33 +0000 Subject: [PATCH 2/2] Validate persisted GitHub auth selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli.ts | 13 +++++++++---- src/core.ts | 28 ++++++++++++++++++++++------ tests/core.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 77bfb00..126547a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,8 @@ import { getManagedPortFromContainerName, getManagedLabels, prepareKnownHostsMount, + parseGithubHost, + parseGithubUser, getWorkspaceSshMetadataFile, getWorkspaceStateDir, getWorkspaceUserDataDir, @@ -457,17 +459,20 @@ function resolveGithubAuthPreference(input: { state: Awaited>; env: Record; }): GithubAuthPreference | null { - const envUser = input.env.DEVBOX_GH_USER?.trim() || undefined; - const envHost = input.env.DEVBOX_GH_HOST?.trim() || undefined; + const envUser = input.env.DEVBOX_GH_USER ? parseGithubUser(input.env.DEVBOX_GH_USER) : undefined; + const envHost = input.env.DEVBOX_GH_HOST ? parseGithubHost(input.env.DEVBOX_GH_HOST) : undefined; const user = input.explicitUser ?? envUser ?? input.state?.githubAuth?.user; if (!user) { + if (input.explicitHost || envHost) { + throw new UserError("A GitHub user is required when selecting a GitHub host. Pass --gh-user or set DEVBOX_GH_USER."); + } return null; } return { - user, - host: input.explicitHost ?? envHost ?? input.state?.githubAuth?.host ?? "github.com", + user: parseGithubUser(user), + host: parseGithubHost(input.explicitHost ?? envHost ?? input.state?.githubAuth?.host ?? "github.com"), }; } diff --git a/src/core.ts b/src/core.ts index d5771ef..dd0a8ee 100644 --- a/src/core.ts +++ b/src/core.ts @@ -435,7 +435,7 @@ export function parsePort(raw: string): number { return port; } -function parseGithubUser(raw: string): string { +export function parseGithubUser(raw: string): string { const value = raw.trim(); if (!value || /\s/.test(value)) { throw new UserError(`Invalid GitHub user: ${raw}`); @@ -444,7 +444,7 @@ function parseGithubUser(raw: string): string { return value; } -function parseGithubHost(raw: string): string { +export function parseGithubHost(raw: string): string { const value = raw.trim(); if (!value || /\s/.test(value) || value.includes("/") || value.includes(":")) { throw new UserError(`Invalid GitHub host: ${raw}`); @@ -453,6 +453,24 @@ function parseGithubHost(raw: string): string { return value; } +function isValidGithubUser(raw: string): boolean { + try { + parseGithubUser(raw); + return true; + } catch { + return false; + } +} + +function isValidGithubHost(raw: string): boolean { + try { + parseGithubHost(raw); + return true; + } catch { + return false; + } +} + export function hashWorkspacePath(workspacePath: string): string { return createHash("sha256").update(workspacePath).digest("hex").slice(0, 16); } @@ -1193,13 +1211,11 @@ function normalizeGithubAuthPreference(value: unknown): GithubAuthPreference | n return null; } - const host = record.host.trim(); - const user = record.user.trim(); - if (!host || !user) { + if (!isValidGithubHost(record.host) || !isValidGithubUser(record.user)) { return null; } - return { host, user }; + return { host: record.host.trim(), user: record.user.trim() }; } function cloneTemplateState(template: WorkspaceTemplateState): WorkspaceTemplateState { diff --git a/tests/core.test.ts b/tests/core.test.ts index 9ad4682..e530b00 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -315,6 +315,36 @@ describe("loadWorkspaceState", () => { githubAuth: { host: "github.com", user: "work-account" }, }); }); + + test("drops invalid persisted GitHub auth preferences", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "devbox-workspace-")); + tempPaths.push(workspacePath); + + const statePath = getWorkspaceStateFile(workspacePath); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + `${JSON.stringify({ + 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, + githubAuth: { host: "https://github.com", user: "not valid" }, + updatedAt: "2026-04-23T00:00:00.000Z", + }, null, 2)}\n`, + "utf8", + ); + + await expect(loadWorkspaceState(workspacePath)).resolves.toMatchObject({ + githubAuth: null, + }); + }); }); describe("resolveUpPortPreference", () => {