Skip to content

Commit 15e8311

Browse files
fix(service-automation)!: a connector stored in the environment never reads the host's files (#28671)
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKQ4XajqQhqyHfXo3mU14Lv)_ Fixes #28594 Clause-②: no (narrowing) Metadata-protocol: narrowed (a stored connector's `providerConfig` may no longer name a package-relative file, and a stored stdio `mcp` transport is refused) Skills-docs: none — the published automation skill teaches a file ref only on a connector the package itself declares (`defineConnector` in the stack), which keeps reading its files; the stored-entry refusal is documented in `content/docs/automation/connectors.mdx` (failure-modes table) ## 维护者速读 - **改了什么**:在环境里保存的连接器(Studio、元数据接口保存的,不论是否属于在本环境创建的包)不再能按相对路径读取主机自身的文件;只有主机代码栈自己声明的连接器才能读主机根目录。元数据列表读取时,存储行的所属包一律以数据库列为准,不再采信正文里自带的包标记。 - **为什么改**:#28534 已经堵住了已安装包这条路,这张卡补齐其余来源。托管环境的管理员在 Studio 里配置的连接器,不应凭写一个路径就读到平台自己的文件。 - **风险与回滚**:中低。受影响的只是“存储在环境里、且用相对路径引用文件或 stdio 传输”的连接器,它们现在会以配置错误不加载并在日志里说明原因;内联文档、http(s) 地址和主机自身连接器不受影响。回滚即还原本 PR。 - **先看**:`packages/services/service-automation/src/plugin.ts`(`nonHostDeclaringPackageOf` 与新的读取器);`packages/metadata-protocol/src/protocol-stored-overlay-reads.ts`(列以列为准);`packages/qa/dogfood/test/stored-connector-package-ref.dogfood.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/stored-connector-package-ref.dogfood.test.ts test/stored-connector-package-ref-restart.dogfood.test.ts`,预期两个文件 13/13 通过。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/service-automation` — `plugin.ts`.** #28534's one classifier, `nonHostDeclaringPackageOf`, is extended; there is no second rule and no second message. An entry reads the host's root only when the host's own stack declares it: - its stamp names the host's package (a `code` row, or a residual owner a `code` door recorded), **and** a package registration filed that very binding under that package (`SchemaRegistry.getArtifactItem('connector', name, packageId)`, the ADR-0010 §3.3 artifact lookup, with the same `provider` and `providerConfig`); or - the host's own artifact carried it (the dev reload payload, the one source of an edit the registry was never re-told). Every other entry is a new `why: 'stored'`: a row with no stamp, a row of an `authored` package, and a row claiming the host's package with a binding no registration filed. Its `loadPackageFile` / `resolvePackagePath` are #28534's refusals, now worded for a stored entry (entry, its package if it has one, the ref, the remedy: inline the document or name it by an http(s) URL; reach MCP over http). The message interpolates only the entry name, the package id and the author's own ref. A registry double that offers no registered layer keeps today's reading (the doubles in the connector packages' suites), as the existing "no registry" answer does; the real `SchemaRegistry` always offers it. - **`@objectstack/metadata-protocol` — `protocol-stored-overlay-reads.ts`.** The body-`_packageId` road (`storedOverlayEntries`, ≈:165) is closed: the row's package is its column's. A body stamp is replaced by the column's package, or dropped when the row is bound to no package, on a copy (a cached row is never written). See Acceptance notes for why the column wins rather than the row being refused. - **Boot.** A stored entry refused its ref never fails a boot: one already saved when the platform starts is logged and skipped like one saved while it runs, and the host's own entries beside it serve. Every other configuration fault keeps failing the boot. - Docs: one row in the connectors failure-modes table. Changesets: `minor` for `@objectstack/service-automation` (the narrowing), `patch` for `@objectstack/metadata-protocol`, both `!` with the ADR-0087 `no-migration-prescription` disposition. ## Tests <details><summary>Red on origin/main f23e051, green at head (the real metadata door)</summary> `stored-connector-package-ref.dogfood.test.ts` boots the showcase, saves connectors through the metadata door, and reads the connector listing. Built from base (the fix reverted), its refusal cases are red: each stored entry listed `ready`, and the list served a body's package stamp. With the fix built: `Tests 7 passed (7)`, `os-verify-lock: VERDICT command-exit 0`. The measurement itself is in the test file and in the hand-back to the seat. Controls in the same boot, green on base and at head: a stored inline document and a stored http(s) URL list `ready` with their operation; the showcase's own file-ref and stdio entries list `ready` with their own actions. </details> <details><summary>Unit: connector-materialization.test.ts (8 new cases) and protocol-stored-overlay-reads.test.ts (4 new cases)</summary> - Refused, naming entry, ref and remedy, nothing read: an environment-wide row; an `authored` package's row; a row claiming the host's package that no registration filed; a row under the host's registered name with another binding (reload); `resolvePackagePath('.')` on a stored entry. - Controls: a stored inline entry materializes with neither member reached; the host's registered entries (a `code` package and a `code` residual) read and resolve under the root, judged by binding, not identity; entries the host's own artifact reload carries read their root, stamped or not. - The column wins: a body stamp on a package-less row is dropped; one naming another package is replaced; an absent or agreeing stamp is served as before; an object-shaped stored body is not written. </details> <details><summary>Ablation (each restored, `blob after restore == blob at HEAD`)</summary> Via `node scripts/ablation-replace.mjs`; both suites import `src/` directly, so no rebuild is in the path. - Drop the origin check (`judgesStored = false`): `5 failed | 3 passed` of the #28594 cases, exactly the five refusals; the three controls stay green. - Restore the body-stamp pass-through in `storedOverlayEntries`: `3 failed | 1 passed`, the three column-wins cases. - Drop the host-artifact reading (`carried = false`): `1 failed | 7 passed`, the reload-payload control. - Drop the boot soft path: `6 failed | 5 passed`; force the early authored row to `unrecorded`: `1 failed | 10 passed`. </details> <details><summary>Restart</summary> `stored-connector-package-ref-restart.dogfood.test.ts`: two boots over one database; the second starts, the refused stored rows are unlisted with their records, and the inline control and the host's entries list ready. Red at `43953ed3` (the second boot did not start), green at head. </details> <details><summary>Package suites, typecheck, dogfood battery</summary> - `@objectstack/service-automation`: `pnpm typecheck` (incl. `check:test-typecheck`) exit 0; `vitest run` `Test Files 251 passed · Tests 3196 passed` at `5c706cd3`. - `@objectstack/metadata-protocol`: `pnpm typecheck` exit 0; `vitest run` `Test Files 292 passed | 4 skipped · Tests 21948 passed | 26 skipped`. - Full dogfood battery (the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the lock on the `@objectstack/dogfood^...` closure at `5c706cd3`: `Test Files 371 passed | 3 skipped (374) · Tests 2994 passed | 28 skipped (3022)`, `VERDICT command-exit 0`. It includes the install-local package-ref file (PR #28587's behaviour) and the install-local restart file (PR #28639's behaviour). </details> ## Acceptance notes - **Column wins, not a refusal (the order's M1 parity).** M1 refuses at a registration door, where an author is present and the item has not been filed. `storedOverlayEntries` is a list read: refusing one row there either fails every list of the type for every caller, or hides the row from the Studio that could fix it. The save door's sibling rule, `stripDerivedProvenance`, already states that the server states provenance rather than reading it back, so the column winning is that rule applied at the read. The materializer does not depend on it: a stored row claiming the host's package is refused its refs either way, because no registration filed its binding. - **A save over the host's own connector name** is refused by the save door (sealed), so that road is closed there; the unit case still pins the materializer's answer for a row under a registered name with another binding. - `check:dual-build-cjs-loads` answered exit 3 (prerequisite: a full build) and is NOT MEASURED locally; CI is the record. `check:skill-examples` answered exit 0 (279 prose examples) after `client-react` was built. ## Verification Derived gate list (`node scripts/pm/dispatch-gates.mjs --commands`), local subset run at the final head with the exit-coded record reconciled by `--ran`; every family CI runs is read from the PR's check-runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mBUwWJMnLUdKBYraT --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mBUwWJMnLUdKBYraT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e170d42 commit 15e8311

