@arnilo/prism-coding-agent 0.0.24 → 0.0.26

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,592 @@
1
+ /**
2
+ * Managed ProcessSession registry — native spawn or optional sandbox startProcess.
3
+ */
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import { isAbsolute, relative, resolve } from "node:path";
7
+ import { assertExecutionAllowed, ExecutionDeniedError } from "@arnilo/prism";
8
+ import { OutputAccumulator } from "../output-accumulator.js";
9
+ import { resolveToCwd } from "../path-utils.js";
10
+ import { killProcessTree } from "../shell.js";
11
+ import { ProcessSessionError, resolveProcessSessionLimits, } from "./types.js";
12
+ function ownershipKey(ownership, identity) {
13
+ if (ownership) {
14
+ return `${ownership.tenantId ?? ""}:${ownership.accountId ?? ""}:${ownership.userId ?? ""}`;
15
+ }
16
+ if (identity) {
17
+ return `${identity.tenantId}:${identity.accountId ?? ""}:${identity.userId ?? ""}`;
18
+ }
19
+ return "default";
20
+ }
21
+ function commandFingerprint(command, args) {
22
+ return createHash("sha256")
23
+ .update(JSON.stringify([command, ...args]))
24
+ .digest("hex");
25
+ }
26
+ function isInsideRoot(root, target) {
27
+ const from = resolve(root);
28
+ const to = resolve(target);
29
+ if (to === from)
30
+ return true;
31
+ const rel = relative(from, to);
32
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
33
+ }
34
+ function nowIso() {
35
+ return new Date().toISOString();
36
+ }
37
+ export function createProcessSessions(options) {
38
+ const workspace = resolve(options.cwd);
39
+ const limits = resolveProcessSessionLimits(options.limits);
40
+ const defaultOwner = ownershipKey(options.ownership, options.identity);
41
+ const policy = options.policy;
42
+ const onEvent = options.onEvent;
43
+ const sandbox = options.sandbox;
44
+ const sessions = new Map();
45
+ let disposed = false;
46
+ let sandboxLost = false;
47
+ const emit = (event) => {
48
+ onEvent?.(event);
49
+ };
50
+ const assertNotDisposed = () => {
51
+ if (disposed)
52
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "process sessions disposed");
53
+ };
54
+ const reconcileAllUnknown = () => {
55
+ let n = 0;
56
+ for (const record of [...sessions.values()]) {
57
+ if (record.state !== "running" && record.state !== "starting")
58
+ continue;
59
+ terminateRecord(record, "unknown", null);
60
+ n += 1;
61
+ }
62
+ return n;
63
+ };
64
+ const checkSandboxAlive = async () => {
65
+ if (!sandbox?.status || sandboxLost)
66
+ return;
67
+ try {
68
+ const status = await sandbox.status();
69
+ if (status.state !== "running") {
70
+ sandboxLost = true;
71
+ reconcileAllUnknown();
72
+ }
73
+ }
74
+ catch {
75
+ sandboxLost = true;
76
+ reconcileAllUnknown();
77
+ }
78
+ };
79
+ const sweepExpired = () => {
80
+ const now = Date.now();
81
+ for (const record of sessions.values()) {
82
+ if (record.state !== "running" && record.state !== "starting")
83
+ continue;
84
+ if (now < record.expiresAt)
85
+ continue;
86
+ terminateRecord(record, "expired", null);
87
+ }
88
+ };
89
+ const settleWaiters = (record) => {
90
+ const result = { exitCode: record.exitCode, state: record.state };
91
+ const waiters = record.waiters;
92
+ record.waiters = [];
93
+ for (const resolveWait of waiters)
94
+ resolveWait(result);
95
+ };
96
+ const terminateRecord = (record, state, exitCode) => {
97
+ if (record.state === "exited" ||
98
+ record.state === "killed" ||
99
+ record.state === "released" ||
100
+ record.state === "expired" ||
101
+ record.state === "unknown") {
102
+ return;
103
+ }
104
+ const child = record.child;
105
+ const backend = record.backend;
106
+ record.child = undefined;
107
+ record.backend = undefined;
108
+ if (state === "released") {
109
+ try {
110
+ child?.stdout.removeAllListeners();
111
+ child?.stderr.removeAllListeners();
112
+ child?.stdout.destroy();
113
+ child?.stderr.destroy();
114
+ child?.stdin.destroy();
115
+ child?.unref();
116
+ }
117
+ catch {
118
+ // best effort
119
+ }
120
+ void backend?.release().catch(() => undefined);
121
+ }
122
+ else if (state === "killed" || state === "expired" || state === "unknown") {
123
+ if (child?.pid) {
124
+ try {
125
+ killProcessTree(child.pid);
126
+ }
127
+ catch {
128
+ // best effort
129
+ }
130
+ }
131
+ if (backend) {
132
+ void backend.kill().catch(() => undefined);
133
+ }
134
+ }
135
+ else if (state === "exited" && child) {
136
+ try {
137
+ child.stdout.destroy();
138
+ child.stderr.destroy();
139
+ child.stdin.destroy();
140
+ }
141
+ catch {
142
+ // best effort
143
+ }
144
+ }
145
+ record.state = state;
146
+ record.exitCode = state === "unknown" || state === "released" ? null : exitCode;
147
+ record.exitedAt = nowIso();
148
+ record.stdinClosed = true;
149
+ try {
150
+ record.accumulator.finish();
151
+ }
152
+ catch {
153
+ // ignore
154
+ }
155
+ settleWaiters(record);
156
+ const type = state === "exited"
157
+ ? "process_exited"
158
+ : state === "killed"
159
+ ? "process_killed"
160
+ : state === "released"
161
+ ? "process_released"
162
+ : state === "expired"
163
+ ? "process_expired"
164
+ : "process_unknown";
165
+ emit({
166
+ type,
167
+ sessionId: record.id,
168
+ processId: record.pid !== undefined ? String(record.pid) : record.id,
169
+ owner: record.owner,
170
+ exitCode: record.exitCode,
171
+ at: record.exitedAt,
172
+ });
173
+ };
174
+ const assertPolicy = async (operation, command, args, cwd, owner) => {
175
+ try {
176
+ await assertExecutionAllowed(policy, {
177
+ kind: "shell",
178
+ operation,
179
+ command,
180
+ paths: [cwd],
181
+ risk: "high",
182
+ metadata: { args: [...args], owner },
183
+ });
184
+ return "allow";
185
+ }
186
+ catch (error) {
187
+ const message = error instanceof ExecutionDeniedError
188
+ ? (error.decision.reason ?? error.message)
189
+ : error instanceof Error
190
+ ? error.message
191
+ : String(error);
192
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", message);
193
+ }
194
+ };
195
+ const requireRecord = (sessionId, owner) => {
196
+ assertNotDisposed();
197
+ sweepExpired();
198
+ const record = sessions.get(sessionId);
199
+ if (!record)
200
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `unknown session: ${sessionId}`);
201
+ if (owner !== undefined && owner !== record.owner) {
202
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_OWNERSHIP", "session owner mismatch");
203
+ }
204
+ return record;
205
+ };
206
+ const makeHandle = (record) => {
207
+ const assertAttached = () => {
208
+ if (record.state === "released") {
209
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "session released");
210
+ }
211
+ };
212
+ const handle = {
213
+ get id() {
214
+ return record.id;
215
+ },
216
+ get state() {
217
+ sweepExpired();
218
+ void checkSandboxAlive();
219
+ return record.state;
220
+ },
221
+ get owner() {
222
+ return record.owner;
223
+ },
224
+ metadata() {
225
+ sweepExpired();
226
+ return {
227
+ id: record.id,
228
+ commandFingerprint: record.commandFingerprint,
229
+ owner: record.owner,
230
+ workspace: record.workspace,
231
+ policyDecision: record.policyDecision,
232
+ startedAt: record.startedAt,
233
+ exitedAt: record.exitedAt,
234
+ state: record.state,
235
+ releaseOnCancel: record.releaseOnCancel,
236
+ };
237
+ },
238
+ async output(request) {
239
+ assertAttached();
240
+ sweepExpired();
241
+ await checkSandboxAlive();
242
+ const cursor = request?.cursor ?? 0;
243
+ const maxBytes = request?.maxBytes ?? limits.maxOutputChunkBytes;
244
+ if (maxBytes > limits.maxOutputChunkBytes) {
245
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `maxBytes exceeds maxOutputChunkBytes (${limits.maxOutputChunkBytes})`);
246
+ }
247
+ const { data, nextCursor } = record.accumulator.readRaw(cursor, maxBytes);
248
+ const eof = record.state !== "running" && record.state !== "starting" && nextCursor >= record.accumulator.getTotalRawBytes();
249
+ return { data: data.toString("utf8"), cursor: nextCursor, eof };
250
+ },
251
+ async input(data) {
252
+ assertAttached();
253
+ sweepExpired();
254
+ await checkSandboxAlive();
255
+ if (record.state !== "running") {
256
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot input in state ${record.state}`);
257
+ }
258
+ await assertPolicy("process_input", record.command, record.args, record.workspace, record.owner);
259
+ const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
260
+ if (buf.byteLength > limits.maxInputBytes) {
261
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `input exceeds maxInputBytes (${limits.maxInputBytes})`);
262
+ }
263
+ if (record.backend) {
264
+ await record.backend.write(buf);
265
+ return;
266
+ }
267
+ if (record.stdinClosed || !record.child?.stdin.writable) {
268
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "stdin closed");
269
+ }
270
+ await new Promise((resolveWrite, rejectWrite) => {
271
+ record.child.stdin.write(buf, (err) => (err ? rejectWrite(err) : resolveWrite()));
272
+ });
273
+ },
274
+ async wait(waitOptions) {
275
+ assertAttached();
276
+ sweepExpired();
277
+ await checkSandboxAlive();
278
+ if (record.state !== "running" && record.state !== "starting") {
279
+ return { exitCode: record.exitCode, state: record.state };
280
+ }
281
+ return await new Promise((resolveWait, rejectWait) => {
282
+ const onDone = (result) => {
283
+ cleanup();
284
+ resolveWait(result);
285
+ };
286
+ const onAbort = () => {
287
+ cleanup();
288
+ rejectWait(new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "wait aborted"));
289
+ };
290
+ let timer;
291
+ const cleanup = () => {
292
+ if (timer)
293
+ clearTimeout(timer);
294
+ waitOptions?.signal?.removeEventListener("abort", onAbort);
295
+ const idx = record.waiters.indexOf(onDone);
296
+ if (idx >= 0)
297
+ record.waiters.splice(idx, 1);
298
+ };
299
+ record.waiters.push(onDone);
300
+ if (waitOptions?.timeoutMs !== undefined) {
301
+ timer = setTimeout(() => {
302
+ cleanup();
303
+ rejectWait(new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", "wait timed out"));
304
+ }, waitOptions.timeoutMs);
305
+ }
306
+ if (waitOptions?.signal) {
307
+ if (waitOptions.signal.aborted)
308
+ onAbort();
309
+ else
310
+ waitOptions.signal.addEventListener("abort", onAbort, { once: true });
311
+ }
312
+ });
313
+ },
314
+ async signal(name) {
315
+ assertAttached();
316
+ sweepExpired();
317
+ await checkSandboxAlive();
318
+ if (record.state !== "running") {
319
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot signal in state ${record.state}`);
320
+ }
321
+ await assertPolicy("process_signal", record.command, record.args, record.workspace, record.owner);
322
+ if (record.backend) {
323
+ await record.backend.signal(name);
324
+ return;
325
+ }
326
+ if (!record.pid)
327
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "process has no pid");
328
+ try {
329
+ process.kill(-record.pid, name);
330
+ }
331
+ catch {
332
+ try {
333
+ process.kill(record.pid, name);
334
+ }
335
+ catch (error) {
336
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", error instanceof Error ? error.message : String(error));
337
+ }
338
+ }
339
+ },
340
+ async kill() {
341
+ assertAttached();
342
+ sweepExpired();
343
+ await checkSandboxAlive();
344
+ if (record.state !== "running" && record.state !== "starting") {
345
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot kill in state ${record.state}`);
346
+ }
347
+ await assertPolicy("process_kill", record.command, record.args, record.workspace, record.owner);
348
+ if (record.backend) {
349
+ try {
350
+ await record.backend.kill();
351
+ }
352
+ catch {
353
+ // still mark killed
354
+ }
355
+ }
356
+ terminateRecord(record, "killed", null);
357
+ },
358
+ async release() {
359
+ assertAttached();
360
+ sweepExpired();
361
+ if (record.state !== "running" && record.state !== "starting") {
362
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot release in state ${record.state}`);
363
+ }
364
+ if (record.backend) {
365
+ try {
366
+ await record.backend.release();
367
+ }
368
+ catch {
369
+ // still mark released
370
+ }
371
+ }
372
+ terminateRecord(record, "released", null);
373
+ },
374
+ };
375
+ return handle;
376
+ };
377
+ return {
378
+ async start(request) {
379
+ assertNotDisposed();
380
+ sweepExpired();
381
+ await checkSandboxAlive();
382
+ if (request.pty) {
383
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_UNSUPPORTED", "PTY not supported on this host");
384
+ }
385
+ if (!request.command || typeof request.command !== "string") {
386
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "command required");
387
+ }
388
+ if (sandbox && typeof sandbox.startProcess !== "function") {
389
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", "sandbox adapter does not support startProcess");
390
+ }
391
+ if (sandboxLost) {
392
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", "sandbox lost; cannot start process");
393
+ }
394
+ const active = [...sessions.values()].filter((s) => s.state === "running" || s.state === "starting").length;
395
+ if (active >= limits.maxSessions) {
396
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `maxSessions (${limits.maxSessions}) reached`);
397
+ }
398
+ const cwdInput = request.cwd ?? ".";
399
+ const cwd = resolveToCwd(cwdInput, workspace);
400
+ if (!isInsideRoot(workspace, cwd)) {
401
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "cwd escapes workspace root");
402
+ }
403
+ const args = request.args ?? [];
404
+ const owner = request.owner ?? defaultOwner;
405
+ const lifetimeMs = request.lifetimeMs ?? limits.maxLifetimeMs;
406
+ if (lifetimeMs > limits.maxLifetimeMs) {
407
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `lifetimeMs exceeds maxLifetimeMs (${limits.maxLifetimeMs})`);
408
+ }
409
+ const policyDecision = await assertPolicy("process_start", request.command, args, cwd, owner);
410
+ const id = `proc_${randomBytes(8).toString("hex")}`;
411
+ const accumulator = new OutputAccumulator({
412
+ maxBytes: limits.maxOutputChunkBytes,
413
+ maxLines: 100_000,
414
+ maxTotalOutputBytes: limits.maxTotalOutputBytes,
415
+ tempFilePrefix: "prism-proc",
416
+ });
417
+ const record = {
418
+ id,
419
+ owner,
420
+ workspace,
421
+ command: request.command,
422
+ args,
423
+ commandFingerprint: commandFingerprint(request.command, args),
424
+ policyDecision,
425
+ startedAt: nowIso(),
426
+ releaseOnCancel: request.releaseOnCancel === true,
427
+ expiresAt: Date.now() + lifetimeMs,
428
+ state: "starting",
429
+ exitCode: null,
430
+ accumulator,
431
+ waiters: [],
432
+ stdinClosed: false,
433
+ handle: null,
434
+ };
435
+ record.handle = makeHandle(record);
436
+ sessions.set(id, record);
437
+ const onData = (buf) => {
438
+ accumulator.append(buf);
439
+ };
440
+ try {
441
+ if (sandbox?.startProcess) {
442
+ const handle = await sandbox.startProcess({
443
+ file: request.command,
444
+ args,
445
+ cwd,
446
+ env: request.env,
447
+ onData,
448
+ });
449
+ record.backend = handle;
450
+ record.state = "running";
451
+ void handle
452
+ .wait()
453
+ .then((result) => {
454
+ if (record.state !== "running" && record.state !== "starting")
455
+ return;
456
+ terminateRecord(record, "exited", result.exitCode);
457
+ })
458
+ .catch(() => {
459
+ if (record.state !== "running" && record.state !== "starting")
460
+ return;
461
+ terminateRecord(record, "unknown", null);
462
+ });
463
+ }
464
+ else {
465
+ const env = { ...process.env, ...(request.env ?? {}) };
466
+ const child = spawn(request.command, [...args], {
467
+ cwd,
468
+ env,
469
+ stdio: ["pipe", "pipe", "pipe"],
470
+ detached: process.platform !== "win32",
471
+ windowsHide: true,
472
+ });
473
+ record.child = child;
474
+ record.pid = child.pid;
475
+ record.state = "running";
476
+ child.stdout.on("data", onData);
477
+ child.stderr.on("data", onData);
478
+ child.stdin.on("close", () => {
479
+ record.stdinClosed = true;
480
+ });
481
+ child.on("error", () => {
482
+ terminateRecord(record, "unknown", null);
483
+ });
484
+ child.on("exit", (code, signal) => {
485
+ if (record.state !== "running" && record.state !== "starting")
486
+ return;
487
+ if (signal)
488
+ terminateRecord(record, "killed", code);
489
+ else
490
+ terminateRecord(record, "exited", code);
491
+ });
492
+ }
493
+ }
494
+ catch (error) {
495
+ sessions.delete(id);
496
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", error instanceof Error ? error.message : String(error));
497
+ }
498
+ emit({
499
+ type: "process_started",
500
+ sessionId: id,
501
+ processId: record.pid !== undefined ? String(record.pid) : id,
502
+ owner,
503
+ at: record.startedAt,
504
+ });
505
+ return record.handle;
506
+ },
507
+ get(sessionId, owner) {
508
+ return requireRecord(sessionId, owner).handle;
509
+ },
510
+ async cancelOwned(owner, cancelOptions) {
511
+ assertNotDisposed();
512
+ sweepExpired();
513
+ await checkSandboxAlive();
514
+ const release = cancelOptions?.release === true;
515
+ for (const record of [...sessions.values()]) {
516
+ if (record.owner !== owner)
517
+ continue;
518
+ if (record.state !== "running" && record.state !== "starting")
519
+ continue;
520
+ if (release || record.releaseOnCancel) {
521
+ if (record.backend) {
522
+ try {
523
+ await record.backend.release();
524
+ }
525
+ catch {
526
+ // continue
527
+ }
528
+ }
529
+ terminateRecord(record, "released", null);
530
+ }
531
+ else {
532
+ if (record.backend) {
533
+ try {
534
+ await record.backend.kill();
535
+ }
536
+ catch {
537
+ // continue
538
+ }
539
+ }
540
+ terminateRecord(record, "killed", null);
541
+ }
542
+ }
543
+ },
544
+ async markUnknown(sessionId, owner) {
545
+ const record = requireRecord(sessionId, owner);
546
+ if (record.state !== "running" && record.state !== "starting") {
547
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot mark unknown in state ${record.state}`);
548
+ }
549
+ terminateRecord(record, "unknown", null);
550
+ },
551
+ async reconcile() {
552
+ assertNotDisposed();
553
+ const markedUnknown = reconcileAllUnknown();
554
+ return { markedUnknown };
555
+ },
556
+ async dispose() {
557
+ if (disposed)
558
+ return;
559
+ disposed = true;
560
+ for (const record of [...sessions.values()]) {
561
+ if (record.state === "running" || record.state === "starting") {
562
+ if (record.backend) {
563
+ try {
564
+ await record.backend.kill();
565
+ }
566
+ catch {
567
+ // best effort
568
+ }
569
+ }
570
+ terminateRecord(record, "killed", null);
571
+ }
572
+ else if (record.state === "released" && record.pid) {
573
+ try {
574
+ killProcessTree(record.pid);
575
+ }
576
+ catch {
577
+ // best effort
578
+ }
579
+ record.pid = undefined;
580
+ }
581
+ try {
582
+ await record.accumulator.cleanupTempFile();
583
+ }
584
+ catch {
585
+ // best effort
586
+ }
587
+ }
588
+ sessions.clear();
589
+ },
590
+ };
591
+ }
592
+ //# sourceMappingURL=sessions.js.map