btca-server 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -0
- package/package.json +56 -0
- package/src/agent/agent.test.ts +111 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/service.ts +328 -0
- package/src/agent/types.ts +16 -0
- package/src/collections/index.ts +2 -0
- package/src/collections/service.ts +100 -0
- package/src/collections/types.ts +18 -0
- package/src/config/config.test.ts +119 -0
- package/src/config/index.ts +563 -0
- package/src/context/index.ts +24 -0
- package/src/context/transaction.ts +28 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +468 -0
- package/src/metrics/index.ts +60 -0
- package/src/resources/helpers.ts +10 -0
- package/src/resources/impls/git.test.ts +119 -0
- package/src/resources/impls/git.ts +156 -0
- package/src/resources/index.ts +10 -0
- package/src/resources/schema.ts +178 -0
- package/src/resources/service.ts +75 -0
- package/src/resources/types.ts +29 -0
- package/src/stream/index.ts +19 -0
- package/src/stream/service.ts +161 -0
- package/src/stream/types.ts +101 -0
- package/src/validation/index.ts +440 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const BtcaModelSchema = z.object({
|
|
4
|
+
provider: z.string(),
|
|
5
|
+
model: z.string()
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const BtcaCollectionInfoSchema = z.object({
|
|
9
|
+
key: z.string(),
|
|
10
|
+
path: z.string()
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const BtcaStreamMetaEventSchema = z.object({
|
|
14
|
+
type: z.literal('meta'),
|
|
15
|
+
model: BtcaModelSchema,
|
|
16
|
+
resources: z.array(z.string()),
|
|
17
|
+
collection: BtcaCollectionInfoSchema
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const BtcaStreamTextDeltaEventSchema = z.object({
|
|
21
|
+
type: z.literal('text.delta'),
|
|
22
|
+
delta: z.string()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const BtcaStreamReasoningDeltaEventSchema = z.object({
|
|
26
|
+
type: z.literal('reasoning.delta'),
|
|
27
|
+
delta: z.string()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const BtcaToolStateSchema = z.discriminatedUnion('status', [
|
|
31
|
+
z.object({
|
|
32
|
+
status: z.literal('pending'),
|
|
33
|
+
input: z.record(z.unknown()),
|
|
34
|
+
raw: z.string()
|
|
35
|
+
}),
|
|
36
|
+
z.object({
|
|
37
|
+
status: z.literal('running'),
|
|
38
|
+
input: z.record(z.unknown()),
|
|
39
|
+
title: z.string().optional(),
|
|
40
|
+
metadata: z.record(z.unknown()).optional(),
|
|
41
|
+
time: z.object({ start: z.number() })
|
|
42
|
+
}),
|
|
43
|
+
z.object({
|
|
44
|
+
status: z.literal('completed'),
|
|
45
|
+
input: z.record(z.unknown()),
|
|
46
|
+
output: z.string(),
|
|
47
|
+
title: z.string(),
|
|
48
|
+
metadata: z.record(z.unknown()),
|
|
49
|
+
time: z.object({ start: z.number(), end: z.number(), compacted: z.number().optional() })
|
|
50
|
+
}),
|
|
51
|
+
z.object({
|
|
52
|
+
status: z.literal('error'),
|
|
53
|
+
input: z.record(z.unknown()),
|
|
54
|
+
error: z.string(),
|
|
55
|
+
metadata: z.record(z.unknown()).optional(),
|
|
56
|
+
time: z.object({ start: z.number(), end: z.number() })
|
|
57
|
+
})
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export const BtcaStreamToolUpdatedEventSchema = z.object({
|
|
61
|
+
type: z.literal('tool.updated'),
|
|
62
|
+
callID: z.string(),
|
|
63
|
+
tool: z.string(),
|
|
64
|
+
state: BtcaToolStateSchema
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const BtcaStreamDoneEventSchema = z.object({
|
|
68
|
+
type: z.literal('done'),
|
|
69
|
+
text: z.string(),
|
|
70
|
+
reasoning: z.string(),
|
|
71
|
+
tools: z.array(
|
|
72
|
+
z.object({
|
|
73
|
+
callID: z.string(),
|
|
74
|
+
tool: z.string(),
|
|
75
|
+
state: BtcaToolStateSchema
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const BtcaStreamErrorEventSchema = z.object({
|
|
81
|
+
type: z.literal('error'),
|
|
82
|
+
tag: z.string(),
|
|
83
|
+
message: z.string()
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const BtcaStreamEventSchema = z.union([
|
|
87
|
+
BtcaStreamMetaEventSchema,
|
|
88
|
+
BtcaStreamTextDeltaEventSchema,
|
|
89
|
+
BtcaStreamReasoningDeltaEventSchema,
|
|
90
|
+
BtcaStreamToolUpdatedEventSchema,
|
|
91
|
+
BtcaStreamDoneEventSchema,
|
|
92
|
+
BtcaStreamErrorEventSchema
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
export type BtcaStreamMetaEvent = z.infer<typeof BtcaStreamMetaEventSchema>;
|
|
96
|
+
export type BtcaStreamTextDeltaEvent = z.infer<typeof BtcaStreamTextDeltaEventSchema>;
|
|
97
|
+
export type BtcaStreamReasoningDeltaEvent = z.infer<typeof BtcaStreamReasoningDeltaEventSchema>;
|
|
98
|
+
export type BtcaStreamToolUpdatedEvent = z.infer<typeof BtcaStreamToolUpdatedEventSchema>;
|
|
99
|
+
export type BtcaStreamDoneEvent = z.infer<typeof BtcaStreamDoneEventSchema>;
|
|
100
|
+
export type BtcaStreamErrorEvent = z.infer<typeof BtcaStreamErrorEventSchema>;
|
|
101
|
+
export type BtcaStreamEvent = z.infer<typeof BtcaStreamEventSchema>;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation utilities for the btca server.
|
|
3
|
+
*
|
|
4
|
+
* These validators prevent security issues including:
|
|
5
|
+
* - Path traversal attacks via resource names
|
|
6
|
+
* - Git injection via malicious URLs
|
|
7
|
+
* - Command injection via branch names
|
|
8
|
+
* - DoS via unbounded input sizes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// Regex Patterns
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resource name: must start with a letter, followed by alphanumeric and hyphens only.
|
|
17
|
+
* This prevents path traversal (../), git option injection (-), and shell metacharacters.
|
|
18
|
+
*/
|
|
19
|
+
const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
|
|
23
|
+
* Must not start with hyphen to prevent git option injection.
|
|
24
|
+
*/
|
|
25
|
+
const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Provider/Model names: letters, numbers, dots, underscores, plus, hyphens, forward slashes, colons.
|
|
29
|
+
* Blocks shell metacharacters and path traversal.
|
|
30
|
+
*/
|
|
31
|
+
const SAFE_NAME_REGEX = /^[a-zA-Z0-9._+\-/:]+$/;
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Limits
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const LIMITS = {
|
|
38
|
+
/** Maximum length for resource names */
|
|
39
|
+
RESOURCE_NAME_MAX: 64,
|
|
40
|
+
/** Maximum length for branch names */
|
|
41
|
+
BRANCH_NAME_MAX: 128,
|
|
42
|
+
/** Maximum length for provider names */
|
|
43
|
+
PROVIDER_NAME_MAX: 100,
|
|
44
|
+
/** Maximum length for model names */
|
|
45
|
+
MODEL_NAME_MAX: 100,
|
|
46
|
+
/** Maximum length for special notes */
|
|
47
|
+
NOTES_MAX: 500,
|
|
48
|
+
/** Maximum length for search paths */
|
|
49
|
+
SEARCH_PATH_MAX: 256,
|
|
50
|
+
/** Maximum length for questions */
|
|
51
|
+
QUESTION_MAX: 10_000,
|
|
52
|
+
/** Maximum number of resources per request */
|
|
53
|
+
MAX_RESOURCES_PER_REQUEST: 20
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Validation Result Type
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export type ValidationResult =
|
|
61
|
+
| { valid: true }
|
|
62
|
+
| { valid: false; error: string };
|
|
63
|
+
|
|
64
|
+
const ok = (): ValidationResult => ({ valid: true });
|
|
65
|
+
const fail = (error: string): ValidationResult => ({ valid: false, error });
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Validators
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a resource name to prevent path traversal and git injection attacks.
|
|
73
|
+
*
|
|
74
|
+
* Requirements:
|
|
75
|
+
* - Non-empty
|
|
76
|
+
* - Starts with a letter (prevents git option injection with -)
|
|
77
|
+
* - Only contains letters, numbers, and hyphens
|
|
78
|
+
* - Max length enforced
|
|
79
|
+
*/
|
|
80
|
+
export const validateResourceName = (name: string): ValidationResult => {
|
|
81
|
+
if (!name || name.trim().length === 0) {
|
|
82
|
+
return fail('Resource name cannot be empty');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (name.length > LIMITS.RESOURCE_NAME_MAX) {
|
|
86
|
+
return fail(
|
|
87
|
+
`Resource name too long: ${name.length} chars (max ${LIMITS.RESOURCE_NAME_MAX})`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!RESOURCE_NAME_REGEX.test(name)) {
|
|
92
|
+
return fail(
|
|
93
|
+
`Invalid resource name: "${name}". Must start with a letter and contain only alphanumeric characters and hyphens`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return ok();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate a git branch name to prevent git injection attacks.
|
|
102
|
+
*
|
|
103
|
+
* Requirements:
|
|
104
|
+
* - Non-empty
|
|
105
|
+
* - Does not start with hyphen (prevents git option injection)
|
|
106
|
+
* - Only safe characters
|
|
107
|
+
* - Max length enforced
|
|
108
|
+
*/
|
|
109
|
+
export const validateBranchName = (branch: string): ValidationResult => {
|
|
110
|
+
if (!branch || branch.trim().length === 0) {
|
|
111
|
+
return fail('Branch name cannot be empty');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (branch.length > LIMITS.BRANCH_NAME_MAX) {
|
|
115
|
+
return fail(
|
|
116
|
+
`Branch name too long: ${branch.length} chars (max ${LIMITS.BRANCH_NAME_MAX})`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (branch.startsWith('-')) {
|
|
121
|
+
return fail(
|
|
122
|
+
`Invalid branch name: "${branch}". Must not start with '-' to prevent git option injection`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!BRANCH_NAME_REGEX.test(branch)) {
|
|
127
|
+
return fail(
|
|
128
|
+
`Invalid branch name: "${branch}". Must contain only alphanumeric characters, forward slashes, dots, underscores, and hyphens`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return ok();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate a git URL to prevent unsafe git operations.
|
|
137
|
+
*
|
|
138
|
+
* Requirements:
|
|
139
|
+
* - Valid URL format
|
|
140
|
+
* - HTTPS protocol only (rejects file://, git://, ssh://, ext::, etc.)
|
|
141
|
+
* - No embedded credentials
|
|
142
|
+
* - No localhost or private IP addresses
|
|
143
|
+
*/
|
|
144
|
+
export const validateGitUrl = (url: string): ValidationResult => {
|
|
145
|
+
if (!url || url.trim().length === 0) {
|
|
146
|
+
return fail('Git URL cannot be empty');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let parsed: URL;
|
|
150
|
+
try {
|
|
151
|
+
parsed = new URL(url);
|
|
152
|
+
} catch {
|
|
153
|
+
return fail(`Invalid URL format: "${url}"`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Only allow HTTPS protocol
|
|
157
|
+
if (parsed.protocol !== 'https:') {
|
|
158
|
+
return fail(
|
|
159
|
+
`Invalid URL protocol: ${parsed.protocol}. Only HTTPS URLs are allowed for security reasons`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Reject embedded credentials
|
|
164
|
+
if (parsed.username || parsed.password) {
|
|
165
|
+
return fail('URL must not contain embedded credentials');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Reject localhost and private IP addresses
|
|
169
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
170
|
+
if (
|
|
171
|
+
hostname === 'localhost' ||
|
|
172
|
+
hostname.startsWith('127.') ||
|
|
173
|
+
hostname.startsWith('192.168.') ||
|
|
174
|
+
hostname.startsWith('10.') ||
|
|
175
|
+
hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) || // 172.16.0.0 - 172.31.255.255
|
|
176
|
+
hostname === '::1' ||
|
|
177
|
+
hostname === '0.0.0.0'
|
|
178
|
+
) {
|
|
179
|
+
return fail(`URL must not point to localhost or private IP addresses: ${hostname}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return ok();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate a git sparse-checkout search path to prevent injection attacks.
|
|
187
|
+
*
|
|
188
|
+
* Requirements:
|
|
189
|
+
* - No newlines (prevents multi-line pattern injection)
|
|
190
|
+
* - No path traversal sequences (..)
|
|
191
|
+
* - No absolute paths
|
|
192
|
+
* - Max length enforced
|
|
193
|
+
*/
|
|
194
|
+
export const validateSearchPath = (searchPath: string | undefined): ValidationResult => {
|
|
195
|
+
// Empty/undefined search path is valid (means no sparse checkout)
|
|
196
|
+
if (!searchPath || searchPath.trim().length === 0) {
|
|
197
|
+
return ok();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (searchPath.length > LIMITS.SEARCH_PATH_MAX) {
|
|
201
|
+
return fail(
|
|
202
|
+
`Search path too long: ${searchPath.length} chars (max ${LIMITS.SEARCH_PATH_MAX})`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Reject newlines (pattern injection)
|
|
207
|
+
if (searchPath.includes('\n') || searchPath.includes('\r')) {
|
|
208
|
+
return fail('Search path must not contain newline characters');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Reject path traversal sequences
|
|
212
|
+
if (searchPath.includes('..')) {
|
|
213
|
+
return fail('Search path must not contain path traversal sequences (..)')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Reject absolute paths
|
|
217
|
+
if (searchPath.startsWith('/') || searchPath.match(/^[a-zA-Z]:\\/)) {
|
|
218
|
+
return fail('Search path must not be an absolute path');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return ok();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validate a local file path.
|
|
226
|
+
*
|
|
227
|
+
* Requirements:
|
|
228
|
+
* - Non-empty
|
|
229
|
+
* - No null bytes
|
|
230
|
+
* - Must be absolute path
|
|
231
|
+
*/
|
|
232
|
+
export const validateLocalPath = (path: string): ValidationResult => {
|
|
233
|
+
if (!path || path.trim().length === 0) {
|
|
234
|
+
return fail('Local path cannot be empty');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Reject null bytes
|
|
238
|
+
if (path.includes('\0')) {
|
|
239
|
+
return fail('Path must not contain null bytes');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Must be absolute path (starts with / on Unix or drive letter on Windows)
|
|
243
|
+
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/)) {
|
|
244
|
+
return fail('Local path must be an absolute path');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return ok();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate resource notes to prevent excessive content.
|
|
252
|
+
*
|
|
253
|
+
* Requirements:
|
|
254
|
+
* - Max length enforced
|
|
255
|
+
* - No control characters (except newlines and tabs)
|
|
256
|
+
*/
|
|
257
|
+
export const validateNotes = (notes: string | undefined): ValidationResult => {
|
|
258
|
+
if (!notes || notes.trim().length === 0) {
|
|
259
|
+
return ok();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (notes.length > LIMITS.NOTES_MAX) {
|
|
263
|
+
return fail(`Notes too long: ${notes.length} chars (max ${LIMITS.NOTES_MAX})`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Reject control characters except newlines and tabs
|
|
267
|
+
// eslint-disable-next-line no-control-regex
|
|
268
|
+
const hasInvalidControlChars = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(notes);
|
|
269
|
+
if (hasInvalidControlChars) {
|
|
270
|
+
return fail('Notes contain invalid control characters');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return ok();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate provider name.
|
|
278
|
+
*/
|
|
279
|
+
export const validateProviderName = (name: string): ValidationResult => {
|
|
280
|
+
if (!name || name.trim().length === 0) {
|
|
281
|
+
return fail('Provider name cannot be empty');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (name.length > LIMITS.PROVIDER_NAME_MAX) {
|
|
285
|
+
return fail(`Provider name too long: ${name.length} chars (max ${LIMITS.PROVIDER_NAME_MAX})`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!SAFE_NAME_REGEX.test(name)) {
|
|
289
|
+
return fail(
|
|
290
|
+
`Invalid provider name: "${name}". Must contain only letters, numbers, and: . _ + - / :`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return ok();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Validate model name.
|
|
299
|
+
*/
|
|
300
|
+
export const validateModelName = (name: string): ValidationResult => {
|
|
301
|
+
if (!name || name.trim().length === 0) {
|
|
302
|
+
return fail('Model name cannot be empty');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (name.length > LIMITS.MODEL_NAME_MAX) {
|
|
306
|
+
return fail(`Model name too long: ${name.length} chars (max ${LIMITS.MODEL_NAME_MAX})`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!SAFE_NAME_REGEX.test(name)) {
|
|
310
|
+
return fail(
|
|
311
|
+
`Invalid model name: "${name}". Must contain only letters, numbers, and: . _ + - / :`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return ok();
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate question text for the /question endpoint.
|
|
320
|
+
*/
|
|
321
|
+
export const validateQuestion = (question: string): ValidationResult => {
|
|
322
|
+
if (!question || question.trim().length === 0) {
|
|
323
|
+
return fail('Question cannot be empty');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (question.length > LIMITS.QUESTION_MAX) {
|
|
327
|
+
return fail(`Question too long: ${question.length} chars (max ${LIMITS.QUESTION_MAX})`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return ok();
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Validate resources array size.
|
|
335
|
+
*/
|
|
336
|
+
export const validateResourcesArray = (resources: string[] | undefined): ValidationResult => {
|
|
337
|
+
if (!resources) {
|
|
338
|
+
return ok();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (resources.length > LIMITS.MAX_RESOURCES_PER_REQUEST) {
|
|
342
|
+
return fail(
|
|
343
|
+
`Too many resources: ${resources.length} (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Validate each resource name
|
|
348
|
+
for (const name of resources) {
|
|
349
|
+
const result = validateResourceName(name);
|
|
350
|
+
if (!result.valid) {
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return ok();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
359
|
+
// Composite Validators
|
|
360
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validate a complete git resource definition.
|
|
364
|
+
*/
|
|
365
|
+
export const validateGitResource = (resource: {
|
|
366
|
+
name: string;
|
|
367
|
+
url: string;
|
|
368
|
+
branch: string;
|
|
369
|
+
searchPath?: string;
|
|
370
|
+
specialNotes?: string;
|
|
371
|
+
}): ValidationResult => {
|
|
372
|
+
const nameResult = validateResourceName(resource.name);
|
|
373
|
+
if (!nameResult.valid) return nameResult;
|
|
374
|
+
|
|
375
|
+
const urlResult = validateGitUrl(resource.url);
|
|
376
|
+
if (!urlResult.valid) return urlResult;
|
|
377
|
+
|
|
378
|
+
const branchResult = validateBranchName(resource.branch);
|
|
379
|
+
if (!branchResult.valid) return branchResult;
|
|
380
|
+
|
|
381
|
+
const searchPathResult = validateSearchPath(resource.searchPath);
|
|
382
|
+
if (!searchPathResult.valid) return searchPathResult;
|
|
383
|
+
|
|
384
|
+
const notesResult = validateNotes(resource.specialNotes);
|
|
385
|
+
if (!notesResult.valid) return notesResult;
|
|
386
|
+
|
|
387
|
+
return ok();
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validate a complete local resource definition.
|
|
392
|
+
*/
|
|
393
|
+
export const validateLocalResource = (resource: {
|
|
394
|
+
name: string;
|
|
395
|
+
path: string;
|
|
396
|
+
specialNotes?: string;
|
|
397
|
+
}): ValidationResult => {
|
|
398
|
+
const nameResult = validateResourceName(resource.name);
|
|
399
|
+
if (!nameResult.valid) return nameResult;
|
|
400
|
+
|
|
401
|
+
const pathResult = validateLocalPath(resource.path);
|
|
402
|
+
if (!pathResult.valid) return pathResult;
|
|
403
|
+
|
|
404
|
+
const notesResult = validateNotes(resource.specialNotes);
|
|
405
|
+
if (!notesResult.valid) return notesResult;
|
|
406
|
+
|
|
407
|
+
return ok();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate a question request.
|
|
412
|
+
*/
|
|
413
|
+
export const validateQuestionRequest = (request: {
|
|
414
|
+
question: string;
|
|
415
|
+
resources?: string[];
|
|
416
|
+
}): ValidationResult => {
|
|
417
|
+
const questionResult = validateQuestion(request.question);
|
|
418
|
+
if (!questionResult.valid) return questionResult;
|
|
419
|
+
|
|
420
|
+
const resourcesResult = validateResourcesArray(request.resources);
|
|
421
|
+
if (!resourcesResult.valid) return resourcesResult;
|
|
422
|
+
|
|
423
|
+
return ok();
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Validate model update request.
|
|
428
|
+
*/
|
|
429
|
+
export const validateModelUpdate = (request: {
|
|
430
|
+
provider: string;
|
|
431
|
+
model: string;
|
|
432
|
+
}): ValidationResult => {
|
|
433
|
+
const providerResult = validateProviderName(request.provider);
|
|
434
|
+
if (!providerResult.valid) return providerResult;
|
|
435
|
+
|
|
436
|
+
const modelResult = validateModelName(request.model);
|
|
437
|
+
if (!modelResult.valid) return modelResult;
|
|
438
|
+
|
|
439
|
+
return ok();
|
|
440
|
+
};
|