@wildo-ai/wildo-module-client 1.1.4 → 1.1.6

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.
Files changed (53) hide show
  1. package/dist/esm/index.d.ts.map +1 -1
  2. package/dist/esm/services/_http-client.d.ts +32 -3
  3. package/dist/esm/services/_http-client.d.ts.map +1 -1
  4. package/dist/esm/services/_http-client.js +100 -6
  5. package/dist/esm/services/_http-client.js.map +1 -1
  6. package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -1
  7. package/dist/esm/services/index.d.ts.map +1 -1
  8. package/dist/esm/services/lockfile-repair.service.d.ts +8 -23
  9. package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -1
  10. package/dist/esm/services/lockfile-repair.service.js +10 -28
  11. package/dist/esm/services/lockfile-repair.service.js.map +1 -1
  12. package/dist/esm/services/module-adopter.service.d.ts +1 -1
  13. package/dist/esm/services/module-adopter.service.d.ts.map +1 -1
  14. package/dist/esm/services/module-adopter.service.js +7 -8
  15. package/dist/esm/services/module-adopter.service.js.map +1 -1
  16. package/dist/esm/services/module-packager.service.d.ts.map +1 -1
  17. package/dist/esm/services/module-packager.service.js +16 -19
  18. package/dist/esm/services/module-packager.service.js.map +1 -1
  19. package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -1
  20. package/dist/esm/services/provider-sync-runner.d.ts +2 -3
  21. package/dist/esm/services/provider-sync-runner.d.ts.map +1 -1
  22. package/dist/esm/services/provider-sync-runner.js +14 -2
  23. package/dist/esm/services/provider-sync-runner.js.map +1 -1
  24. package/dist/esm/services/registry-companion-client.d.ts +10 -25
  25. package/dist/esm/services/registry-companion-client.d.ts.map +1 -1
  26. package/dist/esm/services/registry-companion-client.js +9 -83
  27. package/dist/esm/services/registry-companion-client.js.map +1 -1
  28. package/dist/esm/services/registry-lockfile.service.d.ts +7 -3
  29. package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -1
  30. package/dist/esm/services/registry-lockfile.service.js +8 -3
  31. package/dist/esm/services/registry-lockfile.service.js.map +1 -1
  32. package/dist/esm/services/registry-server-client.d.ts +35 -9
  33. package/dist/esm/services/registry-server-client.d.ts.map +1 -1
  34. package/dist/esm/services/registry-server-client.js +115 -58
  35. package/dist/esm/services/registry-server-client.js.map +1 -1
  36. package/dist/tsconfig.build.tsbuildinfo +1 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/lockfile-repair.service.test.ts +2 -2
  40. package/src/__tests__/module-adopter.service.test.ts +6 -4
  41. package/src/__tests__/module-packager.service.test.ts +36 -34
  42. package/src/__tests__/provider-sync-runner.test.ts +12 -0
  43. package/src/__tests__/registry-lockfile.service.test.ts +3 -3
  44. package/src/__tests__/registry-server-client.test.ts +129 -0
  45. package/src/__tests__/registry-transfer-timeouts.test.ts +94 -0
  46. package/src/services/_http-client.ts +100 -6
  47. package/src/services/lockfile-repair.service.ts +9 -34
  48. package/src/services/module-adopter.service.ts +6 -9
  49. package/src/services/module-packager.service.ts +19 -16
  50. package/src/services/provider-sync-runner.ts +16 -5
  51. package/src/services/registry-companion-client.ts +10 -132
  52. package/src/services/registry-lockfile.service.ts +9 -3
  53. package/src/services/registry-server-client.ts +147 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildo-ai/wildo-module-client",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Module-registry runtime services consumed by wildo-cli and the dev companion (Phase 0.5 skeleton)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Wildo AI",
@@ -30,7 +30,7 @@
30
30
  "test:watch": "vitest"
31
31
  },
32
32
  "dependencies": {
33
- "@wildo-ai/platform-config-lib": "1.1.4"
33
+ "@wildo-ai/platform-config-lib": "1.1.6"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^24.0.15",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Phase 2 tests for `LockfileRepairService`.
2
+ * Read-only inspector tests for `LockfileRepairService`.
3
3
  *
4
4
  * The service is read-only and produces a structured report.
5
5
  * Tests pin every finding kind:
@@ -7,7 +7,7 @@
7
7
  * - malformed envelope → error finding.
8
8
  * - malformed modules section → error finding.
9
9
  * - lockfile entry pointing at a missing file → warn finding.
10
- * - lockfile entry with non-sha256 digest → warn finding.
10
+ * Malformed-digest dispatch and healthy/structure controls are covered by the CLI repair-contract suite.
11
11
  */
12
12
 
13
13
  import { promises as fs } from 'node:fs';
@@ -13,6 +13,7 @@ import { promises as fs } from 'node:fs';
13
13
  import * as os from 'node:os';
14
14
  import * as path from 'node:path';
15
15
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
+ import { ModuleManifestSchema } from '@wildo-ai/platform-config-lib';
16
17
  import { ModuleAdopterService } from '../services/module-adopter.service';
17
18
 
18
19
  let saasRoot: string;
@@ -25,7 +26,8 @@ afterEach(async () => {
25
26
  await fs.rm(saasRoot, { recursive: true, force: true });
26
27
  });
27
28
 
28
- const manifest = {
29
+ // Parsed, so the fixture has the manifest's OUTPUT type: defaults (`kind`, every boundary layer) included.
30
+ const manifest = ModuleManifestSchema.parse({
29
31
  id: 'acme/billing',
30
32
  version: '1.2.3',
31
33
  boundaries: {
@@ -36,7 +38,7 @@ const manifest = {
36
38
  minions: [],
37
39
  workers: [],
38
40
  },
39
- };
41
+ });
40
42
 
41
43
  async function seedFile(rel: string, contents: string): Promise<void> {
42
44
  const abs = path.join(saasRoot, rel);
@@ -67,10 +69,10 @@ describe('ModuleAdopterService.discoverModuleFiles', () => {
67
69
 
68
70
  it('returns an empty plan when the manifest declares no boundaries', async () => {
69
71
  await seedFile('anywhere.ts', 'x');
70
- const plan = await ModuleAdopterService.discoverModuleFiles(saasRoot, {
72
+ const plan = await ModuleAdopterService.discoverModuleFiles(saasRoot, ModuleManifestSchema.parse({
71
73
  id: 'acme/x',
72
74
  version: '0.0.1',
73
- });
75
+ }));
74
76
  expect(plan.files).toEqual([]);
75
77
  expect(plan.emptyBoundaries).toEqual([]);
76
78
  });
@@ -23,6 +23,7 @@ import {
23
23
  getModuleStagingPaths,
24
24
  assertWithinBoundaries,
25
25
  } from '../services/module-packager.service';
26
+ import { ModuleManifestSchema, RegistryBoundaryViolationError } from '@wildo-ai/platform-config-lib';
26
27
 
27
28
  let saasRoot: string;
28
29
 
@@ -34,7 +35,8 @@ afterEach(async () => {
34
35
  await fs.rm(saasRoot, { recursive: true, force: true });
35
36
  });
36
37
 
37
- const baseManifest = (id = 'acme/billing', version = '1.2.3') => ({
38
+ // Parsed, so the fixture has the manifest's OUTPUT type: defaults (`kind`, every boundary layer) included.
39
+ const baseManifest = (id = 'acme/billing', version = '1.2.3') => ModuleManifestSchema.parse({
38
40
  id,
39
41
  version,
40
42
  description: 'Billing module',
@@ -107,9 +109,18 @@ describe('assertWithinBoundaries (KD-12)', () => {
107
109
  expect(result.endsWith('shared-lib/src/modules/acme-billing/index.ts')).toBe(true);
108
110
  });
109
111
 
110
- it('skips boundary checks when no boundaries are declared (manifest.boundaries undefined)', () => {
111
- const result = assertWithinBoundaries(saasRoot, 'acme/billing', 'anywhere.ts', undefined);
112
- expect(result.endsWith('anywhere.ts')).toBe(true);
112
+ // A module that declares no boundary may write nowhere. This used to return early and admit every
113
+ // path in the application — the fail-open reading of an absent declaration.
114
+ it('refuses every path when no boundaries are declared (manifest.boundaries undefined)', () => {
115
+ expect(() => assertWithinBoundaries(saasRoot, 'acme/billing', 'anywhere.ts', undefined)).toThrow(
116
+ RegistryBoundaryViolationError,
117
+ );
118
+ });
119
+
120
+ it('refuses a dot-file inside a declared boundary (a generated .env must never be packed)', () => {
121
+ expect(() =>
122
+ assertWithinBoundaries(saasRoot, 'acme/billing', 'shared-lib/src/modules/acme-billing/.env', baseManifest().boundaries),
123
+ ).toThrow(RegistryBoundaryViolationError);
113
124
  });
114
125
  });
115
126
 
@@ -218,41 +229,32 @@ describe('ModulePackagerService.computeStagingDigest', () => {
218
229
  });
219
230
 
220
231
  /**
221
- * 7th-pass C-L1 — root edge case for `isPathInside`. The helper is
222
- * invoked indirectly through `assertWithinBoundaries`; we exercise
223
- * the degenerate filesystem-root case by pointing `saasRoot` at `/`
224
- * and confirming a child path inside `/` is accepted as "within
225
- * boundaries" when the manifest declares `boundaries.shared: ['./']`
226
- * (relative path that resolves to `/`).
227
- *
228
- * Vanishingly unlikely in production (containerized SaaS roots are
229
- * never literally `/`) but the helper is also reusable for zip-slip
230
- * guards where the assumption is weaker — pinning the behavior
231
- * prevents a future "tighten startsWith" refactor from rejecting
232
- * every child.
232
+ * 7th-pass C-L1 — root edge case for `isPathInside`, which treats the filesystem root as containing
233
+ * every absolute path. Exercised with a LEGAL boundary: the pin used to declare `shared: ['.']`, a
234
+ * boundary the manifest contract now refuses ("the whole application root"), and passed only because
235
+ * the typed object never went through the schema.
233
236
  */
234
237
  describe('assertWithinBoundaries — root edge case (C-L1)', () => {
235
- it('accepts a child path when saasRoot is the filesystem root', () => {
236
- // Use POSIX path.sep as the boundary; the helper resolves
237
- // `path.resolve('/', '.')` → `/` and then `isPathInside('/foo', '/')`
238
- // → true via the new root special-case.
239
- const manifestWithRootBoundary = {
240
- ...baseManifest(),
241
- boundaries: {
242
- specifications: [],
243
- shared: ['.'],
244
- frontend: [],
245
- backend: [],
246
- minions: [],
247
- workers: [],
248
- },
249
- };
250
- // Run only on POSIX; Windows root is `C:\` and tests run on
251
- // POSIX CI.
238
+ it('accepts a file inside a declared boundary when saasRoot is the filesystem root', () => {
239
+ // POSIX only; Windows root is `C:\` and tests run on POSIX CI.
252
240
  if (path.sep === '/') {
253
241
  expect(() =>
254
- assertWithinBoundaries('/', 'acme/billing', 'foo/bar.ts', manifestWithRootBoundary.boundaries),
242
+ assertWithinBoundaries('/', 'acme/billing', 'shared-lib/src/modules/acme-billing/a.ts', baseManifest().boundaries),
255
243
  ).not.toThrow();
256
244
  }
257
245
  });
258
246
  });
247
+
248
+ describe('getModuleStagingPaths — the staging root a recursive delete runs on', () => {
249
+ // `stageFresh` deletes this path recursively. `a/../../../..` passed the old "contains a `/`" rule
250
+ // and resolved to the application root.
251
+ it.each(['a/../../../..', 'acme', 'acme/billing/extra', 'acme/.hidden'])('refuses module id %s', (id) => {
252
+ expect(() => getModuleStagingPaths(saasRoot, id)).toThrow(TypeError);
253
+ });
254
+
255
+ it('resolves a well-formed id below the artifacts directory', () => {
256
+ expect(getModuleStagingPaths(saasRoot, 'acme/billing').stagingRoot).toBe(
257
+ path.join(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR, 'acme/billing'),
258
+ );
259
+ });
260
+ });
@@ -47,6 +47,18 @@ describe('runProviderSync (dry-run)', () => {
47
47
  });
48
48
  });
49
49
 
50
+ describe('runProviderSync (a chatty child)', () => {
51
+ // `wildo config sync` prints progress. Its stdout used to be PIPED and never read, so once the child
52
+ // wrote more than the OS pipe buffer (~64 KB) it blocked on the write, never exited, and the runner
53
+ // reported a timeout for a sync that had in fact finished its work.
54
+ it('completes when the child writes far more than a pipe buffer to stdout', async () => {
55
+ const stub = await writeStub('wildo', "#!/bin/sh\nhead -c 1048576 /dev/zero | tr '\\000' x\nexit 0\n");
56
+ const result = await runProviderSync({ saasRoot, reason: 'install', wildoBinPath: stub, timeoutMs: 5000 });
57
+ expect(result.mode).toBe('shell-out');
58
+ expect(result.exitCode).toBe(0);
59
+ }, 20000);
60
+ });
61
+
50
62
  describe('runProviderSync (shell-out — success)', () => {
51
63
  it('returns mode:shell-out and the stub exit code (0)', async () => {
52
64
  const stub = await writeStub('wildo', '#!/bin/sh\nexit 0\n');
@@ -14,7 +14,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
14
  import { RegistryLockfileService } from '../services/registry-lockfile.service';
15
15
  // L6 (third-pass review): the symbol is no longer re-exported from
16
16
  // `wildo-module-client`; import it directly from `platform-config-lib`.
17
- import { WILDO_SAAS_LOCK_RELATIVE_PATH } from '@wildo-ai/platform-config-lib';
17
+ import { RegistryModulesLockEntrySchema, WILDO_SAAS_LOCK_RELATIVE_PATH } from '@wildo-ai/platform-config-lib';
18
18
 
19
19
  let saasRoot: string;
20
20
 
@@ -86,7 +86,7 @@ describe('RegistryLockfileService', () => {
86
86
  ...current,
87
87
  installed: {
88
88
  ...current.installed,
89
- 'acme/billing': {
89
+ 'acme/billing': RegistryModulesLockEntrySchema.parse({
90
90
  moduleId: 'acme/billing',
91
91
  registryId: 'wildo',
92
92
  registryUrl: 'https://registry.wildo.ai',
@@ -96,7 +96,7 @@ describe('RegistryLockfileService', () => {
96
96
  installedAt: '2026-05-05T12:00:00.000Z',
97
97
  files: [],
98
98
  source: 'install',
99
- },
99
+ }),
100
100
  },
101
101
  }));
102
102
 
@@ -14,11 +14,17 @@ import {
14
14
  RegistryArtifactIntegrityMismatchError,
15
15
  RegistryAuthForbiddenError,
16
16
  RegistryAuthRequiredError,
17
+ RegistryCredentialRejectedError,
17
18
  RegistryMalformedPayloadError,
18
19
  RegistryModuleNotFoundError,
20
+ RegistryRateLimitedError,
21
+ RegistryUnexpectedStatusError,
19
22
  RegistryVersionNotFoundError,
23
+ RegistryYankStateConflictError,
24
+ RegistryYankVerb,
20
25
  } from '@wildo-ai/platform-config-lib';
21
26
  import { RegistryServerClient } from '../services/registry-server-client';
27
+ import { ModuleKind, type ModuleSearchFilter } from '@wildo-ai/platform-config-lib';
22
28
 
23
29
  interface TestServer {
24
30
  baseUrl: string;
@@ -151,6 +157,32 @@ describe('RegistryServerClient.resolve', () => {
151
157
  ).rejects.toBeInstanceOf(RegistryAuthRequiredError);
152
158
  });
153
159
 
160
+ it('tells a REJECTED key from a missing one: a 401 to a request that sent a key is CredentialRejected (#1759 review)', async () => {
161
+ svc = await startServer((req, res) => {
162
+ res.writeHead(401);
163
+ res.end();
164
+ });
165
+ const error = await RegistryServerClient.resolve(
166
+ { baseUrl: svc.baseUrl, authToken: 'sk_org_revoked' },
167
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
168
+ ).catch((err: unknown) => err);
169
+ expect(error).toBeInstanceOf(RegistryCredentialRejectedError);
170
+ expect(error).not.toBeInstanceOf(RegistryAuthRequiredError);
171
+ });
172
+
173
+ it('types a 429 as RegistryRateLimitedError, carrying Retry-After seconds', async () => {
174
+ svc = await startServer((req, res) => {
175
+ res.writeHead(429, { 'retry-after': '600' });
176
+ res.end();
177
+ });
178
+ const error = await RegistryServerClient.resolve(
179
+ { baseUrl: svc.baseUrl, authToken: 'sk_org_x' },
180
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
181
+ ).catch((err: unknown) => err);
182
+ expect(error).toBeInstanceOf(RegistryRateLimitedError);
183
+ expect((error as RegistryRateLimitedError).retryAfterSeconds).toBe(600);
184
+ });
185
+
154
186
  it('throws RegistryAuthForbiddenError on 403', async () => {
155
187
  svc = await startServer((req, res) => {
156
188
  res.writeHead(403);
@@ -287,6 +319,38 @@ describe('RegistryServerClient — parseJsonOrThrow (malformed 2xx body)', () =>
287
319
  });
288
320
  });
289
321
 
322
+ describe('RegistryServerClient.search — manifest filters (#2014)', () => {
323
+ it('sends only the filters given, and reads a provider hit with its kind and provider facts', async () => {
324
+ let requested = '';
325
+ svc = await startServer((req, res) => {
326
+ requested = req.url ?? '';
327
+ res.writeHead(200, { 'content-type': 'application/json' });
328
+ res.end(JSON.stringify({ hits: [
329
+ { moduleId: 'wildo/vlm-run', latestVersion: '2.0.0', kind: 'provider', provider: { ref: 'vlm-run', maturity: 'adopted', engineCapabilities: ['AI_DOCUMENT_EXTRACTION'] } },
330
+ // A registry deployed before #2014 sends neither field.
331
+ { moduleId: 'acme/billing', latestVersion: '1.0.0' },
332
+ ] }));
333
+ });
334
+ const hits = await RegistryServerClient.search(
335
+ { baseUrl: svc.baseUrl },
336
+ { domain: 'wildo', query: 'vlm', filter: { kind: ModuleKind.PROVIDER, engineCapability: 'AI_DOCUMENT_EXTRACTION' as ModuleSearchFilter['engineCapability'] } },
337
+ );
338
+ expect(requested).toBe('/v1/registry/wildo/modules?q=vlm&kind=provider&engineCapability=AI_DOCUMENT_EXTRACTION');
339
+ expect(hits[0]?.provider?.engineCapabilities).toEqual(['AI_DOCUMENT_EXTRACTION']);
340
+ // Re-applied client-side (#2014 review F9): a server that ignored the filter cannot make it fail open.
341
+ expect(hits.map((hit) => hit.moduleId)).toEqual(['wildo/vlm-run']);
342
+ });
343
+
344
+ it('keeps every hit when no filter is given, including one from a server that predates #2014', async () => {
345
+ svc = await startServer((_req, res) => {
346
+ res.writeHead(200, { 'content-type': 'application/json' });
347
+ res.end(JSON.stringify({ hits: [{ moduleId: 'acme/billing', latestVersion: '1.0.0' }] }));
348
+ });
349
+ const hits = await RegistryServerClient.search({ baseUrl: svc.baseUrl }, { domain: 'wildo', query: '' });
350
+ expect(hits.map((hit) => hit.kind ?? 'absent')).toEqual(['absent']);
351
+ });
352
+ });
353
+
290
354
  /**
291
355
  * 7th-pass C-M1: `download()` argument-shape violation. Caller passes
292
356
  * a malformed `expectedDigest` — must throw `TypeError` (not Zod's
@@ -310,3 +374,68 @@ describe('RegistryServerClient.download — malformed expectedDigest (C-M1)', ()
310
374
  ).rejects.toBeInstanceOf(TypeError);
311
375
  });
312
376
  });
377
+
378
+ describe('RegistryServerClient.yank / unyank (#1758)', () => {
379
+ it('posts the version and reason to the verb route, and returns where latest points afterwards', async () => {
380
+ let seen: { url?: string; method?: string; body?: unknown; auth?: string } = {};
381
+ svc = await startServer(async (req, res) => {
382
+ let raw = '';
383
+ for await (const chunk of req) raw += chunk;
384
+ seen = { url: req.url, method: req.method, body: JSON.parse(raw), auth: req.headers.authorization };
385
+ res.writeHead(200, { 'content-type': 'application/json' });
386
+ res.end(JSON.stringify({ ok: true, latest: '1.1.0' }));
387
+ });
388
+ const result = await RegistryServerClient.yank(
389
+ { baseUrl: svc.baseUrl, authToken: 'sk_org_k' },
390
+ { domain: 'acme', moduleId: 'acme/billing', version: '1.2.0', reason: 'broken migration' },
391
+ );
392
+ expect(result).toEqual({ latest: '1.1.0' });
393
+ expect(seen).toEqual({
394
+ url: '/v1/registry/acme/modules/acme%2Fbilling/yank',
395
+ method: 'POST',
396
+ body: { version: '1.2.0', reason: 'broken migration' },
397
+ auth: 'Bearer sk_org_k',
398
+ });
399
+ });
400
+
401
+ it('reports a module whose releases are all yanked as latest: null', async () => {
402
+ svc = await startServer((req, res) => {
403
+ res.writeHead(200, { 'content-type': 'application/json' });
404
+ res.end(JSON.stringify({ ok: true, latest: null }));
405
+ });
406
+ expect(await RegistryServerClient.unyank({ baseUrl: svc.baseUrl }, { domain: 'acme', moduleId: 'acme/billing', version: '1.0.0' }))
407
+ .toEqual({ latest: null });
408
+ });
409
+
410
+ /** The engine's sanitized error envelope, as the registry sends it — wire values pinned on purpose. */
411
+ const conflictBody = (customMessageReference: string, code?: string) =>
412
+ JSON.stringify({ error: { type: 'CONFLICT', customMessageReference, ...(code ? { details: { code } } : {}) } });
413
+
414
+ it.each([RegistryYankVerb.YANK, RegistryYankVerb.UNYANK] as const)('types a release-state 409 on %s as the state conflict it is', async (verb) => {
415
+ svc = await startServer((req, res) => {
416
+ res.writeHead(409, { 'content-type': 'application/json' });
417
+ res.end(conflictBody('conflict', 'registry_release_state'));
418
+ });
419
+ const call = verb === RegistryYankVerb.YANK ? RegistryServerClient.yank : RegistryServerClient.unyank;
420
+ const error = await call({ baseUrl: svc.baseUrl }, { domain: 'acme', moduleId: 'acme/billing', version: '1.0.0' }).catch((err: unknown) => err);
421
+ expect(error).toBeInstanceOf(RegistryYankStateConflictError);
422
+ expect((error as RegistryYankStateConflictError).verb).toBe(verb);
423
+ });
424
+
425
+ it('never reports a 409 for any OTHER reason as "already yanked" (#1758 re-review R3)', async () => {
426
+ // The registry answers 409 VERSION_CONFLICT when it gives up after lost optimistic checks: the yank
427
+ // did NOT happen, and calling it a no-op would tell an operator a compromised release is withdrawn.
428
+ // A bare generic `conflict` is NOT enough: about twenty engine refusals share that reference.
429
+ for (const body of [conflictBody('version_conflict'), conflictBody('conflict'), '', 'not json']) {
430
+ svc = await startServer((req, res) => {
431
+ res.writeHead(409, { 'content-type': 'application/json' });
432
+ res.end(body);
433
+ });
434
+ const error = await RegistryServerClient.yank({ baseUrl: svc.baseUrl }, { domain: 'acme', moduleId: 'acme/billing', version: '1.0.0' }).catch((err: unknown) => err);
435
+ expect(error, body).not.toBeInstanceOf(RegistryYankStateConflictError);
436
+ expect(error, body).toBeInstanceOf(RegistryUnexpectedStatusError);
437
+ await svc.close();
438
+ svc = null;
439
+ }
440
+ });
441
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Registry timeouts (#1763): a download is bounded by SILENCE, not by its size, and every timeout is a
3
+ * typed `RegistryRequestTimeoutError` naming its phase.
4
+ *
5
+ * Before: one 10 s signal covered a download's whole body, so an artifact taking longer than that could
6
+ * never install on any link; and a timeout escaped as a raw `AbortError` (or, mid-JSON, as a "malformed
7
+ * payload"), so no caller could tell slow from refused.
8
+ */
9
+
10
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+ import { RegistryRequestTimeoutError, RegistryTimeoutPhase, isRegistryError } from '@wildo-ai/platform-config-lib';
13
+ import { fetchTransfer } from '../services/_http-client';
14
+ import { RegistryServerClient } from '../services/registry-server-client';
15
+
16
+ let server: Server | undefined;
17
+
18
+ async function serve(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<string> {
19
+ server = createServer(handler);
20
+ await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', resolve));
21
+ const address = server.address();
22
+ if (!address || typeof address === 'string') throw new Error('no address');
23
+ return `http://127.0.0.1:${address.port}`;
24
+ }
25
+
26
+ afterEach(async () => {
27
+ if (!server) return;
28
+ // Stalled responses are held open on purpose; drop them so close() can return.
29
+ server.closeAllConnections();
30
+ await new Promise<void>((resolve) => server!.close(() => resolve()));
31
+ server = undefined;
32
+ });
33
+
34
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
35
+
36
+ describe('a download is bounded by silence, not by size (#1763)', () => {
37
+ it('finishes a transfer that takes far longer than the request budget while bytes keep arriving', async () => {
38
+ const base = await serve(async (_req, res) => {
39
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
40
+ for (let i = 0; i < 8; i += 1) {
41
+ res.write(Buffer.from([i]));
42
+ await sleep(60);
43
+ }
44
+ res.end();
45
+ });
46
+ const { response, readAll } = await fetchTransfer(`${base}/artifact`, { timeoutMs: 150, idleTimeoutMs: 200 });
47
+ expect(response.status).toBe(200);
48
+ // ~480 ms of body against a 150 ms request budget: only an idle budget lets this finish.
49
+ expect(Array.from(await readAll())).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
50
+ });
51
+
52
+ it('fails a transfer that stalls part-way, as a typed TRANSFER_STALLED timeout', async () => {
53
+ const base = await serve((_req, res) => {
54
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
55
+ res.write(Buffer.from([1, 2, 3]));
56
+ // ...and never another byte.
57
+ });
58
+ const { readAll } = await fetchTransfer(`${base}/artifact`, { timeoutMs: 500, idleTimeoutMs: 150 });
59
+ const error = await readAll().catch((err: unknown) => err);
60
+ expect(error).toBeInstanceOf(RegistryRequestTimeoutError);
61
+ expect((error as RegistryRequestTimeoutError).phase).toBe(RegistryTimeoutPhase.TRANSFER_STALLED);
62
+ expect((error as RegistryRequestTimeoutError).timeoutMs).toBe(150);
63
+ });
64
+
65
+ it('fails a registry that never answers, as a typed AWAITING_RESPONSE timeout', async () => {
66
+ const base = await serve(() => { /* never responds */ });
67
+ const error = await fetchTransfer(`${base}/artifact`, { timeoutMs: 150 }).catch((err: unknown) => err);
68
+ expect(error).toBeInstanceOf(RegistryRequestTimeoutError);
69
+ expect((error as RegistryRequestTimeoutError).phase).toBe(RegistryTimeoutPhase.AWAITING_RESPONSE);
70
+ });
71
+ });
72
+
73
+ describe('metadata requests time out in the typed vocabulary (#1763)', () => {
74
+ it('a resolve that never answers is a typed timeout', async () => {
75
+ const base = await serve(() => { /* never responds */ });
76
+ const error = await RegistryServerClient.resolve({ baseUrl: base, timeoutMs: 150 }, { domain: 'd', moduleId: 'acme/x', range: '^1.0.0' }).catch((err: unknown) => err);
77
+ expect(isRegistryError(error)).toBe(true);
78
+ expect(error).toBeInstanceOf(RegistryRequestTimeoutError);
79
+ });
80
+
81
+ it('a JSON body cut short by the budget is a TIMEOUT, not a malformed payload', async () => {
82
+ const base = await serve((_req, res) => {
83
+ res.writeHead(200, { 'content-type': 'application/json' });
84
+ res.write('{"version": "1.0');
85
+ // ...and the rest never arrives.
86
+ });
87
+ const error = await RegistryServerClient.resolve({ baseUrl: base, timeoutMs: 200 }, { domain: 'd', moduleId: 'acme/x', range: '^1.0.0' }).catch((err: unknown) => err);
88
+ expect(error).toBeInstanceOf(RegistryRequestTimeoutError);
89
+ // The headers arrived, so it is the BODY that ran out, and the budget reported is the caller's own
90
+ // (#1763 review: it used to say AWAITING_RESPONSE and the 10 s default).
91
+ expect((error as RegistryRequestTimeoutError).phase).toBe(RegistryTimeoutPhase.READING_BODY);
92
+ expect((error as RegistryRequestTimeoutError).timeoutMs).toBe(200);
93
+ });
94
+ });
@@ -5,9 +5,12 @@
5
5
  * Both clients want the same primitives:
6
6
  * - `joinUrl(base, path)` — single-source-of-truth path joiner
7
7
  * that tolerates leading/trailing slashes;
8
- * - `fetchWithTimeout(url, init, timeoutMs)` — abort-on-timeout
9
- * wrapper that converts `AbortError`/`TimeoutError` into a
10
- * uniform diagnostic message;
8
+ * - `fetchWithTimeout(url, init)` — one budget for a metadata
9
+ * exchange; an abort becomes a typed `RegistryRequestTimeoutError`;
10
+ * - `readJsonBody(...)` — reads that exchange's JSON body, typing a
11
+ * body cut short by the same budget as a `READING_BODY` timeout;
12
+ * - `fetchTransfer(url, init)` — an artifact download: a response
13
+ * budget plus an idle budget between body chunks (#1763);
11
14
  * - `fetchJsonOk(...)` — convenience for liveness probes that
12
15
  * want a boolean answer.
13
16
  *
@@ -17,8 +20,17 @@
17
20
  * download) extend this file rather than copy the primitives.
18
21
  */
19
22
 
23
+ import { isAbortLikeError, RegistryRequestTimeoutError, RegistryTimeoutPhase } from '@wildo-ai/platform-config-lib';
24
+
20
25
  const DEFAULT_TIMEOUT_MS = 10_000;
21
26
 
27
+ /**
28
+ * How long a DOWNLOAD may go without receiving a single byte (#1763). An idle budget, not a cap on the
29
+ * whole body: a large artifact over a slow but moving link finishes, a stalled one fails. 30 s is the
30
+ * order npm and cargo tolerate before calling a transfer stuck.
31
+ */
32
+ export const REGISTRY_TRANSFER_IDLE_TIMEOUT_MS = 30_000;
33
+
22
34
  export function joinUrl(base: string, path: string): string {
23
35
  const normalized = path.startsWith('/') ? path : `/${path}`;
24
36
  return `${base.replace(/\/$/, '')}${normalized}`;
@@ -53,13 +65,95 @@ export async function fetchWithTimeout(
53
65
  try {
54
66
  return await fetch(url, { ...rest, signal });
55
67
  } catch (err) {
56
- if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
57
- throw new Error(`${label} request timed out after ${timeoutMs}ms (${url})`);
58
- }
68
+ // Typed, so an `isRegistryError(err)` handler sees it (#1763). The budget spans the whole exchange,
69
+ // which is right for a small metadata response; a body read the same signal aborts is translated by
70
+ // the reader (see `readJsonBody`).
71
+ if (isAbortLikeError(err)) throw new RegistryRequestTimeoutError(url, label, RegistryTimeoutPhase.AWAITING_RESPONSE, timeoutMs);
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Read a small JSON body fetched through {@link fetchWithTimeout}. A read cut short by that request's
78
+ * timeout is a TIMEOUT, never a malformed payload (it used to be reported as one).
79
+ */
80
+ export async function readJsonBody(res: Response, url: string, label: string, timeoutMs: number | undefined): Promise<unknown> {
81
+ try {
82
+ return await res.json();
83
+ } catch (err) {
84
+ // The headers arrived; the body is what ran out of time (#1763 review).
85
+ if (isAbortLikeError(err)) throw new RegistryRequestTimeoutError(url, label, RegistryTimeoutPhase.READING_BODY, timeoutMs ?? DEFAULT_TIMEOUT_MS);
59
86
  throw err;
60
87
  }
61
88
  }
62
89
 
90
+ export interface TransferInit extends FetchWithTimeoutInit {
91
+ /** No-bytes budget for the body (default {@link REGISTRY_TRANSFER_IDLE_TIMEOUT_MS}). */
92
+ idleTimeoutMs?: number;
93
+ }
94
+
95
+ /**
96
+ * Fetch for a TRANSFER — an artifact download (#1763). Two budgets, because a metadata timeout cannot
97
+ * be sized for a body of unknown length:
98
+ * - `timeoutMs` bounds the wait for the RESPONSE (status + headers);
99
+ * - `idleTimeoutMs` bounds the silence between body chunks, reset on every chunk.
100
+ * Both aborts surface as `RegistryRequestTimeoutError`, with the phase that fired.
101
+ */
102
+ export async function fetchTransfer(url: string, init: TransferInit = {}): Promise<{ response: Response; readAll: () => Promise<Uint8Array> }> {
103
+ const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
104
+ const idleTimeoutMs = init.idleTimeoutMs ?? REGISTRY_TRANSFER_IDLE_TIMEOUT_MS;
105
+ const label = init.diagnosticLabel ?? 'HTTP';
106
+ const { timeoutMs: _t, idleTimeoutMs: _i, diagnosticLabel: _d, ...rest } = init;
107
+ const controller = new AbortController();
108
+ let phase = RegistryTimeoutPhase.AWAITING_RESPONSE;
109
+ let timer = setTimeout(() => controller.abort(), timeoutMs);
110
+ const timeoutError = () =>
111
+ new RegistryRequestTimeoutError(url, label, phase, phase === RegistryTimeoutPhase.AWAITING_RESPONSE ? timeoutMs : idleTimeoutMs);
112
+ let response: Response;
113
+ try {
114
+ response = await fetch(url, { ...rest, signal: controller.signal });
115
+ } catch (err) {
116
+ clearTimeout(timer);
117
+ if (isAbortLikeError(err)) throw timeoutError();
118
+ throw err;
119
+ }
120
+ clearTimeout(timer);
121
+ const readAll = async (): Promise<Uint8Array> => {
122
+ phase = RegistryTimeoutPhase.TRANSFER_STALLED;
123
+ const chunks: Uint8Array[] = [];
124
+ let total = 0;
125
+ const reader = response.body?.getReader();
126
+ if (!reader) return new Uint8Array(0);
127
+ const arm = () => {
128
+ clearTimeout(timer);
129
+ timer = setTimeout(() => controller.abort(), idleTimeoutMs);
130
+ };
131
+ arm();
132
+ try {
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ chunks.push(value);
137
+ total += value.byteLength;
138
+ arm();
139
+ }
140
+ } catch (err) {
141
+ if (isAbortLikeError(err) || controller.signal.aborted) throw timeoutError();
142
+ throw err;
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ const bytes = new Uint8Array(total);
147
+ let offset = 0;
148
+ for (const chunk of chunks) {
149
+ bytes.set(chunk, offset);
150
+ offset += chunk.byteLength;
151
+ }
152
+ return bytes;
153
+ };
154
+ return { response, readAll };
155
+ }
156
+
63
157
  /**
64
158
  * Throws on non-2xx; returns the Response otherwise.
65
159
  *