@hanamesh/dsh-app-host 0.2.0-rc.2

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 (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
@@ -0,0 +1,605 @@
1
+ import { randomBytes, randomUUID, createHash, timingSafeEqual } from 'node:crypto';
2
+ import { join, resolve, dirname } from 'node:path';
3
+ import { access, mkdir, writeFile, rm } from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
5
+ import { AppHostError, requireCondition, SerialQueue, copy } from './errors.js';
6
+ import { validateDefinition, identifier, fingerprint, expand, loopbackOrigin, packageName as validPackageName } from './descriptor.js';
7
+ import { secureDirectory } from './store.js';
8
+ import { freePort, spawnOwned, waitReady } from './runtime.js';
9
+ import { FixedGateway, ancestorOrigin } from './gateway.js';
10
+ import { reserveBeforeLaunch, stopBeforePublish } from './consistency.js';
11
+
12
+ const hash = token => createHash('sha256').update(token).digest('hex');
13
+ const leaseKey = (principalId, viewId, generation) => JSON.stringify([principalId, viewId, generation]);
14
+ const active = (lease, now) => lease.status === 'active' && lease.expiresAt > now;
15
+ const publicLease = lease => {
16
+ const { tokenHash, ...visible } = copy(lease);
17
+ return visible;
18
+ };
19
+ const publicInstance = instance => copy(instance);
20
+ const isTerminal = s => ['stopped','failed','interrupted'].includes(s);
21
+
22
+ /** Sole owner of instance records and view leases. No session-log mutation. */
23
+ export class AppHost {
24
+ #queue = new SerialQueue(); #definitions = new Map(); #controls = new Map(); #listeners = new Set();
25
+ #packageNames = new Map(); #activityListeners = new Set();
26
+ #logs = new Map(); #state; #initialized = false; #closing = false; #poisoned = false; #timer; #disposing;
27
+ #credentialResolver = null; #nodeBinary; #runtimeLedgerReader;
28
+ constructor({ store, dataRoot, parentOrigin, frameAncestors = [], leaseTtlMs = 90_000, sweepIntervalMs = 15_000,
29
+ checkpoint = async () => {}, clock = () => Date.now(), credentialResolver = null, nodeBinary,
30
+ runtimeLedgerReader = async root => (await import('./provision/index.js')).ledger(root) }) {
31
+ requireCondition(store && ['init','load','save','close'].every(k => typeof store[k] === 'function'),
32
+ 'INVALID_STORE','A durable snapshot store is required.');
33
+ requireCondition(typeof dataRoot === 'string' && resolve(dataRoot) === dataRoot,'INVALID_ROOT','dataRoot must be an absolute canonical path.');
34
+ requireCondition(Number.isSafeInteger(leaseTtlMs) && leaseTtlMs >= 100 && leaseTtlMs <= 3_600_000 &&
35
+ Number.isSafeInteger(sweepIntervalMs) && sweepIntervalMs >= 0 && sweepIntervalMs <= 3_600_000,
36
+ 'INVALID_TIMEOUT','Invalid lease or sweep timing.');
37
+ this.store = store; this.dataRoot = dataRoot; this.parentOrigin = loopbackOrigin(parentOrigin); this.frameAncestors = frameAncestors.map(ancestorOrigin);
38
+ this.leaseTtlMs = leaseTtlMs; this.sweepIntervalMs = sweepIntervalMs; this.checkpoint = checkpoint; this.clock = clock;
39
+ this.#nodeBinary = nodeBinary;
40
+ requireCondition(typeof runtimeLedgerReader === 'function','INVALID_RUNTIME','runtimeLedgerReader must be a function.');
41
+ this.#runtimeLedgerReader = runtimeLedgerReader;
42
+ this.setCredentialResolver(credentialResolver);
43
+ }
44
+ /** rc.4: the credential broker (plugin-auth-apikey) seats itself here; null = inject nothing (FR-02: apps still start). */
45
+ setCredentialResolver(resolver) {
46
+ requireCondition(resolver === null || resolver === undefined || typeof resolver === 'function','INVALID_CREDENTIAL_RESOLVER','credentialResolver must be a function or null.');
47
+ this.#credentialResolver = resolver ?? null;
48
+ return () => { if (this.#credentialResolver === resolver) this.#credentialResolver = null; };
49
+ }
50
+ /** Ask the broker for this launch. Only declared names/paths pass; any failure is recorded and never blocks the launch. */
51
+ async #resolveCredentials(instance,deployment) {
52
+ const declared = deployment.credentialEnv ?? [], events = [];
53
+ if (!this.#credentialResolver || declared.length === 0) return { env:{}, files:[], secrets:[], events, envNames:[] };
54
+ const envNames = new Set(declared.filter(c => c.projection === 'env').map(c => c.env));
55
+ const fileBases = new Map(declared.filter(c => c.projection === 'file').map(c => [c.path, c.base ?? 'home']));
56
+ let result;
57
+ try {
58
+ result = await this.#credentialResolver({ appId:instance.appId, deploymentId:instance.deploymentId, instanceId:instance.id,
59
+ principalId:instance.principalId, credentialEnv:copy(declared) });
60
+ } catch (error) {
61
+ events.push({ type:'credential.resolver-failed', details:{ code:error?.code ?? 'RESOLVER_ERROR' } });
62
+ return { env:{}, files:[], secrets:[], events, envNames:[] };
63
+ }
64
+ if (!result || typeof result !== 'object') return { env:{}, files:[], secrets:[], events, envNames:[] };
65
+ const env = {}, rejected = [];
66
+ for (const [key,value] of Object.entries(result.env ?? {})) {
67
+ if (envNames.has(key) && typeof value === 'string' && !value.includes('\0')) env[key] = value; else rejected.push(key);
68
+ }
69
+ const files = [];
70
+ for (const f of Array.isArray(result.files) ? result.files : []) {
71
+ const policy = f?.policy ?? 'if-absent';
72
+ if (f && fileBases.has(f.path) && ['if-absent','overwrite','remove'].includes(policy) && (policy === 'remove' || typeof f.content === 'string'))
73
+ files.push({ path:f.path, base:fileBases.get(f.path), content:policy === 'remove' ? '' : f.content, mode:Number.isInteger(f.mode) ? f.mode : 0o600, policy });
74
+ else rejected.push(`file:${f?.path}`);
75
+ }
76
+ if (rejected.length) events.push({ type:'credential.env-rejected', details:{ names:rejected } });
77
+ const secrets = [...Object.values(env), ...files.filter(f => f.policy !== 'remove').map(f => f.content), ...(Array.isArray(result.secrets) ? result.secrets.filter(x => typeof x === 'string') : [])];
78
+ return { env, files, secrets, events, envNames:Object.keys(env) };
79
+ }
80
+ register(definition) {
81
+ requireCondition(!this.#closing,'HOST_CLOSED','Host is closing.');
82
+ const d = validateDefinition(definition);
83
+ requireCondition(!this.#definitions.has(d.id),'DUPLICATE_APP','An app definition cannot be replaced in a running host.');
84
+ this.#definitions.set(d.id,d);
85
+ return { appId:d.id, definitionHash:fingerprint(d) };
86
+ }
87
+ /**
88
+ * rc.27: the npm package that ships an app, as the installed-package scan saw it (`<profile>/node_modules/<pkg>/app.json`
89
+ * → `id`). Bound before or after `register` — app bundles register themselves once `hanameshApps` is provided, the
90
+ * scan runs earlier. A `packageName` declared in the definition itself always wins. Bounded by installed packages.
91
+ */
92
+ bindPackageName(appId,name) {
93
+ identifier(appId,'appId');
94
+ requireCondition(validPackageName(name),'INVALID_PACKAGE_NAME','packageName must be an npm package name.');
95
+ requireCondition(this.#packageNames.size < 1_024 || this.#packageNames.has(appId),'CAPACITY_LIMIT','Package binding capacity reached.');
96
+ this.#packageNames.set(appId,name);
97
+ }
98
+ /** Usage-evidence `hanaRef` for an app: definition `packageName`, else the bound installed package, else null (no evidence). */
99
+ packageName(appId) {
100
+ return this.#definitions.get(appId)?.packageName ?? this.#packageNames.get(appId) ?? null;
101
+ }
102
+ /**
103
+ * rc.27: real application activity — a request the instance's gateway forwarded for an authorized view (not
104
+ * bootstrap, not a denial, not the host's own readiness probe). Not persisted, not part of `eventsSince`;
105
+ * the usage-evidence adapter buckets it per UTC hour. Listener errors are swallowed.
106
+ */
107
+ onActivity(listener) {
108
+ requireCondition(typeof listener === 'function','INVALID_LISTENER','Activity listener must be a function.');
109
+ this.#activityListeners.add(listener);return () => this.#activityListeners.delete(listener);
110
+ }
111
+ #activity(id) {
112
+ if (this.#activityListeners.size === 0) return;
113
+ const record = this.#state?.instances.find(i => i.id === id);
114
+ if (!record || record.status !== 'ready') return;
115
+ const activity = { type:'instance.activity', instanceId:id, appId:record.appId, deploymentId:record.deploymentId, principalId:record.principalId, at:this.clock() };
116
+ for (const listener of this.#activityListeners) { try { listener({ ...activity }); } catch {} }
117
+ }
118
+ async init() {
119
+ requireCondition(!this.#initialized,'ALREADY_INITIALIZED','Host is already initialized.');
120
+ await secureDirectory(this.dataRoot); await this.store.init();
121
+ try {
122
+ this.#state = await this.store.load();
123
+ const next = copy(this.#state); let changed = false;
124
+ const interrupted = new Set();
125
+ // A persisted ready bit is not proof of a live endpoint. Resume keeps the slot
126
+ // and data identity, but creates a new runtime ID after guardian cleanup.
127
+ for (const instance of next.instances) {
128
+ if (!isTerminal(instance.status)) {
129
+ instance.status = 'interrupted'; instance.endpoint = null; instance.gatewayOrigin = null;
130
+ instance.updatedAt = this.clock(); changed = true;
131
+ interrupted.add(instance.id);
132
+ this.#event(next,'instance.interrupted',instance,{ reason:'host-restart' });
133
+ }
134
+ }
135
+ for (const lease of next.leases) if (lease.status === 'active') {
136
+ const restarted = interrupted.has(lease.instanceId);
137
+ if (restarted || lease.expiresAt <= this.clock()) {
138
+ lease.status = 'expired'; changed = true;
139
+ if (restarted) this.#event(next,'view.expired',next.instances.find(i=>i.id===lease.instanceId),
140
+ {viewId:lease.viewId,generation:lease.generation,reason:'host-restart'});
141
+ }
142
+ }
143
+ if (changed) await this.#commit(next);
144
+ this.#initialized = true;
145
+ if (this.sweepIntervalMs > 0) {
146
+ this.#timer = setInterval(() => { this.sweepLeases().catch(error => {
147
+ this.#notify({ type:'host.error', code:error.code ?? 'SWEEP_FAILED' });
148
+ }); },this.sweepIntervalMs);
149
+ this.#timer.unref();
150
+ }
151
+ return this;
152
+ } catch (error) { await this.store.close().catch(() => {}); throw error; }
153
+ }
154
+ #assertOpen() {
155
+ requireCondition(this.#initialized && !this.#closing,'HOST_CLOSED','Host is not open.',{},503);
156
+ requireCondition(!this.#poisoned,'STORAGE_FAILED','Storage outcome is uncertain; restart before performing mutations.',{},503);
157
+ }
158
+ #event(state,type,instance,details = {}) {
159
+ const event = { sequence:++state.sequence, at:this.clock(), type, instanceId:instance.id,
160
+ principalId:instance.principalId,...details };
161
+ state.events.push(event); if (state.events.length > 1_024) state.events.splice(0,state.events.length-1_024);
162
+ return event;
163
+ }
164
+ #notify(event) { for (const listener of this.#listeners) { try { listener(copy(event)); } catch {} } }
165
+ async #commit(next) {
166
+ next.revision = this.#state.revision + 1;
167
+ const oldSequence = this.#state.sequence;
168
+ try { await this.store.save(next); }
169
+ catch (error) { if (error.code === 'COMMIT_UNCERTAIN') this.#poisoned = true; throw error; }
170
+ this.#state = next;
171
+ for (const event of next.events) if (event.sequence > oldSequence) this.#notify(event);
172
+ }
173
+ #findLease(state,principalId,viewId) { return state.leases.find(l => l.principalId === principalId && l.viewId === viewId); }
174
+ #checkToken(lease,token) {
175
+ requireCondition(lease && typeof token === 'string' && /^[a-f0-9]{64}$/.test(token) &&
176
+ timingSafeEqual(Buffer.from(hash(token),'hex'), Buffer.from(lease?.tokenHash ?? '0'.repeat(64),'hex')),
177
+ 'LEASE_NOT_OWNED','Unknown, stale, or unauthorized view lease.',{},403);
178
+ }
179
+ #activeFor(state,id) { return state.leases.filter(l => l.instanceId === id && active(l,this.clock())); }
180
+ #definition(appId,deploymentId) {
181
+ const app = this.#definitions.get(identifier(appId,'appId'));
182
+ requireCondition(app,'APP_NOT_REGISTERED','Application is not registered.',{},404);
183
+ const deployment = app.deployments.find(d => d.id === identifier(deploymentId,'deploymentId'));
184
+ requireCondition(deployment,'DEPLOYMENT_NOT_REGISTERED','Deployment is not registered.',{},404);
185
+ return { app, deployment };
186
+ }
187
+ #validateRequest(input,allowed) {
188
+ requireCondition(input && typeof input === 'object' && !Array.isArray(input),'INVALID_REQUEST','Expected an object.');
189
+ const forbidden = Object.keys(input).filter(key => !allowed.includes(key));
190
+ requireCondition(!forbidden.length,'UNKNOWN_FIELDS','Only registered identifiers and lease fields are accepted.',{ fields:forbidden });
191
+ }
192
+ async #prepareOpen(input,principalId = 'host') {
193
+ this.#validateRequest(input,['appId','deploymentId','viewId','leaseToken','instanceId','originalSessionId']);
194
+ identifier(principalId,'principalId'); identifier(input.viewId,'viewId');
195
+ if (input.originalSessionId !== undefined) requireCondition(typeof input.originalSessionId === 'string' &&
196
+ input.originalSessionId.length > 0 && input.originalSessionId.length <= 256,'INVALID_REQUEST','Invalid original application session identity.');
197
+ const { app, deployment } = this.#definition(input.appId,input.deploymentId);
198
+ const prepared = await this.#queue.run(async () => {
199
+ this.#assertOpen();
200
+ let next = copy(this.#state), lease = this.#findLease(next,principalId,input.viewId), instance;
201
+ let token = input.leaseToken;
202
+ if (lease) {
203
+ this.#checkToken(lease,token);
204
+ instance = next.instances.find(i => i.id === lease.instanceId);
205
+ requireCondition(instance && instance.appId === app.id && instance.deploymentId === deployment.id &&
206
+ instance.dataId === deployment.dataId && instance.principalId === principalId,
207
+ 'VIEW_BINDING_CONFLICT','A stable view cannot be silently rebound to another application or deployment.');
208
+ requireCondition(input.instanceId === undefined || input.instanceId === instance.id,
209
+ 'VIEW_BINDING_CONFLICT','Requested instance does not match the persisted view.');
210
+ requireCondition(input.originalSessionId === undefined || input.originalSessionId === lease.originalSessionId,
211
+ 'SESSION_BINDING_CONFLICT','Original application session identity cannot be silently replaced.');
212
+ } else {
213
+ requireCondition(input.leaseToken === undefined,'UNKNOWN_VIEW','Cannot resume an unknown view.',{},404);
214
+ requireCondition(next.leases.length < 2_048,'CAPACITY_LIMIT','Lease history capacity reached; explicit maintenance is required.');
215
+ if (input.instanceId !== undefined) {
216
+ identifier(input.instanceId,'instanceId');
217
+ instance = next.instances.find(i => i.id === input.instanceId);
218
+ requireCondition(instance && instance.principalId === principalId && instance.appId === app.id &&
219
+ instance.deploymentId === deployment.id && instance.dataId === deployment.dataId,
220
+ 'INSTANCE_NOT_OWNED','Instance does not belong to this principal/deployment.',{},403);
221
+ } else {
222
+ // SINGLE_INSTANCE_REUSE: this lookup and durable reservation share the serial critical section.
223
+ const reuse = app.singleInstanceOnly;
224
+ if (reuse) instance = next.instances.find(i => i.principalId === principalId && i.appId === app.id &&
225
+ i.deploymentId === deployment.id && i.dataId === deployment.dataId);
226
+ }
227
+ if (!instance) {
228
+ requireCondition(next.instances.length < 256,'CAPACITY_LIMIT','Instance history capacity reached.');
229
+ const id = randomUUID();
230
+ const namespace = createHash('sha256').update(principalId).digest('hex').slice(0,24);
231
+ const directory = join(this.dataRoot,namespace,app.id,deployment.id,deployment.dataId,
232
+ app.singleInstanceOnly ? 'single' : id);
233
+ instance = { id,appId:app.id,deploymentId:deployment.id,dataId:deployment.dataId,principalId,
234
+ dataDir:directory,mode:deployment.mode,definitionHash:fingerprint(app),status:'reserved',runtimeId:null,
235
+ endpoint:null,gatewayOrigin:null,pid:null,guardianPid:null,createdAt:this.clock(),updatedAt:this.clock() };
236
+ next.instances.push(instance);
237
+ }
238
+ token = randomBytes(32).toString('hex');
239
+ lease = { principalId,viewId:input.viewId,instanceId:instance.id,tokenHash:hash(token),generation:1,
240
+ status:'active',expiresAt:this.clock()+this.leaseTtlMs,createdAt:this.clock(),originalSessionId:input.originalSessionId ?? null };
241
+ next.leases.push(lease);
242
+ }
243
+ // App upgrades change descriptors (credentialEnv, purpose text, name). Data ownership is checked separately below
244
+ // (BINDING_PATH_INVALID), so a not-running instance simply adopts the new definition; a running one keeps the
245
+ // definition it was launched with and must be stopped first.
246
+ if (instance.definitionHash !== fingerprint(app)) {
247
+ requireCondition(!this.#controls.get(instance.id),'DEFINITION_CHANGED',
248
+ 'The application definition changed while the instance is running; stop it, then open again.',{},409);
249
+ const previous = instance.definitionHash; instance.definitionHash = fingerprint(app); instance.updatedAt = this.clock();
250
+ this.#event(next,'instance.definition-adopted',instance,{ previous:previous.slice(0,12), current:instance.definitionHash.slice(0,12) });
251
+ }
252
+ identifier(instance.id,'instanceId');
253
+ const expectedNamespace=createHash('sha256').update(principalId).digest('hex').slice(0,24);
254
+ const expectedDirectory=join(this.dataRoot,expectedNamespace,app.id,deployment.id,deployment.dataId,app.singleInstanceOnly?'single':instance.id);
255
+ requireCondition(instance.dataDir===expectedDirectory && instance.mode===deployment.mode,'BINDING_PATH_INVALID',
256
+ 'Persisted data ownership does not match this registered root; explicit migration is required.');
257
+ requireCondition(instance.status !== 'stopping','INSTANCE_STOPPING','Instance is stopping; retry after the stopped event.');
258
+ if (lease.status !== 'active' || lease.expiresAt <= this.clock()) {
259
+ token = randomBytes(32).toString('hex'); lease.tokenHash = hash(token); lease.generation++;
260
+ }
261
+ lease.status = 'active'; lease.expiresAt = this.clock()+this.leaseTtlMs;
262
+ const existingControl = this.#controls.get(instance.id);
263
+ const needsLaunch = !existingControl;
264
+ if (needsLaunch) {
265
+ instance.status = 'reserved'; instance.runtimeId = randomUUID(); instance.pid = null; instance.guardianPid = null;
266
+ instance.endpoint = null; instance.gatewayOrigin = null; instance.updatedAt = this.clock();
267
+ }
268
+ this.#event(next,'view.opened',instance,{ viewId:lease.viewId,generation:lease.generation });
269
+ let control = existingControl;
270
+ if (needsLaunch) {
271
+ control = { abort:new AbortController(),runner:null,gateway:null,readyTask:null,stopTask:null };
272
+ this.#controls.set(instance.id,control);
273
+ try {
274
+ await reserveBeforeLaunch(
275
+ () => this.#commit(next),
276
+ async () => {
277
+ await this.#prepareRuntime(instance,deployment,control);
278
+ const started = copy(this.#state), stored = started.instances.find(i => i.id === instance.id);
279
+ stored.status = 'starting'; stored.endpoint = control.origin; stored.pid = control.runner?.pid ?? null;
280
+ stored.guardianPid = control.runner?.guardianPid ?? null; stored.updatedAt = this.clock();
281
+ for (const e of control.credentialEvents ?? []) this.#event(started,e.type,stored,e.details);
282
+ this.#event(started,'instance.starting',stored);
283
+ await this.#commit(started);
284
+ },
285
+ point => this.checkpoint(point,{ instanceId:instance.id }),
286
+ );
287
+ // Queue reservation is complete before readiness is awaited by any caller.
288
+ control.readyTask = this.#completeLaunch(instance.id,deployment,control);
289
+ control.readyTask.catch(() => {}); // callers receive the original rejection
290
+ } catch (error) {
291
+ await control.gateway?.close().catch(() => {});
292
+ try { await control.runner?.stop(); }
293
+ catch { this.#poisoned = true; throw new AppHostError('CLEANUP_UNCONFIRMED', 'Startup failed and owned cleanup could not be confirmed; ownership retained.'); }
294
+ this.#controls.delete(instance.id);
295
+ if (this.#state.instances.some(i => i.id === instance.id) && !this.#poisoned) {
296
+ const failed = copy(this.#state), record = failed.instances.find(i => i.id === instance.id);
297
+ record.status='failed'; record.endpoint=null; record.errorCode=error.code ?? 'START_FAILED';
298
+ for (const l of failed.leases) if (l.instanceId === record.id && l.status === 'active') l.status='failed';
299
+ this.#event(failed,'instance.failed',record,{code:record.errorCode});
300
+ await this.#commit(failed).catch(() => { this.#poisoned = true; });
301
+ }
302
+ throw error;
303
+ }
304
+ } else await this.#commit(next);
305
+ return { instanceId:instance.id,token,control,generation:lease.generation };
306
+ });
307
+ return prepared;
308
+ }
309
+ async #openResult(prepared, input, principalId, requireReady) {
310
+ return await this.#queue.run(() => {
311
+ this.#assertOpen();
312
+ const lease = this.#findLease(this.#state,principalId,input.viewId);
313
+ this.#checkToken(lease,prepared.token);
314
+ requireCondition(active(lease,this.clock()) && lease.generation === prepared.generation,
315
+ 'START_CANCELLED','View closed or expired while starting.');
316
+ const instance = this.#state.instances.find(i => i.id === prepared.instanceId);
317
+ if (requireReady) requireCondition(instance.status === 'ready','INSTANCE_NOT_READY','Instance is not ready.');
318
+ return { instance:publicInstance(instance),lease:publicLease(lease),leaseToken:prepared.token,
319
+ uiUrl:instance.status !== 'ready' ? null : (prepared.control.gateway ?
320
+ prepared.control.gateway.issue(leaseKey(principalId,input.viewId,lease.generation)) : instance.endpoint),
321
+ originalSessionId:lease.originalSessionId };
322
+ });
323
+ }
324
+ async beginOpen(input,principalId='host') {
325
+ const prepared = await this.#prepareOpen(input,principalId);
326
+ return await this.#openResult(prepared,input,principalId,false);
327
+ }
328
+ async open(input,principalId='host') {
329
+ const prepared = await this.#prepareOpen(input,principalId);
330
+ await prepared.control.readyTask;
331
+ return await this.#openResult(prepared,input,principalId,true);
332
+ }
333
+ // No anonymous reference counter: host start uses the same explicit view contract.
334
+ async start(input,principalId = 'host') { return await this.open(input,principalId); }
335
+ async #prepareRuntime(instance,deployment,control) {
336
+ if (deployment.mode === 'attach') {
337
+ control.origin = deployment.url;
338
+ control.runner = { mode:'attach',stop:async () => {},isAlive:() => true };
339
+ return;
340
+ }
341
+ requireCondition(['linux','darwin'].includes(process.platform),'PLATFORM_UNSUPPORTED','Owned runtimes are supported only on validated POSIX process-group platforms.');
342
+ let command = deployment.command;
343
+ if (deployment.runtime) {
344
+ const selected = deployment.runtime.manifest.items.find(item => item.id === deployment.runtime.item);
345
+ const root = join(this.dataRoot,'runtimes',instance.appId);
346
+ let book;
347
+ try { book = await this.#runtimeLedgerReader(root); }
348
+ catch { requireCondition(false,'RUNTIME_MISSING','Runtime ledger is unavailable; install or repair the application runtime.',{},409); }
349
+ requireCondition(book?.items?.[deployment.runtime.item]?.version === selected.version,
350
+ 'RUNTIME_MISSING','The installed runtime is missing or does not match the application manifest.',{},409);
351
+ command = join(root,selected.installTo,deployment.runtime.exec);
352
+ }
353
+ try { await access(command,constants.X_OK); }
354
+ catch { requireCondition(false,deployment.runtime?'RUNTIME_MISSING':'INVALID_COMMAND',
355
+ deployment.runtime?'The declared runtime executable is missing or not executable.':'The application command is missing or not executable.',{},409); }
356
+ await secureDirectory(instance.dataDir);
357
+ for (const child of ['home','tmp','config','cache','state']) await secureDirectory(join(instance.dataDir,child));
358
+ const port = await freePort();
359
+ const values = { ...instance,instanceId:instance.id,port };
360
+ const credentials = await this.#resolveCredentials(instance,deployment);
361
+ control.credentialEvents = credentials.events;
362
+ const written = [], kept = [], removed = [];
363
+ for (const file of credentials.files) {
364
+ const base = file.base === 'dataDir' ? instance.dataDir : join(instance.dataDir,'home');
365
+ const target = join(base,file.path);
366
+ requireCondition(target.startsWith(base + '/'),'INVALID_CREDENTIAL_ENV','Credential file escapes its declared base.');
367
+ if (file.policy === 'remove') { await rm(target,{ force:true }); removed.push(`file:${file.path}`); continue; }
368
+ // rc.6: 'if-absent' (default) keeps a file the app has since rotated itself (its own refresh token);
369
+ // 'overwrite' is for a new grant version; the broker decides, the host never reads the file back.
370
+ if (file.policy === 'if-absent' && await access(target).then(() => true, () => false)) { kept.push(`file:${file.path}`); continue; }
371
+ await mkdir(dirname(target),{ recursive:true, mode:0o700 });
372
+ await writeFile(target,file.content,{ mode:file.mode });
373
+ written.push(`file:${file.path}`);
374
+ }
375
+ const injected = [...credentials.envNames, ...written];
376
+ if (injected.length) control.credentialEvents.push({ type:'credential.injected', details:{ names:injected } });
377
+ if (kept.length) control.credentialEvents.push({ type:'credential.file-kept', details:{ names:kept } });
378
+ if (removed.length) control.credentialEvents.push({ type:'credential.file-removed', details:{ names:removed } });
379
+ const env = {
380
+ PATH:'/usr/bin:/bin:/usr/sbin:/sbin',LANG:'C.UTF-8',
381
+ ...credentials.env,
382
+ ...Object.fromEntries(Object.entries(deployment.env).map(([key,value]) => [key,expand(value,values)])),
383
+ HOME:join(instance.dataDir,'home'),TMPDIR:join(instance.dataDir,'tmp'),
384
+ XDG_CONFIG_HOME:join(instance.dataDir,'config'),XDG_CACHE_HOME:join(instance.dataDir,'cache'),
385
+ XDG_STATE_HOME:join(instance.dataDir,'state'),XDG_DATA_HOME:instance.dataDir,
386
+ };
387
+ control.origin=`http://127.0.0.1:${port}`;
388
+ control.runner=await spawnOwned({ nodeBinary:this.#nodeBinary,command,args:deployment.args.map(value => expand(value,values)),
389
+ cwd:deployment.cwd ?? instance.dataDir,dataDir:instance.dataDir,port,env,stopGraceMs:deployment.stopGraceMs,secrets:credentials.secrets },{
390
+ onLog:(stream,text) => {
391
+ const records=this.#logs.get(instance.id) ?? [];
392
+ records.push({at:this.clock(),stream,text}); if(records.length>128)records.shift(); this.#logs.set(instance.id,records);
393
+ },
394
+ });
395
+ control.runner.exited.then(result => { void this.#onExit(instance.id,control,result); });
396
+ await this.checkpoint('runtime-spawned',{instanceId:instance.id,pid:control.runner.pid,dataDir:instance.dataDir,
397
+ nodeBinary:control.runner.nodeBinary,guardianBinary:control.runner.guardianBinary,launcherBinary:control.runner.launcherBinary});
398
+ }
399
+ async #completeLaunch(id,deployment,control) {
400
+ try {
401
+ await waitReady(control.origin,deployment.readiness,{runtime:control.runner,timeoutMs:deployment.startTimeoutMs,signal:control.abort.signal});
402
+ if (control.abort.signal.aborted) throw new AppHostError('START_CANCELLED','Launch was cancelled.');
403
+ if (deployment.embedding === 'gateway') {
404
+ control.gateway=new FixedGateway({upstream:control.origin,parentOrigin:this.parentOrigin,frameAncestors:this.frameAncestors,
405
+ ...deployment.gateway,onForward:() => this.#activity(id),isLeaseActive:key => {
406
+ const [principal,view,generation]=JSON.parse(key),lease=this.#findLease(this.#state,principal,view);
407
+ const instance=this.#state.instances.find(i=>i.id===id);
408
+ return Boolean(lease && lease.instanceId===id && lease.generation===generation && active(lease,this.clock()) && instance?.status==='ready');
409
+ }});
410
+ await control.gateway.start();
411
+ }
412
+ await this.#queue.run(async () => {
413
+ requireCondition(!control.abort.signal.aborted,'START_CANCELLED','Launch was cancelled.');
414
+ const next=copy(this.#state),record=next.instances.find(i=>i.id===id);
415
+ requireCondition(record.status==='starting' && this.#activeFor(next,id).length>0,'START_CANCELLED','No live views remain.');
416
+ record.status='ready';record.gatewayOrigin=control.gateway?.origin ?? null;record.updatedAt=this.clock();delete record.errorCode;
417
+ this.#event(next,'instance.ready',record);await this.#commit(next);
418
+ });
419
+ } catch(error) {
420
+ await control.gateway?.close().catch(()=>{});
421
+ try { await control.runner?.stop(); } catch(cleanupError) {
422
+ this.#poisoned=true;throw new AppHostError('CLEANUP_FAILED','Application cleanup could not be confirmed.',{cause:cleanupError.code});
423
+ }
424
+ await this.#queue.run(async()=>{
425
+ const next=copy(this.#state),record=next.instances.find(i=>i.id===id);
426
+ if(record && record.status!=='stopping' && record.status!=='stopped') {
427
+ record.status='failed';record.endpoint=null;record.gatewayOrigin=null;record.errorCode=error.code ?? 'START_FAILED';record.updatedAt=this.clock();
428
+ for(const lease of next.leases)if(lease.instanceId===id && lease.status==='active')lease.status='failed';
429
+ this.#event(next,'instance.failed',record,{code:record.errorCode});await this.#commit(next);
430
+ this.#controls.delete(id);
431
+ }
432
+ }).catch(()=>{this.#poisoned=true;});
433
+ throw error;
434
+ }
435
+ }
436
+ async #onExit(id,control,result) {
437
+ await this.#queue.run(async()=>{
438
+ if(this.#controls.get(id)!==control)return;
439
+ const record=this.#state.instances.find(i=>i.id===id);
440
+ if(record?.status!=='ready')return; // startup/cancellation has a separate completion path
441
+ await control.gateway?.close();
442
+ let cleanupConfirmed=true;
443
+ try { await control.runner.stop(); } catch { cleanupConfirmed=false;this.#poisoned=true; }
444
+ const next=copy(this.#state),item=next.instances.find(i=>i.id===id);
445
+ item.status='failed';item.endpoint=null;item.gatewayOrigin=null;item.errorCode=cleanupConfirmed?'APP_EXITED':'CLEANUP_UNCONFIRMED';item.updatedAt=this.clock();
446
+ for(const lease of next.leases)if(lease.instanceId===id && lease.status==='active')lease.status='failed';
447
+ this.#event(next,'instance.failed',item,{code:item.errorCode,exit:result});await this.#commit(next);
448
+ if(cleanupConfirmed)this.#controls.delete(id);
449
+ }).catch(()=>{this.#poisoned=true;});
450
+ }
451
+ async resume(input,principalId='host',{waitForReady=true}={}) {
452
+ this.#validateRequest(input,['viewId','leaseToken']);
453
+ const known=await this.#queue.run(()=>{
454
+ this.#assertOpen();identifier(principalId);identifier(input.viewId);
455
+ const lease=this.#findLease(this.#state,principalId,input.viewId);this.#checkToken(lease,input.leaseToken);
456
+ const instance=this.#state.instances.find(i=>i.id===lease.instanceId);
457
+ requireCondition(instance,'MISSING_BINDING','Persisted instance binding is missing.',{},409);
458
+ return {appId:instance.appId,deploymentId:instance.deploymentId,instanceId:instance.id};
459
+ });
460
+ return waitForReady ? await this.open({...known,...input},principalId) : await this.beginOpen({...known,...input},principalId);
461
+ }
462
+ /** Explicit authenticated-owner recovery after a lost receipt/browser cache. Never rebinds a view. */
463
+ async recoverView(input,principalId='host') {
464
+ this.#validateRequest(input,['viewId','instanceId','confirm']);
465
+ requireCondition(input.confirm===true,'RECOVERY_CONFIRM_REQUIRED','Recovery invalidates the prior view credential; explicit confirmation is required.');
466
+ identifier(principalId);identifier(input.viewId);identifier(input.instanceId);
467
+ return await this.#queue.run(async()=>{
468
+ this.#assertOpen();const next=copy(this.#state),lease=this.#findLease(next,principalId,input.viewId);
469
+ requireCondition(lease && lease.instanceId===input.instanceId,'LEASE_NOT_OWNED','No matching view belongs to the authenticated principal.',{},403);
470
+ const instance=next.instances.find(i=>i.id===lease.instanceId);
471
+ const leaseToken=randomBytes(32).toString('hex');lease.tokenHash=hash(leaseToken);lease.generation++;
472
+ // Recovery alone is not an Open and does not create/renew a reference.
473
+ this.#event(next,'view.credential-recovered',instance,{viewId:lease.viewId,generation:lease.generation});
474
+ await this.#commit(next);
475
+ return {instance:publicInstance(instance),lease:publicLease(lease),leaseToken,uiUrl:null,originalSessionId:lease.originalSessionId};
476
+ });
477
+ }
478
+ async heartbeat(input,principalId='host') {
479
+ this.#validateRequest(input,['viewId','leaseToken']);
480
+ return await this.#queue.run(async()=>{
481
+ this.#assertOpen();const next=copy(this.#state),lease=this.#findLease(next,principalId,input.viewId);
482
+ this.#checkToken(lease,input.leaseToken);
483
+ requireCondition(active(lease,this.clock()),'LEASE_EXPIRED','Expired views must explicitly resume.');
484
+ const instance=next.instances.find(i=>i.id===lease.instanceId);
485
+ requireCondition(['ready','starting','reserved'].includes(instance.status),'INSTANCE_NOT_READY','Cannot renew a stopped/interrupted runtime.');
486
+ lease.expiresAt=this.clock()+this.leaseTtlMs;await this.#commit(next);return publicLease(lease);
487
+ });
488
+ }
489
+ async close(input,principalId='host') {
490
+ this.#validateRequest(input,['viewId','leaseToken']);
491
+ const result=await this.#queue.run(async()=>{
492
+ this.#assertOpen();const next=copy(this.#state),lease=this.#findLease(next,principalId,input.viewId);
493
+ this.#checkToken(lease,input.leaseToken);
494
+ const record=next.instances.find(i=>i.id===lease.instanceId);
495
+ if(lease.status!=='active')return {instanceId:record.id,alreadyClosed:true,stop:false};
496
+ lease.status='closed';this.#event(next,'view.closed',record,{viewId:lease.viewId,generation:lease.generation});
497
+ await this.#commit(next);
498
+ return {instanceId:record.id,alreadyClosed:false,stop:this.#activeFor(next,record.id).length===0};
499
+ });
500
+ if(result.stop)await this.stop(result.instanceId,{confirm:false},principalId).catch(error=>{if(error.code!=='INSTANCE_IN_USE')throw error;});
501
+ return {instanceId:result.instanceId,alreadyClosed:result.alreadyClosed,instance:this.instance(result.instanceId,principalId)};
502
+ }
503
+ async stop(instanceId,{confirm=false}={},principalId='host') {
504
+ identifier(instanceId,'instanceId');
505
+ const wrapped=await this.#queue.run(async()=>{
506
+ // During dispose, existing runtimes must still be stoppable.
507
+ requireCondition(this.#initialized,'HOST_CLOSED','Host is not initialized.');
508
+ const stored=this.#state.instances.find(i=>i.id===instanceId);
509
+ requireCondition(stored && stored.principalId===principalId,'INSTANCE_NOT_OWNED','Instance not owned by this principal.',{},403);
510
+ const control=this.#controls.get(instanceId);
511
+ if(control?.stopTask)return {task:control.stopTask};
512
+ if(isTerminal(stored.status) && !control)return {task:Promise.resolve(publicInstance(stored))};
513
+ const users=this.#activeFor(this.#state,instanceId);
514
+ requireCondition(confirm || users.length===0,'INSTANCE_IN_USE','Stop refused: views are still using this instance.',
515
+ {views:users.map(l=>({viewId:l.viewId,generation:l.generation,expiresAt:l.expiresAt}))},409);
516
+ const next=copy(this.#state),record=next.instances.find(i=>i.id===instanceId);
517
+ record.status='stopping';record.updatedAt=this.clock();
518
+ this.#event(next,'instance.stop-requested',record,{views:users.map(l=>l.viewId)});
519
+ // Persist stopping before signalling; no UI may infer stopped from this event.
520
+ await this.#commit(next);control?.abort.abort();
521
+ const task=(async()=>{
522
+ await stopBeforePublish(async()=>{
523
+ await control?.gateway?.close();await control?.runner?.stop();
524
+ await control?.readyTask?.catch(()=>{});
525
+ },async()=>{
526
+ return await this.#queue.run(async()=>{
527
+ const final=copy(this.#state),item=final.instances.find(i=>i.id===instanceId);
528
+ item.status='stopped';item.endpoint=null;item.gatewayOrigin=null;item.updatedAt=this.clock();
529
+ for(const lease of final.leases)if(lease.instanceId===instanceId && lease.status==='active') {
530
+ lease.status='stopped';this.#event(final,'view.stopped',item,{viewId:lease.viewId,generation:lease.generation});
531
+ }
532
+ this.#event(final,'instance.stopped',item,{mode:item.mode});await this.#commit(final);
533
+ this.#controls.delete(instanceId);return publicInstance(item);
534
+ });
535
+ },point=>this.checkpoint(point,{instanceId}));
536
+ return this.instance(instanceId,principalId);
537
+ })();
538
+ task.catch(()=>{});if(control)control.stopTask=task;
539
+ return {task};
540
+ });
541
+ return await wrapped.task;
542
+ }
543
+ async stopAll() {
544
+ const records=this.#state?.instances ?? [];
545
+ const targets=records.filter(i=>!isTerminal(i.status)||this.#controls.has(i.id));
546
+ const results=await Promise.allSettled(targets.map(i=>this.stop(i.id,{confirm:true},i.principalId)));
547
+ const failures=results.filter(r=>r.status==='rejected');
548
+ // Host teardown: the persistence medium may already be closing (a whole-profile unload tears the
549
+ // storage domain down alongside this plugin), which makes the "persist stopping first" write fail.
550
+ // An owned process must still never be orphaned by its host going away, so the runtime is signalled
551
+ // directly; the stopped state is not advertised (it was never persisted) and the error still surfaces.
552
+ if(failures.length && this.#closing){
553
+ await Promise.allSettled(targets.filter((_,index)=>results[index].status==='rejected')
554
+ .map(i=>this.#controls.get(i.id)?.runner?.stop()));
555
+ }
556
+ if(failures.length)throw new AppHostError('STOP_ALL_INCOMPLETE','Some runtimes could not be confirmed stopped.',{count:failures.length});
557
+ }
558
+ async sweepLeases() {
559
+ const ids=await this.#queue.run(async()=>{
560
+ this.#assertOpen();const next=copy(this.#state);let changed=false;
561
+ for(const lease of next.leases)if(lease.status==='active' && lease.expiresAt<=this.clock()) {
562
+ lease.status='expired';changed=true;
563
+ this.#event(next,'view.expired',next.instances.find(i=>i.id===lease.instanceId),{viewId:lease.viewId});
564
+ }
565
+ if(changed)await this.#commit(next);
566
+ return next.instances.filter(i=>this.#controls.has(i.id)&&i.status!=='stopping'&&this.#activeFor(next,i.id).length===0).map(i=>[i.id,i.principalId]);
567
+ });
568
+ for(const [id,principal]of ids)await this.stop(id,{confirm:false},principal).catch(error=>{if(error.code!=='INSTANCE_IN_USE')throw error;});
569
+ }
570
+ instance(id,principalId='host') {
571
+ const item=this.#state?.instances.find(i=>i.id===id && i.principalId===principalId);return item?publicInstance(item):null;
572
+ }
573
+ instanceList(principalId='host') {return (this.#state?.instances ?? []).filter(i=>i.principalId===principalId).map(publicInstance);}
574
+ list(principalId='host') {
575
+ return {contractVersion:1,apps:[...this.#definitions.values()].map(d=>({id:d.id,name:d.name,singleInstanceOnly:d.singleInstanceOnly,packageName:this.packageName(d.id),
576
+ deployments:d.deployments.map(p=>({id:p.id,dataId:p.dataId,mode:p.mode,embedding:p.embedding,credentialEnv:copy(p.credentialEnv??[])}))})),
577
+ instances:this.instanceList(principalId),views:(this.#state?.leases??[]).filter(l=>l.principalId===principalId).map(publicLease),
578
+ sequence:this.#state?.sequence??0};
579
+ }
580
+ eventsSince(sequence=0,principalId='host') {
581
+ requireCondition(Number.isSafeInteger(sequence)&&sequence>=0,'INVALID_CURSOR','Invalid event cursor.');
582
+ const all=this.#state.events;
583
+ return {events:all.filter(e=>e.sequence>sequence&&e.principalId===principalId).map(copy),sequence:this.#state.sequence,
584
+ resetRequired:all.length>0 && sequence<all[0].sequence-1};
585
+ }
586
+ subscribe(listener) {this.#listeners.add(listener);return()=>this.#listeners.delete(listener);}
587
+ logTail(id,limit=50,principalId='host') {
588
+ requireCondition(this.instance(id,principalId),'INSTANCE_NOT_OWNED','Instance is not owned.',{},403);
589
+ requireCondition(Number.isInteger(limit)&&limit>=0&&limit<=128,'INVALID_LIMIT','Log limit must be 0..128.');
590
+ return limit===0?[]:copy((this.#logs.get(id)??[]).slice(-limit));
591
+ }
592
+ async dispose() {
593
+ if(this.#disposing)return await this.#disposing;
594
+ this.#closing=true;clearInterval(this.#timer);
595
+ this.#disposing=(async()=>{
596
+ let failure;
597
+ try{await this.stopAll();}catch(error){failure=error;}
598
+ // Release the medium even when a stop could not be confirmed; the failure is still reported.
599
+ try{await this.#queue.drained();await this.store.close();}catch(error){failure??=error;}
600
+ this.#initialized=false;this.#listeners.clear();this.#activityListeners.clear();
601
+ if(failure)throw failure;
602
+ })();
603
+ return await this.#disposing;
604
+ }
605
+ }
@@ -0,0 +1,6 @@
1
+ @hanamesh/lib-provision@0.1.0-rc.1
2
+ Copyright (c) 2026 HanaMesh. All rights reserved.
3
+ License identifier in the source package: UNLICENSED.
4
+ The copyright owner has confirmed distribution of this inlined component
5
+ within @hanamesh/dsh-app-host. No MIT or other open-source license is
6
+ granted for this component by this notice.
@@ -0,0 +1,13 @@
1
+ # 第三方声明
2
+
3
+ `@hanamesh/lib-provision` 0.1.0-rc.1 **没有运行时依赖**。tar / zip 读取、CRC32、sha256、HTTP 下载全部基于 Node.js 内置模块(`zlib`、`crypto`、`fs`、`fetch`)。
4
+
5
+ 开发依赖(不随包分发):
6
+
7
+ | 包 | 版本 | 许可证 | 用途 |
8
+ |---|---|---|---|
9
+ | typescript | 5.9.3 | Apache-2.0 | 构建 `lib/` 与 `.d.ts` |
10
+ | @types/node | 22.19.7 | MIT | 类型声明 |
11
+ | undici-types | 6.21.0 | MIT | `@types/node` 的锁定传递依赖 |
12
+
13
+ 测试 fixture 里的自签证书(`tests/fixtures/fixture-cert.pem` / `fixture-key.pem`)由本仓生成,仅供本地 HTTPS fixture 使用,不随包分发。