litmus-cli 1.4.20 → 1.4.21

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.
@@ -12,12 +12,21 @@
12
12
  * `hook_event_name`, not argv — the same logger command is registered for both
13
13
  * event kinds per tool.
14
14
  *
15
+ * An `ai_response` may additionally carry the turn's metering, when the tool's
16
+ * payload has it: `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
17
+ * `status` (ENG-1956, Cursor `stop` today). Those names are the GATEWAY
18
+ * emitter's, so a reader needs no per-tool branch — see cursorTurnMeta.
19
+ *
15
20
  * Response sources per tool:
16
21
  * claude Stop payload -> read transcript_path (JSONL), assistant text of the
17
22
  * final turn
18
23
  * codex Stop payload -> `last_assistant_message` directly
19
24
  * cursor afterAgentResponse payload -> `text` directly (IDE only; the
20
- * cursor-agent CLI never fires it)
25
+ * cursor-agent CLI never fires it, and Cursor 3.17.19 does not list
26
+ * the event at all, so the container lane's only response event is
27
+ * `stop` and this branch is unreachable there)
28
+ * stop payload -> read transcript_path (JSONL), the final turn's
29
+ * answer, plus the payload's own model + token counts
21
30
  * copilot Stop payload -> read transcript_path (JSONL), assistant.message
22
31
  * entries of the final turn (VS Code shape; CLI best-effort)
23
32
  * gemini AfterAgent payload -> `prompt_response` directly (arrives inline;
@@ -35,7 +44,7 @@ const os = require("os")
35
44
  const https = require("https")
36
45
  const http = require("http")
37
46
  const crypto = require("crypto")
38
- const { spawn } = require("child_process")
47
+ const { spawn, spawnSync } = require("child_process")
39
48
 
40
49
  // Keep under the server's MAX_EVENT_SIZE (2048 bytes) after JSON serialization.
41
50
  // ~1800 chars of prompt + ~150 bytes of wrapper fields + JSON escaping = safely under 2KB.
@@ -513,6 +522,186 @@ function uploadEvent(config, event) {
513
522
  })
514
523
  }
515
524
 
525
+ // ── The drain lane (ENG-1962) ─────────────────────────────────────
526
+ //
527
+ // `activity.jsonl` is `-rw-r--r-- candidate candidate`, and until submission it
528
+ // is the ONLY copy of a captured prompt. A candidate can edit it or delete it
529
+ // after the fact and nothing notices — measured on production 2026-08-28, a
530
+ // container held six correctly paired Cursor events while the database held
531
+ // none, with terminal commands from the same container arriving within ~4s
532
+ // throughout. So the append below is also the trigger for a second copy, sent
533
+ // at write time down a lane the candidate cannot reach afterwards:
534
+ //
535
+ // this process -> /opt/litmus/bin/litmus-ttee-send -> /run/litmus/ttee.sock
536
+ // -> ttee-drain (root) -> the container's root-owned json-log
537
+ // -> hostd `docker logs` -> /internal/host/activity
538
+ //
539
+ // What that buys and what it does not. The host holds the upload credential, so
540
+ // nothing here needs one — which is why this beats giving the container a
541
+ // submission token, since that token would have to live in `.litmus/config.json`
542
+ // and ride into every submission zip and backup tar. The record becomes
543
+ // hard to SUPPRESS. It does not become unforgeable: the drain's own peer check
544
+ // is a filter against a naive injector, and this binary is on the rootfs any
545
+ // process can invoke. See ttee-drain.c's honest-residual note.
546
+ //
547
+ // FAIL-OPEN, like every other line of this lane. A missing sender, a missing
548
+ // socket, a full drain, a machine that is not the workspace at all — every one
549
+ // of them degrades to file-only capture, which is exactly what happens today.
550
+ //
551
+ // Absolute paths, never PATH and never an env override. Two independent reasons,
552
+ // and the second is the one that bites:
553
+ //
554
+ // * The drain grades an AI frame by this exact path (`ai_peer_verdict`,
555
+ // ttee-drain.c): a peer here is ATTESTED, a peer under /home or /tmp is
556
+ // rejected outright, and anything else is accepted but marked unattested.
557
+ // In the container it can never read a cross-uid peer at all — that needs
558
+ // CAP_SYS_PTRACE, which is deliberately dropped — so every frame there is
559
+ // unattested today. The path still matters for the day it can, and it is
560
+ // what install-ttee.sh asserts against. See HARDENING.md's residual.
561
+ // * PATH is the single input to this lane a candidate can influence, and an
562
+ // env override would hand them a one-variable way to turn capture off
563
+ // without touching anything we can see.
564
+ //
565
+ // Historical note, corrected rather than deleted because the old reason is the
566
+ // one people remember: this used to be justified by cursor-server shipping its
567
+ // own node under /home, which the drain's rootfs check rejected. Since the
568
+ // connecting process is this sender and never node, the INTERPRETER's path no
569
+ // longer decides whether a frame is accepted — the SENDER's does.
570
+ const DRAIN_SENDER = "/opt/litmus/bin/litmus-ttee-send"
571
+ const DRAIN_SOCKET = "/run/litmus/ttee.sock"
572
+
573
+ // Must stay at or under ttee-drain's TTEE_MAX_FRAME (8192). The drain recv()s a
574
+ // fixed-size buffer with no MSG_TRUNC check, so an oversize frame arrives cut
575
+ // mid-JSON, hostd's json.loads fails and the event disappears with no diagnostic
576
+ // — which is why the sender REFUSES an over-cap frame rather than clipping it,
577
+ // and why the body is trimmed here, on the only side that knows which field is
578
+ // the body. The margin covers the drain's own envelope and a pathological
579
+ // escape expansion in the fields we do not trim.
580
+ const MAX_DRAIN_FRAME_BYTES = 7800
581
+
582
+ // The drain answers in microseconds (one connect + one datagram to a local
583
+ // SEQPACKET socket). This bound exists for the pathological case — a wedged
584
+ // socket, a stopped drain process — not the normal one, and it is an order of
585
+ // magnitude under every hook runner's allowance.
586
+ const DRAIN_SEND_TIMEOUT_MS = 1500
587
+
588
+ // The marker a trimmed body carries. ASCII on purpose: `…` reaches the judge as
589
+ // `?`, and a reader with no marker at all cannot tell a short reply from a cut one.
590
+ const TRUNCATION_MARKER = "...[truncated]"
591
+
592
+ /**
593
+ * Trim `event[field]` by halving until the whole event serializes inside
594
+ * `maxBytes`. Returns true if it fits, false if it cannot at any body length.
595
+ *
596
+ * The halving is over the ORIGINAL body, tracked in a counter that strictly
597
+ * decreases, and NOT over the already-trimmed value. Slicing the trimmed value
598
+ * does not converge: TRUNCATION_MARKER is 14 characters, so once the body is
599
+ * short each pass adds back more than it removes and the length oscillates —
600
+ * 13, 27, 13, 27 — forever. That is an infinite loop inside a candidate's
601
+ * prompt hook, which is the one thing this file must never do, and it is
602
+ * reachable through any wrapper field a tool controls the size of (`sessionId`
603
+ * and `model` both arrive from the tool's payload).
604
+ *
605
+ * Character caps do not bound SERIALIZED size, which is why this measures bytes:
606
+ * JSON.stringify escapes non-ASCII and control characters, so a body well under
607
+ * a character cap can be far over a byte one in CJK, Devanagari, emoji, or
608
+ * heavily escaped text.
609
+ */
610
+ function trimEventToBytes(event, field, maxBytes) {
611
+ const full = typeof event[field] === "string" ? event[field] : ""
612
+ let kept = full.length
613
+ while (Buffer.byteLength(JSON.stringify(event), "utf8") > maxBytes) {
614
+ if (kept === 0) return false // the non-body fields alone blow the cap
615
+ kept = Math.floor(kept / 2)
616
+ event[field] = kept ? full.slice(0, kept) + TRUNCATION_MARKER : ""
617
+ }
618
+ return true
619
+ }
620
+
621
+ /**
622
+ * The frame ttee-drain forwards for one captured event, or null if it cannot be
623
+ * built inside the cap.
624
+ *
625
+ * `ev` names the LANE, not the tool: `cursor_hook` is the hook-binary-to-drain
626
+ * route, and the tool that produced the event rides in `tool`. The name is the
627
+ * one already pinned by hostd's `_parse_cursor_hook` and by the bakeoff gate
628
+ * `hook_frame_captured`, which greps for it literally — change it in one place
629
+ * and the frame is dropped by the parser and reported as "no frame arrived".
630
+ *
631
+ * ONE frame per event, never chunked: the drain emits one log line per frame and
632
+ * hostd turns one line into one event, so a split prompt would reach the grader
633
+ * as prompts the candidate never sent (bakeoff gate `hook_oversize_prompt`
634
+ * fails on more than one frame for exactly this reason).
635
+ *
636
+ * Exported for tests.
637
+ */
638
+ function buildDrainFrame(event, hookEventName) {
639
+ if (!event || typeof event !== "object") return null
640
+ const bodyField = event.type === "ai_response" ? "text" : "prompt"
641
+ const body = typeof event[bodyField] === "string" ? event[bodyField] : ""
642
+ // Rebuilt rather than spread: the payload this event came from carries the
643
+ // candidate's real email address and whatever else the tool decides to add
644
+ // next, so the frame is an ALLOW-LIST for the same reason cursorTurnMeta is.
645
+ const frame = {
646
+ ev: "cursor_hook",
647
+ kind: event.type,
648
+ tool: event.tool,
649
+ ts: event.ts,
650
+ }
651
+ // The per-event id minted before either copy was written. It is what makes the
652
+ // two copies of ONE exchange one stored event — see where it is minted for why
653
+ // the pairing cannot rest on (sessionId, turnId). Carried ahead of the body so
654
+ // the trim below can never cost it.
655
+ if (event.id) frame.id = event.id
656
+ if (typeof hookEventName === "string" && hookEventName) frame.hook = hookEventName
657
+ // `sessionId` deliberately does NOT ride under that name. hostd runs every
658
+ // event it emits through `strip_credentials`, whose key list drops anything
659
+ // spelled session / sessionid / session_id — an HTTP-audit rule that is right
660
+ // for its own purpose and a false positive on a chat-session key. hostd's own
661
+ // recovered events already spell it `conversationId` for this reason; the
662
+ // frame matches so the two sides read the same. (Do not "fix" this back.)
663
+ if (event.sessionId) frame.conversationId = event.sessionId
664
+ if (event.turnId) frame.turnId = event.turnId
665
+ for (const k of ["model", "input_tokens", "output_tokens", "cache_read_tokens", "status"]) {
666
+ if (event[k] !== undefined) frame[k] = event[k]
667
+ }
668
+ frame.text = body
669
+ // Trim the BODY only, so the metering and the pairing ids always survive —
670
+ // they are what makes a truncated record still worth having, and they cost a
671
+ // fixed ~200 bytes.
672
+ if (!trimEventToBytes(frame, "text", MAX_DRAIN_FRAME_BYTES)) return null
673
+ if (!frame.text) return null // trimmed to nothing: a husk with a turn id and
674
+ // no content reads downstream as a captured
675
+ // prompt that said nothing. Send no frame.
676
+ if (frame.text !== body) frame.textTruncated = true
677
+ return JSON.stringify(frame)
678
+ }
679
+
680
+ /**
681
+ * Best-effort copy of `event` onto the root drain. Returns nothing and throws
682
+ * nothing: every failure is file-only capture, the state this lane is in today.
683
+ */
684
+ function emitToDrain(event, hookEventName) {
685
+ try {
686
+ // Cheapest possible negative. On a candidate's own laptop — where this file
687
+ // is also installed — neither path exists and this costs two stat calls.
688
+ if (process.platform !== "linux") return
689
+ if (!fs.existsSync(DRAIN_SENDER) || !fs.existsSync(DRAIN_SOCKET)) return
690
+ const frame = buildDrainFrame(event, hookEventName)
691
+ if (!frame) return
692
+ spawnSync(DRAIN_SENDER, [], {
693
+ input: frame,
694
+ timeout: DRAIN_SEND_TIMEOUT_MS,
695
+ // stdout is discarded on principle (the sender writes none, and a byte on
696
+ // OUR stdout would be a byte in Cursor's verdict); stderr is inherited so
697
+ // a refused frame leaves a breadcrumb in the hook log instead of nothing.
698
+ stdio: ["pipe", "ignore", "inherit"],
699
+ })
700
+ } catch {
701
+ // Unwritable, unspawnable, out of pids — never the candidate's problem.
702
+ }
703
+ }
704
+
516
705
  function readStdin() {
517
706
  return new Promise((resolve) => {
518
707
  let resolved = false
@@ -899,6 +1088,43 @@ function readCursorTranscriptResponse(transcriptPath) {
899
1088
  return readSettledTranscript(transcriptPath, raw => parseCursorTranscript(raw).text)
900
1089
  }
901
1090
 
1091
+ /**
1092
+ * The turn's metering, lifted off a Cursor `stop` payload (ENG-1956).
1093
+ *
1094
+ * FIELD NAMES ARE THE GATEWAY'S, NOT CURSOR'S, and that is the point: the
1095
+ * gateway emitter (backend/services/gateway_capture.py `build_event`) already
1096
+ * publishes `model` / `input_tokens` / `output_tokens` alongside a reply, and
1097
+ * every downstream reader is written against those names. Renaming Cursor's
1098
+ * `model_id` to `model` here is what keeps this lane from needing a special
1099
+ * case anywhere in the grader. `cache_read_tokens` and `status` have no gateway
1100
+ * counterpart; they are carried under their own payload names.
1101
+ *
1102
+ * `model_id` before `model` deliberately: the payload's `model` is Cursor's
1103
+ * internal variant string ("cursor-grok-4.6-high-fast") while `model_id` is the
1104
+ * model identity ("grok-4.6"), and identity is what is comparable to what every
1105
+ * other lane reports.
1106
+ *
1107
+ * AN ALLOW-LIST, NEVER A COPY-WITH-DELETIONS. The `stop` payload also carries
1108
+ * `user_email` — the candidate's real personal address — so the only safe
1109
+ * construction is one that names what it takes. A spread-then-delete would
1110
+ * publish the next PII field Cursor adds, on the day they add it, with nothing
1111
+ * failing.
1112
+ */
1113
+ function cursorTurnMeta(data) {
1114
+ if (!data || typeof data !== "object") return null
1115
+ const meta = {}
1116
+ const model = data.model_id || data.model
1117
+ if (typeof model === "string" && model) meta.model = model
1118
+ for (const key of ["input_tokens", "output_tokens", "cache_read_tokens"]) {
1119
+ const value = data[key]
1120
+ // `typeof true === "boolean"`, so bool cannot sneak in here — but a
1121
+ // non-finite number can, and JSON.stringify would render it `null`.
1122
+ if (typeof value === "number" && Number.isFinite(value)) meta[key] = Math.trunc(value)
1123
+ }
1124
+ if (typeof data.status === "string" && data.status) meta.status = data.status
1125
+ return Object.keys(meta).length ? meta : null
1126
+ }
1127
+
902
1128
  /**
903
1129
  * Extract the assistant response from a post-response hook payload. Returns
904
1130
  * `{ text, sessionId }` with text=null when the payload carries no capturable
@@ -955,6 +1181,9 @@ function extractResponse(data, toolName) {
955
1181
  const common = {
956
1182
  sessionId: data.session_id || data.conversation_id || null,
957
1183
  workspaceRoots: Array.isArray(data.workspace_roots) ? data.workspace_roots : [],
1184
+ // Present on `stop` (which carries the metering and no text) and absent
1185
+ // from `afterAgentResponse` (which carries the text and no metering).
1186
+ meta: cursorTurnMeta(data),
958
1187
  }
959
1188
  // afterAgentResponse (IDE only) hands us the text directly — preferred,
960
1189
  // since it needs no transcript and has no flush race.
@@ -1224,11 +1453,13 @@ async function main() {
1224
1453
  let content = null
1225
1454
  let sessionId = null
1226
1455
  let workspaceRoots = []
1456
+ let responseMeta = null
1227
1457
  if (responseEvent) {
1228
1458
  const extracted = extractResponse(parsed, effectiveTool)
1229
1459
  content = extracted.text
1230
1460
  sessionId = extracted.sessionId
1231
1461
  workspaceRoots = extracted.workspaceRoots || []
1462
+ responseMeta = extracted.meta || null
1232
1463
  } else {
1233
1464
  const extracted = extractPrompt(raw, effectiveTool)
1234
1465
  content = extracted.prompt
@@ -1305,25 +1536,48 @@ async function main() {
1305
1536
  tool: effectiveTool,
1306
1537
  prompt: truncated,
1307
1538
  }
1539
+ // ENG-1962: ONE id per captured event, minted here — before either copy is
1540
+ // written — so the copy in `activity.jsonl` and the copy that goes down the
1541
+ // drain carry the same one and collapse into a single stored event.
1542
+ //
1543
+ // Lane-independent on purpose. The obvious pairing handle is
1544
+ // (sessionId, turnId), and it is not enough: `startTurn` returns null for a
1545
+ // payload with no session id, and several tools have none to give (VS Code
1546
+ // Copilot Chat's `session_id` is optional, an unrecognised tool takes the
1547
+ // `default:` branch, and a reply whose prompt-side turn file is missing gets
1548
+ // no turn either). Every one of those exchanges would then be stored and
1549
+ // graded twice — the ENG-1347 2x count, arriving by this new route. An id we
1550
+ // mint ourselves pairs the two copies whatever the tool reports.
1551
+ //
1552
+ // `id` rather than a name of our own because `activity_dedupe_key` already
1553
+ // prefers it over the whole-event digest, so both seams collapse the pair with
1554
+ // no second mechanism; and it survives hostd's `strip_credentials`, whose key
1555
+ // list would have deleted anything spelled session/token/secret.
1556
+ event.id = crypto.randomUUID()
1308
1557
  if (sessionId) event.sessionId = sessionId
1309
1558
  if (turnId) event.turnId = turnId
1310
-
1311
- // Character caps don't bound SERIALIZED size: the server measures
1312
- // JSON bytes, and JSON.stringify escapes non-ASCII and control characters
1313
- // a response well under MAX_RESPONSE_LENGTH characters can exceed the
1314
- // server's 65536-byte content cap in CJK, Devanagari, emoji, or heavily
1315
- // escaped text. The server now trims rather than drops, but trimming here
1316
- // too keeps that a backstop instead of the normal path, and keeps the
1317
- // in-zip activity.jsonl consistent with what was uploaded.
1559
+ // The turn's model and token counts (ENG-1956). Attached BEFORE the byte-cap
1560
+ // loop below so their bytes are counted against the same budget the body is
1561
+ // trimmed to fit otherwise a reply trimmed to exactly the cap could be
1562
+ // pushed back over it by the metering, and the server would trim it again.
1563
+ // They are a fixed ~120 bytes, so they never meaningfully cost reply text.
1564
+ if (responseMeta) Object.assign(event, responseMeta)
1565
+
1566
+ // Character caps don't bound SERIALIZED size — see trimEventToBytes, which
1567
+ // also carries the reason this halves the ORIGINAL body rather than the
1568
+ // already-trimmed one. This loop used to do the latter and could therefore
1569
+ // spin forever on any event whose wrapper fields alone approached the cap;
1570
+ // that needed a ~60 KB `sessionId` or `model` (both arrive from the tool's
1571
+ // payload) so it had never fired, but it is a hang in a candidate's prompt
1572
+ // hook and the drain lane above reuses the same idiom at a much tighter cap,
1573
+ // where it fires easily. One helper, one convergence argument.
1574
+ //
1575
+ // The server now trims rather than drops, but trimming here too keeps that a
1576
+ // backstop instead of the normal path, and keeps the in-zip activity.jsonl
1577
+ // consistent with what was uploaded.
1318
1578
  const MAX_SERIALIZED_BYTES = 60000
1319
1579
  const bodyField = responseEvent ? "text" : "prompt"
1320
- while (
1321
- Buffer.byteLength(JSON.stringify(event), "utf8") > MAX_SERIALIZED_BYTES
1322
- && event[bodyField].length > 0
1323
- ) {
1324
- const kept = Math.max(0, Math.floor(event[bodyField].length / 2))
1325
- event[bodyField] = kept ? event[bodyField].slice(0, kept) + "...[truncated]" : ""
1326
- }
1580
+ trimEventToBytes(event, bodyField, MAX_SERIALIZED_BYTES)
1327
1581
 
1328
1582
  // Record that this turn's reply is captured, so the sibling `stop` hook
1329
1583
  // doesn't record it a second time (IDE only — see the claim helpers).
@@ -1339,6 +1593,12 @@ async function main() {
1339
1593
  // Non-critical
1340
1594
  }
1341
1595
 
1596
+ // 1b. Same moment, second copy: onto the root drain, where the candidate
1597
+ // cannot edit or delete it afterwards (ENG-1962). Additive — step 1 above
1598
+ // stays the submission-zip path, so a container whose drain lane is down
1599
+ // still grades exactly as it does today.
1600
+ emitToDrain(event, parsed && parsed.hook_event_name)
1601
+
1342
1602
  // 2. Upload to the server. Awaited, bounded — see uploadEvent.
1343
1603
  const config = readConfig(litmusDir)
1344
1604
  await uploadEvent(config, event)
@@ -1355,8 +1615,13 @@ async function main() {
1355
1615
  module.exports = {
1356
1616
  watcherPresumedDead,
1357
1617
  reviveThrottleAllows,
1618
+ buildDrainFrame,
1619
+ trimEventToBytes,
1358
1620
  WATCHER_NONCE_MAX_AGE_MS,
1359
1621
  REVIVE_THROTTLE_MS,
1622
+ MAX_DRAIN_FRAME_BYTES,
1623
+ DRAIN_SENDER,
1624
+ DRAIN_SOCKET,
1360
1625
  }
1361
1626
 
1362
1627
  if (require.main === module) {
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Platform adapter — encapsulates OS-specific commands so detection logic
3
3
  * stays platform-agnostic. Callers never branch on process.platform.
4
+ *
5
+ * Every child_process call here MUST pass `windowsHide: true`. This module
6
+ * runs inside the watcher, which is spawned detached and therefore has no
7
+ * console on Windows — and a console-subsystem child (tasklist, netstat) of
8
+ * a console-less parent allocates a NEW VISIBLE console window, so candidates
9
+ * see terminal windows flash on every detection cycle (ENG-2010). The flag is
10
+ * a no-op on Unix; tests/unit/windows-hide.test.ts enforces it.
4
11
  */
5
12
  /**
6
13
  * Returns lowercase text listing all running processes.
@@ -1 +1 @@
1
- {"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;;;;GAQG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAGvC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAarF;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,KAAK,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAqBtE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAkBrF"}
1
+ {"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH;;;;;;;;GAQG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAGvC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAarF;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,KAAK,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAqBtE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAkBrF"}
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Platform adapter — encapsulates OS-specific commands so detection logic
3
3
  * stays platform-agnostic. Callers never branch on process.platform.
4
+ *
5
+ * Every child_process call here MUST pass `windowsHide: true`. This module
6
+ * runs inside the watcher, which is spawned detached and therefore has no
7
+ * console on Windows — and a console-subsystem child (tasklist, netstat) of
8
+ * a console-less parent allocates a NEW VISIBLE console window, so candidates
9
+ * see terminal windows flash on every detection cycle (ENG-2010). The flag is
10
+ * a no-op on Unix; tests/unit/windows-hide.test.ts enforces it.
4
11
  */
5
12
  import { execSync, execFile } from "child_process";
6
13
  const isWin = process.platform === "win32";
@@ -16,7 +23,7 @@ const isLinux = process.platform === "linux";
16
23
  */
17
24
  export function getProcessList() {
18
25
  const cmd = isWin ? "tasklist" : "ps aux";
19
- return execSync(cmd, { timeout: 5000, encoding: "utf8" }).toLowerCase();
26
+ return execSync(cmd, { timeout: 5000, encoding: "utf8", windowsHide: true }).toLowerCase();
20
27
  }
21
28
  /**
22
29
  * Lists active network connections asynchronously.
@@ -31,13 +38,13 @@ export function getProcessList() {
31
38
  */
32
39
  export function getConnections(cb) {
33
40
  if (isWin) {
34
- execFile("netstat", ["-ano"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }, cb);
41
+ execFile("netstat", ["-ano"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, windowsHide: true }, cb);
35
42
  return;
36
43
  }
37
- execFile("lsof", ["-i", "-n", "-P", "+c0"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
44
+ execFile("lsof", ["-i", "-n", "-P", "+c0"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, windowsHide: true }, (err, stdout) => {
38
45
  if (err && err.code === "ENOENT" && isLinux) {
39
46
  // lsof unavailable on Linux — fall back to ss
40
- execFile("ss", ["-tnp"], { timeout: 5000, encoding: "utf8", maxBuffer: 1024 * 1024 }, cb);
47
+ execFile("ss", ["-tnp"], { timeout: 5000, encoding: "utf8", maxBuffer: 1024 * 1024, windowsHide: true }, cb);
41
48
  }
42
49
  else {
43
50
  cb(err, stdout);
@@ -91,7 +98,7 @@ export function resolvePidNames(cb) {
91
98
  cb(null);
92
99
  return;
93
100
  }
94
- execFile("tasklist", ["/fo", "csv", "/nh"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
101
+ execFile("tasklist", ["/fo", "csv", "/nh"], { timeout: 10000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, windowsHide: true }, (err, stdout) => {
95
102
  if (err || !stdout) {
96
103
  cb(null);
97
104
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAElD,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAA;AAC1C,MAAM,OAAO,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAA;AAE5C;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAA;IACzC,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;AACzE,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,EAAgD;IAC7E,IAAI,KAAK,EAAE,CAAC;QACV,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,KAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,EAAE,EAAE,CAAC,CAAA;QACpG,OAAM;IACR,CAAC;IACD,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,OAAO,EAAE,KAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE;QAC7H,IAAI,GAAG,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,EAAE,CAAC;YACvE,8CAA8C;YAC9C,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,GAAG,IAAI,EAAE,EAAE,EAAE,CAAC,CAAA;QAC3F,CAAC;aAAM,CAAC;YACN,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,CAAA;QACjB,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC;AAgBD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;QACtC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,EAAE,CAAC;YACpE,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAA;YACxD,IAAI,CAAC,CAAC;gBAAE,OAAO,IAAI,CAAA;YACnB,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,CAAA;QAC1E,CAAC;QACD,OAAO,IAAI,CAAA;IACb,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAA;IACvD,IAAI,IAAI,EAAE,CAAC;QACT,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAA;IAC7E,CAAC;IACD,0EAA0E;IAC1E,uEAAuE;IACvE,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,4DAA4D,CAAC,CAAA;IACnF,IAAI,EAAE,EAAE,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAA;IACvD,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,EAA+C;IAC7E,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,EAAE,CAAC,IAAI,CAAC,CAAA;QACR,OAAM;IACR,CAAC;IACD,QAAQ,CAAC,UAAU,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,EAAE,OAAO,EAAE,KAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE;QAC7H,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;YACnB,EAAE,CAAC,IAAI,CAAC,CAAA;YACR,OAAM;QACR,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAA;QACvC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACtC,oEAAoE;YACpE,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAA;YAC1C,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAC9B,CAAC;QACD,EAAE,CAAC,KAAK,CAAC,CAAA;IACX,CAAC,CAAC,CAAA;AACJ,CAAC"}
1
+ {"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"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"}
@@ -946,7 +946,10 @@ function checkGitCommits() {
946
946
  if (!currentHash || currentHash === lastCommitHash)
947
947
  return;
948
948
  lastCommitHash = currentHash;
949
- const info = execSync('git log -1 --numstat --pretty=format:"%H|%aI|%s"', { cwd: projectDir, timeout: 5000, encoding: "utf8" });
949
+ // windowsHide on every shell-out in this file: the watcher is spawned
950
+ // detached (no console on Windows), so a console-subsystem child would
951
+ // otherwise open a visible console window in the candidate's face (ENG-2010).
952
+ const info = execSync('git log -1 --numstat --pretty=format:"%H|%aI|%s"', { cwd: projectDir, timeout: 5000, encoding: "utf8", windowsHide: true });
950
953
  const lines = info.trim().split("\n");
951
954
  const header = lines[0].replace(/^"|"$/g, "");
952
955
  const firstPipe = header.indexOf("|");
@@ -1367,6 +1370,7 @@ function getGitDiff() {
1367
1370
  cwd: projectDir,
1368
1371
  maxBuffer: 500000,
1369
1372
  timeout: 10000,
1373
+ windowsHide: true,
1370
1374
  }).toString().slice(0, 200000);
1371
1375
  }
1372
1376
  catch {
@@ -1376,6 +1380,7 @@ function getGitDiff() {
1376
1380
  cwd: projectDir,
1377
1381
  maxBuffer: 500000,
1378
1382
  timeout: 10000,
1383
+ windowsHide: true,
1379
1384
  }).toString().slice(0, 200000);
1380
1385
  }
1381
1386
  catch {
@@ -1389,6 +1394,7 @@ function getRecentCommits() {
1389
1394
  cwd: projectDir,
1390
1395
  maxBuffer: 100000,
1391
1396
  timeout: 10000,
1397
+ windowsHide: true,
1392
1398
  }).toString();
1393
1399
  const commits = [];
1394
1400
  const lines = raw.split("\n");
@@ -1489,7 +1495,7 @@ setTimeout(() => {
1489
1495
  if (cliBinPath && litmusConfig) {
1490
1496
  const runAutoSubmit = () => new Promise((resolveRun) => {
1491
1497
  process.stderr.write("[watcher] deadline reached (server-verified), auto-submitting\n");
1492
- execFile(process.execPath, [cliBinPath, "submit", "--yes"], { cwd: projectDir, timeout: 5 * 60000 }, (err, stdout, stderr) => {
1498
+ execFile(process.execPath, [cliBinPath, "submit", "--yes"], { cwd: projectDir, timeout: 5 * 60000, windowsHide: true }, (err, stdout, stderr) => {
1493
1499
  if (!err) {
1494
1500
  process.stderr.write("[watcher] auto-submit succeeded\n");
1495
1501
  // The watcher's spawn() in tracker.ts uses stdio:["ignore", "ignore",