geniejars 0.2.12 → 0.2.13

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.
package/README.md CHANGED
@@ -217,6 +217,12 @@ script/
217
217
 
218
218
  See [COMMON-ISSUES.md](./COMMON-ISSUES.md) for solving common issues.
219
219
 
220
+ ## Links and contact
221
+
222
+ [Github](https://github.com/SemanticTools/geniejars)
223
+ Contact me on my [Twitter/X account](https://x.com/willm_2022)
224
+
225
+
220
226
  ## License
221
227
 
222
228
  MIT — see [LICENSE.md](./LICENSE.md)
@@ -7,7 +7,7 @@
7
7
  "managerGroup": "geniejars-mgr",
8
8
  "stateFile": "/tmp/geniejars-state.json",
9
9
  "lockFile": "/tmp/geniejars-state.lock",
10
- "backupDir": "/tmp/geniejars-backups",
10
+ "backupDir": "~/geniejars-backups",
11
11
  "socketDir": "/tmp/geniejars-sockets",
12
12
  "nodePath": "/usr/bin/node",
13
13
  "portBase": 10000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geniejars",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "Linux geniejars sandboxing — library and CLI for running processes as isolated system users",
6
6
  "main": "src/index.mjs",
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'url'
34
34
  import { loadConfig, readState, allNames, homeDir, resolveName } from '../src/pool.mjs'
35
35
  import { allocate, deallocate } from '../src/allocate.mjs'
36
36
  import { prepare } from '../src/prepare.mjs'
37
- import { runAs, startAs, stopProcess, agentStatus, ensureAgent } from '../src/run.mjs'
37
+ import { runAs, startAs, stopProcess, agentStatus, ensureAgent, killAgent } from '../src/run.mjs'
38
38
  import { normalize } from '../src/normalize.mjs'
39
39
 
40
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -427,7 +427,7 @@ if (cmd === 'setup') {
427
427
  if (!tag) die('--tag requires a value')
428
428
  }
429
429
  try {
430
- const name = await allocate(config, { backup, clear, tag })
430
+ const name = await allocate(config, { backup, clear, tag, onBackup: p => console.log(`Backup: ${p}`) })
431
431
  if (select) await setSelected(name)
432
432
  console.log(name)
433
433
  } catch (err) { die(err.message) }
@@ -437,6 +437,7 @@ if (cmd === 'setup') {
437
437
  const state = await readState(config)
438
438
  const name = requireName(args[0], state)
439
439
  try {
440
+ await killAgent(config, name)
440
441
  await deallocate(config, name)
441
442
  if (getSelected() === name) await clearSelected()
442
443
  console.log(`Released: ${name}`)
package/src/agent.mjs CHANGED
@@ -78,7 +78,7 @@ function handleConnection(socket) {
78
78
  socket.on('error', () => {})
79
79
  }
80
80
 
81
- function handleRequest(req, socket) {
81
+ async function handleRequest(req, socket) {
82
82
  switch (req.type) {
83
83
 
84
84
  case 'run': {
@@ -166,6 +166,39 @@ function handleRequest(req, socket) {
166
166
  break
167
167
  }
168
168
 
169
+ case 'killall': {
170
+ send(socket, { type: 'killed' })
171
+ socket.end()
172
+ setImmediate(() => process.kill(-1, 'SIGTERM'))
173
+ break
174
+ }
175
+
176
+ case 'clear': {
177
+ const home = process.env.HOME
178
+ try {
179
+ const entries = await fs.readdir(home).catch(() => [])
180
+ await Promise.all(entries.map(e => fs.rm(path.join(home, e), { recursive: true, force: true })))
181
+ send(socket, { type: 'cleared' })
182
+ } catch (err) {
183
+ send(socket, { type: 'error', message: err.message })
184
+ }
185
+ socket.end()
186
+ break
187
+ }
188
+
189
+ case 'backup': {
190
+ const home = process.env.HOME
191
+ const child = spawn('tar', ['-czf', '-', '-C', path.dirname(home), path.basename(home)])
192
+ child.stdout.on('data', d => send(socket, { type: 'backup-data', data: d.toString('base64') }))
193
+ child.on('error', err => { send(socket, { type: 'error', message: err.message }); socket.end() })
194
+ child.on('close', code => {
195
+ if (code < 2) send(socket, { type: 'backup-done' })
196
+ else send(socket, { type: 'error', message: `tar exited with code ${code}` })
197
+ socket.end()
198
+ })
199
+ break
200
+ }
201
+
169
202
  default: {
170
203
  send(socket, { type: 'error', message: `Unknown request type: ${req.type}` })
171
204
  socket.end()
package/src/allocate.mjs CHANGED
@@ -1,10 +1,7 @@
1
1
  import fs from 'fs/promises'
2
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)
3
+ import { acquireLock, readState, writeState, freeNames } from './pool.mjs'
4
+ import { ensureAgent, clearHomeAs, backupHomeAs } from './run.mjs'
8
5
 
9
6
  // Allocate a free geniejars. Returns the geniejars name.
10
7
  // Options:
@@ -12,7 +9,7 @@ const execFileAsync = promisify(execFile)
12
9
  // clear: boolean (default true) - clear home dir contents
13
10
  // tag: string (default null) - label stored in state
14
11
  export async function allocate(config, options = {}) {
15
- const { backup = false, clear = true, tag = null } = options
12
+ const { backup = false, clear = true, tag = null, onBackup = null } = options
16
13
 
17
14
  const release = await acquireLock(config)
18
15
  try {
@@ -25,7 +22,10 @@ export async function allocate(config, options = {}) {
25
22
 
26
23
  const name = free[0]
27
24
 
28
- if (backup) await backupHome(config, name)
25
+ if (backup) {
26
+ const archivePath = await backupHome(config, name)
27
+ if (onBackup) onBackup(archivePath)
28
+ }
29
29
  if (clear) await clearHome(config, name)
30
30
 
31
31
  // Allocate ports
@@ -77,20 +77,22 @@ export async function deallocate(config, name) {
77
77
  }
78
78
 
79
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.
80
+ // Runs via the gjar agent so the user can delete its own files regardless of permissions.
81
+ // Requires the manager server to be up (geniejars sys up).
81
82
  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 })))
83
+ await ensureAgent(config, name)
84
+ await clearHomeAs(config, name)
85
85
  }
86
86
 
87
87
  // Create a tar.gz snapshot of the geniejars's home into backupDir.
88
- // Returns the path to the archive.
88
+ // Agent tars its own home (as the gjar user), streams it over the socket,
89
+ // manager writes the archive. Returns the path to the archive.
89
90
  export async function backupHome(config, name) {
90
- const home = homeDir(config, name)
91
+ await ensureAgent(config, name)
91
92
  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
93
+ const now = new Date()
94
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '')
95
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '')
96
+ const basePath = path.join(config.backupDir, `${name}-${date}-${time}`)
97
+ return backupHomeAs(config, name, basePath)
96
98
  }
package/src/pool.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises'
2
2
  import { createWriteStream } from 'fs'
3
3
  import path from 'path'
4
+ import os from 'os'
4
5
 
5
6
  const LOCK_RETRIES = 20
6
7
  const LOCK_DELAY_BASE_MS = 50
@@ -25,7 +26,11 @@ export async function loadConfig(configPath) {
25
26
  }
26
27
  }
27
28
  const text = await fs.readFile(p, 'utf8')
28
- const config = JSON.parse(text)
29
+ const raw = JSON.parse(text)
30
+ const home = os.homedir()
31
+ const config = Object.fromEntries(
32
+ Object.entries(raw).map(([k, v]) => [k, typeof v === 'string' && v.startsWith('~/') ? path.join(home, v.slice(2)) : v])
33
+ )
29
34
  if (!config.nodePath) config.nodePath = process.execPath
30
35
  return config
31
36
  }
package/src/run.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import net from 'net'
2
2
  import fs from 'fs'
3
3
  import path from 'path'
4
+ import { createHash } from 'crypto'
4
5
  import { homeDir } from './pool.mjs'
5
6
 
6
7
  function socketPath(config, name) {
@@ -62,6 +63,12 @@ function request(config, name, req, options = {}) {
62
63
  case 'stopped':
63
64
  settle(resolve, { stopped: true })
64
65
  break
66
+ case 'cleared':
67
+ settle(resolve, { cleared: true })
68
+ break
69
+ case 'killed':
70
+ settle(resolve, { killed: true })
71
+ break
65
72
  case 'status':
66
73
  settle(resolve, { running: msg.running, pid: msg.pid })
67
74
  break
@@ -132,11 +139,84 @@ export async function stopProcess(config, name) {
132
139
  return request(config, name, { type: 'stop' })
133
140
  }
134
141
 
142
+ // Kill all processes owned by the gjar user. Best-effort — ignores errors if agent is not running.
143
+ export async function killAgent(config, name) {
144
+ return request(config, name, { type: 'killall' }).catch(() => {})
145
+ }
146
+
135
147
  // Get agent status.
136
148
  export async function agentStatus(config, name) {
137
149
  return request(config, name, { type: 'status' })
138
150
  }
139
151
 
152
+ // Clear all contents of the geniejars's home dir, running as the gjar user.
153
+ export async function clearHomeAs(config, name) {
154
+ return request(config, name, { type: 'clear' })
155
+ }
156
+
157
+ // Back up the geniejars's home dir: agent tars and streams, manager writes and hashes.
158
+ // basePath is the archive path without extension (e.g. /backups/gjar002-20260324-1743).
159
+ // Writes to basePath.tmp while streaming, then renames to basePath-{hash8}.tar.gz.
160
+ // Returns the final archive path.
161
+ export function backupHomeAs(config, name, basePath) {
162
+ const tmpPath = basePath + '.tmp'
163
+ const writeStream = fs.createWriteStream(tmpPath)
164
+ const hash = createHash('sha256')
165
+ return new Promise((resolve, reject) => {
166
+ const client = net.createConnection(socketPath(config, name))
167
+ let buf = ''
168
+ let settled = false
169
+ let receivedDone = false
170
+
171
+ function settle(fn, val) {
172
+ if (settled) return
173
+ settled = true
174
+ fn(val)
175
+ }
176
+
177
+ client.on('connect', () => client.write(JSON.stringify({ type: 'backup' }) + '\n'))
178
+ client.on('data', chunk => {
179
+ buf += chunk.toString()
180
+ let nl
181
+ while ((nl = buf.indexOf('\n')) !== -1) {
182
+ const line = buf.slice(0, nl).trim()
183
+ buf = buf.slice(nl + 1)
184
+ if (!line) continue
185
+ let msg
186
+ try { msg = JSON.parse(line) } catch { continue }
187
+ switch (msg.type) {
188
+ case 'backup-data': {
189
+ const data = Buffer.from(msg.data, 'base64')
190
+ writeStream.write(data)
191
+ hash.update(data)
192
+ break
193
+ }
194
+ case 'backup-done': {
195
+ receivedDone = true
196
+ const digest = hash.digest('hex').slice(0, 8)
197
+ const finalPath = `${basePath}-${digest}.tar.gz`
198
+ writeStream.end(() => {
199
+ fs.rename(tmpPath, finalPath, err => {
200
+ if (err) settle(reject, err)
201
+ else settle(resolve, finalPath)
202
+ })
203
+ })
204
+ break
205
+ }
206
+ case 'error':
207
+ writeStream.destroy()
208
+ fs.unlink(tmpPath, () => {})
209
+ settle(reject, new Error(msg.message))
210
+ client.destroy()
211
+ break
212
+ }
213
+ }
214
+ })
215
+ client.on('error', err => { writeStream.destroy(); fs.unlink(tmpPath, () => {}); settle(reject, new Error(`Agent connection failed (${name}): ${err.message}`)) })
216
+ client.on('close', () => { if (!settled && !receivedDone) { writeStream.destroy(); fs.unlink(tmpPath, () => {}); settle(reject, new Error('Connection closed unexpectedly')) } })
217
+ })
218
+ }
219
+
140
220
  // Ensure a geniejars agent is up via the shell server.
141
221
  // Requires the shell server to be running (geniejars sys up).
142
222
  export function ensureAgent(config, name) {