From b4bf30df4f7d7aa7556e05a12b60617da1225b70 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:24 +0700 Subject: [PATCH 1/8] feat(run-spaceapp): support rootless Podman in config and composeCommand (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/src/index.mjs | 42 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..06fc859ba 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -81,6 +81,7 @@ const PROFILE_RUNTIME_SETTINGS = Object.freeze({ }) }); export const INSTALL_PROFILES = Object.freeze(["small", "medium", "large", "light", "standard"]); +export const CONTAINER_ENGINES = Object.freeze(["docker", "podman"]); export const PROFILE_REQUIREMENTS = Object.freeze({ small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, @@ -131,6 +132,7 @@ const CONFIG_KEYS = new Set([ "workspaces", "companionsEnabled", "optionalModules", + "containerEngine", "installId", "users" ]); @@ -197,6 +199,23 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated throw new Error("Install access mode must be isolated or host-root."); } +export function resolveContainerEngine(requestedEngine, existingEngine = "docker") { + if (requestedEngine === undefined || requestedEngine === "auto") { + return existingEngine ?? "docker"; + } + if (requestedEngine === "docker" || requestedEngine === "podman") { + return requestedEngine; + } + throw new Error("Container engine must be auto, docker, or podman."); +} + +export function podmanUnshareChownArgs(hostPath, { uid = 10001, gid = 10001 } = {}) { + if (typeof hostPath !== "string" || !isAbsolute(hostPath)) { + throw new Error("Host path for rootless Podman UID mapping must be absolute."); + } + return ["unshare", "chown", "-R", `${uid}:${gid}`, resolve(hostPath)]; +} + export function versionTuple(version) { if (typeof version !== "string") { return null; @@ -311,13 +330,15 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + containerEngine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); + const resolvedEngine = containerEngine === undefined ? undefined : resolveContainerEngine(containerEngine); return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -328,6 +349,7 @@ export function createDefaultConfig({ profile, accessMode: resolvedAccessMode, companionsEnabled, + ...(resolvedEngine ? { containerEngine: resolvedEngine } : {}), optionalModules: "none", workspaces: [] }; @@ -373,6 +395,9 @@ export function validateConfig(config) { if (config.optionalModules !== undefined && !OPTIONAL_MODULES_PATTERN.test(config.optionalModules)) { throw new Error("optionalModules must be all, none or a comma-separated list of module names."); } + if (config.containerEngine !== undefined && config.containerEngine !== "docker" && config.containerEngine !== "podman") { + throw new Error("containerEngine must be docker or podman."); + } // Optional: only installations whose owner said Yes to telemetry have one. if (config.installId !== undefined && (typeof config.installId !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(config.installId))) { throw new Error("installId must be 8-64 letters, digits, '-' or '_'."); @@ -909,7 +934,11 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + const engine = options.containerEngine ?? options.engine ?? "docker"; + if (engine !== "docker" && engine !== "podman") { + throw new Error(`Unsupported container engine "${engine}".`); + } + return { command: engine, args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -984,7 +1013,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + containerEngine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -1027,6 +1057,12 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } + if (containerEngine !== undefined) { + const resolvedEngine = resolveContainerEngine(containerEngine, config.containerEngine); + if (config.containerEngine !== resolvedEngine) { + config = { ...config, containerEngine: resolvedEngine }; + } + } validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); From 8b65f2cb52c334746692bed2a079f936e55d6804 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:27 +0700 Subject: [PATCH 2/8] feat(run-spaceapp): add rootless Podman detection and readiness checks (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/src/prerequisites.mjs | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..5e80c9145 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -136,6 +136,10 @@ export async function ensureDockerAvailable({ scheduleResume = scheduleWindowsInstallResume, confirmRestart = confirmWindowsRestart }) { + const preferredEngine = installArgs?.requestedEngine ?? env?.SPACEAPP_CONTAINER_ENGINE; + if (preferredEngine === "podman") { + return ensureLinuxPodman({ env, stdin, stdout, stderr, execute, sleep }); + } if (await dockerIsReady(execute)) { return { code: 0, reexecuted: false }; } @@ -654,6 +658,73 @@ async function dockerCliAndComposeAreAvailable(execute) { return true; } + +export async function podmanCliAndComposeAreAvailable(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +export async function podmanIsReady(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] }, + { command: "podman", args: ["info"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +export async function detectContainerEngine({ + execute, + env = process.env, + preferredEngine +} = {}) { + const requested = preferredEngine ?? env?.SPACEAPP_CONTAINER_ENGINE ?? "auto"; + if (requested === "docker") return "docker"; + if (requested === "podman") return "podman"; + if (requested !== "auto") { + throw new Error("Container engine must be auto, docker, or podman."); + } + if (await dockerIsReady(execute)) return "docker"; + if (await podmanIsReady(execute)) return "podman"; + if (await dockerCliAndComposeAreAvailable(execute)) return "docker"; + if (await podmanCliAndComposeAreAvailable(execute)) return "podman"; + return "docker"; +} + +async function ensureLinuxPodman({ + stdin, + stdout, + stderr, + execute +}) { + if (await podmanIsReady(execute)) { + stdout?.write("Rootless Podman engine is ready.\n"); + return { code: 0, reexecuted: false }; + } + const cliOk = await podmanCliAndComposeAreAvailable(execute); + if (!cliOk) { + stderr?.write( + "Podman and a Compose provider (podman-compose or docker-compose) are required when --engine podman is selected.\n" + ); + return { code: 1, reexecuted: false }; + } + stderr?.write( + "Podman CLI is installed, but rootless Podman could not complete 'podman info'. Verify subuid/subgid mappings (/etc/subuid, /etc/subgid).\n" + ); + return { code: 1, reexecuted: false }; +} + async function dockerIsReady(execute) { for (const spec of [ { command: "docker", args: ["--version"] }, From 17895c8d85592f2da472579252e3b01fabb585d4 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:29 +0700 Subject: [PATCH 3/8] feat(run-spaceapp): support rootless Podman in storage plan inspection (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/src/storage.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..7e289d8d4 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -96,7 +96,8 @@ async function installedImageDigests(root, version) { } export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); + const engineCommand = config?.containerEngine === "podman" || existingConfig?.containerEngine === "podman" ? "podman" : "docker"; + const dockerInfo = await captureProbe(execute, { command: engineCommand, args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); @@ -104,7 +105,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,7 +127,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; let cached = null; for (const ref of refs) { - cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + cached = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; } if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { @@ -134,7 +135,8 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc let reserve = imageReserve[type] ?? GiB; if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") { try { - const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? "") ? "arm64" : "amd64", fetch); + const engineArch = engine.Architecture ?? engine.host?.arch ?? ""; + const metadata = await registryLayers(image, /arm|aarch64/.test(engineArch) ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -154,16 +156,17 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } - // Linux can measure the Docker filesystem directly even on a fresh install. - if (engine?.DockerRootDir && process.platform === "linux") { + // Linux can measure the Docker/Podman filesystem directly even on a fresh install. + const engineRootDir = engine?.DockerRootDir ?? engine?.store?.graphRoot; + if (engineRootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(engineRootDir)).freeDiskBytes; } catch {} } return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount: engine?.NCPU ?? engine?.host?.cpus, + engineMemoryBytes: engine?.MemTotal ?? engine?.host?.memTotal } }; } From 0d9daa65fe1a2ab5793a86ff22243cffd695cea7 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:32 +0700 Subject: [PATCH 4/8] feat(run-spaceapp): wire rootless Podman engine through CLI lifecycle and doctor (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/src/cli.mjs | 40 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..b8f00706c 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -34,6 +34,8 @@ import { removeCredential, removeUser, removeWorkspace, + podmanUnshareChownArgs, + resolveContainerEngine, resolveInstallAccessMode, resolveInstallProfile, resolveSpaceAppHome, @@ -50,6 +52,7 @@ import { } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + detectContainerEngine, ensureDockerAvailable, prepareDockerCliPath, windowsPowerShellArgs @@ -81,6 +84,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; const trustedCommands = new Set([ "codesign", "docker", + "podman", "hdiutil", "open", "powershell.exe", @@ -293,7 +297,8 @@ export async function run(argv, { plan: false, nonInteractive: false, logFile: null, - answersFile: null + answersFile: null, + engine: env?.SPACEAPP_CONTAINER_ENGINE }; const filteredArgv = []; for (let i = 0; i < argv.length; i++) { @@ -314,6 +319,10 @@ export async function run(argv, { globalFlags.answersFile = argv[++i]; } else if (arg.startsWith("--answers=")) { globalFlags.answersFile = arg.slice("--answers=".length); + } else if (arg === "--engine" && i + 1 < argv.length) { + globalFlags.engine = argv[++i]; + } else if (arg.startsWith("--engine=")) { + globalFlags.engine = arg.slice("--engine=".length); } else { filteredArgv.push(arg); } @@ -344,7 +353,7 @@ export async function run(argv, { if (globalFlags.localImages) { const originalExecute = execute; execute = async (spec, io) => { - if (spec.command === "docker" && spec.args?.includes("compose")) { + if ((spec.command === "docker" || spec.command === "podman") && spec.args?.includes("compose")) { if (spec.args.at(-1) === "pull") { stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n"); return 0; @@ -424,9 +433,10 @@ export async function run(argv, { if (commandNeedsRuntimeFiles(command, args)) { await writeRuntimeFiles(root, config); } + const activeEngine = resolveContainerEngine(globalFlags.engine, config.containerEngine); const runtimeExecute = (spec, io) => executeWithDockerDiagnostics( execute, - spec, + activeEngine === "podman" && spec.command === "docker" ? { ...spec, command: "podman" } : spec, io, { platform, stderr } ); @@ -1811,7 +1821,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(`Workspace registered: ${identity}\n`); - if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout); + if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout, { containerEngine: resolveContainerEngine(updated.containerEngine) }); return applyWorkspaces(updated); } if (action === "remove") { @@ -1836,9 +1846,18 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout // The CLIs run as uid 10001 in Docker; on Linux a bind-mounted folder keeps its host owner, so they cannot write to it. const CLI_UID = 10001; -async function writeAccessHint(hostPath, stdout) { +async function writeAccessHint(hostPath, stdout, { containerEngine = "docker" } = {}) { const info = await stat(hostPath); if (info.uid === CLI_UID || (info.mode & 0o002) !== 0) return; + if (containerEngine === "podman") { + const args = podmanUnshareChownArgs(hostPath, { uid: CLI_UID, gid: CLI_UID }); + stdout.write( + `The AI CLIs run as uid ${CLI_UID} inside rootless Podman. Map the workspace ownership in your user namespace without root:\n` + + ` podman ${args.slice(0, -1).join(" ")} ${JSON.stringify(args.at(-1))}\n` + + "(or add --read-only if the AI CLIs should only read)\n" + ); + return; + } const owner = process.getuid?.() ?? info.uid; stdout.write( `The AI CLIs run as uid ${CLI_UID} and cannot write to this folder yet. Give them access without changing its owner:\n` + @@ -2434,10 +2453,13 @@ async function doctor({ ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; const dockerResults = []; + const existingForDoctor = await loadConfig(root).catch(() => null); + const doctorEngine = existingForDoctor?.containerEngine === "podman" || process.env.SPACEAPP_CONTAINER_ENGINE === "podman" ? "podman" : "docker"; + const engineLabel = doctorEngine === "podman" ? "Podman" : "Docker"; for (const probe of [ - { name: "Docker CLI", command: "docker", args: ["--version"] }, - { name: "Docker Compose", command: "docker", args: ["compose", "version"] }, - { name: "Docker Engine", command: "docker", args: ["info"] } + { name: `${engineLabel} CLI`, command: doctorEngine, args: ["--version"] }, + { name: `${engineLabel} Compose`, command: doctorEngine, args: ["compose", "version"] }, + { name: `${engineLabel} Engine`, command: doctorEngine, args: ["info"] } ]) { const code = dockerReady ? 0 @@ -2655,6 +2677,7 @@ function spawnTrustedCommand(spec, options) { switch (spec.command) { case "codesign": return spawn("codesign", args, options); case "docker": return spawn("docker", args, options); + case "podman": return spawn("podman", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); case "powershell.exe": @@ -2791,6 +2814,7 @@ Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data] Remove containers; keep data by default Options: + --engine Select container engine (or set SPACEAPP_CONTAINER_ENGINE) --plan Dry-run mode: print actions without executing --json Output structured JSON where supported --log-file Append all execution logs to specified file From e018e1d2b06cb6fe7e5a06e3b9e92e866da3bcf1 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:35 +0700 Subject: [PATCH 5/8] test(run-spaceapp): add rootless Podman unit and lifecycle test suite (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/tests/podman.test.mjs | 240 ++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/run-spaceapp/tests/podman.test.mjs diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..9e40b5185 --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Readable, Writable } from "node:stream"; +import test from "node:test"; +import { + composeCommand, + createDefaultConfig, + loadConfig, + podmanUnshareChownArgs, + prepareInstallation, + resolveContainerEngine, + saveConfig, + validateConfig +} from "../src/index.mjs"; +import { + detectContainerEngine, + ensureDockerAvailable, + podmanCliAndComposeAreAvailable, + podmanIsReady +} from "../src/prerequisites.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; +import { run } from "../src/cli.mjs"; + +function capture() { + let value = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + } + }); + return { stream, value: () => value }; +} + +test("resolveContainerEngine and validateConfig support docker and podman", () => { + assert.equal(resolveContainerEngine(undefined, "docker"), "docker"); + assert.equal(resolveContainerEngine("auto", "podman"), "podman"); + assert.equal(resolveContainerEngine("podman", "docker"), "podman"); + assert.equal(resolveContainerEngine("docker", "podman"), "docker"); + assert.throws(() => resolveContainerEngine("containerd"), /auto, docker, or podman/i); + + const podmanConfig = createDefaultConfig({ + version: "1.1.8", + profile: "small", + containerEngine: "podman" + }); + assert.equal(podmanConfig.containerEngine, "podman"); + assert.equal(validateConfig(podmanConfig).containerEngine, "podman"); + assert.throws( + () => validateConfig({ ...podmanConfig, containerEngine: "rkt" }), + /containerEngine must be docker or podman/i + ); +}); + +test("composeCommand uses podman compose when containerEngine or engine is podman", () => { + const home = resolve(tmpdir(), "spaceapp-podman-home"); + const up = composeCommand("up", home, { + profile: "small", + containerEngine: "podman" + }); + assert.equal(up.command, "podman"); + assert.deepEqual(up.args.slice(0, 3), ["compose", "--project-name", up.args[2]]); + assert.deepEqual(up.args.slice(-3), ["up", "-d", "--remove-orphans"]); + + const status = composeCommand("status", home, { + profile: "small", + engine: "podman" + }); + assert.equal(status.command, "podman"); + assert.equal(status.args.at(-1), "ps"); + + const down = composeCommand("down", home, { + profile: "small", + containerEngine: "podman" + }); + assert.equal(down.command, "podman"); + assert.equal(down.args.at(-1), "down"); + + assert.throws( + () => composeCommand("up", home, { engine: "invalid" }), + /Unsupported container engine/i + ); +}); + +test("detectContainerEngine falls back to rootless Podman when Docker is absent", async () => { + const podmanOnlyExecute = async (spec) => { + if (spec.command === "docker") return 127; + if (spec.command === "podman") return 0; + return 127; + }; + assert.equal(await podmanCliAndComposeAreAvailable(podmanOnlyExecute), true); + assert.equal(await podmanIsReady(podmanOnlyExecute), true); + assert.equal( + await detectContainerEngine({ execute: podmanOnlyExecute, env: {} }), + "podman" + ); + + const dockerAndPodmanExecute = async (spec) => { + if (spec.command === "docker" || spec.command === "podman") return 0; + return 127; + }; + assert.equal( + await detectContainerEngine({ execute: dockerAndPodmanExecute, env: {} }), + "docker" + ); + assert.equal( + await detectContainerEngine({ + execute: dockerAndPodmanExecute, + env: { SPACEAPP_CONTAINER_ENGINE: "podman" } + }), + "podman" + ); +}); + +test("ensureDockerAvailable verifies rootless Podman readiness when podman is requested", async () => { + const stdout = capture(); + const stderr = capture(); + const calls = []; + const result = await ensureDockerAvailable({ + platform: "linux", + arch: "x64", + env: { SPACEAPP_CONTAINER_ENGINE: "podman" }, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + execute: async (spec) => { + calls.push(`${spec.command} ${spec.args.join(" ")}`); + return spec.command === "podman" ? 0 : 127; + } + }); + assert.deepEqual(result, { code: 0, reexecuted: false }); + assert.deepEqual(calls, [ + "podman --version", + "podman compose version", + "podman info" + ]); + assert.match(stdout.value(), /Rootless Podman engine is ready/i); + assert.equal(stderr.value(), ""); +}); + +test("podmanUnshareChownArgs builds rootless UID mapping arguments without shell execution", () => { + const home = resolve(tmpdir(), "spaceapp-workspace"); + assert.deepEqual(podmanUnshareChownArgs(home), [ + "unshare", + "chown", + "-R", + "10001:10001", + home + ]); + assert.throws(() => podmanUnshareChownArgs("relative/path"), /must be absolute/i); +}); + +test("inspectStoragePlan reads rootless Podman host info and graphRoot", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); + try { + const { config } = await prepareInstallation(root, { + version: "1.1.8", + profile: "small", + containerEngine: "podman" + }); + const commands = []; + const plan = await inspectStoragePlan({ + root, + config, + existingConfig: null, + resources: { + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }, + operation: "fresh", + execute: async (spec, io) => { + commands.push(spec.command); + if (spec.command === "podman" && spec.args[0] === "info") { + io.stdout?.write(JSON.stringify({ + host: { + arch: "amd64", + cpus: 6, + memTotal: 12 * 1024 ** 3, + security: { rootless: true } + }, + store: { graphRoot: root } + })); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "image") { + io.stdout?.write("sha256:" + "a".repeat(64) + "\n"); + return 0; + } + return 1; + } + }); + assert.ok(commands.every((cmd) => cmd === "podman")); + assert.equal(plan.resources.engineCpuCount, 6); + assert.equal(plan.resources.engineMemoryBytes, 12 * 1024 ** 3); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("CLI lifecycle (up -> status -> down) executes podman compose when configured for Podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-cli-")); + try { + const { config } = await prepareInstallation(root, { + version: "1.1.8", + profile: "small", + containerEngine: "podman" + }); + await saveConfig(root, config); + const loaded = await loadConfig(root); + assert.equal(loaded.containerEngine, "podman"); + + const executed = []; + for (const cmd of ["up", "status", "down"]) { + const stdout = capture(); + const stderr = capture(); + const code = await run([cmd], { + env: { SPACEAPP_HOME: root, SPACEAPP_IMAGE_VERIFY: "off" }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: Readable.from([]), + execute: async (spec) => { + executed.push({ command: spec.command, action: spec.args.at(-1) }); + return 0; + } + }); + assert.equal(code, 0); + } + assert.deepEqual(executed, [ + { command: "podman", action: "--remove-orphans" }, + { command: "podman", action: "ps" }, + { command: "podman", action: "down" } + ]); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); From d990f433d28ebe8141deba212988b4322f32b6c7 Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:37 +0700 Subject: [PATCH 6/8] ci(launcher): add rootless Podman job to platform matrix (#63) Signed-off-by: Gleb Nguyen --- .github/workflows/platform-matrix.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..588fd8f64 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,29 @@ jobs: - run: npm run check -w run-spaceapp - run: npm run pack:dry-run -w run-spaceapp - run: node packages/run-spaceapp/bin/spaceapp.mjs --version + + podman-rootless: + name: Launcher / ubuntu-latest / Rootless Podman + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.23.0" + cache: npm + + - name: Verify rootless Podman on runner + run: | + podman --version + podman info --format '{{json .Host.Security.Rootless}}' + + - run: npm ci + - name: Run Podman engine unit and lifecycle suite + env: + SPACEAPP_CONTAINER_ENGINE: podman + run: node --test packages/run-spaceapp/tests/podman.test.mjs + - run: npm run check -w run-spaceapp From 5c9423b821d8f3bcee6755cf86d91b9f0b970aaf Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:39 +0700 Subject: [PATCH 7/8] docs(run-spaceapp): document rootless Podman engine and UID mapping (#63) Signed-off-by: Gleb Nguyen --- packages/run-spaceapp/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..d45c32759 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -118,6 +118,18 @@ Host-root is supported only on Linux. Run `npx --yes run-spaceapp@latest install --access isolated` to remove the host mount while keeping SpaceApp data and volumes. +On Linux hosts with rootless Podman (`podman` and `podman compose`), the launcher automatically falls back to Podman when Docker is absent, or you can select it explicitly with `--engine podman` or `SPACEAPP_CONTAINER_ENGINE=podman`: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +For writable bind-mounted workspaces under rootless Podman, map the container CLI user (`10001:10001`) inside your user namespace without root privileges: + +```bash +podman unshare chown -R 10001:10001 /path/to/workspace +``` + Provider API keys are read from masked standard input and written to restrictive files outside Git. OAuth and device-code credentials remain in isolated Docker provider state. Neither credential source is included in From 73ea683c6ecbb289200b56d0278df68ec2c6598f Mon Sep 17 00:00:00 2001 From: Gleb Nguyen Date: Tue, 27 Oct 2026 12:47:42 +0700 Subject: [PATCH 8/8] docs(getting-started): add rootless Podman section for Linux (#63) Signed-off-by: Gleb Nguyen --- docs/getting-started.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..9317f3f90 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,6 +39,20 @@ starts the service. Before adding the current user to the `docker` group, it explains that the group grants root-level privileges, adds the user automatically, and continues in the same command. +### Rootless Podman on Linux + +On Fedora, RHEL, CentOS Stream, Debian, Ubuntu, and Arch-family hosts where rootless Podman (`podman` and `podman compose`) is installed instead of the rootful Docker daemon, `run-spaceapp` detects rootless Podman automatically or accepts `--engine podman` (`SPACEAPP_CONTAINER_ENGINE=podman`): + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +The selected `containerEngine` (`"docker"` or `"podman"`) is persisted in `config.json` and used across `up`, `down`, `status`, `logs`, `doctor`, `backup`, `restore`, `update`, and `uninstall`. When registering a writable workspace under rootless Podman, map UID/GID `10001:10001` in your user namespace without root: + +```bash +podman unshare chown -R 10001:10001 /path/to/workspace +``` + ## macOS Install Node.js 20.11 or newer, then run in Terminal: