log-llm-config-staging 1.3.81 → 1.3.82

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.
@@ -50,38 +50,58 @@ function collectDirectoryEntries(t) {
50
50
  }
51
51
  return results;
52
52
  }
53
- /**
54
- * For metadata-style DIR targets: scan files matching dir_glob, return one entry
55
- * with the highest mtime found. Falls back to the directory's own mtime if no files match.
56
- */
57
- function collectDirectoryMetadata(t) {
53
+ const METADATA_DIR_SCAN_MAX_DEPTH = 8;
54
+ function matchesDirGlob(name, glob) {
55
+ if (glob === '*')
56
+ return true;
57
+ // **/*.jsonl → match any extension suffix after **/
58
+ if (glob.startsWith('**/')) {
59
+ const suffix = glob.slice(3);
60
+ if (suffix.startsWith('*.'))
61
+ return name.endsWith(suffix.slice(1));
62
+ return name === suffix;
63
+ }
64
+ if (glob.startsWith('*.'))
65
+ return name.endsWith(glob.slice(1));
66
+ return name === glob;
67
+ }
68
+ /** Walk dir tree (bounded depth) and return the file with the highest mtime matching dir_glob. */
69
+ function findNewestMatchingFile(dirPath, glob, depth, maxDepth) {
70
+ let best = null;
58
71
  try {
59
- const dirStat = statSync(t.path);
60
- let bestMtime = dirStat.mtime;
61
- let bestPath = t.path;
62
- const glob = t.dir_glob ?? '*';
63
- const matchName = (name) => {
64
- if (glob === '*')
65
- return true;
66
- if (glob.startsWith('*.'))
67
- return name.endsWith(glob.slice(1));
68
- return name === glob;
69
- };
70
- try {
71
- for (const entry of readdirSync(t.path, { withFileTypes: true })) {
72
- if (!entry.isFile() || !matchName(entry.name))
73
- continue;
72
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
73
+ const fullPath = join(dirPath, entry.name);
74
+ if (entry.isFile() && matchesDirGlob(entry.name, glob)) {
74
75
  try {
75
- const fileStat = statSync(join(t.path, entry.name));
76
- if (fileStat.mtime > bestMtime) {
77
- bestMtime = fileStat.mtime;
78
- bestPath = join(t.path, entry.name);
76
+ const fileStat = statSync(fullPath);
77
+ if (!best || fileStat.mtime > best.mtime) {
78
+ best = { path: fullPath, mtime: fileStat.mtime };
79
79
  }
80
80
  }
81
81
  catch { /* ignore unreadable files */ }
82
82
  }
83
+ else if (entry.isDirectory() && depth < maxDepth) {
84
+ const nested = findNewestMatchingFile(fullPath, glob, depth + 1, maxDepth);
85
+ if (nested && (!best || nested.mtime > best.mtime)) {
86
+ best = nested;
87
+ }
88
+ }
83
89
  }
84
- catch { /* ignore unreadable dir */ }
90
+ }
91
+ catch { /* ignore unreadable dir */ }
92
+ return best;
93
+ }
94
+ /**
95
+ * For metadata-style DIR targets: recursively scan files matching dir_glob, return one entry
96
+ * with the highest mtime found. Falls back to the directory's own mtime if no files match.
97
+ */
98
+ function collectDirectoryMetadata(t) {
99
+ try {
100
+ const dirStat = statSync(t.path);
101
+ const glob = t.dir_glob ?? '*';
102
+ const newest = findNewestMatchingFile(t.path, glob, 0, METADATA_DIR_SCAN_MAX_DEPTH);
103
+ const bestPath = newest?.path ?? t.path;
104
+ const bestMtime = newest?.mtime ?? dirStat.mtime;
85
105
  return {
86
106
  file_type: t.file_type,
87
107
  file_path: bestPath,
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ const HOME_DIR = homedir();
4
5
  import { getFileCollectionPatterns, FILE_PATH_REGISTRY_FILE_PATTERNS_PATH } from '../../endpoint_client/index.js';
5
6
  import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
6
7
  import { runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
@@ -17,6 +18,7 @@ import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
17
18
  import { sendConfigFile, sendConfigFilesBatch, sendHookRequestCreate, sendHookRequestUpdateManifest, sendIngestSessionStart, sendIngestSessionFinish, BATCH_CHUNK_SIZE } from '../sender/batch_sender.js';
18
19
  import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js';
19
20
  import { ensureWorkspaceRepoEnv, resolveProjectRoot } from './workspace_repo.js';
21
+ import { scanActiveWorktrees, activeWorktreeRootSet, filterConfigFilesToActiveWorktrees, } from './worktree_scanner.js';
20
22
  import { createSignature, canonicalizePayload } from '../sender/signing.js';
21
23
  const PROJECT_ROOT = resolveProjectRoot();
22
24
  async function collectAllConfigFiles(endpointBase) {
@@ -67,7 +69,12 @@ async function collectAllConfigFiles(endpointBase) {
67
69
  if (!patternsResponse.client_path_constants)
68
70
  throw new Error('client_path_constants required from API response');
69
71
  configFiles.push(...collectConfigFilesFromInstalledPlugins(patternsResponse.client_path_constants));
70
- return configFiles;
72
+ const worktreeReport = scanActiveWorktrees(PROJECT_ROOT, HOME_DIR);
73
+ const activeRoots = activeWorktreeRootSet(worktreeReport);
74
+ const beforeFilter = configFiles.length;
75
+ const filtered = filterConfigFilesToActiveWorktrees(configFiles, activeRoots);
76
+ hookRunLog(`worktree_scan: active=${worktreeReport.length} config_files ${beforeFilter}→${filtered.length}`);
77
+ return { configFiles: filtered, worktreeReport };
71
78
  }
72
79
  async function addSensitivePathsAudit(endpointBase, configFiles) {
73
80
  hookRunLog(`running sensitive paths audit`);
@@ -82,7 +89,7 @@ async function addSensitivePathsAudit(endpointBase, configFiles) {
82
89
  configFiles.push({ file_type: 'sensitive_paths_audit', file_path: 'sensitive_paths_audit.txt', raw_content: { paths: [] } });
83
90
  }
84
91
  }
85
- async function sendAllConfigFiles(configFiles, hardwareUuid, authKey) {
92
+ async function sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey) {
86
93
  const hookTypeRaw = (process.env.OPTIMUS_HOOK_TYPE || 'claude').toLowerCase();
87
94
  const hookType = hookTypeRaw === 'cursor' ? 'cursor' : 'claude';
88
95
  const manifest = configFiles.map((c) => canonicalCursorUserStateVscdbPath(c.file_path));
@@ -101,9 +108,11 @@ async function sendAllConfigFiles(configFiles, hardwareUuid, authKey) {
101
108
  const ok = await sendHookRequestUpdateManifest(hardwareUuid, authKey, hookRequestId, manifest);
102
109
  hookRunLog(`hook-request update manifest result=${ok ? 'ok' : 'fail'}`);
103
110
  }
104
- // Report failed uploads on finish so the backend holds those paths from OpenClaw prune.
105
- if (ingestSessionId && batchResult.accepted > 0) {
106
- const ok = await sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, batchResult.failedArtifacts);
111
+ // Finish ingest session: OpenClaw hold set, worktree prune (report may be []), and scans.
112
+ // Run finish whenever a session was started — even if every upload failed so worktree
113
+ // cleanup still runs for machines with only stale worktrees on disk.
114
+ if (ingestSessionId) {
115
+ const ok = await sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, batchResult.failedArtifacts, worktreeReport);
107
116
  hookRunLog(`ingest-session finish result=${ok ? 'ok' : 'fail'}`
108
117
  + (batchResult.failedArtifacts.length > 0
109
118
  ? ` held=${batchResult.failedArtifacts.length} failed upload(s)`
@@ -133,8 +142,11 @@ async function main() {
133
142
  throw err;
134
143
  }
135
144
  let configFiles;
145
+ let worktreeReport = [];
136
146
  try {
137
- configFiles = await collectAllConfigFiles(endpointBase);
147
+ const collected = await collectAllConfigFiles(endpointBase);
148
+ configFiles = collected.configFiles;
149
+ worktreeReport = collected.worktreeReport;
138
150
  }
139
151
  catch (err) {
140
152
  hookRunLog(`patterns_error: ${err instanceof Error ? err.message : String(err)}`);
@@ -158,7 +170,7 @@ async function main() {
158
170
  hookRunLog('no config files found, exiting');
159
171
  process.exit(0);
160
172
  }
161
- await sendAllConfigFiles(configFiles, hardwareUuid, authKey);
173
+ await sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey);
162
174
  }
163
175
  async function logSingleFile(filePath) {
164
176
  const hardwareUuid = resolveHardwareUuid();
@@ -11,6 +11,8 @@ import { loadEndpointBase } from '../sender/endpoint_config.js';
11
11
  import { tryResolveHardwareUuid } from './hardware_uuid.js';
12
12
  import { CURSOR_SCALAR_ITEMTABLE_FIELDS, persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
13
13
  import { sendConfigFile } from '../sender/batch_sender.js';
14
+ import { parseWorktreeRootFromPath } from './worktree_scanner.js';
15
+ import { reportAbsentWorktrees } from './worktree_absent.js';
14
16
  import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
15
17
  import { resolveRemediationConfigPath } from './remediation_config_path.js';
16
18
  import { resolveSqlite3Binary } from './sqlite_binary.js';
@@ -1189,9 +1191,26 @@ export function enforceRemediation(instruction) {
1189
1191
  if (fixSpec?.file_format !== 'json') {
1190
1192
  return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
1191
1193
  }
1194
+ const worktreeRoot = parseWorktreeRootFromPath(inst.config_file_path);
1195
+ if (worktreeRoot && !existsSync(worktreeRoot)) {
1196
+ void reportAbsentWorktrees([
1197
+ {
1198
+ worktree_root: worktreeRoot,
1199
+ reason: 'path_missing_on_remediate',
1200
+ source_path: inst.config_file_path,
1201
+ },
1202
+ ]);
1203
+ return fail('worktree_absent');
1204
+ }
1192
1205
  const dir = dirname(inst.config_file_path);
1193
- if (!existsSync(dir))
1206
+ if (worktreeRoot) {
1207
+ if (!existsSync(dir)) {
1208
+ return fail('worktree_path_missing');
1209
+ }
1210
+ }
1211
+ else if (!existsSync(dir)) {
1194
1212
  mkdirSync(dir, { recursive: true, mode: 0o700 });
1213
+ }
1195
1214
  let configJson = {};
1196
1215
  if (existsSync(inst.config_file_path)) {
1197
1216
  try {
@@ -0,0 +1,50 @@
1
+ import { executeBody } from '../../endpoint_client/http_transport.js';
2
+ import { buildApiUrl } from '../../endpoint_client/registry_api.js';
3
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
4
+ import { createSignature } from '../sender/signing.js';
5
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
6
+ import { tryResolveHardwareUuid } from './hardware_uuid.js';
7
+ import { hookRunLog } from './hook_logger.js';
8
+ import { normalizePathForWorktree } from './worktree_scanner.js';
9
+ /**
10
+ * Notify server that worktree directories are gone so inventory/findings are pruned.
11
+ */
12
+ export function reportAbsentWorktrees(entries) {
13
+ const authKey = readStoredAuthKey();
14
+ if (!authKey) {
15
+ hookRunLog('worktree_absent: no auth key, skipping');
16
+ return Promise.resolve();
17
+ }
18
+ const hardwareUuid = tryResolveHardwareUuid();
19
+ if (!hardwareUuid) {
20
+ hookRunLog('worktree_absent: no hardware uuid, skipping');
21
+ return Promise.resolve();
22
+ }
23
+ const absent_worktrees = entries
24
+ .map((e) => ({
25
+ worktree_root: normalizePathForWorktree(e.worktree_root).replace(/\/+$/, ''),
26
+ reason: (e.reason || 'path_missing').slice(0, 64),
27
+ }))
28
+ .filter((e) => e.worktree_root)
29
+ .sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
30
+ if (absent_worktrees.length === 0) {
31
+ return Promise.resolve();
32
+ }
33
+ const endpointBase = loadEndpointBase();
34
+ const url = buildApiUrl(endpointBase, '/endpoint_security/worktree-absent/');
35
+ const payload = { hardware_uuid: hardwareUuid, absent_worktrees };
36
+ const signature = createSignature(payload, authKey.key);
37
+ const body = JSON.stringify({ ...payload, signature, key_id: authKey.key_id || '' });
38
+ return executeBody(url, 'POST', body, 8000)
39
+ .then(({ statusCode }) => {
40
+ if (statusCode !== 200) {
41
+ hookRunLog(`worktree_absent: server returned ${statusCode}`);
42
+ }
43
+ else {
44
+ hookRunLog(`worktree_absent: reported ${absent_worktrees.length} root(s)`);
45
+ }
46
+ })
47
+ .catch((err) => {
48
+ hookRunLog(`worktree_absent: request failed: ${err instanceof Error ? err.message : String(err)}`);
49
+ });
50
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Worktrees touched within this window are reported and collected (Phase 1). */
4
+ export const WORKTREE_ACTIVE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
5
+ const IN_REPO_SEGMENT = '/.claude/worktrees/';
6
+ const HOME_SEGMENT = '/.claude-worktrees/';
7
+ export function normalizePathForWorktree(path) {
8
+ return path.replace(/\\/g, '/');
9
+ }
10
+ export function parseWorktreeRootFromPath(filePath) {
11
+ const norm = normalizePathForWorktree(filePath);
12
+ if (norm.includes(HOME_SEGMENT)) {
13
+ const parts = norm.split(HOME_SEGMENT);
14
+ if (parts.length !== 2)
15
+ return null;
16
+ const prefix = parts[0];
17
+ const after = parts[1].replace(/^\/+/, '');
18
+ const segments = after.split('/');
19
+ if (segments.length < 2)
20
+ return null;
21
+ return `${prefix}${HOME_SEGMENT}${segments[0]}/${segments[1]}`.replace(/\/+$/, '');
22
+ }
23
+ if (norm.includes(IN_REPO_SEGMENT)) {
24
+ const idx = norm.indexOf(IN_REPO_SEGMENT);
25
+ const before = norm.slice(0, idx).replace(/\/+$/, '');
26
+ const after = norm.slice(idx + IN_REPO_SEGMENT.length).replace(/^\/+/, '');
27
+ const name = after.split('/')[0];
28
+ if (!name)
29
+ return null;
30
+ return `${before}${IN_REPO_SEGMENT}${name}`.replace(/\/+$/, '');
31
+ }
32
+ return null;
33
+ }
34
+ function isActiveDir(dirPath, maxAgeMs) {
35
+ if (!existsSync(dirPath))
36
+ return false;
37
+ try {
38
+ const st = statSync(dirPath);
39
+ if (!st.isDirectory())
40
+ return false;
41
+ return Date.now() - st.mtimeMs <= maxAgeMs;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function scanInRepoWorktrees(projectRoot, maxAgeMs) {
48
+ const base = join(projectRoot, '.claude', 'worktrees');
49
+ if (!existsSync(base))
50
+ return [];
51
+ const repoName = projectRoot.split(/[/\\]/).filter(Boolean).pop() || '';
52
+ const entries = [];
53
+ try {
54
+ for (const dirent of readdirSync(base, { withFileTypes: true })) {
55
+ if (!dirent.isDirectory())
56
+ continue;
57
+ const root = join(base, dirent.name);
58
+ if (!isActiveDir(root, maxAgeMs))
59
+ continue;
60
+ const st = statSync(root);
61
+ entries.push({
62
+ worktree_root: normalizePathForWorktree(root),
63
+ worktree_name: dirent.name,
64
+ repo_identifier: repoName,
65
+ layout: 'in_repo',
66
+ last_modified: st.mtime.toISOString(),
67
+ });
68
+ }
69
+ }
70
+ catch {
71
+ return entries;
72
+ }
73
+ return entries;
74
+ }
75
+ function scanHomeWorktrees(home, maxAgeMs) {
76
+ const base = join(home, '.claude-worktrees');
77
+ if (!existsSync(base))
78
+ return [];
79
+ const entries = [];
80
+ try {
81
+ for (const repoDir of readdirSync(base, { withFileTypes: true })) {
82
+ if (!repoDir.isDirectory())
83
+ continue;
84
+ const repoPath = join(base, repoDir.name);
85
+ let worktreeNames = [];
86
+ try {
87
+ worktreeNames = readdirSync(repoPath, { withFileTypes: true })
88
+ .filter((d) => d.isDirectory())
89
+ .map((d) => d.name);
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ for (const wtName of worktreeNames) {
95
+ const root = join(repoPath, wtName);
96
+ if (!isActiveDir(root, maxAgeMs))
97
+ continue;
98
+ const st = statSync(root);
99
+ entries.push({
100
+ worktree_root: normalizePathForWorktree(root),
101
+ worktree_name: wtName,
102
+ repo_identifier: repoDir.name,
103
+ layout: 'home',
104
+ last_modified: st.mtime.toISOString(),
105
+ });
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return entries;
111
+ }
112
+ return entries;
113
+ }
114
+ /**
115
+ * Scan project and home Claude worktree directories; return those active within maxAgeMs.
116
+ */
117
+ export function scanActiveWorktrees(projectRoot, home, maxAgeMs = WORKTREE_ACTIVE_MAX_AGE_MS) {
118
+ const byRoot = new Map();
119
+ for (const e of [...scanInRepoWorktrees(projectRoot, maxAgeMs), ...scanHomeWorktrees(home, maxAgeMs)]) {
120
+ byRoot.set(e.worktree_root, e);
121
+ }
122
+ return [...byRoot.values()].sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
123
+ }
124
+ export function activeWorktreeRootSet(entries) {
125
+ return new Set(entries.map((e) => e.worktree_root));
126
+ }
127
+ export function filterConfigFilesToActiveWorktrees(files, activeRoots) {
128
+ if (activeRoots.size === 0) {
129
+ return files.filter((f) => parseWorktreeRootFromPath(f.file_path) === null);
130
+ }
131
+ return files.filter((f) => {
132
+ const root = parseWorktreeRootFromPath(f.file_path);
133
+ if (root === null)
134
+ return true;
135
+ return activeRoots.has(root);
136
+ });
137
+ }
@@ -177,7 +177,7 @@ async function sendIngestSessionStart(hardwareUuid, authKey) {
177
177
  return null;
178
178
  }
179
179
  }
180
- async function sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, failedUploads = []) {
180
+ async function sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, failedUploads = [], worktreeReport = []) {
181
181
  const endpoint = loadEndpointBase();
182
182
  const apiUrl = `${resolveApiBase(endpoint)}/endpoint_security/ingest-session/finish/`;
183
183
  const failed_uploads = [...failedUploads]
@@ -191,6 +191,17 @@ async function sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, f
191
191
  if (failed_uploads.length > 0) {
192
192
  payload.failed_uploads = failed_uploads;
193
193
  }
194
+ const worktree_report = [...worktreeReport]
195
+ .map((e) => ({
196
+ worktree_root: e.worktree_root,
197
+ worktree_name: e.worktree_name,
198
+ repo_identifier: e.repo_identifier,
199
+ layout: e.layout,
200
+ last_modified: e.last_modified,
201
+ }))
202
+ .sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
203
+ // Always send the key (may be []) so the server prunes stale worktrees when none are active.
204
+ payload.worktree_report = worktree_report;
194
205
  const signature = createSignature(payload, authKey.key);
195
206
  const body = { ...payload, signature, key_id: authKey.key_id || '' };
196
207
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config-staging",
3
- "version": "1.3.81",
3
+ "version": "1.3.82",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {