From 54a30657e1ad5f6eb8ab0eac5a1009acf82d8a39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:12:24 +0000 Subject: [PATCH 1/6] fix(service-automation): refuse an installed package's package-relative connector ref An entry whose declaring package was installed (registry source 'marketplace') is handed refusing loadPackageFile / resolvePackagePath members instead of ones anchored at the host's root. Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../services/service-automation/src/plugin.ts | 105 +++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index a9bd5ae0ed8..6aa82bd682f 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -364,6 +364,74 @@ export function createPackagePathResolver(packageRoot?: string): (relativePath: return (relativePath: string) => resolveInsidePackageRoot(packageRoot, relativePath, 'paths'); } +/** + * [#28534] The package-anchored members a connector entry is handed when the + * package that declared it was INSTALLED into this runtime rather than shipped + * in the host's own stack: both {@link ConnectorProviderContext.loadPackageFile} + * and {@link ConnectorProviderContext.resolvePackagePath} refuse every ref. + * + * The contract resolves a relative ref against the root of the package that + * declared the entry. An installed package has no root on this runtime's disk + * — the install keeps its artifact JSON and nothing else — and the only root + * the host holds is its own, so answering would read, or point a launched + * process at, ANOTHER package's files: an install-local app naming + * `./src/system/connectors/status-openapi.json` materialized against the host + * showcase's own document. So the ref is refused, naming the entry, the + * package, the ref and the remedy. + * + * Members, not `undefined`: a factory handed `undefined` takes the + * no-filesystem path, which for `resolvePackagePath` means "keep the + * behaviour without it" (a stdio child inherits the host's working + * directory), the same cross-package reach by another road. A refusal thrown + * from the member is the factory's own failure, handled by the materializer + * like every other configuration fault (fatal at boot, a skipped entry on + * reload); a factory that never dereferences a ref (an inline document, an + * http(s) URL) never meets it. + */ +export function createInstalledPackageRefRefusal( + connectorName: string, + packageId: string, + member: 'reads' | 'paths', +): (relativePath: string) => Promise { + return async (relativePath: string) => { + const head = + `connector instance '${connectorName}' is declared by package '${packageId}', which was installed into this ` + + `runtime and has no package root on its disk, so its package ${member === 'reads' ? 'file' : 'path'} ref ` + + `'${String(relativePath)}' is refused: a relative ref resolves only against the root of the package that ` + + `declared it, never against the host's.`; + const remedy = member === 'reads' + ? ' Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, ' + + 'or inline the document in the package.' + : ' A provider that needs a location inside the package (a stdio MCP server\'s working directory) cannot ' + + 'serve an installed package\'s entry; declare a transport that needs none, such as an http MCP server.'; + throw new Error(head + remedy); + }; +} + +/** + * [#28534] The id of the INSTALLED package that declared a connector entry, + * or `undefined` when the entry is the host's own. + * + * The answer is the package registry's row for the entry's `_packageId` + * stamp — its `source`, which the door that registered the package states and + * the registry records without inferring it (`InstalledPackageSchema.source`, + * #23972): the install-local door registers with `'marketplace'`, at install + * and at every restart's rehydrate, while the host's own stack, each member of + * a multi-package artifact that states no other origin, and every plugin + * package register as `'code'`. Only `'marketplace'` is read as installed. An + * entry with no stamp, a stamp no row answers (a composed stack's residual + * owner), or no registry at all is the host's: a package reaches this runtime + * as installed only through a door that writes that row. + */ +export function installedDeclaringPackageOf( + entry: unknown, + registry: { getPackage?: (id: string) => { source?: unknown } | undefined } | undefined, +): string | undefined { + const packageId = (entry as { _packageId?: unknown } | null | undefined)?._packageId; + if (typeof packageId !== 'string' || packageId.length === 0) return undefined; + return registry?.getPackage?.(packageId)?.source === 'marketplace' ? packageId : undefined; +} + // The credential resolver lives in ./credential-resolver.ts so the engine reads // the same one for an `api` flow's `config.secretRef`; re-exported unchanged. export { defaultEnvCredentialResolver, type CredentialResolver }; @@ -2266,6 +2334,25 @@ export class AutomationServicePlugin implements Plugin { * down every live connector on a reload. Same rule as the flow re-sync's * `null` read: never tear down on an absent answer. */ + /** + * [#28534] The package registry the declared connector items are read + * from — the ObjectQL registry {@link readDeclaredConnectorItems} lists + * them out of — for {@link installedDeclaringPackageOf}. `undefined` when + * no `objectql` service is registered: then no door has registered an + * installed package in this kernel either. + */ + private packageRegistryOrNone( + ctx: PluginContext, + ): { getPackage?: (id: string) => { source?: unknown } | undefined } | undefined { + try { + return ctx.getService<{ + registry?: { getPackage?: (id: string) => { source?: unknown } | undefined }; + }>('objectql')?.registry; + } catch { + return undefined; // no registry service + } + } + private async readDeclaredConnectorItems( ctx: PluginContext, opts: { post: boolean }, @@ -2558,6 +2645,11 @@ export class AutomationServicePlugin implements Plugin { if (!desired.has(name)) this.credentialRefusalAttempts.delete(name); } + // [#28534] The package registry, read once per run, answers which + // declaring packages were installed rather than shipped in the host's + // stack ({@link installedDeclaringPackageOf}). + const packageRegistry = this.packageRegistryOrNone(ctx); + // 2. Add new instances and re-materialize changed ones (signature diff). let changed = 0; for (const [name, { entry, signature }] of desired) { @@ -2788,6 +2880,7 @@ export class AutomationServicePlugin implements Plugin { retryConfig = parsed.data; } + const installedBy = installedDeclaringPackageOf(entry, packageRegistry); const providerCtx: ConnectorProviderContext = { name, label: entry.label ?? name, @@ -2808,11 +2901,17 @@ export class AutomationServicePlugin implements Plugin { requestTimeoutMs: entry.requestTimeoutMs, // #3016 — lets a factory dereference relative file refs (e.g. // openapi's `providerConfig.spec: './billing-openapi.json'`), - // confined to the stack/package root. - loadPackageFile: createPackageFileLoader(this.options.packageRoot), + // confined to the stack/package root. [#28534] An entry an + // INSTALLED package declared is handed refusals instead: that + // package has no root here, and the host's is not its own. + loadPackageFile: installedBy === undefined + ? createPackageFileLoader(this.options.packageRoot) + : createInstalledPackageRefRefusal(name, installedBy, 'reads'), // #22434 — the same root as a location (e.g. a launched // process's working directory), under the same confinement. - resolvePackagePath: createPackagePathResolver(this.options.packageRoot), + resolvePackagePath: installedBy === undefined + ? createPackagePathResolver(this.options.packageRoot) + : createInstalledPackageRefRefusal(name, installedBy, 'paths'), }; let materialization; From 2047273b8a1421cc96ef6f915af0f81925df282b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:15:37 +0000 Subject: [PATCH 2/6] test(service-automation,dogfood): pin the installed-package ref refusal; docs and changeset Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../28534-installed-package-file-ref.md | 23 ++ content/docs/automation/connectors.mdx | 1 + ...ocal-connector-package-ref.dogfood.test.ts | 196 ++++++++++++++++++ .../src/connector-materialization.test.ts | 139 ++++++++++++- 4 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 .changeset/28534-installed-package-file-ref.md create mode 100644 packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts diff --git a/.changeset/28534-installed-package-file-ref.md b/.changeset/28534-installed-package-file-ref.md new file mode 100644 index 00000000000..9fc8cd128b2 --- /dev/null +++ b/.changeset/28534-installed-package-file-ref.md @@ -0,0 +1,23 @@ +--- +"@objectstack/service-automation": patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(service-automation): an installed package's connector entry never reads the host's files + +A connector entry declared by a package that was installed into the runtime (`os package install`, +`POST /api/v1/marketplace/install-local`; the package registry's `source: 'marketplace'`) was handed +`loadPackageFile` / `resolvePackagePath` anchored at the HOST's root, whichever package declared it. An installed +package has no directory on the runtime it is installed into, so a relative ref resolved against another package's +files: an installed `openapi` connector naming `./src/system/connectors/status-openapi.json` materialized `ready` +with the showcase's own document, and a stdio `mcp` connector naming `./scripts/mcp-fixture.mjs` spawned the +showcase's own fixture in the showcase's directory. + +Both members now refuse every ref for such an entry, naming the entry, its package and the ref: a configuration +fault, so the instance is not materialized and the reconcile logs the refusal. Fix by rebuilding the package with a +current `os build`, which carries an `openapi` spec file into the artifact, or by inlining the document; an +installed package reaches an MCP server over `http`, since a stdio transport needs a working directory inside the +package. An entry carrying its document inline or as an `http(s)` URL is unaffected, and so is every entry of the +host's own stack, which keeps reading its files as before. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index b1e7a3842e7..72a3784b2b2 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -793,6 +793,7 @@ retry): | The upstream refuses the credential (`401` / `403`), or an `mcp` server sent a static credential does not list a tool the instance's `include` names | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | | stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried | +| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. The host's own entries keep reading their files | | MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic | | A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer; on an `mcp` instance that sends a static credential, the credential row above applies instead) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it | | Wrong spec URL (other 4xx) or unparseable document | Configuration fault — **hard boot error** | diff --git a/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts b/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts new file mode 100644 index 00000000000..8a360df5890 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — a connector entry an INSTALLED package declares never +// reads, or launches a process in, the HOST's package root (#28534). +// +// ## What was measured before the fix +// +// Installed into the showcase through `POST /api/v1/marketplace/install-local`, +// an inline manifest whose `provider: 'openapi'` connector names +// `providerConfig.spec: './src/system/connectors/status-openapi.json'` — a file +// the installed package does not ship, and the showcase does — materialized +// `ready` on `GET /api/v1/automation/connectors` carrying the showcase's own +// document: its description and its `getHealth` action. A `provider: 'mcp'` +// stdio connector naming `./scripts/mcp-fixture.mjs` spawned the showcase's own +// fixture, its working directory the showcase's root, and listed `ready` with +// the fixture's `echo_upper` tool. The materializer handed every entry the +// host's root as `loadPackageFile` / `resolvePackagePath`, whichever package +// declared it; an installed package has no root on disk at all, since the +// install keeps its artifact JSON and nothing else. +// +// ## What this file pins, on one boot +// +// 1. the install answers 200 and hot-loads the package; +// 2. neither installed entry that names a package-relative ref materializes: +// both are absent from the listing, and the reconcile's error record names +// each entry, its ref and the remedy; +// 3. controls: the installed entry carrying its document inline lists +// `ready` with that document's operation, and the showcase's own +// file-ref and stdio entries keep listing `ready` with their own actions. +// +// Boots a fixture stack 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.pkgref'; +/** An `openapi` entry naming a file the showcase ships and the installed package does not. */ +const FILE_REF_ENTRY = 'pkgref_host_file'; +const FILE_REF = './src/system/connectors/status-openapi.json'; +/** A stdio `mcp` entry whose working directory the provider asks for with `resolvePackagePath('.')`. */ +const PATH_REF_ENTRY = 'pkgref_host_cwd'; +/** Control: an installed `openapi` entry carrying its document inline. */ +const INLINE_ENTRY = 'pkgref_inline'; +/** The showcase's own entries — the file-ref and stdio forms of the same two providers. */ +const HOST_FILE_ENTRY = 'showcase_status_openapi'; +const HOST_STDIO_ENTRY = 'showcase_mcp_tools'; + +/** A one-operation OpenAPI 3 document; nothing is dispatched, so its server answers nothing. */ +const DOCUMENT = { + openapi: '3.0.3', + info: { title: 'Installed inline document', version: '1.0.0' }, + servers: [{ url: 'http://127.0.0.1:9/' }], + paths: { '/v1/things': { get: { operationId: 'GetThings', responses: { '200': { description: 'ok' } } } } }, +}; + +/** The install body `os package install` sends for an inline manifest. */ +const INSTALL_BODY = { + manifest: { + id: PACKAGE_ID, + namespace: 'pkgref', + name: 'Installed package-relative connector refs', + version: '1.0.0', + type: 'app', + connectors: [ + { + name: FILE_REF_ENTRY, + label: 'Names a host file', + type: 'api', + provider: 'openapi', + providerConfig: { spec: FILE_REF, baseUrl: 'http://127.0.0.1:9/' }, + }, + { + name: PATH_REF_ENTRY, + label: 'Runs in the host root', + type: 'api', + provider: 'mcp', + providerConfig: { transport: { kind: 'stdio', command: 'node', args: ['./scripts/mcp-fixture.mjs'] } }, + }, + { + name: INLINE_ENTRY, + label: 'Inline document', + type: 'api', + provider: 'openapi', + providerConfig: { spec: DOCUMENT }, + }, + ], + }, +}; + +type Answer = { status: number; body: any }; + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ +async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const lines: string[] = []; + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const warn = console.warn; + const error = console.error; + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; + console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); error(...args); }; + try { + return { value: await run(), lines }; + } finally { + (process.stdout as any).write = stdout; + (process.stderr as any).write = stderr; + console.warn = warn; + console.error = error; + } +} + +const listed = (answer: Answer): any[] => answer.body?.data?.connectors ?? []; +const entryOf = (answer: Answer, name: string): any => listed(answer).find((c: any) => c?.name === name); +/** What a listing says about one entry — enough for a failure to show whose document it carried. */ +const shown = (answer: Answer, name: string): string => { + const c = entryOf(answer, name); + return c === undefined + ? `'${name}' not listed` + : JSON.stringify({ name: c.name, state: c.state, description: c.description, actions: c.actions?.map((a: any) => a.key) }); +}; + +describe('dogfood: an installed package\'s package-relative connector ref is refused, never resolved against the host\'s root', () => { + let stack: VerifyStack; + let storageDir: string; + let install: Answer; + let connectors: Answer; + let lines: string[] = []; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-connector-package-ref-')); + stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + const token = await stack.signIn(); + const captured = await captureOutput(async () => { + install = await json(await stack.apiAs(token, 'POST', '/marketplace/install-local', INSTALL_BODY)); + // The reconcile the install's re-sync announces may land after the + // answer: read the listing until the inline control is in it, or the + // window closes. The control and the refused entries are judged in the + // same reconcile run. + const deadline = Date.now() + 10_000; + do { + connectors = await json(await stack.apiAs(token, 'GET', '/automation/connectors')); + if (entryOf(connectors, INLINE_ENTRY)) break; + await new Promise((r) => setTimeout(r, 100)); + } while (Date.now() < deadline); + }); + lines = captured.lines; + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('the install answers 200 and hot-loads the package', () => { + expect(install.status, JSON.stringify(install.body)).toBe(200); + expect(install.body?.data?.hotLoaded).toBe(true); + expect(connectors.status, JSON.stringify(connectors.body)).toBe(200); + }); + + it('controls: the installed inline entry and the showcase\'s own file-ref and stdio entries list ready', () => { + expect(entryOf(connectors, INLINE_ENTRY)?.state, shown(connectors, INLINE_ENTRY)).toBe('ready'); + expect(entryOf(connectors, INLINE_ENTRY).actions.map((a: any) => a.key)).toEqual(['GetThings']); + expect(entryOf(connectors, HOST_FILE_ENTRY)?.state, shown(connectors, HOST_FILE_ENTRY)).toBe('ready'); + expect(entryOf(connectors, HOST_FILE_ENTRY).actions.map((a: any) => a.key)).toEqual(['getHealth']); + expect(entryOf(connectors, HOST_STDIO_ENTRY)?.state, shown(connectors, HOST_STDIO_ENTRY)).toBe('ready'); + }); + + it('the installed entry naming a host FILE does not materialize, and the record names it, its ref and the remedy', () => { + expect(entryOf(connectors, FILE_REF_ENTRY), shown(connectors, FILE_REF_ENTRY)).toBeUndefined(); + const record = lines.find((l) => l.includes(`connector instance '${FILE_REF_ENTRY}'`) && l.includes(PACKAGE_ID)); + expect(record, `no reconcile record names '${FILE_REF_ENTRY}' with its package`).toBeDefined(); + expect(record).toContain(`'${FILE_REF}'`); + expect(record).toContain('os build'); + }); + + it('the installed entry asking for a host LOCATION does not materialize, and the record names it and its ref', () => { + expect(entryOf(connectors, PATH_REF_ENTRY), shown(connectors, PATH_REF_ENTRY)).toBeUndefined(); + const record = lines.find((l) => l.includes(`connector instance '${PATH_REF_ENTRY}'`) && l.includes(PACKAGE_ID)); + expect(record, `no reconcile record names '${PATH_REF_ENTRY}' with its package`).toBeDefined(); + expect(record).toContain('path ref \'.\''); + }); +}); diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 18e6887d6a2..99225e6389a 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -98,6 +98,8 @@ interface BootOptions { credentialResolver?: CredentialResolver; /** Stack/package root that relative file refs resolve against (#3016). */ packageRoot?: string; + /** The package registry's rows by id, as `getPackage` answers them (#28534). */ + packages?: Record; } /** @@ -119,6 +121,7 @@ async function boot(declared: unknown[], opts: BootOptions = {}): Promise (type === 'connector' ? declared : []), + getPackage: (id: string) => opts.packages?.[id], }, }); const automation = ctx.getService('automation'); @@ -177,7 +180,13 @@ function makeClosableProvider() { */ async function bootReloadable( initial: unknown[], - opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver; packageRoot?: string }, + opts: { + providerFactory: ConnectorProviderFactory; + credentialResolver?: CredentialResolver; + packageRoot?: string; + /** The package registry's rows by id, as `getPackage` answers them (#28534). */ + packages?: Record; + }, ) { const state = { declared: initial }; let captured: any; @@ -191,7 +200,10 @@ async function bootReloadable( async init(ctx: any) { captured = ctx; ctx.registerService('objectql', { - registry: { listItems: (type: string) => (type === 'connector' ? state.declared : []) }, + registry: { + listItems: (type: string) => (type === 'connector' ? state.declared : []), + getPackage: (id: string) => opts.packages?.[id], + }, }); ctx.getService('automation').registerConnectorProvider('fake', opts.providerFactory); }, @@ -673,6 +685,129 @@ describe('#22434 — the materializer hands resolvePackagePath anchored to packa }); }); +// ── #28534 — an INSTALLED package's package-relative refs are refused ─────── +// +// `loadPackageFile` / `resolvePackagePath` resolve a relative ref against the +// root of the package that DECLARED the entry. A package installed into the +// runtime (registry source `marketplace`) has no root on disk, and the host's +// root is not its own, so its entries are handed members that refuse every +// ref; the host's own entries keep reading their files exactly as before. + +describe('#28534 — an entry an installed package declares is refused its package-relative refs', () => { + const INSTALLED = 'com.acme.installed'; + const HOST = 'com.acme.host'; + const packages = { [INSTALLED]: { source: 'marketplace' }, [HOST]: { source: 'code' } }; + const ownedBy = (item: T, packageId: string) => ({ ...item, _packageId: packageId }); + + /** + * A provider that dereferences `providerConfig.spec` through + * `loadPackageFile` only when it is a string, and resolves each of + * `providerConfig.paths` through `resolvePackagePath` — so an inline + * document reaches neither member. + */ + function makeRefProvider() { + const reads: string[] = []; + const resolved: string[] = []; + const factory: ConnectorProviderFactory = async (ctx) => { + if (typeof ctx.providerConfig.spec === 'string') reads.push(await ctx.loadPackageFile!(ctx.providerConfig.spec)); + for (const ref of (ctx.providerConfig.paths as string[] | undefined) ?? []) { + resolved.push(await ctx.resolvePackagePath!(ref)); + } + return { + def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] } as unknown as Connector, + handlers: { ping: async () => ({ ok: true }) }, + }; + }; + return { factory, reads, resolved }; + } + + it('reload: an installed entry naming a file the host has is not materialized, and nothing is read', async () => { + const { factory, reads } = makeRefProvider(); + const { engine, reload, kernel } = await bootReloadable([], { providerFactory: factory, packageRoot: fixtureRoot, packages }); + await expect( + reload([ownedBy(providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } }), INSTALLED)]), + ).resolves.toBeUndefined(); + expect(engine.getRegisteredConnectors()).not.toContain('billing'); + expect(reads).toEqual([]); + await kernel.shutdown(); + }); + + it('the refusal names the entry, its package, the ref and the remedy (the boot reconcile throws it)', async () => { + const { factory, reads } = makeRefProvider(); + const err = await rejectionOf( + boot([ownedBy(providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } }), INSTALLED)], { + providerFactory: factory, + packageRoot: fixtureRoot, + packages, + }), + ); + expect(err.message).toContain(`failed to materialize connector instance 'billing'`); + expect(err.message).toContain(`connector instance 'billing' is declared by package '${INSTALLED}'`); + expect(err.message).toContain(`file ref './specs/billing.json' is refused`); + expect(err.message).toContain('`os build`'); + // Never the host's path: the ref was not resolved at all. + expect(err.message).not.toContain(path.resolve(fixtureRoot)); + expect(reads).toEqual([]); + }); + + it('resolvePackagePath takes the same rule: an installed entry asking for `.` is refused, naming the path ref', async () => { + const { factory, resolved } = makeRefProvider(); + const err = await rejectionOf( + boot([ownedBy(providerConnector('tools', { providerConfig: { paths: ['.'] } }), INSTALLED)], { + providerFactory: factory, + packageRoot: fixtureRoot, + packages, + }), + ); + expect(err.message).toContain(`connector instance 'tools' is declared by package '${INSTALLED}'`); + expect(err.message).toContain(`path ref '.' is refused`); + expect(err.message).not.toContain(path.resolve(fixtureRoot)); + expect(resolved).toEqual([]); + }); + + it('control: an installed entry carrying its document inline materializes — neither member is reached', async () => { + const { factory, reads, resolved } = makeRefProvider(); + const kernel = await boot([ownedBy(providerConnector('billing', { providerConfig: { spec: { openapi: '3.0.0' } } }), INSTALLED)], { + providerFactory: factory, + packageRoot: fixtureRoot, + packages, + }); + expect(automationOf(kernel).getRegisteredConnectors()).toContain('billing'); + expect(reads).toEqual([]); + expect(resolved).toEqual([]); + await kernel.shutdown(); + }); + + it('control: the host\'s own entries read and resolve under its root — a `code` row, a stamp no row answers, no stamp', async () => { + const { factory, reads, resolved } = makeRefProvider(); + const spec = './specs/billing.json'; + const kernel = await boot( + [ + ownedBy(providerConnector('host_pkg', { providerConfig: { spec, paths: ['.'] } }), HOST), + // A composed stack's residual owner: a stamp with no package row. + ownedBy(providerConnector('residual', { providerConfig: { spec } }), 'com.acme.residual'), + providerConnector('unstamped', { providerConfig: { spec } }), + ], + { providerFactory: factory, packageRoot: fixtureRoot, packages }, + ); + const registered = automationOf(kernel).getRegisteredConnectors(); + expect(registered).toEqual(expect.arrayContaining(['host_pkg', 'residual', 'unstamped'])); + expect(reads).toEqual(Array(3).fill(JSON.stringify({ openapi: '3.0.0' }))); + expect(resolved).toEqual([path.resolve(fixtureRoot)]); + await kernel.shutdown(); + }); + + it('control: one installed entry refused on reload leaves the host\'s entry beside it serving', async () => { + const { factory } = makeRefProvider(); + const host = ownedBy(providerConnector('host_pkg', { providerConfig: { spec: './specs/billing.json' } }), HOST); + const { engine, reload, kernel } = await bootReloadable([host], { providerFactory: factory, packageRoot: fixtureRoot, packages }); + await reload([host, ownedBy(providerConnector('installed', { providerConfig: { spec: './specs/billing.json' } }), INSTALLED)]); + expect(engine.getRegisteredConnectors()).toContain('host_pkg'); + expect(engine.getRegisteredConnectors()).not.toContain('installed'); + await kernel.shutdown(); + }); +}); + // ── #3017 — upstream unavailable: degrade + retry, never a dead boot ───────── // // A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker From c67c1dfabc098b4b42d7c62b0491f72262df1a57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:31:38 +0000 Subject: [PATCH 3/6] fix(objectql,cloud-connection,service-automation): the installed-package classifier trusts only what a registering door wrote registerApp and the residual door refuse a collection item whose body _packageId differs from the package registering it; install-local registers every packages[] member as 'marketplace'; the manifest service records each residual owner's door; a stamp no registration answers for is refused its package-relative refs. Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 44 ++++- packages/objectql/src/engine.ts | 106 +++++++++++ packages/objectql/src/plugin.ts | 20 ++ .../src/connector-materialization.test.ts | 48 ++++- .../services/service-automation/src/plugin.ts | 176 ++++++++++++------ 5 files changed, 331 insertions(+), 63 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index d582fa92760..6e0299c552b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -650,6 +650,37 @@ function declaredDatasources(body: unknown): any[] { : []; } +/** + * [#28534] The artifact as this door hands it to the `manifest` service: every + * `packages[]` entry that states a `source` other than `'marketplace'` states + * `'marketplace'` instead; every other key, and every entry stating none or + * `'marketplace'`, is handed through as received (by reference when nothing + * changes). + * + * The `manifest` service stamps an entry's own `source` over the door's + * (`ArtifactPackageSchema.source`: the producer that MERGED an artifact knows + * each member's origin — a control plane folding installs into a boot + * artifact), so a bundle installed here whose member said `'code'` registered + * that member as the host's own code. Every package this door registers + * reached the runtime through it, whatever its entry says about how it reached + * the artifact, so the door's word is the origin; overridden rather than + * refused because the entry's statement is about the artifact's assembly, not + * wrong in itself, and because the rehydrate must register a ledger entry + * written before this rule without refusing it. The rule lives at this door, + * not in the `manifest` service, so a boot that registers a merged artifact + * stating no source keeps every member's own statement. + */ +export function asMarketplaceRegistration(artifact: T): T { + if (!isPlainObject(artifact) || !Array.isArray(artifact.packages)) return artifact; + const restated = (entry: unknown): boolean => + isPlainObject(entry) && entry.source !== undefined && entry.source !== 'marketplace'; + if (!artifact.packages.some(restated)) return artifact; + return { + ...artifact, + packages: artifact.packages.map((entry: unknown) => (restated(entry) ? { ...(entry as object), source: 'marketplace' } : entry)), + } as T; +} + /** * The seed datasets a cached manifest bundles: its `data[]` entries that name * an object and carry records. One reading for the two doors that match seed @@ -1018,8 +1049,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // the package as soon as rehydrate reports success. // [#23972] Every ledger entry is a marketplace install, so its // registry row says so (`source: 'marketplace'`) after a restart - // exactly as it did when it was installed. - await manifestService!.register(converted, { source: 'marketplace' }); + // exactly as it did when it was installed — every member of a + // `packages[]` bundle included, whatever its entry states + // ([#28534], {@link asMarketplaceRegistration}), a ledger entry + // written before that rule included. + await manifestService!.register(asMarketplaceRegistration(converted), { source: 'marketplace' }); // Sync schemas so the driver creates tables for the newly- // registered objects (idempotent — already-synced tables // are no-ops). @@ -1947,8 +1981,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // metadata service — a caller that reads metadata right after a // 200 (AI describe_object, Studio object list) must see them. // [#23972] The registry row records where this package came - // from: the marketplace (`source: 'marketplace'`). - await manifestService.register(converted, { source: 'marketplace' }); + // from: the marketplace (`source: 'marketplace'`) — for every + // member of a `packages[]` bundle too, whatever its entry states + // ([#28534], {@link asMarketplaceRegistration}). + await manifestService.register(asMarketplaceRegistration(converted), { source: 'marketplace' }); } catch (err: any) { const registerError = String(err?.message ?? err); // For offline file imports we treat a register failure as a hard diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 005ae2a0407..033f12274c3 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4812,6 +4812,63 @@ export function permissionSetOwnerRegistrationRefusal( return err; } +/** + * [#28534] The registration door's refusal of a collection item whose body + * already carries a `_packageId` other than the package it is registered under + * (`ownerId`; none for a manifest that carries no id). + * + * `_packageId` is the ADR-0010 envelope stamp: the package that registered the + * item, written by the door (`stampPackage` here, `registerItem` behind it, + * both through `applyProtection`). `applyProtection` writes it only while it is + * `undefined`, and every item schema that spreads `MetadataProtectionFields` + * (a connector entry among them) declares the key, so a body could arrive + * wearing ANOTHER package's id and keep it: an installed package naming the + * host's own id, or an id no package holds, and every reader that asks an + * item's owner (the connector materializer judging whose root a file ref + * resolves against, the disabled-package filter, the ADR-0130 artifact gates) + * then reads a stamp the body wrote about itself. + * + * Refused rather than overwritten, beside the two other body-against-key + * refusals of the same loop (the divergent view-container `name`, the + * permission set's `packageId`): resolving a disagreement between the body and + * the key it is filed under silently, in either direction, files the item + * under something nobody wrote (commit d0ee598e6). A stamp EQUAL to the owner + * is accepted — it is what an item re-registered under its own package (a + * ledger rehydrate, an artifact the metadata door already stamped in place + * with the same body id) carries. `null` and the empty string are stamps too: + * neither is overwritten by `applyProtection`, so either would survive as an + * owner no row answers. + * + * `VALIDATION_ERROR` / 400, `issues[]` at `_packageId`: the envelope of + * {@link permissionSetOwnerRegistrationRefusal}. Exported for the stack's + * RESIDUAL registration (`registerUnclaimedTopLevelItems` in `plugin.ts`), + * which files top-level items under the residual owner and refuses the same + * stamp in this same envelope. ⛔ Never rebuild the envelope at a second door. + */ +export function collectionItemOwnerStampRefusal( + item: unknown, + type: string, + name: string, + ownerId: string | undefined, +): (Error & { code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }> }) | undefined { + if (!item || typeof item !== 'object') return undefined; + const stamped = (item as { _packageId?: unknown })._packageId; + if (stamped === undefined || stamped === ownerId) return undefined; + const owner = ownerId === undefined ? 'no package' : `package '${ownerId}'`; + const issueMessage = `carries _packageId ${JSON.stringify(stamped)}, but it is registered under ${owner}`; + const err = new Error( + `Drop \`_packageId\` from ${type} '${name}': it ${issueMessage}. The package an item belongs to is the one ` + + 'that registers it, stamped by the registration; a body never names its own owner.', + ) as Error & { + code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }>; + }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.httpStatus = 400; + err.issues = [{ path: '_packageId', message: issueMessage, code: 'custom' }]; + return err; +} + /** * [#5320] The registration door's refusal of a `views:` entry that is not a * view CONTAINER (`isViewContainerShaped` is false: a ViewItem's @@ -5049,6 +5106,40 @@ export class ObjectQL implements IObjectQLEngine { // Package manifests registry (for defaultDatasource lookup) private manifests = new Map(); + // [#28534] Where each ADR-0130 residual owner came from — see + // {@link recordResidualOwner}. + private readonly residualOwnerSources = new Map(); + + /** + * [#28534] Record the origin of an artifact's RESIDUAL owner: the id the + * residual rule files a multi-package stack's unclaimed top-level items + * under (`unclaimedTopLevel`, `@objectstack/metadata`), as the door that + * registered the artifact states it — `'code'` for the deployment's own + * boot, `'marketplace'` for an install. + * + * The residual is not a package, so no package row records that id (unless + * a body carries it, and then the body's row answers), yet its items wear it + * as their `_packageId`. Without this record a reader asking an item's + * origin could not tell the host's own residual from an installed bundle's. + * Called by the `manifest` service, the one place that knows both the + * residual's id and the door's statement. Once an id is recorded as + * `'marketplace'` it stays so for this engine: an install can never turn + * into the host's own by re-registering under the same id. + */ + recordResidualOwner(ownerId: string, source: 'code' | 'marketplace'): void { + if (this.residualOwnerSources.get(ownerId) === 'marketplace') return; + this.residualOwnerSources.set(ownerId, source); + } + + /** + * [#28534] The origin {@link recordResidualOwner} recorded for a residual + * owner id, or `undefined` when no artifact this engine registered named it + * as its residual's owner. + */ + residualOwnerSource(ownerId: string): 'code' | 'marketplace' | undefined { + return this.residualOwnerSources.get(ownerId); + } + // Datasource definitions by name (ADR-0015): carries schemaMode + // external.allowWrites so the write gate (Gate 3) can enforce federation // ownership, and external.credentialsRef so a sys_secret reference sweep can @@ -8921,6 +9012,10 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registering seed data datasets', { id, count: seedData.length }); for (const dataset of seedData) { if (dataset.object) { + // [#28534] The collection loop's owner-stamp refusal, for the + // one collection filed outside it. + const ownerStamp = collectionItemOwnerStampRefusal(dataset, 'data', String(dataset.object), id); + if (ownerStamp) throw ownerStamp; this._registry.registerItem('data', stampPackage(dataset, id, version), 'object' as any, id); } } @@ -9113,6 +9208,15 @@ export class ObjectQL implements IObjectQLEngine { const refusal = permissionSetOwnerRegistrationRefusal(item, itemName, ownerId); if (refusal) throw refusal; } + // [#28534] …and an item of ANY collection whose body already wears + // a `_packageId` other than `ownerId`: `stampPackage` and + // `registerItem` stamp the owner only while the key is unset, so + // the body's own stamp used to win. See + // {@link collectionItemOwnerStampRefusal}. + { + const refusal = collectionItemOwnerStampRefusal(item, pluralToSingular(key), itemName, ownerId); + if (refusal) throw refusal; + } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; // [#5320] The `views:` tighten — containers ONLY, the contract the // stack schema has always declared (`stack.zod.ts`, @@ -9186,6 +9290,8 @@ export class ObjectQL implements IObjectQLEngine { this.logger.warn('Skipping viewItems entry without a derivable name', { id: ownerId }); continue; } + const ownerStamp = collectionItemOwnerStampRefusal(body, 'view', itemName, ownerId); + if (ownerStamp) throw ownerStamp; const toRegister = body.name === itemName ? body : { ...body, name: itemName }; this._registry.registerItem('view', stampPackage(toRegister, ownerId, ownerVersion), 'name' as any, ownerId); } diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 844310ca371..8be6467aafb 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -3,6 +3,7 @@ import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT, + collectionItemOwnerStampRefusal, permissionSetOwnerRegistrationRefusal, viewsEntryNotContainerRefusal, type UnservableControlledByParentMaster, @@ -953,6 +954,14 @@ export class ObjectQLPlugin implements Plugin { // the residual is not a package, and when the id names one of the // bodies (a composed stack keeps one member's manifest) that body's // record must not be replaced. + // [#28534] …and the door's statement of where that owner came from, + // recorded before anything of it registers: no package row answers the + // id, and a reader of an item's origin must tell the host's own + // residual from an installed bundle's (`ObjectQL.recordResidualOwner`). + // The residual is not an artifact entry, so it states no source of its + // own; the door's word, `code` when it states none, is its origin. + const residualOwnerId = residual?.ownerId ?? residualItems?.ownerId; + if (residualOwnerId !== undefined) ql.recordResidualOwner(residualOwnerId, options?.source ?? 'code'); if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered); // [ADR-0015 D7, ADR-0130 D4] Every other residual item, the same way // and in the same place: after every body, under the residual rule's @@ -3153,12 +3162,22 @@ export class ObjectQLPlugin implements Plugin { const stamp = (item: Record): Record => ownerVersion === undefined ? { ...item } : applyProtection({ ...item } as any, { packageId: ownerId, packageVersion: ownerVersion }); + // [#28534] The body seam's owner-stamp refusal, in its envelope and at its + // place in the order (after the name and permission-set judges): a residual + // item wearing a `_packageId` other than the residual's id kept it through + // `stamp` and `registerItem` alike, which stamp only an unset key + // ({@link collectionItemOwnerStampRefusal}, `engine.ts`). + const refuseOwnerStamp = (item: Record, type: string, name: string | undefined): void => { + const refusal = collectionItemOwnerStampRefusal(item, type, name ?? '(unnamed)', ownerId); + if (refusal) throw refusal; + }; for (const { type, item } of residual.items) { if (type === 'view' && isAggregatedViewContainer(item)) { const nameRefusal = viewContainerNameRefusal(item, 'manifest', ownerId); if (nameRefusal) throw nameRefusal; const object = deriveViewContainerObject(item); if (!object) continue; + refuseOwnerStamp(item, type, object); const container = stamp({ ...item, name: object }); ql.registry.registerItem('view', container, 'name' as any, ownerId); for (const view of expandViewContainer(object, container)) { @@ -3178,6 +3197,7 @@ export class ObjectQLPlugin implements Plugin { // entry that is not container-shaped (`viewKind`/`config`/flattened // overlay keys) is refused — `views:` carries containers only (#5320). const name = (item.name as string | undefined) || (type === 'view' ? deriveViewContainerObject(item) : undefined); + refuseOwnerStamp(item, type, name); const toRegister = item.name === name ? item : { ...item, name }; if (type === 'view' && !isViewContainerShaped(toRegister)) { throw viewsEntryNotContainerRefusal(name, 'manifest', ownerId); diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 99225e6389a..6922eda7ffa 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -100,6 +100,8 @@ interface BootOptions { packageRoot?: string; /** The package registry's rows by id, as `getPackage` answers them (#28534). */ packages?: Record; + /** The engine's residual-owner record by id, as `residualOwnerSource` answers it (#28534). */ + residualOwners?: Record; } /** @@ -123,6 +125,7 @@ async function boot(declared: unknown[], opts: BootOptions = {}): Promise (type === 'connector' ? declared : []), getPackage: (id: string) => opts.packages?.[id], }, + residualOwnerSource: (id: string) => opts.residualOwners?.[id], }); const automation = ctx.getService('automation'); if (opts.providerFactory) { @@ -186,6 +189,8 @@ async function bootReloadable( packageRoot?: string; /** The package registry's rows by id, as `getPackage` answers them (#28534). */ packages?: Record; + /** The engine's residual-owner record by id, as `residualOwnerSource` answers it (#28534). */ + residualOwners?: Record; }, ) { const state = { declared: initial }; @@ -204,6 +209,7 @@ async function bootReloadable( listItems: (type: string) => (type === 'connector' ? state.declared : []), getPackage: (id: string) => opts.packages?.[id], }, + residualOwnerSource: (id: string) => opts.residualOwners?.[id], }); ctx.getService('automation').registerConnectorProvider('fake', opts.providerFactory); }, @@ -697,6 +703,9 @@ describe('#28534 — an entry an installed package declares is refused its packa const INSTALLED = 'com.acme.installed'; const HOST = 'com.acme.host'; const packages = { [INSTALLED]: { source: 'marketplace' }, [HOST]: { source: 'code' } }; + const HOST_RESIDUAL = 'com.acme.host.stack'; + const INSTALLED_RESIDUAL = 'com.acme.installed.bundle'; + const residualOwners = { [HOST_RESIDUAL]: 'code', [INSTALLED_RESIDUAL]: 'marketplace' } as const; const ownedBy = (item: T, packageId: string) => ({ ...item, _packageId: packageId }); /** @@ -778,17 +787,18 @@ describe('#28534 — an entry an installed package declares is refused its packa await kernel.shutdown(); }); - it('control: the host\'s own entries read and resolve under its root — a `code` row, a stamp no row answers, no stamp', async () => { + it('control: the host\'s own entries read and resolve under its root — a `code` row, the residual owner a `code` door recorded, no stamp', async () => { const { factory, reads, resolved } = makeRefProvider(); const spec = './specs/billing.json'; const kernel = await boot( [ ownedBy(providerConnector('host_pkg', { providerConfig: { spec, paths: ['.'] } }), HOST), - // A composed stack's residual owner: a stamp with no package row. - ownedBy(providerConnector('residual', { providerConfig: { spec } }), 'com.acme.residual'), + // The host artifact's residual owner: no package row, but the + // `manifest` service recorded the boot door's `code` for it. + ownedBy(providerConnector('residual', { providerConfig: { spec } }), HOST_RESIDUAL), providerConnector('unstamped', { providerConfig: { spec } }), ], - { providerFactory: factory, packageRoot: fixtureRoot, packages }, + { providerFactory: factory, packageRoot: fixtureRoot, packages, residualOwners }, ); const registered = automationOf(kernel).getRegisteredConnectors(); expect(registered).toEqual(expect.arrayContaining(['host_pkg', 'residual', 'unstamped'])); @@ -797,6 +807,36 @@ describe('#28534 — an entry an installed package declares is refused its packa await kernel.shutdown(); }); + it('a stamp no registration answers for is refused, never read as the host\'s — no row and no residual record', async () => { + const { factory, reads } = makeRefProvider(); + const err = await rejectionOf( + boot([ownedBy(providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } }), 'com.acme.nobody')], { + providerFactory: factory, + packageRoot: fixtureRoot, + packages, + residualOwners, + }), + ); + expect(err.message).toContain(`connector instance 'billing' names package 'com.acme.nobody' as its owner`); + expect(err.message).toContain(`file ref './specs/billing.json' is refused`); + expect(err.message).not.toContain(path.resolve(fixtureRoot)); + expect(reads).toEqual([]); + }); + + it('an installed bundle\'s residual owner (recorded by an install, no row) is installed: refused', async () => { + const { factory, reads } = makeRefProvider(); + const err = await rejectionOf( + boot([ownedBy(providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } }), INSTALLED_RESIDUAL)], { + providerFactory: factory, + packageRoot: fixtureRoot, + packages, + residualOwners, + }), + ); + expect(err.message).toContain(`connector instance 'billing' is declared by package '${INSTALLED_RESIDUAL}', which was installed`); + expect(reads).toEqual([]); + }); + it('control: one installed entry refused on reload leaves the host\'s entry beside it serving', async () => { const { factory } = makeRefProvider(); const host = ownedBy(providerConnector('host_pkg', { providerConfig: { spec: './specs/billing.json' } }), HOST); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index afb3b62d0a6..2c20845ee59 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -365,10 +365,12 @@ export function createPackagePathResolver(packageRoot?: string): (relativePath: } /** - * [#28534] The package-anchored members a connector entry is handed when the - * package that declared it was INSTALLED into this runtime rather than shipped - * in the host's own stack: both {@link ConnectorProviderContext.loadPackageFile} - * and {@link ConnectorProviderContext.resolvePackagePath} refuse every ref. + * [#28534] The package-anchored members a connector entry is handed when it is + * not the host's own ({@link nonHostDeclaringPackageOf}) — the package that + * declared it was INSTALLED into this runtime, or its stamp names a package no + * registration records as the host's: both + * {@link ConnectorProviderContext.loadPackageFile} and + * {@link ConnectorProviderContext.resolvePackagePath} refuse every ref. * * The contract resolves a relative ref against the root of the package that * declared the entry. An installed package has no root on this runtime's disk @@ -390,13 +392,17 @@ export function createPackagePathResolver(packageRoot?: string): (relativePath: */ export function createInstalledPackageRefRefusal( connectorName: string, - packageId: string, + declaring: NonHostDeclaringPackage, member: 'reads' | 'paths', ): (relativePath: string) => Promise { return async (relativePath: string) => { + const whose = declaring.why === 'installed' + ? `is declared by package '${declaring.packageId}', which was installed into this runtime and has no ` + + 'package root on its disk' + : `names package '${declaring.packageId}' as its owner, which no package registration in this runtime ` + + 'records as the host\'s'; const head = - `connector instance '${connectorName}' is declared by package '${packageId}', which was installed into this ` + - `runtime and has no package root on its disk, so its package ${member === 'reads' ? 'file' : 'path'} ref ` + + `connector instance '${connectorName}' ${whose}, so its package ${member === 'reads' ? 'file' : 'path'} ref ` + `'${String(relativePath)}' is refused: a relative ref resolves only against the root of the package that ` + `declared it, never against the host's.`; const remedy = member === 'reads' @@ -409,27 +415,78 @@ export function createInstalledPackageRefRefusal( } /** - * [#28534] The id of the INSTALLED package that declared a connector entry, - * or `undefined` when the entry is the host's own. + * [#28534] What {@link nonHostDeclaringPackageOf} reads: the objectql engine's + * package registry rows, and its record of each artifact residual's origin. + */ +export interface DeclaringPackageReaders { + /** `SchemaRegistry.getPackage` — the row the registering door wrote, with its `source`. */ + getPackage?: (id: string) => { source?: unknown } | undefined; + /** `ObjectQL.residualOwnerSource` — the registering door's statement for a residual owner id, which has no row. */ + residualOwnerSource?: (id: string) => 'code' | 'marketplace' | undefined; +} + +/** + * [#28534] A declaring package whose entries are NOT the host's own: one the + * runtime installed (`installed`), or a stamp no registration records as the + * host's (`unrecorded`). + */ +export interface NonHostDeclaringPackage { + packageId: string; + why: 'installed' | 'unrecorded'; +} + +/** + * [#28534] Whether a connector entry is the host's own, whose package-relative + * refs resolve against the host's root, or not — then the answer names the + * package and why, and its refs are refused + * ({@link createInstalledPackageRefRefusal}). + * + * ## What it trusts * - * The answer is the package registry's row for the entry's `_packageId` - * stamp — its `source`, which the door that registered the package states and - * the registry records without inferring it (`InstalledPackageSchema.source`, - * #23972): the install-local door registers with `'marketplace'`, at install - * and at every restart's rehydrate, while the host's own stack, each member of - * a multi-package artifact that states no other origin, and every plugin - * package register as `'code'`. Only `'marketplace'` is read as installed. An - * entry with no stamp, a stamp no row answers (a composed stack's residual - * owner), or no registry at all is the host's: a package reaches this runtime - * as installed only through a door that writes that row. + * Only what a REGISTERING DOOR wrote: the package registry's row for the + * entry's `_packageId` and that row's `source` (`InstalledPackageSchema.source`, + * #23972), and, for an artifact residual's owner, which has no row, the + * door's statement the `manifest` service recorded (`ObjectQL.residualOwnerSource`). + * The install-local door registers `'marketplace'` at install and at every + * restart's rehydrate, for every member of a `packages[]` bundle whatever its + * entry states (`asMarketplaceRegistration`, `@objectstack/cloud-connection`); + * the host's boot, its multi-package artifact's members and every plugin + * package register `'code'`. + * + * ## What it does not trust + * + * The item's own stamp, as a statement of ownership: it is used only as the + * key the door's record is looked up under, and the registration refuses a + * body whose `_packageId` differs from the package registering it + * (`collectionItemOwnerStampRefusal`, `@objectstack/objectql`), so a stamp on + * a registered item is the registering door's. Nor an artifact entry's stated + * `source` — it reaches here only as the row the door wrote. + * + * ## The answers + * + * - no registry at all: the host's (no door could have registered a package); + * - no stamp: the host's (an item registered into no package); + * - a row: `'marketplace'` is `installed`; any other source is the host's + * (`'authored'` packages keep today's reading, a separate question); + * - no row, but the stamp is a residual owner a `'code'` door recorded: the + * host's; one an install recorded: `installed`; + * - any other stamp — no row and no residual record, or not a non-empty + * string: `unrecorded`, refused. Fail closed: a stamp nobody's registration + * answers for is never read as the host's. */ -export function installedDeclaringPackageOf( +export function nonHostDeclaringPackageOf( entry: unknown, - registry: { getPackage?: (id: string) => { source?: unknown } | undefined } | undefined, -): string | undefined { - const packageId = (entry as { _packageId?: unknown } | null | undefined)?._packageId; - if (typeof packageId !== 'string' || packageId.length === 0) return undefined; - return registry?.getPackage?.(packageId)?.source === 'marketplace' ? packageId : undefined; + readers: DeclaringPackageReaders | undefined, +): NonHostDeclaringPackage | undefined { + if (readers === undefined) return undefined; + const stamp = (entry as { _packageId?: unknown } | null | undefined)?._packageId; + if (stamp === undefined) return undefined; + if (typeof stamp !== 'string' || stamp.length === 0) return { packageId: String(stamp), why: 'unrecorded' }; + const row = readers.getPackage?.(stamp); + if (row !== undefined) return row.source === 'marketplace' ? { packageId: stamp, why: 'installed' } : undefined; + const residual = readers.residualOwnerSource?.(stamp); + if (residual === 'code') return undefined; + return { packageId: stamp, why: residual === 'marketplace' ? 'installed' : 'unrecorded' }; } // The credential resolver lives in ./credential-resolver.ts so the engine reads @@ -2302,6 +2359,32 @@ export class AutomationServicePlugin implements Plugin { this.reloadedConnectors = { items, owners }; } + /** + * [#28534] The readers {@link nonHostDeclaringPackageOf} judges a + * connector entry's origin with, off the `objectql` engine the declared + * items are listed out of ({@link readDeclaredConnectorItems}): its package + * registry's rows and its residual-owner record. `undefined` when no + * `objectql` service is registered: then no door has registered a package + * in this kernel either. + */ + private declaringPackageReadersOrNone(ctx: PluginContext): DeclaringPackageReaders | undefined { + let ql: { + registry?: { getPackage?: (id: string) => { source?: unknown } | undefined }; + residualOwnerSource?: (id: string) => 'code' | 'marketplace' | undefined; + } | undefined; + try { + ql = ctx.getService('objectql'); + } catch { + return undefined; // no registry service + } + if (!ql) return undefined; + const registry = ql.registry; + return { + getPackage: registry && typeof registry.getPackage === 'function' ? (id) => registry.getPackage!(id) : undefined, + residualOwnerSource: typeof ql.residualOwnerSource === 'function' ? (id) => ql!.residualOwnerSource!(id) : undefined, + }; + } + /** * The declarative `connectors:` entries this plugin should treat as the * current declaration — the input to both the descriptor audit and the @@ -2334,24 +2417,6 @@ export class AutomationServicePlugin implements Plugin { * down every live connector on a reload. Same rule as the flow re-sync's * `null` read: never tear down on an absent answer. */ - /** - * [#28534] The package registry the declared connector items are read - * from — the ObjectQL registry {@link readDeclaredConnectorItems} lists - * them out of — for {@link installedDeclaringPackageOf}. `undefined` when - * no `objectql` service is registered: then no door has registered an - * installed package in this kernel either. - */ - private packageRegistryOrNone( - ctx: PluginContext, - ): { getPackage?: (id: string) => { source?: unknown } | undefined } | undefined { - try { - return ctx.getService<{ - registry?: { getPackage?: (id: string) => { source?: unknown } | undefined }; - }>('objectql')?.registry; - } catch { - return undefined; // no registry service - } - } private async readDeclaredConnectorItems( ctx: PluginContext, @@ -2645,10 +2710,10 @@ export class AutomationServicePlugin implements Plugin { if (!desired.has(name)) this.credentialRefusalAttempts.delete(name); } - // [#28534] The package registry, read once per run, answers which - // declaring packages were installed rather than shipped in the host's - // stack ({@link installedDeclaringPackageOf}). - const packageRegistry = this.packageRegistryOrNone(ctx); + // [#28534] The engine's package rows and residual-owner record, read + // once per run (each lookup is live), answer which declaring packages + // are not the host's own ({@link nonHostDeclaringPackageOf}). + const declaringReaders = this.declaringPackageReadersOrNone(ctx); // 2. Add new instances and re-materialize changed ones (signature diff). let changed = 0; @@ -2880,7 +2945,7 @@ export class AutomationServicePlugin implements Plugin { retryConfig = parsed.data; } - const installedBy = installedDeclaringPackageOf(entry, packageRegistry); + const notHost = nonHostDeclaringPackageOf(entry, declaringReaders); const providerCtx: ConnectorProviderContext = { name, label: entry.label ?? name, @@ -2901,17 +2966,18 @@ export class AutomationServicePlugin implements Plugin { requestTimeoutMs: entry.requestTimeoutMs, // #3016 — lets a factory dereference relative file refs (e.g. // openapi's `providerConfig.spec: './billing-openapi.json'`), - // confined to the stack/package root. [#28534] An entry an - // INSTALLED package declared is handed refusals instead: that - // package has no root here, and the host's is not its own. - loadPackageFile: installedBy === undefined + // confined to the stack/package root. [#28534] An entry that is + // not the host's own (an INSTALLED package's, or one whose stamp + // no registration answers for) is handed refusals instead: such + // a package has no root here, and the host's is not its own. + loadPackageFile: notHost === undefined ? createPackageFileLoader(this.options.packageRoot) - : createInstalledPackageRefRefusal(name, installedBy, 'reads'), + : createInstalledPackageRefRefusal(name, notHost, 'reads'), // #22434 — the same root as a location (e.g. a launched // process's working directory), under the same confinement. - resolvePackagePath: installedBy === undefined + resolvePackagePath: notHost === undefined ? createPackagePathResolver(this.options.packageRoot) - : createInstalledPackageRefRefusal(name, installedBy, 'paths'), + : createInstalledPackageRefRefusal(name, notHost, 'paths'), }; let materialization; From 7ea3d0927033e73a7933ceb172b3c361dfa7eaa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:35:37 +0000 Subject: [PATCH 4/6] test(objectql,cloud-connection,dogfood): pin the owner-stamp refusal, member source, and both bypasses over the real door; docs and changesets Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../28534-install-local-member-source.md | 15 ++ .../28534-installed-package-file-ref.md | 4 +- .changeset/28534-item-owner-stamp-refused.md | 22 ++ content/docs/automation/connectors.mdx | 2 +- ...tplace-install-local-member-source.test.ts | 165 +++++++++++++++ .../src/collection-item-owner-stamp.test.ts | 192 ++++++++++++++++++ ...ocal-connector-package-ref.dogfood.test.ts | 115 ++++++++++- 7 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 .changeset/28534-install-local-member-source.md create mode 100644 .changeset/28534-item-owner-stamp-refused.md create mode 100644 packages/cloud-connection/src/marketplace-install-local-member-source.test.ts create mode 100644 packages/objectql/src/collection-item-owner-stamp.test.ts diff --git a/.changeset/28534-install-local-member-source.md b/.changeset/28534-install-local-member-source.md new file mode 100644 index 00000000000..c1740dedf90 --- /dev/null +++ b/.changeset/28534-install-local-member-source.md @@ -0,0 +1,15 @@ +--- +"@objectstack/cloud-connection": patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(cloud-connection): every member of an installed bundle is recorded as installed + +`POST /api/v1/marketplace/install-local` (and `os package install`) now registers every member of a `packages[]` +bundle as `source: 'marketplace'`, at the install and at every restart's rehydrate, whatever the member's entry +states. The `manifest` service stamps an entry's own `source` over the door's, so a member whose entry said `'code'` +was recorded as the host's own code, and readers of a package's origin treated it so — the connector materializer +let its package-relative file refs read the host's directory. The precedence itself is unchanged for the boot, where +a merged artifact's entries keep stating their own origin. diff --git a/.changeset/28534-installed-package-file-ref.md b/.changeset/28534-installed-package-file-ref.md index 9fc8cd128b2..692db582cd6 100644 --- a/.changeset/28534-installed-package-file-ref.md +++ b/.changeset/28534-installed-package-file-ref.md @@ -20,4 +20,6 @@ fault, so the instance is not materialized and the reconcile logs the refusal. F current `os build`, which carries an `openapi` spec file into the artifact, or by inlining the document; an installed package reaches an MCP server over `http`, since a stdio transport needs a working directory inside the package. An entry carrying its document inline or as an `http(s)` URL is unaffected, and so is every entry of the -host's own stack, which keeps reading its files as before. +host's own stack, which keeps reading its files as before. Whose entry it is comes only from what a registering door +recorded (the package row's `source`, or the residual owner's door for an artifact residual), never from the entry's +own words: an entry stamped with a package no registration records is refused its refs as well. diff --git a/.changeset/28534-item-owner-stamp-refused.md b/.changeset/28534-item-owner-stamp-refused.md new file mode 100644 index 00000000000..5be575805c0 --- /dev/null +++ b/.changeset/28534-item-owner-stamp-refused.md @@ -0,0 +1,22 @@ +--- +"@objectstack/objectql": patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(objectql): a collection item never names its own owning package + +`registerApp` (a manifest and its nested plugins) and the `manifest` service's ADR-0130 residual registration now +refuse a collection item whose body already carries a `_packageId` other than the package registering it, with +`VALIDATION_ERROR` / 400 at `_packageId`, the envelope of the permission-set owner refusal beside it; seed datasets +are judged the same way. The registration stamps `_packageId` only while it is unset, so a body could wear another +package's id — an installed connector naming the host's own package, or an id nobody holds — and every reader of an +item's owner (the connector materializer deciding whose directory a file ref resolves against among them) read the +body's claim about itself. A stamp equal to the registering package (an item re-registered under its own package) +and an absent stamp register as before; `null` and `''` are refused. Fix by removing `_packageId` from the item: the +package an item belongs to is the one that registers it. + +The `manifest` service also records which door registered each multi-package artifact's residual owner +(`ObjectQL.recordResidualOwner` / `residualOwnerSource`: `'code'` for the boot, `'marketplace'` for an install), the +only record of that id's origin, since the residual has no package row. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 38036d25f65..eacb863c7fe 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -865,7 +865,7 @@ retry): | The upstream refuses the credential (`401` / `403`), or an `mcp` server sent a static credential does not list a tool the instance's `include` names | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | | stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried | -| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. The host's own entries keep reading their files | +| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. Whether an entry is the host's is read from what the install recorded, never from the package's own words: an entry carrying its own `_packageId` is refused at the install, every member of a `packages[]` bundle is recorded as installed whatever its `source` says, and an entry naming a package no registration records is refused its ref too. The host's own entries keep reading their files | | MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic | | An `mcp` instance's `toolsSnapshot` does not match what the server serves (a pinned tool's description or `inputSchema` changed, or a pinned tool is no longer listed) | **Not fatal**: the instance registers **degraded** with no actions, its reason naming each tool and the digest it now serves, and the logs say "serves a tool definition that differs from its toolsSnapshot" (`CONNECTOR_UPSTREAM_CHANGED`); it is re-checked on the same backoff and recovers once the server serves the pinned definitions again — or set the pin to the reviewed digest | | A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer; on an `mcp` instance that sends a static credential, the credential row above applies instead) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it | diff --git a/packages/cloud-connection/src/marketplace-install-local-member-source.test.ts b/packages/cloud-connection/src/marketplace-install-local-member-source.test.ts new file mode 100644 index 00000000000..d59e749eeee --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-member-source.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28534] Every package the install-local door registers is registered as + * `'marketplace'` — every member of a `packages[]` bundle included, whatever + * its entry states — at the install and at the restart's rehydrate. + * + * ## The defect, read before the fix + * + * The door handed the bundle to the `manifest` service with + * `{ source: 'marketplace' }`, and the service stamps an entry's own `source` + * over the door's (`ArtifactPackageSchema.source`, pinned for a merged boot + * artifact by `@objectstack/objectql`'s `package-source-stamp.test.ts`). So an + * installed bundle whose member said `source: 'code'` landed a `'code'` row, + * and every reader of a package's origin — the connector materializer judging + * whose root a file ref resolves against among them — read that installed + * member as the host's own code. + * + * The door, not the service, now says what it registers + * (`asMarketplaceRegistration`), so the boot's merged-artifact precedence is + * untouched. The engine and its `manifest` service are REAL (an + * `ObjectKernel` with `ObjectQLPlugin`): the row's `source` is what is pinned. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +// The plugin reaches `@objectstack/runtime` through a lazy `import()`; its first +// load is paid here, at module top, never inside a clocked `it`. +import '@objectstack/runtime'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { MarketplaceInstallLocalPlugin, asMarketplaceRegistration } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; + +type Bag = Record; +type Handler = (c: any) => Promise; + +const ROUTE = '/api/v1/marketplace/install-local'; +const BUNDLE = 'com.probe.membersource'; +const MEMBER = 'com.probe.membersource.extra'; + +const body = (id: string, namespace: string) => ({ id, name: namespace, namespace, version: '1.0.0', type: 'app' }); +/** A two-member bundle: the wrapper's own id is one member's, the other member states `source: 'code'`. */ +const bundle = (): Bag => ({ + ...body(BUNDLE, 'probems'), + packages: [ + { manifest: body(BUNDLE, 'probems') }, + { manifest: body(MEMBER, 'probemsx'), source: 'code' }, + ], +}); + +let dir: string; +const kernels: ObjectKernel[] = []; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-member-source-')); }); +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** The plugin over a REAL engine and `manifest` service, booted to `kernel:ready` (which runs the rehydrate). */ +async function bootPlugin() { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + 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 Promise>(); + const services: Record = { + manifest: kernel.getService('manifest'), + auth: installerAuthService(), + objectql: withInstallerGrants({ + convertPackageBodyForward: (b: unknown) => ql.convertPackageBodyForward(b), + registry: ql.registry, + syncSchemas: async () => undefined, + }), + }; + const ctx = { + hook: (e: string, h: () => Promise) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (!(name in services)) throw new Error(`no ${name}`); + return services[name]; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + // Side readers that need services this harness does not carry; what is pinned is the row. + const p = plugin as unknown as Record; + p.bindArtifactHandlers = async () => undefined; + p.applySideEffects = async () => ({ seeded: { mode: 'none' } }); + p.maybeHealSampleData = async () => ({ seeded: { mode: 'none' } }); + await plugin.start(ctx as never); + await hooks.get('kernel:ready')?.(); + const install = async (manifest: Bag) => { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return routes.get(`POST ${ROUTE}`)!({ + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request('http://localhost:3000/x'), + json: async () => ({ manifest }), + param: () => undefined, + }, + json, + }); + }; + return { ql, install }; +} + +const sourceOf = (ql: ObjectQL, id: string) => (ql.registry.getPackage(id) as { source?: unknown } | undefined)?.source; + +describe('[#28534] install-local registers every member of a bundle as marketplace, whatever its entry states', () => { + it('install: the member whose entry states `code` records `marketplace`, beside its sibling', async () => { + const { ql, install } = await bootPlugin(); + const answer = await install(bundle()); + expect(answer.status, JSON.stringify(answer.payload)).toBe(200); + expect([sourceOf(ql, BUNDLE), sourceOf(ql, MEMBER)]).toEqual(['marketplace', 'marketplace']); + }); + + it('rehydrate: a ledger entry written before this rule, with a member stating `code`, records `marketplace` after the restart', async () => { + new LocalManifestSource(dir).write({ + packageId: BUNDLE, + versionId: 'local', + manifestId: BUNDLE, + version: '1.0.0', + manifest: bundle(), + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + }); + const { ql } = await bootPlugin(); + expect([sourceOf(ql, BUNDLE), sourceOf(ql, MEMBER)]).toEqual(['marketplace', 'marketplace']); + }); +}); + +describe('asMarketplaceRegistration', () => { + it('restates every entry whose source is not marketplace, on a copy; the caller\'s artifact is untouched', () => { + const artifact = bundle(); + const out = asMarketplaceRegistration(artifact); + expect(out).not.toBe(artifact); + expect(out.packages.map((e: Bag) => e.source)).toEqual([undefined, 'marketplace']); + expect(out.packages[0], 'an entry stating none is handed through as received').toBe(artifact.packages[0]); + expect(artifact.packages[1].source).toBe('code'); + }); + + it('CONTROL: an artifact with nothing to restate — no `packages[]`, or entries stating none or marketplace — is handed back by reference', () => { + const single = body(BUNDLE, 'probems'); + const quiet = { ...single, packages: [{ manifest: single }, { manifest: body(MEMBER, 'x'), source: 'marketplace' }] }; + expect(asMarketplaceRegistration(single)).toBe(single); + expect(asMarketplaceRegistration(quiet)).toBe(quiet); + }); +}); diff --git a/packages/objectql/src/collection-item-owner-stamp.test.ts b/packages/objectql/src/collection-item-owner-stamp.test.ts new file mode 100644 index 00000000000..9673a7d6a86 --- /dev/null +++ b/packages/objectql/src/collection-item-owner-stamp.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28534] A collection item's `_packageId` is the REGISTRATION's word, never + * the body's: `registerApp` (the manifest seam and a nested plugin, through the + * one `registerMetadataCollections`) and the `manifest` service's residual + * registration refuse an item whose body already wears a `_packageId` other + * than the package they register it under — and the `manifest` service records + * which door registered each artifact residual's owner. + * + * ## The defect, read before the fix + * + * `stampPackage` and `registerItem` stamp through `applyProtection`, which + * writes `_packageId` only while it is `undefined`, and `ConnectorBaseSchema` + * (like every item schema spreading `MetadataProtectionFields`) declares the + * key. So a connector entry of package `com.probe.pkg` carrying + * `_packageId: 'com.probe.host'` registered with that stamp, and the connector + * materializer, asking whose root a file ref resolves against, read the host's. + * + * ## What this file pins + * + * - the refusal's envelope (`VALIDATION_ERROR` / 400, `issues[]` at + * `_packageId`, the permission-set owner refusal's envelope at the same + * seam) and that nothing is filed — for a foreign id, `null` and `''`, on + * the manifest seam, a nested plugin and seed datasets; + * - CONTROLS: an absent stamp and a stamp equal to the owner register under + * the package, and one item object re-registered under its own package + * (the in-place stamp a first registration leaves) is accepted; + * - the residual door refuses the same stamp in the same words, and records + * the residual owner's origin as the door states it. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +const PKG = 'com.probe.pkg'; +const HOST = 'com.probe.host'; + +const connector = (stamp?: unknown) => ({ + name: 'probe_conn', + label: 'Probe', + type: 'api', + provider: 'openapi', + providerConfig: { spec: './specs/billing.json' }, + ...(stamp === undefined ? {} : { _packageId: stamp }), +}); + +/** Register once; hand back what was thrown (if anything) and what was filed. */ +function register(manifest: Record, type = 'connector', name = 'probe_conn'): { error: any; filed: any } { + const engine = new ObjectQL(); + let error: any; + try { + engine.registerApp(manifest as any); + } catch (e) { + error = e; + } + const filed = engine.registry.listItems(type).find((i: any) => (i?.name ?? i?.object) === name); + return { error, filed }; +} + +describe('[#28534] registerApp refuses a collection item whose body `_packageId` is not its package', () => { + it('a connector naming another package: VALIDATION_ERROR / 400 at `_packageId`, and nothing filed', () => { + const { error, filed } = register({ id: PKG, name: 'probe', connectors: [connector(HOST)] }); + expect(error, 'the registration must be refused').toBeInstanceOf(Error); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 }); + expect(error.issues).toEqual([expect.objectContaining({ path: '_packageId', code: 'custom' })]); + expect(error.message.indexOf("Drop `_packageId` from connector 'probe_conn'")).toBe(0); + expect(error.message).toContain(`"${HOST}"`); + expect(error.message).toContain(`under package '${PKG}'`); + expect(filed, 'refused before the item is filed').toBeUndefined(); + }); + + it('`null` and the empty string are stamps too — neither is overwritten, so both are refused', () => { + for (const stamp of [null, '']) { + const { error, filed } = register({ id: PKG, name: 'probe', connectors: [connector(stamp)] }); + expect(error, `stamp ${JSON.stringify(stamp)}`).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(filed).toBeUndefined(); + } + }); + + it('a nested plugin\'s item is judged against the parent package it registers under', () => { + const { error, filed } = register({ id: PKG, name: 'probe', plugins: [{ name: 'nested', connectors: [connector(HOST)] }] }); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 }); + expect(filed).toBeUndefined(); + }); + + it('a seed dataset, filed outside the collection loop, is judged the same way', () => { + const { error, filed } = register( + { id: PKG, name: 'probe', data: [{ object: 'probe_obj', records: [], _packageId: HOST }] }, + 'data', + 'probe_obj', + ); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(error.message).toContain("from data 'probe_obj'"); + expect(filed).toBeUndefined(); + }); + + it('CONTROL: an absent stamp and a stamp equal to the package register under the package', () => { + for (const stamp of [undefined, PKG]) { + const { error, filed } = register({ id: PKG, name: 'probe', connectors: [connector(stamp)] }); + expect(error, `stamp ${String(stamp)}`).toBeUndefined(); + expect(filed).toMatchObject({ name: 'probe_conn', _packageId: PKG }); + } + }); + + it('CONTROL: one manifest object registered twice under its own id is accepted (the first pass stamps it in place)', () => { + const engine = new ObjectQL(); + const manifest = { id: PKG, name: 'probe', version: '1.0.0', connectors: [connector()] }; + engine.registerApp(manifest as any); + expect((manifest.connectors[0] as any)._packageId, 'the first registration stamps the caller\'s item').toBe(PKG); + expect(() => engine.registerApp(manifest as any)).not.toThrow(); + expect(engine.registry.listItems('connector')).toEqual([expect.objectContaining({ name: 'probe_conn', _packageId: PKG })]); + }); +}); + +// ── the residual door and its owner record ───────────────────────────────── + +type ManifestService = { register(m: unknown, options?: { source?: 'code' | 'marketplace' }): void | Promise }; + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } +}); + +const STACK = 'com.probe.stack'; +const body = (id: string, namespace: string) => ({ id, name: namespace, namespace, version: '1.0.0', type: 'app' }); +/** A two-body artifact whose top-level connector no body declares: the residual, owned by `STACK`. */ +const withResidual = (residualConnector: Record) => ({ + manifest: { ...body(STACK, 'probestack') }, + ...body(STACK, 'probestack'), + packages: [{ manifest: body('com.probe.a', 'probea') }, { manifest: body('com.probe.b', 'probeb') }], + connectors: [residualConnector], +}); + +/** Boot a kernel whose one plugin registers `artifact` through the `manifest` service in `init()`; answer what it threw. */ +async function bootRegistering( + artifact: unknown, + options?: { source?: 'code' | 'marketplace' }, +): Promise<{ ql: ObjectQL; error: any }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + let error: any; + await kernel.use({ + name: 'test.owner-stamp.registrar', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + try { + await ctx.getService('manifest').register(artifact, options); + } catch (e) { + error = e; + } + }, + } as Plugin); + await kernel.bootstrap(); + return { ql: kernel.getService('objectql'), error }; +} + +describe('[#28534] the residual door: the same refusal, and the owner\'s origin recorded', () => { + it('a residual connector wearing another package\'s id is refused in the body seam\'s envelope, and not filed', async () => { + const { ql, error } = await bootRegistering(withResidual(connector(HOST))); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 }); + expect(error.issues).toEqual([expect.objectContaining({ path: '_packageId' })]); + expect(error.message).toContain(`under package '${STACK}'`); + expect(ql.registry.listItems('connector').find((i: any) => i?.name === 'probe_conn')).toBeUndefined(); + }); + + it('the boot door (no source stated) records the residual owner as `code`; an install records `marketplace`', async () => { + const boot = await bootRegistering(withResidual(connector())); + expect(boot.error).toBeUndefined(); + expect(boot.ql.residualOwnerSource(STACK)).toBe('code'); + expect(boot.ql.registry.getPackage(STACK), 'the residual is not a package: no row').toBeUndefined(); + expect(boot.ql.registry.listItems('connector')).toEqual([expect.objectContaining({ name: 'probe_conn', _packageId: STACK })]); + + const install = await bootRegistering(withResidual(connector()), { source: 'marketplace' }); + expect(install.error).toBeUndefined(); + expect(install.ql.residualOwnerSource(STACK)).toBe('marketplace'); + }); + + it('CONTROL: an artifact with no residual records no owner, and a recorded `marketplace` is never turned into `code`', () => { + const ql = new ObjectQL(); + expect(ql.residualOwnerSource(STACK)).toBeUndefined(); + ql.recordResidualOwner(STACK, 'marketplace'); + ql.recordResidualOwner(STACK, 'code'); + expect(ql.residualOwnerSource(STACK)).toBe('marketplace'); + }); +}); diff --git a/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts b/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts index 8a360df5890..1bb37e0dc86 100644 --- a/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-connector-package-ref.dogfood.test.ts @@ -26,7 +26,15 @@ // each entry, its ref and the remedy; // 3. controls: the installed entry carrying its document inline lists // `ready` with that document's operation, and the showcase's own -// file-ref and stdio entries keep listing `ready` with their own actions. +// file-ref and stdio entries keep listing `ready` with their own actions; +// 4. the installed package cannot opt out of 2 by what it writes about +// itself (the review of the first fix, read and then measured here): +// a connector entry stamping `_packageId: 'com.example.showcase'` (the +// host's own `code` package) is refused at the install, nothing of it +// registered; and a two-member bundle whose member's entry states +// `source: 'code'` installs with that member recorded `marketplace`, so its +// file-ref connector is refused its ref while the inline one beside it +// lists `ready`. // // Boots a fixture stack of its own (the install-local plugin mounted), so it // stays out of `SHARED_SHOWCASE`. @@ -41,6 +49,16 @@ import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import { bootShowcase } from './showcase-boot.js'; const PACKAGE_ID = 'com.dogfood.pkgref'; +/** The showcase's own package id — a `code` row. */ +const HOST_PACKAGE_ID = 'com.example.showcase'; +/** M1: an installed entry naming the host's package as its own owner. */ +const STAMPED_PACKAGE_ID = 'com.dogfood.pkgrefstamp'; +const STAMPED_ENTRY = 'pkgrefstamp_host_file'; +/** M2: a two-member bundle, its second member's entry stating `source: 'code'`. */ +const BUNDLE_ID = 'com.dogfood.pkgrefbundle'; +const BUNDLE_MEMBER_ID = 'com.dogfood.pkgrefbundle.member'; +const MEMBER_FILE_ENTRY = 'pkgrefbundle_host_file'; +const MEMBER_INLINE_ENTRY = 'pkgrefbundle_inline'; /** An `openapi` entry naming a file the showcase ships and the installed package does not. */ const FILE_REF_ENTRY = 'pkgref_host_file'; const FILE_REF = './src/system/connectors/status-openapi.json'; @@ -94,6 +112,61 @@ const INSTALL_BODY = { }, }; +/** M1: the install body of an inline manifest whose connector stamps the host's package id on itself. */ +const STAMPED_INSTALL_BODY = { + manifest: { + id: STAMPED_PACKAGE_ID, + namespace: 'pkgrefstamp', + name: 'Self-stamped connector owner', + version: '1.0.0', + type: 'app', + connectors: [ + { + name: STAMPED_ENTRY, + label: 'Claims the host as its owner', + type: 'api', + provider: 'openapi', + providerConfig: { spec: FILE_REF, baseUrl: 'http://127.0.0.1:9/' }, + _packageId: HOST_PACKAGE_ID, + }, + ], + }, +}; + +/** M2: a two-member bundle whose second member's entry states `source: 'code'`. */ +const BUNDLE_INSTALL_BODY = { + manifest: { + id: BUNDLE_ID, + namespace: 'pkgrefbundle', + name: 'Bundle with a member stating code', + version: '1.0.0', + type: 'app', + packages: [ + { manifest: { id: BUNDLE_ID, namespace: 'pkgrefbundle', name: 'Bundle with a member stating code', version: '1.0.0', type: 'app' } }, + { + source: 'code', + manifest: { + id: BUNDLE_MEMBER_ID, + namespace: 'pkgrefbundlem', + name: 'Member stating code', + version: '1.0.0', + type: 'app', + connectors: [ + { + name: MEMBER_FILE_ENTRY, + label: 'Names a host file', + type: 'api', + provider: 'openapi', + providerConfig: { spec: FILE_REF, baseUrl: 'http://127.0.0.1:9/' }, + }, + { name: MEMBER_INLINE_ENTRY, label: 'Inline document', type: 'api', provider: 'openapi', providerConfig: { spec: DOCUMENT } }, + ], + }, + }, + ], + }, +}; + type Answer = { status: number; body: any }; async function json(res: Response): Promise { @@ -137,6 +210,11 @@ describe('dogfood: an installed package\'s package-relative connector ref is ref let install: Answer; let connectors: Answer; let lines: string[] = []; + let stampedInstall: Answer; + let bundleInstall: Answer; + let installed: Answer; + let afterBundle: Answer; + let bundleLines: string[] = []; beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-connector-package-ref-')); @@ -158,6 +236,19 @@ describe('dogfood: an installed package\'s package-relative connector ref is ref } while (Date.now() < deadline); }); lines = captured.lines; + + stampedInstall = await json(await stack.apiAs(token, 'POST', '/marketplace/install-local', STAMPED_INSTALL_BODY)); + const bundled = await captureOutput(async () => { + bundleInstall = await json(await stack.apiAs(token, 'POST', '/marketplace/install-local', BUNDLE_INSTALL_BODY)); + const deadline = Date.now() + 10_000; + do { + afterBundle = await json(await stack.apiAs(token, 'GET', '/automation/connectors')); + if (entryOf(afterBundle, MEMBER_INLINE_ENTRY)) break; + await new Promise((r) => setTimeout(r, 100)); + } while (Date.now() < deadline); + }); + bundleLines = bundled.lines; + installed = await json(await stack.apiAs(token, 'GET', '/marketplace/install-local')); }, 300_000); afterAll(async () => { @@ -193,4 +284,26 @@ describe('dogfood: an installed package\'s package-relative connector ref is ref expect(record, `no reconcile record names '${PATH_REF_ENTRY}' with its package`).toBeDefined(); expect(record).toContain('path ref \'.\''); }); + + it('M1: an installed entry stamping the host\'s own package id on itself is refused at the install, nothing of it registered', () => { + expect(stampedInstall.status, JSON.stringify(stampedInstall.body)).toBe(422); + expect(stampedInstall.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED'); + expect(stampedInstall.body?.error?.message).toContain(`Drop \`_packageId\` from connector '${STAMPED_ENTRY}'`); + expect(stampedInstall.body?.error?.message).toContain(`under package '${STAMPED_PACKAGE_ID}'`); + expect(entryOf(afterBundle, STAMPED_ENTRY), shown(afterBundle, STAMPED_ENTRY)).toBeUndefined(); + const ids = (installed.body?.data?.items ?? []).map((i: any) => i.manifestId); + expect(ids, 'a refused inline install persists no ledger entry').not.toContain(STAMPED_PACKAGE_ID); + // CONTROL: the host's own file-ref entry is untouched. + expect(entryOf(afterBundle, HOST_FILE_ENTRY)?.state, shown(afterBundle, HOST_FILE_ENTRY)).toBe('ready'); + }); + + it('M2: a bundle member whose entry states `code` is installed as marketplace — its file ref refused, its inline sibling ready', () => { + expect(bundleInstall.status, JSON.stringify(bundleInstall.body)).toBe(200); + expect(entryOf(afterBundle, MEMBER_INLINE_ENTRY)?.state, shown(afterBundle, MEMBER_INLINE_ENTRY)).toBe('ready'); + expect(entryOf(afterBundle, MEMBER_FILE_ENTRY), shown(afterBundle, MEMBER_FILE_ENTRY)).toBeUndefined(); + const record = bundleLines.find((l) => l.includes(`connector instance '${MEMBER_FILE_ENTRY}'`) && l.includes(BUNDLE_MEMBER_ID)); + expect(record, `no reconcile record names '${MEMBER_FILE_ENTRY}' with its package`).toBeDefined(); + expect(record).toContain('which was installed into this runtime'); + expect(record).toContain(`'${FILE_REF}'`); + }); }); From f8fa5255294b87be28f44a39a6162c2f72845a5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:54:25 +0000 Subject: [PATCH 5/6] fix(objectql): a stamp a registration wrote onto the caller's object is not refused as the body's registerItem and stampPackage stamp the caller's object in place, so a module constant registered by one package and handed to another arrived wearing the first registration's _packageId. Those stamps are recorded where written and exempt; a stamp an object arrives with is still refused. Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../src/collection-item-owner-stamp.test.ts | 21 ++++++++++ packages/objectql/src/engine.ts | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/objectql/src/collection-item-owner-stamp.test.ts b/packages/objectql/src/collection-item-owner-stamp.test.ts index 9673a7d6a86..8659cae3a31 100644 --- a/packages/objectql/src/collection-item-owner-stamp.test.ts +++ b/packages/objectql/src/collection-item-owner-stamp.test.ts @@ -115,6 +115,27 @@ describe('[#28534] registerApp refuses a collection item whose body `_packageId` }); }); +describe('[#28534] a stamp a REGISTRATION wrote onto the caller\'s object is not a body\'s claim', () => { + it('one item object, stamped in place by another engine\'s registration under A, registers under B as before; a fresh copy wearing the same stamp is refused', () => { + const shared = connector(); + new ObjectQL().registerApp({ id: HOST, name: 'host', connectors: [shared] } as any); + expect((shared as { _packageId?: unknown })._packageId, 'the first registration stamped the caller\'s own object').toBe(HOST); + + // The object a registration stamped — a module constant handed on, as a + // fixture shared by two tests is: no body wrote that stamp. + const reused = register({ id: PKG, name: 'probe', connectors: [shared] }); + expect(reused.error).toBeUndefined(); + expect(reused.filed).toBeDefined(); + + // The same bytes ARRIVING as a body (a parsed install body, never touched + // by a registration): the stamp is the body's own, and it is refused. + const arrived = register({ id: PKG, name: 'probe', connectors: [JSON.parse(JSON.stringify(shared))] }); + expect(arrived.error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(arrived.error.message).toContain(`"${HOST}"`); + expect(arrived.filed).toBeUndefined(); + }); +}); + // ── the residual door and its owner record ───────────────────────────────── type ManifestService = { register(m: unknown, options?: { source?: 'code' | 'marketplace' }): void | Promise }; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 033f12274c3..c29ae1ba563 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4812,6 +4812,26 @@ export function permissionSetOwnerRegistrationRefusal( return err; } +/** + * [#28534] The `_packageId` each item object was stamped with BY A + * REGISTRATION of this process — written onto the caller's own object by + * `stampPackage` / `registerItem`, recorded by {@link noteRegistrationStamp}. + * Weakly held and process-wide: the earlier registration may have been + * another engine's. Read by {@link collectionItemOwnerStampRefusal} only. + */ +const REGISTRATION_WRITTEN_STAMPS = new WeakMap(); + +/** + * [#28534] Record that a registration just wrote `item`'s `_packageId`: it was + * unset before (`stampBefore`) and is set now. A stamp the object already + * carried is never recorded, so it stays a stamp the body arrived with. + */ +function noteRegistrationStamp(item: unknown, stampBefore: unknown): void { + if (stampBefore !== undefined || !item || typeof item !== 'object') return; + const written = (item as { _packageId?: unknown })._packageId; + if (written !== undefined) REGISTRATION_WRITTEN_STAMPS.set(item, written); +} + /** * [#28534] The registration door's refusal of a collection item whose body * already carries a `_packageId` other than the package it is registered under @@ -4844,6 +4864,19 @@ export function permissionSetOwnerRegistrationRefusal( * RESIDUAL registration (`registerUnclaimedTopLevelItems` in `plugin.ts`), * which files top-level items under the residual owner and refuses the same * stamp in this same envelope. ⛔ Never rebuild the envelope at a second door. + * + * ## A stamp a registration wrote is not a body's claim + * + * `stampPackage` and `registerItem` stamp the CALLER'S object in place, so an + * item object registered once keeps that registration's `_packageId` for as + * long as it lives — a module constant handed to a second registration (a + * fixture another test registered under its own package, measured in + * `engine-picklist.test.ts`) arrives wearing the first registration's stamp, + * which no body wrote. Those stamps are recorded where they are written + * ({@link noteRegistrationStamp}) and are not refused here: the item + * registers as it always has. Only a stamp the object ARRIVED with is judged — + * which every installed body's is, since an install door parses its body + * fresh, so no registration has touched it. */ export function collectionItemOwnerStampRefusal( item: unknown, @@ -4854,6 +4887,7 @@ export function collectionItemOwnerStampRefusal( if (!item || typeof item !== 'object') return undefined; const stamped = (item as { _packageId?: unknown })._packageId; if (stamped === undefined || stamped === ownerId) return undefined; + if (REGISTRATION_WRITTEN_STAMPS.get(item) === stamped) return undefined; const owner = ownerId === undefined ? 'no package' : `package '${ownerId}'`; const issueMessage = `carries _packageId ${JSON.stringify(stamped)}, but it is registered under ${owner}`; const err = new Error( @@ -9016,7 +9050,9 @@ export class ObjectQL implements IObjectQLEngine { // one collection filed outside it. const ownerStamp = collectionItemOwnerStampRefusal(dataset, 'data', String(dataset.object), id); if (ownerStamp) throw ownerStamp; + const stampBefore = (dataset as { _packageId?: unknown })._packageId; this._registry.registerItem('data', stampPackage(dataset, id, version), 'object' as any, id); + noteRegistrationStamp(dataset, stampBefore); } } } @@ -9236,7 +9272,9 @@ export class ObjectQL implements IObjectQLEngine { if (key === 'views' && !isViewContainerShaped(toRegister)) { throw viewsEntryNotContainerRefusal(itemName, sourceLabel, ownerId); } + const stampBefore = (toRegister as { _packageId?: unknown })._packageId; this._registry.registerItem(pluralToSingular(key), stampPackage(toRegister, ownerId, ownerVersion), 'name' as any, ownerId); + noteRegistrationStamp(toRegister, stampBefore); // "Object has-many View" (ADR-0017): a `defineView` document // aggregates an object's views. Register the container under the // bare key (above, back-compat) AND expand it into From ffbdd0fbe1dcea2d6b0aacf5e72b0c2a589c87ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:06:07 +0000 Subject: [PATCH 6/6] chore(changeset): declare the narrowing on all three changesets; objectql and cloud-connection at minor Clause-2 no (narrowing) and Metadata-protocol narrowed, one adr-0087 disposition each. Pins the viewItems channel stamping its parse output and words the docs row as 'a _packageId other than its own package's'. Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U Co-authored-by: Claude --- .../28534-install-local-member-source.md | 27 ++++++++----- .../28534-installed-package-file-ref.md | 39 +++++++++---------- .changeset/28534-item-owner-stamp-refused.md | 34 ++++++++-------- content/docs/automation/connectors.mdx | 2 +- .../src/collection-item-owner-stamp.test.ts | 31 +++++++++++++++ packages/objectql/src/engine.ts | 7 ++++ 6 files changed, 92 insertions(+), 48 deletions(-) diff --git a/.changeset/28534-install-local-member-source.md b/.changeset/28534-install-local-member-source.md index c1740dedf90..91ba74c4834 100644 --- a/.changeset/28534-install-local-member-source.md +++ b/.changeset/28534-install-local-member-source.md @@ -1,15 +1,22 @@ --- -"@objectstack/cloud-connection": patch +"@objectstack/cloud-connection": minor --- -Clause-②: no -Metadata-protocol: none +fix(cloud-connection)!: every member of an installed bundle is recorded as installed, whatever its entry's `source` states -fix(cloud-connection): every member of an installed bundle is recorded as installed +`POST /api/v1/marketplace/install-local` (and `os package install`) now registers every member of a `packages[]` bundle as `source: 'marketplace'`, at the install and at every restart's rehydrate. The `manifest` service stamps an entry's own `source` over the door's, so a member whose entry said `'code'` was recorded as the host's own code, and readers of a package's origin treated it so: the connector materializer let its package-relative file refs read the host's directory. -`POST /api/v1/marketplace/install-local` (and `os package install`) now registers every member of a `packages[]` -bundle as `source: 'marketplace'`, at the install and at every restart's rehydrate, whatever the member's entry -states. The `manifest` service stamps an entry's own `source` over the door's, so a member whose entry said `'code'` -was recorded as the host's own code, and readers of a package's origin treated it so — the connector materializer -let its package-relative file refs read the host's directory. The precedence itself is unchanged for the boot, where -a merged artifact's entries keep stating their own origin. +Clause-②: no (narrowing) — at the install-local door a `packages[]` entry's stated `source` no longer decides the member's recorded origin: a member stating `'code'` is recorded `'marketplace'`, as the door states. Nothing is widened. + +Metadata-protocol: narrowed (an installed bundle member's `source: 'code'` is no longer honoured at the install door) + + + +**BREAKING** semantic narrowing at the install-local door (`@objectstack/cloud-connection`), shipped as `minor`. + +- **Before.** A bundle installed through the door whose second member's entry stated `source: 'code'` recorded that member as `'code'`, and a ledger entry holding such a bundle did so again at every restart. +- **Now.** Both members are recorded `'marketplace'`, at the install and at the rehydrate, so a ledger entry written earlier is recorded the same way after the next restart. +- **Unchanged.** The `manifest` service's precedence for a boot that registers a merged artifact stating no source of its own: each entry's statement still stands there. An entry stating `'marketplace'` or nothing is handed through as received. +- **Who sees it.** A bundle installed through this door whose entry claimed `'code'`. Its members now read as installed everywhere a package's origin is read. + +**The remedy.** None is owed. Drop the entry's `source` if it was written for this door: the door states the origin of what it installs. diff --git a/.changeset/28534-installed-package-file-ref.md b/.changeset/28534-installed-package-file-ref.md index 692db582cd6..1923713531c 100644 --- a/.changeset/28534-installed-package-file-ref.md +++ b/.changeset/28534-installed-package-file-ref.md @@ -2,24 +2,21 @@ "@objectstack/service-automation": patch --- -Clause-②: no -Metadata-protocol: none - -fix(service-automation): an installed package's connector entry never reads the host's files - -A connector entry declared by a package that was installed into the runtime (`os package install`, -`POST /api/v1/marketplace/install-local`; the package registry's `source: 'marketplace'`) was handed -`loadPackageFile` / `resolvePackagePath` anchored at the HOST's root, whichever package declared it. An installed -package has no directory on the runtime it is installed into, so a relative ref resolved against another package's -files: an installed `openapi` connector naming `./src/system/connectors/status-openapi.json` materialized `ready` -with the showcase's own document, and a stdio `mcp` connector naming `./scripts/mcp-fixture.mjs` spawned the -showcase's own fixture in the showcase's directory. - -Both members now refuse every ref for such an entry, naming the entry, its package and the ref: a configuration -fault, so the instance is not materialized and the reconcile logs the refusal. Fix by rebuilding the package with a -current `os build`, which carries an `openapi` spec file into the artifact, or by inlining the document; an -installed package reaches an MCP server over `http`, since a stdio transport needs a working directory inside the -package. An entry carrying its document inline or as an `http(s)` URL is unaffected, and so is every entry of the -host's own stack, which keeps reading its files as before. Whose entry it is comes only from what a registering door -recorded (the package row's `source`, or the residual owner's door for an artifact residual), never from the entry's -own words: an entry stamped with a package no registration records is refused its refs as well. +fix(service-automation)!: an installed package's connector entry never reads the host's files + +A connector entry declared by a package that was installed into the runtime (`os package install`, `POST /api/v1/marketplace/install-local`; the package registry's `source: 'marketplace'`) was handed `loadPackageFile` / `resolvePackagePath` anchored at the HOST's root, whichever package declared it. An installed package has no directory on the runtime it is installed into, so a relative ref resolved against another package's files: an installed `openapi` connector naming `./src/system/connectors/status-openapi.json` materialized `ready` with the showcase's own document, and a stdio `mcp` connector naming `./scripts/mcp-fixture.mjs` spawned the showcase's own fixture in the showcase's directory. + +Clause-②: no (narrowing) — a connector entry that is not the host's own (an installed package's, or one whose stamp no registration records) is refused its package-relative file and path refs, which it was handed before against the host's root. Nothing is widened. + +Metadata-protocol: narrowed (an installed package's connector `providerConfig` may no longer name a package-relative file, and an installed package's stdio `mcp` transport is refused) + + + +**BREAKING** behaviour narrowing for installed packages' connectors (`@objectstack/service-automation`), shipped as `patch` beside the `minor` of `@objectstack/objectql` and `@objectstack/cloud-connection` that carries the same declaration. + +- **Before.** An installed package's entry naming a package-relative file or location read the host's own file, or ran a stdio process in the host's directory. +- **Now.** Both members refuse every ref for such an entry, naming the entry, its package and the ref: a configuration fault, so the instance is not materialized and the reconcile logs the refusal. Whose entry it is comes only from what a registering door recorded (the package row's `source`, or the residual owner's door for an artifact residual), never from the entry's own words: an entry stamped with a package no registration records is refused its refs as well. +- **Unchanged.** An entry carrying its document inline or as an `http(s)` URL, and every entry of the host's own stack, which keeps reading its files as before. +- **Who sees it.** An installed package whose connector names a package-relative `openapi` spec, or declares a stdio `mcp` transport. + +**The remedy.** Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; an installed package reaches an MCP server over `http`, since a stdio transport needs a working directory inside the package. diff --git a/.changeset/28534-item-owner-stamp-refused.md b/.changeset/28534-item-owner-stamp-refused.md index 5be575805c0..132c8a31dac 100644 --- a/.changeset/28534-item-owner-stamp-refused.md +++ b/.changeset/28534-item-owner-stamp-refused.md @@ -1,22 +1,24 @@ --- -"@objectstack/objectql": patch +"@objectstack/objectql": minor --- -Clause-②: no -Metadata-protocol: none +fix(objectql)!: a collection item never names its own owning package — package registration refuses a body `_packageId` other than the registering package's -fix(objectql): a collection item never names its own owning package +`ObjectQL.registerApp` (a manifest and its nested plugins, seed datasets included) and the `manifest` service's ADR-0130 residual registration now refuse a collection item whose body already carries a `_packageId` other than the package registering it: `VALIDATION_ERROR` / 400 with `issues[]` at `_packageId`, before the item is filed, the envelope of the permission-set owner refusal in the same loop. The registration stamps `_packageId` only while it is unset, so a body could wear another package's id (an installed connector naming the host's own package, or an id nobody holds) and every reader of an item's owner, the connector materializer deciding whose directory a file ref resolves against among them, read the body's claim about itself. -`registerApp` (a manifest and its nested plugins) and the `manifest` service's ADR-0130 residual registration now -refuse a collection item whose body already carries a `_packageId` other than the package registering it, with -`VALIDATION_ERROR` / 400 at `_packageId`, the envelope of the permission-set owner refusal beside it; seed datasets -are judged the same way. The registration stamps `_packageId` only while it is unset, so a body could wear another -package's id — an installed connector naming the host's own package, or an id nobody holds — and every reader of an -item's owner (the connector materializer deciding whose directory a file ref resolves against among them) read the -body's claim about itself. A stamp equal to the registering package (an item re-registered under its own package) -and an absent stamp register as before; `null` and `''` are refused. Fix by removing `_packageId` from the item: the -package an item belongs to is the one that registers it. +Clause-②: no (narrowing) — package registration refuses a collection item whose body `_packageId` names a package other than the one registering it, which it registered before with the body's stamp kept. Nothing is widened. -The `manifest` service also records which door registered each multi-package artifact's residual owner -(`ObjectQL.recordResidualOwner` / `residualOwnerSource`: `'code'` for the boot, `'marketplace'` for an install), the -only record of that id's origin, since the residual has no package row. +Metadata-protocol: narrowed (a collection item's `_packageId`, declared by `MetadataProtectionFields`, may no longer name a package other than the one that registers it) + + + +**BREAKING** accept-set narrowing at package registration (`@objectstack/objectql`), shipped as `minor`. + +- **Before.** A manifest `com.acme.app` whose connector carried `_packageId: 'com.acme.host'` registered the connector with that stamp kept, with no refusal and no warning; so did a nested plugin's item, a seed dataset and a multi-package artifact's residual item. `null` and `''` were kept too. +- **Now.** The registration throws `400 VALIDATION_ERROR` with `issues[]` at `_packageId`, naming the item, the stamp and the package registering it, before anything of the item is filed. Through `POST /api/v1/marketplace/install-local` an inline install answers `422 PLUGIN_REGISTER_FAILED` with that message and persists nothing. +- **Unchanged.** An absent `_packageId` and one equal to the registering package register as before. An item object that an earlier registration stamped in place (a module constant handed to a second registration) registers as it always has: only a stamp the object arrives with is judged. Objects (`objects`) are not judged by this seam. +- **Who sees it.** Whoever hand-writes `_packageId` on a manifest item naming another package, which no build, export or install writes. + +**The remedy.** Remove `_packageId` from the item. The package an item belongs to is the one that registers it, stamped by the registration. + +The `manifest` service also records which door registered each multi-package artifact's residual owner (`ObjectQL.recordResidualOwner` / `residualOwnerSource`: `'code'` for the boot, `'marketplace'` for an install), the only record of that id's origin, since the residual has no package row. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index eacb863c7fe..3472de8051c 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -865,7 +865,7 @@ retry): | The upstream refuses the credential (`401` / `403`), or an `mcp` server sent a static credential does not list a tool the instance's `include` names | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | | stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried | -| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. Whether an entry is the host's is read from what the install recorded, never from the package's own words: an entry carrying its own `_packageId` is refused at the install, every member of a `packages[]` bundle is recorded as installed whatever its `source` says, and an entry naming a package no registration records is refused its ref too. The host's own entries keep reading their files | +| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. Whether an entry is the host's is read from what the install recorded, never from the package's own words: an entry carrying a `_packageId` other than its own package's is refused at the install, every member of a `packages[]` bundle is recorded as installed whatever its `source` says, and an entry naming a package no registration records is refused its ref too. The host's own entries keep reading their files | | MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic | | An `mcp` instance's `toolsSnapshot` does not match what the server serves (a pinned tool's description or `inputSchema` changed, or a pinned tool is no longer listed) | **Not fatal**: the instance registers **degraded** with no actions, its reason naming each tool and the digest it now serves, and the logs say "serves a tool definition that differs from its toolsSnapshot" (`CONNECTOR_UPSTREAM_CHANGED`); it is re-checked on the same backoff and recovers once the server serves the pinned definitions again — or set the pin to the reviewed digest | | A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer; on an `mcp` instance that sends a static credential, the credential row above applies instead) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it | diff --git a/packages/objectql/src/collection-item-owner-stamp.test.ts b/packages/objectql/src/collection-item-owner-stamp.test.ts index 8659cae3a31..4afbde3441c 100644 --- a/packages/objectql/src/collection-item-owner-stamp.test.ts +++ b/packages/objectql/src/collection-item-owner-stamp.test.ts @@ -136,6 +136,37 @@ describe('[#28534] a stamp a REGISTRATION wrote onto the caller\'s object is not }); }); +describe('[#28534] the `viewItems:` channel stamps its parse output, never the caller\'s object', () => { + const viewItem = (stamp?: unknown) => ({ + name: 'account.hot', + object: 'account', + viewKind: 'list', + config: { type: 'grid', columns: [{ field: 'name' }] }, + ...(stamp === undefined ? {} : { _packageId: stamp }), + }); + + it('a registered entry leaves the caller\'s object unstamped — so there is no in-place stamp to record there', () => { + const entry = viewItem(); + const engine = new ObjectQL(); + engine.registerApp({ id: PKG, name: 'probe', viewItems: [entry] } as any); + expect(engine.registry.listItems('view').find((v: any) => v?.name === 'account.hot')).toMatchObject({ _packageId: PKG }); + expect((entry as { _packageId?: unknown })._packageId, 'the parse output was stamped, not the caller\'s entry').toBeUndefined(); + // …so the same object registers again under another package, unjudged by an earlier stamp. + expect(() => new ObjectQL().registerApp({ id: HOST, name: 'host', viewItems: [entry] } as any)).not.toThrow(); + }); + + it('an entry arriving with another package\'s `_packageId` is refused there too', () => { + let error: any; + try { + new ObjectQL().registerApp({ id: PKG, name: 'probe', viewItems: [viewItem(HOST)] } as any); + } catch (e) { + error = e; + } + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(error.message).toContain("from view 'account.hot'"); + }); +}); + // ── the residual door and its owner record ───────────────────────────────── type ManifestService = { register(m: unknown, options?: { source?: 'code' | 'marketplace' }): void | Promise }; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index c29ae1ba563..86e1517badd 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9328,6 +9328,13 @@ export class ObjectQL implements IObjectQLEngine { this.logger.warn('Skipping viewItems entry without a derivable name', { id: ownerId }); continue; } + // [#28534] The owner-stamp refusal, judged on the PARSED body. No + // {@link noteRegistrationStamp} follows the `registerItem` below, on + // purpose: what it stamps is `parsed.data`, the parse's own new + // object, never the caller's entry, so this site leaves no in-place + // stamp on anything a later registration could be handed + // (`collection-item-owner-stamp.test.ts` pins the caller's entry + // unstamped). const ownerStamp = collectionItemOwnerStampRefusal(body, 'view', itemName, ownerId); if (ownerStamp) throw ownerStamp; const toRegister = body.name === itemName ? body : { ...body, name: itemName };