@bbigbang/core 0.1.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.
- package/dist/config.js +380 -0
- package/dist/execution/executionDispatcher.js +3810 -0
- package/dist/main.js +90 -0
- package/dist/nodeEventHistory.js +206 -0
- package/dist/scheduler/dreamLogic.js +50 -0
- package/dist/scheduler/dreamScheduler.js +65 -0
- package/dist/services/agentFileAccessService.js +1913 -0
- package/dist/services/agentRuntimeCleanupBroker.js +62 -0
- package/dist/services/agentSkillsBroker.js +118 -0
- package/dist/services/agentSkillsService.js +83 -0
- package/dist/services/agentWorkspaceBroker.js +937 -0
- package/dist/services/agentWorkspaceService.js +70 -0
- package/dist/services/appVersion.js +14 -0
- package/dist/services/auth.js +586 -0
- package/dist/services/claudeControlBroker.js +154 -0
- package/dist/services/claudeTranscriptBroker.js +100 -0
- package/dist/services/claudeTranscriptService.js +359 -0
- package/dist/services/codexAppServerBroker.js +155 -0
- package/dist/services/codexTranscriptBroker.js +98 -0
- package/dist/services/codexTranscriptService.js +961 -0
- package/dist/services/droidMissionBroker.js +124 -0
- package/dist/services/droidMissionImporter.js +630 -0
- package/dist/services/droidModelOptions.js +165 -0
- package/dist/services/hubServerRegistrationService.js +268 -0
- package/dist/services/libraryManifest.js +43 -0
- package/dist/services/libraryScaffold.js +26 -0
- package/dist/services/libraryService.js +2263 -0
- package/dist/services/memoryService.js +386 -0
- package/dist/services/missionEvidence.js +377 -0
- package/dist/services/missionService.js +2361 -0
- package/dist/services/missionTrace.js +158 -0
- package/dist/services/nativeMissionBriefParser.js +120 -0
- package/dist/services/nativeMissionOrchestrator.js +2045 -0
- package/dist/services/nativeMissionReportGenerator.js +227 -0
- package/dist/services/nativeMissionValidationRunner.js +452 -0
- package/dist/services/nativeMissionWorkerBroker.js +190 -0
- package/dist/services/nodeRegistry.js +34 -0
- package/dist/services/nodeStateReconciler.js +97 -0
- package/dist/services/panelMediaScanner.js +119 -0
- package/dist/services/persistentRuntimeJsonlClient.js +153 -0
- package/dist/services/platformAgentPolicy.js +180 -0
- package/dist/services/platformAgentService.js +2041 -0
- package/dist/services/projectAccessResolver.js +93 -0
- package/dist/services/projectService.js +392 -0
- package/dist/services/resourceSpaceService.js +140 -0
- package/dist/services/scenarioRuntimeService.js +1130 -0
- package/dist/services/suggestedPlannerService.js +868 -0
- package/dist/services/workbenchGitBroker.js +161 -0
- package/dist/services/workbenchGitService.js +69 -0
- package/dist/services/workbenchInspectBroker.js +65 -0
- package/dist/services/workbenchNodePathService.js +79 -0
- package/dist/services/workbenchRegistryService.js +240 -0
- package/dist/services/workbenchRootService.js +181 -0
- package/dist/services/workbenchTerminalBroker.js +378 -0
- package/dist/services/workspaceRunOwnership.js +60 -0
- package/dist/services/workspaceScaffold.js +105 -0
- package/dist/services/workspaceSessionRuntimeService.js +576 -0
- package/dist/services/workspaceSessionService.js +245 -0
- package/dist/services/workspaceToolActionRunner.js +1582 -0
- package/dist/services/workspaceToolErrors.js +10 -0
- package/dist/services/workspaceToolExecutionUtils.js +895 -0
- package/dist/services/workspaceToolLatestStateProjector.js +91 -0
- package/dist/services/workspaceToolManifest.js +572 -0
- package/dist/services/workspaceToolMutationQueue.js +43 -0
- package/dist/services/workspaceToolPanelProjection.js +460 -0
- package/dist/services/workspaceToolPromotion.js +255 -0
- package/dist/services/workspaceToolPromotionState.js +224 -0
- package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
- package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
- package/dist/services/workspaceToolReadModel.js +378 -0
- package/dist/services/workspaceToolRunLedger.js +239 -0
- package/dist/services/workspaceToolService.js +3067 -0
- package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
- package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
- package/dist/services/workspaceToolTypes.js +1 -0
- package/dist/services/workspaceToolUploadMaterializer.js +228 -0
- package/dist/web/actionCardRoutes.js +129 -0
- package/dist/web/actionCards.js +469 -0
- package/dist/web/activationContext.js +684 -0
- package/dist/web/agentChannelGuards.js +48 -0
- package/dist/web/agentMentionCooldowns.js +32 -0
- package/dist/web/agentReminders.js +1668 -0
- package/dist/web/agentRuntimePresence.js +197 -0
- package/dist/web/agentSelfState.js +494 -0
- package/dist/web/agentTaskLinks.js +26 -0
- package/dist/web/agentVisibility.js +79 -0
- package/dist/web/assets.js +95 -0
- package/dist/web/channelActivationPrompt.js +395 -0
- package/dist/web/channelMemoryNotes.js +127 -0
- package/dist/web/channelMentions.js +10 -0
- package/dist/web/channelMessageSequences.js +19 -0
- package/dist/web/channelSubscriptions.js +26 -0
- package/dist/web/clearedTaskRoots.js +10 -0
- package/dist/web/collaborationPromptGuidance.js +36 -0
- package/dist/web/collaborationSurfaceState.js +140 -0
- package/dist/web/contextBundleRanking.js +154 -0
- package/dist/web/contextBundleResolver.js +488 -0
- package/dist/web/conversationBuiltinSkillRoots.js +50 -0
- package/dist/web/conversationControls.js +232 -0
- package/dist/web/conversationHandoffs.js +612 -0
- package/dist/web/conversationManager.js +2511 -0
- package/dist/web/conversationSummaries.js +876 -0
- package/dist/web/conversationSurfaceKinds.js +17 -0
- package/dist/web/conversationTargets.js +173 -0
- package/dist/web/directActivationPrompt.js +122 -0
- package/dist/web/directReplyTargets.js +69 -0
- package/dist/web/directThreadResolver.js +129 -0
- package/dist/web/dmTaskHandoffPrompt.js +120 -0
- package/dist/web/dmTaskThreadStatusProjection.js +229 -0
- package/dist/web/ftsQuery.js +33 -0
- package/dist/web/internalAgentRouter.js +11341 -0
- package/dist/web/libraryCuratorScheduler.js +58 -0
- package/dist/web/libraryDocumentPromptGuidance.js +8 -0
- package/dist/web/messageCheckpoints.js +19 -0
- package/dist/web/nodeWsHandler.js +2495 -0
- package/dist/web/notificationRounds.js +1061 -0
- package/dist/web/panelActionMessages.js +108 -0
- package/dist/web/panelActivationPrompt.js +18 -0
- package/dist/web/panelAudit.js +273 -0
- package/dist/web/panelLifecycle.js +222 -0
- package/dist/web/panelMediaPolicy.js +43 -0
- package/dist/web/panelPathPolicy.js +63 -0
- package/dist/web/panelPreviews.js +175 -0
- package/dist/web/panelQueryHandles.js +2749 -0
- package/dist/web/panelRoutes.js +2147 -0
- package/dist/web/panels.js +904 -0
- package/dist/web/peerInboxAggregates.js +1247 -0
- package/dist/web/planApprovalState.js +92 -0
- package/dist/web/platformAgentScheduler.js +66 -0
- package/dist/web/proactiveOpportunities.js +452 -0
- package/dist/web/promptContextSections.js +242 -0
- package/dist/web/promptHistorySanitizer.js +26 -0
- package/dist/web/promptSlashCommands.js +158 -0
- package/dist/web/rollingConversationSummary.js +453 -0
- package/dist/web/routeHelpers.js +11 -0
- package/dist/web/routes/handoff.js +288 -0
- package/dist/web/routes/history.js +345 -0
- package/dist/web/routes/memory.js +258 -0
- package/dist/web/routes/selfState.js +171 -0
- package/dist/web/routes/workspace.js +154 -0
- package/dist/web/runSurfaceWatermarks.js +431 -0
- package/dist/web/runtimeCapabilities.js +48 -0
- package/dist/web/sameAgentHandoffs.js +494 -0
- package/dist/web/server.js +15567 -0
- package/dist/web/sharedCollaborationCapsules.js +163 -0
- package/dist/web/soloSessionRelay.js +42 -0
- package/dist/web/soloWsHandler.js +138 -0
- package/dist/web/suggestedPlannerScheduler.js +56 -0
- package/dist/web/surfaceActivationPolicy.js +108 -0
- package/dist/web/surfaceCollaborators.js +61 -0
- package/dist/web/surfaceSystemStatus.js +263 -0
- package/dist/web/targetParticipants.js +77 -0
- package/dist/web/taskEvents.js +49 -0
- package/dist/web/taskLifecycleMessages.js +165 -0
- package/dist/web/taskLoops.js +732 -0
- package/dist/web/taskMemoryNotes.js +224 -0
- package/dist/web/taskNumbers.js +16 -0
- package/dist/web/taskOwnerGuards.js +49 -0
- package/dist/web/taskParticipantResolver.js +42 -0
- package/dist/web/taskParticipants.js +97 -0
- package/dist/web/taskSourceDetails.js +20 -0
- package/dist/web/taskStateViews.js +210 -0
- package/dist/web/taskStatusTransitions.js +9 -0
- package/dist/web/taskThreadFollowups.js +599 -0
- package/dist/web/taskThreadRuntimeClosure.js +685 -0
- package/dist/web/taskUpdateDelivery.js +104 -0
- package/dist/web/threadReplyContentHeuristics.js +30 -0
- package/dist/web/threadRoots.js +61 -0
- package/dist/web/threadTaskBindings.js +365 -0
- package/dist/web/uiPanelPromptGuidance.js +27 -0
- package/dist/web/workspaceMemoryHints.js +143 -0
- package/dist/web/workspaceToolPromptGuidance.js +30 -0
- package/dist/web/wsHandler.js +397 -0
- package/dist/web/wsSink.js +116 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1913 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { AgentWorkspaceServiceError, mapAgentWorkspaceError } from './agentWorkspaceService.js';
|
|
4
|
+
const PATH_SEGMENT_PATTERN = '[A-Za-z0-9._~@%+=,-]+';
|
|
5
|
+
const ABSOLUTE_PATH_RE = new RegExp(`(^|[\\s"'\\\`([{<])((?:/${PATH_SEGMENT_PATTERN})+/?)(?::\\d+(?::\\d+)?)?(?=$|[\\s"'\\\`)\\]}>;,!?,。;、])`, 'g');
|
|
6
|
+
const RELATIVE_PATH_RE = new RegExp(`(^|[\\s"'\\\`([{<])((?:\\./)?${PATH_SEGMENT_PATTERN}(?:/${PATH_SEGMENT_PATTERN})+/?|(?:\\./)?${PATH_SEGMENT_PATTERN}/|${PATH_SEGMENT_PATTERN}\\.[A-Za-z0-9][A-Za-z0-9._~-]*)(?::\\d+(?::\\d+)?)?(?=$|[\\s"'\\\`)\\]}>;,!?,。;、])`, 'g');
|
|
7
|
+
const FILE_EXTENSION_RE = /\.(?:avif|bmp|c|cc|cjs|cpp|cs|css|cts|env|gif|go|h|hpp|html|ico|java|jpeg|jpg|js|json|jsonl|jsx|kt|lock|log|md|mdx|mjs|mts|pdf|php|png|py|rb|rs|scss|sh|sql|svg|swift|tif|tiff|toml|ts|tsx|txt|webp|xml|yaml|yml)$/i;
|
|
8
|
+
const CURRENT_ACCESS_INDEX_VERSION = 9;
|
|
9
|
+
const DEFAULT_DIRECTORY_PAGE_LIMIT = 100;
|
|
10
|
+
const MAX_DIRECTORY_PAGE_LIMIT = 200;
|
|
11
|
+
const SINGLE_FILE_NAMES = new Set([
|
|
12
|
+
'AGENTS.md',
|
|
13
|
+
'Agents.md',
|
|
14
|
+
'CLAUDE.md',
|
|
15
|
+
'Dockerfile',
|
|
16
|
+
'Makefile',
|
|
17
|
+
'MEMORY.md',
|
|
18
|
+
'README.md',
|
|
19
|
+
'changelog.md',
|
|
20
|
+
'package.json',
|
|
21
|
+
'pnpm-lock.yaml',
|
|
22
|
+
'pnpm-workspace.yaml',
|
|
23
|
+
'tsconfig.json',
|
|
24
|
+
'vite.config.ts',
|
|
25
|
+
]);
|
|
26
|
+
export class AgentFileAccessServiceError extends Error {
|
|
27
|
+
statusCode;
|
|
28
|
+
constructor(statusCode, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.statusCode = statusCode;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function getRunEventSeqCutoff(db, runId) {
|
|
34
|
+
if (!runId)
|
|
35
|
+
return null;
|
|
36
|
+
const row = db.prepare(`SELECT COALESCE(MAX(seq), 0) as maxSeq
|
|
37
|
+
FROM events
|
|
38
|
+
WHERE run_id = ?
|
|
39
|
+
AND method = 'node/event'`).get(runId);
|
|
40
|
+
return row?.maxSeq ?? 0;
|
|
41
|
+
}
|
|
42
|
+
export class AgentFileAccessService {
|
|
43
|
+
db;
|
|
44
|
+
getAgentById;
|
|
45
|
+
broker;
|
|
46
|
+
entryTokenSecret;
|
|
47
|
+
constructor(params) {
|
|
48
|
+
this.db = params.db;
|
|
49
|
+
this.getAgentById = params.getAgentById;
|
|
50
|
+
this.broker = params.broker;
|
|
51
|
+
this.entryTokenSecret = params.entryTokenSecret ?? 'agent-file-access-entry-token-secret';
|
|
52
|
+
}
|
|
53
|
+
recordEventAccesses(params) {
|
|
54
|
+
if (params.event.type !== 'tool.call' && params.event.type !== 'tool.result')
|
|
55
|
+
return;
|
|
56
|
+
const context = this.getRunContext(params.runId, params.conversationId);
|
|
57
|
+
if (!context.agentId)
|
|
58
|
+
return;
|
|
59
|
+
const candidates = extractPathCandidatesFromToolEvent(params.event, {
|
|
60
|
+
toolCall: params.event.type === 'tool.result'
|
|
61
|
+
? this.loadToolCallEvent(params.runId, params.event.toolCallId)
|
|
62
|
+
: undefined,
|
|
63
|
+
});
|
|
64
|
+
if (candidates.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
this.insertCandidates({
|
|
67
|
+
agentId: context.agentId,
|
|
68
|
+
nodeId: params.nodeId,
|
|
69
|
+
conversationId: context.conversationId,
|
|
70
|
+
runId: params.runId,
|
|
71
|
+
toolCallId: params.event.toolCallId,
|
|
72
|
+
candidates,
|
|
73
|
+
createdAt: params.createdAt ?? Date.now(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
ensureRunIndexed(runId) {
|
|
77
|
+
const indexed = this.db.prepare(`SELECT index_version as indexVersion
|
|
78
|
+
FROM agent_file_access_indexed_runs
|
|
79
|
+
WHERE run_id = ?`).get(runId);
|
|
80
|
+
if ((indexed?.indexVersion ?? 1) >= CURRENT_ACCESS_INDEX_VERSION)
|
|
81
|
+
return;
|
|
82
|
+
const context = this.getRunContext(runId);
|
|
83
|
+
if (!context.agentId) {
|
|
84
|
+
this.markRunIndexed(runId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!context.nodeId) {
|
|
88
|
+
this.markRunIndexed(runId);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const agentId = context.agentId;
|
|
92
|
+
const nodeId = context.nodeId;
|
|
93
|
+
this.db.transaction(() => {
|
|
94
|
+
this.db.prepare(`DELETE FROM agent_file_accesses WHERE run_id = ?`).run(runId);
|
|
95
|
+
const rows = this.db.prepare(`SELECT payload_json as payloadJson, created_at as createdAt
|
|
96
|
+
FROM events
|
|
97
|
+
WHERE run_id = ?
|
|
98
|
+
AND method = 'node/event'
|
|
99
|
+
AND json_valid(payload_json)
|
|
100
|
+
AND json_extract(payload_json, '$.type') IN ('tool.call', 'tool.result')
|
|
101
|
+
ORDER BY seq ASC`).all(runId);
|
|
102
|
+
const toolCalls = new Map();
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
try {
|
|
105
|
+
const event = JSON.parse(row.payloadJson);
|
|
106
|
+
if (event.type !== 'tool.call' && event.type !== 'tool.result')
|
|
107
|
+
continue;
|
|
108
|
+
if (event.type === 'tool.call')
|
|
109
|
+
toolCalls.set(event.toolCallId, event);
|
|
110
|
+
const candidates = extractPathCandidatesFromToolEvent(event, {
|
|
111
|
+
toolCall: event.type === 'tool.result' ? toolCalls.get(event.toolCallId) : undefined,
|
|
112
|
+
});
|
|
113
|
+
if (candidates.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
this.insertCandidates({
|
|
116
|
+
agentId,
|
|
117
|
+
nodeId,
|
|
118
|
+
conversationId: context.conversationId,
|
|
119
|
+
runId,
|
|
120
|
+
toolCallId: event.toolCallId,
|
|
121
|
+
candidates,
|
|
122
|
+
createdAt: row.createdAt,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Ignore malformed historical event payloads.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.markRunIndexed(runId);
|
|
130
|
+
})();
|
|
131
|
+
}
|
|
132
|
+
getFileRefsForMessage(params) {
|
|
133
|
+
if (!params.runId || !params.content.trim())
|
|
134
|
+
return [];
|
|
135
|
+
const boundary = {
|
|
136
|
+
createdAt: params.createdAt,
|
|
137
|
+
seqCutoff: params.runEventSeqCutoff,
|
|
138
|
+
};
|
|
139
|
+
this.ensureRunIndexed(params.runId);
|
|
140
|
+
const accessCutoff = buildAccessBoundaryWhere(boundary);
|
|
141
|
+
const accesses = this.db.prepare(`SELECT access_id as accessId,
|
|
142
|
+
agent_id as agentId,
|
|
143
|
+
node_id as nodeId,
|
|
144
|
+
conversation_id as conversationId,
|
|
145
|
+
run_id as runId,
|
|
146
|
+
tool_call_id as toolCallId,
|
|
147
|
+
absolute_path as absolutePath,
|
|
148
|
+
kind,
|
|
149
|
+
source,
|
|
150
|
+
created_at as createdAt
|
|
151
|
+
FROM agent_file_accesses
|
|
152
|
+
WHERE agent_id = ?
|
|
153
|
+
AND run_id = ?
|
|
154
|
+
${accessCutoff.sql}
|
|
155
|
+
ORDER BY length(absolute_path) DESC, created_at ASC`).all(params.agentId, params.runId, ...accessCutoff.params);
|
|
156
|
+
if (accesses.length === 0)
|
|
157
|
+
return [];
|
|
158
|
+
const refs = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
for (const token of scanMessagePathTokens(params.content)) {
|
|
161
|
+
const candidates = resolveTokenCandidates(token, accesses)
|
|
162
|
+
.filter((access) => this.accessEvidenceMatchesBoundary(access, token, boundary));
|
|
163
|
+
if (candidates.length !== 1)
|
|
164
|
+
continue;
|
|
165
|
+
const hit = candidates[0];
|
|
166
|
+
const key = `${token}:${hit.accessId}`;
|
|
167
|
+
if (seen.has(key))
|
|
168
|
+
continue;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
refs.push({
|
|
171
|
+
accessId: hit.accessId,
|
|
172
|
+
token,
|
|
173
|
+
absolutePath: hit.absolutePath,
|
|
174
|
+
kind: hit.kind,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return refs;
|
|
178
|
+
}
|
|
179
|
+
accessEvidenceMatchesBoundary(access, token, boundary) {
|
|
180
|
+
if (access.kind === 'directory') {
|
|
181
|
+
if (boundary.createdAt == null && boundary.seqCutoff == null)
|
|
182
|
+
return true;
|
|
183
|
+
return this.findLatestDirectoryEnumerationOutput(access, boundary) != null
|
|
184
|
+
|| this.findLatestExplicitDirectoryAccess(access, boundary) != null;
|
|
185
|
+
}
|
|
186
|
+
if (token.endsWith('/'))
|
|
187
|
+
return false;
|
|
188
|
+
return this.accessExistsBeforeBoundary(access, boundary);
|
|
189
|
+
}
|
|
190
|
+
accessExistsBeforeBoundary(access, boundary) {
|
|
191
|
+
if (boundary.seqCutoff != null) {
|
|
192
|
+
const evidenceSeq = this.findAccessEvidenceSeq(access);
|
|
193
|
+
return evidenceSeq != null && evidenceSeq <= boundary.seqCutoff;
|
|
194
|
+
}
|
|
195
|
+
if (boundary.createdAt == null)
|
|
196
|
+
return true;
|
|
197
|
+
if (access.createdAt < boundary.createdAt)
|
|
198
|
+
return true;
|
|
199
|
+
if (access.createdAt > boundary.createdAt)
|
|
200
|
+
return false;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
findAccessEvidenceSeq(access) {
|
|
204
|
+
if (!access.toolCallId)
|
|
205
|
+
return null;
|
|
206
|
+
const expectedType = access.source === 'tool_input' ? 'tool.call' : 'tool.result';
|
|
207
|
+
const row = this.db.prepare(`SELECT seq
|
|
208
|
+
FROM events
|
|
209
|
+
WHERE run_id = ?
|
|
210
|
+
AND method = 'node/event'
|
|
211
|
+
AND json_valid(payload_json)
|
|
212
|
+
AND json_extract(payload_json, '$.type') = ?
|
|
213
|
+
AND json_extract(payload_json, '$.toolCallId') = ?
|
|
214
|
+
ORDER BY seq DESC
|
|
215
|
+
LIMIT 1`).get(access.runId, expectedType, access.toolCallId);
|
|
216
|
+
return row?.seq ?? null;
|
|
217
|
+
}
|
|
218
|
+
async readAccess(params) {
|
|
219
|
+
const context = this.loadMessageAccessContext(params);
|
|
220
|
+
const { row, boundary, message } = context;
|
|
221
|
+
try {
|
|
222
|
+
if (row.kind === 'directory') {
|
|
223
|
+
const directory = await this.readAuthorizedDirectoryPath(row, row.absolutePath, message.messageId, boundary, {
|
|
224
|
+
cursor: params.cursor,
|
|
225
|
+
limit: params.limit,
|
|
226
|
+
});
|
|
227
|
+
if (directory)
|
|
228
|
+
return directory;
|
|
229
|
+
}
|
|
230
|
+
const result = await this.broker.readExactPath(row.nodeId, row.absolutePath);
|
|
231
|
+
// Only list a directory when the access record was already typed as a directory.
|
|
232
|
+
// Unknown-to-file upgrades are useful for extensionless files, but unknown-to-directory
|
|
233
|
+
// must not auto-grant listing rights for sibling paths the agent never enumerated.
|
|
234
|
+
if (row.kind === 'unknown' && result.kind === 'file') {
|
|
235
|
+
this.db.prepare(`UPDATE agent_file_accesses
|
|
236
|
+
SET kind = 'file', updated_at = ?
|
|
237
|
+
WHERE access_id = ?`).run(Date.now(), row.accessId);
|
|
238
|
+
}
|
|
239
|
+
if (result.kind === 'directory' && row.kind !== 'directory') {
|
|
240
|
+
return {
|
|
241
|
+
accessId: row.accessId,
|
|
242
|
+
absolutePath: row.absolutePath,
|
|
243
|
+
kind: 'directory',
|
|
244
|
+
modifiedAt: result.modifiedAt,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
accessId: row.accessId,
|
|
249
|
+
absolutePath: row.absolutePath,
|
|
250
|
+
kind: result.kind,
|
|
251
|
+
...(result.kind === 'file'
|
|
252
|
+
? {
|
|
253
|
+
content: result.content,
|
|
254
|
+
mimeType: result.mimeType,
|
|
255
|
+
size: result.size,
|
|
256
|
+
modifiedAt: result.modifiedAt,
|
|
257
|
+
}
|
|
258
|
+
: result.kind === 'directory'
|
|
259
|
+
? {
|
|
260
|
+
entries: this.annotateDirectoryEntries(result.entries, row, message.messageId, boundary),
|
|
261
|
+
directoryPage: result.directoryPage ?? singlePageInfo(result.entries.length),
|
|
262
|
+
modifiedAt: result.modifiedAt,
|
|
263
|
+
}
|
|
264
|
+
: { modifiedAt: result.modifiedAt }),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error instanceof AgentFileAccessServiceError)
|
|
269
|
+
throw error;
|
|
270
|
+
throw mapAgentFileAccessError(error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async readDirectoryEntry(params) {
|
|
274
|
+
const context = this.loadMessageAccessContext(params);
|
|
275
|
+
const { row, boundary, message } = context;
|
|
276
|
+
const childPath = normalizeAbsolutePath(params.path)?.absolutePath;
|
|
277
|
+
if (!childPath) {
|
|
278
|
+
throw new AgentFileAccessServiceError(400, 'path must be an absolute path.');
|
|
279
|
+
}
|
|
280
|
+
if (childPath === row.absolutePath || !childPath.startsWith(`${row.absolutePath}/`)) {
|
|
281
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry is outside the authorized directory.');
|
|
282
|
+
}
|
|
283
|
+
if (!this.verifyDirectoryEntryToken({
|
|
284
|
+
accessId: row.accessId,
|
|
285
|
+
messageId: context.message.messageId,
|
|
286
|
+
path: childPath,
|
|
287
|
+
token: params.entryToken,
|
|
288
|
+
})) {
|
|
289
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry token is invalid.');
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
if (this.isDirectoryPathAuthorized(row, childPath, boundary)) {
|
|
293
|
+
const directory = await this.readAuthorizedDirectoryPath(row, childPath, message.messageId, boundary, {
|
|
294
|
+
cursor: params.cursor,
|
|
295
|
+
limit: params.limit,
|
|
296
|
+
});
|
|
297
|
+
if (directory)
|
|
298
|
+
return directory;
|
|
299
|
+
}
|
|
300
|
+
if (!this.isFilePathAuthorized(row, childPath, boundary)) {
|
|
301
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry is not authorized for this message.');
|
|
302
|
+
}
|
|
303
|
+
const result = await this.broker.readExactPath(row.nodeId, childPath);
|
|
304
|
+
if (result.kind !== 'file') {
|
|
305
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry is not authorized for listing.');
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
accessId: row.accessId,
|
|
309
|
+
absolutePath: result.absolutePath,
|
|
310
|
+
kind: 'file',
|
|
311
|
+
content: result.content,
|
|
312
|
+
mimeType: result.mimeType,
|
|
313
|
+
size: result.size,
|
|
314
|
+
modifiedAt: result.modifiedAt,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (error instanceof AgentFileAccessServiceError)
|
|
319
|
+
throw error;
|
|
320
|
+
throw mapAgentFileAccessError(error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async streamAccess(params) {
|
|
324
|
+
const { row } = this.loadMessageAccessContext(params);
|
|
325
|
+
if (row.kind === 'directory') {
|
|
326
|
+
throw new AgentFileAccessServiceError(400, 'Directory records cannot be streamed as files.');
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return await this.broker.streamExactPath(row.nodeId, row.absolutePath);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
throw mapAgentFileAccessError(error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async streamDirectoryEntry(params) {
|
|
336
|
+
const context = this.loadMessageAccessContext(params);
|
|
337
|
+
const { row, boundary } = context;
|
|
338
|
+
const childPath = normalizeAbsolutePath(params.path)?.absolutePath;
|
|
339
|
+
if (!childPath) {
|
|
340
|
+
throw new AgentFileAccessServiceError(400, 'path must be an absolute path.');
|
|
341
|
+
}
|
|
342
|
+
if (childPath === row.absolutePath || !childPath.startsWith(`${row.absolutePath}/`)) {
|
|
343
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry is outside the authorized directory.');
|
|
344
|
+
}
|
|
345
|
+
if (!this.verifyDirectoryEntryToken({
|
|
346
|
+
accessId: row.accessId,
|
|
347
|
+
messageId: context.message.messageId,
|
|
348
|
+
path: childPath,
|
|
349
|
+
token: params.entryToken,
|
|
350
|
+
})) {
|
|
351
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry token is invalid.');
|
|
352
|
+
}
|
|
353
|
+
if (!this.isFilePathAuthorized(row, childPath, boundary)) {
|
|
354
|
+
throw new AgentFileAccessServiceError(403, 'Directory entry is not authorized for this message.');
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return await this.broker.streamExactPath(row.nodeId, childPath);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
throw mapAgentFileAccessError(error);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
loadMessageAccessContext(params) {
|
|
364
|
+
const row = this.db.prepare(`SELECT access_id as accessId,
|
|
365
|
+
agent_id as agentId,
|
|
366
|
+
node_id as nodeId,
|
|
367
|
+
conversation_id as conversationId,
|
|
368
|
+
run_id as runId,
|
|
369
|
+
tool_call_id as toolCallId,
|
|
370
|
+
absolute_path as absolutePath,
|
|
371
|
+
kind,
|
|
372
|
+
source,
|
|
373
|
+
created_at as createdAt
|
|
374
|
+
FROM agent_file_accesses
|
|
375
|
+
WHERE access_id = ?`).get(params.accessId);
|
|
376
|
+
if (!row || row.agentId !== params.agentId) {
|
|
377
|
+
throw new AgentFileAccessServiceError(404, 'File access record not found.');
|
|
378
|
+
}
|
|
379
|
+
const messageId = params.messageId.trim();
|
|
380
|
+
if (!messageId) {
|
|
381
|
+
throw new AgentFileAccessServiceError(400, 'messageId is required.');
|
|
382
|
+
}
|
|
383
|
+
const message = this.db.prepare(`SELECT run_id as runId,
|
|
384
|
+
sender_id as senderId,
|
|
385
|
+
content,
|
|
386
|
+
created_at as createdAt,
|
|
387
|
+
run_event_seq_cutoff as runEventSeqCutoff
|
|
388
|
+
FROM channel_messages
|
|
389
|
+
WHERE message_id = ?`).get(messageId);
|
|
390
|
+
if (!message || message.runId !== row.runId || message.senderId !== row.agentId) {
|
|
391
|
+
throw new AgentFileAccessServiceError(403, 'File access record does not belong to this message.');
|
|
392
|
+
}
|
|
393
|
+
const refs = this.getFileRefsForMessage({
|
|
394
|
+
agentId: row.agentId,
|
|
395
|
+
runId: row.runId,
|
|
396
|
+
content: message.content,
|
|
397
|
+
createdAt: message.createdAt,
|
|
398
|
+
runEventSeqCutoff: message.runEventSeqCutoff,
|
|
399
|
+
});
|
|
400
|
+
if (!refs.some((ref) => ref.accessId === row.accessId)) {
|
|
401
|
+
throw new AgentFileAccessServiceError(403, 'File access record is not bound to this message.');
|
|
402
|
+
}
|
|
403
|
+
const agent = this.getAgentById(row.agentId);
|
|
404
|
+
if (!agent)
|
|
405
|
+
throw new AgentFileAccessServiceError(404, 'Agent not found.');
|
|
406
|
+
if (!row.nodeId)
|
|
407
|
+
throw new AgentFileAccessServiceError(409, 'File access record has no source node.');
|
|
408
|
+
return {
|
|
409
|
+
row,
|
|
410
|
+
message: { ...message, messageId },
|
|
411
|
+
agent,
|
|
412
|
+
boundary: { createdAt: message.createdAt, seqCutoff: message.runEventSeqCutoff },
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async readAuthorizedDirectoryPath(rootRow, directoryPath, messageId, boundary, options) {
|
|
416
|
+
const directoryRow = this.directoryRowForPath(rootRow, directoryPath);
|
|
417
|
+
const directorySnapshot = this.findLatestDirectoryEnumerationOutput(directoryRow, boundary);
|
|
418
|
+
const explicitDirectoryAccess = this.findLatestExplicitDirectoryAccess(directoryRow, boundary);
|
|
419
|
+
const snapshotWins = directorySnapshot
|
|
420
|
+
&& (explicitDirectoryAccess == null || compareRunEventPositions(directorySnapshot.position, explicitDirectoryAccess) >= 0);
|
|
421
|
+
if (snapshotWins && directorySnapshot.mode === 'snapshot') {
|
|
422
|
+
const entries = this.annotateDirectoryEntries(buildEnumeratedDirectoryEntries(directoryPath, directorySnapshot.candidates), rootRow, messageId, boundary);
|
|
423
|
+
const page = paginateDirectoryEntries(entries, options);
|
|
424
|
+
return {
|
|
425
|
+
accessId: rootRow.accessId,
|
|
426
|
+
absolutePath: directoryPath,
|
|
427
|
+
kind: 'directory',
|
|
428
|
+
entries: page.entries,
|
|
429
|
+
directoryPage: page.directoryPage,
|
|
430
|
+
modifiedAt: null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (!explicitDirectoryAccess && !(snapshotWins && directorySnapshot.mode === 'root_only_traversal'))
|
|
434
|
+
return null;
|
|
435
|
+
const result = options.cursor || typeof options.limit === 'number'
|
|
436
|
+
? await this.broker.readExactPath(rootRow.nodeId, directoryPath, options)
|
|
437
|
+
: await this.broker.readExactPath(rootRow.nodeId, directoryPath);
|
|
438
|
+
if (result.kind !== 'directory') {
|
|
439
|
+
throw new AgentFileAccessServiceError(403, 'Authorized directory path is not a directory.');
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
accessId: rootRow.accessId,
|
|
443
|
+
absolutePath: result.absolutePath,
|
|
444
|
+
kind: 'directory',
|
|
445
|
+
entries: this.annotateDirectoryEntries(result.entries, rootRow, messageId, boundary),
|
|
446
|
+
directoryPage: result.directoryPage ?? singlePageInfo(result.entries.length),
|
|
447
|
+
modifiedAt: result.modifiedAt,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
annotateDirectoryEntries(entries, rootRow, messageId, boundary) {
|
|
451
|
+
return entries.map((entry) => {
|
|
452
|
+
const canOpen = entry.kind === 'file'
|
|
453
|
+
? this.isFilePathAuthorized(rootRow, entry.path, boundary)
|
|
454
|
+
: this.isDirectoryPathAuthorized(rootRow, entry.path, boundary);
|
|
455
|
+
return {
|
|
456
|
+
...entry,
|
|
457
|
+
canOpen,
|
|
458
|
+
...(canOpen
|
|
459
|
+
? {
|
|
460
|
+
entryToken: this.signDirectoryEntryToken({
|
|
461
|
+
accessId: rootRow.accessId,
|
|
462
|
+
messageId,
|
|
463
|
+
path: entry.path,
|
|
464
|
+
}),
|
|
465
|
+
}
|
|
466
|
+
: {}),
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
signDirectoryEntryToken(params) {
|
|
471
|
+
const payload = Buffer.from(JSON.stringify(params), 'utf8').toString('base64url');
|
|
472
|
+
const signature = createHmac('sha256', this.entryTokenSecret).update(payload).digest('base64url');
|
|
473
|
+
return `${payload}.${signature}`;
|
|
474
|
+
}
|
|
475
|
+
verifyDirectoryEntryToken(params) {
|
|
476
|
+
const [payload, signature] = params.token.split('.');
|
|
477
|
+
if (!payload || !signature)
|
|
478
|
+
return false;
|
|
479
|
+
const expectedSignature = createHmac('sha256', this.entryTokenSecret).update(payload).digest('base64url');
|
|
480
|
+
if (!safeEqual(signature, expectedSignature))
|
|
481
|
+
return false;
|
|
482
|
+
try {
|
|
483
|
+
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
484
|
+
return parsed.accessId === params.accessId
|
|
485
|
+
&& parsed.messageId === params.messageId
|
|
486
|
+
&& parsed.path === params.path;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
isFilePathAuthorized(rootRow, filePath, boundary) {
|
|
493
|
+
if (filePath === rootRow.absolutePath || !filePath.startsWith(`${rootRow.absolutePath}/`))
|
|
494
|
+
return false;
|
|
495
|
+
const parentDirectory = path.posix.dirname(filePath);
|
|
496
|
+
const parentView = this.getDirectoryAuthorizationView(rootRow, parentDirectory, boundary);
|
|
497
|
+
if (parentView === 'explicit')
|
|
498
|
+
return true;
|
|
499
|
+
if (parentView !== 'snapshot')
|
|
500
|
+
return false;
|
|
501
|
+
const snapshot = this.findLatestDirectoryEnumerationOutput(this.directoryRowForPath(rootRow, parentDirectory), boundary);
|
|
502
|
+
return Boolean(snapshot?.candidates.some((candidate) => candidate.absolutePath === filePath && candidate.kind !== 'directory'));
|
|
503
|
+
}
|
|
504
|
+
isDirectoryPathAuthorized(rootRow, directoryPath, boundary) {
|
|
505
|
+
if (directoryPath === rootRow.absolutePath)
|
|
506
|
+
return rootRow.kind === 'directory';
|
|
507
|
+
if (!directoryPath.startsWith(`${rootRow.absolutePath}/`))
|
|
508
|
+
return false;
|
|
509
|
+
return this.getDirectoryAuthorizationView(rootRow, directoryPath, boundary) != null;
|
|
510
|
+
}
|
|
511
|
+
getDirectoryAuthorizationView(rootRow, directoryPath, boundary) {
|
|
512
|
+
const directoryRow = this.directoryRowForPath(rootRow, directoryPath);
|
|
513
|
+
const snapshot = this.findLatestDirectoryEnumerationOutput(directoryRow, boundary);
|
|
514
|
+
const explicit = this.findLatestExplicitDirectoryAccess(directoryRow, boundary);
|
|
515
|
+
if (snapshot && (explicit == null || compareRunEventPositions(snapshot.position, explicit) >= 0)) {
|
|
516
|
+
return snapshot.mode === 'root_only_traversal' ? 'explicit' : 'snapshot';
|
|
517
|
+
}
|
|
518
|
+
if (explicit)
|
|
519
|
+
return 'explicit';
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
directoryRowForPath(row, directoryPath) {
|
|
523
|
+
return {
|
|
524
|
+
...row,
|
|
525
|
+
absolutePath: directoryPath,
|
|
526
|
+
kind: 'directory',
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
findLatestDirectoryEnumerationOutput(row, boundary) {
|
|
530
|
+
const cutoff = buildEventBoundaryWhere(boundary, 'created_at', 'seq');
|
|
531
|
+
const results = this.db.prepare(`SELECT payload_json as payloadJson,
|
|
532
|
+
created_at as createdAt,
|
|
533
|
+
seq
|
|
534
|
+
FROM events
|
|
535
|
+
WHERE run_id = ?
|
|
536
|
+
AND method = 'node/event'
|
|
537
|
+
AND json_valid(payload_json)
|
|
538
|
+
AND json_extract(payload_json, '$.type') = 'tool.result'
|
|
539
|
+
${cutoff.sql}
|
|
540
|
+
ORDER BY created_at DESC, seq DESC`).all(row.runId, ...cutoff.params);
|
|
541
|
+
for (const resultRow of results) {
|
|
542
|
+
try {
|
|
543
|
+
const event = JSON.parse(resultRow.payloadJson);
|
|
544
|
+
if (event.type !== 'tool.result')
|
|
545
|
+
continue;
|
|
546
|
+
const toolCall = this.loadToolCallEvent(row.runId, event.toolCallId);
|
|
547
|
+
if (!toolCall)
|
|
548
|
+
continue;
|
|
549
|
+
const cwd = findCwd(toolCall.input);
|
|
550
|
+
const countOnlyRoots = collectShellCountOnlyDirectoryRootPaths(toolCall.input, cwd);
|
|
551
|
+
const roots = [...new Set([
|
|
552
|
+
...collectShellDirectoryRootPaths(toolCall.input, cwd),
|
|
553
|
+
...countOnlyRoots,
|
|
554
|
+
])];
|
|
555
|
+
if (roots.length === 0)
|
|
556
|
+
continue;
|
|
557
|
+
if (!roots.some((root) => row.absolutePath === root || row.absolutePath.startsWith(`${root}/`)))
|
|
558
|
+
continue;
|
|
559
|
+
const outputCandidates = extractPathCandidatesFromToolOutput(event.output, toolCall);
|
|
560
|
+
const position = { createdAt: resultRow.createdAt, seq: resultRow.seq };
|
|
561
|
+
if (roots.some((root) => root === row.absolutePath)
|
|
562
|
+
|| outputCandidates.some((candidate) => (candidate.absolutePath.startsWith(`${row.absolutePath}/`)
|
|
563
|
+
|| (candidate.absolutePath === row.absolutePath && candidate.kind === 'directory')))) {
|
|
564
|
+
const hasChildOutput = outputCandidates.some((candidate) => candidate.absolutePath.startsWith(`${row.absolutePath}/`));
|
|
565
|
+
const isRootOnlyTraversal = countOnlyRoots.some((root) => root === row.absolutePath)
|
|
566
|
+
&& !hasChildOutput;
|
|
567
|
+
return {
|
|
568
|
+
candidates: outputCandidates,
|
|
569
|
+
position,
|
|
570
|
+
mode: isRootOnlyTraversal ? 'root_only_traversal' : 'snapshot',
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Ignore malformed historical tool results.
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
findLatestExplicitDirectoryAccess(row, boundary) {
|
|
581
|
+
const cutoff = buildEventBoundaryWhere(boundary, 'created_at', 'seq');
|
|
582
|
+
const results = this.db.prepare(`SELECT payload_json as payloadJson,
|
|
583
|
+
created_at as createdAt,
|
|
584
|
+
seq
|
|
585
|
+
FROM events
|
|
586
|
+
WHERE run_id = ?
|
|
587
|
+
AND method = 'node/event'
|
|
588
|
+
AND json_valid(payload_json)
|
|
589
|
+
AND json_extract(payload_json, '$.type') = 'tool.call'
|
|
590
|
+
${cutoff.sql}
|
|
591
|
+
ORDER BY created_at DESC, seq DESC`).all(row.runId, ...cutoff.params);
|
|
592
|
+
for (const resultRow of results) {
|
|
593
|
+
try {
|
|
594
|
+
const event = JSON.parse(resultRow.payloadJson);
|
|
595
|
+
if (event.type !== 'tool.call')
|
|
596
|
+
continue;
|
|
597
|
+
const candidates = extractPathCandidatesFromToolEvent(event);
|
|
598
|
+
if (candidates.some((candidate) => (candidate.absolutePath === row.absolutePath
|
|
599
|
+
&& candidate.kind === 'directory'
|
|
600
|
+
&& !isDirectoryOutputToolCall(event)))) {
|
|
601
|
+
return { createdAt: resultRow.createdAt, seq: resultRow.seq };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Ignore malformed historical tool calls.
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
getRunContext(runId, fallbackConversationId) {
|
|
611
|
+
const row = this.db.prepare(`SELECT c.agent_id as agentId,
|
|
612
|
+
c.id as conversationId,
|
|
613
|
+
c.node_id as nodeId
|
|
614
|
+
FROM runs r
|
|
615
|
+
JOIN conversations c ON c.session_key = r.session_key
|
|
616
|
+
WHERE r.run_id = ?
|
|
617
|
+
LIMIT 1`).get(runId);
|
|
618
|
+
if (row)
|
|
619
|
+
return row;
|
|
620
|
+
if (fallbackConversationId) {
|
|
621
|
+
const fallback = this.db.prepare(`SELECT agent_id as agentId,
|
|
622
|
+
id as conversationId,
|
|
623
|
+
node_id as nodeId
|
|
624
|
+
FROM conversations
|
|
625
|
+
WHERE id = ?`).get(fallbackConversationId);
|
|
626
|
+
if (fallback)
|
|
627
|
+
return fallback;
|
|
628
|
+
}
|
|
629
|
+
return { agentId: null, conversationId: fallbackConversationId ?? '', nodeId: null };
|
|
630
|
+
}
|
|
631
|
+
insertCandidates(params) {
|
|
632
|
+
const stmt = this.db.prepare(`INSERT INTO agent_file_accesses(
|
|
633
|
+
access_id, agent_id, node_id, conversation_id, run_id, tool_call_id,
|
|
634
|
+
absolute_path, kind, source, created_at, updated_at
|
|
635
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
636
|
+
ON CONFLICT(run_id, absolute_path) DO UPDATE SET
|
|
637
|
+
kind = CASE
|
|
638
|
+
WHEN agent_file_accesses.kind = 'unknown' AND excluded.kind IN ('file', 'directory') THEN excluded.kind
|
|
639
|
+
ELSE agent_file_accesses.kind
|
|
640
|
+
END,
|
|
641
|
+
source = CASE
|
|
642
|
+
WHEN agent_file_accesses.kind = 'directory' AND excluded.source = 'tool_output_directory' THEN excluded.source
|
|
643
|
+
WHEN agent_file_accesses.kind = 'unknown' AND excluded.kind IN ('file', 'directory') THEN excluded.source
|
|
644
|
+
ELSE agent_file_accesses.source
|
|
645
|
+
END,
|
|
646
|
+
tool_call_id = CASE
|
|
647
|
+
WHEN agent_file_accesses.kind = 'directory' AND excluded.source = 'tool_output_directory' THEN excluded.tool_call_id
|
|
648
|
+
WHEN agent_file_accesses.kind = 'unknown' AND excluded.kind IN ('file', 'directory') THEN excluded.tool_call_id
|
|
649
|
+
ELSE COALESCE(agent_file_accesses.tool_call_id, excluded.tool_call_id)
|
|
650
|
+
END,
|
|
651
|
+
updated_at = excluded.updated_at`);
|
|
652
|
+
const seen = new Set();
|
|
653
|
+
for (const candidate of params.candidates) {
|
|
654
|
+
if (seen.has(candidate.absolutePath))
|
|
655
|
+
continue;
|
|
656
|
+
seen.add(candidate.absolutePath);
|
|
657
|
+
stmt.run(randomUUID(), params.agentId, params.nodeId, params.conversationId, params.runId, params.toolCallId, candidate.absolutePath, candidate.kind, candidate.source, params.createdAt, Date.now());
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
loadToolCallEvent(runId, toolCallId) {
|
|
661
|
+
const row = this.db.prepare(`SELECT payload_json as payloadJson
|
|
662
|
+
FROM events
|
|
663
|
+
WHERE run_id = ?
|
|
664
|
+
AND method = 'node/event'
|
|
665
|
+
AND json_valid(payload_json)
|
|
666
|
+
AND json_extract(payload_json, '$.type') = 'tool.call'
|
|
667
|
+
AND json_extract(payload_json, '$.toolCallId') = ?
|
|
668
|
+
ORDER BY seq DESC
|
|
669
|
+
LIMIT 1`).get(runId, toolCallId);
|
|
670
|
+
if (!row)
|
|
671
|
+
return undefined;
|
|
672
|
+
try {
|
|
673
|
+
const event = JSON.parse(row.payloadJson);
|
|
674
|
+
return event.type === 'tool.call' ? event : undefined;
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
markRunIndexed(runId) {
|
|
681
|
+
this.db.prepare(`INSERT INTO agent_file_access_indexed_runs(run_id, indexed_at, index_version)
|
|
682
|
+
VALUES(?, ?, ?)
|
|
683
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
684
|
+
indexed_at = excluded.indexed_at,
|
|
685
|
+
index_version = excluded.index_version`).run(runId, Date.now(), CURRENT_ACCESS_INDEX_VERSION);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function buildEnumeratedDirectoryEntries(directoryPath, candidates) {
|
|
689
|
+
const prefix = `${directoryPath}/`;
|
|
690
|
+
const entries = new Map();
|
|
691
|
+
for (const child of candidates) {
|
|
692
|
+
if (!child.absolutePath.startsWith(prefix) || child.absolutePath === directoryPath)
|
|
693
|
+
continue;
|
|
694
|
+
const suffix = child.absolutePath.slice(prefix.length);
|
|
695
|
+
const [name, ...rest] = suffix.split('/');
|
|
696
|
+
if (!name)
|
|
697
|
+
continue;
|
|
698
|
+
const isNested = rest.length > 0;
|
|
699
|
+
const entryPath = isNested ? `${prefix}${name}` : child.absolutePath;
|
|
700
|
+
const kind = isNested || child.kind === 'directory' ? 'directory' : 'file';
|
|
701
|
+
const existing = entries.get(entryPath);
|
|
702
|
+
if (existing?.kind === 'directory')
|
|
703
|
+
continue;
|
|
704
|
+
entries.set(entryPath, {
|
|
705
|
+
name,
|
|
706
|
+
path: entryPath,
|
|
707
|
+
kind,
|
|
708
|
+
size: null,
|
|
709
|
+
modifiedAt: null,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return [...entries.values()].sort((left, right) => {
|
|
713
|
+
if (left.kind !== right.kind)
|
|
714
|
+
return left.kind === 'directory' ? -1 : 1;
|
|
715
|
+
return left.name.localeCompare(right.name);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
function paginateDirectoryEntries(entries, options) {
|
|
719
|
+
const limit = normalizeDirectoryLimit(options.limit);
|
|
720
|
+
const offset = parseDirectoryCursor(options.cursor);
|
|
721
|
+
const pageEntries = entries.slice(offset, offset + limit);
|
|
722
|
+
const nextOffset = offset + pageEntries.length;
|
|
723
|
+
const hasMore = nextOffset < entries.length;
|
|
724
|
+
return {
|
|
725
|
+
entries: pageEntries,
|
|
726
|
+
directoryPage: {
|
|
727
|
+
nextCursor: hasMore ? String(nextOffset) : null,
|
|
728
|
+
hasMore,
|
|
729
|
+
limit,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function normalizeDirectoryLimit(value) {
|
|
734
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
735
|
+
return DEFAULT_DIRECTORY_PAGE_LIMIT;
|
|
736
|
+
return Math.min(MAX_DIRECTORY_PAGE_LIMIT, Math.max(1, Math.floor(value)));
|
|
737
|
+
}
|
|
738
|
+
function parseDirectoryCursor(value) {
|
|
739
|
+
if (!value)
|
|
740
|
+
return 0;
|
|
741
|
+
const parsed = Number.parseInt(value, 10);
|
|
742
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
743
|
+
return 0;
|
|
744
|
+
return parsed;
|
|
745
|
+
}
|
|
746
|
+
function singlePageInfo(entryCount) {
|
|
747
|
+
return {
|
|
748
|
+
nextCursor: null,
|
|
749
|
+
hasMore: false,
|
|
750
|
+
limit: entryCount,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function safeEqual(left, right) {
|
|
754
|
+
const leftBuffer = Buffer.from(left);
|
|
755
|
+
const rightBuffer = Buffer.from(right);
|
|
756
|
+
if (leftBuffer.length !== rightBuffer.length)
|
|
757
|
+
return false;
|
|
758
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
759
|
+
}
|
|
760
|
+
function buildEventBoundaryWhere(boundary, createdAtColumn, seqColumn) {
|
|
761
|
+
if (seqColumn && boundary.seqCutoff != null) {
|
|
762
|
+
return {
|
|
763
|
+
sql: `AND ${seqColumn} <= ?`,
|
|
764
|
+
params: [boundary.seqCutoff],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (boundary.createdAt == null)
|
|
768
|
+
return { sql: '', params: [] };
|
|
769
|
+
return {
|
|
770
|
+
sql: `AND ${createdAtColumn} < ?`,
|
|
771
|
+
params: [boundary.createdAt],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function buildAccessBoundaryWhere(boundary) {
|
|
775
|
+
if (boundary.seqCutoff != null)
|
|
776
|
+
return { sql: '', params: [] };
|
|
777
|
+
if (boundary.createdAt == null)
|
|
778
|
+
return { sql: '', params: [] };
|
|
779
|
+
return { sql: 'AND created_at < ?', params: [boundary.createdAt] };
|
|
780
|
+
}
|
|
781
|
+
function compareRunEventPositions(left, right) {
|
|
782
|
+
if (left.createdAt !== right.createdAt)
|
|
783
|
+
return left.createdAt - right.createdAt;
|
|
784
|
+
return left.seq - right.seq;
|
|
785
|
+
}
|
|
786
|
+
function extractPathCandidatesFromToolEvent(event, context = {}) {
|
|
787
|
+
const candidates = [];
|
|
788
|
+
if (event.type === 'tool.call') {
|
|
789
|
+
const cwd = findCwd(event.input);
|
|
790
|
+
collectStructuredPathCandidates(event.input, cwd, 'tool_input', candidates);
|
|
791
|
+
collectShellCommandPathCandidates(event.input, cwd, candidates);
|
|
792
|
+
}
|
|
793
|
+
else if (shouldTrustToolResultPaths(context.toolCall)) {
|
|
794
|
+
const outputCandidates = extractPathCandidatesFromToolOutput(event.output, context.toolCall);
|
|
795
|
+
for (const candidate of outputCandidates) {
|
|
796
|
+
candidates.push(candidate);
|
|
797
|
+
}
|
|
798
|
+
candidates.push(...deriveDirectoryCandidatesFromToolOutput(outputCandidates, context.toolCall));
|
|
799
|
+
}
|
|
800
|
+
return dedupeCandidates(candidates);
|
|
801
|
+
}
|
|
802
|
+
const PATH_FIELD_NAMES = new Set([
|
|
803
|
+
'absolute_path',
|
|
804
|
+
'absolutePath',
|
|
805
|
+
'dir',
|
|
806
|
+
'directory',
|
|
807
|
+
'file',
|
|
808
|
+
'file_name',
|
|
809
|
+
'file_path',
|
|
810
|
+
'fileName',
|
|
811
|
+
'filepath',
|
|
812
|
+
'filePath',
|
|
813
|
+
'files',
|
|
814
|
+
'folder',
|
|
815
|
+
'path',
|
|
816
|
+
'paths',
|
|
817
|
+
]);
|
|
818
|
+
const COMMAND_FIELD_NAMES = new Set(['cmd', 'command', 'shellCommand']);
|
|
819
|
+
const SCRIPT_RUNNER_COMMANDS = new Set(['bash', 'bun', 'deno', 'node', 'python', 'python3', 'sh', 'ts-node', 'tsx', 'zsh']);
|
|
820
|
+
const SHELL_COMMAND_WRAPPERS = new Set(['bash', 'sh', 'zsh']);
|
|
821
|
+
const DIRECTORY_OUTPUT_COMMANDS = new Set(['fd', 'find']);
|
|
822
|
+
const DIRECTORY_COUNT_ONLY_COMMANDS = new Set(['fd', 'find', 'ls']);
|
|
823
|
+
const FILE_OPERAND_COMMANDS = new Set([
|
|
824
|
+
'cat',
|
|
825
|
+
'fd',
|
|
826
|
+
'find',
|
|
827
|
+
'grep',
|
|
828
|
+
'head',
|
|
829
|
+
'less',
|
|
830
|
+
'ls',
|
|
831
|
+
'more',
|
|
832
|
+
'nl',
|
|
833
|
+
'rg',
|
|
834
|
+
'sed',
|
|
835
|
+
'tail',
|
|
836
|
+
'wc',
|
|
837
|
+
...SCRIPT_RUNNER_COMMANDS,
|
|
838
|
+
]);
|
|
839
|
+
const OUTPUT_PATH_COMMANDS = new Set(['fd', 'find', 'grep', 'ls', 'rg']);
|
|
840
|
+
const OPTIONS_WITH_VALUES = new Set([
|
|
841
|
+
'-A',
|
|
842
|
+
'-B',
|
|
843
|
+
'-C',
|
|
844
|
+
'-e',
|
|
845
|
+
'-f',
|
|
846
|
+
'-g',
|
|
847
|
+
'-I',
|
|
848
|
+
'-m',
|
|
849
|
+
'-o',
|
|
850
|
+
'-t',
|
|
851
|
+
'--after-context',
|
|
852
|
+
'--before-context',
|
|
853
|
+
'--color',
|
|
854
|
+
'--config',
|
|
855
|
+
'--context',
|
|
856
|
+
'--encoding',
|
|
857
|
+
'--exclude',
|
|
858
|
+
'--exclude-dir',
|
|
859
|
+
'--glob',
|
|
860
|
+
'--ignore-file',
|
|
861
|
+
'--include',
|
|
862
|
+
'--input',
|
|
863
|
+
'--max-count',
|
|
864
|
+
'--max-depth',
|
|
865
|
+
'--output',
|
|
866
|
+
'--path-separator',
|
|
867
|
+
'--regexp',
|
|
868
|
+
'--sort',
|
|
869
|
+
'--type',
|
|
870
|
+
]);
|
|
871
|
+
function collectStructuredPathCandidates(value, cwd, source, out, fieldName) {
|
|
872
|
+
if (typeof value === 'string') {
|
|
873
|
+
if (fieldName && PATH_FIELD_NAMES.has(fieldName)) {
|
|
874
|
+
out.push(...extractPathValueCandidates(value, cwd, source));
|
|
875
|
+
}
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (Array.isArray(value)) {
|
|
879
|
+
for (const item of value)
|
|
880
|
+
collectStructuredPathCandidates(item, cwd, source, out, fieldName);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (!value || typeof value !== 'object')
|
|
884
|
+
return;
|
|
885
|
+
const record = value;
|
|
886
|
+
const nestedCwd = typeof record.cwd === 'string'
|
|
887
|
+
? normalizeAbsolutePath(record.cwd)?.absolutePath ?? cwd
|
|
888
|
+
: cwd;
|
|
889
|
+
for (const [key, item] of Object.entries(record)) {
|
|
890
|
+
if (key === 'cwd')
|
|
891
|
+
continue;
|
|
892
|
+
collectStructuredPathCandidates(item, nestedCwd, source, out, key);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function extractPathValueCandidates(value, cwd, source) {
|
|
896
|
+
const cleaned = cleanPathToken(value);
|
|
897
|
+
const normalizedAbsolute = normalizeAbsolutePath(cleaned);
|
|
898
|
+
if (normalizedAbsolute) {
|
|
899
|
+
return [{
|
|
900
|
+
absolutePath: normalizedAbsolute.absolutePath,
|
|
901
|
+
kind: inferPathKind(cleaned, cleaned),
|
|
902
|
+
source,
|
|
903
|
+
}];
|
|
904
|
+
}
|
|
905
|
+
if (!cwd || !isRelativeCandidate(cleaned))
|
|
906
|
+
return [];
|
|
907
|
+
const normalizedRelative = normalizeRelativePath(cwd, cleaned);
|
|
908
|
+
if (!normalizedRelative)
|
|
909
|
+
return [];
|
|
910
|
+
return [{
|
|
911
|
+
absolutePath: normalizedRelative,
|
|
912
|
+
kind: inferPathKind(cleaned, cleaned),
|
|
913
|
+
source,
|
|
914
|
+
}];
|
|
915
|
+
}
|
|
916
|
+
function collectShellCommandPathCandidates(value, fallbackCwd, out) {
|
|
917
|
+
if (!value || typeof value !== 'object')
|
|
918
|
+
return;
|
|
919
|
+
if (Array.isArray(value)) {
|
|
920
|
+
for (const item of value)
|
|
921
|
+
collectShellCommandPathCandidates(item, fallbackCwd, out);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const record = value;
|
|
925
|
+
const cwd = typeof record.cwd === 'string'
|
|
926
|
+
? normalizeAbsolutePath(record.cwd)?.absolutePath ?? fallbackCwd
|
|
927
|
+
: fallbackCwd;
|
|
928
|
+
for (const [key, item] of Object.entries(record)) {
|
|
929
|
+
if (COMMAND_FIELD_NAMES.has(key) && typeof item === 'string') {
|
|
930
|
+
out.push(...extractShellCommandCandidates(item, cwd));
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
collectShellCommandPathCandidates(item, cwd, out);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function shouldTrustToolResultPaths(toolCall) {
|
|
937
|
+
if (!toolCall)
|
|
938
|
+
return false;
|
|
939
|
+
if (isFileSemanticToolName(toolCall.name))
|
|
940
|
+
return true;
|
|
941
|
+
return getShellCommandNames(toolCall.input).some((commandName) => OUTPUT_PATH_COMMANDS.has(commandName));
|
|
942
|
+
}
|
|
943
|
+
function isDirectoryOutputToolCall(toolCall) {
|
|
944
|
+
return getShellCommandNames(toolCall.input).some((commandName) => DIRECTORY_OUTPUT_COMMANDS.has(commandName));
|
|
945
|
+
}
|
|
946
|
+
function isDirectoryOnlyEnumerationToolCall(toolCall) {
|
|
947
|
+
return inputHasDirectoryOnlyEnumeration(toolCall.input);
|
|
948
|
+
}
|
|
949
|
+
function isFileSemanticToolName(name) {
|
|
950
|
+
return /(?:^|[_.-])(?:read|list|open|stat|glob|search|grep|find)(?:[_-]?(?:file|files|path|paths|dir|directory|workspace))?/i.test(name)
|
|
951
|
+
|| /(?:file|workspace|fs|path|directory)(?:[_-]?(?:read|list|open|stat|glob|search|grep|find))?/i.test(name);
|
|
952
|
+
}
|
|
953
|
+
function getShellCommandNames(value) {
|
|
954
|
+
const names = [];
|
|
955
|
+
const collectSegmentName = (segment, depth = 0) => {
|
|
956
|
+
const commandName = normalizeShellCommandName(segment);
|
|
957
|
+
if (!commandName)
|
|
958
|
+
return;
|
|
959
|
+
names.push(commandName);
|
|
960
|
+
if (depth >= 4 || !SHELL_COMMAND_WRAPPERS.has(commandName))
|
|
961
|
+
return;
|
|
962
|
+
const nestedCommand = extractNestedShellCommand(segment);
|
|
963
|
+
if (!nestedCommand)
|
|
964
|
+
return;
|
|
965
|
+
for (const nestedSegment of splitShellSegments(tokenizeShellCommand(nestedCommand))) {
|
|
966
|
+
collectSegmentName(nestedSegment, depth + 1);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
const visit = (item) => {
|
|
970
|
+
if (!item || typeof item !== 'object')
|
|
971
|
+
return;
|
|
972
|
+
if (Array.isArray(item)) {
|
|
973
|
+
for (const child of item)
|
|
974
|
+
visit(child);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
for (const [key, child] of Object.entries(item)) {
|
|
978
|
+
if (COMMAND_FIELD_NAMES.has(key) && typeof child === 'string') {
|
|
979
|
+
for (const segment of splitShellSegments(tokenizeShellCommand(child))) {
|
|
980
|
+
collectSegmentName(segment);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
visit(child);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
visit(value);
|
|
989
|
+
return names;
|
|
990
|
+
}
|
|
991
|
+
function inputHasDirectoryOnlyEnumeration(value) {
|
|
992
|
+
if (!value || typeof value !== 'object')
|
|
993
|
+
return false;
|
|
994
|
+
if (Array.isArray(value))
|
|
995
|
+
return value.some((item) => inputHasDirectoryOnlyEnumeration(item));
|
|
996
|
+
for (const [key, child] of Object.entries(value)) {
|
|
997
|
+
if (COMMAND_FIELD_NAMES.has(key) && typeof child === 'string') {
|
|
998
|
+
if (commandHasDirectoryOnlyEnumeration(child))
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
else if (inputHasDirectoryOnlyEnumeration(child)) {
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
function commandHasDirectoryOnlyEnumeration(command) {
|
|
1008
|
+
let sawDirectoryOnly = false;
|
|
1009
|
+
let sawOtherDirectoryOutput = false;
|
|
1010
|
+
for (const segment of splitShellSegments(tokenizeShellCommand(command))) {
|
|
1011
|
+
const commandName = normalizeShellCommandName(segment);
|
|
1012
|
+
if (!commandName)
|
|
1013
|
+
continue;
|
|
1014
|
+
if (SHELL_COMMAND_WRAPPERS.has(commandName)) {
|
|
1015
|
+
const nestedCommand = extractNestedShellCommand(segment);
|
|
1016
|
+
if (!nestedCommand)
|
|
1017
|
+
continue;
|
|
1018
|
+
const nestedMode = commandDirectoryEnumerationMode(nestedCommand);
|
|
1019
|
+
if (nestedMode === 'directory_only')
|
|
1020
|
+
sawDirectoryOnly = true;
|
|
1021
|
+
if (nestedMode === 'mixed_or_unknown')
|
|
1022
|
+
sawOtherDirectoryOutput = true;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (!DIRECTORY_OUTPUT_COMMANDS.has(commandName))
|
|
1026
|
+
continue;
|
|
1027
|
+
if (segmentHasDirectoryOnlyTypeFilter(commandName, segment)) {
|
|
1028
|
+
sawDirectoryOnly = true;
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
sawOtherDirectoryOutput = true;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return sawDirectoryOnly && !sawOtherDirectoryOutput;
|
|
1035
|
+
}
|
|
1036
|
+
function extractShellCountOnlyDirectoryRoots(command, cwd) {
|
|
1037
|
+
const roots = [];
|
|
1038
|
+
const segments = splitShellPipelineSegments(tokenizeShellCommandPreservingPipes(command));
|
|
1039
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
1040
|
+
const segment = segments[index];
|
|
1041
|
+
const commandName = normalizeShellCommandName(segment.tokens);
|
|
1042
|
+
if (!commandName)
|
|
1043
|
+
continue;
|
|
1044
|
+
if (SHELL_COMMAND_WRAPPERS.has(commandName)) {
|
|
1045
|
+
const nestedCommand = extractNestedShellCommand(segment.tokens);
|
|
1046
|
+
if (nestedCommand)
|
|
1047
|
+
roots.push(...extractShellCountOnlyDirectoryRoots(nestedCommand, cwd));
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (!DIRECTORY_COUNT_ONLY_COMMANDS.has(commandName))
|
|
1051
|
+
continue;
|
|
1052
|
+
const next = segments[index + 1];
|
|
1053
|
+
if (!segment.pipeToNext || !next || !isWcLineCountOnlySegment(next.tokens))
|
|
1054
|
+
continue;
|
|
1055
|
+
roots.push(...extractShellDirectoryRootsFromSegment(commandName, segment.tokens, cwd));
|
|
1056
|
+
}
|
|
1057
|
+
return [...new Set(roots)];
|
|
1058
|
+
}
|
|
1059
|
+
function extractShellDirectoryRootsFromSegment(commandName, segment, cwd) {
|
|
1060
|
+
const roots = [];
|
|
1061
|
+
const operands = collectShellOperands(segment);
|
|
1062
|
+
const pathOperands = commandName === 'ls'
|
|
1063
|
+
? selectCountOnlyLsRootOperands(operands, segment)
|
|
1064
|
+
: selectPathOperandsForCommand(commandName, operands, segment);
|
|
1065
|
+
for (const operand of pathOperands) {
|
|
1066
|
+
const root = commandName === 'ls'
|
|
1067
|
+
? absolutePathFromLsDirectoryRootToken(operand, cwd)
|
|
1068
|
+
: absolutePathFromShellToken(operand, cwd, commandName);
|
|
1069
|
+
if (root)
|
|
1070
|
+
roots.push(root);
|
|
1071
|
+
}
|
|
1072
|
+
if (pathOperands.length === 0
|
|
1073
|
+
&& cwd
|
|
1074
|
+
&& (commandName === 'find'
|
|
1075
|
+
|| commandName === 'fd'
|
|
1076
|
+
|| (operands.length === 0 && !segmentHasLsDirectorySelfOption(segment)))) {
|
|
1077
|
+
roots.push(cwd);
|
|
1078
|
+
}
|
|
1079
|
+
return [...new Set(roots)];
|
|
1080
|
+
}
|
|
1081
|
+
function selectCountOnlyLsRootOperands(operands, segment) {
|
|
1082
|
+
if (segmentHasLsDirectorySelfOption(segment))
|
|
1083
|
+
return [];
|
|
1084
|
+
return operands.filter((operand) => isLsDirectoryRootOperand(operand));
|
|
1085
|
+
}
|
|
1086
|
+
function segmentHasLsDirectorySelfOption(segment) {
|
|
1087
|
+
return segment.some((token) => token === '--directory' || /^-[A-Za-z]*d[A-Za-z]*$/.test(token));
|
|
1088
|
+
}
|
|
1089
|
+
function isWcLineCountOnlySegment(segment) {
|
|
1090
|
+
const commandIndex = commandTokenIndex(segment);
|
|
1091
|
+
if (commandIndex < 0)
|
|
1092
|
+
return false;
|
|
1093
|
+
const commandName = normalizeShellCommandName(segment);
|
|
1094
|
+
if (commandName !== 'wc')
|
|
1095
|
+
return false;
|
|
1096
|
+
let hasLineCount = false;
|
|
1097
|
+
for (let index = commandIndex + 1; index < segment.length; index += 1) {
|
|
1098
|
+
const token = segment[index];
|
|
1099
|
+
if (isShellRedirectionToken(token))
|
|
1100
|
+
continue;
|
|
1101
|
+
if (isShellRedirectionOperator(token)) {
|
|
1102
|
+
index += 1;
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (token === '--')
|
|
1106
|
+
continue;
|
|
1107
|
+
if (token === '-l' || token === '--lines') {
|
|
1108
|
+
hasLineCount = true;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (/^-[A-Za-z]*l[A-Za-z]*$/.test(token)) {
|
|
1112
|
+
hasLineCount = true;
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (token.startsWith('-'))
|
|
1116
|
+
continue;
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
return hasLineCount;
|
|
1120
|
+
}
|
|
1121
|
+
function commandDirectoryEnumerationMode(command) {
|
|
1122
|
+
let sawDirectoryOnly = false;
|
|
1123
|
+
for (const segment of splitShellSegments(tokenizeShellCommand(command))) {
|
|
1124
|
+
const commandName = normalizeShellCommandName(segment);
|
|
1125
|
+
if (!commandName)
|
|
1126
|
+
continue;
|
|
1127
|
+
if (SHELL_COMMAND_WRAPPERS.has(commandName)) {
|
|
1128
|
+
const nestedCommand = extractNestedShellCommand(segment);
|
|
1129
|
+
if (!nestedCommand)
|
|
1130
|
+
continue;
|
|
1131
|
+
const nestedMode = commandDirectoryEnumerationMode(nestedCommand);
|
|
1132
|
+
if (nestedMode === 'mixed_or_unknown')
|
|
1133
|
+
return nestedMode;
|
|
1134
|
+
if (nestedMode === 'directory_only')
|
|
1135
|
+
sawDirectoryOnly = true;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (!DIRECTORY_OUTPUT_COMMANDS.has(commandName))
|
|
1139
|
+
continue;
|
|
1140
|
+
if (!segmentHasDirectoryOnlyTypeFilter(commandName, segment))
|
|
1141
|
+
return 'mixed_or_unknown';
|
|
1142
|
+
sawDirectoryOnly = true;
|
|
1143
|
+
}
|
|
1144
|
+
return sawDirectoryOnly ? 'directory_only' : 'none';
|
|
1145
|
+
}
|
|
1146
|
+
function extractShellCommandCandidates(command, cwd) {
|
|
1147
|
+
const candidates = [];
|
|
1148
|
+
for (const segment of splitShellSegments(tokenizeShellCommand(command))) {
|
|
1149
|
+
const commandName = normalizeShellCommandName(segment);
|
|
1150
|
+
if (!commandName || !FILE_OPERAND_COMMANDS.has(commandName))
|
|
1151
|
+
continue;
|
|
1152
|
+
if (SHELL_COMMAND_WRAPPERS.has(commandName)) {
|
|
1153
|
+
const nestedCommand = extractNestedShellCommand(segment);
|
|
1154
|
+
if (nestedCommand) {
|
|
1155
|
+
candidates.push(...extractShellCommandCandidates(nestedCommand, cwd));
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const operands = collectShellOperands(segment);
|
|
1160
|
+
const pathOperands = selectPathOperandsForCommand(commandName, operands, segment);
|
|
1161
|
+
for (const operand of pathOperands) {
|
|
1162
|
+
const candidate = pathCandidateFromShellToken(operand, cwd, commandName);
|
|
1163
|
+
if (candidate)
|
|
1164
|
+
candidates.push(candidate);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return candidates;
|
|
1168
|
+
}
|
|
1169
|
+
function extractShellDirectoryRoots(command, cwd) {
|
|
1170
|
+
const roots = [];
|
|
1171
|
+
for (const segment of splitShellSegments(tokenizeShellCommand(command))) {
|
|
1172
|
+
const commandName = normalizeShellCommandName(segment);
|
|
1173
|
+
if (!commandName)
|
|
1174
|
+
continue;
|
|
1175
|
+
if (SHELL_COMMAND_WRAPPERS.has(commandName)) {
|
|
1176
|
+
const nestedCommand = extractNestedShellCommand(segment);
|
|
1177
|
+
if (nestedCommand)
|
|
1178
|
+
roots.push(...extractShellDirectoryRoots(nestedCommand, cwd));
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (!DIRECTORY_OUTPUT_COMMANDS.has(commandName))
|
|
1182
|
+
continue;
|
|
1183
|
+
const operands = collectShellOperands(segment);
|
|
1184
|
+
const pathOperands = selectPathOperandsForCommand(commandName, operands, segment);
|
|
1185
|
+
for (const operand of pathOperands) {
|
|
1186
|
+
const root = absolutePathFromShellToken(operand, cwd, commandName);
|
|
1187
|
+
if (root)
|
|
1188
|
+
roots.push(root);
|
|
1189
|
+
}
|
|
1190
|
+
if (pathOperands.length === 0 && cwd)
|
|
1191
|
+
roots.push(cwd);
|
|
1192
|
+
}
|
|
1193
|
+
return roots;
|
|
1194
|
+
}
|
|
1195
|
+
function extractNestedShellCommand(tokens) {
|
|
1196
|
+
let commandSeen = false;
|
|
1197
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1198
|
+
const token = tokens[index];
|
|
1199
|
+
if (!commandSeen) {
|
|
1200
|
+
if (isShellAssignment(token) || token === 'env' || token === 'command' || token === 'time')
|
|
1201
|
+
continue;
|
|
1202
|
+
commandSeen = true;
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (token === '-c' || token === '-lc' || token === '-ec' || token === '-elc') {
|
|
1206
|
+
return tokens[index + 1] ?? null;
|
|
1207
|
+
}
|
|
1208
|
+
if (token.startsWith('-') && token.includes('c') && tokens[index + 1]) {
|
|
1209
|
+
return tokens[index + 1];
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
function tokenizeShellCommand(command) {
|
|
1215
|
+
const tokens = [];
|
|
1216
|
+
let current = '';
|
|
1217
|
+
let quote = null;
|
|
1218
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1219
|
+
const char = command[i];
|
|
1220
|
+
if (quote) {
|
|
1221
|
+
if (char === quote) {
|
|
1222
|
+
quote = null;
|
|
1223
|
+
}
|
|
1224
|
+
else if (char === '\\' && quote !== "'" && i + 1 < command.length) {
|
|
1225
|
+
i += 1;
|
|
1226
|
+
current += command[i];
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
current += char;
|
|
1230
|
+
}
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
1234
|
+
quote = char;
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (/\s/.test(char)) {
|
|
1238
|
+
if (current) {
|
|
1239
|
+
tokens.push(current);
|
|
1240
|
+
current = '';
|
|
1241
|
+
}
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
if (char === ';' || char === '|') {
|
|
1245
|
+
if (current) {
|
|
1246
|
+
tokens.push(current);
|
|
1247
|
+
current = '';
|
|
1248
|
+
}
|
|
1249
|
+
if (char === '|' && command[i + 1] === '|')
|
|
1250
|
+
i += 1;
|
|
1251
|
+
tokens.push(';');
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
if (char === '&' && command[i + 1] === '&') {
|
|
1255
|
+
if (current) {
|
|
1256
|
+
tokens.push(current);
|
|
1257
|
+
current = '';
|
|
1258
|
+
}
|
|
1259
|
+
tokens.push(';');
|
|
1260
|
+
i += 1;
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if ((char === '>' || char === '<') && current === '') {
|
|
1264
|
+
const next = command[i + 1];
|
|
1265
|
+
if (next === char) {
|
|
1266
|
+
tokens.push(`${char}${next}`);
|
|
1267
|
+
i += 1;
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
tokens.push(char);
|
|
1271
|
+
}
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
if (char === '\\' && i + 1 < command.length) {
|
|
1275
|
+
i += 1;
|
|
1276
|
+
current += command[i];
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
current += char;
|
|
1280
|
+
}
|
|
1281
|
+
if (current)
|
|
1282
|
+
tokens.push(current);
|
|
1283
|
+
return tokens;
|
|
1284
|
+
}
|
|
1285
|
+
function tokenizeShellCommandPreservingPipes(command) {
|
|
1286
|
+
const tokens = [];
|
|
1287
|
+
let current = '';
|
|
1288
|
+
let quote = null;
|
|
1289
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1290
|
+
const char = command[i];
|
|
1291
|
+
if (quote) {
|
|
1292
|
+
if (char === quote) {
|
|
1293
|
+
quote = null;
|
|
1294
|
+
}
|
|
1295
|
+
else if (char === '\\' && quote !== "'" && i + 1 < command.length) {
|
|
1296
|
+
i += 1;
|
|
1297
|
+
current += command[i];
|
|
1298
|
+
}
|
|
1299
|
+
else {
|
|
1300
|
+
current += char;
|
|
1301
|
+
}
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
1305
|
+
quote = char;
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (/\s/.test(char)) {
|
|
1309
|
+
if (current) {
|
|
1310
|
+
tokens.push(current);
|
|
1311
|
+
current = '';
|
|
1312
|
+
}
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (char === ';') {
|
|
1316
|
+
if (current) {
|
|
1317
|
+
tokens.push(current);
|
|
1318
|
+
current = '';
|
|
1319
|
+
}
|
|
1320
|
+
tokens.push(';');
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (char === '|') {
|
|
1324
|
+
if (current) {
|
|
1325
|
+
tokens.push(current);
|
|
1326
|
+
current = '';
|
|
1327
|
+
}
|
|
1328
|
+
if (command[i + 1] === '|') {
|
|
1329
|
+
tokens.push(';');
|
|
1330
|
+
i += 1;
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
tokens.push('|');
|
|
1334
|
+
if (command[i + 1] === '&')
|
|
1335
|
+
i += 1;
|
|
1336
|
+
}
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (char === '&' && command[i + 1] === '&') {
|
|
1340
|
+
if (current) {
|
|
1341
|
+
tokens.push(current);
|
|
1342
|
+
current = '';
|
|
1343
|
+
}
|
|
1344
|
+
tokens.push(';');
|
|
1345
|
+
i += 1;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
if ((char === '>' || char === '<') && current === '') {
|
|
1349
|
+
const next = command[i + 1];
|
|
1350
|
+
if (next === char) {
|
|
1351
|
+
tokens.push(`${char}${next}`);
|
|
1352
|
+
i += 1;
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
tokens.push(char);
|
|
1356
|
+
}
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
if (char === '\\' && i + 1 < command.length) {
|
|
1360
|
+
i += 1;
|
|
1361
|
+
current += command[i];
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
current += char;
|
|
1365
|
+
}
|
|
1366
|
+
if (current)
|
|
1367
|
+
tokens.push(current);
|
|
1368
|
+
return tokens;
|
|
1369
|
+
}
|
|
1370
|
+
function splitShellSegments(tokens) {
|
|
1371
|
+
const segments = [];
|
|
1372
|
+
let current = [];
|
|
1373
|
+
for (const token of tokens) {
|
|
1374
|
+
if (token === ';') {
|
|
1375
|
+
if (current.length > 0)
|
|
1376
|
+
segments.push(current);
|
|
1377
|
+
current = [];
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
current.push(token);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (current.length > 0)
|
|
1384
|
+
segments.push(current);
|
|
1385
|
+
return segments;
|
|
1386
|
+
}
|
|
1387
|
+
function splitShellPipelineSegments(tokens) {
|
|
1388
|
+
const segments = [];
|
|
1389
|
+
let current = [];
|
|
1390
|
+
for (const token of tokens) {
|
|
1391
|
+
if (token === ';' || token === '|') {
|
|
1392
|
+
if (current.length > 0) {
|
|
1393
|
+
segments.push({ tokens: current, pipeToNext: token === '|' });
|
|
1394
|
+
}
|
|
1395
|
+
current = [];
|
|
1396
|
+
}
|
|
1397
|
+
else {
|
|
1398
|
+
current.push(token);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (current.length > 0)
|
|
1402
|
+
segments.push({ tokens: current, pipeToNext: false });
|
|
1403
|
+
return segments;
|
|
1404
|
+
}
|
|
1405
|
+
function normalizeShellCommandName(tokens) {
|
|
1406
|
+
for (const token of tokens) {
|
|
1407
|
+
if (isShellAssignment(token))
|
|
1408
|
+
continue;
|
|
1409
|
+
if (token === 'env' || token === 'command' || token === 'time')
|
|
1410
|
+
continue;
|
|
1411
|
+
return path.posix.basename(token).toLowerCase();
|
|
1412
|
+
}
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
function collectShellOperands(tokens) {
|
|
1416
|
+
const operands = [];
|
|
1417
|
+
let commandSeen = false;
|
|
1418
|
+
let skipNext = false;
|
|
1419
|
+
for (const token of tokens) {
|
|
1420
|
+
if (!commandSeen) {
|
|
1421
|
+
if (isShellAssignment(token) || token === 'env' || token === 'command' || token === 'time')
|
|
1422
|
+
continue;
|
|
1423
|
+
commandSeen = true;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (skipNext) {
|
|
1427
|
+
skipNext = false;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (token === '--')
|
|
1431
|
+
continue;
|
|
1432
|
+
if (isShellRedirectionToken(token))
|
|
1433
|
+
continue;
|
|
1434
|
+
if (isShellRedirectionOperator(token)) {
|
|
1435
|
+
skipNext = true;
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
if (token.startsWith('-')) {
|
|
1439
|
+
const optionName = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
|
1440
|
+
if (OPTIONS_WITH_VALUES.has(optionName))
|
|
1441
|
+
skipNext = !token.includes('=');
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
operands.push(token);
|
|
1445
|
+
}
|
|
1446
|
+
return operands;
|
|
1447
|
+
}
|
|
1448
|
+
function selectPathOperandsForCommand(commandName, operands, segment) {
|
|
1449
|
+
if (SCRIPT_RUNNER_COMMANDS.has(commandName)) {
|
|
1450
|
+
if (segment.includes('-m'))
|
|
1451
|
+
return [];
|
|
1452
|
+
const firstPath = operands.find((operand) => isShellPathLike(operand));
|
|
1453
|
+
return firstPath ? [firstPath] : [];
|
|
1454
|
+
}
|
|
1455
|
+
if (commandName === 'find') {
|
|
1456
|
+
return collectFindRootOperands(segment);
|
|
1457
|
+
}
|
|
1458
|
+
if (commandName === 'fd') {
|
|
1459
|
+
return collectFdRootOperands(operands);
|
|
1460
|
+
}
|
|
1461
|
+
if ((commandName === 'rg' || commandName === 'grep') && !segment.includes('--files')) {
|
|
1462
|
+
const pathOperands = operands.filter((operand) => isShellPathLike(operand));
|
|
1463
|
+
if (pathOperands.length === 0)
|
|
1464
|
+
return [];
|
|
1465
|
+
const firstOperand = operands.find((operand) => !operand.startsWith('-'));
|
|
1466
|
+
return firstOperand && pathOperands[0] === firstOperand ? pathOperands.slice(1) : pathOperands;
|
|
1467
|
+
}
|
|
1468
|
+
return operands.filter((operand) => isShellPathLike(operand));
|
|
1469
|
+
}
|
|
1470
|
+
function segmentHasDirectoryOnlyTypeFilter(commandName, segment) {
|
|
1471
|
+
const types = commandName === 'find'
|
|
1472
|
+
? collectFindTypeFilters(segment)
|
|
1473
|
+
: commandName === 'fd'
|
|
1474
|
+
? collectFdTypeFilters(segment)
|
|
1475
|
+
: [];
|
|
1476
|
+
return types.length > 0 && types.every((type) => type === 'directory');
|
|
1477
|
+
}
|
|
1478
|
+
function collectFindTypeFilters(segment) {
|
|
1479
|
+
const commandIndex = commandTokenIndex(segment);
|
|
1480
|
+
if (commandIndex < 0)
|
|
1481
|
+
return [];
|
|
1482
|
+
const types = [];
|
|
1483
|
+
for (let index = commandIndex + 1; index < segment.length; index += 1) {
|
|
1484
|
+
const token = segment[index];
|
|
1485
|
+
if (token === '-type' && segment[index + 1]) {
|
|
1486
|
+
types.push(normalizeFindType(segment[index + 1]));
|
|
1487
|
+
index += 1;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return types.filter(Boolean);
|
|
1491
|
+
}
|
|
1492
|
+
function normalizeFindType(value) {
|
|
1493
|
+
return value === 'd' ? 'directory' : value;
|
|
1494
|
+
}
|
|
1495
|
+
function collectFdTypeFilters(segment) {
|
|
1496
|
+
const commandIndex = commandTokenIndex(segment);
|
|
1497
|
+
if (commandIndex < 0)
|
|
1498
|
+
return [];
|
|
1499
|
+
const types = [];
|
|
1500
|
+
for (let index = commandIndex + 1; index < segment.length; index += 1) {
|
|
1501
|
+
const token = segment[index];
|
|
1502
|
+
if ((token === '-t' || token === '--type') && segment[index + 1]) {
|
|
1503
|
+
types.push(normalizeFdType(segment[index + 1]));
|
|
1504
|
+
index += 1;
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (token.startsWith('--type=')) {
|
|
1508
|
+
types.push(normalizeFdType(token.slice('--type='.length)));
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
const shortMatch = token.match(/^-t(.+)$/);
|
|
1512
|
+
if (shortMatch) {
|
|
1513
|
+
types.push(...shortMatch[1].split('').map(normalizeFdType));
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return types.filter(Boolean);
|
|
1517
|
+
}
|
|
1518
|
+
function normalizeFdType(value) {
|
|
1519
|
+
const normalized = value.toLowerCase();
|
|
1520
|
+
if (normalized === 'd' || normalized === 'dir' || normalized === 'directory')
|
|
1521
|
+
return 'directory';
|
|
1522
|
+
return normalized;
|
|
1523
|
+
}
|
|
1524
|
+
function pathCandidateFromShellToken(token, cwd, commandName) {
|
|
1525
|
+
if (!isShellPathLike(token) && !isDirectoryRootOperand(commandName, token))
|
|
1526
|
+
return null;
|
|
1527
|
+
const kind = inferShellOperandKind(commandName, token);
|
|
1528
|
+
const absolute = normalizeAbsolutePath(token);
|
|
1529
|
+
if (absolute) {
|
|
1530
|
+
return {
|
|
1531
|
+
absolutePath: absolute.absolutePath,
|
|
1532
|
+
kind,
|
|
1533
|
+
source: 'tool_input',
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
if (!cwd)
|
|
1537
|
+
return null;
|
|
1538
|
+
const relative = normalizeRelativePath(cwd, token);
|
|
1539
|
+
if (!relative)
|
|
1540
|
+
return null;
|
|
1541
|
+
return {
|
|
1542
|
+
absolutePath: relative,
|
|
1543
|
+
kind,
|
|
1544
|
+
source: 'tool_input',
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
function inferShellOperandKind(commandName, token) {
|
|
1548
|
+
const kind = inferPathKind(token, token);
|
|
1549
|
+
if (isDirectoryRootOperand(commandName, token) && kind === 'unknown')
|
|
1550
|
+
return 'directory';
|
|
1551
|
+
void commandName;
|
|
1552
|
+
return kind;
|
|
1553
|
+
}
|
|
1554
|
+
function absolutePathFromShellToken(token, cwd, commandName) {
|
|
1555
|
+
if (!isShellPathLike(token) && !isDirectoryRootOperand(commandName, token))
|
|
1556
|
+
return null;
|
|
1557
|
+
const absolute = normalizeAbsolutePath(token);
|
|
1558
|
+
if (absolute)
|
|
1559
|
+
return absolute.absolutePath;
|
|
1560
|
+
if (!cwd)
|
|
1561
|
+
return null;
|
|
1562
|
+
return normalizeRelativePath(cwd, token);
|
|
1563
|
+
}
|
|
1564
|
+
function absolutePathFromLsDirectoryRootToken(token, cwd) {
|
|
1565
|
+
if (!isLsDirectoryRootOperand(token))
|
|
1566
|
+
return null;
|
|
1567
|
+
const absolute = normalizeAbsolutePath(token);
|
|
1568
|
+
if (absolute)
|
|
1569
|
+
return absolute.absolutePath;
|
|
1570
|
+
if (!cwd)
|
|
1571
|
+
return null;
|
|
1572
|
+
if (token === '.')
|
|
1573
|
+
return normalizeAbsolutePath(cwd)?.absolutePath ?? null;
|
|
1574
|
+
return normalizeRelativePath(cwd, token);
|
|
1575
|
+
}
|
|
1576
|
+
function isShellPathLike(token) {
|
|
1577
|
+
const cleaned = cleanPathToken(token);
|
|
1578
|
+
if (!cleaned || cleaned.includes('*') || cleaned.includes('?') || cleaned.includes('[') || cleaned.includes(']')) {
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
return Boolean(normalizeAbsolutePath(cleaned)) || isRelativeCandidate(cleaned);
|
|
1582
|
+
}
|
|
1583
|
+
function isDirectoryRootOperand(commandName, token) {
|
|
1584
|
+
if (commandName !== 'find' && commandName !== 'fd')
|
|
1585
|
+
return false;
|
|
1586
|
+
const cleaned = cleanPathToken(token).replace(/^\.\//, '');
|
|
1587
|
+
if (!cleaned || cleaned.startsWith('-') || cleaned.includes('://') || hasParentPathSegment(cleaned))
|
|
1588
|
+
return false;
|
|
1589
|
+
if (cleaned.includes('*') || cleaned.includes('?') || cleaned.includes('[') || cleaned.includes(']'))
|
|
1590
|
+
return false;
|
|
1591
|
+
if (cleaned === '.')
|
|
1592
|
+
return true;
|
|
1593
|
+
if (normalizeAbsolutePath(cleaned) || isRelativeCandidate(cleaned))
|
|
1594
|
+
return true;
|
|
1595
|
+
if (cleaned.includes('/'))
|
|
1596
|
+
return true;
|
|
1597
|
+
if (SINGLE_FILE_NAMES.has(cleaned) || FILE_EXTENSION_RE.test(cleaned))
|
|
1598
|
+
return false;
|
|
1599
|
+
return /^[A-Za-z0-9._~@%+=,-]+$/.test(cleaned);
|
|
1600
|
+
}
|
|
1601
|
+
function isLsDirectoryRootOperand(token) {
|
|
1602
|
+
const rawCleaned = cleanPathToken(token);
|
|
1603
|
+
const cleaned = rawCleaned.replace(/^\.\//, '');
|
|
1604
|
+
if (!cleaned || cleaned.startsWith('-') || cleaned.includes('://') || hasParentPathSegment(cleaned))
|
|
1605
|
+
return false;
|
|
1606
|
+
if (cleaned.includes('*') || cleaned.includes('?') || cleaned.includes('[') || cleaned.includes(']'))
|
|
1607
|
+
return false;
|
|
1608
|
+
if (cleaned === '.')
|
|
1609
|
+
return true;
|
|
1610
|
+
if (rawCleaned.endsWith('/'))
|
|
1611
|
+
return true;
|
|
1612
|
+
if (normalizeAbsolutePath(cleaned))
|
|
1613
|
+
return true;
|
|
1614
|
+
if (rawCleaned.startsWith('./') && /^[A-Za-z0-9._~@%+=,-]+$/.test(cleaned))
|
|
1615
|
+
return true;
|
|
1616
|
+
return cleaned.includes('/') && isRelativeCandidate(cleaned);
|
|
1617
|
+
}
|
|
1618
|
+
function commandTokenIndex(tokens) {
|
|
1619
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1620
|
+
const token = tokens[index];
|
|
1621
|
+
if (isShellAssignment(token) || token === 'env' || token === 'command' || token === 'time')
|
|
1622
|
+
continue;
|
|
1623
|
+
return index;
|
|
1624
|
+
}
|
|
1625
|
+
return -1;
|
|
1626
|
+
}
|
|
1627
|
+
function collectFindRootOperands(segment) {
|
|
1628
|
+
const commandIndex = commandTokenIndex(segment);
|
|
1629
|
+
if (commandIndex < 0)
|
|
1630
|
+
return [];
|
|
1631
|
+
const roots = [];
|
|
1632
|
+
for (let index = commandIndex + 1; index < segment.length; index += 1) {
|
|
1633
|
+
const token = segment[index];
|
|
1634
|
+
if (token === '--')
|
|
1635
|
+
continue;
|
|
1636
|
+
if (isShellRedirectionToken(token))
|
|
1637
|
+
continue;
|
|
1638
|
+
if (isShellRedirectionOperator(token))
|
|
1639
|
+
break;
|
|
1640
|
+
if (isFindLeadingOption(token)) {
|
|
1641
|
+
if (token === '-D' || token === '-O')
|
|
1642
|
+
index += 1;
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (/^-O\d+$/.test(token) || token.startsWith('-D'))
|
|
1646
|
+
continue;
|
|
1647
|
+
if (token.startsWith('-') || token === '(' || token === ')' || token === '!')
|
|
1648
|
+
break;
|
|
1649
|
+
if (isDirectoryRootOperand('find', token))
|
|
1650
|
+
roots.push(token);
|
|
1651
|
+
}
|
|
1652
|
+
return roots;
|
|
1653
|
+
}
|
|
1654
|
+
function isFindLeadingOption(token) {
|
|
1655
|
+
return token === '-H' || token === '-L' || token === '-P' || token === '-D' || token === '-O';
|
|
1656
|
+
}
|
|
1657
|
+
function collectFdRootOperands(operands) {
|
|
1658
|
+
if (operands.length < 2)
|
|
1659
|
+
return [];
|
|
1660
|
+
return operands.slice(1).filter((operand) => isDirectoryRootOperand('fd', operand));
|
|
1661
|
+
}
|
|
1662
|
+
function isShellAssignment(token) {
|
|
1663
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
|
1664
|
+
}
|
|
1665
|
+
function isShellRedirectionOperator(token) {
|
|
1666
|
+
return /^(\d*)?(?:>|>>|<|<>|>&|<&|&>|&>>)$/.test(token);
|
|
1667
|
+
}
|
|
1668
|
+
function isShellRedirectionToken(token) {
|
|
1669
|
+
return /^(\d*)?(?:>|>>|<|<>|>&|<&|&>|&>>).+/.test(token);
|
|
1670
|
+
}
|
|
1671
|
+
function deriveDirectoryCandidatesFromToolOutput(outputCandidates, toolCall) {
|
|
1672
|
+
if (!toolCall)
|
|
1673
|
+
return [];
|
|
1674
|
+
const cwd = findCwd(toolCall.input);
|
|
1675
|
+
const roots = [...new Set([
|
|
1676
|
+
...collectShellDirectoryRootPaths(toolCall.input, cwd),
|
|
1677
|
+
...collectShellCountOnlyDirectoryRootPaths(toolCall.input, cwd),
|
|
1678
|
+
])];
|
|
1679
|
+
if (roots.length === 0)
|
|
1680
|
+
return [];
|
|
1681
|
+
const directories = roots.map((root) => ({
|
|
1682
|
+
absolutePath: root,
|
|
1683
|
+
kind: 'directory',
|
|
1684
|
+
source: 'tool_output_directory',
|
|
1685
|
+
}));
|
|
1686
|
+
for (const candidate of outputCandidates) {
|
|
1687
|
+
for (const directory of parentDirectoriesWithinRoots(candidate.absolutePath, roots)) {
|
|
1688
|
+
directories.push({
|
|
1689
|
+
absolutePath: directory,
|
|
1690
|
+
kind: 'directory',
|
|
1691
|
+
source: 'tool_output_directory',
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return dedupeCandidates(directories);
|
|
1696
|
+
}
|
|
1697
|
+
function collectShellDirectoryRootPaths(value, fallbackCwd) {
|
|
1698
|
+
if (!value || typeof value !== 'object')
|
|
1699
|
+
return [];
|
|
1700
|
+
if (Array.isArray(value))
|
|
1701
|
+
return value.flatMap((item) => collectShellDirectoryRootPaths(item, fallbackCwd));
|
|
1702
|
+
const record = value;
|
|
1703
|
+
const cwd = typeof record.cwd === 'string'
|
|
1704
|
+
? normalizeAbsolutePath(record.cwd)?.absolutePath ?? fallbackCwd
|
|
1705
|
+
: fallbackCwd;
|
|
1706
|
+
const roots = [];
|
|
1707
|
+
for (const [key, item] of Object.entries(record)) {
|
|
1708
|
+
if (COMMAND_FIELD_NAMES.has(key) && typeof item === 'string') {
|
|
1709
|
+
roots.push(...extractShellDirectoryRoots(item, cwd));
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
roots.push(...collectShellDirectoryRootPaths(item, cwd));
|
|
1713
|
+
}
|
|
1714
|
+
return [...new Set(roots)];
|
|
1715
|
+
}
|
|
1716
|
+
function collectShellCountOnlyDirectoryRootPaths(value, fallbackCwd) {
|
|
1717
|
+
if (!value || typeof value !== 'object')
|
|
1718
|
+
return [];
|
|
1719
|
+
if (Array.isArray(value))
|
|
1720
|
+
return value.flatMap((item) => collectShellCountOnlyDirectoryRootPaths(item, fallbackCwd));
|
|
1721
|
+
const record = value;
|
|
1722
|
+
const cwd = typeof record.cwd === 'string'
|
|
1723
|
+
? normalizeAbsolutePath(record.cwd)?.absolutePath ?? fallbackCwd
|
|
1724
|
+
: fallbackCwd;
|
|
1725
|
+
const roots = [];
|
|
1726
|
+
for (const [key, item] of Object.entries(record)) {
|
|
1727
|
+
if (COMMAND_FIELD_NAMES.has(key) && typeof item === 'string') {
|
|
1728
|
+
roots.push(...extractShellCountOnlyDirectoryRoots(item, cwd));
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
roots.push(...collectShellCountOnlyDirectoryRootPaths(item, cwd));
|
|
1732
|
+
}
|
|
1733
|
+
return [...new Set(roots)];
|
|
1734
|
+
}
|
|
1735
|
+
function parentDirectoriesWithinRoots(absolutePath, roots) {
|
|
1736
|
+
const directories = [];
|
|
1737
|
+
let current = path.posix.dirname(absolutePath);
|
|
1738
|
+
while (current && current !== '/' && current !== '.') {
|
|
1739
|
+
const matchingRoot = roots.find((root) => current === root || current.startsWith(`${root}/`));
|
|
1740
|
+
if (!matchingRoot)
|
|
1741
|
+
break;
|
|
1742
|
+
directories.push(current);
|
|
1743
|
+
if (current === matchingRoot)
|
|
1744
|
+
break;
|
|
1745
|
+
current = path.posix.dirname(current);
|
|
1746
|
+
}
|
|
1747
|
+
return directories;
|
|
1748
|
+
}
|
|
1749
|
+
function findCwd(value) {
|
|
1750
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1751
|
+
return null;
|
|
1752
|
+
const cwd = value.cwd;
|
|
1753
|
+
return typeof cwd === 'string' ? normalizeAbsolutePath(cwd)?.absolutePath ?? null : null;
|
|
1754
|
+
}
|
|
1755
|
+
function extractPathCandidatesFromToolOutput(text, toolCall) {
|
|
1756
|
+
const candidates = extractAbsolutePathsFromText(text, 'tool_output');
|
|
1757
|
+
const cwd = toolCall ? findCwd(toolCall.input) : null;
|
|
1758
|
+
if (cwd) {
|
|
1759
|
+
candidates.push(...extractRelativePathsFromText(text, cwd, 'tool_output'));
|
|
1760
|
+
}
|
|
1761
|
+
const outputCandidates = dedupeCandidates(candidates);
|
|
1762
|
+
if (!toolCall || !isDirectoryOnlyEnumerationToolCall(toolCall))
|
|
1763
|
+
return outputCandidates;
|
|
1764
|
+
return outputCandidates.map((candidate) => (candidate.kind === 'unknown'
|
|
1765
|
+
? { ...candidate, kind: 'directory' }
|
|
1766
|
+
: candidate));
|
|
1767
|
+
}
|
|
1768
|
+
function extractAbsolutePathsFromText(text, source) {
|
|
1769
|
+
const candidates = [];
|
|
1770
|
+
ABSOLUTE_PATH_RE.lastIndex = 0;
|
|
1771
|
+
for (const match of text.matchAll(ABSOLUTE_PATH_RE)) {
|
|
1772
|
+
const raw = match[2] ?? '';
|
|
1773
|
+
const normalized = normalizeAbsolutePath(raw);
|
|
1774
|
+
if (!normalized)
|
|
1775
|
+
continue;
|
|
1776
|
+
candidates.push({
|
|
1777
|
+
absolutePath: normalized.absolutePath,
|
|
1778
|
+
kind: inferPathKind(raw, text.slice((match.index ?? 0), (match.index ?? 0) + (match[0]?.length ?? 0))),
|
|
1779
|
+
source,
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
return candidates;
|
|
1783
|
+
}
|
|
1784
|
+
function extractRelativePathsFromText(text, cwd, source) {
|
|
1785
|
+
const candidates = [];
|
|
1786
|
+
RELATIVE_PATH_RE.lastIndex = 0;
|
|
1787
|
+
for (const match of text.matchAll(RELATIVE_PATH_RE)) {
|
|
1788
|
+
const raw = match[2] ?? '';
|
|
1789
|
+
const normalized = normalizeRelativePath(cwd, raw);
|
|
1790
|
+
if (!normalized)
|
|
1791
|
+
continue;
|
|
1792
|
+
candidates.push({
|
|
1793
|
+
absolutePath: normalized,
|
|
1794
|
+
kind: inferPathKind(raw, text.slice((match.index ?? 0), (match.index ?? 0) + (match[0]?.length ?? 0))),
|
|
1795
|
+
source,
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
return candidates;
|
|
1799
|
+
}
|
|
1800
|
+
function normalizeAbsolutePath(raw) {
|
|
1801
|
+
const cleaned = cleanPathToken(raw);
|
|
1802
|
+
if (!cleaned || cleaned.includes('://') || hasParentPathSegment(cleaned) || !cleaned.startsWith('/'))
|
|
1803
|
+
return null;
|
|
1804
|
+
const normalized = normalizePathWithoutTrailingSlash(cleaned);
|
|
1805
|
+
if (!normalized || normalized === '/' || hasParentPathSegment(normalized))
|
|
1806
|
+
return null;
|
|
1807
|
+
return { absolutePath: normalized };
|
|
1808
|
+
}
|
|
1809
|
+
function normalizeRelativePath(cwd, raw) {
|
|
1810
|
+
const cleaned = cleanPathToken(raw).replace(/^\.\//, '');
|
|
1811
|
+
if (!cleaned || cleaned.startsWith('/') || cleaned.startsWith('-') || cleaned.includes('://') || hasParentPathSegment(cleaned))
|
|
1812
|
+
return null;
|
|
1813
|
+
const base = normalizeAbsolutePath(cwd);
|
|
1814
|
+
if (!base)
|
|
1815
|
+
return null;
|
|
1816
|
+
return normalizePathWithoutTrailingSlash(path.posix.join(base.absolutePath, cleaned));
|
|
1817
|
+
}
|
|
1818
|
+
function normalizePathWithoutTrailingSlash(value) {
|
|
1819
|
+
const normalized = path.posix.normalize(value);
|
|
1820
|
+
return normalized === '/' ? normalized : normalized.replace(/\/+$/g, '');
|
|
1821
|
+
}
|
|
1822
|
+
function cleanPathToken(raw) {
|
|
1823
|
+
return raw
|
|
1824
|
+
.trim()
|
|
1825
|
+
.replace(/^[\s'"`(<\[]+/g, '')
|
|
1826
|
+
.replace(/[\s'"`),.;!?>\]]+$/g, '')
|
|
1827
|
+
.replace(/:(\d+)(?::\d+)?$/g, '');
|
|
1828
|
+
}
|
|
1829
|
+
function inferPathKind(pathToken, fullToken) {
|
|
1830
|
+
if (pathToken.endsWith('/'))
|
|
1831
|
+
return 'directory';
|
|
1832
|
+
if (/:\d+(?::\d+)?$/.test(fullToken))
|
|
1833
|
+
return 'file';
|
|
1834
|
+
const basename = path.posix.basename(cleanPathToken(pathToken));
|
|
1835
|
+
if (SINGLE_FILE_NAMES.has(basename) || FILE_EXTENSION_RE.test(basename))
|
|
1836
|
+
return 'file';
|
|
1837
|
+
return 'unknown';
|
|
1838
|
+
}
|
|
1839
|
+
function hasParentPathSegment(value) {
|
|
1840
|
+
return value.split(/[\\/]+/).some((segment) => segment === '..');
|
|
1841
|
+
}
|
|
1842
|
+
function isRelativeCandidate(raw) {
|
|
1843
|
+
const cleaned = cleanPathToken(raw);
|
|
1844
|
+
if (!cleaned || cleaned.startsWith('-') || cleaned.startsWith('/') || cleaned.includes('://') || hasParentPathSegment(cleaned))
|
|
1845
|
+
return false;
|
|
1846
|
+
if (cleaned.includes('/'))
|
|
1847
|
+
return true;
|
|
1848
|
+
return SINGLE_FILE_NAMES.has(cleaned) || FILE_EXTENSION_RE.test(cleaned);
|
|
1849
|
+
}
|
|
1850
|
+
function dedupeCandidates(candidates) {
|
|
1851
|
+
const byPath = new Map();
|
|
1852
|
+
for (const candidate of candidates) {
|
|
1853
|
+
const existing = byPath.get(candidate.absolutePath);
|
|
1854
|
+
if (!existing || existing.kind === 'unknown')
|
|
1855
|
+
byPath.set(candidate.absolutePath, candidate);
|
|
1856
|
+
}
|
|
1857
|
+
return [...byPath.values()];
|
|
1858
|
+
}
|
|
1859
|
+
function scanMessagePathTokens(content) {
|
|
1860
|
+
const tokens = [];
|
|
1861
|
+
const seen = new Set();
|
|
1862
|
+
const scan = (regex) => {
|
|
1863
|
+
regex.lastIndex = 0;
|
|
1864
|
+
for (const match of content.matchAll(regex)) {
|
|
1865
|
+
const token = cleanPathToken(match[2] ?? '');
|
|
1866
|
+
if (!token || hasParentPathSegment(token) || seen.has(token))
|
|
1867
|
+
continue;
|
|
1868
|
+
seen.add(token);
|
|
1869
|
+
tokens.push(token);
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
scan(ABSOLUTE_PATH_RE);
|
|
1873
|
+
scan(RELATIVE_PATH_RE);
|
|
1874
|
+
return tokens;
|
|
1875
|
+
}
|
|
1876
|
+
function resolveTokenCandidates(token, accesses) {
|
|
1877
|
+
const requiresDirectory = token.endsWith('/');
|
|
1878
|
+
if (token.startsWith('/')) {
|
|
1879
|
+
const normalized = normalizeAbsolutePath(token)?.absolutePath;
|
|
1880
|
+
if (!normalized)
|
|
1881
|
+
return [];
|
|
1882
|
+
return accesses.filter((access) => access.absolutePath === normalized && (!requiresDirectory || access.kind === 'directory'));
|
|
1883
|
+
}
|
|
1884
|
+
const relative = normalizePathWithoutTrailingSlash(token.replace(/^\.\//, ''));
|
|
1885
|
+
if (!isRelativeCandidate(relative) && !(requiresDirectory && isBareRelativeDirectoryCandidate(relative)))
|
|
1886
|
+
return [];
|
|
1887
|
+
return accesses.filter((access) => pathEndsWithToken(access.absolutePath, relative) && (!requiresDirectory || access.kind === 'directory'));
|
|
1888
|
+
}
|
|
1889
|
+
function isBareRelativeDirectoryCandidate(raw) {
|
|
1890
|
+
const cleaned = cleanPathToken(raw);
|
|
1891
|
+
if (!cleaned || cleaned.startsWith('/') || cleaned.startsWith('-') || cleaned.includes('://') || hasParentPathSegment(cleaned))
|
|
1892
|
+
return false;
|
|
1893
|
+
if (cleaned.includes('/') || cleaned.includes('*') || cleaned.includes('?') || cleaned.includes('[') || cleaned.includes(']'))
|
|
1894
|
+
return false;
|
|
1895
|
+
if (SINGLE_FILE_NAMES.has(cleaned) || FILE_EXTENSION_RE.test(cleaned))
|
|
1896
|
+
return false;
|
|
1897
|
+
return /^[A-Za-z0-9._~@%+=,-]+$/.test(cleaned);
|
|
1898
|
+
}
|
|
1899
|
+
function pathEndsWithToken(absolutePath, token) {
|
|
1900
|
+
const normalizedPath = path.posix.normalize(absolutePath);
|
|
1901
|
+
const normalizedToken = normalizePathWithoutTrailingSlash(token).replace(/^\/+/, '');
|
|
1902
|
+
if (!normalizedToken.includes('/')) {
|
|
1903
|
+
return path.posix.basename(normalizedPath) === normalizedToken;
|
|
1904
|
+
}
|
|
1905
|
+
return normalizedPath.endsWith(`/${normalizedToken}`);
|
|
1906
|
+
}
|
|
1907
|
+
function mapAgentFileAccessError(error) {
|
|
1908
|
+
const mapped = mapAgentWorkspaceError(error);
|
|
1909
|
+
if (mapped instanceof AgentWorkspaceServiceError) {
|
|
1910
|
+
return new AgentFileAccessServiceError(mapped.statusCode, mapped.message);
|
|
1911
|
+
}
|
|
1912
|
+
return new AgentFileAccessServiceError(500, String(error?.message ?? error));
|
|
1913
|
+
}
|