Skip to content

Commit 3d4e7d5

Browse files
committed
fix(api): run scoped skiller as project user
1 parent 1076ed0 commit 3d4e7d5

5 files changed

Lines changed: 232 additions & 30 deletions

File tree

90.3 KB
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"click": {
3+
"clicked": true,
4+
"text": "Projects1",
5+
"href": "#/projects"
6+
},
7+
"page": {
8+
"href": "http://127.0.0.1:45112/api/skiller/app/#/projects",
9+
"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/"
10+
}
11+
}
90 KB
Loading

packages/api/src/services/skiller.ts

Lines changed: 193 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { spawn, type ChildProcess } from "node:child_process"
2-
import { chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs"
1+
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
2+
import { chmodSync, chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs"
33
import { createServer } from "node:net"
44
import { homedir } from "node:os"
55
import { dirname, join, resolve } from "node:path"
@@ -49,11 +49,25 @@ type SkillerProcess = {
4949
readonly trpcPort: number
5050
}
5151

52-
type SkillerProcessUser = {
52+
export type SkillerProcessUser = {
5353
readonly gid: number
5454
readonly uid: number
5555
}
5656

57+
export type SkillerLaunchCommand = {
58+
readonly args: ReadonlyArray<string>
59+
readonly command: string
60+
readonly groupName?: string
61+
readonly gid?: number
62+
readonly uid?: number
63+
readonly userName?: string
64+
}
65+
66+
type SkillerProcessAccount = SkillerProcessUser & {
67+
readonly groupName: string
68+
readonly userName: string
69+
}
70+
5771
export type SkillerRoute =
5872
| { readonly _tag: "App"; readonly relativePath: string; readonly sessionId: string | null }
5973
| { readonly _tag: "Trpc"; readonly sessionId: string | null; readonly upstreamPath: string }
@@ -285,7 +299,7 @@ const waitForSkillerReady = (trpcPort: number): Effect.Effect<void, ApiInternalE
285299
}
286300
})
287301