9 files changed

Lines changed: 950 additions & 30 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(service-automation)!: a connector stored in the environment never reads the host's files
6+
7+
A provider-bound connector saved through the metadata door (`PUT /api/v1/meta/connector/:name`, Studio), environment-wide or into a package created in the environment (`POST /api/v1/packages`, an `authored` package), had its package-relative refs resolved against the HOST's root, as if the host's own stack had declared it. A stored definition is no package's declaration and has no directory on disk, so a relative ref it names is now refused, the way an installed package's has been since #28534.
8+
9+
Clause-②: no (narrowing) — a stored connector entry (environment-wide, or in an `authored` package) is refused its package-relative file and path refs, which it was handed before against the host's root. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (a stored connector's `providerConfig` may no longer name a package-relative file, and a stored stdio `mcp` transport is refused)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed and the stored shape is unchanged: the same `providerConfig` is accepted at the save door, and what is refused is the host's root being read on the stored entry's behalf. No conversion can carry a file the environment never held; the refusal names the entry, its package if it has one, the ref and the remedy. -->
14+
15+
**BREAKING** behaviour narrowing for stored connectors (`@objectstack/service-automation`), shipped as `minor`.
16+
17+
- **Before.** A stored entry's package-relative refs (an `openapi` spec path, a stdio `mcp` transport's working directory) resolved against the host's root.
18+
- **Now.** Every such ref is refused for a stored entry, naming the entry, its package if it has one, and the ref: a configuration fault, so the instance is not materialized and the reconcile logs the refusal. It never fails a boot: a stored row already at rest when the platform starts is logged and skipped like one saved while it runs, and the host's own entries beside it serve. Only an entry the host's stack declares reads the host's root: one a package registration filed with the same provider binding, or one the host's own artifact carries. A stored row is never read as the host's, whatever `_packageId` its body carries.
19+
- **Unchanged.** A stored entry carrying its document inline or as an `http(s)` URL, every entry of the host's own stack, and an installed package's entries (refused as before).
20+
- **Who sees it.** A connector saved in the environment whose `openapi` spec is a package-relative path, or whose `mcp` transport is stdio.
21+
22+
**The remedy.** Carry the document inline in the definition, or name it by an `http(s)` URL; reach an MCP server from a stored entry over `http`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol)!: a stored row's package in the metadata list is its column's, never its body's
6+
7+
The metadata list read (`getMetaItems`, and the execution read the automation engine binds from) served a stored row's `_packageId` from its `package_id` column only when the body carried none, so a row whose body carried another package's id was served as that package's item. The column now wins: a body stamp is replaced by the column's package, or dropped when the row is bound to no package. The save door has stripped a body's `_packageId` since #16702, so only a row written before that, or by another writer, carries one.
8+
9+
Clause-②: no (narrowing) — a stored row whose body carries a `_packageId` other than its column's is served under its column's package (or none), no longer under the body's. Nothing is widened.
10+
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed and no stored body is rewritten or refused: the row's package binding is its `package_id` column, which the read already served whenever the body carried no stamp; a body stamp that disagrees with the column was never written by the save door, so no author has anything to convert. -->
14+
15+
**BREAKING** read narrowing (`@objectstack/metadata-protocol`), shipped as `patch`.
16+
17+
- **Before.** A stored row whose body carried `_packageId` was served with the body's value, whatever its column said.
18+
- **Now.** It is served with its column's package, or with none when the row is bound to no package.
19+
- **Unchanged.** A row whose body carries no stamp, or one equal to its column, is served exactly as before.
20+
- **Who sees it.** Only a row written before the save door began stripping body stamps, or by a writer other than the save door.

