Skip to content

Commit 60fe44f

Browse files
feat(cloud-connection,metadata-core): an install the protocol handshake admitted unchecked says so in its answer (#30409)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgXzMmGY8jy4ddsrnh83rUgi)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:从市场或文件安装一个没声明平台协议版本(`engines.protocol`)的应用时,安装成功的应答里多了 `protocolCheckSkipped`:说明没做兼容性检查、按哪个协议版本装的,并带一句给管理员看的话;声明了版本范围、检查通过的包,应答和以前一字不差。 - 为什么改:兼容性检查被跳过时,只有服务器日志里有一行 warn,点"安装"的管理员看不到;这是卡 #30326 的 server 半,页面上怎么展示是之后的 ui 卡。 - 风险与回滚:只在成功应答里加一个可选键,不改任何拒绝路径,日志行照旧;`assertProtocolCompat` 从无返回值改为返回它本来就算出的判决,旧调用方不受影响。回滚即撤本 PR,无数据迁移。 - 先看:`packages/cloud-connection/src/marketplace-install-local-plugin.ts` 的 `protocolCheckSkippedOf` 和步骤 1c 里接住判决的那几行;`packages/metadata-core/src/protocol-handshake.ts` 的 `assertProtocolCompat` 返回值。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-protocol-handshake.test.ts -t 30326`,预期 5 条全绿;在 main 的代码上其中 4 条是红的(对照那条本来就绿)。 - 需要你做的:无,席位落地。 Part of #30326 Clause-②: yes (widening) — the three success answers of `POST /api/v1/marketplace/install-local` gain the optional `data.protocolCheckSkipped`; `@objectstack/metadata-core`'s `assertProtocolCompat` returns `ProtocolCompatAdmitted` (new exported type) where it returned `void`, and the `no-range` / `unparsed-range` members of the exported `ProtocolCompatResult` gain `protocolVersion`. Nothing accepted or answered is taken away. Metadata-protocol: none — an install answer and a runtime helper's return value, not anything a metadata author writes Skills-docs: none — `git grep -n -i "install-local\|declares no engines\|without a compatibility check\|protocolCheck\|engines.protocol" -- skills` (evals excluded) hits four lines: `objectstack-platform/SKILL.md:118` (a declared range is checked and an out-of-range app refused, still true), `objectstack-platform/references/operations.md:106` and `:111` (what `os package install` and the plugin are), `objectstack-upgrade/SKILL.md:87` (where the declared range is read). None describes what an install answers for a package with no range. ## What changes The card: "The install answer carries this, and the page shows it in plain words". The triage (6652014530) puts the server half in `marketplace-install-local-plugin.ts`; the UI half is a later `domain:ui` card, so #30326 stays open after this PR (the seat releases it at landing). **The handshake hands its verdict back.** `assertProtocolCompat` (ADR-0087 D1) already computed a verdict and threw on `incompatible`; for `ok`, `no-range` and `unparsed-range` it returned nothing, so a door that answers a caller could only log. It now returns the verdict `checkProtocolCompat` gave (`ProtocolCompatAdmitted` = every member but `incompatible`), and the `no-range` / `unparsed-range` members carry `protocolVersion`, the version they were judged against (the `incompatible` member and its diagnostic already did). No new judgement: the range is read once, by the handshake. **The install answer carries the skipped check.** Step 1c of `handleInstall` keeps the verdict, and `protocolCheckSkippedOf` turns a `no-range` or `unparsed-range` verdict into ``` protocolCheckSkipped: { reason, packageId, protocolVersion, message } // + requiredRange, rangeSource for unparsed-range ``` `reason` is the handshake's own `status` value, the stable machine code; `packageId` is the id the door's id gate parsed; `protocolVersion` is the protocol the package was installed under unchecked; `message` says it in plain words for the person who pressed Install. It rides all three success answers: managed, `unmanaged` (`installUnmanaged`) and a paired customization package (`installCustomizationPair`), passed down the way `withheldMaps` is. It is omitted, not nulled, when the range was checked, so that answer's key set is unchanged. The `warn` line `[MarketplaceInstallLocal] [protocol] package '…' declares no engines.protocol range; …` is unchanged. **Why the code is in `reason`, not `code`.** A `code:` literal in the answer would be read by `check:dispatcher-error-vocabulary` as an ADR-0112 error code and would need a row in the spec ledger. This is a notice on a `200`, not an error code (the D6c family `check:error-code-casing` describes), and the handshake's own status value is the code the ruling asks for. ## Contract stop: measured, not reached The order: stop if carrying the notice needs a `packages/spec` change. Measured on `fdad1786` and again on `870ebce3`: no schema in `packages/spec/src` describes the install-local success answer. The spec's `withheldMaps` (`PackageExportManifestSchema`) is the export manifest's, not the answer's; `git grep -n "hotLoaded\|upgradedFrom\|credentialNote" -- packages/spec/src` (three keys only this answer carries) hits nothing. The answer is a cloud-connection-internal REST shape, so the field is added there. ## Tests - `marketplace-install-local-protocol-handshake.test.ts`, new top-level `describe('[#30326] …')`: an inline manifest with no range answers `protocolCheckSkipped` `{ reason: 'no-range', packageId, protocolVersion }` with the plain-words message, is registered, and logs exactly one `[protocol]` warn line; the compiled artifact `os package install FILE` sends answers the same; a range the handshake cannot read (`workspace:*`) answers `unparsed-range` with `requiredRange` and `rangeSource`; an `unmanaged` install of the same package carries it; control: under `^CURRENT_MAJOR` the answer has no `protocolCheckSkipped` key. The harness's `mount` gained an optional protocol `importUnmanagedPackage` stub for the unmanaged case. - `marketplace-install-local-customization-pair-install.test.ts`, new top-level `describe`: a pair export with no range answers `no-range`; with `^CURRENT_MAJOR` no key. - `protocol-handshake.test.ts`, new top-level `describe`: `assertProtocolCompat` returns the `no-range` and `unparsed-range` verdicts with `protocolVersion`, and the `ok` verdict equal to `checkProtocolCompat`'s. - Red first, on `fdad1786` with the fix reverted and the pins in: the 5 notice cases red (`200` with no `protocolCheckSkipped`), the 2 controls green. With the fix: 43/43 in the two files. - `marketplace-install-local-jobs.test.ts`: its fixture now declares `engines.protocol: ^CURRENT_MAJOR`, as a scaffolded app does. Its jobs-free case pins the answer's exact key set, and the fixture declared no range, so with this change the set gained `protocolCheckSkipped`; with a declared range the pin keeps meaning "the jobs gate adds nothing". <details><summary>Suites and typecheck on 4d926a7</summary> `os-verify-lock` VERDICT command-exit 0: `@objectstack/metadata-core` typecheck exit 0, `@objectstack/cloud-connection` typecheck exit 0; `@objectstack/metadata-core` test Test Files 23/23, Tests 911/911; `@objectstack/cloud-connection` test Test Files 70/70, Tests 877/877. </details> ## Ablation One leg, through `scripts/ablation-replace.mjs` on `4d926a74`: deleting `protocolCheckSkipped = protocolCheckSkippedOf(admitted, manifestId);` (anchor 1 → 0, blob `8e7fdb92` → `5f6c1432`) turns the 5 notice cases red, 38 green; restored to blob == HEAD `8e7fdb92`, `git diff HEAD` empty. The cloud-connection suite resolves `@objectstack/metadata-core` to `src/` (vitest alias), and the plugin is imported from `src/`, so no `dist/` leg applies. ## Acceptance notes - The other install door, `POST /api/v1/packages` (`packages/runtime/src/domains/packages.ts`, `assertProtocolCompat(manifest)`), still answers success with no notice for a package with no range. It is outside this order's surface; with `assertProtocolCompat` now returning the verdict, the same spread there is a small follow-up. carrier: seat 5. - `os package install` prints the answer's `note`; whether the CLI also prints `protocolCheckSkipped` is the CLI's call. carrier: seat 5. - Whether publishing should warn or refuse such a package is out of scope (the card says so). ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head `6c1d2ef22` (after merging `origin/main` at `8fad7b193`): 60 families, the same list as on `4d926a749`. All 60 run, exit codes captured before any pipe, all `exit 0`; `--ran` reconciliation: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN. On `4d926a749` the same 60 were all `exit 0` after `git fetch origin 621a487` gave `check-plugin-teardown-shape --self-test` its pinned fixture (it exits 3 on a shallow clone). - `pnpm lint` exit 0 on `4d926a749`; the merge brought no file of this diff's packages. - The merge from `origin/main` (#30380, #30368) touched `metadata-protocol`, `cli` and `spec`, none of this diff's packages, so the suites and typechecks above were not re-run on `6c1d2ef22`. - No `!` in the title and no `(narrowing)` arm: no full dogfood run owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZ35sjTKgyrvdkytCz)_ <!-- signed: domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09720e2 commit 60fe44f

7 files changed

Lines changed: 242 additions & 14 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
'@objectstack/metadata-core': patch
4+
---
5+
6+
feat(cloud-connection,metadata-core): an install the protocol handshake admitted without checking says so in its answer, not only in the server log
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/cloud-connection`: `POST /api/v1/marketplace/install-local` answers `data.protocolCheckSkipped`** when the ADR-0087 handshake admitted the package without checking it: `{ reason, packageId, protocolVersion, message }`, plus `requiredRange` and `rangeSource` for `reason: 'unparsed-range'`. `reason` is the handshake's own verdict: `no-range` (the package declares no `engines.protocol`) or `unparsed-range` (it declares a range the handshake cannot read). `protocolVersion` is the protocol the package was installed under unchecked, and `message` says so in plain words for the person who pressed Install. It is on all three success answers (managed, `unmanaged`, and a paired customization package), and omitted, not nulled, when the range was checked, so that answer's key set is unchanged. The `warn` log line stays as it was.
12+
- **`@objectstack/metadata-core`: `assertProtocolCompat` returns the verdict it admitted the package under** (new exported type `ProtocolCompatAdmitted`; it returned `void`), and the `no-range` and `unparsed-range` verdicts of `checkProtocolCompat` carry `protocolVersion`, the version they were judged against. A door that answers a caller reads the skipped check from the handshake itself instead of judging the range a second time.

‎packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2727
import { mkdtempSync, rmSync } from 'node:fs';
2828
import { join } from 'node:path';
2929
import { tmpdir } from 'node:os';
30+
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
3031
// The runtime binder the door's step 1d loads lazily — paid here, at module
3132
// top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`).
3233
import '@objectstack/runtime';
@@ -368,3 +369,21 @@ describe('install-local — a pair install refused before any write', () => {
368369
expectNoCodeInstall(h);
369370
});
370371
});
372+
373+
describe('[#30326] a pair install the handshake admitted unchecked says so in its answer', () => {
374+
it('a pair export with no engines.protocol answers `protocolCheckSkipped` reason `no-range`', async () => {
375+
const h = await mount(dir);
376+
const res = await h.install(exportedPair());
377+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
378+
const { message, ...machine } = res.payload.data.protocolCheckSkipped;
379+
expect(machine).toEqual({ reason: 'no-range', packageId: PAIR, protocolVersion: PROTOCOL_VERSION });
380+
expect(message).toContain(`'${PAIR}'`);
381+
});
382+
383+
it(`control: a pair export that declares ^${PROTOCOL_MAJOR} answers no \`protocolCheckSkipped\` key`, async () => {
384+
const h = await mount(dir);
385+
const res = await h.install(exportedPair({ engines: { protocol: `^${PROTOCOL_MAJOR}` } }));
386+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
387+
expect(Object.keys(res.payload.data)).not.toContain('protocolCheckSkipped');
388+
});
389+
});

