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.
Files changed (127) hide show
  1. package/dist/.src-hash +1 -1
  2. package/dist/agent-contained.d.ts +2 -0
  3. package/dist/agent-contained.d.ts.map +1 -1
  4. package/dist/agent-contained.js +22 -5
  5. package/dist/agent-contained.js.map +1 -1
  6. package/dist/agent-exec.d.ts.map +1 -1
  7. package/dist/agent-exec.js +1 -0
  8. package/dist/agent-exec.js.map +1 -1
  9. package/dist/autonomy-tick.d.ts +46 -0
  10. package/dist/autonomy-tick.d.ts.map +1 -1
  11. package/dist/autonomy-tick.js +44 -2
  12. package/dist/autonomy-tick.js.map +1 -1
  13. package/dist/bin.d.ts.map +1 -1
  14. package/dist/bin.js +131 -93
  15. package/dist/bin.js.map +1 -1
  16. package/dist/brain-advice.js +2 -2
  17. package/dist/brain-advice.js.map +1 -1
  18. package/dist/command-runner.d.ts.map +1 -1
  19. package/dist/command-runner.js +1 -0
  20. package/dist/command-runner.js.map +1 -1
  21. package/dist/connect-node.d.ts.map +1 -1
  22. package/dist/connect-node.js +8 -4
  23. package/dist/connect-node.js.map +1 -1
  24. package/dist/connect.d.ts +8 -0
  25. package/dist/connect.d.ts.map +1 -1
  26. package/dist/connect.js +22 -0
  27. package/dist/connect.js.map +1 -1
  28. package/dist/contained-node.d.ts +51 -0
  29. package/dist/contained-node.d.ts.map +1 -0
  30. package/dist/contained-node.js +116 -0
  31. package/dist/contained-node.js.map +1 -0
  32. package/dist/daemon-cmd.d.ts.map +1 -1
  33. package/dist/daemon-cmd.js +54 -2
  34. package/dist/daemon-cmd.js.map +1 -1
  35. package/dist/daemon.d.ts +9 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +11 -1
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/exec-domains.d.ts +4 -0
  40. package/dist/exec-domains.d.ts.map +1 -1
  41. package/dist/exec-domains.js +7 -2
  42. package/dist/exec-domains.js.map +1 -1
  43. package/dist/gate-draft.d.ts +99 -0
  44. package/dist/gate-draft.d.ts.map +1 -0
  45. package/dist/gate-draft.js +544 -0
  46. package/dist/gate-draft.js.map +1 -0
  47. package/dist/git-driver.d.ts.map +1 -1
  48. package/dist/git-driver.js +1 -0
  49. package/dist/git-driver.js.map +1 -1
  50. package/dist/init-detect.d.ts +20 -10
  51. package/dist/init-detect.d.ts.map +1 -1
  52. package/dist/init-detect.js +27 -85
  53. package/dist/init-detect.js.map +1 -1
  54. package/dist/model-transport.d.ts.map +1 -1
  55. package/dist/model-transport.js +25 -8
  56. package/dist/model-transport.js.map +1 -1
  57. package/dist/notify-channel.d.ts.map +1 -1
  58. package/dist/notify-channel.js +1 -0
  59. package/dist/notify-channel.js.map +1 -1
  60. package/dist/provider-cmd.d.ts +56 -0
  61. package/dist/provider-cmd.d.ts.map +1 -0
  62. package/dist/provider-cmd.js +260 -0
  63. package/dist/provider-cmd.js.map +1 -0
  64. package/dist/provider-discovery.d.ts +75 -0
  65. package/dist/provider-discovery.d.ts.map +1 -0
  66. package/dist/provider-discovery.js +235 -0
  67. package/dist/provider-discovery.js.map +1 -0
  68. package/dist/provider-identity.d.ts +3 -3
  69. package/dist/provider-identity.d.ts.map +1 -1
  70. package/dist/provider-identity.js +4 -5
  71. package/dist/provider-identity.js.map +1 -1
  72. package/dist/provider-preflight.d.ts.map +1 -1
  73. package/dist/provider-preflight.js +1 -0
  74. package/dist/provider-preflight.js.map +1 -1
  75. package/dist/provider-ready.d.ts +66 -0
  76. package/dist/provider-ready.d.ts.map +1 -0
  77. package/dist/provider-ready.js +105 -0
  78. package/dist/provider-ready.js.map +1 -0
  79. package/dist/provider-state.d.ts.map +1 -1
  80. package/dist/provider-state.js +1 -0
  81. package/dist/provider-state.js.map +1 -1
  82. package/dist/refresh-gates.d.ts +47 -0
  83. package/dist/refresh-gates.d.ts.map +1 -0
  84. package/dist/refresh-gates.js +128 -0
  85. package/dist/refresh-gates.js.map +1 -0
  86. package/dist/repo-init.d.ts +5 -1
  87. package/dist/repo-init.d.ts.map +1 -1
  88. package/dist/repo-init.js +19 -3
  89. package/dist/repo-init.js.map +1 -1
  90. package/dist/run-cmd.js +4 -4
  91. package/dist/run-cmd.js.map +1 -1
  92. package/dist/run-launcher.d.ts +5 -0
  93. package/dist/run-launcher.d.ts.map +1 -1
  94. package/dist/run-launcher.js +38 -1
  95. package/dist/run-launcher.js.map +1 -1
  96. package/dist/serve-cmd.d.ts.map +1 -1
  97. package/dist/serve-cmd.js +14 -5
  98. package/dist/serve-cmd.js.map +1 -1
  99. package/dist/serve.d.ts +19 -7
  100. package/dist/serve.d.ts.map +1 -1
  101. package/dist/serve.js +28 -12
  102. package/dist/serve.js.map +1 -1
  103. package/dist/server-client.d.ts +12 -1
  104. package/dist/server-client.d.ts.map +1 -1
  105. package/dist/server-client.js +10 -2
  106. package/dist/server-client.js.map +1 -1
  107. package/dist/session-checkpoint-runtime.d.ts.map +1 -1
  108. package/dist/session-checkpoint-runtime.js +1 -0
  109. package/dist/session-checkpoint-runtime.js.map +1 -1
  110. package/dist/setup.d.ts +7 -1
  111. package/dist/setup.d.ts.map +1 -1
  112. package/dist/setup.js +13 -13
  113. package/dist/setup.js.map +1 -1
  114. package/dist/shared-grants.js +2 -2
  115. package/dist/shared-grants.js.map +1 -1
  116. package/dist/toolchain-mirror.js +1 -1
  117. package/dist/toolchain-mirror.js.map +1 -1
  118. package/dist/untrusted-exec.d.ts +23 -0
  119. package/dist/untrusted-exec.d.ts.map +1 -1
  120. package/dist/untrusted-exec.js +18 -1
  121. package/dist/untrusted-exec.js.map +1 -1
  122. package/dist/untrusted-gate.d.ts.map +1 -1
  123. package/dist/untrusted-gate.js +4 -0
  124. package/dist/untrusted-gate.js.map +1 -1
  125. package/package.json +2 -2
  126. package/win/appcontainer.ps1 +97 -4
  127. package/win/contained-node.cjs +530 -0
@@ -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
- $runErr = [LooplyAc]::Run($req.executable, $req.commandLine, $req.cwd, [string[]]$envPairs,
489
- $req.stdoutPath, $req.stderrPath, $stdinPath, [uint32]$req.timeoutMs, [string[]]$caps,
490
- [ref]$exitCode, [ref]$timedOut)
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();