@optimuslabs/harness-map-staging 1.5.16
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/README.md +46 -0
- package/dist/apply_deferred_vscdb.js +38 -0
- package/dist/bootstrap_constants.js +15 -0
- package/dist/cli/bash_script_generator.js +95 -0
- package/dist/cli.js +144 -0
- package/dist/cli_invocation_match.js +30 -0
- package/dist/compliance_check_runner.js +57 -0
- package/dist/compliance_prompt_gate.js +396 -0
- package/dist/dialog_prefs_cli.js +109 -0
- package/dist/endpoint_client/compress_payload.js +25 -0
- package/dist/endpoint_client/http_transport.js +90 -0
- package/dist/endpoint_client/index.js +3 -0
- package/dist/endpoint_client/registry_api.js +41 -0
- package/dist/endpoint_client/startup_api.js +44 -0
- package/dist/endpoint_client/types.js +4 -0
- package/dist/execute_trusted_restarts.js +60 -0
- package/dist/log_config_files/auth/auth_flow.js +22 -0
- package/dist/log_config_files/auth/auth_key_store.js +14 -0
- package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
- package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
- package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
- package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
- package/dist/log_config_files/collection/config_collector.js +245 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
- package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
- package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
- package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
- package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
- package/dist/log_config_files/collection/directory_collector.js +126 -0
- package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
- package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
- package/dist/log_config_files/collection/file_type_rules.js +47 -0
- package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
- package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
- package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
- package/dist/log_config_files/collection/metadata_merge.js +43 -0
- package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
- package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
- package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
- package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
- package/dist/log_config_files/collection/plugin_collector.js +223 -0
- package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
- package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
- package/dist/log_config_files/index.js +19 -0
- package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
- package/dist/log_config_files/paths/pattern_resolver.js +326 -0
- package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
- package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
- package/dist/log_config_files/readers/file_readers.js +196 -0
- package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
- package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
- package/dist/log_config_files/readers/vscdb_reader.js +337 -0
- package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
- package/dist/log_config_files/runtime/compliance_check.js +1013 -0
- package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
- package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
- package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
- package/dist/log_config_files/runtime/hook_logger.js +267 -0
- package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
- package/dist/log_config_files/runtime/log_metadata.js +122 -0
- package/dist/log_config_files/runtime/main_runner.js +376 -0
- package/dist/log_config_files/runtime/management_storage.js +144 -0
- package/dist/log_config_files/runtime/ops_target_path.js +18 -0
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
- package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
- package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
- package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
- package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
- package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
- package/dist/log_config_files/runtime/workspace_repo.js +170 -0
- package/dist/log_config_files/runtime/worktree_absent.js +50 -0
- package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
- package/dist/log_config_files/sender/batch_sender.js +283 -0
- package/dist/log_config_files/sender/endpoint_config.js +24 -0
- package/dist/log_config_files/sender/signing.js +1 -0
- package/dist/log_sensitive_paths_audit.js +89 -0
- package/dist/log_uuid/auth_key_store.js +37 -0
- package/dist/log_uuid/hardware_uuid.js +35 -0
- package/dist/log_uuid/index.js +11 -0
- package/dist/log_uuid/log_uuid_helper.js +30 -0
- package/dist/log_uuid/startup_sender.js +131 -0
- package/dist/log_uuid/user_profile.js +192 -0
- package/dist/remediation_change_preview.js +182 -0
- package/dist/tofu.js +22 -0
- package/dist/tofu_environment.js +36 -0
- package/dist/types/config_file_types.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
3
|
+
import { mergeCursorShadowKeyValue, mergeShadowKeysIntoComposerState, shadowKeysFromReadQueries, } from './composer_shadow_merge.js';
|
|
4
|
+
import { composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from './vscdb_reactive_storage.js';
|
|
5
|
+
import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
|
|
6
|
+
/**
|
|
7
|
+
* ItemTable keys that store a bare JSON boolean; compliance paths use `${key}.${field}`.
|
|
8
|
+
* Kept in sync with `coerceItemTableValueToObjectRoot` / `serializeItemTableValueForWrite` in remediation_sync.
|
|
9
|
+
*/
|
|
10
|
+
export const CURSOR_SCALAR_ITEMTABLE_FIELDS = {
|
|
11
|
+
'cursor/thirdPartyExtensibilityEnabled': 'thirdPartyExtensibilityEnabled',
|
|
12
|
+
'cursorai/donotchange/privacyMode': 'privacyMode',
|
|
13
|
+
'cursor/autoOpenLocalhostUrls': 'autoOpenLocalhostUrls',
|
|
14
|
+
};
|
|
15
|
+
function querySqlite(dbPath, key) {
|
|
16
|
+
const bin = resolveSqlite3Binary();
|
|
17
|
+
if (!bin)
|
|
18
|
+
throw new Error('sqlite3 not found');
|
|
19
|
+
const safe = key.replace(/'/g, "''");
|
|
20
|
+
const script = `.timeout 60000\nSELECT value FROM ItemTable WHERE key='${safe}';\n`;
|
|
21
|
+
return execSqlite3(bin, ['-noheader', dbPath], script).trim();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cursor keeps live agent settings (e.g. yoloCommandAllowlist) on nested composerState inside
|
|
25
|
+
* the reactive blob while the legacy ItemTable `composerState` row is often stale/partial.
|
|
26
|
+
* Merge nested fields so compliance/autofix match policy scans on uploaded reactive data.
|
|
27
|
+
*/
|
|
28
|
+
function mergeLegacyComposerStateWithReactiveNested(dbPath, reactiveKey, legacyComposerState) {
|
|
29
|
+
try {
|
|
30
|
+
const reactive = querySqlite(dbPath, reactiveKey);
|
|
31
|
+
if (!reactive)
|
|
32
|
+
return legacyComposerState;
|
|
33
|
+
const root = JSON.parse(reactive);
|
|
34
|
+
const nested = root.composerState;
|
|
35
|
+
if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) {
|
|
36
|
+
return legacyComposerState;
|
|
37
|
+
}
|
|
38
|
+
return { ...legacyComposerState, ...nested };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return legacyComposerState;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fallback if the API omits vscdb_composer_contract (older server): derive from vscdb_read_queries.
|
|
46
|
+
*/
|
|
47
|
+
export function parseVscdbComposerContractFromReadQueries(queries) {
|
|
48
|
+
const empty = {
|
|
49
|
+
version: 1,
|
|
50
|
+
reactive_storage_item_key: undefined,
|
|
51
|
+
composer_shadow_keys: [],
|
|
52
|
+
};
|
|
53
|
+
if (!queries?.length)
|
|
54
|
+
return empty;
|
|
55
|
+
for (const step of queries) {
|
|
56
|
+
if (step.value_kind === 'composer_with_include' && step.state_key === 'composerState') {
|
|
57
|
+
const keys = step.item_table_keys ?? [];
|
|
58
|
+
return {
|
|
59
|
+
version: 1,
|
|
60
|
+
reactive_storage_item_key: keys[0],
|
|
61
|
+
composer_shadow_keys: [...(step.include_keys ?? [])],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return empty;
|
|
66
|
+
}
|
|
67
|
+
export function normalizeVscdbComposerContractFromPatternsResponse(resp) {
|
|
68
|
+
const c = resp.vscdb_composer_contract;
|
|
69
|
+
const hasKey = c != null &&
|
|
70
|
+
((typeof c.reactive_storage_item_key === 'string' && c.reactive_storage_item_key.trim() !== '') ||
|
|
71
|
+
(c.composer_shadow_keys?.length ?? 0) > 0);
|
|
72
|
+
if (hasKey && c) {
|
|
73
|
+
return sanitizeFileCollectionVscdbContract({
|
|
74
|
+
version: 1,
|
|
75
|
+
reactive_storage_item_key: c.reactive_storage_item_key ?? undefined,
|
|
76
|
+
composer_shadow_keys: [...(c.composer_shadow_keys ?? [])],
|
|
77
|
+
}) ?? { version: 1, reactive_storage_item_key: undefined, composer_shadow_keys: [] };
|
|
78
|
+
}
|
|
79
|
+
return parseVscdbComposerContractFromReadQueries(resp.vscdb_read_queries);
|
|
80
|
+
}
|
|
81
|
+
/** Persist backend-derived contract next to remediation_instructions.json. */
|
|
82
|
+
export function persistVscdbComposerContractFromPatternsResponse(resp) {
|
|
83
|
+
const norm = normalizeVscdbComposerContractFromPatternsResponse(resp);
|
|
84
|
+
const existing = readFileCollectionVscdbContract();
|
|
85
|
+
const merged = {
|
|
86
|
+
version: 1,
|
|
87
|
+
reactive_storage_item_key: norm.reactive_storage_item_key ?? existing?.reactive_storage_item_key ?? undefined,
|
|
88
|
+
composer_shadow_keys: norm.composer_shadow_keys.length > 0
|
|
89
|
+
? norm.composer_shadow_keys
|
|
90
|
+
: [...(existing?.composer_shadow_keys ?? [])],
|
|
91
|
+
};
|
|
92
|
+
if (merged.reactive_storage_item_key || merged.composer_shadow_keys.length > 0) {
|
|
93
|
+
writeFileCollectionVscdbContract(merged);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read one ItemTable JSON blob (e.g. key `composerState`) and return `{ [itemKey]: object }` so
|
|
98
|
+
* dot-paths like `composerState.modes4.agent.autoRun` (or legacy numeric `modes4.0`) work in compliance checks. Empty / missing value
|
|
99
|
+
* yields `{ [itemKey]: {} }`. Returns null if the DB is missing, sqlite3 is unavailable, or JSON parse fails.
|
|
100
|
+
*
|
|
101
|
+
* When `file_collection_vscdb_contract.json` lists `reactive_storage_item_key`, a missing legacy `composerState` row
|
|
102
|
+
* falls back to nested `composerState` inside that reactive blob (paths from the backend API only).
|
|
103
|
+
*/
|
|
104
|
+
export function readVscdbItemTableJson(dbPath, itemKey) {
|
|
105
|
+
try {
|
|
106
|
+
if (!dbPath || !existsSync(dbPath))
|
|
107
|
+
return null;
|
|
108
|
+
if (!resolveSqlite3Binary())
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const raw = querySqlite(dbPath, itemKey);
|
|
116
|
+
const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath) ?? '';
|
|
117
|
+
if (itemKey === 'composerState' && (!raw || raw === '{}') && reactiveKey) {
|
|
118
|
+
const reactive = querySqlite(dbPath, reactiveKey);
|
|
119
|
+
if (reactive) {
|
|
120
|
+
const root = JSON.parse(reactive);
|
|
121
|
+
const cs = root.composerState;
|
|
122
|
+
if (cs !== null && typeof cs === 'object' && !Array.isArray(cs)) {
|
|
123
|
+
return { composerState: cs };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!raw) {
|
|
128
|
+
return { [itemKey]: {} };
|
|
129
|
+
}
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
132
|
+
if (itemKey === 'composerState' && reactiveKey) {
|
|
133
|
+
const merged = mergeLegacyComposerStateWithReactiveNested(dbPath, reactiveKey, parsed);
|
|
134
|
+
return { [itemKey]: merged };
|
|
135
|
+
}
|
|
136
|
+
return { [itemKey]: parsed };
|
|
137
|
+
}
|
|
138
|
+
// Bare JSON arrays (e.g. cursor/disabledMcpServers) — wrap under itemKey so ops-based
|
|
139
|
+
// compliance checks can locate the array via getByPath(itemKey).
|
|
140
|
+
if (Array.isArray(parsed)) {
|
|
141
|
+
return { [itemKey]: parsed };
|
|
142
|
+
}
|
|
143
|
+
// Bare JSON primitives. Scalar toggles must be wrapped so getByPath(key.field) works in compliance checks.
|
|
144
|
+
// Match coerceScalarForItemTableField in remediation_sync: Cursor may store toggles as JSON 0/1, not only booleans.
|
|
145
|
+
if (typeof parsed === 'boolean' || typeof parsed === 'number' || typeof parsed === 'string') {
|
|
146
|
+
const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[itemKey];
|
|
147
|
+
if (field) {
|
|
148
|
+
if (typeof parsed === 'boolean') {
|
|
149
|
+
return { [itemKey]: { [field]: parsed } };
|
|
150
|
+
}
|
|
151
|
+
if (typeof parsed === 'number' && !Number.isNaN(parsed)) {
|
|
152
|
+
return { [itemKey]: { [field]: parsed !== 0 } };
|
|
153
|
+
}
|
|
154
|
+
if (typeof parsed === 'string') {
|
|
155
|
+
const lower = parsed.trim().toLowerCase();
|
|
156
|
+
return {
|
|
157
|
+
[itemKey]: { [field]: lower === 'true' || lower === '1' || lower === 'yes' },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { [itemKey]: parsed };
|
|
162
|
+
}
|
|
163
|
+
return { [itemKey]: {} };
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Cursor stores web-tool toggles on the reactive blob root and nested `composerState`.
|
|
171
|
+
* Merge root shadow keys into nested composerState (matches policy_engine.scan_targets).
|
|
172
|
+
*/
|
|
173
|
+
export function mergeComposerShadowKeysFromReactiveBlob(dbPath, merged) {
|
|
174
|
+
const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath)?.trim();
|
|
175
|
+
const shadowKeys = composerShadowKeysFromContract();
|
|
176
|
+
if (!reactiveKey || shadowKeys.length === 0)
|
|
177
|
+
return;
|
|
178
|
+
const reactiveWrapped = readVscdbItemTableJson(dbPath, reactiveKey);
|
|
179
|
+
if (reactiveWrapped === null)
|
|
180
|
+
return;
|
|
181
|
+
const blob = reactiveWrapped[reactiveKey];
|
|
182
|
+
if (!blob || typeof blob !== 'object' || Array.isArray(blob))
|
|
183
|
+
return;
|
|
184
|
+
const root = blob;
|
|
185
|
+
let cs = merged.composerState;
|
|
186
|
+
if (!cs || typeof cs !== 'object' || Array.isArray(cs)) {
|
|
187
|
+
const nested = root.composerState;
|
|
188
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
189
|
+
cs = { ...nested };
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
cs = {};
|
|
193
|
+
}
|
|
194
|
+
merged.composerState = cs;
|
|
195
|
+
}
|
|
196
|
+
const composerState = cs;
|
|
197
|
+
const nestedRoot = root.composerState && typeof root.composerState === 'object' && !Array.isArray(root.composerState)
|
|
198
|
+
? root.composerState
|
|
199
|
+
: undefined;
|
|
200
|
+
for (const key of shadowKeys) {
|
|
201
|
+
const value = mergeCursorShadowKeyValue(key, root, nestedRoot ?? composerState);
|
|
202
|
+
if (value !== undefined) {
|
|
203
|
+
composerState[key] = value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* After a deferred state.vscdb write, upload the live reactive blob (not stale legacy composerState row).
|
|
209
|
+
* Return the full blob so the server can apply per-key shadow merge (same as log-config ingest).
|
|
210
|
+
*/
|
|
211
|
+
export function buildVscdbPostApplyRawContent(dbPath, itemKeyFromPath) {
|
|
212
|
+
if (itemKeyFromPath === 'composerState') {
|
|
213
|
+
const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath);
|
|
214
|
+
if (reactiveKey) {
|
|
215
|
+
const reactiveWrapped = readVscdbItemTableJson(dbPath, reactiveKey);
|
|
216
|
+
const blob = reactiveWrapped?.[reactiveKey];
|
|
217
|
+
if (blob && typeof blob === 'object' && !Array.isArray(blob)) {
|
|
218
|
+
return { ...blob };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return readVscdbItemTableJson(dbPath, itemKeyFromPath);
|
|
222
|
+
}
|
|
223
|
+
return readVscdbItemTableJson(dbPath, itemKeyFromPath);
|
|
224
|
+
}
|
|
225
|
+
function setNested(obj, dotPath, value) {
|
|
226
|
+
const parts = dotPath.split('.');
|
|
227
|
+
const safe = (k) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
|
|
228
|
+
if (!parts.every(safe))
|
|
229
|
+
return;
|
|
230
|
+
let cur = obj;
|
|
231
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
232
|
+
const p = parts[i];
|
|
233
|
+
if (!(p in cur) || typeof cur[p] !== 'object')
|
|
234
|
+
cur[p] = Object.create(null);
|
|
235
|
+
cur = cur[p];
|
|
236
|
+
}
|
|
237
|
+
cur[parts[parts.length - 1]] = value;
|
|
238
|
+
}
|
|
239
|
+
function runOneStep(dbPath, stateData, step) {
|
|
240
|
+
for (const key of step.item_table_keys) {
|
|
241
|
+
try {
|
|
242
|
+
const result = querySqlite(dbPath, key);
|
|
243
|
+
if (!result)
|
|
244
|
+
continue;
|
|
245
|
+
if (step.value_kind === 'boolean') {
|
|
246
|
+
const lower = result.toLowerCase();
|
|
247
|
+
setNested(stateData, step.state_key, lower === 'true' || lower === '1' || lower === 'yes');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const parsed = JSON.parse(result);
|
|
251
|
+
if (step.value_kind === 'composer_with_include') {
|
|
252
|
+
if (!parsed || typeof parsed !== 'object')
|
|
253
|
+
continue;
|
|
254
|
+
const obj = parsed;
|
|
255
|
+
const composerState = (obj.composerState ?? obj);
|
|
256
|
+
if (composerState && typeof composerState === 'object') {
|
|
257
|
+
stateData.composerState = composerState;
|
|
258
|
+
}
|
|
259
|
+
if (step.include_keys?.length) {
|
|
260
|
+
const nested = composerState && typeof composerState === 'object' && !Array.isArray(composerState)
|
|
261
|
+
? composerState
|
|
262
|
+
: undefined;
|
|
263
|
+
if (!nested)
|
|
264
|
+
return;
|
|
265
|
+
for (const k of step.include_keys) {
|
|
266
|
+
const merged = mergeCursorShadowKeyValue(k, obj, nested);
|
|
267
|
+
if (merged !== undefined) {
|
|
268
|
+
nested[k] = merged;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (step.value_kind === 'json_array_or_object') {
|
|
275
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
276
|
+
setNested(stateData, step.state_key, Object.keys(parsed));
|
|
277
|
+
}
|
|
278
|
+
else if (Array.isArray(parsed)) {
|
|
279
|
+
setNested(stateData, step.state_key, parsed);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (step.value_kind === 'json') {
|
|
284
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
285
|
+
setNested(stateData, step.state_key, parsed);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function mergeFromComposerState(stateData, mergeKeys) {
|
|
296
|
+
const composer = stateData.composerState;
|
|
297
|
+
if (!composer || typeof composer !== 'object')
|
|
298
|
+
return;
|
|
299
|
+
const composerState = composer;
|
|
300
|
+
for (const key of mergeKeys) {
|
|
301
|
+
if (key in stateData && stateData[key] !== undefined)
|
|
302
|
+
continue;
|
|
303
|
+
if (key in composerState && composerState[key] !== undefined) {
|
|
304
|
+
stateData[key] = composerState[key];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read state from a vscdb file using backend-driven queries only (no hardcoded keys).
|
|
310
|
+
* dbPath must be provided (e.g. from API client_path_constants via getVscdbPath); no default path.
|
|
311
|
+
* Constants come from API vscdb_read_queries and vscdb_merge_from_composer_state (backend utils/queries.py).
|
|
312
|
+
*/
|
|
313
|
+
export function readVSCDBState(dbPath, readQueries, mergeFromComposerStateKeys) {
|
|
314
|
+
try {
|
|
315
|
+
if (!dbPath || !existsSync(dbPath))
|
|
316
|
+
return null;
|
|
317
|
+
if (!resolveSqlite3Binary()) {
|
|
318
|
+
console.warn('sqlite3 command not found; skipping vscdb reading');
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (!readQueries?.length)
|
|
322
|
+
return null;
|
|
323
|
+
const stateData = {};
|
|
324
|
+
for (const step of readQueries) {
|
|
325
|
+
runOneStep(dbPath, stateData, step);
|
|
326
|
+
}
|
|
327
|
+
if (mergeFromComposerStateKeys?.length) {
|
|
328
|
+
mergeFromComposerState(stateData, mergeFromComposerStateKeys);
|
|
329
|
+
}
|
|
330
|
+
mergeShadowKeysIntoComposerState(stateData, shadowKeysFromReadQueries(readQueries));
|
|
331
|
+
return Object.keys(stateData).length > 0 ? stateData : null;
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
console.error(`Error reading vscdb:`, error instanceof Error ? error.message : String(error));
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fire-and-forget client telemetry → POST /endpoint_security/api/client-event/.
|
|
3
|
+
*
|
|
4
|
+
* Reports HookRun / FunctionalRun / Flow so the backend has visibility into what ran and
|
|
5
|
+
* what failed — even when a unit fails locally before any happy-path object exists.
|
|
6
|
+
*
|
|
7
|
+
* Guarantees the event is never lost AND never breaks the hook:
|
|
8
|
+
* - All calls are best-effort and never throw.
|
|
9
|
+
* - On send failure (backend down / network / no auth) the event is appended to a durable
|
|
10
|
+
* JSONL buffer and flushed on the next successful send (atomic-rename claim, like tool-call).
|
|
11
|
+
* - The backend accepts uuid-keyed reports without a valid signature, so auth failures report too.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, unlinkSync, statSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { OPTIMUSLABS_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
17
|
+
import { executeBody } from '../../endpoint_client/http_transport.js';
|
|
18
|
+
import { loadEndpointBase } from '../sender/endpoint_config.js';
|
|
19
|
+
import { createSignature } from '../sender/signing.js';
|
|
20
|
+
import { readStoredAuthKey } from '../auth/auth_key_store.js';
|
|
21
|
+
import { resolveHardwareUuid } from './hardware_uuid.js';
|
|
22
|
+
import { buildLogMetadataObject } from './log_metadata.js';
|
|
23
|
+
const CLIENT_EVENT_PATH = '/endpoint_security/api/client-event/';
|
|
24
|
+
const SEND_TIMEOUT_MS = 4000;
|
|
25
|
+
const BUFFER_BASENAME = 'client_events.jsonl';
|
|
26
|
+
const MAX_BUFFER_BYTES = 1 * 1024 * 1024;
|
|
27
|
+
const MAX_FLUSH_EVENTS = 50;
|
|
28
|
+
function bufferPath() {
|
|
29
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
30
|
+
if (!home)
|
|
31
|
+
return null;
|
|
32
|
+
return path.join(home, OPTIMUSLABS_MANAGEMENT_REL, 'telemetry', BUFFER_BASENAME);
|
|
33
|
+
}
|
|
34
|
+
/** Keep filename-safe chars only — must match optimus_sanitize_id in optimus-hook-common.sh. */
|
|
35
|
+
function sanitizeId(v) {
|
|
36
|
+
return v.replace(/[^A-Za-z0-9._-]/g, '');
|
|
37
|
+
}
|
|
38
|
+
/** File start-every-prompt overwrites each submit with this prompt's story id (short-lived handoff). */
|
|
39
|
+
function storyFilePath() {
|
|
40
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
41
|
+
if (!home)
|
|
42
|
+
return null;
|
|
43
|
+
const telemetryDir = path.join(home, OPTIMUSLABS_MANAGEMENT_REL, 'telemetry');
|
|
44
|
+
const session = sanitizeId((process.env.OPTIMUS_SESSION_ID || '').trim());
|
|
45
|
+
if (session) {
|
|
46
|
+
const agent = sanitizeId((process.env.OPTIMUS_HOOK_TYPE || process.env.OPTIMUS_AGENT || 'claude').trim()) || 'claude';
|
|
47
|
+
return path.join(telemetryDir, `current_story.${agent}.${session}.id`);
|
|
48
|
+
}
|
|
49
|
+
return path.join(telemetryDir, 'current_story.id');
|
|
50
|
+
}
|
|
51
|
+
/** Match shell OPTIMUS_STORY_FILE_MAX_AGE_SEC — ignore stale ids from a prior prompt. */
|
|
52
|
+
const STORY_FILE_MAX_AGE_MS = 180_000;
|
|
53
|
+
function readStoryIdFromFileIfFresh(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(filePath);
|
|
56
|
+
if (Date.now() - st.mtimeMs > STORY_FILE_MAX_AGE_MS)
|
|
57
|
+
return null;
|
|
58
|
+
const fromFile = readFileSync(filePath, 'utf8').trim();
|
|
59
|
+
return fromFile || null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Map a functional unit to the shell hook that runs it (every npm command belongs to one hook). */
|
|
66
|
+
const UNIT_HOOK = {
|
|
67
|
+
inventory: 'start_every_prompt',
|
|
68
|
+
auth_version: 'start_every_prompt',
|
|
69
|
+
compliance: 'compliance_check',
|
|
70
|
+
tool_call: 'tool_call',
|
|
71
|
+
install: 'install',
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* story_id groups all hooks + npm of one prompt submit. Resolution order:
|
|
75
|
+
* 1. OPTIMUS_STORY_ID env (when the hook successfully exported it), then
|
|
76
|
+
* 2. current_story.id if mtime is fresh (same temp handoff the shell hooks use), then
|
|
77
|
+
* 3. a fresh id (standalone / manually-run command).
|
|
78
|
+
* Cached on the env for the rest of this process.
|
|
79
|
+
*/
|
|
80
|
+
export function getStoryId() {
|
|
81
|
+
const fromEnv = (process.env.OPTIMUS_STORY_ID || '').trim();
|
|
82
|
+
if (fromEnv)
|
|
83
|
+
return fromEnv;
|
|
84
|
+
const p = storyFilePath();
|
|
85
|
+
if (p) {
|
|
86
|
+
const fromFile = readStoryIdFromFileIfFresh(p);
|
|
87
|
+
if (fromFile) {
|
|
88
|
+
process.env.OPTIMUS_STORY_ID = fromFile;
|
|
89
|
+
return fromFile;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const id = randomUUID();
|
|
93
|
+
process.env.OPTIMUS_STORY_ID = id;
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
/** The shell hook this process runs under: env, else derived from the unit. */
|
|
97
|
+
function resolveHookName(unit) {
|
|
98
|
+
const fromEnv = (process.env.OPTIMUS_HOOK_NAME || '').trim();
|
|
99
|
+
if (fromEnv)
|
|
100
|
+
return fromEnv;
|
|
101
|
+
if (unit && UNIT_HOOK[unit])
|
|
102
|
+
return UNIT_HOOK[unit];
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
/** File written by optimus_hook_begin for this hook invocation (gate + runner share it). */
|
|
106
|
+
function hookRunFilePath() {
|
|
107
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
108
|
+
if (!home)
|
|
109
|
+
return null;
|
|
110
|
+
return path.join(home, OPTIMUSLABS_MANAGEMENT_REL, 'telemetry', 'current_hook_run.id');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* hook_run_id: one uuid per shell hook invocation.
|
|
114
|
+
* 1. OPTIMUS_HOOK_RUN_ID env
|
|
115
|
+
* 2. current_hook_run.id (same hook process tree)
|
|
116
|
+
* 3. `${story_id}::${hook_name}` legacy fallback only
|
|
117
|
+
*/
|
|
118
|
+
export function getHookRunId(unit) {
|
|
119
|
+
const fromEnv = (process.env.OPTIMUS_HOOK_RUN_ID || '').trim();
|
|
120
|
+
if (fromEnv)
|
|
121
|
+
return fromEnv;
|
|
122
|
+
const hp = hookRunFilePath();
|
|
123
|
+
if (hp) {
|
|
124
|
+
try {
|
|
125
|
+
const fromFile = readFileSync(hp, 'utf8').trim();
|
|
126
|
+
if (fromFile) {
|
|
127
|
+
process.env.OPTIMUS_HOOK_RUN_ID = fromFile;
|
|
128
|
+
return fromFile;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// fall through
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const hook = resolveHookName(unit);
|
|
136
|
+
return hook ? `${getStoryId()}::${hook}` : getStoryId();
|
|
137
|
+
}
|
|
138
|
+
function agentFromEnv() {
|
|
139
|
+
const v = (process.env.OPTIMUS_AGENT || process.env.OPTIMUS_HOOK_TYPE || '').trim().toLowerCase();
|
|
140
|
+
return v || 'unknown';
|
|
141
|
+
}
|
|
142
|
+
function enrich(kind, fields, unit) {
|
|
143
|
+
return {
|
|
144
|
+
kind,
|
|
145
|
+
story_id: fields.story_id ?? getStoryId(),
|
|
146
|
+
hook_run_id: fields.hook_run_id ?? getHookRunId(unit),
|
|
147
|
+
outcome: fields.outcome,
|
|
148
|
+
exit_code: fields.exit_code ?? null,
|
|
149
|
+
message: fields.message ?? '',
|
|
150
|
+
detail: fields.detail ?? '',
|
|
151
|
+
metadata: { ...buildLogMetadataObject(), ...(fields.metadata ?? {}) },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function appendToBuffer(events) {
|
|
155
|
+
const p = bufferPath();
|
|
156
|
+
if (!p)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
const dir = path.dirname(p);
|
|
160
|
+
if (!existsSync(dir))
|
|
161
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
162
|
+
if (existsSync(p) && statSync(p).size > MAX_BUFFER_BYTES)
|
|
163
|
+
return; // cap; drop rather than grow unbounded
|
|
164
|
+
appendFileSync(p, events.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// best-effort
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Atomically claim + read buffered events so a concurrent hook won't double-send. */
|
|
171
|
+
function claimBufferedEvents() {
|
|
172
|
+
const p = bufferPath();
|
|
173
|
+
if (!p || !existsSync(p))
|
|
174
|
+
return [];
|
|
175
|
+
const claimed = `${p}.sending.${process.pid}`;
|
|
176
|
+
try {
|
|
177
|
+
renameSync(p, claimed);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const lines = readFileSync(claimed, 'utf8').split('\n').filter((l) => l.trim());
|
|
184
|
+
unlinkSync(claimed);
|
|
185
|
+
return lines
|
|
186
|
+
.slice(0, MAX_FLUSH_EVENTS)
|
|
187
|
+
.map((l) => {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(l);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
.filter((e) => e !== null);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function postEvents(events) {
|
|
202
|
+
if (events.length === 0)
|
|
203
|
+
return true;
|
|
204
|
+
let hardwareUuid;
|
|
205
|
+
try {
|
|
206
|
+
hardwareUuid = resolveHardwareUuid();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const base = loadEndpointBase().replace(/\/+$/, '');
|
|
212
|
+
const url = `${base}${CLIENT_EVENT_PATH}`;
|
|
213
|
+
const signingPayload = { hardware_uuid: hardwareUuid, events };
|
|
214
|
+
let signature = '';
|
|
215
|
+
try {
|
|
216
|
+
const authKey = readStoredAuthKey();
|
|
217
|
+
if (authKey?.key)
|
|
218
|
+
signature = createSignature(signingPayload, authKey.key);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// unsigned is fine — backend stores signature_valid=false and still records
|
|
222
|
+
}
|
|
223
|
+
const body = JSON.stringify({ ...signingPayload, signature });
|
|
224
|
+
try {
|
|
225
|
+
const res = await executeBody(url, 'POST', body, SEND_TIMEOUT_MS);
|
|
226
|
+
return res.statusCode >= 200 && res.statusCode < 300;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Core: send these events + opportunistically flush the buffer; buffer on failure. Never throws. */
|
|
233
|
+
async function send(events) {
|
|
234
|
+
try {
|
|
235
|
+
const buffered = claimBufferedEvents();
|
|
236
|
+
const all = [...buffered, ...events];
|
|
237
|
+
const ok = await postEvents(all);
|
|
238
|
+
if (!ok)
|
|
239
|
+
appendToBuffer(all);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
appendToBuffer(events);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Each report* returns the send promise but never rejects. Callers on a hot path ignore it
|
|
247
|
+
* (fire-and-forget); callers on a process-exit path `await` it so the POST lands before exit.
|
|
248
|
+
*/
|
|
249
|
+
export function reportHookRun(fields) {
|
|
250
|
+
try {
|
|
251
|
+
const hook_name = fields.hook_name;
|
|
252
|
+
return send([
|
|
253
|
+
{
|
|
254
|
+
...enrich('hook_run', { ...fields, hook_run_id: fields.hook_run_id ?? getHookRunId(undefined) }),
|
|
255
|
+
agent: fields.agent ?? agentFromEnv(),
|
|
256
|
+
hook_name,
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return Promise.resolve();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function reportFunctionalRun(fields) {
|
|
265
|
+
try {
|
|
266
|
+
return send([
|
|
267
|
+
{
|
|
268
|
+
...enrich('functional_run', fields, fields.unit),
|
|
269
|
+
unit: fields.unit,
|
|
270
|
+
command: fields.command ?? '',
|
|
271
|
+
hook_name: fields.hook_name ?? resolveHookName(fields.unit),
|
|
272
|
+
log_text: fields.log_text ?? '',
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return Promise.resolve();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Append a functional run to the durable buffer WITHOUT an inline POST. For latency-critical
|
|
282
|
+
* paths (the compliance gate blocks prompt submit) — the next background run flushes it.
|
|
283
|
+
*/
|
|
284
|
+
export function bufferFunctionalRun(fields) {
|
|
285
|
+
try {
|
|
286
|
+
appendToBuffer([
|
|
287
|
+
{
|
|
288
|
+
...enrich('functional_run', fields, fields.unit),
|
|
289
|
+
unit: fields.unit,
|
|
290
|
+
command: fields.command ?? '',
|
|
291
|
+
hook_name: fields.hook_name ?? resolveHookName(fields.unit),
|
|
292
|
+
log_text: fields.log_text ?? '',
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* never throw */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Convenience for catch blocks: report a functional-unit failure with stack. */
|
|
301
|
+
export function reportFunctionalFailure(unit, command, err, extra) {
|
|
302
|
+
const message = extra?.message ?? (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
|
|
303
|
+
const detail = err instanceof Error && err.stack ? err.stack : String(err);
|
|
304
|
+
reportFunctionalRun({
|
|
305
|
+
unit,
|
|
306
|
+
command,
|
|
307
|
+
outcome: 'failed',
|
|
308
|
+
message,
|
|
309
|
+
detail,
|
|
310
|
+
exit_code: extra?.exit_code ?? null,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Awaitable failure report for the fatal-exit path (so the POST completes before process.exit).
|
|
315
|
+
* Falls back to the durable buffer if the send fails. Never throws.
|
|
316
|
+
*/
|
|
317
|
+
export async function reportFunctionalFailureAwait(unit, command, err, extra) {
|
|
318
|
+
try {
|
|
319
|
+
const message = extra?.message ?? (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
|
|
320
|
+
const detail = err instanceof Error && err.stack ? err.stack : String(err);
|
|
321
|
+
const event = {
|
|
322
|
+
...enrich('functional_run', { outcome: 'failed', message, detail, exit_code: extra?.exit_code ?? null }, unit),
|
|
323
|
+
unit,
|
|
324
|
+
command,
|
|
325
|
+
hook_name: resolveHookName(unit),
|
|
326
|
+
log_text: '',
|
|
327
|
+
};
|
|
328
|
+
await send([event]);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
/* never throw on the exit path */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Flush any buffered events (call opportunistically at the start of a run). Never throws. */
|
|
335
|
+
export async function flushBufferedClientEvents() {
|
|
336
|
+
try {
|
|
337
|
+
const buffered = claimBufferedEvents();
|
|
338
|
+
if (buffered.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
const ok = await postEvents(buffered);
|
|
341
|
+
if (!ok)
|
|
342
|
+
appendToBuffer(buffered);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
/* best-effort */
|
|
346
|
+
}
|
|
347
|
+
}
|