‎packages/cloud-connection/src/marketplace-install-local-jobs.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { tmpdir } from 'node:os';
4747
// the plugin reaches the same module through a dynamic `import()`.
4848
import '@objectstack/runtime';
4949
import { SCHEDULED_WORK_ENV } from '@objectstack/types';
50+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
5051
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
5152
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
5253
import { LocalManifestSource } from './local-manifest-source.js';
@@ -66,7 +67,13 @@ const HANDLER_JOB = { name: 'jobs_app_tick_handler', schedule: INTERVAL, handler
6667
/** The compiled-artifact shape `os build` writes and `os package install` sends. */
6768
function artifact(jobs: unknown[] | undefined, id: string = APP_ID) {
6869
return {
69-
manifest: { id, namespace: 'jobs_app', version: '0.1.0', type: 'app', name: 'Jobs App' },
70+
// A range this runtime admits, as a scaffolded app declares one: the
71+
// answer then carries no `protocolCheckSkipped` notice, and the key
72+
// set the jobs-free case pins is the jobs gate's alone.
73+
manifest: {
74+
id, namespace: 'jobs_app', version: '0.1.0', type: 'app', name: 'Jobs App',
75+
engines: { protocol: `^${PROTOCOL_MAJOR}` },
76+
},
7077
objects: [{ name: TICK, label: 'Tick', fields: { name: { type: 'text', label: 'Name' } } }],
7178
...(jobs ? { jobs } : {}),
7279
};

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ import { PackageExportManifestSchema, type PackageExportManifest } from '@object
207207
import {
208208
assertProtocolCompat,
209209
checkProtocolCompat,
210+
type ProtocolCompatAdmitted,
210211
// [ADR-0141 D2] The pre-install dependency check: the one predicate and
211212
// the one answer every package-install door gives its refusal.
212213
checkDeclaredDependencies,
@@ -728,6 +729,58 @@ function withheldMapsLogLine(
728729
+ 'environment variables until each map is supplied again where the connector is authored.';
729730
}
730731

732+
/**
733+
* [#30326] What an install answer carries when ADR-0087 D1's handshake
734+
* admitted the package WITHOUT checking it: the person who pressed Install is
735+
* the one who needs to know, and the `warn` line alone reaches only whoever
736+
* reads the server log. Present on a success answer only then; omitted, not
737+
* nulled, for a package whose range was checked, so that answer's key set is
738+
* unchanged.
739+
*
740+
* `reason` is the handshake's own verdict, never a second reading of the
741+
* range: `no-range` (the package declares no `engines.protocol`, nor either
742+
* legacy spelling) or `unparsed-range` (it declares a range the handshake's
743+
* grammar does not read). `protocolVersion` is the protocol the package was
744+
* loaded under unchecked.
745+
*/
746+
export type ProtocolCheckSkipped =
747+
| { reason: 'no-range'; packageId: string; protocolVersion: string; message: string }
748+
| {
749+
reason: 'unparsed-range';
750+
packageId: string;
751+
protocolVersion: string;
752+
requiredRange: string;
753+
rangeSource: string;
754+
message: string;
755+
};
756+
757+
/** [#30326] The notice for a verdict the handshake admitted unchecked; `undefined` for a checked one. */
758+
function protocolCheckSkippedOf(verdict: ProtocolCompatAdmitted, packageId: string): ProtocolCheckSkipped | undefined {
759+
if (verdict.status === 'ok') return undefined;
760+
const consequence = `so it was installed under platform protocol ${verdict.protocolVersion} without a compatibility `
761+
+ 'check and may not work on this platform version. A build of the package that declares the protocol it '
762+
+ 'supports (`engines.protocol`) is checked at install.';
763+
switch (verdict.status) {
764+
case 'no-range':
765+
return {
766+
reason: 'no-range',
767+
packageId,
768+
protocolVersion: verdict.protocolVersion,
769+
message: `Package '${packageId}' does not state which platform protocol it supports, ${consequence}`,
770+
};
771+
case 'unparsed-range':
772+
return {
773+
reason: 'unparsed-range',
774+
packageId,
775+
protocolVersion: verdict.protocolVersion,
776+
requiredRange: verdict.requiredRange,
777+
rangeSource: verdict.source,
778+
message: `Package '${packageId}' states its platform protocol as '${verdict.requiredRange}' `
779+
+ `(${verdict.source}), which this runtime cannot read, ${consequence}`,
780+
};
781+
}
782+
}
783+
731784
/**
732785
* [ADR-0131 D6] The package manifest an install body DECLARES — where each of
733786
* the two single-package shapes this door accepts carries it: a compiled
@@ -1911,8 +1964,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
19111964
// hooks with THIS runtime's binder: ADR-0087 D1 checks "before
19121965
// loading a package's metadata". An absent or unrecognised range is
19131966
// admitted with a warning, as at every seam the handshake guards.
1967+
//
1968+
// [#30326] The log line is the operator's; the verdict it admitted
1969+
// the package under is also the answer's: every success answer
1970+
// below carries `protocolCheckSkipped` when the check did not run.
1971+
let protocolCheckSkipped: ProtocolCheckSkipped | undefined;
19141972
try {
1915-
assertProtocolCompat(manifest, undefined, (m) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${m}`));
1973+
const admitted = assertProtocolCompat(manifest, undefined, (m) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${m}`));
1974+
protocolCheckSkipped = protocolCheckSkippedOf(admitted, manifestId);
19161975
} catch (err) {
19171976
if (!isProtocolIncompatibleError(err)) throw err;
19181977
const refusal = protocolIncompatibleAnswer(err);
@@ -2090,6 +2149,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
20902149
versionId: resolvedVersionId,
20912150
userId,
20922151
withheldMaps,
2152+
protocolCheckSkipped,
20932153
});
20942154
}
20952155

@@ -2135,6 +2195,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
21352195
versionId: resolvedVersionId,
21362196
userId,
21372197
withheldMaps,
2198+
protocolCheckSkipped,
21382199
});
21392200
}
21402201

@@ -2520,6 +2581,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
25202581
// withheld whole, so it arrived absent. Omitted when there are
25212582
// none, so that answer's key set is unchanged.
25222583
...(withheldMaps.length > 0 ? { withheldMaps } : {}),
2584+
// [#30326] The handshake admitted the package without checking
2585+
// it (no `engines.protocol`, or a range it cannot read): the
2586+
// installer is told, not only the server log. Omitted when the
2587+
// range was checked, so that answer's key set is unchanged.
2588+
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
25232589
upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null,
25242590
// [ADR-0146 D2, #28410] Names installed dependents already held,
25252591
// undeclared, that this version now ships (step 2d). Omitted when
@@ -3738,9 +3804,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
37383804
manifest: Record<string, any>; manifestId: string; version: string; versionId: string; userId: string | null;
37393805
/** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */
37403806
withheldMaps: readonly WithheldMap[];
3807+
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
3808+
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
37413809
},
37423810
): Promise<Response> => {
3743-
const { manifest, manifestId, version, versionId, userId, withheldMaps } = args;
3811+
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
37443812
let protocol: UnmanagedImporter | undefined;
37453813
try { protocol = ctx.getService<UnmanagedImporter>('protocol'); } catch { /* no protocol service */ }
37463814
if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') {
@@ -3820,6 +3888,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
38203888
// withheld whole, so its copy carries it absent. Omitted when
38213889
// there are none, so that answer's key set is unchanged.
38223890
...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}),
3891+
// [#30326] Omitted when the handshake checked the package's range.
3892+
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
38233893
...(declaresSeeds
38243894
? {
38253895
seeded: {
@@ -3891,9 +3961,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
38913961
version: string; versionId: string; userId: string | null;
38923962
/** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */
38933963
withheldMaps: readonly WithheldMap[];
3964+
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
3965+
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
38943966
},
38953967
): Promise<Response> => {
3896-
const { manifest, manifestId, baseId, version, versionId, userId, withheldMaps } = args;
3968+
const { manifest, manifestId, baseId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
38973969
const refuse = (status: number, code: string, message: string, details?: Record<string, unknown>) =>
38983970
c.json({ success: false, error: { code, message, ...(details ? { details } : {}) } }, status as any);
38993971

@@ -4054,6 +4126,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
40544126
written,
40554127
// [#28640 ruling ③A] Omitted when the export withheld none.
40564128
...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}),
4129+
// [#30326] Omitted when the handshake checked the package's range.
4130+
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
40574131
...(plan.carriesSeedData
40584132
? {
40594133
seeded: {

‎packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,28 @@ function makeRawApp() {
9696
* hand back every writer the install path can reach, so a refusal can be
9797
* checked for having written nothing at all.
9898
*/
99-
async function mount(dir: string, opts: { controlPlaneUrl?: string } = {}) {
99+
async function mount(dir: string, opts: { controlPlaneUrl?: string; unmanagedImport?: boolean } = {}) {
100100
const rawApp = makeRawApp();
101101
const hooks = new Map<string, any>();
102102
const registry: any[] = [];
103103
const register = vi.fn((m: any) => { registry.push({ manifest: m }); });
104104
const syncSchemas = vi.fn(async () => undefined);
105105
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
106+
// [#30326] The protocol's copy-once import, for an `unmanaged` install.
107+
const importUnmanagedPackage = vi.fn(async (request: any) => ({
108+
sourcePackageId: request.manifest.id,
109+
sourceVersion: request.manifest.version,
110+
targetPackageId: `${request.manifest.id}-copy`,
111+
namespace: request.manifest.namespace,
112+
copiedCount: 0,
113+
copied: [],
114+
strippedLocks: [],
115+
}));
106116
const services: Record<string, any> = {
107117
manifest: { register },
108118
auth: installerAuthService(),
109119
objectql: withInstallerGrants({ syncSchemas, registry: { getAllPackages: () => registry } }),
120+
...(opts.unmanagedImport ? { protocol: { importUnmanagedPackage } } : {}),
110121
};
111122
const ctx = {
112123
hook: (e: string, h: any) => hooks.set(e, h),
@@ -520,3 +531,67 @@ describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to
520531
expect(said[0]).toMatch(new RegExp(`upgrade the runtime to protocol ${NEW_MAJOR}`, 'i'));
521532
});
522533
});
534+
535+
describe('[#30326] an install the handshake admitted UNCHECKED says so in its answer, not only in the server log', () => {
536+
/** The card's reproduction: a package that declares no protocol range at all. */
537+
const UNDECLARED_ID = 'com.example.qanorange';
538+
const UNDECLARED = { id: UNDECLARED_ID, name: 'No range', version: '6.0.0', scope: 'project', type: 'app' };
539+
540+
/** The notice the answer carries for a package the handshake could not check. */
541+
function expectSkipped(res: { payload: any; status: number }, expected: Record<string, unknown>) {
542+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
543+
expect(res.payload.success).toBe(true);
544+
const notice = res.payload.data.protocolCheckSkipped;
545+
expect(notice, JSON.stringify(res.payload.data)).toBeDefined();
546+
const { message, ...machine } = notice;
547+
expect(machine).toEqual({ protocolVersion: PROTOCOL_VERSION, ...expected });
548+
expect(message).toContain(`'${expected.packageId}'`);
549+
expect(message).toContain(`protocol ${PROTOCOL_VERSION} without a compatibility check`);
550+
}
551+
552+
it('no engines.protocol (inline manifest): 200 with `protocolCheckSkipped` reason `no-range`, and the log line stays', async () => {
553+
const h = await mount(dir);
554+
const res = await h.install({ manifest: UNDECLARED });
555+
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
556+
expect(h.installRegistrations().map((m) => m.id)).toEqual([UNDECLARED_ID]);
557+
const said = h.logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes('[protocol]'));
558+
expect(said).toHaveLength(1);
559+
expect(said[0]).toContain(`package '${UNDECLARED_ID}' declares no engines.protocol range`);
560+
});
561+
562+
it('the compiled artifact `os package install <file>` sends answers the same notice', async () => {
563+
const h = await mount(dir);
564+
const res = await h.install({
565+
manifest: { manifest: UNDECLARED, id: UNDECLARED_ID, version: UNDECLARED.version, objects: [] },
566+
});
567+
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
568+
});
569+
570+
it('a range the handshake cannot read: reason `unparsed-range`, with the range and where it was declared', async () => {
571+
const h = await mount(dir);
572+
const res = await h.install({ manifest: { ...UNDECLARED, engines: { protocol: 'workspace:*' } } });
573+
expectSkipped(res, {
574+
reason: 'unparsed-range',
575+
packageId: UNDECLARED_ID,
576+
requiredRange: 'workspace:*',
577+
rangeSource: 'engines.protocol',
578+
});
579+
});
580+
581+
it('an `unmanaged` install of the same package carries the notice too', async () => {
582+
const h = await mount(dir, { unmanagedImport: true });
583+
const res = await h.install({
584+
manifest: { ...UNDECLARED, namespace: 'qanorange', installModes: ['managed', 'unmanaged'] },
585+
installMode: 'unmanaged',
586+
});
587+
expect(res.payload.data?.installMode, JSON.stringify(res.payload)).toBe('unmanaged');
588+
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
589+
});
590+
591+
it(`control: under ${CURRENT_RANGE} the range is checked, and the answer has no \`protocolCheckSkipped\` key`, async () => {
592+
const h = await mount(dir);
593+
const res = await h.install({ manifest: { ...UNDECLARED, engines: { protocol: CURRENT_RANGE } } });
594+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
595+
expect(Object.keys(res.payload.data)).not.toContain('protocolCheckSkipped');
596+
});
597+
});

‎packages/metadata-core/src/protocol-handshake.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,3 +544,24 @@ describe('[#26546] the refusal reads the comparator direction, so an exclusive b
544544
});
545545
});
546546
});
547+
548+
describe('[#30326] assertProtocolCompat answers the verdict it admitted the package under', () => {
549+
it('no-range: the handshake\'s own verdict, naming the protocol the package loads under unchecked', () => {
550+
expect(assertProtocolCompat({ id: 'p' }, RT, () => {})).toEqual({
551+
status: 'no-range', runtimeMajor: 11, protocolVersion: RT,
552+
});
553+
});
554+
555+
it('unparsed-range: the verdict with the range and its source', () => {
556+
expect(assertProtocolCompat({ id: 'p', engines: { protocol: '???' } }, RT, () => {})).toEqual({
557+
status: 'unparsed-range', runtimeMajor: 11, protocolVersion: RT, requiredRange: '???', source: 'engines.protocol',
558+
});
559+
});
560+
561+
it('ok: the checked verdict, equal to checkProtocolCompat\'s for the same manifest', () => {
562+
const manifest = { id: 'p', engines: { protocol: '^11' } };
563+
const admitted = assertProtocolCompat(manifest, RT, () => {});
564+
expect(admitted.status).toBe('ok');
565+
expect(admitted).toEqual(checkProtocolCompat(manifest, RT));
566+
});
567+
});

0 commit comments

Comments
 (0)