@skyramp/mcp 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/playwright/registerPlaywrightTools.js +42 -1
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
- package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
- package/build/prompts/test-recommendation/recommendationSections.js +5 -5
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
- package/build/prompts/testbot/testbot-prompts.js +6 -13
- package/build/recommendation/discriminators.d.ts +7 -1
- package/build/recommendation/discriminators.js +16 -3
- package/build/resources/testbotResource.js +0 -1
- package/build/services/ScenarioGenerationService.js +5 -2
- package/build/services/TestExecutionService.js +25 -1
- package/build/services/TestGenerationService.js +24 -9
- package/build/services/containerEnv.d.ts +12 -1
- package/build/services/containerEnv.js +94 -1
- package/build/tools/executeSkyrampTestTool.d.ts +9 -0
- package/build/tools/executeSkyrampTestTool.js +20 -6
- package/build/tools/execution-video-state.d.ts +21 -0
- package/build/tools/execution-video-state.js +51 -0
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
- package/build/tools/generate-tests/planGuard.d.ts +5 -5
- package/build/tools/generate-tests/planGuard.js +5 -17
- package/build/tools/submitReportTool.d.ts +83 -10
- package/build/tools/submitReportTool.js +169 -28
- package/build/tools/test-management/actionsTool.js +40 -39
- package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
- package/build/tools/test-management/analyzeChangesTool.js +37 -33
- package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
- package/build/tools/test-management/registerTestPlanTool.js +113 -31
- package/build/types/TestExecution.d.ts +14 -0
- package/build/types/TestTypes.js +3 -2
- package/build/types/TestbotPromptOptions.d.ts +0 -1
- package/build/types/TestbotReport.d.ts +10 -0
- package/build/types/TestbotReport.js +10 -1
- package/build/types/index.d.ts +1 -0
- package/build/types/index.js +1 -0
- package/build/utils/AnalysisStateManager.d.ts +36 -2
- package/build/utils/AnalysisStateManager.js +34 -13
- package/build/utils/scenarioDrafting.js +7 -1
- package/build/utils/skyrampMdContent.d.ts +1 -1
- package/build/utils/skyrampMdContent.js +1 -1
- package/build/utils/urlPath.d.ts +37 -0
- package/build/utils/urlPath.js +55 -0
- package/build/utils/utils.d.ts +45 -0
- package/build/utils/utils.js +50 -0
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.d.ts +15 -15
- package/build/utils/workspaceAuth.js +32 -17
- package/build/workspace/queryParamResolution.d.ts +93 -0
- package/build/workspace/queryParamResolution.js +201 -0
- package/build/workspace/workspace.d.ts +104 -0
- package/build/workspace/workspace.js +24 -0
- package/package.json +3 -2
package/build/utils/utils.d.ts
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
import { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
export declare function readDiffFile(diffFilePath: string | undefined): string | undefined;
|
|
3
3
|
export declare function toolError(message: string): CallToolResult;
|
|
4
|
+
/**
|
|
5
|
+
* Build a successful tool result whose payload is delivered on BOTH MCP result
|
|
6
|
+
* channels — `structuredContent` and `content[]` — carrying the identical text.
|
|
7
|
+
* The counterpart to `toolError` for any tool that returns a large text payload.
|
|
8
|
+
*
|
|
9
|
+
* Use this for every non-error return of such a tool, and declare a matching
|
|
10
|
+
* `outputSchema`. Two separate failures make that necessary:
|
|
11
|
+
*
|
|
12
|
+
* 1. A result with no `structuredContent` is persisted by the agent harness as a
|
|
13
|
+
* pretty-printed `.json` spill, which buries the payload inside an escaped
|
|
14
|
+
* JSON string. Coming through `structuredContent` instead lands it as a
|
|
15
|
+
* `.txt`. Measured on eval runs 32227152533 and 32283973875: the extension
|
|
16
|
+
* follows the result shape, with no exceptions either way.
|
|
17
|
+
*
|
|
18
|
+
* The extension is not cosmetic: it changes how the payload TOKENIZES. The
|
|
19
|
+
* reader refuses any spill over 25,000 tokens, and pretty-printed JSON with
|
|
20
|
+
* escaped quotes and newlines costs 2-3x the tokens of the same bytes in raw
|
|
21
|
+
* form. Measured on one payload either way: 141.2KB of `.json` counted 72,318
|
|
22
|
+
* tokens and was refused, while the same result at 142.0KB of `.txt` was read
|
|
23
|
+
* back in full. A second: 54,085 tokens as `.json`, 26,950 as `.txt`.
|
|
24
|
+
*
|
|
25
|
+
* So the shape raises the deliverable size ceiling by roughly 3x, but does not
|
|
26
|
+
* remove it. That second payload still missed the cap by 1,950 tokens, and
|
|
27
|
+
* `skyramp_actions` results of 178-621KB are 45,598-158,997 tokens even as
|
|
28
|
+
* `.txt` — the agent then falls back to slicing the file with `head -c`. The
|
|
29
|
+
* check covers the whole file, so a `limit:` on the read cannot evade it.
|
|
30
|
+
* Payloads that big have to shrink (SKYR-4188 and its sibling); this shape is
|
|
31
|
+
* what lets a shrunk payload actually arrive.
|
|
32
|
+
*
|
|
33
|
+
* 2. Each agent CLI testbot supports reads a different channel and none falls
|
|
34
|
+
* back to the other: Claude Code takes `structuredContent` and drops
|
|
35
|
+
* `content[]`; Cursor takes `content[]` and ignores `structuredContent`;
|
|
36
|
+
* GitHub Copilot CLI surfaces both and de-duplicates them only when the text
|
|
37
|
+
* is the literal JSON serialization of `structuredContent` (MCP spec 5.2.6),
|
|
38
|
+
* otherwise it concatenates. Hence `JSON.stringify` rather than the raw text,
|
|
39
|
+
* which would deliver the payload twice there. Trimming either channel
|
|
40
|
+
* silently starves a client.
|
|
41
|
+
*
|
|
42
|
+
* Declaring the outputSchema also obliges every non-error return to come through
|
|
43
|
+
* here: the SDK rejects a successful result without `structuredContent`
|
|
44
|
+
* ("has an output schema but no structured content was provided"). Error results
|
|
45
|
+
* are exempt — that check returns early on `isError` — so they keep using
|
|
46
|
+
* `toolError`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function dualChannelResult(structuredContent: Record<string, string>): CallToolResult;
|
|
4
49
|
/**
|
|
5
50
|
* Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
|
|
6
51
|
* absolute test file path)? Exact match first, then a real path-segment boundary match
|
package/build/utils/utils.js
CHANGED
|
@@ -18,6 +18,56 @@ export function toolError(message) {
|
|
|
18
18
|
isError: true,
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a successful tool result whose payload is delivered on BOTH MCP result
|
|
23
|
+
* channels — `structuredContent` and `content[]` — carrying the identical text.
|
|
24
|
+
* The counterpart to `toolError` for any tool that returns a large text payload.
|
|
25
|
+
*
|
|
26
|
+
* Use this for every non-error return of such a tool, and declare a matching
|
|
27
|
+
* `outputSchema`. Two separate failures make that necessary:
|
|
28
|
+
*
|
|
29
|
+
* 1. A result with no `structuredContent` is persisted by the agent harness as a
|
|
30
|
+
* pretty-printed `.json` spill, which buries the payload inside an escaped
|
|
31
|
+
* JSON string. Coming through `structuredContent` instead lands it as a
|
|
32
|
+
* `.txt`. Measured on eval runs 32227152533 and 32283973875: the extension
|
|
33
|
+
* follows the result shape, with no exceptions either way.
|
|
34
|
+
*
|
|
35
|
+
* The extension is not cosmetic: it changes how the payload TOKENIZES. The
|
|
36
|
+
* reader refuses any spill over 25,000 tokens, and pretty-printed JSON with
|
|
37
|
+
* escaped quotes and newlines costs 2-3x the tokens of the same bytes in raw
|
|
38
|
+
* form. Measured on one payload either way: 141.2KB of `.json` counted 72,318
|
|
39
|
+
* tokens and was refused, while the same result at 142.0KB of `.txt` was read
|
|
40
|
+
* back in full. A second: 54,085 tokens as `.json`, 26,950 as `.txt`.
|
|
41
|
+
*
|
|
42
|
+
* So the shape raises the deliverable size ceiling by roughly 3x, but does not
|
|
43
|
+
* remove it. That second payload still missed the cap by 1,950 tokens, and
|
|
44
|
+
* `skyramp_actions` results of 178-621KB are 45,598-158,997 tokens even as
|
|
45
|
+
* `.txt` — the agent then falls back to slicing the file with `head -c`. The
|
|
46
|
+
* check covers the whole file, so a `limit:` on the read cannot evade it.
|
|
47
|
+
* Payloads that big have to shrink (SKYR-4188 and its sibling); this shape is
|
|
48
|
+
* what lets a shrunk payload actually arrive.
|
|
49
|
+
*
|
|
50
|
+
* 2. Each agent CLI testbot supports reads a different channel and none falls
|
|
51
|
+
* back to the other: Claude Code takes `structuredContent` and drops
|
|
52
|
+
* `content[]`; Cursor takes `content[]` and ignores `structuredContent`;
|
|
53
|
+
* GitHub Copilot CLI surfaces both and de-duplicates them only when the text
|
|
54
|
+
* is the literal JSON serialization of `structuredContent` (MCP spec 5.2.6),
|
|
55
|
+
* otherwise it concatenates. Hence `JSON.stringify` rather than the raw text,
|
|
56
|
+
* which would deliver the payload twice there. Trimming either channel
|
|
57
|
+
* silently starves a client.
|
|
58
|
+
*
|
|
59
|
+
* Declaring the outputSchema also obliges every non-error return to come through
|
|
60
|
+
* here: the SDK rejects a successful result without `structuredContent`
|
|
61
|
+
* ("has an output schema but no structured content was provided"). Error results
|
|
62
|
+
* are exempt — that check returns early on `isError` — so they keep using
|
|
63
|
+
* `toolError`.
|
|
64
|
+
*/
|
|
65
|
+
export function dualChannelResult(structuredContent) {
|
|
66
|
+
return {
|
|
67
|
+
structuredContent,
|
|
68
|
+
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
21
71
|
/**
|
|
22
72
|
* Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
|
|
23
73
|
* absolute test file path)? Exact match first, then a real path-segment boundary match
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const SKYRAMP_IMAGE_VERSION = "v1.3.
|
|
2
|
-
export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.
|
|
3
|
-
export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.
|
|
1
|
+
export declare const SKYRAMP_IMAGE_VERSION = "v1.3.40";
|
|
2
|
+
export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.40";
|
|
3
|
+
export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.40";
|
|
4
4
|
export declare const WORKER_CONTROL_PORT = 35142;
|
package/build/utils/versions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SKYRAMP_IMAGE_VERSION = "v1.3.
|
|
1
|
+
export const SKYRAMP_IMAGE_VERSION = "v1.3.40";
|
|
2
2
|
export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
|
|
3
3
|
export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
|
|
4
4
|
// Control port the Skyramp worker listens on (SDK `CONTAINER_PORT`).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ScopedQueryParams } from "../workspace/queryParamResolution.js";
|
|
1
2
|
/**
|
|
2
3
|
* Reads `.skyramp/workspace.yml`.
|
|
3
4
|
*
|
|
@@ -133,20 +134,6 @@ export declare function getWorkspaceAuthConfig(repositoryPath: string): Promise<
|
|
|
133
134
|
/** True when any workspace service declares api.skipTLSVerify — the SUT serves
|
|
134
135
|
* self-signed TLS and generated tests must skip cert verification (SKYR-3961). */
|
|
135
136
|
export declare function getWorkspaceSkipTLSVerify(repositoryPath: string): Promise<boolean>;
|
|
136
|
-
/**
|
|
137
|
-
* Resolve api.defaultQueryParams for test generation — query params always
|
|
138
|
-
* attached to every generated request for this service (e.g. RBAC context
|
|
139
|
-
* an authorization layer requires beyond the auth header). Same
|
|
140
|
-
* first-matching-service convention as getWorkspaceAuthConfig (SKYR-4050).
|
|
141
|
-
*
|
|
142
|
-
* Unlike readWorkspaceConfigRaw (which delegates to WorkspaceConfigManager and
|
|
143
|
-
* resolves EXACTLY `<repositoryPath>/.skyramp/workspace.yml`), this walks up
|
|
144
|
-
* from `repositoryPath` to find the nearest `.skyramp/workspace.yml` — both
|
|
145
|
-
* call sites pass a nested test-output directory (e.g. `<repo>/tests/skyramp`),
|
|
146
|
-
* not the repo root, so an exact-match lookup would silently never find the
|
|
147
|
-
* config. Mirrors the sync fs.existsSync walk-up in
|
|
148
|
-
* generateBatchScenarioRestTool.ts.
|
|
149
|
-
*/
|
|
150
137
|
/**
|
|
151
138
|
* Walk up from `startDir` to find the nearest `.skyramp/workspace.yml`, checking
|
|
152
139
|
* `startDir` itself first and continuing through every ancestor INCLUDING the
|
|
@@ -156,7 +143,20 @@ export declare function getWorkspaceSkipTLSVerify(repositoryPath: string): Promi
|
|
|
156
143
|
* `existsSync` is injectable for testing; defaults to the real filesystem.
|
|
157
144
|
*/
|
|
158
145
|
export declare function findWorkspaceConfigPath(startDir: string, existsSync?: (p: string) => boolean): string | null;
|
|
159
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Resolve api.defaultQueryParams together with api.queryParamOverrides for test
|
|
148
|
+
* generation (SKYR-4127). Same first-matching-service convention as
|
|
149
|
+
* getWorkspaceAuthConfig: the first service declaring EITHER key wins.
|
|
150
|
+
*
|
|
151
|
+
* Reads the YAML directly rather than through the Zod schema — the walk-up must
|
|
152
|
+
* stay cheap, and hand-rolled fixtures routinely omit fields the strict schema
|
|
153
|
+
* requires. normalizeOverrides therefore validates the override list itself,
|
|
154
|
+
* once per load rather than per request.
|
|
155
|
+
*
|
|
156
|
+
* Returns undefined when no config is found or no service declares either key,
|
|
157
|
+
* so callers can skip the merge entirely.
|
|
158
|
+
*/
|
|
159
|
+
export declare function getWorkspaceScopedQueryParams(repositoryPath: string): Promise<ScopedQueryParams | undefined>;
|
|
160
160
|
/**
|
|
161
161
|
* Merge workspace-declared default query params into a comma-separated
|
|
162
162
|
* "key=value,key2=value2" queryParams string (the format used by
|
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { WorkspaceConfigManager } from "../workspace/workspace.js";
|
|
5
|
+
import { normalizeBaseMap, normalizeOverrides } from "../workspace/queryParamResolution.js";
|
|
5
6
|
import { logger } from "./logger.js";
|
|
6
7
|
/**
|
|
7
8
|
* Reads `.skyramp/workspace.yml`.
|
|
@@ -363,20 +364,6 @@ export async function getWorkspaceSkipTLSVerify(repositoryPath) {
|
|
|
363
364
|
return false;
|
|
364
365
|
return (rawConfig.services ?? []).some((s) => s.api?.skipTLSVerify === true);
|
|
365
366
|
}
|
|
366
|
-
/**
|
|
367
|
-
* Resolve api.defaultQueryParams for test generation — query params always
|
|
368
|
-
* attached to every generated request for this service (e.g. RBAC context
|
|
369
|
-
* an authorization layer requires beyond the auth header). Same
|
|
370
|
-
* first-matching-service convention as getWorkspaceAuthConfig (SKYR-4050).
|
|
371
|
-
*
|
|
372
|
-
* Unlike readWorkspaceConfigRaw (which delegates to WorkspaceConfigManager and
|
|
373
|
-
* resolves EXACTLY `<repositoryPath>/.skyramp/workspace.yml`), this walks up
|
|
374
|
-
* from `repositoryPath` to find the nearest `.skyramp/workspace.yml` — both
|
|
375
|
-
* call sites pass a nested test-output directory (e.g. `<repo>/tests/skyramp`),
|
|
376
|
-
* not the repo root, so an exact-match lookup would silently never find the
|
|
377
|
-
* config. Mirrors the sync fs.existsSync walk-up in
|
|
378
|
-
* generateBatchScenarioRestTool.ts.
|
|
379
|
-
*/
|
|
380
367
|
/**
|
|
381
368
|
* Walk up from `startDir` to find the nearest `.skyramp/workspace.yml`, checking
|
|
382
369
|
* `startDir` itself first and continuing through every ancestor INCLUDING the
|
|
@@ -398,15 +385,43 @@ export function findWorkspaceConfigPath(startDir, existsSync = fs.existsSync) {
|
|
|
398
385
|
searchDir = parentDir;
|
|
399
386
|
}
|
|
400
387
|
}
|
|
401
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Resolve api.defaultQueryParams together with api.queryParamOverrides for test
|
|
390
|
+
* generation (SKYR-4127). Same first-matching-service convention as
|
|
391
|
+
* getWorkspaceAuthConfig: the first service declaring EITHER key wins.
|
|
392
|
+
*
|
|
393
|
+
* Reads the YAML directly rather than through the Zod schema — the walk-up must
|
|
394
|
+
* stay cheap, and hand-rolled fixtures routinely omit fields the strict schema
|
|
395
|
+
* requires. normalizeOverrides therefore validates the override list itself,
|
|
396
|
+
* once per load rather than per request.
|
|
397
|
+
*
|
|
398
|
+
* Returns undefined when no config is found or no service declares either key,
|
|
399
|
+
* so callers can skip the merge entirely.
|
|
400
|
+
*/
|
|
401
|
+
export async function getWorkspaceScopedQueryParams(repositoryPath) {
|
|
402
402
|
const wsConfigPath = findWorkspaceConfigPath(repositoryPath);
|
|
403
403
|
if (!wsConfigPath)
|
|
404
404
|
return undefined;
|
|
405
405
|
try {
|
|
406
406
|
const raw = fs.readFileSync(wsConfigPath, "utf-8");
|
|
407
407
|
const parsed = yaml.load(raw);
|
|
408
|
-
const
|
|
409
|
-
|
|
408
|
+
const declaring = (parsed?.services ?? []).filter((s) => s?.api?.defaultQueryParams !== undefined || s?.api?.queryParamOverrides !== undefined);
|
|
409
|
+
const svc = declaring[0];
|
|
410
|
+
if (!svc)
|
|
411
|
+
return undefined;
|
|
412
|
+
// First-wins is deliberate (same convention as getWorkspaceAuthConfig) —
|
|
413
|
+
// but silently discarding a later service's config is easy to miss, so
|
|
414
|
+
// name what's being ignored (Finding 3, SKYR-4127 final review).
|
|
415
|
+
if (declaring.length > 1) {
|
|
416
|
+
logger.warning("Multiple services declare defaultQueryParams/queryParamOverrides — using the first, ignoring the rest", {
|
|
417
|
+
used: svc.serviceName,
|
|
418
|
+
ignored: declaring.slice(1).map((s) => s.serviceName),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
base: normalizeBaseMap(svc.api.defaultQueryParams),
|
|
423
|
+
overrides: normalizeOverrides(svc.api.queryParamOverrides),
|
|
424
|
+
};
|
|
410
425
|
}
|
|
411
426
|
catch {
|
|
412
427
|
return undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface QueryParamOverride {
|
|
2
|
+
/** picomatch glob matched against the request URL pathname. OpenAPI-style
|
|
3
|
+
* `{param}` placeholders are treated as a single-segment wildcard. */
|
|
4
|
+
pathPattern: string;
|
|
5
|
+
/**
|
|
6
|
+
* Known wart (Finding 6, SKYR-4127 final review): `values: {}` is a valid,
|
|
7
|
+
* deliberately-typed empty map. If its pathPattern is the single most
|
|
8
|
+
* specific match for a request, it wins and contributes nothing — silently
|
|
9
|
+
* suppressing a broader override that would otherwise have applied. Not
|
|
10
|
+
* validated against because an intentionally no-op override (e.g. "no
|
|
11
|
+
* extra params for this path, unlike its siblings") is a legitimate use.
|
|
12
|
+
*/
|
|
13
|
+
values: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export interface ScopedQueryParams {
|
|
16
|
+
/** api.defaultQueryParams — applied to every request for the service. */
|
|
17
|
+
base: Record<string, string>;
|
|
18
|
+
/** api.queryParamOverrides — at most one applies to any given request. */
|
|
19
|
+
overrides: QueryParamOverride[];
|
|
20
|
+
}
|
|
21
|
+
/** Pathname of a full endpoint URL, or undefined if unparseable.
|
|
22
|
+
* `endpointURL` is contractually base URL + path, so the result includes any
|
|
23
|
+
* base-path prefix: `https://host/api/v1/persons/42` -> `/api/v1/persons/42`.
|
|
24
|
+
*
|
|
25
|
+
* Delegates to the shared helper so this module and the plan guard cannot
|
|
26
|
+
* disagree about what an endpointURL's path is — the shared version also
|
|
27
|
+
* restores `{param}` placeholders that `new URL()` percent-encodes, which
|
|
28
|
+
* patterns are authored with. */
|
|
29
|
+
export declare function extractPathname(endpointURL: string | undefined): string | undefined;
|
|
30
|
+
/** True when a resolved config would contribute no query params at all.
|
|
31
|
+
* Both call sites skip the merge entirely in that case, so an empty
|
|
32
|
+
* declaration leaves queryParams untouched rather than setting it to an empty
|
|
33
|
+
* value. Defined once so the two sites cannot drift on what "empty" means. */
|
|
34
|
+
export declare function contributesNothing(config: ScopedQueryParams): boolean;
|
|
35
|
+
/** Batch-scenario steps carry a bare path that may or may not be rooted. */
|
|
36
|
+
export declare function ensureLeadingSlash(p: string): string;
|
|
37
|
+
/** Rank for most-specific-wins: length of the literal prefix before the first
|
|
38
|
+
* wildcard, computed on the ORIGINAL pattern so `{id}` still counts. */
|
|
39
|
+
export declare function patternSpecificity(pathPattern: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Validate and coerce the raw `api.queryParamOverrides` value read from YAML.
|
|
42
|
+
*
|
|
43
|
+
* The read path in workspaceAuth.ts parses workspace.yml directly rather than
|
|
44
|
+
* through the Zod schema (the walk-up read must stay cheap), so nothing has
|
|
45
|
+
* validated this yet. Malformed entries are dropped with a warning instead of
|
|
46
|
+
* failing generation. Called once per load, not per request.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeOverrides(raw: unknown): QueryParamOverride[];
|
|
49
|
+
/**
|
|
50
|
+
* Validate and coerce the raw `api.defaultQueryParams` value read from YAML.
|
|
51
|
+
*
|
|
52
|
+
* Same reason `normalizeOverrides` exists: this read path deliberately bypasses
|
|
53
|
+
* the Zod schema, so nothing has checked the shape. Without this, a scalar
|
|
54
|
+
* declaration (`defaultQueryParams: just-a-string`) is spread by key and yields
|
|
55
|
+
* one query param per character — `0=j&1=u&2=s&…` sent to the server. A list
|
|
56
|
+
* behaves the same way, and a map with non-string values would reach
|
|
57
|
+
* mergeQueryParams* and be dropped there with a less specific warning.
|
|
58
|
+
*
|
|
59
|
+
* Anything that is not a plain object of string values degrades to an empty
|
|
60
|
+
* base map with a warning naming what was found.
|
|
61
|
+
*/
|
|
62
|
+
export declare function normalizeBaseMap(raw: unknown): Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* Effective query params for one request path: the base map with the single
|
|
65
|
+
* most-specific matching override layered on top.
|
|
66
|
+
*
|
|
67
|
+
* Overrides never merge with each other — at most one wins. Ranking is by
|
|
68
|
+
* literal prefix length, then total pattern length, then document order.
|
|
69
|
+
*
|
|
70
|
+
* Whenever the patterns differ in specificity the result is independent of how
|
|
71
|
+
* the YAML list is ordered. Document order is only reached when two patterns
|
|
72
|
+
* tie on BOTH earlier criteria, and reordering the list does change the winner
|
|
73
|
+
* in that case — which is why a tie emits an ambiguity warning naming both
|
|
74
|
+
* patterns rather than resolving silently.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveQueryParamsForPath(config: ScopedQueryParams, pathname: string): Record<string, string>;
|
|
77
|
+
/** Environment variables this workspace's query params defer to, in declaration
|
|
78
|
+
* order and deduplicated (SKYR-4127).
|
|
79
|
+
*
|
|
80
|
+
* These references resolve where the test RUNS, so under Testbot they resolve
|
|
81
|
+
* inside the executor container — whose environment is a fixed allowlist. A
|
|
82
|
+
* name that never reaches the container resolves to nothing, and an unresolved
|
|
83
|
+
* query param is dropped from the request silently, producing a passing test
|
|
84
|
+
* that exercised the wrong identity. The executor uses this list to forward
|
|
85
|
+
* exactly what the workspace asked for.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately derived from the declarations rather than from the ambient
|
|
88
|
+
* environment: nothing is forwarded that workspace.yml did not name.
|
|
89
|
+
*
|
|
90
|
+
* Both maps are read even though `defaultQueryParams` documents itself as
|
|
91
|
+
* literal-only — that is a doc contract the core loader does not enforce, and
|
|
92
|
+
* a declaration there would otherwise reproduce the original silent failure. */
|
|
93
|
+
export declare function extractEnvVarNames(config: ScopedQueryParams): string[];
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-scoped resolution for workspace-declared query params (SKYR-4127).
|
|
3
|
+
*
|
|
4
|
+
* SKYR-4050 shipped a single flat `api.defaultQueryParams` map applied
|
|
5
|
+
* identically to every generated request. That is too coarse for APIs whose
|
|
6
|
+
* authorization context is per-endpoint (e.g. an RBAC role validated against a
|
|
7
|
+
* different allow-list per path). `api.queryParamOverrides` layers path-scoped
|
|
8
|
+
* values on top of that base map.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately free of filesystem access so it unit-tests directly — reading
|
|
11
|
+
* workspace.yml lives in utils/workspaceAuth.ts.
|
|
12
|
+
*/
|
|
13
|
+
import picomatch from "picomatch";
|
|
14
|
+
import { logger } from "../utils/logger.js";
|
|
15
|
+
import { pathFromEndpointURL } from "../utils/urlPath.js";
|
|
16
|
+
/** Characters that terminate a pattern's literal prefix.
|
|
17
|
+
*
|
|
18
|
+
* `{` counts because an OpenAPI-style `{id}` placeholder becomes `*` before
|
|
19
|
+
* matching. `[` and `(` count because picomatch also treats character classes
|
|
20
|
+
* (`[0-9]`) and extglobs (`+(a|b)`) as wildcards — without them a pattern like
|
|
21
|
+
* `/v1/users/[0-9]*` would be ranked as though `[` were literal, scoring it
|
|
22
|
+
* more specific than it is and letting it beat a genuinely narrower pattern. */
|
|
23
|
+
const WILDCARD_CHARS = ["*", "?", "{", "[", "("];
|
|
24
|
+
/** Pathname of a full endpoint URL, or undefined if unparseable.
|
|
25
|
+
* `endpointURL` is contractually base URL + path, so the result includes any
|
|
26
|
+
* base-path prefix: `https://host/api/v1/persons/42` -> `/api/v1/persons/42`.
|
|
27
|
+
*
|
|
28
|
+
* Delegates to the shared helper so this module and the plan guard cannot
|
|
29
|
+
* disagree about what an endpointURL's path is — the shared version also
|
|
30
|
+
* restores `{param}` placeholders that `new URL()` percent-encodes, which
|
|
31
|
+
* patterns are authored with. */
|
|
32
|
+
export function extractPathname(endpointURL) {
|
|
33
|
+
return pathFromEndpointURL(endpointURL);
|
|
34
|
+
}
|
|
35
|
+
/** True when a resolved config would contribute no query params at all.
|
|
36
|
+
* Both call sites skip the merge entirely in that case, so an empty
|
|
37
|
+
* declaration leaves queryParams untouched rather than setting it to an empty
|
|
38
|
+
* value. Defined once so the two sites cannot drift on what "empty" means. */
|
|
39
|
+
export function contributesNothing(config) {
|
|
40
|
+
return Object.keys(config.base).length === 0 && config.overrides.length === 0;
|
|
41
|
+
}
|
|
42
|
+
/** Batch-scenario steps carry a bare path that may or may not be rooted. */
|
|
43
|
+
export function ensureLeadingSlash(p) {
|
|
44
|
+
return p.startsWith("/") ? p : `/${p}`;
|
|
45
|
+
}
|
|
46
|
+
/** Rank for most-specific-wins: length of the literal prefix before the first
|
|
47
|
+
* wildcard, computed on the ORIGINAL pattern so `{id}` still counts. */
|
|
48
|
+
export function patternSpecificity(pathPattern) {
|
|
49
|
+
let earliest = pathPattern.length;
|
|
50
|
+
for (const ch of WILDCARD_CHARS) {
|
|
51
|
+
const idx = pathPattern.indexOf(ch);
|
|
52
|
+
if (idx !== -1 && idx < earliest)
|
|
53
|
+
earliest = idx;
|
|
54
|
+
}
|
|
55
|
+
return earliest;
|
|
56
|
+
}
|
|
57
|
+
/** Rewrite OpenAPI-style `{param}` placeholders to a single-segment wildcard.
|
|
58
|
+
* Without this, picomatch reads `{id}` as brace alternation and matches only
|
|
59
|
+
* the literal string `/v1/persons/id`. Brace alternation is therefore not
|
|
60
|
+
* supported in patterns — an accepted trade. */
|
|
61
|
+
function toGlob(pathPattern) {
|
|
62
|
+
return pathPattern.replace(/\{[^/}]*\}/g, "*");
|
|
63
|
+
}
|
|
64
|
+
function isStringMap(v) {
|
|
65
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
66
|
+
return false;
|
|
67
|
+
return Object.values(v).every((x) => typeof x === "string");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate and coerce the raw `api.queryParamOverrides` value read from YAML.
|
|
71
|
+
*
|
|
72
|
+
* The read path in workspaceAuth.ts parses workspace.yml directly rather than
|
|
73
|
+
* through the Zod schema (the walk-up read must stay cheap), so nothing has
|
|
74
|
+
* validated this yet. Malformed entries are dropped with a warning instead of
|
|
75
|
+
* failing generation. Called once per load, not per request.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeOverrides(raw) {
|
|
78
|
+
if (!Array.isArray(raw)) {
|
|
79
|
+
if (raw !== undefined) {
|
|
80
|
+
logger.warning("Ignoring api.queryParamOverrides — expected a list", { got: typeof raw });
|
|
81
|
+
}
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const entry of raw) {
|
|
86
|
+
const e = entry;
|
|
87
|
+
if (!e || typeof e !== "object" || typeof e.pathPattern !== "string" || e.pathPattern.length === 0) {
|
|
88
|
+
logger.warning("Skipping api.queryParamOverrides entry with a missing or empty pathPattern");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!isStringMap(e.values)) {
|
|
92
|
+
logger.warning("Skipping api.queryParamOverrides entry whose values are not a string map", {
|
|
93
|
+
pathPattern: e.pathPattern,
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
result.push({ pathPattern: e.pathPattern, values: e.values });
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate and coerce the raw `api.defaultQueryParams` value read from YAML.
|
|
103
|
+
*
|
|
104
|
+
* Same reason `normalizeOverrides` exists: this read path deliberately bypasses
|
|
105
|
+
* the Zod schema, so nothing has checked the shape. Without this, a scalar
|
|
106
|
+
* declaration (`defaultQueryParams: just-a-string`) is spread by key and yields
|
|
107
|
+
* one query param per character — `0=j&1=u&2=s&…` sent to the server. A list
|
|
108
|
+
* behaves the same way, and a map with non-string values would reach
|
|
109
|
+
* mergeQueryParams* and be dropped there with a less specific warning.
|
|
110
|
+
*
|
|
111
|
+
* Anything that is not a plain object of string values degrades to an empty
|
|
112
|
+
* base map with a warning naming what was found.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeBaseMap(raw) {
|
|
115
|
+
if (raw === undefined || raw === null)
|
|
116
|
+
return {};
|
|
117
|
+
if (isStringMap(raw))
|
|
118
|
+
return raw;
|
|
119
|
+
logger.warning("Ignoring api.defaultQueryParams — expected a map of string values", { got: Array.isArray(raw) ? "array" : typeof raw });
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Effective query params for one request path: the base map with the single
|
|
124
|
+
* most-specific matching override layered on top.
|
|
125
|
+
*
|
|
126
|
+
* Overrides never merge with each other — at most one wins. Ranking is by
|
|
127
|
+
* literal prefix length, then total pattern length, then document order.
|
|
128
|
+
*
|
|
129
|
+
* Whenever the patterns differ in specificity the result is independent of how
|
|
130
|
+
* the YAML list is ordered. Document order is only reached when two patterns
|
|
131
|
+
* tie on BOTH earlier criteria, and reordering the list does change the winner
|
|
132
|
+
* in that case — which is why a tie emits an ambiguity warning naming both
|
|
133
|
+
* patterns rather than resolving silently.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveQueryParamsForPath(config, pathname) {
|
|
136
|
+
const matches = config.overrides
|
|
137
|
+
.map((override, index) => ({ override, index }))
|
|
138
|
+
.filter(({ override }) => picomatch.isMatch(pathname, toGlob(override.pathPattern)));
|
|
139
|
+
if (matches.length === 0)
|
|
140
|
+
return { ...config.base };
|
|
141
|
+
matches.sort((a, b) => {
|
|
142
|
+
const specDiff = patternSpecificity(b.override.pathPattern) - patternSpecificity(a.override.pathPattern);
|
|
143
|
+
if (specDiff !== 0)
|
|
144
|
+
return specDiff;
|
|
145
|
+
// Known limitation (Finding 5, SKYR-4127 final review): at equal literal-
|
|
146
|
+
// prefix length, this tiebreak prefers the LONGER total pattern — which
|
|
147
|
+
// is not always the narrower one. E.g. "/v1/persons/**" (14 chars) beats
|
|
148
|
+
// "/v1/persons/*" (13 chars) even though "**" matches strictly more paths
|
|
149
|
+
// than "*". Length is a proxy for specificity, not a guarantee of it; not
|
|
150
|
+
// fixed here since it would change ranking behavior for existing configs.
|
|
151
|
+
const lenDiff = b.override.pathPattern.length - a.override.pathPattern.length;
|
|
152
|
+
if (lenDiff !== 0)
|
|
153
|
+
return lenDiff;
|
|
154
|
+
return a.index - b.index;
|
|
155
|
+
});
|
|
156
|
+
const [winner, runnerUp] = matches;
|
|
157
|
+
if (runnerUp &&
|
|
158
|
+
patternSpecificity(winner.override.pathPattern) === patternSpecificity(runnerUp.override.pathPattern) &&
|
|
159
|
+
winner.override.pathPattern.length === runnerUp.override.pathPattern.length) {
|
|
160
|
+
logger.warning("Ambiguous api.queryParamOverrides — two equally specific patterns match this path; using the first", {
|
|
161
|
+
pathname,
|
|
162
|
+
patterns: [winner.override.pathPattern, runnerUp.override.pathPattern],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { ...config.base, ...winner.override.values };
|
|
166
|
+
}
|
|
167
|
+
/** `env.VAR` / legacy `envs.VAR`, anchored so a literal that merely contains
|
|
168
|
+
* the prefix (`prod.env.HOST`) is not mistaken for a reference, and requiring
|
|
169
|
+
* at least one character of variable name. Case-sensitive, matching the
|
|
170
|
+
* schema's documented behavior: `ENV.VAR` is an ordinary literal. */
|
|
171
|
+
const ENV_REFERENCE = /^envs?\.(.+)$/;
|
|
172
|
+
/** Environment variables this workspace's query params defer to, in declaration
|
|
173
|
+
* order and deduplicated (SKYR-4127).
|
|
174
|
+
*
|
|
175
|
+
* These references resolve where the test RUNS, so under Testbot they resolve
|
|
176
|
+
* inside the executor container — whose environment is a fixed allowlist. A
|
|
177
|
+
* name that never reaches the container resolves to nothing, and an unresolved
|
|
178
|
+
* query param is dropped from the request silently, producing a passing test
|
|
179
|
+
* that exercised the wrong identity. The executor uses this list to forward
|
|
180
|
+
* exactly what the workspace asked for.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately derived from the declarations rather than from the ambient
|
|
183
|
+
* environment: nothing is forwarded that workspace.yml did not name.
|
|
184
|
+
*
|
|
185
|
+
* Both maps are read even though `defaultQueryParams` documents itself as
|
|
186
|
+
* literal-only — that is a doc contract the core loader does not enforce, and
|
|
187
|
+
* a declaration there would otherwise reproduce the original silent failure. */
|
|
188
|
+
export function extractEnvVarNames(config) {
|
|
189
|
+
const names = new Set();
|
|
190
|
+
const collect = (values) => {
|
|
191
|
+
for (const value of Object.values(values)) {
|
|
192
|
+
const match = typeof value === "string" ? ENV_REFERENCE.exec(value) : null;
|
|
193
|
+
if (match)
|
|
194
|
+
names.add(match[1]);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
collect(config.base);
|
|
198
|
+
for (const override of config.overrides)
|
|
199
|
+
collect(override.values);
|
|
200
|
+
return [...names];
|
|
201
|
+
}
|