288-
const launchScript = [
302+
const prepareLaunchScript = [
289303
"set -euo pipefail",
290304
"DOCKER_GIT_SKILLER_PATCH=../../patches/skiller/docker-git-browser-folder-picker.patch",
291305
"DOCKER_GIT_SKILLER_PATCH_MARKER=out/.docker-git-browser-folder-picker.patch",
@@ -296,27 +310,58 @@ const launchScript = [
296310
" mkdir -p out",
297311
" touch \"$DOCKER_GIT_SKILLER_PATCH_MARKER\"",
298312
"fi",
299-
"if [ ! -e out/preload/index.js ]; then ln -sf index.mjs out/preload/index.js; fi",
313+
"if [ ! -e out/preload/index.js ]; then ln -sf index.mjs out/preload/index.js; fi"
314+
].join("\n")
315+
316+
const electronLaunchFlags = [
317+
"--no-sandbox",
318+
"--disable-dev-shm-usage",
319+
"--disable-gpu",
320+
"--no-zygote"
321+
].join(" ")
322+
323+
const electronLaunchScript = [
324+
"set -euo pipefail",
300325
"if [ -z \"${DISPLAY:-}\" ] && command -v xvfb-run >/dev/null 2>&1; then",
301-
" exec xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox out/main/index.js",
326+
` exec xvfb-run -a ./node_modules/electron/dist/electron ${electronLaunchFlags} out/main/index.js`,
302327
"fi",
303-
"exec ./node_modules/electron/dist/electron --no-sandbox out/main/index.js"
328+
`exec ./node_modules/electron/dist/electron ${electronLaunchFlags} out/main/index.js`
304329
].join("\n")
305330

331+
const skillerXdgRoot = (scope: SkillerContainerScope): string =>
332+
join(scope.hostHomePath, ".docker-git", "skiller")
333+
334+
const safeRuntimeKey = (value: string): string =>
335+
value.replace(/[^A-Za-z0-9._-]/gu, "_")
336+
337+
const skillerRuntimeBase = "/tmp/docker-git-skiller"
338+
339+
const skillerRuntimeRoot = (scope: SkillerContainerScope): string =>
340+
join(skillerRuntimeBase, safeRuntimeKey(scope.projectKey))
341+
342+
const dockerVolumeRoot = "/var/lib/docker/volumes"
343+
306344
const skillerHomeEnv = (
307-
scope: SkillerContainerScope | null
345+
scope: SkillerContainerScope | null,
346+
processUserName?: string
308347
): Record<string, string> =>
309348
scope === null
310349
? {}
311-
: {
312-
DOCKER_GIT_SKILLER_CONTAINER_HOME_PATH: scope.containerHomePath,
313-
DOCKER_GIT_SKILLER_HOST_ENV_GLOBAL_PATH: scope.hostEnvGlobalPath,
314-
HOME: scope.hostHomePath,
315-
USER: scope.sshUser,
316-
XDG_CACHE_HOME: join(scope.hostHomePath, ".cache"),
317-
XDG_CONFIG_HOME: join(scope.hostHomePath, ".config"),
318-
XDG_DATA_HOME: join(scope.hostHomePath, ".local", "share")
319-
}
350+
: (() => {
351+
const runtimeRoot = skillerRuntimeRoot(scope)
352+
const userName = processUserName ?? scope.sshUser
353+
return {
354+
DOCKER_GIT_SKILLER_CONTAINER_HOME_PATH: scope.containerHomePath,
355+
DOCKER_GIT_SKILLER_HOST_ENV_GLOBAL_PATH: scope.hostEnvGlobalPath,
356+
HOME: join(runtimeRoot, "home"),
357+
LOGNAME: userName,
358+
USER: userName,
359+
XDG_CACHE_HOME: join(runtimeRoot, "cache"),
360+
XDG_CONFIG_HOME: join(runtimeRoot, "config"),
361+
XDG_DATA_HOME: join(runtimeRoot, "data"),
362+
XDG_RUNTIME_DIR: join(runtimeRoot, "runtime")
363+
}
364+
})()
320365

321366
const scopedProcessUser = (
322367
scope: SkillerContainerScope | null
@@ -328,12 +373,45 @@ const scopedProcessUser = (
328373
return { gid: stats.gid, uid: stats.uid }
329374
}
330375

331-
const ensureOwnedDirectory = (path: string, user: SkillerProcessUser): void => {
332-
mkdirSync(path, { recursive: true })
376+
const ensureOwnedDirectory = (path: string, user: SkillerProcessUser, mode?: number): void => {
377+
mkdirSync(path, { mode, recursive: true })
333378
const stats = statSync(path)
334379
if (stats.uid !== user.uid || stats.gid !== user.gid) {
335380
chownSync(path, user.uid, user.gid)
336381
}
382+
if (mode !== undefined) {
383+
chmodSync(path, mode)
384+
}
385+
}
386+
387+
const ensureDirectoryMode = (path: string, mode: number): void => {
388+
mkdirSync(path, { mode, recursive: true })
389+
chmodSync(path, mode)
390+
}
391+
392+
const ensureOtherExecute = (path: string): void => {
393+
const stats = statSync(path)
394+
const mode = stats.mode & 0o7777
395+
if (stats.isDirectory() && (mode & 0o001) === 0) {
396+
chmodSync(path, mode | 0o001)
397+
}
398+
}
399+
400+
const ensureKnownDockerVolumeTraverse = (path: string): void => {
401+
const normalizedRoot = `${dockerVolumeRoot}/`
402+
if (!path.startsWith(normalizedRoot)) {
403+
return
404+
}
405+
// Docker data dirs are often 0710 root:root; non-root Skiller only needs
406+
// execute on known ancestors to stat an already-selected project path.
407+
let current = "/var/lib/docker"
408+
ensureOtherExecute(current)
409+
for (const part of path.slice(current.length + 1).split("/").slice(0, -1)) {
410+
current = join(current, part)
411+
if (existsSync(current)) {
412+
ensureOtherExecute(current)
413+
}
414+
}
337415
}
338416

339417
const chownIfExists = (path: string, user: SkillerProcessUser): void => {
@@ -358,15 +436,102 @@ const prepareSkillerScopeHome = (scope: SkillerContainerScope | null): SkillerPr
358436
ensureOwnedDirectory(join(scope.hostHomePath, ".cache"), processUser)
359437
ensureOwnedDirectory(join(scope.hostHomePath, ".local", "share"), processUser)
360438
ensureOwnedDirectory(join(scope.hostHomePath, ".skiller"), processUser)
439+
ensureOwnedDirectory(join(scope.hostHomePath, ".docker-git"), processUser)
440+
ensureOwnedDirectory(skillerXdgRoot(scope), processUser)
441+
ensureOwnedDirectory(join(skillerXdgRoot(scope), "cache"), processUser)
442+
ensureOwnedDirectory(join(skillerXdgRoot(scope), "config"), processUser)
443+
ensureOwnedDirectory(join(skillerXdgRoot(scope), "data"), processUser)
444+
ensureOwnedDirectory(join(skillerXdgRoot(scope), "runtime"), processUser, 0o700)
445+
ensureDirectoryMode(skillerRuntimeBase, 0o711)
446+
ensureOwnedDirectory(skillerRuntimeRoot(scope), processUser, 0o700)
447+
ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "home"), processUser, 0o700)
448+
ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "cache"), processUser, 0o700)
449+
ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "config"), processUser, 0o700)
450+
ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "data"), processUser, 0o700)
451+
ensureOwnedDirectory(join(skillerRuntimeRoot(scope), "runtime"), processUser, 0o700)
452+
ensureKnownDockerVolumeTraverse(scope.hostHomePath)
453+
ensureKnownDockerVolumeTraverse(scope.hostCodexSkillsPath)
454+
ensureKnownDockerVolumeTraverse(scope.hostProjectPath)
361455
chownIfExists(join(scope.hostHomePath, ".codex", "config.toml"), processUser)
362456
chownIfExists(join(scope.hostHomePath, ".skiller", "config.toml"), processUser)
363457
return processUser
364458
}
365459

