geniejars 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ // One-time system setup — must be run as root (sudo node script/setup-geniejars.mjs)
3
+ //
4
+ // Creates geniejars, groups, ACLs, sudoers rule, and socket directory.
5
+ // Idempotent — safe to run again after changing pool size.
6
+
7
+ import { execFile as _execFile } from 'child_process'
8
+ import { promisify } from 'util'
9
+ import fs from 'fs/promises'
10
+ import path from 'path'
11
+ import { fileURLToPath } from 'url'
12
+ import { loadConfig, allNames } from '../src/pool.mjs'
13
+
14
+ const execFile = promisify(_execFile)
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+ const agentScript = path.resolve(__dirname, '../src/agent.mjs')
17
+
18
+ // Assert running as root
19
+ if (process.getuid() !== 0) {
20
+ console.error('Error: setup must be run as root. Use: sudo node script/setup-geniejars.mjs')
21
+ process.exit(1)
22
+ }
23
+
24
+ const config = await loadConfig()
25
+ const { managerUser, managerGroup, homeBase, socketDir, nodePath } = config
26
+ const commonUser = config.commonUser ?? 'subcommon'
27
+ const commonDir = config.commonDir ?? path.join(homeBase, commonUser)
28
+ const names = allNames(config)
29
+
30
+ async function run(cmd, args) {
31
+ try {
32
+ await execFile(cmd, args)
33
+ } catch (err) {
34
+ // surface stderr but let caller decide if it's fatal
35
+ throw new Error(`${cmd} ${args.join(' ')}: ${err.stderr?.trim() ?? err.message}`)
36
+ }
37
+ }
38
+
39
+ async function groupExists(group) {
40
+ try { await execFile('getent', ['group', group]); return true } catch { return false }
41
+ }
42
+
43
+ async function userExists(user) {
44
+ try { await execFile('id', [user]); return true } catch { return false }
45
+ }
46
+
47
+ async function userInGroup(user, group) {
48
+ try {
49
+ const { stdout } = await execFile('id', ['-Gn', user])
50
+ return stdout.trim().split(/\s+/).includes(group)
51
+ } catch { return false }
52
+ }
53
+
54
+ console.log('=== geniejars system setup ===\n')
55
+
56
+ // 1. Create manager group
57
+ if (await groupExists(managerGroup)) {
58
+ console.log(`group ${managerGroup}: already exists`)
59
+ } else {
60
+ await run('groupadd', [managerGroup])
61
+ console.log(`group ${managerGroup}: created`)
62
+ }
63
+
64
+ // 2. Add manager user to manager group
65
+ if (await userInGroup(managerUser, managerGroup)) {
66
+ console.log(`${managerUser} in ${managerGroup}: already set`)
67
+ } else {
68
+ await run('usermod', ['-aG', managerGroup, managerUser])
69
+ console.log(`${managerUser} in ${managerGroup}: added`)
70
+ }
71
+
72
+ // 3. Create each geniejars
73
+ for (const name of names) {
74
+ const home = path.join(homeBase, name)
75
+
76
+ // Create group for this geniejars
77
+ if (await groupExists(name)) {
78
+ console.log(`group ${name}: already exists`)
79
+ } else {
80
+ await run('groupadd', ['--system', name])
81
+ console.log(`group ${name}: created`)
82
+ }
83
+
84
+ // Create user
85
+ if (await userExists(name)) {
86
+ console.log(`user ${name}: already exists`)
87
+ } else {
88
+ await run('useradd', [
89
+ '--system',
90
+ '--create-home',
91
+ '--home-dir', home,
92
+ '--shell', '/usr/bin/nologin',
93
+ '--gid', name,
94
+ name,
95
+ ])
96
+ console.log(`user ${name}: created`)
97
+ }
98
+
99
+ // Add manager user to this geniejars's group (so manager can read/write home)
100
+ if (await userInGroup(managerUser, name)) {
101
+ console.log(`${managerUser} in group ${name}: already set`)
102
+ } else {
103
+ await run('usermod', ['-aG', name, managerUser])
104
+ console.log(`${managerUser} in group ${name}: added`)
105
+ }
106
+
107
+ // Set home dir: owned by geniejars:geniejars, setgid (2770)
108
+ await run('chown', [`${name}:${name}`, home])
109
+ await run('chmod', ['2770', home])
110
+ console.log(`${name} home permissions: set`)
111
+
112
+ // Default ACLs so manager-written files are accessible by geniejars and vice versa
113
+ await run('setfacl', ['-d', '-m', `u:${managerUser}:rwX`, home])
114
+ await run('setfacl', ['-d', '-m', `u:${name}:rwX`, home])
115
+ console.log(`${name} default ACLs: set`)
116
+
117
+ // Apply ACLs recursively to any files already in the home dir (e.g. created by system at useradd time)
118
+ await run('setfacl', ['-R', '-m', `u:${managerUser}:rwX`, home])
119
+ console.log(`${name} recursive ACLs: set`)
120
+ }
121
+
122
+ // 4. Create common user and shared directory
123
+ // commonDir is owned by the manager (write access) with group=subcommon (read access).
124
+ // All geniejars are added to the subcommon group so they can traverse and read it.
125
+
126
+ if (await groupExists(commonUser)) {
127
+ console.log(`group ${commonUser}: already exists`)
128
+ } else {
129
+ await run('groupadd', ['--system', commonUser])
130
+ console.log(`group ${commonUser}: created`)
131
+ }
132
+
133
+ if (await userExists(commonUser)) {
134
+ console.log(`user ${commonUser}: already exists`)
135
+ } else {
136
+ await run('useradd', [
137
+ '--system',
138
+ '--create-home',
139
+ '--home-dir', commonDir,
140
+ '--shell', '/usr/bin/nologin',
141
+ '--gid', commonUser,
142
+ commonUser,
143
+ ])
144
+ console.log(`user ${commonUser}: created`)
145
+ }
146
+
147
+ // Manager owns commonDir so they can install tools freely.
148
+ // subcommon group (sub* users) gets r-x only. Others get nothing.
149
+ // setgid ensures new files/dirs created by manager inherit the subcommon group.
150
+ // Default ACLs enforce correct permissions on new content automatically.
151
+ await fs.mkdir(commonDir, { recursive: true })
152
+ await run('chown', [`${managerUser}:${commonUser}`, commonDir])
153
+ await run('chmod', ['2750', commonDir])
154
+ await run('setfacl', ['-d', '-m', `u:${managerUser}:rwX`, commonDir])
155
+ await run('setfacl', ['-d', '-m', `g:${commonUser}:rX`, commonDir])
156
+ await run('setfacl', ['-d', '-m', 'o::---', commonDir])
157
+ console.log(`${commonUser} home ${commonDir}: permissions set`)
158
+
159
+ // Add manager to subcommon group so chgrp works without sudo after re-login.
160
+ if (await userInGroup(managerUser, commonUser)) {
161
+ console.log(`${managerUser} in ${commonUser}: already set`)
162
+ } else {
163
+ await run('usermod', ['-aG', commonUser, managerUser])
164
+ console.log(`${managerUser} in ${commonUser}: added`)
165
+ }
166
+
167
+ // Add each geniejars to the subcommon group so they can access commonDir.
168
+ for (const name of names) {
169
+ if (await userInGroup(name, commonUser)) {
170
+ console.log(`${name} in ${commonUser}: already set`)
171
+ } else {
172
+ await run('usermod', ['-aG', commonUser, name])
173
+ console.log(`${name} in ${commonUser}: added`)
174
+ }
175
+ }
176
+
177
+ // 5. Write sudoers rule — restrict to running the agent script only
178
+ const userList = names.join(', ')
179
+ const sudoersEntry = [
180
+ '# geniejars agent — allows manager to start agents as geniejars',
181
+ `${managerUser} ALL = (${userList}) NOPASSWD: ${nodePath} ${agentScript} *`,
182
+ '',
183
+ ].join('\n')
184
+
185
+ const sudoersFile = '/etc/sudoers.d/geniejars'
186
+ await fs.writeFile(sudoersFile, sudoersEntry, { mode: 0o440 })
187
+ console.log(`sudoers rule: written to ${sudoersFile}`)
188
+
189
+ // 6. Create socket directory: sticky + world-writable (like /tmp) so each geniejars
190
+ // can create their own socket. Socket files are 0660 owned by the geniejars, and the
191
+ // manager is in each geniejars's group — so only the manager can connect to each socket.
192
+ // Other geniejars cannot connect to each other's sockets (not in each other's groups).
193
+ await fs.mkdir(socketDir, { recursive: true })
194
+ await run('chown', ['root:root', socketDir])
195
+ await run('chmod', ['1777', socketDir])
196
+ console.log(`socket dir ${socketDir}: ready`)
197
+
198
+ const tmpfilesConf = `/etc/tmpfiles.d/geniejars.conf`
199
+ await fs.writeFile(tmpfilesConf, `d ${socketDir} 1777 root root -\n`)
200
+ console.log(`tmpfiles.d: written to ${tmpfilesConf}`)
201
+
202
+ console.log('\n=== setup complete ===')
203
+ console.log(`\nIMPORTANT: group membership changes require a new login session to take effect.`)
204
+ console.log(`Please log out and log back in, or run: su ${managerUser}`)
205
+ console.log(`Note: 'su' is a temporary workaround — a full re-login is recommended.`)
206
+ console.log(`After that, no more sudo or su required.`)
207
+ console.log(`Then: geniejars sys up`)
208
+ console.log(`\n🧙 GenieJars ready! Happy summoning your agents!`)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Uninstall — must be run as root (sudo geniejars uninstall)
3
+ //
4
+ // Removes all geniejars, groups, home dirs, sudoers rule, and tmp state.
5
+ // Does NOT remove the manager user or the manager's own home directory.
6
+
7
+ import { execFile as _execFile } from 'child_process'
8
+ import { promisify } from 'util'
9
+ import fs from 'fs/promises'
10
+ import path from 'path'
11
+ import { loadConfig, allNames } from '../src/pool.mjs'
12
+
13
+ const execFile = promisify(_execFile)
14
+
15
+ if (process.getuid() !== 0) {
16
+ console.error('Error: uninstall must be run as root. Use: sudo geniejars uninstall')
17
+ process.exit(1)
18
+ }
19
+
20
+ const config = await loadConfig()
21
+ const { managerUser, managerGroup, homeBase, socketDir } = config
22
+ const commonUser = config.commonUser ?? 'subcommon'
23
+ const commonDir = config.commonDir ?? path.join(homeBase, commonUser)
24
+ const names = allNames(config)
25
+
26
+ async function run(cmd, args, { silent = false } = {}) {
27
+ try {
28
+ await execFile(cmd, args)
29
+ } catch (err) {
30
+ if (!silent) console.warn(` warning: ${cmd} ${args.join(' ')}: ${err.stderr?.trim() ?? err.message}`)
31
+ }
32
+ }
33
+
34
+ async function userExists(user) {
35
+ try { await execFile('id', [user]); return true } catch { return false }
36
+ }
37
+
38
+ async function groupExists(group) {
39
+ try { await execFile('getent', ['group', group]); return true } catch { return false }
40
+ }
41
+
42
+ console.log('=== geniejars uninstall ===\n')
43
+
44
+ // 1. Stop the shell server gracefully if running
45
+ const pidFile = path.join(socketDir, 'server.pid')
46
+ try {
47
+ const pid = parseInt(await fs.readFile(pidFile, 'utf8'), 10)
48
+ if (!isNaN(pid)) {
49
+ try { process.kill(pid, 'SIGTERM') } catch {}
50
+ // Wait up to 3s for it to exit
51
+ for (let i = 0; i < 15; i++) {
52
+ await new Promise(r => setTimeout(r, 200))
53
+ try { process.kill(pid, 0) } catch { break }
54
+ }
55
+ try { process.kill(pid, 'SIGKILL') } catch {}
56
+ console.log('shell server: stopped')
57
+ }
58
+ } catch { console.log('shell server: not running') }
59
+
60
+ // 2. Remove each geniejars and their home directory
61
+ for (const name of names) {
62
+ const home = path.join(homeBase, name)
63
+ if (await userExists(name)) {
64
+ await run('userdel', [name])
65
+ console.log(`user ${name}: removed`)
66
+ } else {
67
+ console.log(`user ${name}: not found`)
68
+ }
69
+ await fs.rm(home, { recursive: true, force: true })
70
+ console.log(`home ${home}: removed`)
71
+ if (await groupExists(name)) {
72
+ await run('groupdel', [name])
73
+ console.log(`group ${name}: removed`)
74
+ }
75
+ }
76
+
77
+ // 3. Remove common user and directory
78
+ if (await userExists(commonUser)) {
79
+ await run('userdel', [commonUser])
80
+ console.log(`user ${commonUser}: removed`)
81
+ } else {
82
+ console.log(`user ${commonUser}: not found`)
83
+ }
84
+ await fs.rm(commonDir, { recursive: true, force: true })
85
+ console.log(`commonDir ${commonDir}: removed`)
86
+ if (await groupExists(commonUser)) {
87
+ await run('groupdel', [commonUser])
88
+ console.log(`group ${commonUser}: removed`)
89
+ }
90
+
91
+ // 4. Remove manager group
92
+ if (await groupExists(managerGroup)) {
93
+ await run('groupdel', [managerGroup])
94
+ console.log(`group ${managerGroup}: removed`)
95
+ } else {
96
+ console.log(`group ${managerGroup}: not found`)
97
+ }
98
+
99
+ // 5. Remove sudoers rule
100
+ await fs.unlink('/etc/sudoers.d/geniejars').catch(() => {})
101
+ console.log('sudoers rule: removed')
102
+
103
+ // 6. Remove tmpfiles.d config
104
+ await fs.unlink('/etc/tmpfiles.d/geniejars.conf').catch(() => {})
105
+ console.log('tmpfiles.d config: removed')
106
+
107
+ // 7. Clean up /tmp state
108
+ const toRemove = [
109
+ socketDir,
110
+ config.stateFile,
111
+ config.lockFile,
112
+ config.logDir,
113
+ ]
114
+ for (const p of toRemove) {
115
+ if (!p) continue
116
+ await fs.rm(p, { recursive: true, force: true })
117
+ console.log(`${p}: removed`)
118
+ }
119
+
120
+ console.log('\n=== uninstall complete ===')
121
+ console.log(`\nNOTE: ${managerUser} may still have residual group memberships in this session.`)
122
+ console.log(`Run: su - ${managerUser} to get a clean login.`)
package/src/agent.mjs ADDED
@@ -0,0 +1,194 @@
1
+ // Geniejars agent — runs as the geniejars, listens on a Unix socket.
2
+ // Started at boot via systemd with an absolute path to node:
3
+ // /usr/bin/node /path/to/src/agent.mjs <geniejars-name>
4
+ //
5
+ // Handles requests from the manager over a newline-delimited JSON protocol:
6
+ // run — execute a command, stream stdout/stderr, report exit
7
+ // start — start the one long-running main process, stream output
8
+ // stop — kill the main process
9
+ // status — report whether main process is running
10
+
11
+ import net from 'net'
12
+ import { spawn } from 'child_process'
13
+ import fs from 'fs/promises'
14
+ import path from 'path'
15
+ import { loadConfig } from './pool.mjs'
16
+
17
+ const name = process.argv[2]
18
+ if (!name) {
19
+ console.error('Usage: agent <geniejars-name>')
20
+ process.exit(1)
21
+ }
22
+
23
+ const config = await loadConfig()
24
+ const sockPath = path.join(config.socketDir, `${name}.sock`)
25
+
26
+ await fs.mkdir(config.socketDir, { recursive: true })
27
+ await fs.unlink(sockPath).catch(() => {})
28
+
29
+ let mainProc = null // the one long-running process
30
+
31
+ // --- Idle timeout ---
32
+ const idleTimeoutMs = (config.agentIdleTimeout ?? 300) * 1000
33
+ let idleTimer = null
34
+
35
+ function scheduleIdleShutdown() {
36
+ if (idleTimeoutMs === 0 || mainProc) return
37
+ if (idleTimer) clearTimeout(idleTimer)
38
+ idleTimer = setTimeout(async () => {
39
+ if (mainProc) return // became busy — skip
40
+ server.close()
41
+ await fs.unlink(sockPath).catch(() => {})
42
+ process.exit(0)
43
+ }, idleTimeoutMs)
44
+ }
45
+
46
+ function resetIdleTimer() {
47
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null }
48
+ scheduleIdleShutdown()
49
+ }
50
+
51
+ function send(socket, obj) {
52
+ if (!socket.destroyed) socket.write(JSON.stringify(obj) + '\n')
53
+ }
54
+
55
+ function handleConnection(socket) {
56
+ let buf = ''
57
+
58
+ socket.on('data', chunk => {
59
+ buf += chunk.toString()
60
+ let nl
61
+ while ((nl = buf.indexOf('\n')) !== -1) {
62
+ const line = buf.slice(0, nl).trim()
63
+ buf = buf.slice(nl + 1)
64
+ if (!line) continue
65
+ let req
66
+ try { req = JSON.parse(line) } catch {
67
+ send(socket, { type: 'error', message: 'Invalid JSON' })
68
+ socket.destroy()
69
+ return
70
+ }
71
+ handleRequest(req, socket)
72
+ }
73
+ })
74
+
75
+ socket.on('error', () => {})
76
+ }
77
+
78
+ function handleRequest(req, socket) {
79
+ switch (req.type) {
80
+
81
+ case 'run': {
82
+ resetIdleTimer()
83
+ let child
84
+ try {
85
+ child = spawn(req.cmd, req.args ?? [], {
86
+ cwd: req.cwd ?? process.env.HOME,
87
+ env: { ...process.env, ...(req.env ?? {}) },
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ })
90
+ } catch (err) {
91
+ send(socket, { type: 'error', message: err.message })
92
+ socket.end()
93
+ return
94
+ }
95
+ child.stdout.on('data', d => send(socket, { type: 'stdout', data: d.toString() }))
96
+ child.stderr.on('data', d => send(socket, { type: 'stderr', data: d.toString() }))
97
+ child.on('error', err => { send(socket, { type: 'error', message: err.message }); socket.end() })
98
+ child.on('close', code => { send(socket, { type: 'exit', code: code ?? 1 }); socket.end(); resetIdleTimer() })
99
+ break
100
+ }
101
+
102
+ case 'start': {
103
+ if (mainProc) {
104
+ send(socket, { type: 'error', message: 'Process already running' })
105
+ socket.end()
106
+ return
107
+ }
108
+ resetIdleTimer()
109
+ let child
110
+ try {
111
+ child = spawn(req.cmd, req.args ?? [], {
112
+ cwd: req.cwd ?? process.env.HOME,
113
+ env: { ...process.env, ...(req.env ?? {}) },
114
+ stdio: ['ignore', 'pipe', 'pipe'],
115
+ })
116
+ } catch (err) {
117
+ send(socket, { type: 'error', message: err.message })
118
+ socket.end()
119
+ return
120
+ }
121
+ mainProc = child
122
+ send(socket, { type: 'started', pid: child.pid })
123
+
124
+ child.stdout.on('data', d => send(socket, { type: 'stdout', data: d.toString() }))
125
+ child.stderr.on('data', d => send(socket, { type: 'stderr', data: d.toString() }))
126
+ child.on('error', err => {
127
+ send(socket, { type: 'error', message: err.message })
128
+ mainProc = null
129
+ resetIdleTimer()
130
+ socket.end()
131
+ })
132
+ child.on('close', code => {
133
+ send(socket, { type: 'exit', code: code ?? 1 })
134
+ mainProc = null
135
+ resetIdleTimer()
136
+ socket.end()
137
+ })
138
+
139
+ // If manager disconnects, process keeps running — detach listeners
140
+ socket.on('close', () => {
141
+ if (mainProc) {
142
+ mainProc.stdout.removeAllListeners('data')
143
+ mainProc.stderr.removeAllListeners('data')
144
+ }
145
+ })
146
+ break
147
+ }
148
+
149
+ case 'stop': {
150
+ if (!mainProc) {
151
+ send(socket, { type: 'error', message: 'No process running' })
152
+ } else {
153
+ mainProc.kill('SIGTERM')
154
+ send(socket, { type: 'stopped' })
155
+ }
156
+ socket.end()
157
+ break
158
+ }
159
+
160
+ case 'status': {
161
+ send(socket, { type: 'status', running: mainProc !== null, pid: mainProc?.pid ?? null })
162
+ socket.end()
163
+ break
164
+ }
165
+
166
+ default: {
167
+ send(socket, { type: 'error', message: `Unknown request type: ${req.type}` })
168
+ socket.end()
169
+ }
170
+ }
171
+ }
172
+
173
+ const server = net.createServer(handleConnection)
174
+
175
+ server.listen(sockPath, async () => {
176
+ // Override any setgid inheritance from the directory — socket must be owned by
177
+ // this process's own uid:gid so the manager (in the geniejars's group) can connect.
178
+ await fs.chown(sockPath, process.getuid(), process.getgid())
179
+ await fs.chmod(sockPath, 0o660)
180
+ console.log(`[geniejars-agent] ${name} listening on ${sockPath}`)
181
+ scheduleIdleShutdown()
182
+ })
183
+
184
+ server.on('error', err => {
185
+ console.error('[geniejars-agent] Error:', err.message)
186
+ process.exit(1)
187
+ })
188
+
189
+ process.on('SIGTERM', async () => {
190
+ if (mainProc) mainProc.kill('SIGTERM')
191
+ server.close()
192
+ await fs.unlink(sockPath).catch(() => {})
193
+ process.exit(0)
194
+ })
@@ -0,0 +1,96 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { execFile } from 'child_process'
4
+ import { promisify } from 'util'
5
+ import { acquireLock, readState, writeState, freeNames, homeDir } from './pool.mjs'
6
+
7
+ const execFileAsync = promisify(execFile)
8
+
9
+ // Allocate a free geniejars. Returns the geniejars name.
10
+ // Options:
11
+ // backup: boolean (default false) - tar home before clearing
12
+ // clear: boolean (default true) - clear home dir contents
13
+ // tag: string (default null) - label stored in state
14
+ export async function allocate(config, options = {}) {
15
+ const { backup = false, clear = true, tag = null } = options
16
+
17
+ const release = await acquireLock(config)
18
+ try {
19
+ const state = await readState(config)
20
+ const free = freeNames(state)
21
+ if (free.length === 0) {
22
+ const total = Object.keys(state.pool).length
23
+ throw new Error(`No free geniejars available (pool size: ${total})`)
24
+ }
25
+
26
+ const name = free[0]
27
+
28
+ if (backup) await backupHome(config, name)
29
+ if (clear) await clearHome(config, name)
30
+
31
+ // Allocate ports
32
+ const freePorts = Object.entries(state.ports)
33
+ .filter(([, v]) => v === 'free')
34
+ .map(([k]) => Number(k))
35
+ .sort((a, b) => a - b)
36
+ if (freePorts.length < config.portsPerGeniejars) {
37
+ throw new Error(`Not enough free ports (need ${config.portsPerGeniejars}, have ${freePorts.length})`)
38
+ }
39
+ const assignedPorts = freePorts.slice(0, config.portsPerGeniejars)
40
+ for (const port of assignedPorts) state.ports[port] = name
41
+
42
+ state.pool[name] = {
43
+ status: 'occupied',
44
+ allocatedAt: new Date().toISOString(),
45
+ allocatedBy: config.managerUser,
46
+ tag: tag ?? null,
47
+ ports: assignedPorts,
48
+ prepared: null,
49
+ }
50
+ await writeState(config, state)
51
+ return name
52
+ } finally {
53
+ await release()
54
+ }
55
+ }
56
+
57
+ // Release a geniejars back to the free pool.
58
+ export async function deallocate(config, name) {
59
+ const release = await acquireLock(config)
60
+ try {
61
+ const state = await readState(config)
62
+ if (!(name in state.pool)) {
63
+ throw new Error(`Unknown geniejars: ${name}`)
64
+ }
65
+ if (state.pool[name].status !== 'occupied') {
66
+ throw new Error(`Geniejars ${name} is not currently occupied`)
67
+ }
68
+ // Free ports
69
+ const ports = state.pool[name].ports ?? []
70
+ for (const port of ports) state.ports[port] = 'free'
71
+
72
+ state.pool[name] = { status: 'free', allocatedAt: null, allocatedBy: null, tag: null, ports: null, prepared: null }
73
+ await writeState(config, state)
74
+ } finally {
75
+ await release()
76
+ }
77
+ }
78
+
79
+ // Remove all contents inside a geniejars's home dir (not the dir itself).
80
+ // Runs as the manager directly — manager has group write access via default ACLs.
81
+ export async function clearHome(config, name) {
82
+ const home = homeDir(config, name)
83
+ const entries = await fs.readdir(home).catch(() => [])
84
+ await Promise.all(entries.map(e => fs.rm(path.join(home, e), { recursive: true, force: true })))
85
+ }
86
+
87
+ // Create a tar.gz snapshot of the geniejars's home into backupDir.
88
+ // Returns the path to the archive.
89
+ export async function backupHome(config, name) {
90
+ const home = homeDir(config, name)
91
+ await fs.mkdir(config.backupDir, { recursive: true })
92
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
93
+ const archive = path.join(config.backupDir, `${name}-${timestamp}.tar.gz`)
94
+ await execFileAsync('tar', ['-czf', archive, '-C', path.dirname(home), path.basename(home)])
95
+ return archive
96
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ export { loadConfig, poolName, allNames, homeDir, freeNames, occupiedNames, readState, writeState, initialState, resolveName, acquireLock } from './pool.mjs'
2
+ export { allocate, deallocate, clearHome, backupHome } from './allocate.mjs'
3
+ export { parseJFile, readJFile, applyJFile, prepare } from './prepare.mjs'
4
+ export { runAs, runShellAs, startAs, stopProcess, agentStatus, logFilePath, ensureAgent } from './run.mjs'
5
+ export { normalize, applyAcl } from './normalize.mjs'
@@ -0,0 +1,26 @@
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { homeDir } from './pool.mjs'
4
+
5
+ const execFileAsync = promisify(execFile)
6
+
7
+ // Re-apply ACLs to all manager-owned files in a geniejars's home.
8
+ // Grants the geniejars rw(X) on any file currently owned by the manager.
9
+ // Runs without sudo because the manager is in the geniejars's group (set up in Phase 1).
10
+ export async function normalize(config, name) {
11
+ const home = homeDir(config, name)
12
+ const manager = config.managerUser
13
+ const aclEntry = `u:${name}:rwX`
14
+
15
+ // find all files/dirs owned by the manager, then setfacl on them in batch
16
+ await execFileAsync('find', [
17
+ home,
18
+ '-user', manager,
19
+ '-exec', 'setfacl', '-m', aclEntry, '{}', '+'
20
+ ])
21
+ }
22
+
23
+ // Apply a single ACL entry to a specific path.
24
+ export async function applyAcl(config, name, filePath) {
25
+ await execFileAsync('setfacl', ['-m', `u:${name}:rwX`, filePath])
26
+ }