‎content/docs/automation/connectors.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ retry):
875875
| Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds |
876876
| stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried |
877877
| 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 |
878+
| A **stored** entry — one saved in this environment through the metadata door (`PUT /api/v1/meta/connector/:name`, Studio), environment-wide or in a package created here (`POST /api/v1/packages`) — names a package-relative file or needs a location inside a package | Configuration fault, **never resolved against the host's directory**: a stored definition is no package's declaration and has no directory on disk. The instance is not materialized, and the reconcile's error names the entry, its package if it has one, and the ref. This one is never fatal, even at boot: a stored row at rest is logged and skipped, and the platform starts. Carry the document inline in the definition, or name it by an `http(s)` URL; reach an MCP server from a stored entry over `http`. Only what the host's own stack declares reads its files: a stored row is never read as the host's, whatever `_packageId` its body carries |
878879
| 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 |
879880
| 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, on an instance whose credential did not reach the server) | **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 pinned tool no longer listed on an instance whose credential **did** reach the server (its `auth` over http) takes the credential row above instead: a server may hide tools from a credential, so the reason names both causes (fix the credential and set it again, or update the pin), a stored credential is marked `rejected`, and no retry runs against the same value |
880881
| 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 |
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28594] `storedOverlayEntries` serves a stored row's `_packageId` from the
4+
// row's `package_id` COLUMN, never from its body: a body stamp is replaced by
5+
// the column's package, or dropped when the row is bound to no package. Every
6+
// reader of the list (the connector materializer judging whose root a file ref
7+
// reads among them) asks an item's owner of `_packageId`.
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { storedOverlayEntries } from './protocol-stored-overlay-reads.js';
11+
12+
const host = { convertStoredItem: (_type: string, body: unknown) => body };
13+
const parse = (records: any[]) => storedOverlayEntries.call(host, { type: 'connector' }, records);
14+
const row = (body: Record<string, unknown>, packageId: string | null) => ({
15+
type: 'connector', name: String(body.name), metadata: JSON.stringify(body), package_id: packageId,
16+
});
17+
18+
describe('#28594 — a stored row\'s package is its column\'s', () => {
19+
it('a body stamp on a row bound to no package is dropped', () => {
20+
const [entry] = parse([row({ name: 'a', _packageId: 'com.acme.host' }, null)]);
21+
expect(entry!.packageId).toBeUndefined();
22+
expect(entry!.data).not.toHaveProperty('_packageId');
23+
});
24+
25+
it('a body stamp naming another package is replaced by the column\'s', () => {
26+
const [entry] = parse([row({ name: 'a', _packageId: 'com.acme.host' }, 'com.acme.authored')]);
27+
expect(entry!.packageId).toBe('com.acme.authored');
28+
expect(entry!.data._packageId).toBe('com.acme.authored');
29+
});
30+
31+
it('control: a body with no stamp is stamped from its column, and an agreeing stamp is kept', () => {
32+
const [bound, agreeing, unbound] = parse([
33+
row({ name: 'a' }, 'com.acme.authored'),
34+
row({ name: 'b', _packageId: 'com.acme.authored' }, 'com.acme.authored'),
35+
row({ name: 'c' }, null),
36+
]);
37+
expect(bound!.data._packageId).toBe('com.acme.authored');
38+
expect(agreeing!.data._packageId).toBe('com.acme.authored');
39+
expect(unbound!.data).not.toHaveProperty('_packageId');
40+
});
41+
42+
it('a row whose metadata is an object is not written: the served body is a copy', () => {
43+
const body = { name: 'a', _packageId: 'com.acme.host' };
44+
const [entry] = parse([{ type: 'connector', name: 'a', metadata: body, package_id: null }]);
45+
expect(entry!.data).not.toHaveProperty('_packageId');
46+
expect(body._packageId).toBe('com.acme.host');
47+
});
48+
});

