@wildo-ai/wildo-module-client 1.1.5 → 1.1.7
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/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/services/_http-client.d.ts +32 -3
- package/dist/esm/services/_http-client.d.ts.map +1 -1
- package/dist/esm/services/_http-client.js +100 -6
- package/dist/esm/services/_http-client.js.map +1 -1
- package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -1
- package/dist/esm/services/index.d.ts.map +1 -1
- package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -1
- package/dist/esm/services/module-adopter.service.d.ts +1 -1
- package/dist/esm/services/module-adopter.service.d.ts.map +1 -1
- package/dist/esm/services/module-adopter.service.js +7 -8
- package/dist/esm/services/module-adopter.service.js.map +1 -1
- package/dist/esm/services/module-packager.service.d.ts.map +1 -1
- package/dist/esm/services/module-packager.service.js +16 -19
- package/dist/esm/services/module-packager.service.js.map +1 -1
- package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -1
- package/dist/esm/services/provider-sync-runner.d.ts +2 -3
- package/dist/esm/services/provider-sync-runner.d.ts.map +1 -1
- package/dist/esm/services/provider-sync-runner.js +14 -2
- package/dist/esm/services/provider-sync-runner.js.map +1 -1
- package/dist/esm/services/registry-companion-client.d.ts +10 -47
- package/dist/esm/services/registry-companion-client.d.ts.map +1 -1
- package/dist/esm/services/registry-companion-client.js +9 -97
- package/dist/esm/services/registry-companion-client.js.map +1 -1
- package/dist/esm/services/registry-lockfile.service.d.ts +7 -3
- package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -1
- package/dist/esm/services/registry-lockfile.service.js +8 -3
- package/dist/esm/services/registry-lockfile.service.js.map +1 -1
- package/dist/esm/services/registry-server-client.d.ts +35 -9
- package/dist/esm/services/registry-server-client.d.ts.map +1 -1
- package/dist/esm/services/registry-server-client.js +115 -58
- package/dist/esm/services/registry-server-client.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/__tests__/module-adopter.service.test.ts +6 -4
- package/src/__tests__/module-packager.service.test.ts +36 -34
- package/src/__tests__/provider-sync-runner.test.ts +12 -0
- package/src/__tests__/registry-lockfile.service.test.ts +3 -3
- package/src/__tests__/registry-server-client.test.ts +129 -0
- package/src/__tests__/registry-transfer-timeouts.test.ts +94 -0
- package/src/services/_http-client.ts +100 -6
- package/src/services/module-adopter.service.ts +6 -9
- package/src/services/module-packager.service.ts +19 -16
- package/src/services/provider-sync-runner.ts +16 -5
- package/src/services/registry-companion-client.ts +10 -166
- package/src/services/registry-lockfile.service.ts +9 -3
- package/src/services/registry-server-client.ts +147 -57
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.js.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.js.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.js.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.js.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.js.map +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wildo-ai/wildo-module-client",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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.
|
|
33
|
+
"@wildo-ai/platform-config-lib": "1.1.7"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^24.0.15",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
222
|
-
*
|
|
223
|
-
* the
|
|
224
|
-
*
|
|
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
|
|
236
|
-
//
|
|
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', '
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
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
|
*
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { promises as fs } from 'node:fs';
|
|
24
24
|
import * as path from 'node:path';
|
|
25
|
-
import type
|
|
25
|
+
import { isModuleArtifactPathAllowed, MODULE_BOUNDARY_LAYERS, type ModuleManifest } from '@wildo-ai/platform-config-lib';
|
|
26
26
|
import { ModulePackagerService, type StagingPaths } from './module-packager.service';
|
|
27
27
|
|
|
28
28
|
export interface AdoptionPlan {
|
|
@@ -65,6 +65,9 @@ async function walkBoundary(
|
|
|
65
65
|
const entries = await fs.readdir(path.resolve(saasRoot, cur), { withFileTypes: true });
|
|
66
66
|
for (const entry of entries) {
|
|
67
67
|
const rel = path.join(cur, entry.name);
|
|
68
|
+
// Skip what a module never ships, exactly as `wildo registry publish` does — otherwise the
|
|
69
|
+
// packager (which refuses those paths) throws on a module's own `.env` or `node_modules`.
|
|
70
|
+
if (!isModuleArtifactPathAllowed(rel.split(path.sep).join('/'))) continue;
|
|
68
71
|
if (entry.isDirectory()) {
|
|
69
72
|
queue.push(rel);
|
|
70
73
|
} else if (entry.isFile()) {
|
|
@@ -88,14 +91,8 @@ export const ModuleAdopterService = {
|
|
|
88
91
|
if (!boundaries) {
|
|
89
92
|
return { files: [], emptyBoundaries: [] };
|
|
90
93
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
'shared',
|
|
94
|
-
'frontend',
|
|
95
|
-
'backend',
|
|
96
|
-
'minions',
|
|
97
|
-
'workers',
|
|
98
|
-
];
|
|
94
|
+
// Every layer the registry knows, not a hand-kept copy: a copy here missed `providers` (#2014).
|
|
95
|
+
const layers = MODULE_BOUNDARY_LAYERS;
|
|
99
96
|
const aggregate = new Set<string>();
|
|
100
97
|
const empty: string[] = [];
|
|
101
98
|
for (const layer of layers) {
|