log-llm-config 1.2.7 → 1.3.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.
@@ -1,16 +1,37 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
3
4
  import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
4
- import { hookRunLog } from './hook_logger.js';
5
- import { getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
5
+ import { complianceRunnerDiag, hookRunLog } from './hook_logger.js';
6
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readFileCollectionVscdbContract, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
6
7
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
7
8
  import { createSignature } from '../sender/signing.js';
8
9
  import { loadEndpointBase } from '../sender/endpoint_config.js';
9
10
  import { resolveHardwareUuid } from './hardware_uuid.js';
11
+ import { persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
12
+ import { sendConfigFile } from '../sender/batch_sender.js';
13
+ import { getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
14
+ function reactiveStorageItemKeyFromContract() {
15
+ const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
16
+ return typeof k === 'string' && k.trim() !== '' ? k.trim() : undefined;
17
+ }
18
+ function composerShadowKeySetFromContract() {
19
+ return new Set(readFileCollectionVscdbContract()?.composer_shadow_keys ?? []);
20
+ }
10
21
  /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
11
22
  export function remediationFixSpec(inst) {
12
23
  return inst.fix ?? inst.compliance ?? null;
13
24
  }
25
+ /** True when this row should be replaced from GET manifest (missing or empty fix checks). */
26
+ function needsManifestRefetch(inst) {
27
+ if (!inst)
28
+ return true;
29
+ const spec = remediationFixSpec(inst);
30
+ if (!spec)
31
+ return true;
32
+ const checks = spec.checks;
33
+ return !Array.isArray(checks) || checks.length === 0;
34
+ }
14
35
  // ---------------------------------------------------------------------------
15
36
  // Persistence (single file: remediation_instructions.json)
16
37
  // ---------------------------------------------------------------------------
@@ -34,7 +55,8 @@ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overl
34
55
  if (want.has(inst.uuid))
35
56
  byUuid.set(inst.uuid, inst);
36
57
  }
37
- const missing = [...want].filter((u) => !byUuid.has(u));
58
+ // Refetch rows that are missing entirely or have no usable checks (stale/incomplete local cache).
59
+ const missing = [...want].filter((u) => needsManifestRefetch(byUuid.get(u)));
38
60
  if (missing.length > 0) {
39
61
  try {
40
62
  const fetched = await fetchManifest(endpointBase, machineUuid, missing);
@@ -85,24 +107,36 @@ export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutM
85
107
  const uuidsParam = activeUuids.join(',');
86
108
  const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/sync/?machine_uuid=${encodeURIComponent(machineUuid)}&active_uuids=${encodeURIComponent(uuidsParam)}`;
87
109
  const { statusCode, body } = await executeGet(url, timeoutMs);
88
- if (statusCode !== 200 || !body)
110
+ if (statusCode !== 200 || !body) {
111
+ const line = `remediation_sync_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
112
+ hookRunLog(line);
113
+ complianceRunnerDiag(line);
89
114
  return null;
115
+ }
90
116
  try {
91
117
  return JSON.parse(body);
92
118
  }
93
119
  catch {
120
+ hookRunLog('remediation_sync_get: invalid JSON body');
121
+ complianceRunnerDiag('remediation_sync_get: invalid JSON body');
94
122
  return null;
95
123
  }
96
124
  }
97
125
  async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000) {
98
126
  const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/manifest/?machine_uuid=${encodeURIComponent(machineUuid)}&uuids=${encodeURIComponent(uuids.join(','))}`;
99
127
  const { statusCode, body } = await executeGet(url, timeoutMs);
100
- if (statusCode !== 200 || !body)
128
+ if (statusCode !== 200 || !body) {
129
+ const line = `remediation_manifest_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
130
+ hookRunLog(line);
131
+ complianceRunnerDiag(line);
101
132
  return null;
133
+ }
102
134
  try {
103
135
  const parsed = JSON.parse(body);
104
- if (!Array.isArray(parsed.remediations))
136
+ if (!Array.isArray(parsed.remediations)) {
137
+ complianceRunnerDiag('remediation_manifest_get: JSON remediations is not an array');
105
138
  return null;
139
+ }
106
140
  return parsed.remediations.map((raw) => {
107
141
  const { compliance, ...rest } = raw;
108
142
  const fix = rest.fix ?? compliance ?? null;
@@ -110,6 +144,7 @@ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000)
110
144
  });
111
145
  }
112
146
  catch {
147
+ complianceRunnerDiag('remediation_manifest_get: invalid JSON body');
113
148
  return null;
114
149
  }
115
150
  }
@@ -139,6 +174,19 @@ function getByPath(obj, path) {
139
174
  }
140
175
  return current;
141
176
  }
177
+ /** Recursively set a value at a dot-notation path in a JSON object. */
178
+ function setByPathNested(obj, path, value) {
179
+ const parts = path.split('.');
180
+ let current = obj;
181
+ for (let i = 0; i < parts.length - 1; i++) {
182
+ const part = parts[i];
183
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
184
+ current[part] = {};
185
+ }
186
+ current = current[part];
187
+ }
188
+ current[parts[parts.length - 1]] = value;
189
+ }
142
190
  function isPlainObject(v) {
143
191
  return v != null && typeof v === 'object' && !Array.isArray(v);
144
192
  }
@@ -247,17 +295,605 @@ function applyCheck(configJson, check) {
247
295
  }
248
296
  setByPath(configJson, check.setting_path, value);
249
297
  }
298
+ function mergePostApplyUploadIntoPayload(payload, hint) {
299
+ if (!hint)
300
+ return;
301
+ const ft = hint.file_type?.trim();
302
+ if (!ft || !hint.file_path.includes('#'))
303
+ return;
304
+ if (!payload.post_apply_uploads)
305
+ payload.post_apply_uploads = [];
306
+ if (payload.post_apply_uploads.some((u) => u.file_path === hint.file_path))
307
+ return;
308
+ payload.post_apply_uploads.push({ file_path: hint.file_path, file_type: ft });
309
+ }
310
+ function assertSqlite3Available() {
311
+ try {
312
+ execFileSync('which', ['sqlite3'], { stdio: 'ignore' });
313
+ return true;
314
+ }
315
+ catch {
316
+ const line = 'sqlite_update: sqlite3 command not found';
317
+ hookRunLog(line);
318
+ complianceRunnerDiag(line);
319
+ return false;
320
+ }
321
+ }
322
+ /** Legacy Cursor: dedicated ItemTable row `composerState`. Current Cursor: nested under reactive `applicationUser` blob. */
323
+ function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
324
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, 'composerState').trim();
325
+ if (!raw || raw === '{}')
326
+ return false;
327
+ try {
328
+ const o = JSON.parse(raw);
329
+ return typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).length > 0;
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ }
335
+ function resolveCursorComposerSqliteOp(dbPath, sqliteOp) {
336
+ if (sqliteOp.target_key !== 'composerState')
337
+ return sqliteOp;
338
+ const reactiveKey = reactiveStorageItemKeyFromContract();
339
+ /** Merge into applicationUser (or equivalent) JSON: inner `composerState` or nested path under it. */
340
+ const resolveToReactive = () => {
341
+ if (!reactiveKey)
342
+ return sqliteOp;
343
+ const jp = (sqliteOp.json_path ?? '').trim();
344
+ if (!jp) {
345
+ return {
346
+ ...sqliteOp,
347
+ target_key: reactiveKey,
348
+ json_path: 'composerState',
349
+ };
350
+ }
351
+ const nestedPath = jp.startsWith('composerState.') ? jp : `composerState.${jp}`;
352
+ return {
353
+ ...sqliteOp,
354
+ target_key: reactiveKey,
355
+ json_path: nestedPath,
356
+ };
357
+ };
358
+ // Prefer the reactive storage blob when it exists and is non-empty. Cursor reads web/composer
359
+ // toggles from there; a legacy ItemTable `composerState` row may still hold stale JSON — writing
360
+ // only that row leaves the UI unchanged (user sees "remediation did nothing" after restart).
361
+ if (reactiveKey) {
362
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, reactiveKey).trim();
363
+ const reactiveHasBlob = raw !== '' && raw !== '{}';
364
+ if (reactiveHasBlob) {
365
+ return resolveToReactive();
366
+ }
367
+ }
368
+ if (cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp))
369
+ return sqliteOp;
370
+ if (reactiveKey) {
371
+ return resolveToReactive();
372
+ }
373
+ hookRunLog('vscdb_contract: missing file_collection_vscdb_contract.json (reactive_storage_item_key); run log-config or remediation sync against API');
374
+ return sqliteOp;
375
+ }
376
+ /** Apply sqlite merge: dot-path, or array match where `json_path` is `…container.arrayKey` (e.g. `modes4` or `composerState.modes4`). */
377
+ /**
378
+ * ItemTable values are sometimes bare JSON booleans for toggle keys; sqlite merge expects an object root.
379
+ * Maps known scalar roots to the policy-shaped object before applying updates.
380
+ */
381
+ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
382
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
383
+ return parsed;
384
+ }
385
+ if (targetKey === 'cursor/thirdPartyExtensibilityEnabled') {
386
+ if (typeof parsed === 'boolean') {
387
+ return { thirdPartyExtensibilityEnabled: parsed };
388
+ }
389
+ if (typeof parsed === 'string') {
390
+ const lower = parsed.trim().toLowerCase();
391
+ const b = lower === 'true' || lower === '1' || lower === 'yes';
392
+ return { thirdPartyExtensibilityEnabled: b };
393
+ }
394
+ if (typeof parsed === 'number' && !Number.isNaN(parsed)) {
395
+ return { thirdPartyExtensibilityEnabled: parsed !== 0 };
396
+ }
397
+ }
398
+ if (targetKey === 'cursorai/donotchange/privacyMode') {
399
+ if (typeof parsed === 'boolean') {
400
+ return { privacyMode: parsed };
401
+ }
402
+ if (typeof parsed === 'string') {
403
+ const lower = parsed.trim().toLowerCase();
404
+ const b = lower === 'true' || lower === '1' || lower === 'yes';
405
+ return { privacyMode: b };
406
+ }
407
+ if (typeof parsed === 'number' && !Number.isNaN(parsed)) {
408
+ return { privacyMode: parsed !== 0 };
409
+ }
410
+ }
411
+ if (targetKey === 'cursor/autoOpenLocalhostUrls') {
412
+ if (typeof parsed === 'boolean') {
413
+ return { autoOpenLocalhostUrls: parsed };
414
+ }
415
+ if (typeof parsed === 'string') {
416
+ const lower = parsed.trim().toLowerCase();
417
+ const b = lower === 'true' || lower === '1' || lower === 'yes';
418
+ return { autoOpenLocalhostUrls: b };
419
+ }
420
+ if (typeof parsed === 'number' && !Number.isNaN(parsed)) {
421
+ return { autoOpenLocalhostUrls: parsed !== 0 };
422
+ }
423
+ }
424
+ return {};
425
+ }
426
+ /**
427
+ * Cursor stores some ItemTable toggles as bare JSON booleans (`true`/`false` text). Writing a wrapper
428
+ * object can be ignored or overwritten on launch; match the native primitive shape when disabling.
429
+ */
430
+ function serializeItemTableValueForWrite(targetKey, root) {
431
+ if (targetKey === 'cursor/thirdPartyExtensibilityEnabled') {
432
+ const keys = Object.keys(root);
433
+ if (keys.length === 1 && keys[0] === 'thirdPartyExtensibilityEnabled') {
434
+ const v = root.thirdPartyExtensibilityEnabled;
435
+ if (typeof v === 'boolean')
436
+ return v ? 'true' : 'false';
437
+ if (typeof v === 'number' && !Number.isNaN(v))
438
+ return v !== 0 ? 'true' : 'false';
439
+ }
440
+ }
441
+ if (targetKey === 'cursorai/donotchange/privacyMode') {
442
+ const keys = Object.keys(root);
443
+ if (keys.length === 1 && keys[0] === 'privacyMode') {
444
+ const v = root.privacyMode;
445
+ if (typeof v === 'boolean')
446
+ return v ? 'true' : 'false';
447
+ if (typeof v === 'number' && !Number.isNaN(v))
448
+ return v !== 0 ? 'true' : 'false';
449
+ }
450
+ }
451
+ if (targetKey === 'cursor/autoOpenLocalhostUrls') {
452
+ const keys = Object.keys(root);
453
+ if (keys.length === 1 && keys[0] === 'autoOpenLocalhostUrls') {
454
+ const v = root.autoOpenLocalhostUrls;
455
+ if (typeof v === 'boolean')
456
+ return v ? 'true' : 'false';
457
+ if (typeof v === 'number' && !Number.isNaN(v))
458
+ return v !== 0 ? 'true' : 'false';
459
+ }
460
+ }
461
+ return JSON.stringify(root);
462
+ }
463
+ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
464
+ const where = sqliteOp.array_item_where;
465
+ const jp = sqliteOp.json_path ?? '';
466
+ if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
467
+ const parts = jp.split('.').filter(Boolean);
468
+ if (parts.length < 1) {
469
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
470
+ return;
471
+ }
472
+ const arrayKey = parts[parts.length - 1];
473
+ const parentParts = parts.slice(0, -1);
474
+ let node = currentJson;
475
+ for (const p of parentParts) {
476
+ if (node == null || typeof node !== 'object')
477
+ return;
478
+ node = node[p];
479
+ }
480
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
481
+ return;
482
+ const container = node;
483
+ const arr = container[arrayKey];
484
+ if (!Array.isArray(arr))
485
+ return;
486
+ const idx = arr.findIndex((item) => {
487
+ if (item === null || typeof item !== 'object')
488
+ return false;
489
+ const o = item;
490
+ return Object.entries(where).every(([k, v]) => o[k] === v);
491
+ });
492
+ if (idx < 0)
493
+ return;
494
+ Object.assign(arr[idx], sqliteOp.updates);
495
+ return;
496
+ }
497
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
498
+ mirrorComposerShadowKeysToReactiveRoot(currentJson, sqliteOp);
499
+ }
500
+ /**
501
+ * Cursor remediations historically used json_path `composerState.` (trailing dot). That yields an empty
502
+ * segment and mergeJsonAtSqlitePath wrote under composerState[''] instead of composerState — the real
503
+ * isWebFetchToolEnabled stayed true. Strip that spurious object if present.
504
+ */
505
+ function repairComposerStateEmptySegmentBug(root) {
506
+ const cs = root.composerState;
507
+ if (!cs || typeof cs !== 'object' || Array.isArray(cs))
508
+ return;
509
+ const o = cs;
510
+ if (!('' in o))
511
+ return;
512
+ delete o[''];
513
+ }
514
+ /**
515
+ * Cursor sometimes reads web-tool toggles from the reactive blob root; policy/reads merge those into
516
+ * `composerState` for scan. After patching `composerState` directly (resolved json_path exactly
517
+ * `composerState`), copy whitelisted keys to the root using cached
518
+ * `composer_shadow_keys` from the file-patterns API.
519
+ * Skips array-target ops (`modes4`, etc.).
520
+ */
521
+ function mirrorComposerShadowKeysToReactiveRoot(root, resolvedOp) {
522
+ const rk = reactiveStorageItemKeyFromContract();
523
+ if (!rk || resolvedOp.target_key !== rk)
524
+ return;
525
+ const where = resolvedOp.array_item_where;
526
+ if (where && typeof where === 'object' && Object.keys(where).length > 0)
527
+ return;
528
+ const jp = (resolvedOp.json_path ?? '').split('.').filter(Boolean);
529
+ if (jp.length !== 1 || jp[0] !== 'composerState')
530
+ return;
531
+ const updates = resolvedOp.updates;
532
+ if (!updates || typeof updates !== 'object')
533
+ return;
534
+ const shadow = composerShadowKeySetFromContract();
535
+ for (const k of Object.keys(updates)) {
536
+ if (shadow.has(k))
537
+ root[k] = updates[k];
538
+ }
539
+ }
540
+ /** Merge `updates` into JSON at dot-path `json_path` (same rules as server-generated ops). */
541
+ function mergeJsonAtSqlitePath(currentJson, json_path, updates) {
542
+ if (json_path?.trim()) {
543
+ const pathParts = json_path.split('.').filter(Boolean);
544
+ if (pathParts.length === 0) {
545
+ Object.assign(currentJson, updates);
546
+ return;
547
+ }
548
+ let current = currentJson;
549
+ for (let i = 0; i < pathParts.length - 1; i++) {
550
+ const part = pathParts[i];
551
+ const idx = parseInt(part, 10);
552
+ if (!isNaN(idx) && Array.isArray(current)) {
553
+ if (!current[idx])
554
+ current[idx] = {};
555
+ current = current[idx];
556
+ }
557
+ else {
558
+ if (!current[part])
559
+ current[part] = {};
560
+ current = current[part];
561
+ }
562
+ }
563
+ const lastPart = pathParts[pathParts.length - 1];
564
+ const lastIdx = parseInt(lastPart, 10);
565
+ if (!isNaN(lastIdx) && Array.isArray(current)) {
566
+ if (!current[lastIdx])
567
+ current[lastIdx] = {};
568
+ const target = current[lastIdx];
569
+ Object.assign(target, updates);
570
+ }
571
+ else {
572
+ if (!current[lastPart])
573
+ current[lastPart] = {};
574
+ const target = current[lastPart];
575
+ Object.assign(target, updates);
576
+ }
577
+ }
578
+ else {
579
+ Object.assign(currentJson, updates);
580
+ }
581
+ }
582
+ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key) {
583
+ const safeName = target_key.replace(/'/g, "''");
584
+ const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
585
+ return execFileSync('sqlite3', ['-noheader', dbPath], {
586
+ input: script,
587
+ encoding: 'utf8',
588
+ stdio: ['pipe', 'pipe', 'pipe'],
589
+ }).trim();
590
+ }
591
+ function sqliteExecWithTimeout(dbPath, sqlBody) {
592
+ const script = `.timeout 60000\n${sqlBody}\n`;
593
+ execFileSync('sqlite3', [dbPath], {
594
+ input: script,
595
+ encoding: 'utf8',
596
+ stdio: ['pipe', 'pipe', 'pipe'],
597
+ });
598
+ }
599
+ /** Runs a single UPDATE (or other SQL) and returns sqlite `changes()` for the last statement. */
600
+ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
601
+ const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
602
+ const out = execFileSync('sqlite3', [dbPath], {
603
+ input: script,
604
+ encoding: 'utf8',
605
+ stdio: ['pipe', 'pipe', 'pipe'],
606
+ }).trim();
607
+ const lines = out.split(/\r?\n/).filter((l) => l.length > 0);
608
+ const last = lines[lines.length - 1] ?? '0';
609
+ return parseInt(last, 10) || 0;
610
+ }
611
+ function queueDeferredVscdbItem(item, postApplyUpload) {
612
+ const path = getDeferredVscdbApplyPath();
613
+ let payload = { version: 1, items: [] };
614
+ if (existsSync(path)) {
615
+ try {
616
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
617
+ if (parsed?.items && Array.isArray(parsed.items)) {
618
+ payload = parsed;
619
+ if (!Array.isArray(payload.post_apply_uploads))
620
+ payload.post_apply_uploads = [];
621
+ }
622
+ }
623
+ catch {
624
+ /* replace corrupt file */
625
+ }
626
+ }
627
+ payload.items.push(item);
628
+ mergePostApplyUploadIntoPayload(payload, postApplyUpload);
629
+ atomicWriteJson(path, payload);
630
+ }
631
+ /**
632
+ * After Cursor exits: apply queued ItemTable JSON updates, optional log-config-file uploads (re-read sqlite),
633
+ * then delete the queue file.
634
+ * No-op (returns true) if there is nothing pending so `open -a Cursor` still runs.
635
+ */
636
+ export async function applyDeferredVscdbFromDisk() {
637
+ const path = getDeferredVscdbApplyPath();
638
+ if (!existsSync(path))
639
+ return true;
640
+ if (!assertSqlite3Available())
641
+ return false;
642
+ let payload;
643
+ try {
644
+ payload = JSON.parse(readFileSync(path, 'utf8'));
645
+ }
646
+ catch {
647
+ hookRunLog('deferred_vscdb: could not parse queue file');
648
+ return false;
649
+ }
650
+ const postApplyUploads = [...(payload.post_apply_uploads ?? [])];
651
+ if (!payload.items?.length) {
652
+ try {
653
+ unlinkSync(path);
654
+ }
655
+ catch {
656
+ /* ignore */
657
+ }
658
+ return true;
659
+ }
660
+ try {
661
+ for (const it of payload.items) {
662
+ if (!existsSync(it.dbPath)) {
663
+ hookRunLog(`deferred_vscdb: database missing ${it.dbPath}`);
664
+ return false;
665
+ }
666
+ const safeJson = it.new_value_json.replace(/'/g, "''");
667
+ const safeName = it.target_key.replace(/'/g, "''");
668
+ const sql = `UPDATE ${it.table} SET ${it.value_column}='${safeJson}' WHERE ${it.key_column}='${safeName}';`;
669
+ const changed = sqliteRunUpdateReturningChanges(it.dbPath, sql);
670
+ if (changed < 1) {
671
+ hookRunLog(`deferred_vscdb: UPDATE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
672
+ return false;
673
+ }
674
+ }
675
+ hookRunLog(`deferred_vscdb: applied ${payload.items.length} queued update(s)`);
676
+ const authKey = readStoredAuthKey();
677
+ for (const u of postApplyUploads) {
678
+ if (!authKey) {
679
+ hookRunLog(`deferred_vscdb: skip post-apply upload (no auth) path=${u.file_path}`);
680
+ continue;
681
+ }
682
+ const hi = u.file_path.indexOf('#');
683
+ if (hi < 0)
684
+ continue;
685
+ const dbPath = u.file_path.slice(0, hi);
686
+ const itemKey = u.file_path.slice(hi + 1).trim();
687
+ if (!itemKey)
688
+ continue;
689
+ const rawContent = readVscdbItemTableJson(dbPath, itemKey);
690
+ if (rawContent === null) {
691
+ hookRunLog(`deferred_vscdb: post-apply read failed path=${u.file_path}`);
692
+ continue;
693
+ }
694
+ const sent = await sendConfigFile({ file_type: u.file_type, file_path: u.file_path, raw_content: rawContent }, resolveHardwareUuid(), authKey);
695
+ hookRunLog(`deferred_vscdb: post-apply upload path=${u.file_path} ok=${sent}`);
696
+ }
697
+ unlinkSync(path);
698
+ return true;
699
+ }
700
+ catch (e) {
701
+ hookRunLog(`deferred_vscdb: apply failed: ${e instanceof Error ? e.message : String(e)}`);
702
+ return false;
703
+ }
704
+ }
705
+ /** macOS Cursor: SIGKILL, apply queued vscdb writes, reopen project. */
706
+ export function buildDeferredCursorRestartCommand() {
707
+ // Prefer monorepo path when hooks run from optimus-secure-fdn; otherwise `npx -p log-llm-config apply-deferred-vscdb`
708
+ // (package bin) so published installs work without a local npx_packages copy.
709
+ return ('REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT CURSOR_PROJECT="$REPO_ROOT" && ' +
710
+ "nohup bash -c 'sleep 2; if [ -f \"$REPO_ROOT/npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\" ]; then node \"$REPO_ROOT/npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\"; else npx --yes -p log-llm-config apply-deferred-vscdb; fi || true; open -a Cursor \"$CURSOR_PROJECT\"' >/dev/null 2>&1 & killall -9 Cursor");
711
+ }
712
+ function sqliteRowGroupKey(dbPath, op) {
713
+ return `${dbPath}|${op.table}|${op.key_column}|${op.value_column}|${op.target_key}`;
714
+ }
715
+ /**
716
+ * Deferred sqlite path: one read per logical row, apply every `sqlite_op` merge in memory, then one queue
717
+ * entry per row. Without this, two ops on the same `composerState` row each read the stale DB and queue
718
+ * two full JSON blobs — the second UPDATE overwrites the first (e.g. only one of autoRun/fullAutoRun sticks).
719
+ */
720
+ function queueDeferredSqliteOpsMerged(configPath, sqliteOps, postApplyUpload) {
721
+ const dbPath = configPath.split('#')[0];
722
+ if (!existsSync(dbPath)) {
723
+ const line = `sqlite_update: database not found at ${dbPath}`;
724
+ hookRunLog(line);
725
+ complianceRunnerDiag(line);
726
+ return false;
727
+ }
728
+ if (!assertSqlite3Available())
729
+ return false;
730
+ const resolvedOps = sqliteOps.map((op) => resolveCursorComposerSqliteOp(dbPath, op));
731
+ const groups = new Map();
732
+ for (const op of resolvedOps) {
733
+ const k = sqliteRowGroupKey(dbPath, op);
734
+ const arr = groups.get(k);
735
+ if (arr)
736
+ arr.push(op);
737
+ else
738
+ groups.set(k, [op]);
739
+ }
740
+ try {
741
+ for (const ops of groups.values()) {
742
+ const first = ops[0];
743
+ complianceRunnerDiag(`sqlite_update: deferred merge db=${dbPath} target_key=${first.target_key} operations=${ops.length}`);
744
+ let currentJson = {};
745
+ try {
746
+ const result = sqliteSelectValueCell(dbPath, first.table, first.key_column, first.value_column, first.target_key);
747
+ if (result) {
748
+ const parsed = JSON.parse(result);
749
+ currentJson = coerceItemTableValueToObjectRoot(first.target_key, parsed);
750
+ }
751
+ }
752
+ catch (e) {
753
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
754
+ hookRunLog(line);
755
+ complianceRunnerDiag(line);
756
+ return false;
757
+ }
758
+ for (const op of ops) {
759
+ mergeSqliteOpIntoJson(currentJson, op);
760
+ }
761
+ repairComposerStateEmptySegmentBug(currentJson);
762
+ const updatedJson = serializeItemTableValueForWrite(first.target_key, currentJson);
763
+ queueDeferredVscdbItem({
764
+ dbPath,
765
+ table: first.table,
766
+ key_column: first.key_column,
767
+ value_column: first.value_column,
768
+ target_key: first.target_key,
769
+ new_value_json: updatedJson,
770
+ }, postApplyUpload);
771
+ const okLine = `sqlite_update: queued deferred write (merged ${ops.length} op(s)) for ${first.target_key} in ${dbPath}`;
772
+ hookRunLog(okLine);
773
+ complianceRunnerDiag(okLine);
774
+ }
775
+ return true;
776
+ }
777
+ catch (err) {
778
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
779
+ hookRunLog(line);
780
+ complianceRunnerDiag(line);
781
+ return false;
782
+ }
783
+ }
784
+ /**
785
+ * Read + merge JSON for a sqlite op; when `deferred`, queue UPDATE for after IDE exit (state.vscdb lock).
786
+ */
787
+ function applyOrQueueSqliteJsonUpdate(configPath, sqliteOp, deferred) {
788
+ try {
789
+ const dbPath = configPath.split('#')[0];
790
+ complianceRunnerDiag(`sqlite_update: attempt db=${dbPath} target_key=${sqliteOp.target_key} json_path=${sqliteOp.json_path} deferred=${deferred}`);
791
+ if (!existsSync(dbPath)) {
792
+ const line = `sqlite_update: database not found at ${dbPath}`;
793
+ hookRunLog(line);
794
+ complianceRunnerDiag(line);
795
+ return false;
796
+ }
797
+ if (!assertSqlite3Available())
798
+ return false;
799
+ sqliteOp = resolveCursorComposerSqliteOp(dbPath, sqliteOp);
800
+ const { table, key_column, value_column, target_key } = sqliteOp;
801
+ const safeName = target_key.replace(/'/g, "''");
802
+ let currentJson = {};
803
+ try {
804
+ const result = sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key);
805
+ if (result) {
806
+ const parsed = JSON.parse(result);
807
+ currentJson = coerceItemTableValueToObjectRoot(target_key, parsed);
808
+ }
809
+ }
810
+ catch (e) {
811
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
812
+ hookRunLog(line);
813
+ complianceRunnerDiag(line);
814
+ return false;
815
+ }
816
+ mergeSqliteOpIntoJson(currentJson, sqliteOp);
817
+ repairComposerStateEmptySegmentBug(currentJson);
818
+ const updatedJson = serializeItemTableValueForWrite(target_key, currentJson);
819
+ if (deferred) {
820
+ queueDeferredVscdbItem({
821
+ dbPath,
822
+ table,
823
+ key_column,
824
+ value_column,
825
+ target_key,
826
+ new_value_json: updatedJson,
827
+ });
828
+ const okLine = `sqlite_update: queued deferred write for ${target_key} in ${dbPath}`;
829
+ hookRunLog(okLine);
830
+ complianceRunnerDiag(okLine);
831
+ return true;
832
+ }
833
+ const safeJson = updatedJson.replace(/'/g, "''");
834
+ const updateSql = `UPDATE ${table} SET ${value_column}='${safeJson}' WHERE ${key_column}='${safeName}';`;
835
+ try {
836
+ sqliteExecWithTimeout(dbPath, updateSql);
837
+ const okLine = `sqlite_update: successfully updated ${target_key} in ${dbPath}`;
838
+ hookRunLog(okLine);
839
+ complianceRunnerDiag(okLine);
840
+ return true;
841
+ }
842
+ catch (e) {
843
+ const line = `sqlite_update: error updating database: ${e instanceof Error ? e.message : String(e)}`;
844
+ hookRunLog(line);
845
+ complianceRunnerDiag(line);
846
+ return false;
847
+ }
848
+ }
849
+ catch (err) {
850
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
851
+ hookRunLog(line);
852
+ complianceRunnerDiag(line);
853
+ return false;
854
+ }
855
+ }
250
856
  export function enforceRemediation(instruction) {
251
857
  try {
252
- const dir = dirname(instruction.config_file_path);
253
- if (!existsSync(dir))
254
- mkdirSync(dir, { recursive: true, mode: 0o700 });
255
858
  const fixSpec = remediationFixSpec(instruction);
256
- const checks = fixSpec?.file_format === 'json' ? (fixSpec.checks ?? []) : [];
859
+ const checks = fixSpec?.checks ?? [];
257
860
  if (checks.length === 0) {
258
861
  hookRunLog(`remediation_enforce: no checks to apply uuid=${instruction.uuid}`);
259
- return false;
862
+ return { ok: false };
863
+ }
864
+ const sqliteOps = checks.filter((c) => {
865
+ const raw = c;
866
+ return raw.sqlite_op !== undefined;
867
+ });
868
+ if (sqliteOps.length > 0) {
869
+ complianceRunnerDiag(`remediation_enforce: sqlite path uuid=${instruction.uuid} checks_with_sqlite=${sqliteOps.length}`);
870
+ const restartRequired = !!fixSpec?.restart_required;
871
+ if (restartRequired) {
872
+ const ops = sqliteOps.map((c) => c.sqlite_op);
873
+ const ft = instruction.file_type?.trim();
874
+ const postApplyUpload = ft && instruction.config_file_path.includes('#')
875
+ ? { file_path: instruction.config_file_path, file_type: ft }
876
+ : undefined;
877
+ const ok = queueDeferredSqliteOpsMerged(instruction.config_file_path, ops, postApplyUpload);
878
+ return { ok, deferredSqlite: ok };
879
+ }
880
+ let allSuccess = true;
881
+ for (const check of sqliteOps) {
882
+ const raw = check;
883
+ const sqliteOp = raw.sqlite_op;
884
+ const rowOk = applyOrQueueSqliteJsonUpdate(instruction.config_file_path, sqliteOp, false);
885
+ if (!rowOk)
886
+ allSuccess = false;
887
+ }
888
+ return { ok: allSuccess, deferredSqlite: false };
889
+ }
890
+ if (fixSpec?.file_format !== 'json') {
891
+ hookRunLog(`remediation_enforce: unsupported file format ${fixSpec?.file_format} uuid=${instruction.uuid}`);
892
+ return { ok: false };
260
893
  }
894
+ const dir = dirname(instruction.config_file_path);
895
+ if (!existsSync(dir))
896
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
261
897
  let configJson = {};
262
898
  if (existsSync(instruction.config_file_path)) {
263
899
  try {
@@ -274,11 +910,11 @@ export function enforceRemediation(instruction) {
274
910
  const tmp = `${instruction.config_file_path}.tmp`;
275
911
  writeFileSync(tmp, content, 'utf8');
276
912
  renameSync(tmp, instruction.config_file_path);
277
- return true;
913
+ return { ok: true };
278
914
  }
279
915
  catch (err) {
280
916
  hookRunLog(`remediation_enforce_error: uuid=${instruction.uuid} err=${err instanceof Error ? err.message : String(err)}`);
281
- return false;
917
+ return { ok: false };
282
918
  }
283
919
  }
284
920
  // ---------------------------------------------------------------------------
@@ -318,27 +954,43 @@ export function reportAutofixApplied(remediationUuid, result) {
318
954
  export async function syncRemediations(endpointBase, machineUuid) {
319
955
  let remediations = readInstructions().remediations;
320
956
  const activeUuids = remediations.map((r) => r.uuid);
957
+ complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
958
+ try {
959
+ if (!existsSync(getFileCollectionVscdbContractPath())) {
960
+ const pr = await getFileCollectionPatterns(endpointBase);
961
+ if (pr)
962
+ persistVscdbComposerContractFromPatternsResponse(pr);
963
+ }
964
+ }
965
+ catch {
966
+ /* contract also written on full log-config run */
967
+ }
321
968
  let syncResult;
322
969
  try {
323
970
  syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
324
971
  }
325
972
  catch (err) {
326
- hookRunLog(`remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`);
973
+ const msg = `remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`;
974
+ hookRunLog(msg);
975
+ complianceRunnerDiag(msg);
327
976
  return;
328
977
  }
329
978
  if (!syncResult) {
330
979
  hookRunLog(`remediation_sync: no response from server, skipping`);
980
+ complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
331
981
  return;
332
982
  }
333
983
  if (syncResult.status === 'unchanged') {
334
984
  hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
985
+ complianceRunnerDiag(`remediation_sync: server status=unchanged local_uuids=${activeUuids.length} — no new rows to fetch; if a deploy should exist, verify Finding.machine matches this hardware_uuid`);
335
986
  const want = new Set(activeUuids);
336
987
  const have = new Map(remediations.map((r) => [r.uuid, r]));
337
988
  const needBackfill = activeUuids.some((u) => !have.has(u));
989
+ const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
338
990
  const haveOrphan = remediations.some((r) => !want.has(r.uuid));
339
991
  const path = getRemediationInstructionsPath();
340
992
  if (activeUuids.length > 0) {
341
- if (needBackfill || haveOrphan || !existsSync(path)) {
993
+ if (needBackfill || haveIncomplete || haveOrphan || !existsSync(path)) {
342
994
  try {
343
995
  await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
344
996
  }
@@ -359,6 +1011,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
359
1011
  }
360
1012
  const { added, removed } = syncResult;
361
1013
  hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1014
+ complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
362
1015
  const removedSet = new Set(removed);
363
1016
  let removedCount = 0;
364
1017
  remediations = remediations.filter((r) => {
@@ -375,7 +1028,9 @@ export async function syncRemediations(endpointBase, machineUuid) {
375
1028
  manifest = await fetchManifest(endpointBase, machineUuid, added);
376
1029
  }
377
1030
  catch (err) {
378
- hookRunLog(`remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`);
1031
+ const msg = `remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`;
1032
+ hookRunLog(msg);
1033
+ complianceRunnerDiag(msg);
379
1034
  if (removedCount > 0) {
380
1035
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
381
1036
  writeInstructions({ remediations });
@@ -384,6 +1039,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
384
1039
  }
385
1040
  if (!manifest) {
386
1041
  hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1042
+ complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
387
1043
  if (removedCount > 0) {
388
1044
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
389
1045
  writeInstructions({ remediations });
@@ -411,9 +1067,15 @@ export async function syncRemediations(endpointBase, machineUuid) {
411
1067
  hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
412
1068
  }
413
1069
  const addedCount = overlay.length;
1070
+ if (added.length > 0 && addedCount === 0) {
1071
+ const msg = `remediation_sync: manifest had no rows for added uuids=${added.join(',')} — check server manifest/machine linkage`;
1072
+ hookRunLog(msg);
1073
+ complianceRunnerDiag(`${msg} — remediation_instructions.json not written`);
1074
+ }
414
1075
  if (removedCount > 0 || addedCount > 0) {
415
1076
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
416
1077
  writeInstructions({ remediations });
1078
+ complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${addedCount} removed=${removedCount})`);
417
1079
  const finalWant = new Set(remediations.map((r) => r.uuid));
418
1080
  try {
419
1081
  await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
@@ -432,4 +1094,5 @@ export async function syncRemediations(endpointBase, machineUuid) {
432
1094
  }
433
1095
  }
434
1096
  hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
1097
+ complianceRunnerDiag(`remediation_sync: done saved=${addedCount} removed=${removedCount}`);
435
1098
  }