‎packages/metadata-protocol/src/protocol-stored-overlay-reads.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,28 @@ export function storedOverlayEntries(
155155
// provenance classification see overlay rows the way they see
156156
// registry items.
157157
return records.map((record) => {
158-
const data = this.convertStoredItem(
158+
const parsed = this.convertStoredItem(
159159
String(record.type ?? request.type),
160160
typeof record.metadata === 'string'
161161
? JSON.parse(record.metadata)
162162
: record.metadata,
163163
) as any;
164164
const recPkg = (record as { package_id?: string | null }).package_id ?? undefined;
165-
if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) {
166-
(data as any)._packageId = recPkg;
165+
// [#28594] The row's package is its COLUMN's, and only the column's: a
166+
// `_packageId` the stored body carries is not served (dropped when the
167+
// row is bound to no package, replaced when it is bound to another).
168+
// The save door already strips a body's stamp (`stripDerivedProvenance`
169+
// in `protocol.ts`: the server states provenance, a caller never does),
170+
// but a row at rest can still carry one, and every reader of this list
171+
// asks an item's owner of `_packageId` — the connector materializer
172+
// deciding whose root a file ref reads among them. A copy, so a cached
173+
// row's body is never written.
174+
let data = parsed;
175+
if (data && typeof data === 'object' && !Array.isArray(data) && (data as any)._packageId !== recPkg) {
176+
const bound: Record<string, unknown> = { ...(data as Record<string, unknown>) };
177+
if (recPkg) bound._packageId = recPkg;
178+
else delete bound._packageId;
179+
data = bound;
167180
}
168181
// [ADR-0131 D6/D7] Every row is the environment's: the family
169182
// declares no organization column, and the reads that feed this

0 commit comments

Comments
 (0)