diff --git a/docker-compose.api.yml b/docker-compose.api.yml index ee1c6af6..910129f8 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -35,7 +35,7 @@ services: - 1.1.1.1 volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - - docker_git_docker_data:/var/lib/docker + - /var/lib/docker:/var/lib/docker - /var/run/docker.sock:/var/run/docker.sock privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false} cgroup: host diff --git a/docker-compose.yml b/docker-compose.yml index ee1c6af6..910129f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: - 1.1.1.1 volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - - docker_git_docker_data:/var/lib/docker + - /var/lib/docker:/var/lib/docker - /var/run/docker.sock:/var/run/docker.sock privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false} cgroup: host diff --git a/docs/pr-screenshots/issue-365/skiller-dashboard.png b/docs/pr-screenshots/issue-365/skiller-dashboard.png new file mode 100644 index 00000000..08ef1557 Binary files /dev/null and b/docs/pr-screenshots/issue-365/skiller-dashboard.png differ diff --git a/docs/pr-screenshots/issue-365/skiller-projects-after-click.json b/docs/pr-screenshots/issue-365/skiller-projects-after-click.json new file mode 100644 index 00000000..7af55ee8 --- /dev/null +++ b/docs/pr-screenshots/issue-365/skiller-projects-after-click.json @@ -0,0 +1,11 @@ +{ + "click": { + "clicked": true, + "text": "Projects1", + "href": "#/projects" + }, + "page": { + "href": "http://127.0.0.1:45112/api/skiller/app/#/projects", + "text": "Import from Git\nImport from Local\nWORKSPACE\nDashboard\nAll Skills\n0\nMarketplace\nProjects\n1\nSettings\nProjects\nAdd project\n\napp\n\n/home/dev/app\n\napp\n/home/dev/app\nImport from Git\nImport Local\nCopy from installed\nSKILLS IN THIS PROJECT\n\nNo project-scoped skills yet.\n\nNo skills here yet. Copy one you already have installed, import from Git, or browse the Marketplace — use the buttons above or below.\n\nCopy from installed\nImport from Git\nBrowse Marketplace\nSYSTEM PROMPTS\n\nEdit the prompt files that Codex, Claude Code, and Gemini read from this container.\n\nProject system prompts\n\nFiles in this repository workspace.\n\nCodex\n\nnot created\n\n/home/dev/app/AGENTS.md\n\nDelete\nSave\n\nClaude Code\n\nnot created\n\n/home/dev/app/CLAUDE.md\n\nDelete\nSave\n\nGemini\n\nnot created\n\n/home/dev/app/GEMINI.md\n\nDelete\nSave\nGlobal system prompts\n\nFiles in the selected container home.\n\nCodex\n\nnot created\n\n/tmp/docker-git-skiller/0e9c63fe5287/home/.codex/AGENTS.md\n\nDelete\nSave\n\nClaude Code\n\nnot created\n\n/tmp/docker-git-skiller/" + } +} diff --git a/docs/pr-screenshots/issue-365/skiller-projects-after-click.png b/docs/pr-screenshots/issue-365/skiller-projects-after-click.png new file mode 100644 index 00000000..431632bc Binary files /dev/null and b/docs/pr-screenshots/issue-365/skiller-projects-after-click.png differ diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index df11fd31..726a05ab 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -121,6 +121,16 @@ RUN if [ "$DOCKER_GIT_CONTROLLER_BUILD_SKILLER" = "1" ]; then \ rm -rf /root/.bun/install/cache node_modules; \ sleep $((attempt * 2)); \ done \ + && electron_zip="$(find "${electron_config_cache:-/root/.cache/electron}" -name 'electron-v*-linux-*.zip' -print -quit)" \ + && if [ -z "$electron_zip" ]; then echo "Electron zip not found in cache: ${electron_config_cache:-/root/.cache/electron}" >&2; exit 1; fi \ + && unzip -Z1 "$electron_zip" > /tmp/electron-zip-entries \ + && if grep -Eq '(^/|(^|/)\.\.($|/))' /tmp/electron-zip-entries; then echo "Unsafe paths in Electron zip: $electron_zip" >&2; exit 1; fi \ + && rm -f /tmp/electron-zip-entries \ + && rm -rf node_modules/electron/dist node_modules/electron/path.txt \ + && mkdir -p node_modules/electron/dist \ + && unzip -q "$electron_zip" -d node_modules/electron/dist \ + && printf '%s' electron > node_modules/electron/path.txt \ + && test -x node_modules/electron/dist/electron \ && bun run build \ && touch out/.docker-git-browser-folder-picker.patch \ && mkdir -p out/preload \ diff --git a/packages/api/README.md b/packages/api/README.md index 1b1d6f91..c97ce007 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -12,14 +12,16 @@ This is now the intended controller plane: ## Runtime contract: host-Docker-backed `docker-git` is host-Docker-backed by default. The primary controller -container created from this package binds the host socket -(`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and -uses it to spawn per-project containers. `DOCKER_GIT_DOCKER_RUNTIME=isolated` -is an opt-in fallback for environments that explicitly require an embedded -controller daemon. In isolated mode, start the controller through the host CLI -or include `docker-compose.isolated.yml`; that overlay removes the host socket -bind and defaults project containers to the embedded daemon endpoint -`tcp://host.docker.internal:2375`. +container created from this package binds the host socket and Docker data root +(`/var/run/docker.sock:/var/run/docker.sock` and +`/var/lib/docker:/var/lib/docker`, see `docker-compose.yml`) and uses them to +spawn per-project containers and access the Docker volume paths reported by +`docker inspect`. `DOCKER_GIT_DOCKER_RUNTIME=isolated` is an opt-in fallback for +environments that explicitly require an embedded controller daemon. In isolated +mode, start the controller through the host CLI or include +`docker-compose.isolated.yml`; that overlay removes the host socket bind, keeps +Docker data inside the controller volume, and defaults project containers to the +embedded daemon endpoint `tcp://host.docker.internal:2375`. Security note: binding `/var/run/docker.sock` gives the controller container root-equivalent control over the host Docker daemon, including the ability to diff --git a/packages/api/src/services/skiller.ts b/packages/api/src/services/skiller.ts index eadb7e6b..a8b49450 100644 --- a/packages/api/src/services/skiller.ts +++ b/packages/api/src/services/skiller.ts @@ -1,5 +1,5 @@ -import { spawn, type ChildProcess } from "node:child_process" -import { chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs" +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" +import { chmodSync, chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs" import { createServer } from "node:net" import { homedir } from "node:os" import { dirname, join, resolve } from "node:path" @@ -49,11 +49,25 @@ type SkillerProcess = { readonly trpcPort: number } -type SkillerProcessUser = { +export type SkillerProcessUser = { readonly gid: number readonly uid: number } +export type SkillerLaunchCommand = { + readonly args: ReadonlyArray + readonly command: string + readonly groupName?: string + readonly gid?: number + readonly uid?: number + readonly userName?: string +} + +type SkillerProcessAccount = SkillerProcessUser & { + readonly groupName: string + readonly userName: string +} + export type SkillerRoute = | { readonly _tag: "App"; readonly relativePath: string; readonly sessionId: string | null } | { readonly _tag: "Trpc"; readonly sessionId: string | null; readonly upstreamPath: string } @@ -174,6 +188,101 @@ const sleep = (durationMs: number): Promise => setTimeout(resolve, durationMs) }) +const defaultRunProcessTimeoutMs = 300_000 + +export class SkillerProcessTimeoutError extends Error { + readonly args: ReadonlyArray + readonly command: string + override readonly name = "SkillerProcessTimeoutError" + readonly timeoutMs: number + + constructor( + command: string, + args: ReadonlyArray, + timeoutMs: number, + cause?: Error + ) { + super( + `${command} ${args.join(" ")} timed out after ${timeoutMs}ms.`, + cause === undefined ? undefined : { cause } + ) + this.args = args + this.command = command + this.timeoutMs = timeoutMs + } +} + +export const runProcess = ( + command: string, + args: ReadonlyArray, + options: SpawnOptions = {}, + timeoutMs: number = defaultRunProcessTimeoutMs +): Promise => + new Promise((resolve, reject) => { + const timeoutController = new AbortController() + let settled = false + let timer: ReturnType | null = null + let timeoutError: SkillerProcessTimeoutError | null = null + + const child = spawn(command, [...args], { ...options, signal: timeoutController.signal }) + + const cleanup = (): void => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + child.off("error", onError) + child.off("exit", onExit) + } + + const resolveOnce = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + + const onError = (error: Error): void => { + rejectOnce( + timeoutError === null + ? error + : new SkillerProcessTimeoutError(command, args, timeoutMs, error) + ) + } + + const onExit = (exitCode: number | null, signal: string | null): void => { + if (timeoutError !== null) { + rejectOnce(timeoutError) + return + } + if (exitCode === 0) { + resolveOnce() + return + } + const reason = exitCode === null ? `signal ${signal}` : `exit code ${exitCode}` + rejectOnce(new Error(`${command} ${args.join(" ")} failed with ${reason}.`)) + } + + child.once("error", onError) + child.once("exit", onExit) + timer = setTimeout(() => { + timeoutError = new SkillerProcessTimeoutError(command, args, timeoutMs) + timeoutController.abort(timeoutError) + child.kill("SIGTERM") + }, timeoutMs) + }) + const containerHomePath = (sshUser: string): string => `/home/${sshUser}` const inspectContainerMounts = ( @@ -285,7 +394,7 @@ const waitForSkillerReady = (trpcPort: number): Effect.Effect/dev/null 2>&1; then", - " exec xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox out/main/index.js", + ` exec xvfb-run -a ./node_modules/electron/dist/electron ${electronLaunchFlags} out/main/index.js`, "fi", - "exec ./node_modules/electron/dist/electron --no-sandbox out/main/index.js" + `exec ./node_modules/electron/dist/electron ${electronLaunchFlags} out/main/index.js` ].join("\n") +const skillerXdgRoot = (scope: SkillerContainerScope): string => + join(scope.hostHomePath, ".docker-git", "skiller") + +const safeRuntimeKey = (value: string): string => + value.replace(/[^A-Za-z0-9._-]/gu, "_") + +const skillerRuntimeBase = "/tmp/docker-git-skiller" + +const skillerRuntimeRoot = (scope: SkillerContainerScope): string => + join(skillerRuntimeBase, safeRuntimeKey(scope.projectKey)) + +const dockerVolumeRoot = "/var/lib/docker/volumes" + const skillerHomeEnv = ( - scope: SkillerContainerScope | null + scope: SkillerContainerScope | null, + processUserName?: string ): Record => scope === null ? {} - : { - DOCKER_GIT_SKILLER_CONTAINER_HOME_PATH: scope.containerHomePath, - DOCKER_GIT_SKILLER_HOST_ENV_GLOBAL_PATH: scope.hostEnvGlobalPath, - HOME: scope.hostHomePath, - USER: scope.sshUser, - XDG_CACHE_HOME: join(scope.hostHomePath, ".cache"), - XDG_CONFIG_HOME: join(scope.hostHomePath, ".config"), - XDG_DATA_HOME: join(scope.hostHomePath, ".local", "share") - } + : (() => { + const runtimeRoot = skillerRuntimeRoot(scope) + const userName = processUserName ?? scope.sshUser + return { + DOCKER_GIT_SKILLER_CONTAINER_HOME_PATH: scope.containerHomePath, + DOCKER_GIT_SKILLER_HOST_ENV_GLOBAL_PATH: scope.hostEnvGlobalPath, + HOME: join(runtimeRoot, "home"), + LOGNAME: userName, + USER: userName, + XDG_CACHE_HOME: join(runtimeRoot, "cache"), + XDG_CONFIG_HOME: join(runtimeRoot, "config"), + XDG_DATA_HOME: join(runtimeRoot, "data"), + XDG_RUNTIME_DIR: join(runtimeRoot, "runtime") + } + })() const scopedProcessUser = ( scope: SkillerContainerScope | null @@ -328,12 +471,45 @@ const scopedProcessUser = ( return { gid: stats.gid, uid: stats.uid } } -const ensureOwnedDirectory = (path: string, user: SkillerProcessUser): void => { - mkdirSync(path, { recursive: true }) +const ensureOwnedDirectory = (path: string, user: SkillerProcessUser, mode?: number): void => { + mkdirSync(path, { mode, recursive: true }) const stats = statSync(path) if (stats.uid !== user.uid || stats.gid !== user.gid) { chownSync(path, user.uid, user.gid) } + if (mode !== undefined) { + chmodSync(path, mode) + } +} + +const ensureDirectoryMode = (path: string, mode: number): void => { + mkdirSync(path, { mode, recursive: true }) + chmodSync(path, mode) +} + +const ensureOtherExecute = (path: string): void => { + const stats = statSync(path) + const mode = stats.mode & 0o7777 + if (stats.isDirectory() && (mode & 0o001) === 0) { + chmodSync(path, mode | 0o001) + } +} + +const ensureKnownDockerVolumeTraverse = (path: string): void => { + const normalizedRoot = `${dockerVolumeRoot}/` + if (!path.startsWith(normalizedRoot)) { + return + } + // Docker data dirs are often 0710 root:root; non-root Skiller only needs + // execute on known ancestors to stat an already-selected project path. + let current = "/var/lib/docker" + ensureOtherExecute(current) + for (const part of path.slice(current.length + 1).split("/").slice(0, -1)) { + current = join(current, part) + if (existsSync(current)) { + ensureOtherExecute(current) + } + } } const chownIfExists = (path: string, user: SkillerProcessUser): void => { @@ -358,27 +534,160 @@ const prepareSkillerScopeHome = (scope: SkillerContainerScope | null): SkillerPr ensureOwnedDirectory(join(scope.hostHomePath, ".cache"), processUser) ensureOwnedDirectory(join(scope.hostHomePath, ".local", "share"), processUser) ensureOwnedDirectory(join(scope.hostHomePath, ".skiller"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".docker-git"), processUser) + ensureOwnedDirectory(skillerXdgRoot(scope), processUser) + ensureOwnedDirectory(join(skillerXdgRoot(scope), "cache"), processUser) + ensureOwnedDirectory(join(skillerXdgRoot(scope), "config"), processUser) + ensureOwnedDirectory(join(skillerXdgRoot(scope), "data"), processUser) + ensureOwnedDirectory(join(skillerXdgRoot(scope), "runtime"), processUser, 0o700) + ensureDirectoryMode(skillerRuntimeBase, 0o711) + ensureOwnedDirectory(skillerRuntimeRoot(scope), processUser, 0o700) + ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "home"), processUser, 0o700) + ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "cache"), processUser, 0o700) + ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "config"), processUser, 0o700) + ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "data"), processUser, 0o700) + ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "runtime"), processUser, 0o700) + ensureKnownDockerVolumeTraverse(scope.hostHomePath) + ensureKnownDockerVolumeTraverse(scope.hostCodexSkillsPath) + ensureKnownDockerVolumeTraverse(scope.hostProjectPath) chownIfExists(join(scope.hostHomePath, ".codex", "config.toml"), processUser) chownIfExists(join(scope.hostHomePath, ".skiller", "config.toml"), processUser) return processUser } -const skillerLaunchCommand = ( +const nameForId = ( + contents: string, + id: number, + idFieldIndex: number +): string | null => { + for (const line of contents.split(/\r?\n/u)) { + const fields = line.split(":") + const name = fields[0] + const rawId = fields[idFieldIndex] + if (name === undefined || rawId === undefined) { + continue + } + if (Number.parseInt(rawId, 10) === id) { + return name + } + } + return null +} + +const localUserNameForUid = (uid: number): string | null => + nameForId(readFileSync("/etc/passwd", "utf8"), uid, 2) + +const localGroupNameForGid = (gid: number): string | null => + nameForId(readFileSync("/etc/group", "utf8"), gid, 2) + +const skillerUserNameForUid = (uid: number): string => `dg-skiller-u${uid}` + +const skillerGroupNameForGid = (gid: number): string => `dg-skiller-g${gid}` + +const resolveSkillerProcessAccount = (user: SkillerProcessUser): SkillerProcessAccount => { + if (user.uid === 0 || user.gid === 0) { + throw new Error("Refusing to launch scoped Skiller as root; selected container home is root-owned.") + } + const userName = localUserNameForUid(user.uid) ?? skillerUserNameForUid(user.uid) + const groupName = localGroupNameForGid(user.gid) ?? skillerGroupNameForGid(user.gid) + return { ...user, groupName, userName } +} + +const ensureLocalGroup = async (gid: number, groupName: string): Promise => { + if (localGroupNameForGid(gid) !== null) { + return + } + try { + await runProcess("groupadd", ["--gid", String(gid), groupName]) + } catch (error) { + if (localGroupNameForGid(gid) !== null) { + return + } + throw error + } + if (localGroupNameForGid(gid) === null) { + throw new Error(`Cannot launch scoped Skiller: failed to create local group entry for GID ${gid}.`) + } +} + +const ensureLocalUser = async ( + user: SkillerProcessUser, + userName: string, + groupName: string +): Promise => { + if (localUserNameForUid(user.uid) !== null) { + return + } + try { + await runProcess("useradd", [ + "--uid", + String(user.uid), + "--gid", + groupName, + "--no-create-home", + "--home-dir", + "/nonexistent", + "--shell", + "/bin/false", + userName + ]) + } catch (error) { + if (localUserNameForUid(user.uid) !== null) { + return + } + throw error + } + if (localUserNameForUid(user.uid) === null) { + throw new Error(`Cannot launch scoped Skiller: failed to create local passwd entry for UID ${user.uid}.`) + } +} + +const ensureSkillerProcessAccount = async ( user: SkillerProcessUser | null -): readonly [string, ReadonlyArray] => - user === null - ? ["bash", ["-lc", launchScript]] - : [ - "setpriv", - [ - `--reuid=${user.uid}`, - `--regid=${user.gid}`, - "--clear-groups", - "bash", - "-lc", - launchScript - ] - ] +): Promise => { + if (user === null) { + return null + } + const account = resolveSkillerProcessAccount(user) + await ensureLocalGroup(account.gid, account.groupName) + await ensureLocalUser(account, account.userName, account.groupName) + return resolveSkillerProcessAccount(user) +} + +export const skillerLaunchCommand = ( + user: SkillerProcessUser | null, + resolveAccount: (user: SkillerProcessUser) => SkillerProcessAccount = resolveSkillerProcessAccount +): SkillerLaunchCommand => { + if (user === null) { + return { args: ["-c", electronLaunchScript], command: "bash" } + } + const account = resolveAccount(user) + return { + args: [ + "--preserve-environment", + "-u", + account.userName, + "-g", + account.groupName, + "--", + "bash", + "-c", + electronLaunchScript + ], + command: "runuser", + gid: account.gid, + groupName: account.groupName, + uid: account.uid, + userName: account.userName + } +} + +const prepareSkillerRuntime = (skillerDir: string, logFd: number): Promise => + runProcess("bash", ["-c", prepareLaunchScript], { + cwd: skillerDir, + env: process.env, + stdio: ["ignore", logFd, logFd] + }) const stopSkillerProcess = (process: SkillerProcess): void => { const pid = process.process.pid @@ -416,24 +725,28 @@ const registerSkillerProject = ( } }) -const launchSkillerProcess = ( +const launchSkillerProcess = async ( skillerDir: string, trpcPort: number, scope: SkillerContainerScope | null -): SkillerLaunch => { +): Promise => { mkdirSync(dirname(launchLogPath), { recursive: true }) const processUser = prepareSkillerScopeHome(scope) const logFd = openSync(launchLogPath, "a") try { - const [command, args] = skillerLaunchCommand(processUser) - const child = spawn(command, args, { + const processAccount = await ensureSkillerProcessAccount(processUser) + await prepareSkillerRuntime(skillerDir, logFd) + const launchCommand = processAccount === null + ? skillerLaunchCommand(null) + : skillerLaunchCommand(processAccount, () => processAccount) + const child = spawn(launchCommand.command, launchCommand.args, { cwd: skillerDir, detached: true, env: { ...process.env, AGENTSKILLS_TRPC_PORT: String(trpcPort), ELECTRON_ENABLE_LOGGING: "1", - ...skillerHomeEnv(scope) + ...skillerHomeEnv(scope, launchCommand.userName) }, stdio: ["ignore", logFd, logFd] }) @@ -519,7 +832,7 @@ export const openSkiller = ( }), try: () => findAvailablePort(skillerPreferredTrpcPort) })) - const launch = yield* _(Effect.try({ + const launch = yield* _(Effect.tryPromise({ catch: (cause) => new ApiInternalError({ message: "Failed to launch Skiller.", cause diff --git a/packages/api/tests/skiller-routes.test.ts b/packages/api/tests/skiller-routes.test.ts index ce58ae1d..da7ec13d 100644 --- a/packages/api/tests/skiller-routes.test.ts +++ b/packages/api/tests/skiller-routes.test.ts @@ -4,6 +4,9 @@ import { parseSkillerRoute, resolveSkillerBrowserScopeSelection, resolveSkillerRouteScopeSelection, + runProcess, + SkillerProcessTimeoutError, + skillerLaunchCommand, type SkillerRoute } from "../src/services/skiller.js" import type { SkillerContainerScope } from "../src/services/skiller-core.js" @@ -31,6 +34,56 @@ const scope = (projectKey: string): SkillerContainerScope => ({ }) describe("skiller routes", () => { + it("launches Electron through the Skiller launch script", () => { + const launch = skillerLaunchCommand(null) + const launchCommand = launch.args.join("\n") + + expect(launch.command).toBe("bash") + expect(launch.args).toContain("-c") + expect(launchCommand).toContain("xvfb-run -a ./node_modules/electron/dist/electron") + expect(launchCommand).toContain("exec ./node_modules/electron/dist/electron") + expect(launchCommand).toContain("--disable-dev-shm-usage") + }) + + it("launches scoped Skiller with the selected home owner credentials", () => { + const launch = skillerLaunchCommand( + { gid: 1000, uid: 1000 }, + (user) => ({ ...user, groupName: "ubuntu", userName: "ubuntu" }) + ) + + expect(launch.command).toBe("runuser") + expect(launch.args).toEqual(expect.arrayContaining([ + "--preserve-environment", + "-u", + "ubuntu", + "-g", + "ubuntu", + "--", + "bash", + "-c" + ])) + expect(launch.gid).toBe(1000) + expect(launch.groupName).toBe("ubuntu") + expect(launch.uid).toBe(1000) + expect(launch.userName).toBe("ubuntu") + }) + + it("uses deterministic scoped account names for missing local UID and GID entries", () => { + const launch = skillerLaunchCommand({ gid: 2_147_483_002, uid: 2_147_483_001 }) + + expect(launch.command).toBe("runuser") + expect(launch.groupName).toBe("dg-skiller-g2147483002") + expect(launch.userName).toBe("dg-skiller-u2147483001") + }) + + it("fails stalled child processes with a distinct timeout error", () => + expect(runProcess( + process.execPath, + ["-e", "setTimeout(() => undefined, 1_000)"], + {}, + 25 + )).rejects.toBeInstanceOf(SkillerProcessTimeoutError)) + it("keeps the terminal session id on session-scoped app routes", () => { expect(parseSkillerRoute("/api/ssh/session/terminal-proof/skiller/app/")).toEqual({ _tag: "App", diff --git a/packages/app/tests/docker-git/controller-resource-limits.test.ts b/packages/app/tests/docker-git/controller-resource-limits.test.ts index c5e7bdae..9b03314a 100644 --- a/packages/app/tests/docker-git/controller-resource-limits.test.ts +++ b/packages/app/tests/docker-git/controller-resource-limits.test.ts @@ -18,6 +18,8 @@ import { const composeFiles: ReadonlyArray = ["docker-compose.yml", "docker-compose.api.yml"] const isolatedComposeFiles: ReadonlyArray = ["docker-compose.isolated.yml", "docker-compose.api.isolated.yml"] +const hostDockerDataBind = "/var/lib/docker:/var/lib/docker" +const isolatedDockerDataVolume = "docker_git_docker_data:/var/lib/docker" const readComposeFile = (relativePath: string): Effect.Effect => Effect.gen(function*(_) { @@ -50,6 +52,13 @@ describe("controller compose resource limits", () => { const contents = yield* _(readComposeFile(composeFile)) expect(contents).toMatch(/pids_limit: \$\{DOCKER_GIT_CONTROLLER_PIDS:-\d+\}/u) })) + + it.effect("binds host Docker data root for host runtime volume path access", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toContain(`- ${hostDockerDataBind}`) + expect(contents).not.toContain(`- ${isolatedDockerDataVolume}`) + })) }) } @@ -75,10 +84,30 @@ describe("controller compose resource limits", () => { const contents = yield* _(readComposeFile(composeFile)) expect(contents).toContain("privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-true}") })) + + it.effect("keeps Docker data inside the embedded controller daemon volume", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toContain(`- ${isolatedDockerDataVolume}`) + expect(contents).not.toContain(`- ${hostDockerDataBind}`) + })) }) } }) +describe("API Dockerfile Electron materialization", () => { + it.effect("materializes Electron binary before bundling Skiller", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile("packages/api/Dockerfile")) + expect(contents).toMatch(/electron_zip="\$\(find "\$\{electron_config_cache:-\/root\/\.cache\/electron\}"/u) + expect(contents).toMatch(/Electron zip not found in cache/u) + expect(contents).toMatch(/unzip -Z1 "\$electron_zip"/u) + expect(contents).toMatch(/Unsafe paths in Electron zip/u) + expect(contents).toMatch(/unzip -q "\$electron_zip" -d node_modules\/electron\/dist/u) + expect(contents).toMatch(/test -x node_modules\/electron\/dist\/electron/u) + })) +}) + describe("controller resource limit resolution", () => { it.effect("resolves CPU and RAM defaults to 90% of host resources", () => Effect.sync(() => {