@smthrs/platform-node 1.0.0-rc.3 → 1.0.0-rc.4
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/bin/darwin-arm64/smithers-jj-export +0 -0
- package/bin/darwin-x64/smithers-jj-export +0 -0
- package/bin/linux-arm64/smithers-jj-export +0 -0
- package/bin/linux-x64/smithers-jj-export +2 -2
- package/bin/win32-x64/smithers-jj-export.exe +0 -0
- package/dist/cjs/ProcessConfinement.d.ts.map +1 -1
- package/dist/cjs/ProcessConfinement.js +6 -1
- package/dist/cjs/ProcessConfinement.js.map +2 -2
- package/dist/cjs/ProcessSandbox.d.ts +16 -2
- package/dist/cjs/ProcessSandbox.d.ts.map +1 -1
- package/dist/cjs/ProcessSandbox.js +17 -2
- package/dist/cjs/ProcessSandbox.js.map +2 -2
- package/dist/cjs/ScopedProcess.d.ts +2 -1
- package/dist/cjs/ScopedProcess.d.ts.map +1 -1
- package/dist/cjs/ScopedProcess.js.map +2 -2
- package/dist/cjs/internal/ProcessSupervisor.d.ts +2 -0
- package/dist/cjs/internal/ProcessSupervisor.d.ts.map +1 -1
- package/dist/cjs/internal/ProcessSupervisor.js +5 -2
- package/dist/cjs/internal/ProcessSupervisor.js.map +2 -2
- package/dist/cjs/internal/SupervisorProgram.d.ts.map +1 -1
- package/dist/cjs/internal/SupervisorProgram.js +23 -3
- package/dist/cjs/internal/SupervisorProgram.js.map +2 -2
- package/dist/esm/ProcessConfinement.d.ts.map +1 -1
- package/dist/esm/ProcessConfinement.js.map +1 -1
- package/dist/esm/ProcessSandbox.d.ts +16 -2
- package/dist/esm/ProcessSandbox.d.ts.map +1 -1
- package/dist/esm/ProcessSandbox.js +33 -4
- package/dist/esm/ProcessSandbox.js.map +1 -1
- package/dist/esm/ScopedProcess.d.ts +2 -1
- package/dist/esm/ScopedProcess.d.ts.map +1 -1
- package/dist/esm/ScopedProcess.js +2 -1
- package/dist/esm/ScopedProcess.js.map +1 -1
- package/dist/esm/internal/ProcessSupervisor.d.ts +2 -0
- package/dist/esm/internal/ProcessSupervisor.d.ts.map +1 -1
- package/dist/esm/internal/ProcessSupervisor.js +9 -2
- package/dist/esm/internal/ProcessSupervisor.js.map +1 -1
- package/dist/esm/internal/SupervisorProgram.d.ts.map +1 -1
- package/dist/esm/internal/SupervisorProgram.js +23 -3
- package/dist/esm/internal/SupervisorProgram.js.map +1 -1
- package/docs/api.md +2 -1
- package/docs/concepts/process-containment.md +8 -0
- package/package.json +4 -4
- package/src/ProcessConfinement.ts +10 -3
- package/src/ProcessSandbox.ts +38 -4
- package/src/ScopedProcess.ts +2 -1
- package/src/internal/ProcessSupervisor.ts +8 -2
- 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]!,
|
|
302
|
+
originalArgv[0]!,
|
|
303
|
+
targetEnvironment.PATH,
|
|
303
304
|
[...confinement.reads, ...confinement.writes, ...confinement.externalReads],
|
|
304
|
-
confinement.workspaceRoot,
|
|
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(
|
|
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
|
package/src/ProcessSandbox.ts
CHANGED
|
@@ -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
|
|
850
|
-
|
|
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++) {
|
package/src/ScopedProcess.ts
CHANGED
|
@@ -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) ||
|
|
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
|
-
|
|
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
|
|
208
|
-
// exited target is already reaped and its orphans have
|
|
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) => {
|