btca-server 1.0.92 → 1.0.93
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 +7 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +13 -4
- package/src/agent/service.ts +23 -23
- package/src/collections/service.ts +93 -3
- package/src/collections/virtual-metadata.ts +3 -1
- package/src/config/config.test.ts +28 -0
- package/src/config/index.ts +7 -2
- package/src/errors.test.ts +65 -0
- package/src/errors.ts +99 -6
- package/src/index.ts +47 -14
- package/src/providers/auth.ts +1 -1
- package/src/resources/impls/npm.test.ts +337 -0
- package/src/resources/impls/npm.ts +498 -0
- package/src/resources/index.ts +12 -1
- package/src/resources/schema.ts +38 -1
- package/src/resources/service.test.ts +26 -1
- package/src/resources/service.ts +59 -17
- package/src/resources/types.ts +12 -1
- package/src/stream/service.ts +5 -3
- package/src/stream/types.ts +2 -1
- package/src/validation/index.test.ts +29 -1
- package/src/validation/index.ts +139 -1
package/src/resources/service.ts
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { Config } from '../config/index.ts';
|
|
4
|
-
import { validateGitUrl } from '../validation/index.ts';
|
|
4
|
+
import { parseNpmReference, validateGitUrl } from '../validation/index.ts';
|
|
5
5
|
import { CommonHints } from '../errors.ts';
|
|
6
6
|
|
|
7
7
|
import { ResourceError, resourceNameToKey } from './helpers.ts';
|
|
8
8
|
import { loadGitResource } from './impls/git.ts';
|
|
9
|
+
import { loadNpmResource } from './impls/npm.ts';
|
|
9
10
|
import {
|
|
10
11
|
isGitResource,
|
|
12
|
+
isNpmResource,
|
|
11
13
|
type ResourceDefinition,
|
|
12
14
|
type GitResource,
|
|
13
|
-
type LocalResource
|
|
15
|
+
type LocalResource,
|
|
16
|
+
type NpmResource
|
|
14
17
|
} from './schema.ts';
|
|
15
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
BtcaFsResource,
|
|
20
|
+
BtcaGitResourceArgs,
|
|
21
|
+
BtcaLocalResourceArgs,
|
|
22
|
+
BtcaNpmResourceArgs
|
|
23
|
+
} from './types.ts';
|
|
16
24
|
|
|
17
25
|
const ANON_PREFIX = 'anonymous:';
|
|
18
26
|
const ANON_DIRECTORY_PREFIX = 'anonymous-';
|
|
19
27
|
const DEFAULT_ANON_BRANCH = 'main';
|
|
20
28
|
|
|
21
|
-
export const createAnonymousDirectoryKey = (
|
|
22
|
-
const hash = createHash('sha256').update(
|
|
29
|
+
export const createAnonymousDirectoryKey = (reference: string): string => {
|
|
30
|
+
const hash = createHash('sha256').update(reference).digest('hex').slice(0, 12);
|
|
23
31
|
return `${ANON_DIRECTORY_PREFIX}${hash}`;
|
|
24
32
|
};
|
|
25
33
|
|
|
@@ -69,6 +77,25 @@ export namespace Resources {
|
|
|
69
77
|
specialAgentInstructions: definition.specialNotes ?? ''
|
|
70
78
|
});
|
|
71
79
|
|
|
80
|
+
const definitionToNpmArgs = (
|
|
81
|
+
definition: NpmResource,
|
|
82
|
+
resourcesDirectory: string
|
|
83
|
+
): BtcaNpmResourceArgs => {
|
|
84
|
+
const reference = `${definition.package}${definition.version ? `@${definition.version}` : ''}`;
|
|
85
|
+
return {
|
|
86
|
+
type: 'npm',
|
|
87
|
+
name: definition.name,
|
|
88
|
+
package: definition.package,
|
|
89
|
+
...(definition.version ? { version: definition.version } : {}),
|
|
90
|
+
resourcesDirectoryPath: resourcesDirectory,
|
|
91
|
+
specialAgentInstructions: definition.specialNotes ?? '',
|
|
92
|
+
ephemeral: isAnonymousResource(definition.name),
|
|
93
|
+
localDirectoryKey: isAnonymousResource(definition.name)
|
|
94
|
+
? createAnonymousDirectoryKey(reference)
|
|
95
|
+
: undefined
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
72
99
|
const loadLocalResource = (args: BtcaLocalResourceArgs): BtcaFsResource => ({
|
|
73
100
|
_tag: 'fs-based',
|
|
74
101
|
name: args.name,
|
|
@@ -79,17 +106,28 @@ export namespace Resources {
|
|
|
79
106
|
getAbsoluteDirectoryPath: async () => args.path
|
|
80
107
|
});
|
|
81
108
|
|
|
82
|
-
export const createAnonymousResource = (reference: string):
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
109
|
+
export const createAnonymousResource = (reference: string): ResourceDefinition | null => {
|
|
110
|
+
const npmReference = parseNpmReference(reference);
|
|
111
|
+
if (npmReference) {
|
|
112
|
+
return {
|
|
113
|
+
type: 'npm',
|
|
114
|
+
name: `${ANON_PREFIX}${npmReference.normalizedReference}`,
|
|
115
|
+
package: npmReference.packageName,
|
|
116
|
+
...(npmReference.version ? { version: npmReference.version } : {})
|
|
117
|
+
};
|
|
118
|
+
}
|
|
85
119
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
120
|
+
const gitUrlResult = validateGitUrl(reference);
|
|
121
|
+
if (gitUrlResult.valid) {
|
|
122
|
+
const normalizedUrl = gitUrlResult.value;
|
|
123
|
+
return {
|
|
124
|
+
type: 'git',
|
|
125
|
+
name: `${ANON_PREFIX}${normalizedUrl}`,
|
|
126
|
+
url: normalizedUrl,
|
|
127
|
+
branch: DEFAULT_ANON_BRANCH
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
93
131
|
};
|
|
94
132
|
|
|
95
133
|
export const resolveResourceDefinition = (
|
|
@@ -116,9 +154,13 @@ export namespace Resources {
|
|
|
116
154
|
|
|
117
155
|
if (isGitResource(definition)) {
|
|
118
156
|
return loadGitResource(definitionToGitArgs(definition, config.resourcesDirectory, quiet));
|
|
119
|
-
} else {
|
|
120
|
-
return loadLocalResource(definitionToLocalArgs(definition));
|
|
121
157
|
}
|
|
158
|
+
|
|
159
|
+
if (isNpmResource(definition)) {
|
|
160
|
+
return loadNpmResource(definitionToNpmArgs(definition, config.resourcesDirectory));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return loadLocalResource(definitionToLocalArgs(definition));
|
|
122
164
|
}
|
|
123
165
|
};
|
|
124
166
|
};
|
package/src/resources/types.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type BtcaFsResource = {
|
|
|
5
5
|
readonly _tag: 'fs-based';
|
|
6
6
|
readonly name: string;
|
|
7
7
|
readonly fsName: string;
|
|
8
|
-
readonly type: 'git' | 'local';
|
|
8
|
+
readonly type: 'git' | 'local' | 'npm';
|
|
9
9
|
readonly repoSubPaths: readonly string[];
|
|
10
10
|
readonly specialAgentInstructions: string;
|
|
11
11
|
readonly getAbsoluteDirectoryPath: () => Promise<string>;
|
|
@@ -31,3 +31,14 @@ export type BtcaLocalResourceArgs = {
|
|
|
31
31
|
readonly path: string;
|
|
32
32
|
readonly specialAgentInstructions: string;
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
export type BtcaNpmResourceArgs = {
|
|
36
|
+
readonly type: 'npm';
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly package: string;
|
|
39
|
+
readonly version?: string;
|
|
40
|
+
readonly resourcesDirectoryPath: string;
|
|
41
|
+
readonly specialAgentInstructions: string;
|
|
42
|
+
readonly ephemeral?: boolean;
|
|
43
|
+
readonly localDirectoryKey?: string;
|
|
44
|
+
};
|
package/src/stream/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stripUserQuestionFromStart, extractCoreQuestion } from '@btca/shared';
|
|
2
2
|
import { Result } from 'better-result';
|
|
3
3
|
|
|
4
|
-
import { getErrorMessage, getErrorTag } from '../errors.ts';
|
|
4
|
+
import { getErrorHint, getErrorMessage, getErrorTag } from '../errors.ts';
|
|
5
5
|
import { Metrics } from '../metrics/index.ts';
|
|
6
6
|
import type { AgentLoop } from '../agent/loop.ts';
|
|
7
7
|
|
|
@@ -304,7 +304,8 @@ export namespace StreamService {
|
|
|
304
304
|
const err: BtcaStreamErrorEvent = {
|
|
305
305
|
type: 'error',
|
|
306
306
|
tag: getErrorTag(event.error),
|
|
307
|
-
message: getErrorMessage(event.error)
|
|
307
|
+
message: getErrorMessage(event.error),
|
|
308
|
+
hint: getErrorHint(event.error)
|
|
308
309
|
};
|
|
309
310
|
emit(controller, err);
|
|
310
311
|
break;
|
|
@@ -323,7 +324,8 @@ export namespace StreamService {
|
|
|
323
324
|
const err: BtcaStreamErrorEvent = {
|
|
324
325
|
type: 'error',
|
|
325
326
|
tag: getErrorTag(cause),
|
|
326
|
-
message: getErrorMessage(cause)
|
|
327
|
+
message: getErrorMessage(cause),
|
|
328
|
+
hint: getErrorHint(cause)
|
|
327
329
|
};
|
|
328
330
|
emit(controller, err);
|
|
329
331
|
}
|
package/src/stream/types.ts
CHANGED
|
@@ -129,7 +129,8 @@ export const BtcaStreamDoneEventSchema = z.object({
|
|
|
129
129
|
export const BtcaStreamErrorEventSchema = z.object({
|
|
130
130
|
type: z.literal('error'),
|
|
131
131
|
tag: z.string(),
|
|
132
|
-
message: z.string()
|
|
132
|
+
message: z.string(),
|
|
133
|
+
hint: z.string().optional()
|
|
133
134
|
});
|
|
134
135
|
|
|
135
136
|
export const BtcaStreamEventSchema = z.union([
|
|
@@ -21,6 +21,30 @@ describe('validateResourceReference', () => {
|
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it('accepts and normalizes npm references', () => {
|
|
25
|
+
const result = validateResourceReference('npm:@types/node@22.10.1');
|
|
26
|
+
expect(result.valid).toBe(true);
|
|
27
|
+
if (result.valid) {
|
|
28
|
+
expect(result.value).toBe('npm:@types/node@22.10.1');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('accepts and normalizes npm package URLs', () => {
|
|
33
|
+
const result = validateResourceReference('https://www.npmjs.com/package/react/v/19.0.0');
|
|
34
|
+
expect(result.valid).toBe(true);
|
|
35
|
+
if (result.valid) {
|
|
36
|
+
expect(result.value).toBe('npm:react@19.0.0');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects malformed npm package URLs without throwing', () => {
|
|
41
|
+
const result = validateResourceReference('https://www.npmjs.com/package/%');
|
|
42
|
+
expect(result.valid).toBe(false);
|
|
43
|
+
if (!result.valid) {
|
|
44
|
+
expect(result.error).toContain('Invalid npm reference');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
24
48
|
it('rejects invalid resource references', () => {
|
|
25
49
|
const result = validateResourceReference('not a resource');
|
|
26
50
|
expect(result.valid).toBe(false);
|
|
@@ -40,7 +64,11 @@ describe('validateResourceReference', () => {
|
|
|
40
64
|
|
|
41
65
|
describe('validateResourcesArray', () => {
|
|
42
66
|
it('validates names and URLs together', () => {
|
|
43
|
-
const result = validateResourcesArray([
|
|
67
|
+
const result = validateResourcesArray([
|
|
68
|
+
'svelte',
|
|
69
|
+
'https://github.com/sveltejs/svelte.dev',
|
|
70
|
+
'npm:react'
|
|
71
|
+
]);
|
|
44
72
|
expect(result.valid).toBe(true);
|
|
45
73
|
});
|
|
46
74
|
});
|
package/src/validation/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0
|
|
|
24
24
|
* Must not start with hyphen to prevent git option injection.
|
|
25
25
|
*/
|
|
26
26
|
const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
|
|
27
|
+
const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/;
|
|
28
|
+
const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/;
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Provider/Model names: letters, numbers, dots, underscores, plus, hyphens, forward slashes, colons.
|
|
@@ -256,6 +258,134 @@ export const validateGitUrl = (url: string): ValidationResultWithValue<string> =
|
|
|
256
258
|
return okWithValue(normalizedUrl);
|
|
257
259
|
};
|
|
258
260
|
|
|
261
|
+
export type ParsedNpmReference = {
|
|
262
|
+
packageName: string;
|
|
263
|
+
version?: string;
|
|
264
|
+
normalizedReference: string;
|
|
265
|
+
packageUrl: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const isValidNpmPackageName = (name: string): boolean => {
|
|
269
|
+
if (name.startsWith('@')) {
|
|
270
|
+
const [scope, pkg, ...rest] = name.split('/');
|
|
271
|
+
return (
|
|
272
|
+
rest.length === 0 &&
|
|
273
|
+
!!scope &&
|
|
274
|
+
scope.length > 1 &&
|
|
275
|
+
!!pkg &&
|
|
276
|
+
NPM_PACKAGE_SEGMENT_REGEX.test(scope.slice(1)) &&
|
|
277
|
+
NPM_PACKAGE_SEGMENT_REGEX.test(pkg)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (name.includes('/')) return false;
|
|
282
|
+
return NPM_PACKAGE_SEGMENT_REGEX.test(name);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const isValidNpmVersionOrTag = (value: string): boolean =>
|
|
286
|
+
value.length > 0 &&
|
|
287
|
+
value.length <= LIMITS.BRANCH_NAME_MAX &&
|
|
288
|
+
NPM_VERSION_OR_TAG_REGEX.test(value);
|
|
289
|
+
|
|
290
|
+
const splitNpmSpec = (spec: string): { packageName: string; version?: string } | null => {
|
|
291
|
+
if (!spec) return null;
|
|
292
|
+
if (spec.startsWith('@')) {
|
|
293
|
+
const secondAt = spec.indexOf('@', 1);
|
|
294
|
+
if (secondAt === -1) return { packageName: spec };
|
|
295
|
+
const packageName = spec.slice(0, secondAt);
|
|
296
|
+
const version = spec.slice(secondAt + 1);
|
|
297
|
+
return version ? { packageName, version } : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const at = spec.lastIndexOf('@');
|
|
301
|
+
if (at <= 0) return { packageName: spec };
|
|
302
|
+
const packageName = spec.slice(0, at);
|
|
303
|
+
const version = spec.slice(at + 1);
|
|
304
|
+
return version ? { packageName, version } : null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const encodeNpmPackagePath = (packageName: string): string =>
|
|
308
|
+
packageName.split('/').map(encodeURIComponent).join('/');
|
|
309
|
+
|
|
310
|
+
const toNpmReference = (parsed: { packageName: string; version?: string }): ParsedNpmReference => {
|
|
311
|
+
const normalizedReference = `npm:${parsed.packageName}${parsed.version ? `@${parsed.version}` : ''}`;
|
|
312
|
+
const packageUrl = `https://www.npmjs.com/package/${encodeNpmPackagePath(parsed.packageName)}${
|
|
313
|
+
parsed.version ? `/v/${encodeURIComponent(parsed.version)}` : ''
|
|
314
|
+
}`;
|
|
315
|
+
return {
|
|
316
|
+
packageName: parsed.packageName,
|
|
317
|
+
...(parsed.version ? { version: parsed.version } : {}),
|
|
318
|
+
normalizedReference,
|
|
319
|
+
packageUrl
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const safeDecodeUriComponent = (value: string): string | null =>
|
|
324
|
+
Result.try(() => decodeURIComponent(value)).match({
|
|
325
|
+
ok: (decoded) => decoded,
|
|
326
|
+
err: () => null
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const parseNpmSpecReference = (reference: string): ParsedNpmReference | null => {
|
|
330
|
+
if (!reference.startsWith('npm:')) return null;
|
|
331
|
+
const spec = reference.slice(4).trim();
|
|
332
|
+
if (!spec) return null;
|
|
333
|
+
|
|
334
|
+
const parsed = splitNpmSpec(spec);
|
|
335
|
+
if (!parsed || !isValidNpmPackageName(parsed.packageName)) return null;
|
|
336
|
+
if (parsed.version && !isValidNpmVersionOrTag(parsed.version)) return null;
|
|
337
|
+
|
|
338
|
+
return toNpmReference(parsed);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const parseNpmUrlReference = (reference: string): ParsedNpmReference | null => {
|
|
342
|
+
const parsedUrl = parseUrl(reference).match({
|
|
343
|
+
ok: (value) => value,
|
|
344
|
+
err: () => null
|
|
345
|
+
});
|
|
346
|
+
if (!parsedUrl) return null;
|
|
347
|
+
if (parsedUrl.protocol !== 'https:') return null;
|
|
348
|
+
|
|
349
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
350
|
+
if (hostname !== 'npmjs.com' && hostname !== 'www.npmjs.com') return null;
|
|
351
|
+
|
|
352
|
+
const segments = parsedUrl.pathname.split('/').filter((segment) => segment.length > 0);
|
|
353
|
+
if (segments[0] !== 'package') return null;
|
|
354
|
+
|
|
355
|
+
const packageParts = segments[1]?.startsWith('@') ? segments.slice(1, 3) : segments.slice(1, 2);
|
|
356
|
+
if (packageParts.length === 0 || packageParts.some((part) => !part)) return null;
|
|
357
|
+
const decodedPackageParts = packageParts.map(safeDecodeUriComponent);
|
|
358
|
+
if (decodedPackageParts.some((part) => !part)) return null;
|
|
359
|
+
const packageName = decodedPackageParts.join('/');
|
|
360
|
+
if (!isValidNpmPackageName(packageName)) return null;
|
|
361
|
+
|
|
362
|
+
const remainder = segments.slice(1 + packageParts.length);
|
|
363
|
+
if (remainder.length === 0) return toNpmReference({ packageName });
|
|
364
|
+
if (remainder.length === 2 && remainder[0] === 'v') {
|
|
365
|
+
const version = safeDecodeUriComponent(remainder[1]!);
|
|
366
|
+
if (!version) return null;
|
|
367
|
+
if (!isValidNpmVersionOrTag(version)) return null;
|
|
368
|
+
return toNpmReference({ packageName, version });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return null;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const parseNpmReference = (reference: string): ParsedNpmReference | null =>
|
|
375
|
+
parseNpmSpecReference(reference) ?? parseNpmUrlReference(reference);
|
|
376
|
+
|
|
377
|
+
const isNpmPackageUrl = (reference: string): boolean => {
|
|
378
|
+
const parsedUrl = parseUrl(reference).match({
|
|
379
|
+
ok: (value) => value,
|
|
380
|
+
err: () => null
|
|
381
|
+
});
|
|
382
|
+
if (!parsedUrl || parsedUrl.protocol !== 'https:') return false;
|
|
383
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
384
|
+
if (hostname !== 'npmjs.com' && hostname !== 'www.npmjs.com') return false;
|
|
385
|
+
const segments = parsedUrl.pathname.split('/').filter((segment) => segment.length > 0);
|
|
386
|
+
return segments[0] === 'package';
|
|
387
|
+
};
|
|
388
|
+
|
|
259
389
|
/**
|
|
260
390
|
* Validate a git sparse-checkout search path to prevent injection attacks.
|
|
261
391
|
*
|
|
@@ -423,11 +553,19 @@ export const validateResourceReference = (reference: string): ValidationResultWi
|
|
|
423
553
|
const nameResult = validateResourceName(reference);
|
|
424
554
|
if (nameResult.valid) return okWithValue(reference);
|
|
425
555
|
|
|
556
|
+
const npmReference = parseNpmReference(reference);
|
|
557
|
+
if (npmReference) return okWithValue(npmReference.normalizedReference);
|
|
558
|
+
if (isNpmPackageUrl(reference)) {
|
|
559
|
+
return failWithValue(
|
|
560
|
+
`Invalid npm reference: "${reference}". Use npm:<package> or a valid npmjs package URL.`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
426
564
|
const gitUrlResult = validateGitUrl(reference);
|
|
427
565
|
if (gitUrlResult.valid) return gitUrlResult;
|
|
428
566
|
|
|
429
567
|
return failWithValue(
|
|
430
|
-
`Invalid resource reference: "${reference}". Use an existing resource name
|
|
568
|
+
`Invalid resource reference: "${reference}". Use an existing resource name, a valid HTTPS git URL, or an npm reference (npm:<package> or npmjs.com package URL).`
|
|
431
569
|
);
|
|
432
570
|
};
|
|
433
571
|
|