diff --git a/.changeset/29342-export-withheld-connector-maps.md b/.changeset/29342-export-withheld-connector-maps.md new file mode 100644 index 00000000000..eaa3d9df356 --- /dev/null +++ b/.changeset/29342-export-withheld-connector-maps.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': patch +'@objectstack/cloud-connection': patch +'@objectstack/spec': patch +--- + +feat(runtime): a package export names each connector map it withheld, and both install modes of install-local name each one as incomplete + +Clause-②: yes (widening) +Metadata-protocol: none + +`GET /api/v1/packages/:id/export` writes served items, and the served read withholds four connector maps whole: `providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers` and `providerConfig.transport.env`. An exported connector used to arrive with such a map simply absent, so an import could not tell a withheld map from one the package never had. Now: + +- **The export lists each one.** The manifest's `withheldMaps` (declared on `PackageExportManifestSchema`) holds `{ type: 'connector', name, path }` for every map withheld. Each exported connector's stored body is judged by the same `connector` redactor the served read ran, read through the protocol's by-name execution face. Only the position leaves that read: no header or variable name, and no value. A package that withheld nothing carries no key. An export that cannot judge (a protocol without `getMetaItemForExecution`, or a connector that read answers no body for) fails rather than omitting the key, because from an exporter that writes it, an absent key means nothing was withheld. +- **Both install modes name each one.** `POST /api/v1/marketplace/install-local`, managed and unmanaged, accepts the package and answers `withheldMaps`, the list as the export wrote it, plus one `warn` line naming every map. The key is left out when the body carries no list, so that answer's key set is unchanged. +- **One narrowing, at the door.** A `withheldMaps` value the export's declaration refuses is now answered `PLUGIN_MANIFEST_INVALID` (400 for an inline manifest, 502 for a cloud snapshot) before any write, in either mode, the way an invalid `installModes` already is. Before this change the door ignored the key, whatever its value. No export wrote the key before this release, so only a hand-written list can be refused. Fix: send the list exactly as the export wrote it, or drop it. +- **Spec.** The `[PLANNED — not yet served]` marker is dropped from `withheldMaps`' describe, and the docblocks now say the key is served. diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index a5abb020dc8..68918e0c9d7 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -43,13 +43,6 @@ declared a VERSION rollback against the live COMMIT-rollback path; `RollbackToPackageCommitResponseSchema` below is the true contract, authored after that retirement per the ruling's sequencing (3A). -Two declarations are not transcriptions but DECLARED AHEAD OF THEIR -PRODUCER (ADR-0049), each describe opening `[PLANNED — not yet served]` -until the change that serves it drops the marker: -`PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by -#29342) and `PackageExportBundleSchema` (`GET /packages/export`, #28914). -Their own docblocks say what is not served yet. - **Source:** `packages/spec/src/api/package-lifecycle.zod.ts` @@ -335,7 +328,7 @@ Whole-deployment export, the answer of `GET /packages/export` (`os package expor | **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. | | **dependencies** | `Record` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). | | **label** | `string` | optional | Display label, when the package declares one. | -| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | +| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | --- @@ -352,7 +345,7 @@ Whole-deployment export, the answer of `GET /packages/export` (`os package expor | **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. | | **dependencies** | `Record` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). | | **label** | `string` | optional | Display label, when the package declares one. | -| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | +| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | ### Nested Shape: `PackageExportManifest.withheldMaps[number]` diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 8997b657495..f2cf41315c6 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -75,6 +75,12 @@ * none of which writes, and before any write. * A mode the package's `installModes` does not permit answers * `422 INSTALL_MODE_NOT_PERMITTED`, before any write. + * Either mode reads the body's `withheldMaps` — the connector header + * and environment maps a package export withheld whole (#28640 + * ruling ③A) — accepts the package, and names each map in its + * answer's `withheldMaps` and in one `warn` line; a list the export's + * declaration refuses answers `PLUGIN_MANIFEST_INVALID` (400 inline, + * 502 cloud), before any write. * * [ADR-0126 §6 wall 3] A managed RE-install over an installed package * asks the security plugin's access upgrade check @@ -187,6 +193,9 @@ import { unmanagedInstallValueCarries, type InstallMode, } from '@objectstack/spec/kernel'; +// [#28640 ruling ③A] The export's list of the connector maps it withheld whole, +// read by both install modes through its one declaration. +import { PackageExportManifestSchema, type PackageExportManifest } from '@objectstack/spec/api'; // [#21762] ADR-0087 D1's protocol handshake, its brand predicate and the ONE // answer both package-install doors give its refusal. Imported from the // producer, never restated: `POST /api/v1/packages` answers through the same @@ -602,6 +611,57 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** One entry of an export's `withheldMaps` ({@link PackageExportManifestSchema}). */ +type WithheldMap = NonNullable[number]; + +/** + * [#28640 ruling ③A] The connector maps the export that produced this body + * withheld whole — its `withheldMaps`, parsed through the export's own + * declaration ({@link PackageExportManifestSchema}), by reference. Read by + * BOTH install modes before any write, so each answer names every map that + * arrives incomplete instead of letting it read as one the package never had. + * + * An absent list is `[]`: a body that is not an export, or an export that + * withheld nothing. A list the declaration refuses is refused here, the way + * an invalid `installModes` is ({@link judgeInstallMode}): it can only have + * been written by hand, and guessing which maps it meant is the silent answer + * the list exists to replace. The import never refuses a package FOR an + * incomplete map (the ruling: accept, and name each one). + */ +function judgeWithheldMaps( + manifest: Record, + manifestId: string, + invalidManifestStatus: 400 | 502, +): { ok: true; withheldMaps: WithheldMap[] } | { ok: false; status: number; error: { code: string; message: string } } { + const declared = PackageExportManifestSchema.shape.withheldMaps.safeParse(manifest.withheldMaps); + if (!declared.success) { + const [issue] = declared.error.issues; + return { + ok: false, + status: invalidManifestStatus, + error: { + code: 'PLUGIN_MANIFEST_INVALID', + message: `Package '${manifestId}' carries an invalid \`withheldMaps\`: ${issue?.message ?? 'refused by its declaration'}` + + `${issue && issue.path.length > 0 ? ` (at \`withheldMaps.${issue.path.join('.')}\`)` : ''}. ` + + 'Send the list as the package export wrote it, or omit it.', + }, + }; + } + return { ok: true, withheldMaps: declared.data ?? [] }; +} + +/** + * [#28640 ruling ③A] The operator's line for an install that received + * withheld maps: each one named, once, at `warn` — the connector is installed + * and runs, without the headers or environment variables the export held back. + */ +function withheldMapsLogLine(manifestId: string, mode: InstallMode, withheldMaps: readonly WithheldMap[]): string { + const named = withheldMaps.map((m) => `${m.type} '${m.name}' ${m.path}`).join(', '); + return `[MarketplaceInstallLocal] installed ${manifestId} ${mode} with ${withheldMaps.length} connector map(s) the ` + + `package export withheld whole, each now absent: ${named}. The connector sends none of those headers or ` + + 'environment variables until each map is supplied again where the connector is authored.'; +} + /** * [ADR-0131 D6] The package manifest an install body DECLARES — where each of * the two single-package shapes this door accepts carries it: a compiled @@ -1925,6 +1985,15 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, UNRUNNABLE_REFUSAL_STATUS); } + // 1d'. [#28640 ruling ③A] THE MAPS THE EXPORT WITHHELD, read for both + // modes before any write: each answer names every connector map + // that arrives incomplete ({@link judgeWithheldMaps}). Accepted, + // never refused for, as the ruling has it; only a list the export's + // declaration refuses is. + const withheld = judgeWithheldMaps(manifest, manifestId, inlineManifest ? 400 : 502); + if (!withheld.ok) return c.json({ success: false, error: withheld.error }, withheld.status as any); + const { withheldMaps } = withheld; + // ── [ADR-0131 D6] The `unmanaged` install: a copy, not a registration. // The package's metadata is copied ONCE into a new writable base in // the environment ledger by the import verb, and the steps below — @@ -1940,6 +2009,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { version, versionId: resolvedVersionId, userId, + withheldMaps, }); } @@ -2308,6 +2378,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // The door is named only when it can store one of them: a listing that // says none is writable (a walled posture) gets the other places. const door = described?.writable === undefined || unsetRefs.some((r) => described.writable!.has(r)); + if (withheldMaps.length > 0) ctx.logger?.warn?.(withheldMapsLogLine(manifestId, 'managed', withheldMaps)); return c.json({ success: true, @@ -2333,6 +2404,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // that declares none, so that answer's key set is unchanged. ...(datasources !== undefined ? { datasources } : {}), ...(unsetRefs.length > 0 ? { credentialNote: installedCredentialNote(unsetRefs, { door }) } : {}), + // [#28640 ruling ③A] Each connector map the package export + // withheld whole, so it arrived absent. Omitted when there are + // none, so that answer's key set is unchanged. + ...(withheldMaps.length > 0 ? { withheldMaps } : {}), upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null, // [ADR-0146 D2, #28410] Names installed dependents already held, // undeclared, that this version now ships (step 2d). Omitted when @@ -3538,14 +3613,21 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * either spelling, and the copy does not keep, with the package's * reason — is the verb's own * list, relayed as the verb states it, so the installer reads why the - * vendor locked an item before editing it. + * vendor locked an item before editing it; + * - `withheldMaps` — every connector map the package export withheld + * whole, read by {@link handleInstall} before the verb runs — is on the + * answer and on a `warn` line: the copy carries each map absent. */ private installUnmanaged = async ( c: any, ctx: PluginContext, - args: { manifest: Record; manifestId: string; version: string; versionId: string; userId: string | null }, + args: { + manifest: Record; manifestId: string; version: string; versionId: string; userId: string | null; + /** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */ + withheldMaps: readonly WithheldMap[]; + }, ): Promise => { - const { manifest, manifestId, version, versionId, userId } = args; + const { manifest, manifestId, version, versionId, userId, withheldMaps } = args; let protocol: UnmanagedImporter | undefined; try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') { @@ -3598,6 +3680,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { + `${installedFrom.packageId}${installedFrom.version ? `@${installedFrom.version}` : ''}` + `${userId ? ` by ${userId}` : ''}. Nothing is registered under '${manifestId}'; the copy follows no upgrade.`, ); + if (withheldMaps.length > 0) ctx.logger?.warn?.(withheldMapsLogLine(manifestId, 'unmanaged', withheldMaps)); const declaresSeeds = unmanagedInstallValueCarries(packageBody.data); return c.json({ success: true, @@ -3613,6 +3696,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { copiedCount: result.copiedCount, copied: result.copied, strippedLocks: result.strippedLocks, + // [#28640 ruling ③A] Each connector map the package export + // withheld whole, so its copy carries it absent. Omitted when + // there are none, so that answer's key set is unchanged. + ...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}), ...(declaresSeeds ? { seeded: { diff --git a/packages/cloud-connection/src/marketplace-install-local-withheld-maps.test.ts b/packages/cloud-connection/src/marketplace-install-local-withheld-maps.test.ts new file mode 100644 index 00000000000..3af498f9675 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-withheld-maps.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28640 ruling ③A] install-local reads a package export's `withheldMaps` — + * the connector header and environment maps the export withheld whole — in + * BOTH install modes, at the real install seam (the plugin's own route, + * mounted through `start()` + `kernel:ready`). + * + * Pinned: + * - a managed install of a body carrying the list installs, and its answer + * names each map, as the export wrote it, with one `warn` line naming them; + * - an unmanaged install of such a body copies it, and its answer names each + * map the same way; + * - a body without the list answers with no `withheldMaps` key at all, in + * either mode — the answer's key set is unchanged; + * - a list the export's declaration refuses (a position outside the four, a + * header name) answers `PLUGIN_MANIFEST_INVALID`, 400 inline, before any + * write: nothing registered, ledgered or imported. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The runtime binder the door's step 1d loads lazily — paid here, at module +// top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const ROUTE = '/api/v1/marketplace/install-local'; +const INSTALLER = 'usr_installer'; + +type Handler = (c: any) => Promise; + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request(`http://localhost:3000${ROUTE}`), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +async function mount(dir: string) { + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const hooks = new Map(); + const register = vi.fn(async () => undefined); + const importUnmanagedPackage = vi.fn(async (request: any) => ({ + sourcePackageId: request.manifest.id, + sourceVersion: request.manifest.version, + targetPackageId: `${request.manifest.id}-copy`, + namespace: request.manifest.namespace, + copiedCount: request.manifest.connectors?.length ?? 0, + copied: (request.manifest.connectors ?? []).map((c: any) => ({ type: 'connector', name: c.name })), + strippedLocks: [], + })); + const services: Record = { + manifest: { register }, + auth: installerAuthService(INSTALLER), + objectql: withInstallerGrants({ + syncSchemas: vi.fn(async () => undefined), + manifests: new Map(), + registry: { getAllPackages: () => [], getNamespaceOwners: () => [], getPackage: () => undefined }, + }, INSTALLER), + protocol: { importUnmanagedPackage }, + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + register.mockClear(); + return { + install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), + register, + importUnmanagedPackage, + logger, + ledgerHas: (id: string) => new LocalManifestSource(dir).read(id).entry !== null, + }; +} + +const WITHHELD = [ + { type: 'connector', name: 'crm_billing', path: 'providerConfig.headers' }, + { type: 'connector', name: 'crm_billing', path: 'providerConfig.defaultHeaders' }, + { type: 'connector', name: 'crm_tools', path: 'providerConfig.transport.env' }, +]; + +/** A package as `GET /packages/:id/export` writes it: connectors served, their maps absent. */ +function exported(extra: Record = {}): Record { + return { + id: 'com.example.crm', + name: 'crm', + version: '4.0.0', + namespace: 'crm', + installModes: ['managed', 'unmanaged'], + connectors: [ + { name: 'crm_billing', label: 'Billing', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://billing.example.com' } }, + { name: 'crm_tools', label: 'Tools', type: 'api', provider: 'mcp', providerConfig: { transport: { kind: 'stdio', command: 'crm-tools' } } }, + ], + ...extra, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-withheld-maps-')); }); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('install-local — the maps a package export withheld (`withheldMaps`)', () => { + it('a managed install accepts the package and names each withheld map in its answer and one warn line', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: exported({ withheldMaps: WITHHELD }) }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.withheldMaps).toEqual(WITHHELD); + expect(h.register).toHaveBeenCalledTimes(1); + expect(h.ledgerHas('com.example.crm')).toBe(true); + const lines = h.logger.warn.mock.calls.map((call) => String(call[0])).filter((l) => l.includes('withheld whole')); + expect(lines).toHaveLength(1); + for (const m of WITHHELD) expect(lines[0]).toContain(`connector '${m.name}' ${m.path}`); + }); + + it('an unmanaged install copies the package and names each withheld map in its answer', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: exported({ withheldMaps: WITHHELD }), installMode: 'unmanaged' }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.installMode).toBe('unmanaged'); + expect(res.payload.data.withheldMaps).toEqual(WITHHELD); + expect(h.importUnmanagedPackage).toHaveBeenCalledTimes(1); + expect(h.register).not.toHaveBeenCalled(); + const lines = h.logger.warn.mock.calls.map((call) => String(call[0])).filter((l) => l.includes('withheld whole')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('unmanaged'); + }); + + it.each([['managed'], ['unmanaged']] as const)('a %s install of a body without the list answers no `withheldMaps` key, and warns nothing', async (installMode) => { + const h = await mount(dir); + const res = await h.install({ manifest: exported(), installMode }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data).not.toHaveProperty('withheldMaps'); + expect(h.logger.warn.mock.calls.some((call) => String(call[0]).includes('withheld whole'))).toBe(false); + }); + + it.each([ + ['a position outside the four', [{ type: 'connector', name: 'crm_billing', path: 'auth.token' }]], + ['a list of header names', [{ type: 'connector', name: 'crm_billing', path: 'providerConfig.headers', keys: ['Authorization'] }]], + ['a value that is not a list', 'providerConfig.headers'], + ])('refuses %s — PLUGIN_MANIFEST_INVALID, 400 inline, nothing written in either mode', async (_label, withheldMaps) => { + for (const installMode of ['managed', 'unmanaged'] as const) { + const h = await mount(dir); + const res = await h.install({ manifest: exported({ withheldMaps }), installMode }); + expect(res.status, JSON.stringify(res.payload)).toBe(400); + expect(res.payload.error.code).toBe('PLUGIN_MANIFEST_INVALID'); + expect(res.payload.error.message).toContain('withheldMaps'); + expect(h.register).not.toHaveBeenCalled(); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + } + }); +}); diff --git a/packages/qa/dogfood/test/export-withheld-connector-maps.dogfood.test.ts b/packages/qa/dogfood/test/export-withheld-connector-maps.dogfood.test.ts new file mode 100644 index 00000000000..a5dc5f3f2e7 --- /dev/null +++ b/packages/qa/dogfood/test/export-withheld-connector-maps.dogfood.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — a package export names each connector map it withheld +// whole, and both install modes of the import name each one as incomplete +// (#28640 ruling ③A). +// +// ## What was measured before the fix +// +// A package installed into the showcase through `POST /marketplace/install-local` +// with connectors carrying `providerConfig.headers`, `providerConfig.defaultHeaders` +// and `providerConfig.transport.headers` exported (`GET /packages/:id/export`) +// with each of those maps simply absent — the served read's connector redactor +// withholds them whole — and nothing in the manifest said so. Importing that +// export (managed, then unmanaged) answered 200 and named nothing: an +// incomplete map read as one the package never had. +// +// ## What this file pins, on one boot +// +// 1. the export lists each withheld map as `{ type, name, path }`, and only +// those: the connector with no map is not listed; the exported items stay +// as served, no header name and no header value in the manifest; +// 2. a MANAGED install of that export (the Studio import button's request) +// answers 200 and names each map in `withheldMaps`, as the export wrote it; +// 3. an UNMANAGED install of the same export, on a SECOND boot that never +// held the package (the import "elsewhere" the export exists for), +// answers 200 and names each map the same way. The per-package export +// writes its identity (`id`, `name`, `version`, `label`, `namespace`, +// `dependencies`) and its collections, so it carries neither +// `installModes` nor `type`, and the copy refuses a body without them +// (measured: `manifest.type` refused); that is the export's manifest-key +// family, not this file's. The test adds the two keys an operator adds +// before importing an export as a copy. +// +// Boots fixture stacks of its own (the install-local plugin mounted), so it +// stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { bootShowcase } from './showcase-boot.js'; + +const PACKAGE_ID = 'com.dogfood.withheldmaps'; +const REST_ENTRY = 'withheldmaps_rest'; +const MCP_ENTRY = 'withheldmaps_mcp'; +const PLAIN_ENTRY = 'withheldmaps_plain'; +const HEADER_NAME = 'X-Dogfood-Tenant'; +const HEADER_VALUE = 'dogfood-tenant-value'; + +/** The install body of the SOURCE package: three connectors, two carrying header maps. */ +const SOURCE_BODY = { + manifest: { + id: PACKAGE_ID, + namespace: 'withheldmaps', + name: 'Withheld connector maps', + version: '1.0.0', + type: 'app', + connectors: [ + { + name: REST_ENTRY, + label: 'REST with header maps', + type: 'api', + provider: 'rest', + providerConfig: { + baseUrl: 'http://127.0.0.1:9/', + headers: { [HEADER_NAME]: HEADER_VALUE }, + defaultHeaders: { Accept: 'application/json' }, + }, + }, + { + name: MCP_ENTRY, + label: 'MCP over http with a header map', + type: 'api', + provider: 'mcp', + providerConfig: { transport: { kind: 'http', url: 'http://127.0.0.1:9/mcp', headers: { [HEADER_NAME]: HEADER_VALUE } } }, + }, + { + name: PLAIN_ENTRY, + label: 'REST with no map', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'http://127.0.0.1:9/' }, + }, + ], + }, +}; + +const WITHHELD = [ + { type: 'connector', name: REST_ENTRY, path: 'providerConfig.headers' }, + { type: 'connector', name: REST_ENTRY, path: 'providerConfig.defaultHeaders' }, + { type: 'connector', name: MCP_ENTRY, path: 'providerConfig.transport.headers' }, +]; + +type Answer = { status: number; body: any }; + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +describe('dogfood: a package export names each connector map it withheld, and both install modes name each as incomplete', () => { + let stack: VerifyStack; + let elsewhere: VerifyStack; + const storageDirs: string[] = []; + let source: Answer; + let exported: Answer; + let managed: Answer; + let unmanaged: Answer; + + const boot = async (): Promise => { + const storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-withheld-maps-')); + storageDirs.push(storageDir); + return bootShowcase({ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })] }); + }; + + beforeAll(async () => { + stack = await boot(); + const token = await stack.signIn(); + source = await json(await stack.apiAs(token, 'POST', '/marketplace/install-local', SOURCE_BODY)); + exported = await json(await stack.apiAs(token, 'GET', `/packages/${encodeURIComponent(PACKAGE_ID)}/export`)); + const manifest = exported.body?.data ?? {}; + managed = await json(await stack.apiAs(token, 'POST', '/marketplace/install-local', { manifest })); + await stack.stop(); + + elsewhere = await boot(); + const other = await elsewhere.signIn(); + unmanaged = await json(await elsewhere.apiAs(other, 'POST', '/marketplace/install-local', { + manifest: { ...manifest, type: 'app', installModes: ['managed', 'unmanaged'] }, + installMode: 'unmanaged', + })); + }, 300_000); + + afterAll(async () => { + await elsewhere?.stop?.(); + for (const dir of storageDirs) rmSync(dir, { recursive: true, force: true }); + }); + + it('the source package installs and its export answers 200', () => { + expect(source.status, JSON.stringify(source.body)).toBe(200); + expect(exported.status, JSON.stringify(exported.body)).toBe(200); + }); + + it('1. the export lists each withheld map, and only those, and carries no header name or value', () => { + const manifest = exported.body?.data; + expect(manifest?.withheldMaps).toEqual(WITHHELD); + const names = (manifest?.connectors ?? []).map((c: any) => c.name).sort(); + expect(names).toEqual([MCP_ENTRY, PLAIN_ENTRY, REST_ENTRY]); + const rest = manifest.connectors.find((c: any) => c.name === REST_ENTRY); + expect(rest.providerConfig).not.toHaveProperty('headers'); + expect(rest.providerConfig).not.toHaveProperty('defaultHeaders'); + const text = JSON.stringify(manifest); + expect(text).not.toContain(HEADER_NAME); + expect(text).not.toContain(HEADER_VALUE); + }); + + it('2. a managed install of the export answers 200 and names each map', () => { + expect(managed.status, JSON.stringify(managed.body)).toBe(200); + expect(managed.body?.data?.withheldMaps).toEqual(WITHHELD); + }); + + it('3. an unmanaged install of the export, on a boot that never held the package, answers 200 and names each map', () => { + expect(unmanaged.status, JSON.stringify(unmanaged.body)).toBe(200); + expect(unmanaged.body?.data?.installMode).toBe('unmanaged'); + expect(unmanaged.body?.data?.withheldMaps).toEqual(WITHHELD); + const copied = (unmanaged.body?.data?.copied ?? []).filter((i: any) => i.type === 'connector').map((i: any) => i.name).sort(); + expect(copied).toEqual([MCP_ENTRY, PLAIN_ENTRY, REST_ENTRY]); + }); +}); diff --git a/packages/runtime/src/domains/packages-export-withheld-maps.test.ts b/packages/runtime/src/domains/packages-export-withheld-maps.test.ts new file mode 100644 index 00000000000..c225f50a1a0 --- /dev/null +++ b/packages/runtime/src/domains/packages-export-withheld-maps.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28640 ruling ③A] `GET /packages/:id/export` writes `withheldMaps`: each + * header or environment-variable map of an exported connector that the served + * read withheld whole, as `{ type: 'connector', name, path }`. + * + * The protocol is a double and the door is the real dispatcher. The double's + * per-package list serves each connector the way the real served read does — + * through the real `connector` redactor — and its by-name execution read + * answers the stored body, so the export's verdict is judged against the same + * redaction the served read ran. + * + * - all four positions, in declaration order per connector, in export order; + * - a connector with no map, and one whose map is CLEARED (`{}`, served as + * written), are not listed; a package withholding nothing carries no key; + * - the stored body never reaches the export: no header or variable name; + * - no verdict is guessed: a protocol without the execution face, or a + * connector the face answers no body for, fails the export. + */ +import { describe, expect, it, vi } from 'vitest'; +import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; +import { PackageExportManifestSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.example.crm'; +const redact = getMetadataTypeRedactor('connector')!; + +const REST = { + name: 'crm_billing', label: 'Billing', type: 'api', provider: 'rest', + providerConfig: { + baseUrl: 'https://billing.example.com', + headers: { 'X-Billing-Key': 'sk_live_secret' }, + defaultHeaders: { Accept: 'application/json' }, + }, +}; +const MCP_HTTP = { + name: 'crm_assist', label: 'Assist', type: 'api', provider: 'mcp', + providerConfig: { transport: { kind: 'http', url: 'https://assist.example.com/mcp', headers: { Authorization: 'Bearer tok_secret' } } }, +}; +const MCP_STDIO = { + name: 'crm_tools', label: 'Tools', type: 'api', provider: 'mcp', + providerConfig: { transport: { kind: 'stdio', command: 'crm-tools', env: { CRM_TOKEN: 'env_secret' } } }, +}; +const PLAIN = { name: 'crm_status', label: 'Status', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://status.example.com' } }; +const CLEARED = { name: 'crm_cleared', label: 'Cleared', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://c.example.com', headers: {} } }; + +const system = (): any => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } }); + +/** A protocol whose served list redacts as the real one does, and whose execution face answers the stored body. */ +function protocolOf(stored: Record[], opts: { executionFace?: boolean; missing?: string } = {}) { + const byName = new Map(stored.map((c) => [c.name, { ...c, _packageId: PKG }])); + return { + getMetaItems: vi.fn(async () => ({ items: [] })), + getMetaItemsPerPackage: vi.fn(async (req: { type: string }) => ({ + items: req.type === 'connector' ? [...byName.values()].map((c) => redact(c).item) : [], + })), + ...(opts.executionFace === false ? {} : { + getMetaItemForExecution: vi.fn(async (req: { type: string; name: string; packageId?: string }) => ({ + type: req.type, + name: req.name, + item: req.name === opts.missing ? undefined : byName.get(req.name), + })), + }), + }; +} + +function make(protocol: any) { + const registry = { + getPackage: vi.fn((id: string) => ({ id, manifest: { id, name: 'crm', version: '1.0.0' } })), + getAllPackages: vi.fn().mockReturnValue([]), + }; + const kernel: any = { + context: { getService: (name: string) => (name === 'objectql' ? { registry } : name === 'protocol' ? protocol : null) }, + }; + return new HttpDispatcher(kernel); +} + +async function exportOf(protocol: any) { + const r = await make(protocol).handlePackages(`/${encodeURIComponent(PKG)}/export`, 'GET', undefined, {}, system()); + const body: any = r.response?.body; + return { status: r.response?.status, manifest: body?.data ?? body }; +} + +describe('[#28640 ruling ③A] GET /packages/:id/export names each connector map the served read withheld', () => { + it('lists every withheld position — the four, per connector in declaration order, connectors in export order', async () => { + const protocol = protocolOf([REST, PLAIN, MCP_HTTP, MCP_STDIO]); + const { status, manifest } = await exportOf(protocol); + expect(status, JSON.stringify(manifest)).toBe(200); + expect(manifest.withheldMaps).toEqual([ + { type: 'connector', name: 'crm_billing', path: 'providerConfig.headers' }, + { type: 'connector', name: 'crm_billing', path: 'providerConfig.defaultHeaders' }, + { type: 'connector', name: 'crm_assist', path: 'providerConfig.transport.headers' }, + { type: 'connector', name: 'crm_tools', path: 'providerConfig.transport.env' }, + ]); + // The verdict was read on the execution face, under the package's id. + expect(protocol.getMetaItemForExecution).toHaveBeenCalledWith({ type: 'connector', name: 'crm_billing', packageId: PKG }); + // The export parses through its own declaration, the list preserved. + const parsed = PackageExportManifestSchema.safeParse(manifest); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('the exported items stay as served — each withheld map absent — and no header or variable name leaves', async () => { + const { manifest } = await exportOf(protocolOf([REST, MCP_HTTP, MCP_STDIO])); + const billing = manifest.connectors.find((c: any) => c.name === 'crm_billing'); + expect(billing.providerConfig).toEqual({ baseUrl: 'https://billing.example.com' }); + const text = JSON.stringify(manifest); + for (const secret of ['X-Billing-Key', 'sk_live_secret', 'Authorization', 'tok_secret', 'CRM_TOKEN', 'env_secret']) { + expect(text).not.toContain(secret); + } + }); + + it('a connector with no map, or a CLEARED one, is not listed; a package withholding nothing carries no key', async () => { + const { status, manifest } = await exportOf(protocolOf([PLAIN, CLEARED])); + expect(status).toBe(200); + expect(manifest.connectors.map((c: any) => c.name)).toEqual(['crm_status', 'crm_cleared']); + expect(manifest).not.toHaveProperty('withheldMaps'); + }); + + it('a package with no connectors asks the execution face nothing and carries no key', async () => { + const protocol = protocolOf([]); + const { status, manifest } = await exportOf(protocol); + expect(status).toBe(200); + expect(manifest).not.toHaveProperty('withheldMaps'); + expect(protocol.getMetaItemForExecution).not.toHaveBeenCalled(); + }); + + it('guesses no verdict: a protocol without the execution face fails the export instead of omitting the key', async () => { + const { status, manifest } = await exportOf(protocolOf([REST], { executionFace: false })); + expect(status).toBe(500); + expect(manifest?.connectors).toBeUndefined(); + }); + + it('guesses no verdict: a connector the execution face answers no body for fails the export', async () => { + const { status, manifest } = await exportOf(protocolOf([PLAIN, REST], { missing: 'crm_billing' })); + expect(status).toBe(500); + expect(manifest?.withheldMaps).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 4e2fce3d00c..ec2bd91c5d4 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -166,6 +166,10 @@ import { PackageInstallRequestSchema, PublishPackageDraftsRequestSchema, } from '@objectstack/spec/api'; +// [#28640 ruling ③A] The export names each connector map the served read +// withheld: the declaration of that list, and the redactor that decided it. +import { PackageExportManifestSchema, type PackageExportManifest } from '@objectstack/spec/api'; +import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; /** * [#13598] The `protocol` service slot **as this domain reaches it** — one @@ -243,6 +247,12 @@ export type PackagesDomainProtocol = * producer's own signature as above. */ getMetaItemsPerPackage?: ObjectStackProtocolImplementation['getMetaItemsPerPackage']; + /** + * [#28640 ruling ③A] The by-name execution read the export asks for a + * DECISION only (which connector maps the served read withheld), typed + * from the producer's own signature. + */ + getMetaItemForExecution?: ObjectStackProtocolImplementation['getMetaItemForExecution']; /** ⚠️ Undeclared request shapes — see "Where the ledger honestly ends". */ publishPackageDrafts?(request: any): Promise; discardPackageDrafts?(request: any): Promise; @@ -3592,9 +3602,71 @@ context: HttpProtocolContext, if (namespace !== undefined && namespace !== null) manifest.namespace = namespace; const dependencies = pkg?.manifest?.dependencies; if (dependencies !== undefined && dependencies !== null) manifest.dependencies = { ...dependencies }; + // [#28640 ruling ③A] Every connector map the served read withheld whole, + // named so an import can tell an incomplete map from one never written. + // Omitted, not emptied, when nothing was withheld. + const withheldMaps = await withheldConnectorMaps(protocol, packageId, manifest.connectors); + if (withheldMaps.length > 0) manifest.withheldMaps = withheldMaps; return manifest; } +/** One entry of {@link PackageExportManifestSchema}'s `withheldMaps`. */ +type WithheldMap = NonNullable[number]; + +/** + * [#28640 ruling ③A] The `withheldMaps` of an export: each header or + * environment-variable map of an exported connector that the served read + * withheld whole, as `{ type: 'connector', name, path }`, in the order the + * connectors are exported and, within one, the order the declaration lists + * the positions. + * + * The export writes SERVED items, so a withheld map is simply absent from the + * item, and an absence there cannot be told from a map never written. So the + * verdict is read where the redaction is decided: each exported connector's + * stored body, read on the protocol's by-name EXECUTION face + * (`getMetaItemForExecution`), is handed to the same `connector` redactor the + * served read ran, and every position it reports withheld that the + * declaration names is listed. Only that decision leaves this function, never + * a byte of the stored body: a position, and no header or variable name. + * + * A connector cannot be replaced by a declared override (`connector` is not an + * overridable type, ADR-0146 D2), so the by-name read under the package's id + * reads the item the per-package list served. + * + * ⛔ No verdict is guessed. A protocol without the execution face, or an + * exported connector the face answers no body for, is a failure: an export + * that writes this key promises that its absence means nothing was withheld. + */ +async function withheldConnectorMaps( + protocol: Pick, + packageId: string, + connectors: unknown, +): Promise { + if (!Array.isArray(connectors) || connectors.length === 0) return []; + const redact = getMetadataTypeRedactor('connector'); + if (!redact) { + throw new Error(`[packages] export of '${packageId}': no 'connector' redactor is registered, so the export cannot name the connector maps the served read withheld.`); + } + if (typeof protocol.getMetaItemForExecution !== 'function') { + throw new Error(`[packages] export of '${packageId}': the metadata protocol has no execution read (getMetaItemForExecution), so the export cannot name the connector maps the served read withheld.`); + } + const positions: readonly WithheldMap['path'][] = PackageExportManifestSchema.shape.withheldMaps.unwrap().element.shape.path.options; + const withheld: WithheldMap[] = []; + for (const connector of connectors) { + const name = (connector as { name?: unknown } | null)?.name; + if (typeof name !== 'string') continue; + const stored = (await protocol.getMetaItemForExecution({ type: 'connector', name, packageId }))?.item; + if (!stored || typeof stored !== 'object') { + throw new Error(`[packages] export of '${packageId}': the execution read answered no body for connector '${name}', so the export cannot say whether its maps were withheld.`); + } + const { redactedKeys } = redact(stored as Record); + for (const path of positions) { + if (redactedKeys.includes(path)) withheld.push({ type: 'connector', name, path }); + } + } + return withheld; +} + /** * [#28914] The answer of `GET /packages/export`: every package whose registry * row's `source` is not `code`, enabled or not, each once, dependencies first, diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index 8a542487415..fb697f12ca5 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -241,10 +241,13 @@ describe('PackageExportManifestSchema.withheldMaps names each map the export wit if (result.success) expect(result.data).toEqual(manifest); }); - it('is declared ahead of its producer: the describe opens with the planned marker and names the ADR, never a tracker id', () => { + it('is served: the describe carries no planned marker, and names no tracker id', () => { + // The export writes the list and both install modes read it (runtime + // `assemblePackageManifest`, cloud-connection's install-local door), so the + // ADR-0049 declared-ahead marker is dropped in the change that serves it. const description = PackageExportManifestSchema.shape.withheldMaps.description ?? ''; - expect(description).toMatch(/^\[PLANNED — not yet served\] /); - expect(description).toContain('ADR-0049'); + expect(description).not.toContain('PLANNED'); + expect(description).toMatch(/^Every map the export withheld WHOLE/); expect(description).not.toMatch(/#\d{3,}/); }); diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 3c9938c3879..737863cd020 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -37,13 +37,6 @@ * declared a VERSION rollback against the live COMMIT-rollback path; * `RollbackToPackageCommitResponseSchema` below is the true contract, * authored after that retirement per the ruling's sequencing (3A). - * - * Two declarations are not transcriptions but DECLARED AHEAD OF THEIR - * PRODUCER (ADR-0049), each describe opening `[PLANNED — not yet served]` - * until the change that serves it drops the marker: - * `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by - * #29342) and `PackageExportBundleSchema` (`GET /packages/export`, #28914). - * Their own docblocks say what is not served yet. */ import { z } from 'zod'; @@ -209,14 +202,12 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({ * redactor's own `redactedKeys` spelling of those four positions, and * `package-lifecycle.test.ts` holds the two equal. * - * DECLARED AHEAD OF ITS PRODUCER (ADR-0049): `withheldMaps` is declared - * before any export writes it and before any install door reads it. Until the - * server half (#29342) makes `assemblePackageManifest` write the list and both - * install doors name each incomplete map, no export carries it; its describe - * opens `[PLANNED — not yet served]`, and #29342 drops the marker in the change - * that serves it (the pin is in `package-lifecycle.test.ts`). Its `door` row in - * `UNMANAGED_INSTALL_KEY_DISPOSITIONS` is live today: it keeps the unmanaged - * import from refusing an export that carries the key. + * Served (#29342): `assemblePackageManifest` writes the list, judging each + * exported connector's stored body with the same redactor the served read ran, + * and both install modes of `POST /marketplace/install-local` read it, accept + * the package and name each incomplete map in their answer. Its `door` row in + * `UNMANAGED_INSTALL_KEY_DISPOSITIONS` keeps the unmanaged import from + * refusing an export that carries the key. */ export const PackageExportManifestSchema = lazySchema(() => z.object({ id: z.string().describe('The exported package\'s id.'), @@ -233,8 +224,6 @@ export const PackageExportManifestSchema = lazySchema(() => z.object({ + '(ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2).', ), label: z.string().optional().describe('Display label, when the package declares one.'), - // Declared ahead of its producer: #29342 writes and reads it, and drops the - // `[PLANNED — not yet served]` marker in that change. withheldMaps: z.array(z.object({ type: z.literal('connector').describe('Metadata type of the item whose map was withheld.'), name: z.string().describe('Name of the item whose map was withheld.'), @@ -245,11 +234,10 @@ export const PackageExportManifestSchema = lazySchema(() => z.object({ 'providerConfig.transport.env', ]).describe('Dot path of the withheld map inside the item.'), }).strict()).optional().describe( - '[PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list ' - + 'yet and no install door reads it yet. Every map the export withheld WHOLE because a served read ' - + 'never carries it: a connector\'s header and environment-variable maps. The item is written with ' - + 'that map absent, so once served an import reads this list to tell an incomplete map from one the ' - + 'package never had, and names each one for an operator to re-enter. Positions only: no header or ' + 'Every map the export withheld WHOLE because a served read never carries it: a connector\'s header ' + + 'and environment-variable maps. The item is written with that map absent, so an import reads this ' + + 'list to tell an incomplete map from one the package never had, and names each one for an operator ' + + 'to re-enter. Positions only: no header or ' + 'variable name is listed. Absence says nothing about what was withheld when the exporter predates ' + 'this key; from an exporter that writes it, absence means nothing was withheld.', ), diff --git a/packages/spec/src/kernel/unmanaged-install.ts b/packages/spec/src/kernel/unmanaged-install.ts index 132ea0de80b..52aca2c968a 100644 --- a/packages/spec/src/kernel/unmanaged-install.ts +++ b/packages/spec/src/kernel/unmanaged-install.ts @@ -116,10 +116,10 @@ export const UNMANAGED_INSTALL_KEY_DISPOSITIONS: Readonly