looply-cli 0.5.1 → 0.5.3
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/dist/.src-hash +1 -1
- package/dist/agent-contained.d.ts +2 -0
- package/dist/agent-contained.d.ts.map +1 -1
- package/dist/agent-contained.js +22 -5
- package/dist/agent-contained.js.map +1 -1
- package/dist/agent-exec.d.ts.map +1 -1
- package/dist/agent-exec.js +1 -0
- package/dist/agent-exec.js.map +1 -1
- package/dist/autonomy-tick.d.ts +46 -0
- package/dist/autonomy-tick.d.ts.map +1 -1
- package/dist/autonomy-tick.js +44 -2
- package/dist/autonomy-tick.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +131 -93
- package/dist/bin.js.map +1 -1
- package/dist/brain-advice.js +2 -2
- package/dist/brain-advice.js.map +1 -1
- package/dist/command-runner.d.ts.map +1 -1
- package/dist/command-runner.js +1 -0
- package/dist/command-runner.js.map +1 -1
- package/dist/connect-node.d.ts.map +1 -1
- package/dist/connect-node.js +8 -4
- package/dist/connect-node.js.map +1 -1
- package/dist/connect.d.ts +8 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +22 -0
- package/dist/connect.js.map +1 -1
- package/dist/contained-node.d.ts +51 -0
- package/dist/contained-node.d.ts.map +1 -0
- package/dist/contained-node.js +116 -0
- package/dist/contained-node.js.map +1 -0
- package/dist/daemon-cmd.d.ts.map +1 -1
- package/dist/daemon-cmd.js +54 -2
- package/dist/daemon-cmd.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +11 -1
- package/dist/daemon.js.map +1 -1
- package/dist/exec-domains.d.ts +4 -0
- package/dist/exec-domains.d.ts.map +1 -1
- package/dist/exec-domains.js +7 -2
- package/dist/exec-domains.js.map +1 -1
- package/dist/gate-draft.d.ts +99 -0
- package/dist/gate-draft.d.ts.map +1 -0
- package/dist/gate-draft.js +544 -0
- package/dist/gate-draft.js.map +1 -0
- package/dist/git-driver.d.ts.map +1 -1
- package/dist/git-driver.js +1 -0
- package/dist/git-driver.js.map +1 -1
- package/dist/init-detect.d.ts +20 -10
- package/dist/init-detect.d.ts.map +1 -1
- package/dist/init-detect.js +27 -85
- package/dist/init-detect.js.map +1 -1
- package/dist/model-transport.d.ts.map +1 -1
- package/dist/model-transport.js +25 -8
- package/dist/model-transport.js.map +1 -1
- package/dist/notify-channel.d.ts.map +1 -1
- package/dist/notify-channel.js +1 -0
- package/dist/notify-channel.js.map +1 -1
- package/dist/provider-cmd.d.ts +56 -0
- package/dist/provider-cmd.d.ts.map +1 -0
- package/dist/provider-cmd.js +260 -0
- package/dist/provider-cmd.js.map +1 -0
- package/dist/provider-discovery.d.ts +75 -0
- package/dist/provider-discovery.d.ts.map +1 -0
- package/dist/provider-discovery.js +235 -0
- package/dist/provider-discovery.js.map +1 -0
- package/dist/provider-identity.d.ts +3 -3
- package/dist/provider-identity.d.ts.map +1 -1
- package/dist/provider-identity.js +4 -5
- package/dist/provider-identity.js.map +1 -1
- package/dist/provider-preflight.d.ts.map +1 -1
- package/dist/provider-preflight.js +1 -0
- package/dist/provider-preflight.js.map +1 -1
- package/dist/provider-ready.d.ts +66 -0
- package/dist/provider-ready.d.ts.map +1 -0
- package/dist/provider-ready.js +105 -0
- package/dist/provider-ready.js.map +1 -0
- package/dist/provider-state.d.ts.map +1 -1
- package/dist/provider-state.js +1 -0
- package/dist/provider-state.js.map +1 -1
- package/dist/refresh-gates.d.ts +47 -0
- package/dist/refresh-gates.d.ts.map +1 -0
- package/dist/refresh-gates.js +128 -0
- package/dist/refresh-gates.js.map +1 -0
- package/dist/repo-init.d.ts +5 -1
- package/dist/repo-init.d.ts.map +1 -1
- package/dist/repo-init.js +19 -3
- package/dist/repo-init.js.map +1 -1
- package/dist/run-cmd.js +4 -4
- package/dist/run-cmd.js.map +1 -1
- package/dist/run-launcher.d.ts +5 -0
- package/dist/run-launcher.d.ts.map +1 -1
- package/dist/run-launcher.js +38 -1
- package/dist/run-launcher.js.map +1 -1
- package/dist/serve-cmd.d.ts.map +1 -1
- package/dist/serve-cmd.js +14 -5
- package/dist/serve-cmd.js.map +1 -1
- package/dist/serve.d.ts +19 -7
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +28 -12
- package/dist/serve.js.map +1 -1
- package/dist/server-client.d.ts +12 -1
- package/dist/server-client.d.ts.map +1 -1
- package/dist/server-client.js +10 -2
- package/dist/server-client.js.map +1 -1
- package/dist/session-checkpoint-runtime.d.ts.map +1 -1
- package/dist/session-checkpoint-runtime.js +1 -0
- package/dist/session-checkpoint-runtime.js.map +1 -1
- package/dist/setup.d.ts +7 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +13 -13
- package/dist/setup.js.map +1 -1
- package/dist/shared-grants.js +2 -2
- package/dist/shared-grants.js.map +1 -1
- package/dist/toolchain-mirror.js +1 -1
- package/dist/toolchain-mirror.js.map +1 -1
- package/dist/untrusted-exec.d.ts +23 -0
- package/dist/untrusted-exec.d.ts.map +1 -1
- package/dist/untrusted-exec.js +18 -1
- package/dist/untrusted-exec.js.map +1 -1
- package/dist/untrusted-gate.d.ts.map +1 -1
- package/dist/untrusted-gate.js +4 -0
- package/dist/untrusted-gate.js.map +1 -1
- package/package.json +2 -2
- package/win/appcontainer.ps1 +97 -4
- package/win/contained-node.cjs +530 -0
package/win/appcontainer.ps1
CHANGED
|
@@ -358,6 +358,39 @@ public static class LooplyAc {
|
|
|
358
358
|
|
|
359
359
|
[DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr h);
|
|
360
360
|
static void ResumeThreadSafe(IntPtr h) { ResumeThread(h); }
|
|
361
|
+
|
|
362
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool DefineDosDevice(uint flags, string name, string target);
|
|
363
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern uint QueryDosDevice(string name, StringBuilder buf, int max);
|
|
364
|
+
|
|
365
|
+
const uint DDD_RAW_TARGET_PATH = 0x1, DDD_REMOVE_DEFINITION = 0x2, DDD_EXACT_MATCH_ON_REMOVE = 0x4, DDD_NO_BROADCAST_SYSTEM = 0x8;
|
|
366
|
+
|
|
367
|
+
/** The CURRENT target of a DOS device name such as `Q:`, or null when it names nothing. */
|
|
368
|
+
public static string DeviceTarget(string name) {
|
|
369
|
+
StringBuilder sb = new StringBuilder(4096);
|
|
370
|
+
// The buffer holds a null-separated list, newest first; the builder stops
|
|
371
|
+
// at the first null, which is the definition in force.
|
|
372
|
+
return QueryDosDevice(name, sb, sb.Capacity) == 0 ? null : sb.ToString();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Map `name` to `rawTarget` (`\??\C:\path`), and prove it is OUR definition
|
|
377
|
+
* that is in force. DefineDosDevice does not refuse a name already in use —
|
|
378
|
+
* it stacks a new definition on top — so a name someone else took between
|
|
379
|
+
* the caller's check and this call is detected here and given back.
|
|
380
|
+
*
|
|
381
|
+
* No broadcast: Explorer is not told, so no drive appears in its windows.
|
|
382
|
+
*/
|
|
383
|
+
public static bool MapDrive(string name, string rawTarget) {
|
|
384
|
+
if (!DefineDosDevice(DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, name, rawTarget)) return false;
|
|
385
|
+
if (DeviceTarget(name) == rawTarget) return true;
|
|
386
|
+
UnmapDrive(name, rawTarget);
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Remove exactly this definition — never one stacked above or below it. */
|
|
391
|
+
public static bool UnmapDrive(string name, string rawTarget) {
|
|
392
|
+
return DefineDosDevice(DDD_RAW_TARGET_PATH | DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE | DDD_NO_BROADCAST_SYSTEM, name, rawTarget);
|
|
393
|
+
}
|
|
361
394
|
}
|
|
362
395
|
'@
|
|
363
396
|
|
|
@@ -485,9 +518,69 @@ if ($req.network -eq 'internet-client') {
|
|
|
485
518
|
$caps = @('S-1-15-3-1', 'S-1-15-3-3')
|
|
486
519
|
}
|
|
487
520
|
$stdinPath = if ($req.stdinPath) { [string]$req.stdinPath } else { '' }
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
521
|
+
|
|
522
|
+
# --- drive root --------------------------------------------------------------
|
|
523
|
+
# A container cannot list the directories ABOVE its grant — C:\, C:\Users, the
|
|
524
|
+
# profile — and esbuild's resolver (native Go, so no preload reaches it) lists
|
|
525
|
+
# every ancestor from the drive root down and aborts on "Access is denied" on
|
|
526
|
+
# Windows, where its "treat EACCES as empty" check does not match. That stops
|
|
527
|
+
# Vite, and with it Vitest, before a single test runs (measured on Tabsy).
|
|
528
|
+
#
|
|
529
|
+
# So the step runs from a DRIVE LETTER mapped to the project: the walk's root is
|
|
530
|
+
# then the project itself, which the container can read. A drive letter is a
|
|
531
|
+
# NAME, not a grant — every access still lands on the real files and is decided
|
|
532
|
+
# by their ACLs, so the container reaches nothing it could not reach before.
|
|
533
|
+
#
|
|
534
|
+
# The letter is claimed through a ledger file created exclusively, so two
|
|
535
|
+
# helpers never stack on one letter; it is removed in `finally`, by exact
|
|
536
|
+
# match; and a mapping whose helper died is removed by the next helper to start.
|
|
537
|
+
# Any failure here runs the step from its real path, exactly as before.
|
|
538
|
+
$cwd = [string]$req.cwd
|
|
539
|
+
$drive = $null; $driveTarget = $null; $driveClaim = $null
|
|
540
|
+
if ($req.driveRoot -and $req.driveLedger -and (Test-Path -LiteralPath $req.driveRoot -PathType Container)) {
|
|
541
|
+
try {
|
|
542
|
+
$root = ([string]$req.driveRoot).TrimEnd('\')
|
|
543
|
+
New-Item -ItemType Directory -Force -Path $req.driveLedger | Out-Null
|
|
544
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $req.driveLedger -Filter '*.drive' -File)) {
|
|
545
|
+
$parts = ([IO.File]::ReadAllText($f.FullName)) -split '\|', 2
|
|
546
|
+
if ($parts.Count -ne 2) { continue }
|
|
547
|
+
if (Get-Process -Id ([int]$parts[0]) -ErrorAction SilentlyContinue) { continue }
|
|
548
|
+
[LooplyAc]::UnmapDrive(($f.BaseName + ':'), $parts[1]) | Out-Null
|
|
549
|
+
Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue
|
|
550
|
+
}
|
|
551
|
+
foreach ($l in [char[]]'ZYXWVUTSRQPONMLKJIHGFED') {
|
|
552
|
+
$name = "${l}:"
|
|
553
|
+
if ([LooplyAc]::DeviceTarget($name)) { continue }
|
|
554
|
+
$claimPath = Join-Path $req.driveLedger "$l.drive"
|
|
555
|
+
try { $claim = [IO.File]::Open($claimPath, 'CreateNew', 'Write') } catch { continue }
|
|
556
|
+
$target = '\??\' + $root
|
|
557
|
+
$bytes = [Text.Encoding]::UTF8.GetBytes("$PID|$target")
|
|
558
|
+
$claim.Write($bytes, 0, $bytes.Length); $claim.Close()
|
|
559
|
+
if ([LooplyAc]::MapDrive($name, $target)) { $drive = $name; $driveTarget = $target; $driveClaim = $claimPath; break }
|
|
560
|
+
Remove-Item -LiteralPath $claimPath -Force -ErrorAction SilentlyContinue
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
if ($drive) { [LooplyAc]::UnmapDrive($drive, $driveTarget) | Out-Null; Remove-Item -LiteralPath $driveClaim -Force -ErrorAction SilentlyContinue }
|
|
564
|
+
$drive = $null
|
|
565
|
+
}
|
|
566
|
+
if ($drive) {
|
|
567
|
+
$rest = $null
|
|
568
|
+
if ($cwd.Equals($root, [StringComparison]::OrdinalIgnoreCase)) { $rest = '' }
|
|
569
|
+
elseif ($cwd.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) { $rest = $cwd.Substring($root.Length + 1) }
|
|
570
|
+
if ($rest -ne $null) { $cwd = $drive + '\' + $rest }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
$runErr = [LooplyAc]::Run($req.executable, $req.commandLine, $cwd, [string[]]$envPairs,
|
|
576
|
+
$req.stdoutPath, $req.stderrPath, $stdinPath, [uint32]$req.timeoutMs, [string[]]$caps,
|
|
577
|
+
[ref]$exitCode, [ref]$timedOut)
|
|
578
|
+
} finally {
|
|
579
|
+
if ($drive) {
|
|
580
|
+
[LooplyAc]::UnmapDrive($drive, $driveTarget) | Out-Null
|
|
581
|
+
Remove-Item -LiteralPath $driveClaim -Force -ErrorAction SilentlyContinue
|
|
582
|
+
}
|
|
583
|
+
}
|
|
491
584
|
if ($runErr) {
|
|
492
585
|
# The environment count travels with the failure: CreateProcess answers 203
|
|
493
586
|
# (ERROR_ENVVAR_NOT_FOUND) for an EMPTY block as readily as a malformed one,
|
|
@@ -496,5 +589,5 @@ if ($runErr) {
|
|
|
496
589
|
exit 0
|
|
497
590
|
}
|
|
498
591
|
|
|
499
|
-
Write-Result @{ ok = $true; stage = 'ran'; sid = $sidStr; exitCode = [int]$exitCode; timedOut = $timedOut }
|
|
592
|
+
Write-Result @{ ok = $true; stage = 'ran'; sid = $sidStr; exitCode = [int]$exitCode; timedOut = $timedOut; drive = $drive }
|
|
500
593
|
exit 0
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
WHAT NODE NEEDS TO WORK INSIDE A WINDOWS APPCONTAINER, AND CANNOT DO ITSELF.
|
|
4
|
+
|
|
5
|
+
Loaded into every Node process a contained gate or agent starts, through
|
|
6
|
+
`NODE_OPTIONS=--require` (see `contained-node.ts`). It changes nothing about
|
|
7
|
+
what the container may reach — it is plain JavaScript running with the
|
|
8
|
+
container's own token, so it can do nothing the project's code could not do
|
|
9
|
+
itself. It exists so the project's code does not HANG or FAIL on two
|
|
10
|
+
properties of the boundary that were measured, not assumed:
|
|
11
|
+
|
|
12
|
+
1. PIPED CHILD PROCESSES. libuv implements piped stdio as a named pipe
|
|
13
|
+
called `\\?\pipe\uv\<ptr>-<pid>`, and an AppContainer may not create a
|
|
14
|
+
pipe outside its own `\\.\pipe\LOCAL\` namespace. libuv reads the
|
|
15
|
+
resulting ERROR_ACCESS_DENIED as a name COLLISION and retries with the
|
|
16
|
+
next name, for ever — so `spawn(…, {stdio:'pipe'})` never returns and
|
|
17
|
+
the process spins. That is why esbuild (a Go binary driven over its
|
|
18
|
+
stdin/stdout) hung, and with it Vite and Vitest.
|
|
19
|
+
|
|
20
|
+
The repair keeps the API and moves the pipe: each piped slot gets a pipe
|
|
21
|
+
under `\\.\pipe\LOCAL\`, which the container IS allowed to create; the
|
|
22
|
+
child is handed the client end as an inherited handle (which has always
|
|
23
|
+
worked), and the parent reads and writes the server end through a stream
|
|
24
|
+
with the same surface `child.stdout` always had. `spawnSync` and the
|
|
25
|
+
`exec*Sync` family go through temporary files instead — a synchronous
|
|
26
|
+
call cannot wait for an asynchronous accept.
|
|
27
|
+
|
|
28
|
+
`fork()`'s IPC channel cannot be moved: its pipe is created inside
|
|
29
|
+
libuv and set up by Node internals no preload can reach. So it FAILS
|
|
30
|
+
FAST, naming the cause, instead of hanging until the gate's timeout —
|
|
31
|
+
a test runner then needs its worker-thread pool (`vitest --pool=threads`,
|
|
32
|
+
jest `--runInBand` or `workerThreads`).
|
|
33
|
+
|
|
34
|
+
2. REALPATH. `fs.realpathSync.native` fails with EPERM on EVERY path — it
|
|
35
|
+
maps the final path back to a drive letter, which a container is not
|
|
36
|
+
allowed to ask — and the JavaScript `fs.realpath` walks up to `lstat C:\`,
|
|
37
|
+
which the container cannot read either. Jest and Vite both call it at
|
|
38
|
+
startup. On EPERM/EACCES only, the path is resolved again component by
|
|
39
|
+
component, following the links the container CAN see and taking the
|
|
40
|
+
ancestors it cannot see as they are. Any other error is unchanged.
|
|
41
|
+
|
|
42
|
+
Installed only when `LOOPLY_CONTAINED_NODE=1`, so requiring the file to test
|
|
43
|
+
it changes nothing.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const BS = String.fromCharCode(92);
|
|
47
|
+
const LOCAL_PIPE_PREFIX = BS + BS + '.' + BS + 'pipe' + BS + 'LOCAL' + BS + 'looply-';
|
|
48
|
+
|
|
49
|
+
const IPC_REFUSAL =
|
|
50
|
+
'child_process IPC (fork, or stdio "ipc") cannot work inside the Looply Windows sandbox: ' +
|
|
51
|
+
'Node creates its IPC pipe where an AppContainer may not, and would hang for ever. ' +
|
|
52
|
+
'Use a worker-thread pool instead (vitest --pool=threads; jest --runInBand or workerThreads).';
|
|
53
|
+
|
|
54
|
+
/* ─────────────────────────────────────────────────────────── realpath ── */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A realpath that tolerates ancestors it may not read.
|
|
58
|
+
*
|
|
59
|
+
* Walks the absolute path one component at a time. A component the container
|
|
60
|
+
* cannot `lstat` is taken as it is (it is an ancestor above the grant, and the
|
|
61
|
+
* container could not follow a link there anyway); a component it CAN see is
|
|
62
|
+
* followed if it is a link. A component that does not exist throws ENOENT, as
|
|
63
|
+
* realpath does.
|
|
64
|
+
*/
|
|
65
|
+
function lenientRealpath(fs, path, p) {
|
|
66
|
+
// The root as the platform spells it — `C:\`, or `\\server\share\` — and
|
|
67
|
+
// the components below it.
|
|
68
|
+
const split = (abs) => {
|
|
69
|
+
const root = path.parse(abs).root;
|
|
70
|
+
return [root, abs.slice(root.length).split(/[\\/]+/)];
|
|
71
|
+
};
|
|
72
|
+
let [current, pending] = split(path.resolve(p));
|
|
73
|
+
let links = 0;
|
|
74
|
+
while (pending.length > 0) {
|
|
75
|
+
const part = pending.shift();
|
|
76
|
+
if (part === '' || part === '.') continue;
|
|
77
|
+
if (part === '..') {
|
|
78
|
+
current = path.dirname(current);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const next = path.join(current, part);
|
|
82
|
+
let st = null;
|
|
83
|
+
try {
|
|
84
|
+
st = fs.lstatSync(next);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (!e || (e.code !== 'EPERM' && e.code !== 'EACCES')) throw e;
|
|
87
|
+
}
|
|
88
|
+
if (st !== null && st.isSymbolicLink()) {
|
|
89
|
+
if (++links > 40) {
|
|
90
|
+
const err = new Error(`ELOOP: too many symbolic links encountered, realpath '${p}'`);
|
|
91
|
+
err.code = 'ELOOP';
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
const [root, parts] = split(path.resolve(current, fs.readlinkSync(next)));
|
|
95
|
+
current = root;
|
|
96
|
+
pending = parts.concat(pending);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
current = next;
|
|
100
|
+
}
|
|
101
|
+
return current;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function denied(e) {
|
|
105
|
+
return !!e && (e.code === 'EPERM' || e.code === 'EACCES');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toPathString(p) {
|
|
109
|
+
if (typeof p === 'string') return p;
|
|
110
|
+
if (Buffer.isBuffer(p)) return p.toString('utf8');
|
|
111
|
+
if (p instanceof URL) return require('url').fileURLToPath(p);
|
|
112
|
+
return String(p);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encode(result, options) {
|
|
116
|
+
const enc = typeof options === 'string' ? options : options && options.encoding;
|
|
117
|
+
return enc === 'buffer' ? Buffer.from(result, 'utf8') : result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function installRealpath(fs, path) {
|
|
121
|
+
const lenient = (p, options) => encode(lenientRealpath(fs, path, toPathString(p)), options);
|
|
122
|
+
|
|
123
|
+
const wrapSync = (orig) =>
|
|
124
|
+
function realpathSync(p, options) {
|
|
125
|
+
try {
|
|
126
|
+
return orig.call(fs, p, options);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (!denied(e)) throw e;
|
|
129
|
+
return lenient(p, options);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const wrapCallback = (orig) =>
|
|
133
|
+
function realpath(p, options, callback) {
|
|
134
|
+
if (typeof options === 'function') {
|
|
135
|
+
callback = options;
|
|
136
|
+
options = undefined;
|
|
137
|
+
}
|
|
138
|
+
orig.call(fs, p, options, (e, resolved) => {
|
|
139
|
+
if (!denied(e)) return callback(e, resolved);
|
|
140
|
+
let out;
|
|
141
|
+
try {
|
|
142
|
+
out = lenient(p, options);
|
|
143
|
+
} catch (e2) {
|
|
144
|
+
return callback(e2);
|
|
145
|
+
}
|
|
146
|
+
callback(null, out);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const sync = wrapSync(fs.realpathSync);
|
|
151
|
+
sync.native = wrapSync(fs.realpathSync.native);
|
|
152
|
+
fs.realpathSync = sync;
|
|
153
|
+
const cb = wrapCallback(fs.realpath);
|
|
154
|
+
cb.native = wrapCallback(fs.realpath.native);
|
|
155
|
+
fs.realpath = cb;
|
|
156
|
+
|
|
157
|
+
const origPromise = fs.promises.realpath;
|
|
158
|
+
fs.promises.realpath = async function realpath(p, options) {
|
|
159
|
+
try {
|
|
160
|
+
return await origPromise.call(fs.promises, p, options);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (!denied(e)) throw e;
|
|
163
|
+
return lenient(p, options);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ───────────────────────────────────────────────────── piped children ── */
|
|
169
|
+
|
|
170
|
+
/** Every piped slot normalised the way Node normalises it. */
|
|
171
|
+
function normaliseStdio(stdio) {
|
|
172
|
+
if (stdio === undefined || stdio === null) stdio = 'pipe';
|
|
173
|
+
if (typeof stdio === 'string') return [stdio, stdio, stdio];
|
|
174
|
+
if (!Array.isArray(stdio)) return null; // Node will throw its own error for this
|
|
175
|
+
const out = stdio.slice();
|
|
176
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
177
|
+
if (out[i] === undefined || out[i] === null) out[i] = i < 3 ? 'pipe' : 'ignore';
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const isPipe = (s) => s === 'pipe' || s === 'overlapped';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A pipe pair under `\\.\pipe\LOCAL\`: the child's end as a file descriptor,
|
|
186
|
+
* opened NOW, and the parent's end as a stream that is wired the moment the
|
|
187
|
+
* server accepts it — on the next turn of the loop.
|
|
188
|
+
*
|
|
189
|
+
* The server must stay open until it has accepted: closing it first closes the
|
|
190
|
+
* pending instance the child is connected to, and the child's first write fails
|
|
191
|
+
* with EPIPE.
|
|
192
|
+
*/
|
|
193
|
+
function pipePair(deps, end) {
|
|
194
|
+
const { fs, net, crypto } = deps;
|
|
195
|
+
const name = LOCAL_PIPE_PREFIX + process.pid + '-' + crypto.randomBytes(8).toString('hex');
|
|
196
|
+
const server = net.createServer();
|
|
197
|
+
server.on('error', (e) => end.destroy(e));
|
|
198
|
+
server.once('connection', (socket) => {
|
|
199
|
+
server.close();
|
|
200
|
+
end._attach(socket);
|
|
201
|
+
});
|
|
202
|
+
// Binding a pipe server is synchronous; only the accept is not.
|
|
203
|
+
server.listen(name);
|
|
204
|
+
// Never keep a process alive on its own: the child is already connected, so
|
|
205
|
+
// the accept completes on the next loop turn whenever anything else runs.
|
|
206
|
+
server.unref();
|
|
207
|
+
let fd;
|
|
208
|
+
try {
|
|
209
|
+
fd = fs.openSync(name, 'r+');
|
|
210
|
+
} catch (e) {
|
|
211
|
+
server.close();
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
return fd;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function makePipeEnd(Duplex) {
|
|
218
|
+
/**
|
|
219
|
+
* The parent's end of one piped slot.
|
|
220
|
+
*
|
|
221
|
+
* A Duplex over a socket that does not exist yet: writes queue until it does,
|
|
222
|
+
* reads are pushed as they arrive and pause the socket when nobody is reading.
|
|
223
|
+
* `ref`/`unref` are kept and applied, because esbuild unrefs its service's
|
|
224
|
+
* stdio so a process using it can exit.
|
|
225
|
+
*/
|
|
226
|
+
return class PipeEnd extends Duplex {
|
|
227
|
+
constructor(options) {
|
|
228
|
+
super(options);
|
|
229
|
+
this._socket = null;
|
|
230
|
+
this._queued = [];
|
|
231
|
+
this._finalCallback = null;
|
|
232
|
+
this._refed = true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_attach(socket) {
|
|
236
|
+
if (this.destroyed) {
|
|
237
|
+
socket.destroy();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this._socket = socket;
|
|
241
|
+
if (!this._refed) socket.unref();
|
|
242
|
+
socket.on('data', (chunk) => {
|
|
243
|
+
if (!this.push(chunk)) socket.pause();
|
|
244
|
+
});
|
|
245
|
+
socket.on('end', () => this.push(null));
|
|
246
|
+
socket.on('error', (e) => this.destroy(e));
|
|
247
|
+
for (const [chunk, encoding, callback] of this._queued) socket.write(chunk, encoding, callback);
|
|
248
|
+
this._queued = [];
|
|
249
|
+
if (this._finalCallback !== null) {
|
|
250
|
+
const callback = this._finalCallback;
|
|
251
|
+
this._finalCallback = null;
|
|
252
|
+
this._finish(callback);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_read() {
|
|
257
|
+
if (this._socket !== null) this._socket.resume();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_write(chunk, encoding, callback) {
|
|
261
|
+
if (this._socket !== null) this._socket.write(chunk, encoding, callback);
|
|
262
|
+
else this._queued.push([chunk, encoding, callback]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_final(callback) {
|
|
266
|
+
if (this._socket !== null) this._finish(callback);
|
|
267
|
+
else this._finalCallback = callback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A named pipe has no half-close: the child sees EOF when the handle closes. */
|
|
271
|
+
_finish(callback) {
|
|
272
|
+
const socket = this._socket;
|
|
273
|
+
socket.end(() => {
|
|
274
|
+
socket.destroy();
|
|
275
|
+
callback();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_destroy(error, callback) {
|
|
280
|
+
if (this._socket !== null) this._socket.destroy();
|
|
281
|
+
for (const [, , cb] of this._queued) cb(error || new Error('stream destroyed'));
|
|
282
|
+
this._queued = [];
|
|
283
|
+
callback(error);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
ref() {
|
|
287
|
+
this._refed = true;
|
|
288
|
+
if (this._socket !== null) this._socket.ref();
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
unref() {
|
|
293
|
+
this._refed = false;
|
|
294
|
+
if (this._socket !== null) this._socket.unref();
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function installSpawn(deps) {
|
|
301
|
+
const { cp, fs } = deps;
|
|
302
|
+
const PipeEnd = makePipeEnd(require('stream').Duplex);
|
|
303
|
+
const original = cp.ChildProcess.prototype.spawn;
|
|
304
|
+
|
|
305
|
+
cp.ChildProcess.prototype.spawn = function spawn(options) {
|
|
306
|
+
const stdio = options && typeof options === 'object' ? normaliseStdio(options.stdio) : null;
|
|
307
|
+
if (stdio === null) return original.call(this, options);
|
|
308
|
+
if (stdio.includes('ipc')) {
|
|
309
|
+
const err = new Error(IPC_REFUSAL);
|
|
310
|
+
err.code = 'ERR_LOOPLY_CONTAINED_IPC';
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
if (!stdio.some(isPipe)) return original.call(this, options);
|
|
314
|
+
|
|
315
|
+
const ends = [];
|
|
316
|
+
const fds = [];
|
|
317
|
+
try {
|
|
318
|
+
for (let i = 0; i < stdio.length; i += 1) {
|
|
319
|
+
if (!isPipe(stdio[i])) continue;
|
|
320
|
+
// Node's own shape: stdin is written, every other slot is read.
|
|
321
|
+
const end = new PipeEnd(i === 0 ? { readable: false } : { writable: false });
|
|
322
|
+
const fd = pipePair(deps, end);
|
|
323
|
+
ends.push([i, end]);
|
|
324
|
+
fds.push(fd);
|
|
325
|
+
stdio[i] = fd;
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
for (const fd of fds) fs.closeSync(fd);
|
|
329
|
+
for (const [, end] of ends) end.destroy();
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let result;
|
|
334
|
+
try {
|
|
335
|
+
result = original.call(this, Object.assign({}, options, { stdio }));
|
|
336
|
+
} finally {
|
|
337
|
+
// The child holds its own copies now. Ours must go, or the parent keeps
|
|
338
|
+
// the pipe open and never sees the child's EOF.
|
|
339
|
+
for (const fd of fds) fs.closeSync(fd);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const spawned = typeof this.pid === 'number' && this.pid !== 0;
|
|
343
|
+
for (const [i, end] of ends) {
|
|
344
|
+
if (!spawned) {
|
|
345
|
+
end.destroy();
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
this.stdio[i] = end;
|
|
349
|
+
if (i === 0) this.stdin = end;
|
|
350
|
+
if (i === 1) this.stdout = end;
|
|
351
|
+
if (i === 2) this.stderr = end;
|
|
352
|
+
if (i > 0) {
|
|
353
|
+
// Node waits for every READ slot to close before 'close', so the last
|
|
354
|
+
// of a child's output is never lost to an early 'close'. Same here,
|
|
355
|
+
// through the same counters its own `maybeClose` uses.
|
|
356
|
+
this._closesNeeded += 1;
|
|
357
|
+
end.on('close', () => {
|
|
358
|
+
this._closesGot += 1;
|
|
359
|
+
if (this._closesGot === this._closesNeeded) this.emit('close', this.exitCode, this.signalCode);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ─────────────────────────────────────────────────── synchronous calls ── */
|
|
368
|
+
|
|
369
|
+
function installSync(deps) {
|
|
370
|
+
const { cp, fs, path, os, crypto } = deps;
|
|
371
|
+
const originalSpawnSync = cp.spawnSync;
|
|
372
|
+
const DEFAULT_MAX_BUFFER = 1024 * 1024;
|
|
373
|
+
|
|
374
|
+
function spawnSync(file, args, options) {
|
|
375
|
+
if (!Array.isArray(args)) {
|
|
376
|
+
options = args;
|
|
377
|
+
args = [];
|
|
378
|
+
}
|
|
379
|
+
options = Object.assign({}, options);
|
|
380
|
+
const stdio = normaliseStdio(options.stdio);
|
|
381
|
+
if (stdio === null) return originalSpawnSync(file, args, options);
|
|
382
|
+
if (stdio.includes('ipc')) {
|
|
383
|
+
const err = new Error(IPC_REFUSAL);
|
|
384
|
+
err.code = 'ERR_LOOPLY_CONTAINED_IPC';
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
if (!stdio.some(isPipe)) return originalSpawnSync(file, args, options);
|
|
388
|
+
|
|
389
|
+
const box = fs.mkdtempSync(path.join(os.tmpdir(), 'looply-sync-'));
|
|
390
|
+
const opened = [];
|
|
391
|
+
const outputs = [];
|
|
392
|
+
try {
|
|
393
|
+
for (let i = 0; i < stdio.length; i += 1) {
|
|
394
|
+
if (!isPipe(stdio[i])) continue;
|
|
395
|
+
const file_ = path.join(box, String(i) + '-' + crypto.randomBytes(4).toString('hex'));
|
|
396
|
+
if (i === 0) {
|
|
397
|
+
const input = options.input;
|
|
398
|
+
const bytes =
|
|
399
|
+
input === undefined || input === null
|
|
400
|
+
? Buffer.alloc(0)
|
|
401
|
+
: typeof input === 'string'
|
|
402
|
+
? Buffer.from(input, options.encoding && options.encoding !== 'buffer' ? options.encoding : 'utf8')
|
|
403
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
404
|
+
fs.writeFileSync(file_, bytes);
|
|
405
|
+
const fd = fs.openSync(file_, 'r');
|
|
406
|
+
opened.push(fd);
|
|
407
|
+
stdio[i] = fd;
|
|
408
|
+
} else {
|
|
409
|
+
const fd = fs.openSync(file_, 'w');
|
|
410
|
+
opened.push(fd);
|
|
411
|
+
outputs.push([i, file_]);
|
|
412
|
+
stdio[i] = fd;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
delete options.input;
|
|
416
|
+
const ret = originalSpawnSync(file, args, Object.assign(options, { stdio }));
|
|
417
|
+
for (const fd of opened.splice(0)) fs.closeSync(fd);
|
|
418
|
+
|
|
419
|
+
const maxBuffer = options.maxBuffer === undefined ? DEFAULT_MAX_BUFFER : options.maxBuffer;
|
|
420
|
+
const encoding = options.encoding && options.encoding !== 'buffer' ? options.encoding : null;
|
|
421
|
+
const output = Array.isArray(ret.output) ? ret.output.slice() : new Array(stdio.length).fill(null);
|
|
422
|
+
for (const [i, file_] of outputs) {
|
|
423
|
+
let bytes = fs.readFileSync(file_);
|
|
424
|
+
if (Number.isFinite(maxBuffer) && bytes.length > maxBuffer) {
|
|
425
|
+
bytes = bytes.subarray(0, maxBuffer);
|
|
426
|
+
if (!ret.error) {
|
|
427
|
+
const err = new Error(`spawnSync ${file} ENOBUFS`);
|
|
428
|
+
err.code = 'ENOBUFS';
|
|
429
|
+
err.errno = -4060;
|
|
430
|
+
err.syscall = `spawnSync ${file}`;
|
|
431
|
+
err.path = file;
|
|
432
|
+
err.spawnargs = args;
|
|
433
|
+
ret.error = err;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
output[i] = encoding === null ? bytes : bytes.toString(encoding);
|
|
437
|
+
}
|
|
438
|
+
ret.output = output;
|
|
439
|
+
ret.stdout = output[1] === undefined ? null : output[1];
|
|
440
|
+
ret.stderr = output[2] === undefined ? null : output[2];
|
|
441
|
+
return ret;
|
|
442
|
+
} finally {
|
|
443
|
+
for (const fd of opened) fs.closeSync(fd);
|
|
444
|
+
try {
|
|
445
|
+
fs.rmSync(box, { recursive: true, force: true });
|
|
446
|
+
} catch {
|
|
447
|
+
/* a temp directory is not this call's verdict */
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Node's own `checkExecSyncError`, which is not exported. */
|
|
453
|
+
function execSyncError(ret, command) {
|
|
454
|
+
if (ret.error) return Object.assign(ret.error, ret);
|
|
455
|
+
if (ret.status !== 0) {
|
|
456
|
+
let message = 'Command failed: ' + command;
|
|
457
|
+
if (ret.stderr && ret.stderr.length > 0) message += '\n' + ret.stderr.toString();
|
|
458
|
+
return Object.assign(new Error(message), ret);
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function execFileSync(file, args, options) {
|
|
464
|
+
if (!Array.isArray(args)) {
|
|
465
|
+
options = args;
|
|
466
|
+
args = [];
|
|
467
|
+
}
|
|
468
|
+
options = Object.assign({}, options);
|
|
469
|
+
const inheritStderr = !options.stdio;
|
|
470
|
+
const ret = spawnSync(file, args, options);
|
|
471
|
+
if (inheritStderr && ret.stderr) process.stderr.write(ret.stderr);
|
|
472
|
+
const err = execSyncError(ret, [options.argv0 || file].concat(args).join(' '));
|
|
473
|
+
if (err) throw err;
|
|
474
|
+
return ret.stdout;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function execSync(command, options) {
|
|
478
|
+
options = Object.assign({}, options);
|
|
479
|
+
options.shell = typeof options.shell === 'string' ? options.shell : true;
|
|
480
|
+
const inheritStderr = !options.stdio;
|
|
481
|
+
const ret = spawnSync(command, [], options);
|
|
482
|
+
if (inheritStderr && ret.stderr) process.stderr.write(ret.stderr);
|
|
483
|
+
const err = execSyncError(ret, command);
|
|
484
|
+
if (err) throw err;
|
|
485
|
+
return ret.stdout;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
cp.spawnSync = spawnSync;
|
|
489
|
+
cp.execFileSync = execFileSync;
|
|
490
|
+
cp.execSync = execSync;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* ───────────────────────────────────────────────────────────── install ── */
|
|
494
|
+
|
|
495
|
+
function install() {
|
|
496
|
+
if (process.platform !== 'win32') return false;
|
|
497
|
+
if (global.__looplyContainedNode) return false;
|
|
498
|
+
global.__looplyContainedNode = true;
|
|
499
|
+
const deps = {
|
|
500
|
+
cp: require('child_process'),
|
|
501
|
+
fs: require('fs'),
|
|
502
|
+
net: require('net'),
|
|
503
|
+
os: require('os'),
|
|
504
|
+
path: require('path'),
|
|
505
|
+
crypto: require('crypto'),
|
|
506
|
+
};
|
|
507
|
+
/*
|
|
508
|
+
* Windows REWRITES `TEMP`/`TMP` for an AppContainer process, to
|
|
509
|
+
* `<LOCALAPPDATA>\Packages\<container>\AC\Temp` — whatever the environment
|
|
510
|
+
* block said — and nothing creates that directory, so `os.tmpdir()` names a
|
|
511
|
+
* path that is not there and every temp file fails with ENOENT (measured).
|
|
512
|
+
* It is under the contained root's granted `local`, so it can be made.
|
|
513
|
+
*/
|
|
514
|
+
try {
|
|
515
|
+
deps.fs.mkdirSync(deps.os.tmpdir(), { recursive: true });
|
|
516
|
+
} catch {
|
|
517
|
+
/* a temp directory we cannot make is a failure for whoever needs it */
|
|
518
|
+
}
|
|
519
|
+
installRealpath(deps.fs, deps.path);
|
|
520
|
+
installSpawn(deps);
|
|
521
|
+
installSync(deps);
|
|
522
|
+
// ESM `import { spawn } from 'node:child_process'` reads a snapshot of the
|
|
523
|
+
// CommonJS exports; refresh it so both module systems see the same functions.
|
|
524
|
+
require('module').syncBuiltinESMExports();
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
module.exports = { install, lenientRealpath, normaliseStdio, IPC_REFUSAL, LOCAL_PIPE_PREFIX };
|
|
529
|
+
|
|
530
|
+
if (process.env.LOOPLY_CONTAINED_NODE === '1') install();
|