366-
// Electron aborts under setpriv in the controller image even with --no-sandbox.
367-
// Project scope still comes from explicit host paths and the browser bootstrap.
368-
export const skillerLaunchCommand = (): readonly [string, ReadonlyArray<string>] =>
369-
["bash", ["-lc", launchScript]]
460+
const nameForId = (
461+
contents: string,
462+
id: number,
463+
idFieldIndex: number
464+
): string | null => {
465+
for (const line of contents.split(/\r?\n/u)) {
466+
const fields = line.split(":")
467+
const name = fields[0]
468+
const rawId = fields[idFieldIndex]
469+
if (name === undefined || rawId === undefined) {
470+
continue
471+
}
472+
if (Number.parseInt(rawId, 10) === id) {
473+
return name
474+
}
475+
}
476+
return null
477+
}
478+
479+
const resolveSkillerProcessAccount = (user: SkillerProcessUser): SkillerProcessAccount => {
480+
if (user.uid === 0 || user.gid === 0) {
481+
throw new Error("Refusing to launch scoped Skiller as root; selected container home is root-owned.")
482+
}
483+
const userName = nameForId(readFileSync("/etc/passwd", "utf8"), user.uid, 2)
484+
if (userName === null) {
485+
throw new Error(`Cannot launch scoped Skiller: no local passwd entry for UID ${user.uid}.`)
486+
}
487+
const groupName = nameForId(readFileSync("/etc/group", "utf8"), user.gid, 2)
488+
if (groupName === null) {
489+
throw new Error(`Cannot launch scoped Skiller: no local group entry for GID ${user.gid}.`)
490+
}
491+
return { ...user, groupName, userName }
492+
}
493+
494+
export const skillerLaunchCommand = (
495+
user: SkillerProcessUser | null,
496+
resolveAccount: (user: SkillerProcessUser) => SkillerProcessAccount = resolveSkillerProcessAccount
497+
): SkillerLaunchCommand => {
498+
if (user === null) {
499+
return { args: ["-c", electronLaunchScript], command: "bash" }
500+
}
501+
const account = resolveAccount(user)
502+
return {
503+
args: [
504+
"--preserve-environment",
505+
"-u",
506+
account.userName,
507+
"-g",
508+
account.groupName,
509+
"--",
510+
"bash",
511+
"-c",
512+
electronLaunchScript
513+
],
514+
command: "runuser",
515+
gid: account.gid,
516+
groupName: account.groupName,
517+
uid: account.uid,
518+
userName: account.userName
519+
}
520+
}
521+
522+
const prepareSkillerRuntime = (skillerDir: string, logFd: number): void => {
523+
const result = spawnSync("bash", ["-c", prepareLaunchScript], {
524+
cwd: skillerDir,
525+
env: process.env,
526+
stdio: ["ignore", logFd, logFd]
527+
})
528+
if (result.error !== undefined) {
529+
throw result.error
530+
}
531+
if (result.status !== 0) {
532+
throw new Error(`Skiller runtime preparation failed with exit code ${result.status ?? `signal ${result.signal}`}.`)
533+
}
534+
}
370535

