-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcapabilitiesHandler.ts
More file actions
172 lines (151 loc) · 5.64 KB
/
Copy pathcapabilitiesHandler.ts
File metadata and controls
172 lines (151 loc) · 5.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
/**
* @file capabilitiesHandler.ts
* @description Handles `query_provider_capabilities` — the read-only skill catalogue the
* SaaS shows in the task INSTRUCTIONS slash menu.
*
* The working directory is resolved here, from the task mode, and never taken as a raw
* absolute path from the browser. OUTPUT mode deliberately resolves to the ctrlnode root so
* the menu cannot advertise project skills that the OUTPUT execution would not load.
*/
import path from 'path';
import { BASE_PATH, CTRLNODE_ROOT } from './config.js';
import { HandlerContext } from './handlerContext.js';
import { logger } from './logger.js';
import { sanitizeRelPath } from './fileSystem.js';
import { BridgeMessage } from './types.js';
import {
CapabilityTaskMode,
DiscoverCapabilitiesParams,
ProviderCapabilities,
buildCapabilityCacheKey,
discoverStatelessCapabilities,
emptyCapabilities,
readCapabilityCache,
writeCapabilityCache,
} from './providers/capabilities/index.js';
export interface CapabilityWorkingDirectoryParams {
taskMode: CapabilityTaskMode;
repoPath: string | undefined;
basePath: string;
ctrlnodeRoot: string;
}
/**
* Mirrors `resolveRepoDispatchSpawn`: repo mode runs from the project directory, OUTPUT mode
* from the ctrlnode root. Any resolved path that escapes the base path collapses back to the
* ctrlnode root rather than being trusted.
*/
export function resolveCapabilityWorkingDirectory(
params: CapabilityWorkingDirectoryParams,
): string {
const ctrlnodeRoot = path.resolve(params.ctrlnodeRoot);
if (params.taskMode !== 'repo') return ctrlnodeRoot;
const raw = params.repoPath?.trim();
if (!raw) return ctrlnodeRoot;
const base = path.resolve(params.basePath);
// An absolute path is honoured only when it already resolves inside the base path.
const candidate = path.isAbsolute(raw)
? path.resolve(raw)
: path.resolve(path.join(base, sanitizeRelPath(raw)));
const relative = path.relative(base, candidate);
const escapes = relative.startsWith('..') || path.isAbsolute(relative);
if (escapes) {
logger.warn('capabilities.working_directory_rejected', { reason: 'outside_base_path' });
return ctrlnodeRoot;
}
return candidate;
}
function normalizeTaskMode(value: unknown): CapabilityTaskMode {
return value === 'repo' ? 'repo' : 'output';
}
async function runDiscovery(
ctx: HandlerContext,
params: DiscoverCapabilitiesParams,
): Promise<ProviderCapabilities> {
try {
return ctx.provider.discoverCapabilities
? await ctx.provider.discoverCapabilities(params)
: discoverStatelessCapabilities(ctx.provider.providerName, params);
} catch (e) {
logger.warn('capabilities.query_failed', { agentId: params.agentId, err: String(e) });
const fallback = emptyCapabilities(ctx.provider.providerName, params);
fallback.discovery.warnings.push('discovery_failed');
return fallback;
}
}
/** Only the fields the slash menu actually renders — timestamps must never trigger a "changed". */
function skillsFingerprint(capabilities: ProviderCapabilities): string {
return JSON.stringify(
capabilities.skills
.map((s) => [s.id, s.name, s.description, s.argumentHint, s.invocation, s.scope, s.userInvocable, s.enabled])
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
);
}
/**
* Re-runs discovery after a cache hit already answered the request, so the next open is fresh
* without ever blocking the current one on it. A changed catalogue is pushed to the SaaS —
* unchanged, the cache TTL simply resets and nothing is sent.
*/
function revalidateInBackground(
ctx: HandlerContext,
params: DiscoverCapabilitiesParams,
cacheKey: string,
previous: ProviderCapabilities,
): void {
void runDiscovery(ctx, params).then((fresh) => {
if (fresh.discovery.warnings.length > 0) return;
writeCapabilityCache(cacheKey, fresh);
if (skillsFingerprint(fresh) === skillsFingerprint(previous)) return;
logger.debug('capabilities.revalidate_changed', {
agentId: params.agentId,
provider: ctx.provider.providerName,
skills: fresh.skills.length,
});
ctx.sendToSaas({
action: 'provider_capabilities_changed',
agentId: params.agentId,
taskMode: params.taskMode,
capabilities: fresh,
});
}).catch((e) => {
logger.warn('capabilities.revalidate_failed', { agentId: params.agentId, err: String(e) });
});
}
export async function handleQueryProviderCapabilities(
msg: BridgeMessage,
ctx: HandlerContext,
): Promise<void> {
const taskMode = normalizeTaskMode((msg as any).taskMode);
const workingDirectory = resolveCapabilityWorkingDirectory({
taskMode,
repoPath: (msg as any).repoPath,
basePath: BASE_PATH,
ctrlnodeRoot: CTRLNODE_ROOT,
});
const params = { agentId: msg.agentId, workingDirectory, taskMode };
logger.debug('capabilities.query_received', {
agentId: msg.agentId,
taskMode,
requestId: msg.requestId,
});
const cacheKey = buildCapabilityCacheKey(ctx.provider.providerName, params);
let capabilities = readCapabilityCache(cacheKey);
if (capabilities) {
logger.debug('capabilities.cache_hit', { agentId: msg.agentId, provider: ctx.provider.providerName });
revalidateInBackground(ctx, params, cacheKey, capabilities);
} else {
capabilities = await runDiscovery(ctx, params);
writeCapabilityCache(cacheKey, capabilities);
}
logger.debug('capabilities.query_completed', {
agentId: msg.agentId,
provider: capabilities.provider,
taskMode,
skills: capabilities.skills.length,
discovery: capabilities.discovery.skills,
});
ctx.sendToSaas({
action: 'provider_capabilities_response',
requestId: msg.requestId,
capabilities,
});
}