Repository navigation
feat(spec): ConnectorProviderContext.resolvePackagePath, a host-provided package anchor for provider factories (#22434) - #22465
Conversation
…ded package anchor ConnectorProviderContext gains one optional, host-provided member, resolvePackagePath(relativePath), beside loadPackageFile. It resolves a relative path against the declaring stack/package root, returns the root for '.', and refuses an empty, absolute or root-escaping path. service-automation's materializer hands it to every provider factory, anchored at packageRoot (default process.cwd()), and both members now share one confinement check (resolveInsidePackageRoot) so they cannot disagree. loadPackageFile's behaviour and error strings are unchanged. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3ff7b346d24517c09dc281460ef527388622cde8 && git checkout 3ff7b346d24517c09dc281460ef527388622cde8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 159042aed0b51fa7d901a44e0da62ad694441f8e && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff 159042aed0b51fa7d901a44e0da62ad694441f8e
node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389
|
Contract reviewServed-tier: Inputs, and nothing else: card #22434 (body, the Claim comment, the os-dev-report comment), PR #22465 (body, its five-file list, the net diff against main from the merge-base), and the check-runs on the head. Check-runs read 2026-10-09T11:05Z: 32 runs, 17 completed success, 3 completed skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 12 in progress. Still running at that read, so NOT judged green here: Lint and Repo Gates, Type Check workspace, Type Check consumer gates (which hosts check:api-surface), Test Core (all six shards), Dogfood Regression Gate (1/3 and 3/3), Temporal Conformance. Their conclusions are the gate verdicts; this record pre-judges none of them. Completed success at that read, named because sections ① and ② lean on them: Type Check source gates (check:authorable-surface, check:docs, the check:generated reconcile), Type Check debt ledger, Spec property liveness, Check Changeset (the level axis), Build Core (which hosts check:dual-build-cjs-loads), Governed Surface Queue Guard, Check PR Size, and the claim and closing-keyword guards. ① Derived judgments
② Semver level
③ Boundary flagsThe os-dev-report carries no
Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37931072987 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #22434
Clause-②: yes
What this changes
ConnectorProviderContext(packages/spec/src/integration/connector-provider.ts) gains one optional, host-provided member,resolvePackagePath(relativePath), besideloadPackageFile. It takes the card's preferred, confined shape:'.'gives the root itself;../x,a/../../x);Its signature is exactly its sibling's:
(relativePath: string), resolving to a string. It is async for the same reason the loader is:service-automationimportsnode:pathlazily, so constructing either capability never touches a Node builtin on a host without a filesystem. The consumer is an async provider factory, which awaits it.@objectstack/service-automation's materializer hands it to every provider factory, right besideloadPackageFile(theproviderCtxliteral inplugin.ts), anchored at the samepackageRootoption. The confinement check that lived inline increatePackageFileLoaderis extracted into one private helper,resolveInsidePackageRoot, which both members call, so they cannot disagree about what is inside the root.loadPackageFile's behaviour and its three error strings (pinned verbatim by thedocs/qa/platform-checklist/areas/integration-system.jsonitem) are byte-identical; only the escape refusal's tail word is per member (reads are confined tofor the loader,paths are confined tofor the resolver).createPackagePathResolveris exported fromplugin.tsfor its tests, and deliberately NOT from the package index: no caller outside the materializer needs to build one today, so the published surface ofservice-automationdoes not grow.Not changed:
packages/connectors/connector-mcp(using the member as the stdio transport's working directory is the next stage; #22423 remains open and carries those pins), thedeclarativeStdiopolicy (default deny, exact allowlist), and ADR-0097's text.Measurements (the dispatch's four hypotheses, at
da159f74e6)createPackageFileLoader(plugin.ts:206) held the whole rule inline: a non-empty check, an absolute check (path.isAbsoluteplus a drive-letter regex),path.resolve(packageRoot ?? process.cwd()), thenpath.relativerefused when it starts with..or is absolute. Symlink escapes are not handled today: the check is lexical, with norealpath, so a symlink inside the root is judged by where it sits. The new member matches it exactly because it calls the same helper rather than a copy; a parity test pins "refused for confinement by both members or by neither" over 11 refs.packages/(tests included): one production host, the materializer inservice-automation/src/plugin.ts, which now hands the member. Test-only sites: thectx()helpers inconnector-mcp/src/mcp-provider.test.ts,connector-openapi/src/openapi-provider.test.tsandconnector-rest/src/rest-provider.test.ts. Each builds a context without the member, which still type-checks because it is optional, and none of those factories reads it, so none needs it.connector-materialization.test.tsandconnector-reload-reingest.test.tsonly receive contexts from the materializer.packages/spec/liveness/connector.json's_note(and its README row) sayloadPackageFileis host-injected, and that an authored entry reaches exactly the author-supplied context fields plusproviderandenabled. A second host-injected member makes neither sentence false: it is not author-supplied and it reads no entry key.check:livenessis green on this head.process.cwd()at call time whenpackageRootis unset; the resolver does the same, through the same helper (pinned: an unanchored resolver resolves'.'topath.resolve(process.cwd())).'.'returns the root. For the loader,'.'passes the same confinement check and then fails to read a directory (could not be read), so the two members differ only where one reads and the other does not.Pins
packages/services/service-automation/src/connector-materialization.test.ts, eight new cases:'.'is the root (including an inside-landing./specs/../specsand a path that does not exist yet);..,../outsideandspecs/../../outsideare refused, naming the ref and the root; an absolute path (posix,/etc,C:\evil) and an empty ref are refused; parity with the loader; theprocess.cwd()default;'.'and./scripts/fixture.mjsgets locations underpackageRoot; a factory resolving../outsidefails boot loudly with the confinement refusal; a factory that never reads the member materializes exactly as before.TypeError, so a missing member cannot pass for a refusal.packages/spec/src/integration/connector-provider.test.ts: type-level pins that the member is optional ({} extends Pickalias, whichtoEqualTypeOfcannot tell apart from a required member typed withundefined) and has exactlyloadPackageFile's type, plus a context literal without the member that must keep compiling.check:test-typecheckcompiles this file.One-off proofs (run against committed head
159042aed0, mutated throughscripts/ablation-replace.mjs, every leg restored and proven: blob equals HEAD,git diff HEADempty)this.options.packageRoot(a string) as the memberservice-automationtsc reads the rebuilt spec.d.tsand refuses itsrc/plugin.ts(2022,17): error TS2322: Type 'string or undefined' is not assignable to type '((relativePath: string) =) Promise(string)) or undefined'(angle brackets and pipes respelled here)check:test-typechecknamessrc/integration/connector-provider.test.tswith 4 type errorspath.resolve)process.cwd()Verification
All of the following ran on head
159042aed0(branch pointda159f74e6).pnpm turbo run build --filter='@objectstack/service-automation...' --concurrency=2, under the verify lock: 30 of 30 tasks successful,@objectstack/specand@objectstack/service-automationrebuilt (cache misses). The rebuiltpackages/spec/distcarriesresolvePackagePath.@objectstack/service-automation(vitest run --maxWorkers=2): 178 files, 2187 tests passed. The touched file run verbose: 50 passed, the eight new cases among them.@objectstack/spec(vitest run --project local --maxWorkers=2): 627 files, 18758 passed, 1 todo.connector-provider.test.tsrun verbose: 15 passed.pnpm --filter @objectstack/service-automation typecheckandpnpm --filter @objectstack/spec typecheck(tsc, the scripts program,check:test-typecheck): exit 0, both.node scripts/pm/dispatch-gates.mjs --commands, re-derived on the actual diff: 87 families. That is the dispatch's list plus six families the test-file edits add:check:engine-double-contract,check:objectql-double-limit,check:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:where-matcher. 86 exited 0.check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET: it reads every package's built output, and only this diff's 30-package closure was built. It is NOT MEASURED here and left to CI.dispatch-gates --rananswers: "87 derived famil(ies) accounted for — 86 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".yes, and no package whosepackages/**/src/**it moves is gradedpatch".eslint --no-inline-config --format jsonover the four changed TypeScript files: 4 files, 0 errors, 0 warnings. All four are in the linted population (--print-configreturns a rule set for each).eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.origin/mainhas moved four commits past the branch point (to3ca71b6e05). None of them touches the five paths in this diff, and a local merge-tree merges clean. CI runs on the merge ref.Acceptance notes
defineStack,PUT /meta/connector/:name) can set a path but cannot create a symlink, so the declared threat (an entry reaching outside its package) is covered; arealpathrule would changeloadPackageFile's behaviour and is not this card. Noted, not filed. Carrier: none...-prefixed names are refused although they stay inside the root.path.relativeof a file named..foounder the root is..foo, which thestartsWith('..')test refuses. It is loud (a refusal, never a silent read), the loader has always done it, and it is kept byte-identical here so the two members stay one rule. Noted, not filed. Carrier: none.loadPackageFileonly (docs/adr/0097-declarative-connector-instances.md, theproviderConfig.specbullet). A sentence for the sibling would be a governed (Tier H) edit, so it is left out of this PR. Carrier: none.integration-systemitem pins the loader's three strings, which are unchanged. No checklist item covers the resolver yet; the user-visible behaviour arrives with the connector-mcp stage. Carrier: connector-mcp: a declarative stdio transport resolves its relative command against the process cwd, while the same app's OpenAPI file ref resolves against the package root —showcase_mcp_toolsboots degraded from any other directory #22423.Generated by Claude Code