371536
const stopSkillerProcess = (process: SkillerProcess): void => {
372537
const pid = process.process.pid
@@ -410,18 +575,19 @@ const launchSkillerProcess = (
410575
scope: SkillerContainerScope | null
411576
): SkillerLaunch => {
412577
mkdirSync(dirname(launchLogPath), { recursive: true })
413-
prepareSkillerScopeHome(scope)
578+
const processUser = prepareSkillerScopeHome(scope)
414579
const logFd = openSync(launchLogPath, "a")
415580
try {
416-
const [command, args] = skillerLaunchCommand()
417-
const child = spawn(command, args, {
581+
prepareSkillerRuntime(skillerDir, logFd)
582+
const launchCommand = skillerLaunchCommand(processUser)
583+
const child = spawn(launchCommand.command, launchCommand.args, {
418584
cwd: skillerDir,
419585
detached: true,
420586
env: {
421587
...process.env,
422588
AGENTSKILLS_TRPC_PORT: String(trpcPort),
423589
ELECTRON_ENABLE_LOGGING: "1",
424-
...skillerHomeEnv(scope)
590+
...skillerHomeEnv(scope, launchCommand.userName)
425591
},
426592
stdio: ["ignore", logFd, logFd]
427593
})

packages/api/tests/skiller-routes.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,37 @@ const scope = (projectKey: string): SkillerContainerScope => ({
3333

3434
describe("skiller routes", () => {
3535
it("launches Electron through the Skiller launch script", () => {
36-
const [command, args] = skillerLaunchCommand()
37-
const launchCommand = args.join("\n")
36+
const launch = skillerLaunchCommand(null)
37+
const launchCommand = launch.args.join("\n")
3838

39-
expect(command).toBe("bash")
39+
expect(launch.command).toBe("bash")
40+
expect(launch.args).toContain("-c")
4041
expect(launchCommand).toContain("xvfb-run -a ./node_modules/electron/dist/electron")
4142
expect(launchCommand).toContain("exec ./node_modules/electron/dist/electron")
43+
expect(launchCommand).toContain("--disable-dev-shm-usage")
44+
})
45+
46+
it("launches scoped Skiller with the selected home owner credentials", () => {
47+
const launch = skillerLaunchCommand(
48+
{ gid: 1000, uid: 1000 },
49+
(user) => ({ ...user, groupName: "ubuntu", userName: "ubuntu" })
50+
)
51+
52+
expect(launch.command).toBe("runuser")
53+
expect(launch.args).toEqual(expect.arrayContaining([
54+
"--preserve-environment",
55+
"-u",
56+
"ubuntu",
57+
"-g",
58+
"ubuntu",
59+
"--",
60+
"bash",
61+
"-c"
62+
]))
63+
expect(launch.gid).toBe(1000)
64+
expect(launch.groupName).toBe("ubuntu")
65+
expect(launch.uid).toBe(1000)
66+
expect(launch.userName).toBe("ubuntu")
4267
})
4368

4469
it("keeps the terminal session id on session-scoped app routes", () => {

0 commit comments

Comments
 (0)