litmus-cli 1.4.20 → 1.4.22
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/commands/connect.d.ts +65 -2
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +183 -41
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +68 -4
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +6 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +30 -2
- package/dist/commands/submit.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +10 -1
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +9 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/backfill-tools.d.ts.map +1 -1
- package/dist/lib/backfill-tools.js +2 -0
- package/dist/lib/backfill-tools.js.map +1 -1
- package/dist/lib/chat-model-config.d.ts +2 -0
- package/dist/lib/chat-model-config.d.ts.map +1 -1
- package/dist/lib/chat-model-config.js +27 -2
- package/dist/lib/chat-model-config.js.map +1 -1
- package/dist/lib/config.d.ts +21 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +53 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/hook-logger.cjs +282 -17
- package/dist/lib/platform.d.ts +7 -0
- package/dist/lib/platform.d.ts.map +1 -1
- package/dist/lib/platform.js +12 -5
- package/dist/lib/platform.js.map +1 -1
- package/dist/lib/watcher.js +8 -2
- package/dist/lib/watcher.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/hook-logger.cjs
CHANGED
|
@@ -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
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
//
|
|
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
|
-
|
|
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) {
|
package/dist/lib/platform.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/lib/platform.js
CHANGED
|
@@ -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;
|
package/dist/lib/platform.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/lib/platform.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/watcher.js
CHANGED
|
@@ -946,7 +946,10 @@ function checkGitCommits() {
|
|
|
946
946
|
if (!currentHash || currentHash === lastCommitHash)
|
|
947
947
|
return;
|
|
948
948
|
lastCommitHash = currentHash;
|
|
949
|
-
|
|
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",
|