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"
33import { createServer } from "node:net"
44import { homedir } from "node:os"
55import { 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+
5771export 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 - Z a - z 0 - 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+
306344const 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
321366const 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
339417const 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
371536const 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 } )
0 commit comments