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.
@@ -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 { BtcaFsResource, BtcaGitResourceArgs, BtcaLocalResourceArgs } from './types.ts';
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 = (url: string): string => {
22
- const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
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): GitResource | null => {
83
- const gitUrlResult = validateGitUrl(reference);
84
- if (!gitUrlResult.valid) return null;
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 normalizedUrl = gitUrlResult.value;
87
- return {
88
- type: 'git',
89
- name: `${ANON_PREFIX}${normalizedUrl}`,
90
- url: normalizedUrl,
91
- branch: DEFAULT_ANON_BRANCH
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
  };
@@ -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
+ };
@@ -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
  }
@@ -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(['svelte', 'https://github.com/sveltejs/svelte.dev']);
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
  });
@@ -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 or a valid HTTPS git URL.`
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