@smthrs/platform-node 1.0.0-rc.3 → 1.0.0-rc.5

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.
Files changed (47) hide show
  1. package/bin/darwin-arm64/smithers-jj-export +0 -0
  2. package/bin/darwin-x64/smithers-jj-export +0 -0
  3. package/bin/linux-arm64/smithers-jj-export +0 -0
  4. package/bin/linux-x64/smithers-jj-export +2 -2
  5. package/bin/win32-x64/smithers-jj-export.exe +0 -0
  6. package/dist/cjs/ProcessConfinement.d.ts.map +1 -1
  7. package/dist/cjs/ProcessConfinement.js +6 -1
  8. package/dist/cjs/ProcessConfinement.js.map +2 -2
  9. package/dist/cjs/ProcessSandbox.d.ts +16 -2
  10. package/dist/cjs/ProcessSandbox.d.ts.map +1 -1
  11. package/dist/cjs/ProcessSandbox.js +17 -2
  12. package/dist/cjs/ProcessSandbox.js.map +2 -2
  13. package/dist/cjs/ScopedProcess.d.ts +2 -1
  14. package/dist/cjs/ScopedProcess.d.ts.map +1 -1
  15. package/dist/cjs/ScopedProcess.js.map +2 -2
  16. package/dist/cjs/internal/ProcessSupervisor.d.ts +2 -0
  17. package/dist/cjs/internal/ProcessSupervisor.d.ts.map +1 -1
  18. package/dist/cjs/internal/ProcessSupervisor.js +5 -2
  19. package/dist/cjs/internal/ProcessSupervisor.js.map +2 -2
  20. package/dist/cjs/internal/SupervisorProgram.d.ts.map +1 -1
  21. package/dist/cjs/internal/SupervisorProgram.js +23 -3
  22. package/dist/cjs/internal/SupervisorProgram.js.map +2 -2
  23. package/dist/esm/ProcessConfinement.d.ts.map +1 -1
  24. package/dist/esm/ProcessConfinement.js.map +1 -1
  25. package/dist/esm/ProcessSandbox.d.ts +16 -2
  26. package/dist/esm/ProcessSandbox.d.ts.map +1 -1
  27. package/dist/esm/ProcessSandbox.js +33 -4
  28. package/dist/esm/ProcessSandbox.js.map +1 -1
  29. package/dist/esm/ScopedProcess.d.ts +2 -1
  30. package/dist/esm/ScopedProcess.d.ts.map +1 -1
  31. package/dist/esm/ScopedProcess.js +2 -1
  32. package/dist/esm/ScopedProcess.js.map +1 -1
  33. package/dist/esm/internal/ProcessSupervisor.d.ts +2 -0
  34. package/dist/esm/internal/ProcessSupervisor.d.ts.map +1 -1
  35. package/dist/esm/internal/ProcessSupervisor.js +9 -2
  36. package/dist/esm/internal/ProcessSupervisor.js.map +1 -1
  37. package/dist/esm/internal/SupervisorProgram.d.ts.map +1 -1
  38. package/dist/esm/internal/SupervisorProgram.js +23 -3
  39. package/dist/esm/internal/SupervisorProgram.js.map +1 -1
  40. package/docs/api.md +2 -1
  41. package/docs/concepts/process-containment.md +8 -0
  42. package/package.json +4 -4
  43. package/src/ProcessConfinement.ts +10 -3
  44. package/src/ProcessSandbox.ts +38 -4
  45. package/src/ScopedProcess.ts +2 -1
  46. package/src/internal/ProcessSupervisor.ts +8 -2
  47. package/src/internal/SupervisorProgram.ts +23 -3
@@ -299,14 +299,21 @@ export const make = (options: Options = {}): KernelProcessConfinement.Service =>
299
299
  // installed outside its ordinary runtime paths.
300
300
  const originalArgv = argvOf(command)
301
301
  const launch = ProcessSandbox.launcher(
302
- originalArgv[0]!, targetEnvironment.PATH,
302
+ originalArgv[0]!,
303
+ targetEnvironment.PATH,
303
304
  [...confinement.reads, ...confinement.writes, ...confinement.externalReads],
304
- confinement.workspaceRoot, hostFacts
305
+ confinement.workspaceRoot,
306
+ hostFacts
305
307
  )
306
308
  const targetArgv = ["/usr/bin/env", "-i", "/bin/sh", script, launch.program, ...originalArgv.slice(1)]
307
309
  const wrapped = yield* Effect.try({
308
310
  try: () =>
309
- ProcessSandbox.wrap({ ...confinement, externalReads: [...confinement.externalReads, ...launch.reads, script] }, targetArgv, targetEnvironment, hostFacts),
311
+ ProcessSandbox.wrap(
312
+ { ...confinement, externalReads: [...confinement.externalReads, ...launch.reads, script] },
313
+ targetArgv,
314
+ targetEnvironment,
315
+ hostFacts
316
+ ),
310
317
  catch: (cause) => failure("Unknown", command, `could not render the confinement: ${messageOf(cause)}`, cause)
311
318
  })
312
319
  let [executable, ...args] = wrapped.argv
