javi-forge 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Windows `PlatformSecureFs` adapter for the SkillGuard transactional installer
3
+ * (Slice 3b). It is the win32 analog of the POSIX adapter: the ONLY place a
4
+ * Windows security decision is requested, delegated across an injectable
5
+ * `HelperTransport` seam to a bundled, digest-bound PowerShell helper (Phase 3)
6
+ * that owns the real OS handles and computes Predicate A/B verdicts.
7
+ *
8
+ * This module is host-independent and fully testable on Linux via a fake
9
+ * transport: `createWindowsSecureFs` builds framed requests and maps framed
10
+ * responses to `SecureResult`s; it enforces the TS-side invariants the design
11
+ * pins to the adapter (never the `.ps1`):
12
+ * - C1 (Decision 1a): `mode` is a sentinel, NOT POSIX bits. `captureFile`
13
+ * returns `WIN32_MODE_SENTINEL`; `applyExactMode` refuses any other mode.
14
+ * - C4 (Decision 1b): identity is the full-precision `volumeSerial:FileId`
15
+ * `opaque` token; an absent/zero/malformed token is a HARD REFUSAL, never a
16
+ * fallback to the truncated `dev`/`ino` (display-only).
17
+ * - JDA6-001 (Round-6): `openDir` maps ONLY `ERROR_FILE_NOT_FOUND` (2) /
18
+ * `ERROR_PATH_NOT_FOUND` (3) to `notFound:true`; every other failure leaves
19
+ * it absent so a present-but-unopenable container fails the transaction closed.
20
+ * - JDA7-001 (Round-7): `openDir` asserts `FILE_ATTRIBUTE_DIRECTORY` and
21
+ * refuses a non-directory with `notFound:false` (POSIX `O_DIRECTORY` parity).
22
+ * Every transport error (spawn failure, dead session, bad frame, timeout) maps
23
+ * to a fail-closed refusal — Windows is never a weaker tier than POSIX.
24
+ */
25
+ import { spawn as nodeSpawn } from "node:child_process";
26
+ import { createHash } from "node:crypto";
27
+ import { readFileSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
30
+ // --- protocol constants ------------------------------------------------------
31
+ /**
32
+ * The mode `captureFile` returns and `applyExactMode` demands on win32 (C1).
33
+ * NTFS has no POSIX bits; the value only has to survive the core's opaque
34
+ * round-trip, and `0o600` is the private-file mode the core already threads.
35
+ */
36
+ export const WIN32_MODE_SENTINEL = 0o600;
37
+ /** Reject any frame whose declared length exceeds this (hook assets are tiny). */
38
+ export const HELPER_FRAME_LIMIT = 8 * 1024 * 1024; // 8 MiB
39
+ /** `GetFileInformationByHandle` attribute for a directory node. */
40
+ const FILE_ATTRIBUTE_DIRECTORY = 0x10;
41
+ /** win32 not-found status codes → the ONLY `notFound:true` mapping (JDA6-001). */
42
+ const ERROR_FILE_NOT_FOUND = 2;
43
+ const ERROR_PATH_NOT_FOUND = 3;
44
+ /** Windows PowerShell 5.1 host (always present on windows-latest). */
45
+ const POWERSHELL = "powershell.exe";
46
+ const POWERSHELL_ARGS = [
47
+ "-NoProfile",
48
+ "-NonInteractive",
49
+ "-ExecutionPolicy",
50
+ "Bypass",
51
+ "-File",
52
+ ];
53
+ /** Kill an idle session this long after the last transaction (zero handles). */
54
+ const HELPER_IDLE_MS = 30_000;
55
+ /**
56
+ * R4-001 (Phase-4 hard gate): the per-request / handshake deadline. A timer is
57
+ * armed when a frame is written to the child (and while awaiting the startup
58
+ * handshake) and cleared the instant its response arrives. If it fires, the
59
+ * child is killed and every pending/subsequent op fails closed — a hung or
60
+ * non-responding `.ps1` can no longer hang the installer transaction forever.
61
+ */
62
+ export const HELPER_OP_TIMEOUT_MS = 30_000;
63
+ // --- framing (pure, host-independent) ---------------------------------------
64
+ /** Encode a JSON body as `[uint32 BE byteLength][UTF-8 JSON]`. */
65
+ export function encodeFrame(body) {
66
+ const json = Buffer.from(JSON.stringify(body), "utf8");
67
+ const header = Buffer.alloc(4);
68
+ header.writeUInt32BE(json.byteLength, 0);
69
+ return Buffer.concat([header, json]);
70
+ }
71
+ /**
72
+ * Decode as many complete frames as `buf` holds, returning them plus the
73
+ * unconsumed remainder. Throws on a declared length past `HELPER_FRAME_LIMIT`
74
+ * (the caller kills the session and fails closed).
75
+ */
76
+ export function decodeFrames(buf) {
77
+ const frames = [];
78
+ let offset = 0;
79
+ while (buf.byteLength - offset >= 4) {
80
+ const len = buf.readUInt32BE(offset);
81
+ if (len > HELPER_FRAME_LIMIT) {
82
+ throw new Error(`oversized frame: ${len} > ${HELPER_FRAME_LIMIT}`);
83
+ }
84
+ if (buf.byteLength - offset - 4 < len)
85
+ break; // partial body — wait for more
86
+ const body = buf.subarray(offset + 4, offset + 4 + len);
87
+ frames.push(JSON.parse(body.toString("utf8")));
88
+ offset += 4 + len;
89
+ }
90
+ return { frames, rest: buf.subarray(offset) };
91
+ }
92
+ // --- result helpers ----------------------------------------------------------
93
+ const ok = () => ({ ok: true });
94
+ const okValue = (value) => ({ ok: true, value });
95
+ const refuse = (refusal, detail) => ({ ok: false, refusal, detail });
96
+ /** Map a void framed response, defaulting a refusal to the win32 DACL class. */
97
+ function mapVoid(res, step) {
98
+ // R1-002: acceptance is STRICT — a malformed frame with a truthy-non-boolean
99
+ // `ok` (e.g. `1` or `"false"`) must NOT coerce to ACCEPT.
100
+ if (res.ok === true)
101
+ return ok();
102
+ return {
103
+ ok: false,
104
+ refusal: res.refusal ?? "unsafe-windows-dacl",
105
+ detail: res.detail ?? step,
106
+ };
107
+ }
108
+ /**
109
+ * A valid win32 identity token is `"<volHex>:<fileIdHex>"` with a NON-zero
110
+ * FileId. Absent/empty/malformed/zero-FileId → refuse (C4): distinct SMB/exotic
111
+ * objects share `FileId == 0`, and a swap on a colliding identity would be
112
+ * accepted. Never falls back to the truncated `dev`/`ino`.
113
+ */
114
+ function validOpaque(opaque) {
115
+ if (typeof opaque !== "string")
116
+ return false;
117
+ const parts = opaque.split(":");
118
+ if (parts.length !== 2)
119
+ return false;
120
+ const [vol, fileId] = parts;
121
+ if (!/^[0-9a-fA-F]+$/.test(vol) || !/^[0-9a-fA-F]+$/.test(fileId))
122
+ return false;
123
+ if (/^0+$/.test(fileId))
124
+ return false; // zero FileId → collision → refuse
125
+ return true;
126
+ }
127
+ /** Derive DISPLAY-ONLY `dev`/`ino` from the opaque token; never compared. */
128
+ function identityFromOpaque(opaque) {
129
+ const [vol, fileId] = opaque.split(":");
130
+ const dev = Number.parseInt(vol, 16) >>> 0;
131
+ const ino = Number.parseInt(fileId.slice(-8), 16) >>> 0;
132
+ return { dev, ino, opaque };
133
+ }
134
+ // --- adapter -----------------------------------------------------------------
135
+ export function createWindowsSecureFs(transport) {
136
+ // Maps a returned handle object to the `.ps1`-side handleId so ops taking a
137
+ // SecureDirHandle can address the retained kernel handle.
138
+ const handleIds = new WeakMap();
139
+ /** Send a request, mapping ANY transport error to a fail-closed refusal. */
140
+ async function call(req) {
141
+ try {
142
+ return await transport.request(req);
143
+ }
144
+ catch (error) {
145
+ const cause = error instanceof Error ? error.message : String(error);
146
+ return {
147
+ ok: false,
148
+ refusal: "windows-secure-object-unavailable",
149
+ detail: `helper ${cause}`,
150
+ };
151
+ }
152
+ }
153
+ // Always emits a releaseHandle op, even for a missing/malformed id: the
154
+ // session increments `outstanding` on ANY ok openDir/createDir frame, so a
155
+ // balancing releaseHandle is the ONLY way to decrement it back (R4-003).
156
+ async function releaseHandle(handleId) {
157
+ const handle = typeof handleId === "string" ? handleId : null;
158
+ await call({ op: "releaseHandle", args: { handle } });
159
+ }
160
+ function makeHandle(dirPath, handleId, opaque) {
161
+ const handle = {
162
+ path: dirPath,
163
+ identity: identityFromOpaque(opaque),
164
+ close: () => releaseHandle(handleId),
165
+ };
166
+ handleIds.set(handle, handleId);
167
+ return handle;
168
+ }
169
+ /** Shared open→handle path for openDir and createDir. */
170
+ async function toHandle(res, dirPath, step) {
171
+ // R1-002: STRICT accept — a truthy-non-boolean `ok` must not open a handle.
172
+ if (res.ok !== true || !res.value) {
173
+ const result = refuse(res.refusal ?? "unsafe-parent-chain", res.detail ?? step);
174
+ if (res.status === ERROR_FILE_NOT_FOUND ||
175
+ res.status === ERROR_PATH_NOT_FOUND) {
176
+ result.notFound = true; // the ONLY safe skip/create signal (JDA6-001)
177
+ }
178
+ return result;
179
+ }
180
+ const v = res.value;
181
+ const handleId = typeof v.handleId === "string" ? v.handleId : undefined;
182
+ const attributes = typeof v.attributes === "number" ? v.attributes : 0;
183
+ // JDA7-001: refuse a non-directory (POSIX O_DIRECTORY parity), notFound=false.
184
+ if ((attributes & FILE_ATTRIBUTE_DIRECTORY) === 0) {
185
+ await releaseHandle(handleId);
186
+ return refuse("unsafe-parent-chain", `${step}: not a directory ${dirPath}`);
187
+ }
188
+ // C4: a directory we cannot identify by full-precision FileId is a refusal.
189
+ if (!validOpaque(v.opaque)) {
190
+ await releaseHandle(handleId);
191
+ return refuse("unsafe-parent-chain", `${step}: unresolvable identity ${dirPath}`);
192
+ }
193
+ if (!handleId) {
194
+ // R4-003: the .ps1 acked (session already incremented `outstanding`) but
195
+ // sent no usable id — emit a balancing release so the idle watchdog can
196
+ // still arm; otherwise a malformed ok permanently disarms it.
197
+ await releaseHandle(v.handleId);
198
+ return refuse("unsafe-parent-chain", `${step}: no handle ${dirPath}`);
199
+ }
200
+ return okValue(makeHandle(dirPath, handleId, v.opaque));
201
+ }
202
+ const secureFs = {
203
+ async openDirNoFollow(dirPath) {
204
+ return toHandle(await call({ op: "openDir", args: { path: dirPath } }), dirPath, `openDir ${dirPath}`);
205
+ },
206
+ async revalidateIdentity(target, held) {
207
+ if (!validOpaque(held.opaque)) {
208
+ return refuse("unsafe-parent-chain", `revalidate ${target}: unresolvable identity`);
209
+ }
210
+ return mapVoid(await call({
211
+ op: "revalidate",
212
+ args: { path: target, opaque: held.opaque },
213
+ }), `revalidate ${target}`);
214
+ },
215
+ async proveOwnershipAndMode(dirPath) {
216
+ return mapVoid(await call({ op: "proveOwner", args: { path: dirPath } }), `ownership ${dirPath}`);
217
+ },
218
+ async proveNoExtendedAcl(target) {
219
+ return mapVoid(await call({ op: "proveDacl", args: { path: target } }), `acl ${target}`);
220
+ },
221
+ async proveManagedContainer(dirPath) {
222
+ return mapVoid(await call({ op: "proveContainer", args: { path: dirPath } }), `container ${dirPath}`);
223
+ },
224
+ async createDirExclusive(parent, name, mode) {
225
+ // C1/JD-A-104: createDir ignores the numeric mode (Predicate B descriptor
226
+ // governs), so NO sentinel assertion here — the core threads 0o700.
227
+ const parentHandle = handleIds.get(parent);
228
+ const full = path.join(parent.path, name);
229
+ return toHandle(await call({
230
+ op: "createDir",
231
+ args: { parentHandle, name, mode },
232
+ }), full, `createDir ${full}`);
233
+ },
234
+ async captureFile(target) {
235
+ const res = await call({ op: "capture", args: { path: target } });
236
+ // R1-002: STRICT accept — reject a truthy-non-boolean `ok`.
237
+ if (res.ok !== true || !res.value) {
238
+ return refuse(res.refusal ?? "unsafe-windows-dacl", res.detail ?? `capture ${target}`);
239
+ }
240
+ const v = res.value;
241
+ if (typeof v.bytes !== "string") {
242
+ return refuse("unsafe-parent-chain", `capture ${target}: missing bytes`);
243
+ }
244
+ if (!validOpaque(v.opaque)) {
245
+ return refuse("unsafe-parent-chain", `capture ${target}: unresolvable identity`);
246
+ }
247
+ const bytes = Buffer.from(v.bytes, "base64");
248
+ return okValue({
249
+ bytes,
250
+ mode: WIN32_MODE_SENTINEL, // C1: sentinel, not a real NTFS permission
251
+ identity: identityFromOpaque(v.opaque),
252
+ sha256: createHash("sha256").update(bytes).digest("hex"),
253
+ });
254
+ },
255
+ async writeExclusive(dir, name, bytes, mode) {
256
+ const dirHandle = handleIds.get(dir);
257
+ return mapVoid(await call({
258
+ op: "writeExcl",
259
+ args: {
260
+ dirHandle,
261
+ name,
262
+ bytes: bytes.toString("base64"), // binary payload → base64 JSON body
263
+ mode,
264
+ },
265
+ }), `writeExclusive ${name}`);
266
+ },
267
+ async applyExactMode(target, mode) {
268
+ // JD-A-104: the sentinel equality check lives ONLY here — the core only
269
+ // ever calls applyExactMode with the 0o600 file mode it captured.
270
+ if (mode !== WIN32_MODE_SENTINEL) {
271
+ return refuse("unsafe-parent-chain", `applyMode ${target}: unexpected mode ${mode.toString(8)}`);
272
+ }
273
+ return mapVoid(await call({ op: "applyMode", args: { path: target, mode } }), `applyMode ${target}`);
274
+ },
275
+ async renameInDir(dir, from, to) {
276
+ const dirHandle = handleIds.get(dir);
277
+ return mapVoid(await call({ op: "rename", args: { dirHandle, from, to } }), `rename ${from}->${to}`);
278
+ },
279
+ async unlinkIfIdentity(dir, name, held) {
280
+ if (!validOpaque(held.opaque)) {
281
+ return refuse("unsafe-parent-chain", `unlink ${name}: unresolvable identity`);
282
+ }
283
+ const dirHandle = handleIds.get(dir);
284
+ return mapVoid(await call({
285
+ op: "unlink",
286
+ args: { dirHandle, name, opaque: held.opaque },
287
+ }), `unlink ${name}`);
288
+ },
289
+ async rmdirIfIdentityEmpty(handle) {
290
+ if (!validOpaque(handle.identity.opaque)) {
291
+ return refuse("unsafe-parent-chain", `rmdir ${handle.path}: unresolvable identity`);
292
+ }
293
+ const id = handleIds.get(handle);
294
+ return mapVoid(await call({
295
+ op: "rmdir",
296
+ args: { handle: id, opaque: handle.identity.opaque },
297
+ }), `rmdir ${handle.path}`);
298
+ },
299
+ };
300
+ return secureFs;
301
+ }
302
+ // --- digest-bound refusing transport ----------------------------------------
303
+ /**
304
+ * A transport that refuses EVERY op — used when the `.ps1` digest does not match
305
+ * the manifest binding (or the binding is absent). No PowerShell is spawned.
306
+ */
307
+ export function refusingTransport(detail) {
308
+ return {
309
+ async request() {
310
+ return {
311
+ ok: false,
312
+ refusal: "windows-secure-object-unavailable",
313
+ detail,
314
+ };
315
+ },
316
+ async close() { },
317
+ };
318
+ }
319
+ /**
320
+ * The real transport: verify the on-disk `.ps1` sha256 against the manifest
321
+ * binding BEFORE spawning (tamper-evident, symmetric with the `.mjs`); on a
322
+ * mismatch/absent binding return `refusingTransport` and spawn nothing. On a
323
+ * match, spawn `powershell.exe` lazily on the first request, complete the
324
+ * handshake, and exchange strictly-serial length-prefixed frames. Any oversized
325
+ * frame, bad handshake, child exit, or session error kills the child and fails
326
+ * every pending/subsequent op closed. The idle watchdog only arms when ZERO
327
+ * directory handles are outstanding (W1) so it never kills a live transaction.
328
+ */
329
+ export function createPs1Session(opts = {}) {
330
+ const assetsDir = opts.assetsDir ?? CLAUDE_HOOK_ASSETS_DIR;
331
+ const readFile = opts.readFile ?? ((p) => readFileSync(p));
332
+ const spawn = opts.spawn ??
333
+ ((cmd, args) => nodeSpawn(cmd, args));
334
+ const idleMs = opts.idleMs ?? HELPER_IDLE_MS;
335
+ const opTimeoutMs = opts.opTimeoutMs ?? HELPER_OP_TIMEOUT_MS;
336
+ const setTimer = opts.setTimer ??
337
+ ((fn, ms) => {
338
+ const t = setTimeout(fn, ms);
339
+ t.unref?.();
340
+ return t;
341
+ });
342
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
343
+ const registerExitHook = opts.registerExitHook ?? ((fn) => process.once("exit", fn));
344
+ let initialized = false;
345
+ let dead = false;
346
+ let deadDetail = "helper closed";
347
+ let child = null;
348
+ let ready = false;
349
+ let buffer = Buffer.alloc(0);
350
+ let current = null;
351
+ const queue = [];
352
+ let outstanding = 0; // live directory handles in the .ps1 handle table
353
+ let idleTimer = null;
354
+ let opTimer = null; // R4-001 deadline
355
+ /** Resolve the manifest binding from exactly ONE source (R1-001). */
356
+ function resolveBinding() {
357
+ return opts.manifest
358
+ ? opts.manifest.installerHelpers?.windowsSecureObject
359
+ : readManifestBinding();
360
+ }
361
+ /**
362
+ * R1-001: resolve the binding ONCE, verify the on-disk `.ps1` sha256 against
363
+ * it, and RETURN the verified binding so `init` spawns EXACTLY the artifact
364
+ * that was hashed. A manifest swap between verify and spawn can no longer slip
365
+ * an unverified `.ps1` through (there is no second manifest read). Any
366
+ * mismatch/absent binding/read error → a fail-closed detail, no binding.
367
+ * (The file-content TOCTOU — hash reads the `.ps1`, powershell re-opens it by
368
+ * path — is an already-accepted design residual, parity with the `.mjs`.)
369
+ */
370
+ function verifyDigest() {
371
+ const binding = resolveBinding();
372
+ if (!binding)
373
+ return { detail: "helper digest mismatch" };
374
+ try {
375
+ const bytes = readFile(path.join(assetsDir, binding.name));
376
+ const sha = createHash("sha256").update(bytes).digest("hex");
377
+ if (sha !== binding.sha256)
378
+ return { detail: "helper digest mismatch" };
379
+ }
380
+ catch {
381
+ return { detail: "helper digest mismatch" };
382
+ }
383
+ return { binding };
384
+ }
385
+ function readManifestBinding() {
386
+ try {
387
+ const raw = readFile(path.join(assetsDir, "manifest.json"));
388
+ const parsed = JSON.parse(raw.toString("utf8"));
389
+ return parsed.installerHelpers?.windowsSecureObject;
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ }
395
+ /** R4-001: clear the per-op / handshake deadline (idempotent). */
396
+ function clearOpTimer() {
397
+ if (opTimer) {
398
+ clearTimer(opTimer);
399
+ opTimer = null;
400
+ }
401
+ }
402
+ /**
403
+ * R4-001: arm the per-op / handshake deadline. Idempotent — one outstanding
404
+ * deadline at a time (the protocol is strictly serial). On expiry the child is
405
+ * killed and every pending/subsequent op fails closed via `fail`.
406
+ */
407
+ function armOpTimer() {
408
+ if (dead || opTimer)
409
+ return;
410
+ opTimer = setTimer(() => {
411
+ opTimer = null;
412
+ fail("timeout");
413
+ }, opTimeoutMs);
414
+ }
415
+ function init() {
416
+ // R1-004: a completed close() is TERMINAL. Never re-init/spawn after close —
417
+ // the finished close would not reap the new child, leaking it. `dead` is set
418
+ // by close() (and by fail()), so guarding here + the `dead` check in request()
419
+ // makes the session single-shot: once closed, every request refuses closed.
420
+ if (dead)
421
+ return;
422
+ initialized = true;
423
+ const verified = verifyDigest();
424
+ if ("detail" in verified) {
425
+ dead = true;
426
+ deadDetail = verified.detail;
427
+ return;
428
+ }
429
+ // R1-001: spawn EXACTLY the binding that verifyDigest hashed — no re-read.
430
+ const ps1Path = path.join(assetsDir, verified.binding.name);
431
+ try {
432
+ child = spawn(POWERSHELL, [...POWERSHELL_ARGS, ps1Path]);
433
+ }
434
+ catch (error) {
435
+ // R4-004: a synchronous spawn throw must fail closed, not hang callers.
436
+ dead = true;
437
+ deadDetail = `helper spawn ${error instanceof Error ? error.message : String(error)}`;
438
+ return;
439
+ }
440
+ child.unref?.();
441
+ child.stdout.on("data", onData);
442
+ child.on("exit", () => onExit());
443
+ child.on("error", () => onExit());
444
+ registerExitHook(() => child?.kill());
445
+ // R4-001: arm the handshake deadline — a `.ps1` that spawns but never emits
446
+ // the ready frame must not hang the first caller forever.
447
+ armOpTimer();
448
+ }
449
+ /**
450
+ * Settle every pending/queued request (current is always `queue[0]` until its
451
+ * response shifts it) with the current fail-closed `deadDetail`, so no caller
452
+ * ever hangs. Shared by `fail` and `close` (R4-002).
453
+ */
454
+ function drainPending() {
455
+ const all = [...queue];
456
+ queue.length = 0;
457
+ current = null;
458
+ for (const item of all) {
459
+ item.resolve({
460
+ ok: false,
461
+ refusal: "windows-secure-object-unavailable",
462
+ detail: deadDetail,
463
+ });
464
+ }
465
+ }
466
+ function fail(detail) {
467
+ if (dead)
468
+ return;
469
+ dead = true;
470
+ deadDetail = `helper ${detail}`;
471
+ child?.kill();
472
+ if (idleTimer) {
473
+ clearTimer(idleTimer);
474
+ idleTimer = null;
475
+ }
476
+ clearOpTimer(); // R4-001
477
+ drainPending();
478
+ }
479
+ function onExit() {
480
+ fail("child exited");
481
+ }
482
+ function onData(chunk) {
483
+ buffer = Buffer.concat([buffer, chunk]);
484
+ let decoded;
485
+ try {
486
+ decoded = decodeFrames(buffer);
487
+ }
488
+ catch (error) {
489
+ // R3-004: both stay fail-closed, but distinguish a genuine oversized
490
+ // frame from a malformed/zero-length/bad-JSON body in the diagnostic.
491
+ const msg = error instanceof Error ? error.message : String(error);
492
+ fail(/oversized/i.test(msg) ? "oversized frame" : `malformed frame: ${msg}`);
493
+ return;
494
+ }
495
+ buffer = decoded.rest;
496
+ for (const frame of decoded.frames)
497
+ handleFrame(frame);
498
+ }
499
+ function handleFrame(frame) {
500
+ if (dead)
501
+ return;
502
+ if (!ready) {
503
+ const hs = frame;
504
+ if (hs?.ready === true && hs.protocolVersion === 1) {
505
+ clearOpTimer(); // R4-001: handshake arrived; pump re-arms per request
506
+ ready = true;
507
+ pump();
508
+ }
509
+ else {
510
+ fail("bad handshake");
511
+ }
512
+ return;
513
+ }
514
+ const item = current;
515
+ if (!item)
516
+ return; // stray frame with nothing outstanding — ignore
517
+ clearOpTimer(); // R4-001: response arrived; pump re-arms for the next frame
518
+ current = null;
519
+ queue.shift();
520
+ adjustHandles(item.req, frame);
521
+ item.resolve(frame);
522
+ maybeArmIdle();
523
+ pump();
524
+ }
525
+ function adjustHandles(req, res) {
526
+ if ((req.op === "openDir" || req.op === "createDir") && res.ok === true) {
527
+ outstanding++;
528
+ }
529
+ else if (req.op === "releaseHandle") {
530
+ outstanding = Math.max(0, outstanding - 1);
531
+ }
532
+ }
533
+ function maybeArmIdle() {
534
+ if (dead || current || queue.length > 0 || outstanding > 0)
535
+ return;
536
+ if (idleTimer)
537
+ return;
538
+ idleTimer = setTimer(() => {
539
+ idleTimer = null;
540
+ fail("idle");
541
+ }, idleMs);
542
+ }
543
+ function pump() {
544
+ if (dead || !ready || current || queue.length === 0)
545
+ return;
546
+ current = queue[0];
547
+ child?.stdin.write(encodeFrame(current.req));
548
+ armOpTimer(); // R4-001: deadline for this outstanding request
549
+ }
550
+ return {
551
+ async request(req) {
552
+ if (!initialized)
553
+ init();
554
+ if (dead) {
555
+ return {
556
+ ok: false,
557
+ refusal: "windows-secure-object-unavailable",
558
+ detail: deadDetail,
559
+ };
560
+ }
561
+ if (idleTimer) {
562
+ clearTimer(idleTimer);
563
+ idleTimer = null;
564
+ }
565
+ return new Promise((resolve) => {
566
+ queue.push({ req, resolve });
567
+ pump();
568
+ });
569
+ },
570
+ async close() {
571
+ if (!dead) {
572
+ dead = true;
573
+ deadDetail = "helper closed";
574
+ }
575
+ if (idleTimer) {
576
+ clearTimer(idleTimer);
577
+ idleTimer = null;
578
+ }
579
+ clearOpTimer(); // R4-001
580
+ // R4-002: drain BEFORE killing the child so pending/queued promises settle
581
+ // fail-closed even when close() (not onExit) is the terminator — otherwise
582
+ // a caller that wired abort→close() would hang forever.
583
+ drainPending();
584
+ child?.kill();
585
+ },
586
+ };
587
+ }
588
+ //# sourceMappingURL=secure-fs-windows.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {