@optimuslabs/harness-map-staging 1.5.25 → 1.5.27

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.
@@ -15,7 +15,7 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
15
15
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
16
16
  import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
17
17
  import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
18
- import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
18
+ import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectClaudeDesktopOrgs, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
19
19
  import { ensureCursorUserSettingsSnapshotInBatch } from '../collection/ensure_cursor_user_settings_snapshot.js';
20
20
  import { collectSkillsCliInstalled } from '../collection/skills_cli_collector.js';
21
21
  import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
@@ -26,8 +26,36 @@ import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js'
26
26
  import { ensureWorkspaceRepoEnv, resolveProjectRoot } from './workspace_repo.js';
27
27
  import { scanActiveWorktrees, activeWorktreeRootSet, filterConfigFilesToActiveWorktrees, } from './worktree_scanner.js';
28
28
  import { createSignature, canonicalizePayload } from '../sender/signing.js';
29
+ import { withClaudeLoginPlan } from '../collection/claude_login_plan.js';
29
30
  const PROJECT_ROOT = resolveProjectRoot();
30
- async function collectAllConfigFiles(endpointBase) {
31
+ const configFileKey = (c) => `${c.file_type}\t${c.file_path}`;
32
+ /** Appends the entries not in the batch yet (by file type and path), recording each as seen. */
33
+ function addUnseen(configFiles, seen, entries) {
34
+ for (const entry of entries) {
35
+ const key = configFileKey(entry);
36
+ if (seen.has(key))
37
+ continue;
38
+ seen.add(key);
39
+ configFiles.push(entry);
40
+ }
41
+ }
42
+ /** Machine-wide collectors, run in this order after the plugin caches: [hook log line, collector]. */
43
+ const HOME_COLLECTORS = [
44
+ ['scanning Claude known projects from ~/.claude.json', collectMcpFromClaudeJsonProjects],
45
+ ['aggregating Claude token usage from ~/.claude/projects transcripts', collectClaudeTokenUsage],
46
+ ['aggregating Copilot token usage from ~/.copilot/session-state transcripts', collectCopilotTokenUsage],
47
+ ['aggregating OpenCode token usage from ~/.local/share/opencode/opencode.db', collectOpencodeTokenUsage],
48
+ ['aggregating Codex token usage from ~/.codex/sessions rollouts', collectCodexTokenUsage],
49
+ ['aggregating Cursor token usage from state.vscdb', collectCursorTokenUsage],
50
+ ['aggregating Hermes token usage from ~/.hermes/state.db', collectHermesTokenUsage],
51
+ ['aggregating OpenClaw token usage from ~/.openclaw/agents/*/sessions/sessions.json', collectOpenclawTokenUsage],
52
+ ['aggregating Pi token usage from ~/.pi/agent/sessions transcripts', collectPiTokenUsage],
53
+ ["reading Claude Desktop app version for Cowork's Agent Profile", collectCoworkDesktopVersion],
54
+ ["reading the orgs Claude Desktop's signed-in account has used", collectClaudeDesktopOrgs],
55
+ ['reading Grok CLI version', collectGrokCliVersion],
56
+ ];
57
+ /** The file-path-registry patterns from the API; throws when none came back. */
58
+ async function fetchPatterns(endpointBase) {
31
59
  const patternsUrl = `${endpointBase.replace(/\/+$/, '')}${FILE_PATH_REGISTRY_FILE_PATTERNS_PATH}`;
32
60
  hookRunLog(`fetching patterns from ${patternsUrl}`);
33
61
  const patternsResponse = await getFileCollectionPatterns(endpointBase);
@@ -38,9 +66,12 @@ async function collectAllConfigFiles(endpointBase) {
38
66
  hookRunLog(msg);
39
67
  throw new Error(msg);
40
68
  }
69
+ return patternsResponse;
70
+ }
71
+ function scanPatterns(patternsResponse) {
41
72
  persistVscdbComposerContractFromPatternsResponse(patternsResponse);
42
73
  hookRunLog(`scanning config files`);
43
- const configFiles = collectConfigFilesFromPatterns(patternsResponse.patterns, PROJECT_ROOT, hookRunLog, {
74
+ const files = collectConfigFilesFromPatterns(patternsResponse.patterns, PROJECT_ROOT, hookRunLog, {
44
75
  vscdbEntrySpecs: patternsResponse.vscdb_entry_specs,
45
76
  vscdb_read_queries: patternsResponse.vscdb_read_queries,
46
77
  vscdb_merge_from_composer_state: patternsResponse.vscdb_merge_from_composer_state,
@@ -50,163 +81,32 @@ async function collectAllConfigFiles(endpointBase) {
50
81
  absolute_path_prefixes: patternsResponse.absolute_path_prefixes,
51
82
  mcp_tool_glob_spec: patternsResponse.mcp_tool_glob_spec ?? null,
52
83
  });
84
+ return withClaudeLoginPlan(files, HOME_DIR);
85
+ }
86
+ /** MCP tool caches: the global cache, Cursor's per-project caches and its workspace vscdb files. */
87
+ function addMcpToolCaches(patternsResponse, configFiles, seen) {
53
88
  hookRunLog(`scanning MCP tool cache`);
54
89
  const mcpToolGlobPattern = patternsResponse.patterns.find((p) => p.path_resolution === 'mcp_tool_glob');
55
90
  const mcpToolSkipPrefixes = normalizePathSkipPrefixes(mcpToolGlobPattern?.path_skip_prefixes);
56
- const existingPaths = new Set(configFiles.map((c) => `${c.file_type}\t${c.file_path}`));
57
- for (const m of collectMcpToolFiles(mcpToolSkipPrefixes, patternsResponse.client_path_constants)) {
58
- const key = `${m.file_type}\t${m.file_path}`;
59
- if (!existingPaths.has(key)) {
60
- existingPaths.add(key);
61
- configFiles.push(m);
62
- }
63
- }
91
+ addUnseen(configFiles, seen, collectMcpToolFiles(mcpToolSkipPrefixes, patternsResponse.client_path_constants));
64
92
  hookRunLog(`scanning Cursor project workspace MCP configs (~/.cursor/projects/*/mcp-cache.json)`);
65
- for (const m of collectCursorProjectWorkspaceMcpConfigs(mcpToolSkipPrefixes, patternsResponse.client_path_constants, patternsResponse.workspace_vscdb_spec, HOME_DIR)) {
66
- const key = `${m.file_type}\t${m.file_path}`;
67
- if (!existingPaths.has(key)) {
68
- existingPaths.add(key);
69
- configFiles.push(m);
70
- }
71
- }
93
+ addUnseen(configFiles, seen, collectCursorProjectWorkspaceMcpConfigs(mcpToolSkipPrefixes, patternsResponse.client_path_constants, patternsResponse.workspace_vscdb_spec, HOME_DIR));
72
94
  if (patternsResponse.workspace_vscdb_spec) {
73
95
  hookRunLog(`scanning Cursor workspace vscdb files`);
74
- for (const m of collectWorkspaceVscdbs(patternsResponse.workspace_vscdb_spec)) {
75
- const key = `${m.file_type}\t${m.file_path}`;
76
- if (!existingPaths.has(key)) {
77
- existingPaths.add(key);
78
- configFiles.push(m);
79
- }
80
- }
81
- }
82
- hookRunLog(`scanning installed plugins`);
83
- if (!patternsResponse.client_path_constants)
84
- throw new Error('client_path_constants required from API response');
85
- configFiles.push(...collectConfigFilesFromInstalledPlugins(patternsResponse.client_path_constants));
86
- hookRunLog(`scanning Claude plugin cache MCP manifests`);
87
- for (const entry of collectPluginCacheMcpFiles(patternsResponse.client_path_constants, HOME_DIR)) {
88
- const key = `${entry.file_type}\t${entry.file_path}`;
89
- if (!existingPaths.has(key)) {
90
- existingPaths.add(key);
91
- configFiles.push(entry);
92
- }
93
- }
94
- hookRunLog(`scanning Claude known projects from ~/.claude.json`);
95
- for (const entry of collectMcpFromClaudeJsonProjects(HOME_DIR)) {
96
- const key = `${entry.file_type}\t${entry.file_path}`;
97
- if (!existingPaths.has(key)) {
98
- existingPaths.add(key);
99
- configFiles.push(entry);
100
- }
101
- }
102
- hookRunLog(`aggregating Claude token usage from ~/.claude/projects transcripts`);
103
- for (const entry of collectClaudeTokenUsage(HOME_DIR)) {
104
- const key = `${entry.file_type}\t${entry.file_path}`;
105
- if (!existingPaths.has(key)) {
106
- existingPaths.add(key);
107
- configFiles.push(entry);
108
- }
109
- }
110
- hookRunLog(`aggregating Copilot token usage from ~/.copilot/session-state transcripts`);
111
- for (const entry of collectCopilotTokenUsage(HOME_DIR)) {
112
- const key = `${entry.file_type}\t${entry.file_path}`;
113
- if (!existingPaths.has(key)) {
114
- existingPaths.add(key);
115
- configFiles.push(entry);
116
- }
117
- }
118
- hookRunLog(`aggregating OpenCode token usage from ~/.local/share/opencode/opencode.db`);
119
- for (const entry of collectOpencodeTokenUsage(HOME_DIR)) {
120
- const key = `${entry.file_type}\t${entry.file_path}`;
121
- if (!existingPaths.has(key)) {
122
- existingPaths.add(key);
123
- configFiles.push(entry);
124
- }
125
- }
126
- hookRunLog(`aggregating Codex token usage from ~/.codex/sessions rollouts`);
127
- for (const entry of collectCodexTokenUsage(HOME_DIR)) {
128
- const key = `${entry.file_type}\t${entry.file_path}`;
129
- if (!existingPaths.has(key)) {
130
- existingPaths.add(key);
131
- configFiles.push(entry);
132
- }
133
- }
134
- hookRunLog(`aggregating Cursor token usage from state.vscdb`);
135
- for (const entry of collectCursorTokenUsage(HOME_DIR)) {
136
- const key = `${entry.file_type}\t${entry.file_path}`;
137
- if (!existingPaths.has(key)) {
138
- existingPaths.add(key);
139
- configFiles.push(entry);
140
- }
141
- }
142
- hookRunLog(`aggregating Hermes token usage from ~/.hermes/state.db`);
143
- for (const entry of collectHermesTokenUsage(HOME_DIR)) {
144
- const key = `${entry.file_type}\t${entry.file_path}`;
145
- if (!existingPaths.has(key)) {
146
- existingPaths.add(key);
147
- configFiles.push(entry);
148
- }
149
- }
150
- hookRunLog(`aggregating OpenClaw token usage from ~/.openclaw/agents/*/sessions/sessions.json`);
151
- for (const entry of collectOpenclawTokenUsage(HOME_DIR)) {
152
- const key = `${entry.file_type}\t${entry.file_path}`;
153
- if (!existingPaths.has(key)) {
154
- existingPaths.add(key);
155
- configFiles.push(entry);
156
- }
157
- }
158
- hookRunLog(`aggregating Pi token usage from ~/.pi/agent/sessions transcripts`);
159
- for (const entry of collectPiTokenUsage(HOME_DIR)) {
160
- const key = `${entry.file_type}\t${entry.file_path}`;
161
- if (!existingPaths.has(key)) {
162
- existingPaths.add(key);
163
- configFiles.push(entry);
164
- }
165
- }
166
- hookRunLog(`reading Claude Desktop app version for Cowork's Agent Profile`);
167
- for (const entry of collectCoworkDesktopVersion(HOME_DIR)) {
168
- const key = `${entry.file_type}\t${entry.file_path}`;
169
- if (!existingPaths.has(key)) {
170
- existingPaths.add(key);
171
- configFiles.push(entry);
172
- }
173
- }
174
- hookRunLog(`reading Grok CLI version`);
175
- for (const entry of collectGrokCliVersion(HOME_DIR)) {
176
- const key = `${entry.file_type}\t${entry.file_path}`;
177
- if (!existingPaths.has(key)) {
178
- existingPaths.add(key);
179
- configFiles.push(entry);
180
- }
96
+ addUnseen(configFiles, seen, collectWorkspaceVscdbs(patternsResponse.workspace_vscdb_spec));
181
97
  }
98
+ }
99
+ function addClaudeDesktopExtensions(configFiles, seen) {
182
100
  hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
183
101
  const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
184
102
  hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
185
- for (const entry of collectClaudeDesktopExtensionSettingsFiles(HOME_DIR)) {
186
- const key = `${entry.file_type}\t${entry.file_path}`;
187
- if (!existingPaths.has(key)) {
188
- existingPaths.add(key);
189
- configFiles.push(entry);
190
- }
191
- }
103
+ addUnseen(configFiles, seen, collectClaudeDesktopExtensionSettingsFiles(HOME_DIR));
192
104
  if (!diskExtMerge.hadRegistryUpload && diskExtMerge.mergedCount > 0) {
193
105
  hookRunLog(`scanning Claude Desktop extension manifests on disk (no registry upload in batch)`);
194
- for (const entry of collectClaudeDesktopExtensionManifests(HOME_DIR)) {
195
- const key = `${entry.file_type}\t${entry.file_path}`;
196
- if (!existingPaths.has(key)) {
197
- existingPaths.add(key);
198
- configFiles.push(entry);
199
- }
200
- }
201
- }
202
- const skillsCliEntry = collectSkillsCliInstalled(PROJECT_ROOT, hookRunLog);
203
- if (skillsCliEntry) {
204
- const key = `${skillsCliEntry.file_type}\t${skillsCliEntry.file_path}`;
205
- if (!existingPaths.has(key)) {
206
- existingPaths.add(key);
207
- configFiles.push(skillsCliEntry);
208
- }
106
+ addUnseen(configFiles, seen, collectClaudeDesktopExtensionManifests(HOME_DIR));
209
107
  }
108
+ }
109
+ function filterToActiveWorktrees(configFiles) {
210
110
  const worktreeReport = scanActiveWorktrees(PROJECT_ROOT, HOME_DIR);
211
111
  const activeRoots = activeWorktreeRootSet(worktreeReport);
212
112
  const beforeFilter = configFiles.length;
@@ -214,6 +114,28 @@ async function collectAllConfigFiles(endpointBase) {
214
114
  hookRunLog(`worktree_scan: active=${worktreeReport.length} config_files ${beforeFilter}→${filtered.length}`);
215
115
  return { configFiles: filtered, worktreeReport };
216
116
  }
117
+ async function collectAllConfigFiles(endpointBase) {
118
+ const patternsResponse = await fetchPatterns(endpointBase);
119
+ const configFiles = scanPatterns(patternsResponse);
120
+ const seen = new Set(configFiles.map(configFileKey));
121
+ addMcpToolCaches(patternsResponse, configFiles, seen);
122
+ hookRunLog(`scanning installed plugins`);
123
+ const clientPathConstants = patternsResponse.client_path_constants;
124
+ if (!clientPathConstants)
125
+ throw new Error('client_path_constants required from API response');
126
+ configFiles.push(...collectConfigFilesFromInstalledPlugins(clientPathConstants));
127
+ hookRunLog(`scanning Claude plugin cache MCP manifests`);
128
+ addUnseen(configFiles, seen, collectPluginCacheMcpFiles(clientPathConstants, HOME_DIR));
129
+ for (const [message, collect] of HOME_COLLECTORS) {
130
+ hookRunLog(message);
131
+ addUnseen(configFiles, seen, collect(HOME_DIR));
132
+ }
133
+ addClaudeDesktopExtensions(configFiles, seen);
134
+ const skillsCliEntry = collectSkillsCliInstalled(PROJECT_ROOT, hookRunLog);
135
+ if (skillsCliEntry)
136
+ addUnseen(configFiles, seen, [skillsCliEntry]);
137
+ return filterToActiveWorktrees(configFiles);
138
+ }
217
139
  /** Adds the sensitive paths audit to the batch; false when it could not be run (nothing added). */
218
140
  async function addSensitivePathsAudit(endpointBase, configFiles) {
219
141
  hookRunLog(`running sensitive paths audit`);
@@ -2,7 +2,7 @@
2
2
  * Paths and JSON persistence for ~/.optimuslabs/management:
3
3
  * - remediation_instructions.json (remediations[])
4
4
  */
5
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import { OPTIMUSLABS_MANAGEMENT_REL, OPTIMUSLABS_LOGS_REL } from '../../bootstrap_constants.js';
@@ -129,7 +129,51 @@ function parseInstructionsRaw(raw) {
129
129
  }
130
130
  }
131
131
  export function writeRemediationInstructionsFile(file) {
132
- atomicWriteJson(getRemediationInstructionsPath(), file);
132
+ writeInstructionsFile(file, new Date().toISOString());
133
+ }
134
+ function writeInstructionsFile(file, remediationsWrittenAt) {
135
+ // Most callers write `{ remediations }` only; keep the approval lists they didn't pass.
136
+ const onDisk = readRemediationInstructionsFile();
137
+ const approved_tenants = 'approved_tenants' in file ? file.approved_tenants : onDisk.approved_tenants;
138
+ const unapproved_email_domains = 'unapproved_email_domains' in file ? file.unapproved_email_domains : onDisk.unapproved_email_domains;
139
+ const allowed_email_domains = 'allowed_email_domains' in file ? file.allowed_email_domains : onDisk.allowed_email_domains;
140
+ const approved_agents = 'approved_agents' in file ? file.approved_agents : onDisk.approved_agents;
141
+ atomicWriteJson(getRemediationInstructionsPath(), {
142
+ ...file,
143
+ ...(approved_tenants === undefined ? {} : { approved_tenants }),
144
+ ...(unapproved_email_domains === undefined ? {} : { unapproved_email_domains }),
145
+ ...(allowed_email_domains === undefined ? {} : { allowed_email_domains }),
146
+ ...(approved_agents === undefined ? {} : { approved_agents }),
147
+ remediations_written_at: remediationsWrittenAt,
148
+ });
149
+ }
150
+ /** When the remediations on disk were written: recorded, or for an older file its mtime. */
151
+ function remediationsWrittenAtOf(onDisk) {
152
+ if (onDisk.remediations_written_at)
153
+ return onDisk.remediations_written_at;
154
+ const path = getRemediationInstructionsPath();
155
+ return existsSync(path) ? statSync(path).mtime.toISOString() : new Date().toISOString();
156
+ }
157
+ /** Replace one approval list, keeping the remediations and when they were written. */
158
+ function writeApprovalList(patch) {
159
+ const onDisk = readRemediationInstructionsFile();
160
+ writeInstructionsFile({ ...onDisk, ...patch }, remediationsWrittenAtOf(onDisk));
161
+ }
162
+ /** Replace only the unapproved email domains, keeping everything else on disk. */
163
+ export function writeUnapprovedEmailDomains(unapproved_email_domains) {
164
+ writeApprovalList({ unapproved_email_domains });
165
+ }
166
+ /** Replace only the approved agents, keeping everything else on disk. */
167
+ export function writeApprovedAgents(approved_agents) {
168
+ writeApprovalList({ approved_agents });
169
+ }
170
+ /** Replace only the email allowlist, keeping everything else on disk. */
171
+ export function writeAllowedEmailDomains(allowed_email_domains) {
172
+ writeApprovalList({ allowed_email_domains });
173
+ }
174
+ /** Replace only the approved-tenant list, keeping the remediations already on disk. */
175
+ export function writeApprovedTenants(approved_tenants) {
176
+ writeApprovalList({ approved_tenants });
133
177
  }
134
178
  export function readRemediationInstructionsFile() {
135
179
  const path = getRemediationInstructionsPath();
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
5
5
  import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
- import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeApprovedAgents, writeRemediationInstructionsFile, } from './management_storage.js';
8
8
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
9
  import { ensureAuthentication } from '../auth/auth_flow.js';
10
10
  import { createSignature } from '../sender/signing.js';
@@ -1600,123 +1600,172 @@ export function reportAutofixApplied(remediationUuid, result, details) {
1600
1600
  // ---------------------------------------------------------------------------
1601
1601
  // Main entry point
1602
1602
  // ---------------------------------------------------------------------------
1603
- export async function syncRemediations(endpointBase, machineUuid) {
1604
- let remediations = readInstructions().remediations;
1605
- const activeUuids = remediations.map((r) => r.uuid);
1606
- complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
1603
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
1604
+ /** The strings in a synced list, lowercased; anything else in it is dropped. */
1605
+ function lowercaseStrings(list) {
1606
+ return list.filter((v) => typeof v === 'string').map((v) => v.toLowerCase());
1607
+ }
1608
+ async function ensureVscdbContract(endpointBase) {
1607
1609
  try {
1608
- if (!existsSync(getFileCollectionVscdbContractPath())) {
1609
- const pr = await getFileCollectionPatterns(endpointBase);
1610
- if (pr)
1611
- persistVscdbComposerContractFromPatternsResponse(pr);
1612
- }
1610
+ if (existsSync(getFileCollectionVscdbContractPath()))
1611
+ return;
1612
+ const pr = await getFileCollectionPatterns(endpointBase);
1613
+ if (pr)
1614
+ persistVscdbComposerContractFromPatternsResponse(pr);
1613
1615
  }
1614
1616
  catch {
1615
1617
  /* contract also written on full log-config run */
1616
1618
  }
1619
+ }
1620
+ /** The server's sync answer, or null (logged) when the request failed or came back empty. */
1621
+ async function fetchSyncResult(endpointBase, machineUuid, activeUuids) {
1617
1622
  let syncResult;
1618
1623
  try {
1619
1624
  syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
1620
1625
  }
1621
1626
  catch (err) {
1622
- const msg = `remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`;
1627
+ const msg = `remediation_sync_error: ${errorText(err)}`;
1623
1628
  hookRunLog(msg);
1624
1629
  complianceRunnerDiag(msg);
1625
- return;
1630
+ return null;
1626
1631
  }
1627
1632
  if (!syncResult) {
1628
1633
  hookRunLog(`remediation_sync: no response from server, skipping`);
1629
1634
  complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
1630
- return;
1631
1635
  }
1632
- applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1633
- if (syncResult.status === 'unchanged') {
1634
- hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1635
- 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`);
1636
- const want = new Set(activeUuids);
1637
- const have = new Map(remediations.map((r) => [r.uuid, r]));
1638
- const needBackfill = activeUuids.some((u) => !have.has(u));
1639
- const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
1640
- const haveOrphan = remediations.some((r) => !want.has(r.uuid));
1641
- const path = getRemediationInstructionsPath();
1642
- if (activeUuids.length > 0) {
1643
- if (needBackfill || haveIncomplete || haveOrphan || !existsSync(path)) {
1644
- try {
1645
- await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
1646
- }
1647
- catch (err) {
1648
- hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1649
- }
1650
- }
1651
- }
1652
- else if (remediations.length > 0) {
1653
- try {
1654
- await ensureInstructionsForUuids(endpointBase, machineUuid, new Set(), null);
1655
- }
1656
- catch (err) {
1657
- hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1658
- }
1659
- }
1636
+ return syncResult;
1637
+ }
1638
+ /**
1639
+ * Kept locally so the prompt gate checks the signed-in tenant without a round trip; a failed
1640
+ * sync never gets here and leaves the last lists in place.
1641
+ */
1642
+ function storeApprovals(syncResult) {
1643
+ const approvedTenants = syncResult.approved_tenants;
1644
+ if (approvedTenants && typeof approvedTenants === 'object' && !Array.isArray(approvedTenants)) {
1645
+ writeApprovedTenants(approvedTenants);
1646
+ }
1647
+ const unapprovedDomains = syncResult.unapproved_email_domains;
1648
+ if (Array.isArray(unapprovedDomains))
1649
+ writeUnapprovedEmailDomains(lowercaseStrings(unapprovedDomains));
1650
+ const approvedAgents = syncResult.approved_agents;
1651
+ if (Array.isArray(approvedAgents))
1652
+ writeApprovedAgents(lowercaseStrings(approvedAgents));
1653
+ const allowedDomains = syncResult.allowed_email_domains;
1654
+ if (Array.isArray(allowedDomains))
1655
+ writeAllowedEmailDomains(lowercaseStrings(allowedDomains));
1656
+ }
1657
+ async function reconcileInstructions(endpointBase, machineUuid, want) {
1658
+ try {
1659
+ await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
1660
+ }
1661
+ catch (err) {
1662
+ hookRunLog(`remediation_instructions_reconcile_error: ${errorText(err)}`);
1663
+ }
1664
+ }
1665
+ /** Server says nothing changed: still repair a local file that is missing rows, incomplete or stale. */
1666
+ async function reconcileUnchanged(endpointBase, machineUuid, remediations, activeUuids) {
1667
+ hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1668
+ 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`);
1669
+ const want = new Set(activeUuids);
1670
+ if (activeUuids.length === 0) {
1671
+ if (remediations.length > 0)
1672
+ await reconcileInstructions(endpointBase, machineUuid, new Set());
1660
1673
  return;
1661
1674
  }
1662
- const { added, removed } = syncResult;
1663
- hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1664
- complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
1665
- const removedSet = new Set(removed);
1666
- const retiredInstructions = remediations.filter((r) => removedSet.has(r.uuid));
1667
- let removedCount = 0;
1668
- remediations = remediations.filter((r) => {
1669
- if (removedSet.has(r.uuid)) {
1670
- removedCount++;
1671
- hookRunLog(`remediation_remove: uuid=${r.uuid} path=${r.config_file_path}`);
1672
- return false;
1673
- }
1674
- return true;
1675
- });
1676
- let manifest = null;
1677
- if (added.length > 0) {
1678
- try {
1679
- manifest = await fetchManifest(endpointBase, machineUuid, added);
1680
- }
1681
- catch (err) {
1682
- const msg = `remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`;
1683
- hookRunLog(msg);
1684
- complianceRunnerDiag(msg);
1685
- if (removedCount > 0) {
1686
- remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1687
- writeInstructions({ remediations });
1688
- }
1689
- return;
1690
- }
1691
- if (!manifest) {
1692
- hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1693
- complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
1694
- if (removedCount > 0) {
1695
- remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1696
- writeInstructions({ remediations });
1697
- }
1698
- return;
1699
- }
1675
+ const have = new Map(remediations.map((r) => [r.uuid, r]));
1676
+ const needBackfill = activeUuids.some((u) => !have.has(u));
1677
+ const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
1678
+ const haveOrphan = remediations.some((r) => !want.has(r.uuid));
1679
+ if (needBackfill || haveIncomplete || haveOrphan || !existsSync(getRemediationInstructionsPath())) {
1680
+ await reconcileInstructions(endpointBase, machineUuid, want);
1681
+ }
1682
+ }
1683
+ function writeSortedInstructions(remediations) {
1684
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1685
+ writeInstructions({ remediations });
1686
+ }
1687
+ /** The manifest rows for the added UUIDs; null (logged) when it could not be fetched. */
1688
+ async function fetchAddedManifest(endpointBase, machineUuid, added) {
1689
+ let manifest;
1690
+ try {
1691
+ manifest = await fetchManifest(endpointBase, machineUuid, added);
1692
+ }
1693
+ catch (err) {
1694
+ const msg = `remediation_manifest_error: ${errorText(err)}`;
1695
+ hookRunLog(msg);
1696
+ complianceRunnerDiag(msg);
1697
+ return null;
1698
+ }
1699
+ if (!manifest) {
1700
+ hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1701
+ complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
1702
+ }
1703
+ return manifest;
1704
+ }
1705
+ function upsertInstruction(remediations, existingUuids, instruction) {
1706
+ if (!existingUuids.has(instruction.uuid)) {
1707
+ remediations.push(instruction);
1708
+ existingUuids.add(instruction.uuid);
1709
+ return;
1700
1710
  }
1711
+ // Enforced remediations are always in `added`; overwrite local copy with fresh server data.
1712
+ const idx = remediations.findIndex((r) => r.uuid === instruction.uuid);
1713
+ if (idx >= 0)
1714
+ remediations[idx] = instruction;
1715
+ }
1716
+ /** Put the manifest's added rows into `remediations` (replacing a local copy); returns the rows put in. */
1717
+ function mergeAddedInstructions(remediations, manifest, added) {
1701
1718
  const existingUuids = new Set(remediations.map((r) => r.uuid));
1702
1719
  const addedSet = new Set(added);
1703
1720
  const overlay = [];
1704
- for (const instruction of manifest ?? []) {
1721
+ for (const instruction of manifest) {
1705
1722
  if (!addedSet.has(instruction.uuid))
1706
1723
  continue;
1707
- if (existingUuids.has(instruction.uuid)) {
1708
- // Enforced remediations are always in `added`; overwrite local copy with fresh server data.
1709
- const idx = remediations.findIndex((r) => r.uuid === instruction.uuid);
1710
- if (idx >= 0)
1711
- remediations[idx] = instruction;
1712
- }
1713
- else {
1714
- remediations.push(instruction);
1715
- existingUuids.add(instruction.uuid);
1716
- }
1724
+ upsertInstruction(remediations, existingUuids, instruction);
1717
1725
  overlay.push(instruction);
1718
1726
  hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
1719
1727
  }
1728
+ return overlay;
1729
+ }
1730
+ /** Write the merged instructions, fetch anything still missing, and report the new UUID set. */
1731
+ async function persistSyncedInstructions(endpointBase, machineUuid, remediations, overlay, removedCount) {
1732
+ writeSortedInstructions(remediations);
1733
+ complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${overlay.length} removed=${removedCount})`);
1734
+ const finalWant = new Set(remediations.map((r) => r.uuid));
1735
+ try {
1736
+ await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
1737
+ }
1738
+ catch (err) {
1739
+ hookRunLog(`remediation_instructions_persist_error: ${errorText(err)}`);
1740
+ }
1741
+ // Post-sync heartbeat: report the updated UUID set so the server has a before/after pair.
1742
+ const finalUuids = remediations.map((r) => r.uuid);
1743
+ try {
1744
+ await fetchSync(endpointBase, machineUuid, finalUuids);
1745
+ hookRunLog(`remediation_sync: post-sync heartbeat reported uuids=${finalUuids.length}`);
1746
+ }
1747
+ catch {
1748
+ // Non-critical — skip silently.
1749
+ }
1750
+ }
1751
+ /** Server sent added/removed UUIDs: drop the removed, fetch and merge the added, then persist. */
1752
+ async function applySyncUpdate(endpointBase, machineUuid, local, syncResult) {
1753
+ const { added, removed } = syncResult;
1754
+ hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1755
+ complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
1756
+ const removedSet = new Set(removed);
1757
+ const retiredInstructions = local.filter((r) => removedSet.has(r.uuid));
1758
+ for (const r of retiredInstructions)
1759
+ hookRunLog(`remediation_remove: uuid=${r.uuid} path=${r.config_file_path}`);
1760
+ const removedCount = retiredInstructions.length;
1761
+ const remediations = local.filter((r) => !removedSet.has(r.uuid));
1762
+ const manifest = added.length > 0 ? await fetchAddedManifest(endpointBase, machineUuid, added) : [];
1763
+ if (!manifest) {
1764
+ if (removedCount > 0)
1765
+ writeSortedInstructions(remediations);
1766
+ return;
1767
+ }
1768
+ const overlay = mergeAddedInstructions(remediations, manifest, added);
1720
1769
  transferQuarantineForRetiredUuids(removed, added, retiredInstructions, overlay);
1721
1770
  const addedCount = overlay.length;
1722
1771
  if (added.length > 0 && addedCount === 0) {
@@ -1725,26 +1774,24 @@ export async function syncRemediations(endpointBase, machineUuid) {
1725
1774
  complianceRunnerDiag(`${msg} — remediation_instructions.json not written`);
1726
1775
  }
1727
1776
  if (removedCount > 0 || addedCount > 0) {
1728
- remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1729
- writeInstructions({ remediations });
1730
- complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${addedCount} removed=${removedCount})`);
1731
- const finalWant = new Set(remediations.map((r) => r.uuid));
1732
- try {
1733
- await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
1734
- }
1735
- catch (err) {
1736
- hookRunLog(`remediation_instructions_persist_error: ${err instanceof Error ? err.message : String(err)}`);
1737
- }
1738
- // Post-sync heartbeat: report the updated UUID set so the server has a before/after pair.
1739
- const finalUuids = remediations.map((r) => r.uuid);
1740
- try {
1741
- await fetchSync(endpointBase, machineUuid, finalUuids);
1742
- hookRunLog(`remediation_sync: post-sync heartbeat reported uuids=${finalUuids.length}`);
1743
- }
1744
- catch {
1745
- // Non-critical — skip silently.
1746
- }
1777
+ await persistSyncedInstructions(endpointBase, machineUuid, remediations, overlay, removedCount);
1747
1778
  }
1748
1779
  hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
1749
1780
  complianceRunnerDiag(`remediation_sync: done saved=${addedCount} removed=${removedCount}`);
1750
1781
  }
1782
+ export async function syncRemediations(endpointBase, machineUuid) {
1783
+ const remediations = readInstructions().remediations;
1784
+ const activeUuids = remediations.map((r) => r.uuid);
1785
+ complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
1786
+ await ensureVscdbContract(endpointBase);
1787
+ const syncResult = await fetchSyncResult(endpointBase, machineUuid, activeUuids);
1788
+ if (!syncResult)
1789
+ return;
1790
+ applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1791
+ storeApprovals(syncResult);
1792
+ if (syncResult.status === 'unchanged') {
1793
+ await reconcileUnchanged(endpointBase, machineUuid, remediations, activeUuids);
1794
+ return;
1795
+ }
1796
+ await applySyncUpdate(endpointBase, machineUuid, remediations, syncResult);
1797
+ }