@@ -135,6 +135,12 @@ export interface Host {
135
135
  * paths. Read aliases are only collected when this probe is supplied.
136
136
  */
137
137
  readonly realpath?: ((path: string) => string | undefined) | undefined
138
+ /**
139
+ * The program a package-manager shim script runs: the absolute path on the
140
+ * `# cmd-shim-target=` line pnpm's cmd-shim writes, or `undefined` for any
141
+ * other file. Optional: a host without it treats a shim as the program.
142
+ */
143
+ readonly shimTarget?: ((path: string) => string | undefined) | undefined
138
144
  readonly uid: number | undefined
139
145
  readonly gid: number | undefined
140
146
  }
@@ -301,11 +307,27 @@ export const host = (env: Readonly<Record<string, string | undefined>> = ambient
301
307
  return undefined
302
308
  }
303
309
  },
310
+ shimTarget,
304
311
  uid: typeof process.getuid === "function" ? process.getuid() : undefined,
305
312
  gid: typeof process.getgid === "function" ? process.getgid() : undefined
306
313
  }
307
314
  }
308
315
 
316
+ /** A cmd-shim is a page of shell; a larger file is some other program. */
317
+ const shimLimit = 64 * 1024
318
+
319
+ const shimTarget = (path: string): string | undefined => {
320
+ try {
321
+ if (NodeFs.statSync(path).size > shimLimit) return undefined
322
+ const text = NodeFs.readFileSync(path, "utf8")
323
+ if (!text.startsWith("#!")) return undefined
324
+ const target = /^# cmd-shim-target=(.+)$/m.exec(text)?.[1]
325
+ return target !== undefined && NodePath.isAbsolute(target) ? target : undefined
326
+ } catch {
327
+ return undefined
328
+ }
329
+ }
330
+
309
331
  const isExecutable = (path: string): boolean => {
310
332
  try {
311
333
  NodeFs.accessSync(path, NodeFs.constants.X_OK)
@@ -810,8 +832,6 @@ const credentialPaths = (hostFacts: Host): ReadonlyArray<string> => {
810
832
  /**
811
833
  * The program a confined run execs, made reachable inside its empty root.
812
834
  *
813
- * @internal
814
- *
815
835
  * Bubblewrap resolves a bare program name with `execvp` against `PATH` inside
816
836
  * the new root, and that root holds only the runtime paths and the declared
817
837
  * sets. A package manager installed outside them — pnpm under the
@@ -828,6 +848,16 @@ const credentialPaths = (hostFacts: Host): ReadonlyArray<string> => {
828
848
  * is bound read-only, because a script like `pnpm.cjs` loads its siblings.
829
849
  * With no package directory only the real file is bound. A name that resolves
830
850
  * nowhere is left for `execvp` to report.
851
+ *
852
+ * A package-manager shim script is followed like a link. `pnpm self-update`
853
+ * under `pnpm/action-setup` installs pnpm as a cmd-shim in `$PNPM_HOME/bin`
854
+ * that runs node on a package in pnpm's global store, a path relative to the
855
+ * script; binding the script alone left that package missing. When the
856
+ * program the shim names is executable, the run execs its real file and binds
857
+ * its package as above.
858
+ *
859
+ * @since 1.0.0
860
+ * @private
831
861
  */
832
862
  export const launcher = (
833
863
  program: string,
@@ -846,8 +876,12 @@ export const launcher = (
846
876
  .map((entry) => hostFacts.executable(NodePath.join(entry, program)))
847
877
  .find((candidate) => candidate !== undefined)
848
878
  if (found === undefined || granted.some((parent) => insideRoot(parent, found))) return unchanged
849
- const real = hostFacts.realpath?.(found) ?? found
850
- if (insideRoot(workspaceRoot, found) || insideRoot(workspaceRoot, real)) return unchanged
879
+ const resolved = hostFacts.realpath?.(found) ?? found
880
+ const named = hostFacts.shimTarget?.(resolved)
881
+ const real = named === undefined || hostFacts.executable(named) === undefined
882
+ ? resolved
883
+ : hostFacts.realpath?.(named) ?? named
884
+ if ([found, resolved, real].some((path) => insideRoot(workspaceRoot, path))) return unchanged
851
885
  if (granted.some((parent) => insideRoot(parent, real))) return { program: real, reads: [] }
852
886
  let directory = NodePath.dirname(real)
853
887
  for (let level = 0; level < 3; level++) {
@@ -41,7 +41,8 @@ export interface Handle extends ChildProcessHandle {
41
41
  /**
42
42
  * Starts a pipe-based command and contains its lifetime inside the caller's
43
43
  * scope. The returned pid names the live supervisor; exitCode reports
44
- * the actual target. The private parent connection stops the tree on host loss.
44
+ * the actual target. The private parent connection stops the tree on host loss,
45
+ * and the supervisor's group guardian stops the group on supervisor loss.
45
46
  * Transient commands have no durable ledger; durable hosts use NodeHost instead.
46
47
  *
47
48
  * Unconsumed output stays in bounded native buffers until it is read or the
@@ -222,6 +222,8 @@ export class Control {
222
222
  targetDone = false
223
223
  ownerCreation: unknown
224
224
  targetPid: number | undefined
225
+ /** The owner's group guardian: a member that is neither target nor owner. */
226
+ guardianPid: number | undefined
225
227
  ownerDone = false
226
228
  spawnFailed = false
227
229
  cleanupFailed = false
@@ -425,12 +427,15 @@ export class Control {
425
427
  return
426
428
  case "spawned":
427
429
  if (
428
- !this.activationSent || this.receivedStarted || !Number.isSafeInteger(message.pid) || Number(message.pid) <= 1
430
+ !this.activationSent || this.receivedStarted || !Number.isSafeInteger(message.pid) ||
431
+ Number(message.pid) <= 1 ||
432
+ message.guardian !== undefined && (!Number.isSafeInteger(message.guardian) || Number(message.guardian) <= 1)
429
433
  ) {
430
434
  throw processFault("status_invalid", "Invalid target startup")
431
435
  }
432
436
  this.receivedStarted = true
433
437
  this.targetPid = Number(message.pid)
438
+ if (message.guardian !== undefined) this.guardianPid = Number(message.guardian)
434
439
  this.started.resolve()
435
440
  return
436
441
  case "spawn_error":
@@ -552,7 +557,8 @@ export const prepare = (
552
557
  if (!grouped) return control.targetDone
553
558
  const observed = snapshot()
554
559
  if (observed === undefined || observed.ownGroup === raw.pid) return false
555
- const running = observed.members.filter((member) => !member.zombie)
560
+ // The guardian leaves with the owner's final group SIGKILL.
561
+ const running = observed.members.filter((member) => !member.zombie && member.pid !== control.guardianPid)
556
562
  return running.length === 1 && running[0]!.pid === raw.pid
557
563
  }
558
564
  // This observation only shortens the owner's deadline. The live owner makes
@@ -44,6 +44,13 @@ const control = connect(socketPath);
44
44
  const requests = connect(socketPath.replace(/\/s$/, '/r'));
45
45
  let config;
46
46
  let target;
47
+ // An owner killed outright (SIGKILL, the OOM killer) runs no handler, so it
48
+ // cannot stop its group. Its guardian is a member of that group that waits on
49
+ // a pipe only the owner holds. At EOF it kills its own group: kill 0 names the
50
+ // caller's group, never a number that could be stale. It ignores the catchable
51
+ // signals a stop sends the group and dies in the owner's final SIGKILL.
52
+ let guardian;
53
+ const guardianScript = "command trap '' HUP INT QUIT ABRT ALRM TERM USR1 USR2 PIPE TSTP TTIN TTOU; read -r line; kill -s KILL 0";
47
54
  let targetDone = false;
48
55
  let stopping = false;
49
56
  let explicitStop = false;
@@ -204,8 +211,9 @@ const settleVacantGroup = () => {
204
211
  let rows;
205
212
  try { rows = snapshot(); } catch { return; }
206
213
  for (const row of rows.values()) {
207
- // The owner's only other child is the ps answering this snapshot; the
208
- // exited target is already reaped and its orphans have another parent.
214
+ // The owner's other children are its guardian and the ps answering this
215
+ // snapshot; the exited target is already reaped and its orphans have
216
+ // another parent.
209
217
  if (row.group === process.pid && row.pid !== process.pid && row.parent !== process.pid && !row.zombie) return;
210
218
  }
211
219
  clearTimeout(timer);
@@ -251,6 +259,18 @@ requests.on('data', (data) => {
251
259
  if (config.standardFds !== undefined) {
252
260
  for (const fd of [0, 1, 2]) descriptors[fd] = config.standardFds[fd];
253
261
  }
262
+ if (grouped) {
263
+ // Started before the target, so no target outlives an owner unguarded.
264
+ // Every caller descriptor slot is replaced: a guardian holding a copy
265
+ // of the caller's output would keep that pipe from reaching EOF.
266
+ const guarded = descriptors.map(() => 'ignore');
267
+ guarded[0] = 'pipe';
268
+ guardian = cp.spawn('/bin/sh', ['-c', guardianScript], { cwd: '/', env: {}, detached: false, stdio: guarded });
269
+ // A failed launch has no pid; its later error event is the same fault.
270
+ guardian.once('error', () => {});
271
+ if (guardian.pid === undefined) throw new SupervisorFault('guardian_unavailable', 'Process group guardian unavailable');
272
+ guardian.stdin.on('error', () => {});
273
+ }
254
274
  try {
255
275
  target = cp.spawn(config.command, config.args, {
256
276
  cwd: config.cwd, env: config.env, shell: config.shell,
@@ -273,7 +293,7 @@ requests.on('data', (data) => {
273
293
  }
274
294
  }
275
295
  for (const fd of config.userFds) fs.closeSync(fd);
276
- send({ type: 'spawned', pid: target.pid });
296
+ send({ type: 'spawned', pid: target.pid, ...(guardian === undefined ? {} : { guardian: guardian.pid }) });
277
297
  });
278
298
  target.once('error', spawnError);
279
299
  target.once('exit', (code, signal) => {