Skip to content

Commit b2549a8

Browse files
feat(spec): ConnectorProviderContext.resolvePackagePath, a host-provided package anchor for provider factories (#22434) (#22465)
Fixes #22434 Clause-②: yes ## What this changes `ConnectorProviderContext` (`packages/spec/src/integration/connector-provider.ts`) gains one optional, host-provided member, `resolvePackagePath(relativePath)`, beside `loadPackageFile`. It takes the card's **preferred, confined shape**: - it resolves a relative path against the root of the stack/package that declared the connector entry and resolves to the absolute path; - `'.'` gives the root itself; - it refuses (rejects) an empty path, an absolute path (posix or Windows drive-letter) and a path that escapes the root after resolution (`../x`, `a/../../x`); - it reads nothing and does not check that the path exists. Its signature is exactly its sibling's: `(relativePath: string)`, resolving to a string. It is async for the same reason the loader is: `service-automation` imports `node:path` lazily, 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 beside `loadPackageFile` (the `providerCtx` literal in `plugin.ts`), anchored at the same `packageRoot` option. The confinement check that lived inline in `createPackageFileLoader` is 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 the `docs/qa/platform-checklist/areas/integration-system.json` item) are byte-identical; only the escape refusal's tail word is per member (`reads are confined to` for the loader, `paths are confined to` for the resolver). `createPackagePathResolver` is exported from `plugin.ts` for its tests, and deliberately NOT from the package index: no caller outside the materializer needs to build one today, so the published surface of `service-automation` does 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), the `declarativeStdio` policy (default deny, exact allowlist), and ADR-0097's text. ## Measurements (the dispatch's four hypotheses, at `da159f74e6`) - **H1, confirmed, with one sharpening.** `createPackageFileLoader` (`plugin.ts:206`) held the whole rule inline: a non-empty check, an absolute check (`path.isAbsolute` plus a drive-letter regex), `path.resolve(packageRoot ?? process.cwd())`, then `path.relative` refused when it starts with `..` or is absolute. **Symlink escapes are not handled today**: the check is lexical, with no `realpath`, 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. - **H2, confirmed.** Every construction site of the context type in `packages/` (tests included): one production host, the materializer in `service-automation/src/plugin.ts`, which now hands the member. Test-only sites: the `ctx()` helpers in `connector-mcp/src/mcp-provider.test.ts`, `connector-openapi/src/openapi-provider.test.ts` and `connector-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.ts` and `connector-reload-reingest.test.ts` only receive contexts from the materializer. - **H3, the note stays true and is not edited.** `packages/spec/liveness/connector.json`'s `_note` (and its README row) say `loadPackageFile` is host-injected, and that an authored entry reaches exactly the author-supplied context fields plus `provider` and `enabled`. A second host-injected member makes neither sentence false: it is not author-supplied and it reads no entry key. `check:liveness` is green on this head. - **H4, consistent.** The loader falls back to `process.cwd()` at call time when `packageRoot` is unset; the resolver does the same, through the same helper (pinned: an unanchored resolver resolves `'.'` to `path.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: - the resolver: a relative path resolves under the root and `'.'` is the root (including an inside-landing `./specs/../specs` and a path that does not exist yet); `..`, `../outside` and `specs/../../outside` are refused, naming the ref and the root; an absolute path (posix, `/etc`, `C:\evil`) and an empty ref are refused; parity with the loader; the `process.cwd()` default; - the materializer: a factory resolving `'.'` and `./scripts/fixture.mjs` gets locations under `packageRoot`; a factory resolving `../outside` fails boot loudly with the confinement refusal; a factory that never reads the member materializes exactly as before. - Each refusal case also asserts the rejection is not a `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 Pick` alias, which `toEqualTypeOf` cannot tell apart from a required member typed with `undefined`) and has exactly `loadPackageFile`'s type, plus a context literal without the member that must keep compiling. `check:test-typecheck` compiles this file. ## One-off proofs (run against committed head `159042aed0`, mutated through `scripts/ablation-replace.mjs`, every leg restored and proven: blob equals HEAD, `git diff HEAD` empty) | leg | mutation | expected | observed | |:---|:---|:---|:---| | RV1 (reverse verification) | the materializer hands `this.options.packageRoot` (a string) as the member | `service-automation` tsc reads the rebuilt spec `.d.ts` and refuses it | red: `src/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) | | A1 | the spec member made required | the optionality pins go red | red: `check:test-typecheck` names `src/integration/connector-provider.test.ts` with 4 type errors | | A2 | the resolver bypasses the helper (bare `path.resolve`) | the escape, absolute/empty, parity and escape-at-boot cases go red | red: 4 failed, 46 passed; exactly those four cases | | A3 | the materializer anchors the resolver at `process.cwd()` | only the anchored-locations case goes red | red: 1 failed, 49 passed; the one red is that case | ## Verification All of the following ran on head `159042aed0` (branch point `da159f74e6`). - **Build.** `pnpm turbo run build --filter='@objectstack/service-automation...' --concurrency=2`, under the verify lock: 30 of 30 tasks successful, `@objectstack/spec` and `@objectstack/service-automation` rebuilt (cache misses). The rebuilt `packages/spec/dist` carries `resolvePackagePath`. - **Tests.** `@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.ts` run verbose: 15 passed. - **Typecheck.** `pnpm --filter @objectstack/service-automation typecheck` and `pnpm --filter @objectstack/spec typecheck` (tsc, the scripts program, `check:test-typecheck`): exit 0, both. - **Gates.** `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-loads` exited 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 --ran` answers: "87 derived famil(ies) accounted for — 86 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - **Changeset level axis**, driven offline with a payload carrying this body's first two lines: "this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`". - **Lint, narrowed (a measurement, not a skip).** `eslint --no-inline-config --format json` over the four changed TypeScript files: 4 files, 0 errors, 0 warnings. All four are in the linted population (`--print-config` returns a rule set for each). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. - **Branch freshness.** `origin/main` has moved four commits past the branch point (to `3ca71b6e05`). 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 - **Lexical confinement (both members).** Neither member follows symlinks when judging confinement, which is the family's rule as it stood. The authoring doors (`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; a `realpath` rule would change `loadPackageFile`'s behaviour and is not this card. Noted, not filed. Carrier: none. - **`..`-prefixed names are refused although they stay inside the root.** `path.relative` of a file named `..foo` under the root is `..foo`, which the `startsWith('..')` 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. - **ADR-0097** describes `loadPackageFile` only (`docs/adr/0097-declarative-connector-instances.md`, the `providerConfig.spec` bullet). A sentence for the sibling would be a governed (Tier H) edit, so it is left out of this PR. Carrier: none. - **Platform checklist.** The `integration-system` item 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: #22423. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b713fa commit b2549a8

5 files changed

Lines changed: 281 additions & 28 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root
7+
8+
Clause-②: yes (widening)
9+
10+
- **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise<string>`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists.
11+
- **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged.
12+
- **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from.
13+
- **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before.
14+
- **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root.

‎packages/services/service-automation/src/connector-materialization.test.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration
2424
import {
2525
AutomationServicePlugin,
2626
createPackageFileLoader,
27+
createPackagePathResolver,
2728
DECLARATIVE_RETRY_BASE_MS,
2829
type CredentialResolver,
2930
} from './plugin.js';
@@ -542,6 +543,127 @@ describe('#3016 — file-ref materialization policy (fatal at boot, soft on relo
542543
});
543544
});
544545

546+
// ── #22434 — package path refs (resolvePackagePath) ─────────────────────────
547+
//
548+
// The sibling of `loadPackageFile` for a factory that needs a LOCATION — a
549+
// launched process's working directory — rather than file contents. Same root,
550+
// same confinement rule (one shared check), same `process.cwd()` default. It
551+
// reads nothing, so a resolved path need not exist.
552+
553+
/** The rejection a promise settles with, or a fail-loud marker if it resolved. */
554+
async function rejectionOf(p: Promise<unknown>): Promise<Error> {
555+
return p.then(
556+
(value) => { throw new Error(`expected a refusal, got ${JSON.stringify(value)}`); },
557+
(err: unknown) => err as Error,
558+
);
559+
}
560+
561+
describe('#22434 — package path resolver (createPackagePathResolver)', () => {
562+
const root = path.resolve(fixtureRoot);
563+
const resolvePath = createPackagePathResolver(fixtureRoot);
564+
565+
it('resolves a relative path against the package root, and `.` to the root itself', async () => {
566+
await expect(resolvePath('.')).resolves.toBe(root);
567+
await expect(resolvePath('./specs/billing.json')).resolves.toBe(path.join(root, 'specs', 'billing.json'));
568+
await expect(resolvePath('specs')).resolves.toBe(path.join(root, 'specs'));
569+
// Judged on the RESOLVED path: a traversal that lands back inside passes.
570+
await expect(resolvePath('./specs/../specs')).resolves.toBe(path.join(root, 'specs'));
571+
// A location, not a read: a path that does not exist yet still resolves.
572+
await expect(resolvePath('./scripts/not-there.mjs')).resolves.toBe(path.join(root, 'scripts', 'not-there.mjs'));
573+
});
574+
575+
it('refuses a path that escapes the root, naming the ref and the root it is confined to', async () => {
576+
for (const ref of ['..', '../outside', 'specs/../../outside']) {
577+
const err = await rejectionOf(resolvePath(ref));
578+
expect(err).toBeInstanceOf(Error);
579+
expect(err).not.toBeInstanceOf(TypeError);
580+
expect(err.message).toContain(`'${ref}' escapes the stack/package root`);
581+
expect(err.message).toContain(`confined to '${root}'`);
582+
}
583+
});
584+
585+
it('refuses an absolute path (posix and windows-drive) and an empty ref', async () => {
586+
for (const ref of [path.join(root, 'specs'), '/etc', 'C:\\evil']) {
587+
const err = await rejectionOf(resolvePath(ref));
588+
expect(err).not.toBeInstanceOf(TypeError);
589+
expect(err.message).toContain(`'${ref}' is absolute`);
590+
}
591+
for (const ref of ['', ' ']) {
592+
expect((await rejectionOf(resolvePath(ref))).message).toContain('non-empty relative path');
593+
}
594+
});
595+
596+
it('shares the loader\'s rule: a ref is refused for confinement by both members or by neither', async () => {
597+
const load = createPackageFileLoader(fixtureRoot);
598+
const confinement = /non-empty relative path|is absolute|escapes the stack\/package root/;
599+
const refs = ['.', 'specs', './specs/billing.json', './specs/../specs/billing.json', '', '..', '../x',
600+
'specs/../../x', '/etc/hostname', 'C:/evil', 'D:\\evil'];
601+
for (const ref of refs) {
602+
const loaderRefused = await load(ref).then(() => false, (e: Error) => confinement.test(e.message));
603+
const resolverRefused = await resolvePath(ref).then(() => false, (e: Error) => confinement.test(e.message));
604+
expect(resolverRefused, `ref '${ref}'`).toBe(loaderRefused);
605+
}
606+
});
607+
608+
it('defaults to process.cwd() when no packageRoot is given, as the loader does', async () => {
609+
await expect(createPackagePathResolver()('.')).resolves.toBe(path.resolve(process.cwd()));
610+
});
611+
});
612+
613+
describe('#22434 — the materializer hands resolvePackagePath anchored to packageRoot', () => {
614+
/** A provider whose factory resolves the refs in `providerConfig.paths` before materializing. */
615+
function makePathResolvingProvider() {
616+
const resolved: string[] = [];
617+
const factory: ConnectorProviderFactory = async (ctx) => {
618+
for (const ref of (ctx.providerConfig.paths as string[]) ?? []) {
619+
resolved.push(await ctx.resolvePackagePath!(ref));
620+
}
621+
return {
622+
def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] } as unknown as Connector,
623+
handlers: { ping: async () => ({ ok: true }) },
624+
};
625+
};
626+
return { factory, resolved };
627+
}
628+
629+
it('a factory resolving `.` and a relative path gets locations under packageRoot', async () => {
630+
const { factory, resolved } = makePathResolvingProvider();
631+
const kernel = await boot(
632+
[providerConnector('tools', { providerConfig: { paths: ['.', './scripts/fixture.mjs'] } })],
633+
{ providerFactory: factory, packageRoot: fixtureRoot },
634+
);
635+
expect(automationOf(kernel).getRegisteredConnectors()).toContain('tools');
636+
expect(resolved).toEqual([path.resolve(fixtureRoot), path.join(path.resolve(fixtureRoot), 'scripts', 'fixture.mjs')]);
637+
await kernel.shutdown();
638+
});
639+
640+
it('fails boot loudly when a factory resolves a path that escapes the package root', async () => {
641+
const { factory, resolved } = makePathResolvingProvider();
642+
await expect(
643+
boot([providerConnector('tools', { providerConfig: { paths: ['../outside'] } })], {
644+
providerFactory: factory,
645+
packageRoot: fixtureRoot,
646+
}),
647+
).rejects.toThrow(/failed to materialize connector instance 'tools'.*'\.\.\/outside' escapes the stack\/package root/s);
648+
expect(resolved).toEqual([]);
649+
});
650+
651+
it('a factory that never reads it materializes exactly as before', async () => {
652+
const { factory, calls } = makeFakeProvider();
653+
const kernel = await boot([providerConnector('billing')], { providerFactory: factory, packageRoot: fixtureRoot });
654+
const engine = automationOf(kernel);
655+
expect(engine.getRegisteredConnectors()).toContain('billing');
656+
// The member is handed (the factory simply ignores it) …
657+
expect(typeof calls[0]?.resolvePackagePath).toBe('function');
658+
// … and the connector it materialized is listed exactly as it always was.
659+
const desc = engine.getConnectorDescriptors().find((d) => d.name === 'billing');
660+
expect(desc?.actions.map((a) => a.key)).toEqual(['ping']);
661+
expect(desc?.origin).toBe('declarative');
662+
expect(calls).toHaveLength(1);
663+
await kernel.shutdown();
664+
});
665+
});
666+
545667
// ── #3017 — upstream unavailable: degrade + retry, never a dead boot ─────────
546668
//
547669
// A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker

‎packages/services/service-automation/src/plugin.ts‎

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -183,47 +183,75 @@ export interface AutomationServicePluginOptions {
183183
* against (#3016, e.g. `providerConfig.spec: './billing-openapi.json'` for
184184
* `provider: 'openapi'`). The CLI passes the directory containing
185185
* `objectstack.config.ts`; embedders pass their stack root. Defaults to
186-
* `process.cwd()`. Reads are confined to this root (see
187-
* {@link createPackageFileLoader}).
186+
* `process.cwd()`. Reads and resolved paths are confined to this root (see
187+
* {@link createPackageFileLoader} and {@link createPackagePathResolver}).
188188
*/
189189
packageRoot?: string;
190190
}
191191

192+
/**
193+
* The ONE confinement rule behind both package-anchored members of
194+
* {@link ConnectorProviderContext} — `loadPackageFile` (#3016) and
195+
* `resolvePackagePath` (#22434) — so the two can never disagree about what
196+
* "inside the stack/package root" means. Resolves `relativePath` against
197+
* `packageRoot` (default `process.cwd()`, read at call time) and returns the
198+
* absolute result. Rejects an empty ref, an absolute ref (posix or Windows
199+
* drive-letter), and any ref that escapes the root after resolution (`../…`,
200+
* `a/../../…`). The check is on the normalized path, not on `realpath`: a
201+
* symlink inside the root is judged by where it sits, not by where it points,
202+
* for both members alike.
203+
*
204+
* `confined` only words the escape refusal for the member that raised it
205+
* (`reads` for the loader, whose text is pinned verbatim by the platform
206+
* checklist, `paths` for the resolver); the rule itself is identical.
207+
*
208+
* `node:path` is imported lazily so merely constructing either capability
209+
* never touches it — hosts without a filesystem only fail if a factory
210+
* actually dereferences a package ref.
211+
*/
212+
async function resolveInsidePackageRoot(
213+
packageRoot: string | undefined,
214+
relativePath: string,
215+
confined: 'reads' | 'paths',
216+
): Promise<string> {
217+
if (typeof relativePath !== 'string' || relativePath.trim().length === 0) {
218+
throw new Error('package file ref must be a non-empty relative path.');
219+
}
220+
const path = await import('node:path');
221+
// Windows drive-letter absolutes ('C:\…') are not `isAbsolute` on posix —
222+
// reject them explicitly so the guard is platform-independent.
223+
if (path.isAbsolute(relativePath) || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
224+
throw new Error(
225+
`package file ref '${relativePath}' is absolute — file refs must be relative to the declaring stack/package root.`,
226+
);
227+
}
228+
const root = path.resolve(packageRoot ?? process.cwd());
229+
const resolved = path.resolve(root, relativePath);
230+
const rel = path.relative(root, resolved);
231+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
232+
throw new Error(
233+
`package file ref '${relativePath}' escapes the stack/package root — ${confined} are confined to '${root}'.`,
234+
);
235+
}
236+
return resolved;
237+
}
238+
192239
/**
193240
* Build the `loadPackageFile` capability handed to provider factories via
194241
* {@link ConnectorProviderContext} (#3016): read a UTF-8 text file resolved
195-
* against `packageRoot`, **confined to that root**. Rejects absolute paths and
196-
* any path that escapes the root after resolution (`../…`, `a/../../…`), so a
197-
* declarative entry can never read outside the stack/package that declared it.
198-
* A missing/unreadable file throws — the materializer's reconcile policy makes
199-
* that fatal at boot and a skipped entry on reload, like every other ADR-0097
200-
* materialization failure.
242+
* against `packageRoot`, **confined to that root** by
243+
* {@link resolveInsidePackageRoot}, so a declarative entry can never read
244+
* outside the stack/package that declared it. A missing/unreadable file throws
245+
* — the materializer's reconcile policy makes that fatal at boot and a skipped
246+
* entry on reload, like every other ADR-0097 materialization failure.
201247
*
202248
* Node builtins are imported lazily inside the returned closure so merely
203249
* constructing the capability never touches `node:fs`/`node:path` — hosts
204250
* without a filesystem only fail if a factory actually dereferences a file ref.
205251
*/
206252
export function createPackageFileLoader(packageRoot?: string): (relativePath: string) => Promise<string> {
207253
return async (relativePath: string) => {
208-
if (typeof relativePath !== 'string' || relativePath.trim().length === 0) {
209-
throw new Error('package file ref must be a non-empty relative path.');
210-
}
211-
const path = await import('node:path');
212-
// Windows drive-letter absolutes ('C:\…') are not `isAbsolute` on posix —
213-
// reject them explicitly so the guard is platform-independent.
214-
if (path.isAbsolute(relativePath) || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
215-
throw new Error(
216-
`package file ref '${relativePath}' is absolute — file refs must be relative to the declaring stack/package root.`,
217-
);
218-
}
219-
const root = path.resolve(packageRoot ?? process.cwd());
220-
const resolved = path.resolve(root, relativePath);
221-
const rel = path.relative(root, resolved);
222-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
223-
throw new Error(
224-
`package file ref '${relativePath}' escapes the stack/package root — reads are confined to '${root}'.`,
225-
);
226-
}
254+
const resolved = await resolveInsidePackageRoot(packageRoot, relativePath, 'reads');
227255
const { readFile } = await import('node:fs/promises');
228256
try {
229257
return await readFile(resolved, 'utf8');
@@ -235,6 +263,20 @@ export function createPackageFileLoader(packageRoot?: string): (relativePath: st
235263
};
236264
}
237265

266+
/**
267+
* Build the `resolvePackagePath` capability handed to provider factories via
268+
* {@link ConnectorProviderContext} (#22434): the absolute path of a ref
269+
* resolved against `packageRoot` — `'.'` is the root itself — **confined to
270+
* that root** by the same {@link resolveInsidePackageRoot} rule the loader
271+
* uses, with the same `process.cwd()` default. It reads nothing and does not
272+
* check existence; a factory that needs a location (a launched process's
273+
* working directory) gets the declaring app's root instead of the server's
274+
* current directory.
275+
*/
276+
export function createPackagePathResolver(packageRoot?: string): (relativePath: string) => Promise<string> {
277+
return (relativePath: string) => resolveInsidePackageRoot(packageRoot, relativePath, 'paths');
278+
}
279+
238280
/**
239281
* Resolves a declarative connector `credentialRef` to its plaintext secret, or
240282
* `undefined`/empty when unknown (which the materializer turns into a hard boot
@@ -1975,6 +2017,9 @@ export class AutomationServicePlugin implements Plugin {
19752017
// openapi's `providerConfig.spec: './billing-openapi.json'`),
19762018
// confined to the stack/package root.
19772019
loadPackageFile: createPackageFileLoader(this.options.packageRoot),
2020+
// #22434 — the same root as a location (e.g. a launched
2021+
// process's working directory), under the same confinement.
2022+
resolvePackagePath: createPackagePathResolver(this.options.packageRoot),
19782023
};
19792024

19802025
let materialization;

‎packages/spec/src/integration/connector-provider.test.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
// credentialRef-based instance auth (no inline secrets), and the authoring rules
66
// enforced by DeclarativeConnectorEntrySchema.
77

8-
import { describe, it, expect } from 'vitest';
8+
import { describe, it, expect, expectTypeOf } from 'vitest';
99
import {
1010
ConnectorSchema,
1111
DeclarativeConnectorEntrySchema,
1212
ConnectorInstanceAuthSchema,
1313
} from './connector.zod';
14+
import type { ConnectorProviderContext } from './connector-provider';
1415

1516
describe('ADR-0097 connector schema evolution', () => {
1617
describe('ConnectorSchema — new fields', () => {
@@ -167,3 +168,53 @@ describe('ADR-0097 connector schema evolution', () => {
167168
});
168169
});
169170
});
171+
172+
// ---------------------------------------------------------------------------
173+
// [#22434] `ConnectorProviderContext.resolvePackagePath` — the host-provided
174+
// package anchor, beside `loadPackageFile`.
175+
//
176+
// The pins here hold what the card decided at the CONTRACT layer: the member is
177+
// OPTIONAL — a host that does not hand it, and every context built before it
178+
// existed, still conforms, so its absence leaves a factory's behaviour as it
179+
// was — and it has exactly the signature of its sibling `loadPackageFile`. The
180+
// behaviour pins (resolves against the package root, `'.'` is the root, an
181+
// escape is refused) live with the host that implements it:
182+
// `packages/services/service-automation/src/connector-materialization.test.ts`.
183+
//
184+
// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf<…
185+
// | undefined>` cannot tell an optional member from a REQUIRED one typed
186+
// `… | undefined`, while `{} extends Pick<…>` can. Exported for TS6196;
187+
// `check:test-typecheck` compiles this file.
188+
// ---------------------------------------------------------------------------
189+
190+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
191+
type Assert<T extends true> = T;
192+
193+
/** Optional: a host may omit it. A required member turns this alias red. */
194+
export type ResolvePackagePathIsOptional = Assert<
195+
{} extends Pick<ConnectorProviderContext, 'resolvePackagePath'> ? true : false
196+
>;
197+
198+
/** One member family: the same `(relativePath) => Promise<string>` shape as the loader. */
199+
export type ResolvePackagePathMatchesLoader = Assert<
200+
Eq<ConnectorProviderContext['resolvePackagePath'], ConnectorProviderContext['loadPackageFile']>
201+
>;
202+
203+
describe('ConnectorProviderContext.resolvePackagePath — the host-provided package anchor', () => {
204+
it('is optional: a context without it still conforms, and a factory can tell it is absent', async () => {
205+
expectTypeOf<ConnectorProviderContext['resolvePackagePath']>().toEqualTypeOf<
206+
((relativePath: string) => Promise<string>) | undefined
207+
>();
208+
209+
// The shape every host built before this member existed. This literal
210+
// failing to compile is the regression.
211+
const withoutAnchor: ConnectorProviderContext = { name: 'tools', label: 'Tools', type: 'api', providerConfig: {} };
212+
expect(withoutAnchor.resolvePackagePath).toBeUndefined();
213+
214+
const withAnchor: ConnectorProviderContext = {
215+
...withoutAnchor,
216+
resolvePackagePath: async (relativePath) => `/srv/app/${relativePath}`,
217+
};
218+
await expect(withAnchor.resolvePackagePath?.('scripts')).resolves.toBe('/srv/app/scripts');
219+
});
220+
});

0 commit comments

Comments
 (0)