@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.
Files changed (55) hide show
  1. package/build/playwright/registerPlaywrightTools.js +42 -1
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
  3. package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
  5. package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
  6. package/build/prompts/test-recommendation/recommendationSections.js +5 -5
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
  8. package/build/prompts/testbot/testbot-prompts.js +6 -13
  9. package/build/recommendation/discriminators.d.ts +7 -1
  10. package/build/recommendation/discriminators.js +16 -3
  11. package/build/resources/testbotResource.js +0 -1
  12. package/build/services/ScenarioGenerationService.js +5 -2
  13. package/build/services/TestExecutionService.js +25 -1
  14. package/build/services/TestGenerationService.js +24 -9
  15. package/build/services/containerEnv.d.ts +12 -1
  16. package/build/services/containerEnv.js +94 -1
  17. package/build/tools/executeSkyrampTestTool.d.ts +9 -0
  18. package/build/tools/executeSkyrampTestTool.js +20 -6
  19. package/build/tools/execution-video-state.d.ts +21 -0
  20. package/build/tools/execution-video-state.js +51 -0
  21. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
  22. package/build/tools/generate-tests/planGuard.d.ts +5 -5
  23. package/build/tools/generate-tests/planGuard.js +5 -17
  24. package/build/tools/submitReportTool.d.ts +83 -10
  25. package/build/tools/submitReportTool.js +169 -28
  26. package/build/tools/test-management/actionsTool.js +40 -39
  27. package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
  28. package/build/tools/test-management/analyzeChangesTool.js +37 -33
  29. package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
  30. package/build/tools/test-management/registerTestPlanTool.js +113 -31
  31. package/build/types/TestExecution.d.ts +14 -0
  32. package/build/types/TestTypes.js +3 -2
  33. package/build/types/TestbotPromptOptions.d.ts +0 -1
  34. package/build/types/TestbotReport.d.ts +10 -0
  35. package/build/types/TestbotReport.js +10 -1
  36. package/build/types/index.d.ts +1 -0
  37. package/build/types/index.js +1 -0
  38. package/build/utils/AnalysisStateManager.d.ts +36 -2
  39. package/build/utils/AnalysisStateManager.js +34 -13
  40. package/build/utils/scenarioDrafting.js +7 -1
  41. package/build/utils/skyrampMdContent.d.ts +1 -1
  42. package/build/utils/skyrampMdContent.js +1 -1
  43. package/build/utils/urlPath.d.ts +37 -0
  44. package/build/utils/urlPath.js +55 -0
  45. package/build/utils/utils.d.ts +45 -0
  46. package/build/utils/utils.js +50 -0
  47. package/build/utils/versions.d.ts +3 -3
  48. package/build/utils/versions.js +1 -1
  49. package/build/utils/workspaceAuth.d.ts +15 -15
  50. package/build/utils/workspaceAuth.js +32 -17
  51. package/build/workspace/queryParamResolution.d.ts +93 -0
  52. package/build/workspace/queryParamResolution.js +201 -0
  53. package/build/workspace/workspace.d.ts +104 -0
  54. package/build/workspace/workspace.js +24 -0
  55. package/package.json +3 -2
@@ -4,11 +4,37 @@
4
4
  */
5
5
  import { z } from "zod";
6
6
  import { logger } from "../utils/logger.js";
7
+ import { dualChannelResult } from "../utils/utils.js";
7
8
  import { PlaywrightTraceService, } from "./PlaywrightTraceService.js";
8
9
  let _service = null;
9
10
  export function getPlaywrightTraceService() {
10
11
  return _service;
11
12
  }
13
+ /**
14
+ * The one browser tool whose result is re-shaped on the way out — see
15
+ * toStructuredBlueprintResult.
16
+ */
17
+ const BLUEPRINT_TOOL = "browser_blueprint";
18
+ /**
19
+ * Re-shape a browser_blueprint result onto both MCP result channels.
20
+ *
21
+ * SKYR-4194. The browser tools are registered here as passthroughs, and the fork
22
+ * returns the captured DOM as one `content[]` text block with no
23
+ * `structuredContent` — the shape whose spill the agent's file reader refuses.
24
+ * See `dualChannelResult` for why both channels carry the payload.
25
+ *
26
+ * An error result is returned untouched: the SDK's outputSchema check returns
27
+ * early on `isError`, so it needs no structuredContent.
28
+ */
29
+ function toStructuredBlueprintResult(result) {
30
+ if (result?.isError)
31
+ return result;
32
+ const blueprint = (result?.content ?? [])
33
+ .filter((block) => block?.type === "text")
34
+ .map((block) => block.text)
35
+ .join("\n");
36
+ return dualChannelResult({ blueprint });
37
+ }
12
38
  export async function registerPlaywrightTools(server, options) {
13
39
  _service = new PlaywrightTraceService(options);
14
40
  try {
@@ -68,9 +94,23 @@ export async function registerPlaywrightTools(server, options) {
68
94
  logger.info(`Filtering to ${filteredTools.length} essential tools (from ${tools.length} total)`);
69
95
  for (const tool of filteredTools) {
70
96
  const zodSchema = jsonSchemaToZod(tool.inputSchema);
97
+ const isBlueprint = tool.name === BLUEPRINT_TOOL;
71
98
  server.registerTool(tool.name, {
72
99
  description: tool.description || `Playwright tool: ${tool.name}`,
73
100
  inputSchema: zodSchema,
101
+ // SKYR-4194: only browser_blueprint returns its payload via
102
+ // structuredContent, which requires a declared outputSchema. The other
103
+ // browser tools return small results, and some return images, which this
104
+ // text-only re-shaping would drop.
105
+ ...(isBlueprint
106
+ ? {
107
+ outputSchema: {
108
+ blueprint: z
109
+ .string()
110
+ .describe("The captured PageBlueprint — sections, elements, and their logical names. Read and follow this field."),
111
+ },
112
+ }
113
+ : {}),
74
114
  }, async (params) => {
75
115
  if (!_service?.isInitialized()) {
76
116
  return {
@@ -83,7 +123,8 @@ export async function registerPlaywrightTools(server, options) {
83
123
  isError: true,
84
124
  };
85
125
  }
86
- return _service.callTool(tool.name, params);
126
+ const result = await _service.callTool(tool.name, params);
127
+ return isBlueprint ? toStructuredBlueprintResult(result) : result;
87
128
  });
88
129
  }
89
130
  logger.info(`Registered ${filteredTools.length} Playwright tools: ${filteredTools.map((t) => t.name).join(", ")}`);
@@ -154,6 +154,25 @@ expect(getResponseValue(productsPostResponse, "created_at")).toMatch(/^\\d{4}-\\
154
154
  },
155
155
  ],
156
156
  },
157
+ {
158
+ title: "Assert a nullable field is present, not merely null",
159
+ description: "Reading a field's value cannot tell a null field from a missing one — `get_response_value` / `getValue` return the same result for both, so `is None` does not prove the field exists. Use a schema check with an example body instead.",
160
+ subPoints: [
161
+ "An example carrying `null` for the field asserts presence and nullness together, and fails when the field is absent.",
162
+ "An example carrying a concrete value only asserts that value's type, so a nullable field checked against a string example fails once the real value is null.",
163
+ "`check_schema` / `checkSchema` takes the RESPONSE OBJECT, not a parsed body. The `@skyramp/skyramp` type declaration says otherwise and is wrong — passing a plain object makes every check silently return false (SKYR-4189).",
164
+ ],
165
+ examples: [
166
+ {
167
+ language: "python",
168
+ code: `assert skyramp.check_schema(order_response, '{"cancelled_at": null}')`,
169
+ },
170
+ {
171
+ language: "javascript",
172
+ code: `expect(checkSchema(orderResponse, '{"cancelled_at": null}')).toBe(true);`,
173
+ },
174
+ ],
175
+ },
157
176
  ];
158
177
  export function renderRule(index, rule) {
159
178
  const subPoints = rule.subPoints && rule.subPoints.length > 0
@@ -15,7 +15,7 @@ export function buildFileRenameStrategy() {
15
15
  }
16
16
  /** Strategy string for in-place UPDATE edits. */
17
17
  export function buildUpdateStrategy() {
18
- return `Apply each file in update_context yourself with the Edit tool, using its current_content and the changes described in context. Preserve all existing test logic — only add or adjust what context describes.
18
+ return `Apply each file in update_context yourself with the Edit tool. Read the file first, then make the changes described in context. Preserve all existing test logic — only add or adjust what context describes.
19
19
 
20
20
  When a file provides edit_sites, apply each site directly using its anchor as the Edit target — do not re-grep or re-page the file to relocate the sites; they were already found for you.
21
21
 
@@ -25,7 +25,7 @@ After editing, re-read each file to confirm the change landed, then call skyramp
25
25
  }
26
26
  /** Strategy string for REGENERATE — call generation tool to overwrite the file. */
27
27
  export function buildRegenerateStrategy() {
28
- return `For each file in regenerate_context, call the appropriate generation tool (skyramp_integration_test_generation or skyramp_contract_test_generation) with outputDir set to the file's directory and output set to the filename. Use existing_content to determine the test type, endpoint, auth pattern, and language. The generation tool will overwrite the file. Do NOT use skyramp_ui_test_generation here — UI test regeneration requires a recorded trace (playwrightInput) and must be handled separately.`;
28
+ return `For each file in regenerate_context, call the appropriate generation tool (skyramp_integration_test_generation or skyramp_contract_test_generation) with outputDir set to the file's directory and output set to the filename. The generation tool will overwrite the file. Do NOT use skyramp_ui_test_generation here — UI test regeneration requires a recorded trace (playwrightInput) and must be handled separately.`;
29
29
  }
30
30
  /** Strategy string for DELETE — remove obsolete test files whose covered endpoints/UI no longer exist. */
31
31
  export function buildDeleteStrategy() {
@@ -347,10 +347,7 @@ Static endpoint scan results are **best-effort hints only**. Do not assume the s
347
347
  ${staticHintCount > 0 ? `Static hints available: ${staticHintCount}. Verify every hinted method/path against source before using it.` : "Static hints available: 0. Build the endpoint list from source, router context, spec, and diff."}
348
348
 
349
349
  ${p.candidateRouteFiles && p.candidateRouteFiles.length > 0
350
- ? `Candidate files to inspect:\n${p.candidateRouteFiles
351
- .slice(0, 15)
352
- .map((f) => `- \`${f}\``)
353
- .join("\n")}${p.candidateRouteFiles.length > 15 ? `\n_(${p.candidateRouteFiles.length - 15} more files not shown)_` : ""}`
350
+ ? ""
354
351
  : staticHintCount > 0
355
352
  ? "Candidate files to inspect: none identified by static scanning. Verify the static hints against their source files, changed files, router context, and specs above."
356
353
  : "Candidate files to inspect: use the changed files and routing entry-point files above."}
@@ -32,4 +32,4 @@ export declare function buildTestQualityCriteria(): string;
32
32
  export declare function buildGenerationRules(isUIOnlyPR: boolean): string;
33
33
  export declare function buildVerificationChecklist(topN: number, maxGen: number, codeReviewStepLabel?: string): string;
34
34
  export declare function buildFewShotExamples(): string;
35
- export declare function buildToolWorkflows(authHeaderValue: string, authTypeValue?: string, explicitScheme?: string): string;
35
+ export declare function buildToolWorkflows(authHeaderValue: string, authTypeValue?: string, explicitScheme?: string, enrichStepLabel?: string): string;
@@ -1,6 +1,6 @@
1
1
  import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
2
2
  import { resolveServiceDetailsRef } from "../../utils/utils.js";
3
- import { WorkspaceAuthType, getAuthScheme, isAuthorizationHeaderName, AUTH_MIDDLEWARE_PATTERNS_STR } from "../../utils/workspaceAuth.js";
3
+ import { WorkspaceAuthType, getAuthScheme, isAuthorizationHeaderName } from "../../utils/workspaceAuth.js";
4
4
  // Cached at module-load — flags are process-wide and cannot change per call.
5
5
  const CONSUMER_MODE_ENABLED = isContractConsumerModeEnabled();
6
6
  const SERVICE_REFS = resolveServiceDetailsRef();
@@ -243,7 +243,7 @@ Reasoning: Catches a missing 404 guard on DELETE — verifies the handler return
243
243
  </example>
244
244
  </examples>`;
245
245
  }
246
- export function buildToolWorkflows(authHeaderValue, authTypeValue = "", explicitScheme) {
246
+ export function buildToolWorkflows(authHeaderValue, authTypeValue = "", explicitScheme, enrichStepLabel = "2") {
247
247
  const isAuthorizationHeader = isAuthorizationHeaderName(authHeaderValue);
248
248
  const noAuth = !authHeaderValue;
249
249
  let authGuidance;
@@ -251,7 +251,7 @@ export function buildToolWorkflows(authHeaderValue, authTypeValue = "", explicit
251
251
  if (noAuth) {
252
252
  authGuidance = `**Auth Verification Required:** The workspace config indicates no authentication, but you MUST verify this independently before omitting auth:
253
253
  1. **OpenAPI spec** \u2192 check \`securitySchemes\` / \`securityDefinitions\` for \`type: http\`, \`type: apiKey\`, or \`type: oauth2\`
254
- 2. **Source code** \u2192 look for known auth signals (${AUTH_MIDDLEWARE_PATTERNS_STR}).
254
+ 2. **Source code** \u2192 look for the auth middleware signals listed in Step ${enrichStepLabel} of the Execution Plan.
255
255
  3. **Route definitions** \u2192 check if routes have auth decorators or middleware applied
256
256
  4. **Still unknown** \u2192 proceed with \`authHeader: ""\` and note "auth pattern unrecognized" in your recommendation description.
257
257
  If you find auth requirements, pass the appropriate \`authHeader\` and \`authScheme\` to EVERY tool call. Only pass \`authHeader: ""\` if you confirm the API is truly unauthenticated.
@@ -270,7 +270,7 @@ If you find auth requirements, pass the appropriate \`authHeader\` and \`authSch
270
270
  authGuidance = `**Auth Scheme:** The workspace \`api.authType\` is \`"${authTypeValue}"\`.
271
271
  **Where to find the scheme** (check in order):
272
272
  1. **OpenAPI spec** \u2192 look at \`securitySchemes\` / \`securityDefinitions\` for \`type: http, scheme: bearer\` or \`type: apiKey\`
273
- 2. **Source code** \u2192 auth middleware signals: ${AUTH_MIDDLEWARE_PATTERNS_STR}
273
+ 2. **Source code** \u2192 the auth middleware signals listed in Step ${enrichStepLabel} of the Execution Plan
274
274
  3. **Workspace config** \u2192 use \`api.authType\` value as the scheme if source is inconclusive
275
275
  Pass the prefix as \`authScheme\` (e.g., \`"Bearer"\`, \`"Token"\`, \`"Basic"\`). If the API uses raw tokens with no prefix, pass \`authScheme: ""\`.
276
276
  **Do NOT guess the scheme.**
@@ -280,7 +280,7 @@ To skip auth entirely, pass \`authHeader: ""\`.`;
280
280
  authGuidance = `**Auth Scheme:** No \`api.authType\` in workspace config.
281
281
  **Where to find the scheme** (check in order):
282
282
  1. **OpenAPI spec** \u2192 look at \`securitySchemes\` / \`securityDefinitions\` for \`type: http, scheme: bearer\` or \`type: apiKey\`
283
- 2. **Source code** \u2192 auth middleware signals: ${AUTH_MIDDLEWARE_PATTERNS_STR}
283
+ 2. **Source code** \u2192 the auth middleware signals listed in Step ${enrichStepLabel} of the Execution Plan
284
284
  3. **Fallback** \u2192 use \`"Bearer"\` only if the project clearly uses JWT or OAuth; otherwise pass \`authScheme: ""\`
285
285
  Pass the prefix as \`authScheme\` (e.g., \`"Bearer"\`, \`"Token"\`, \`"Basic"\`). If the API uses raw tokens with no prefix, pass \`authScheme: ""\`.
286
286
  **Do NOT guess the scheme.**
@@ -5,7 +5,7 @@ import { logger } from "../../utils/logger.js";
5
5
  import { buildArchitectPreamble, buildContextFetchingGuidance, buildReasoningProtocol, buildToolWorkflows, buildFewShotExamples, buildVerificationChecklist, getAuthSnippets, MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, } from "./recommendationSections.js";
6
6
  import { CATEGORY_PRIORITY, Novelty, PriorityTier } from "../../types/TestRecommendation.js";
7
7
  import { buildScopeAssessmentSection, isFrontendFile } from "./scopeAssessment.js";
8
- import { buildExecutionPlan, EXEC_STEP_CODE_REVIEW } from "./diffExecutionPlan.js";
8
+ import { buildExecutionPlan, EXEC_STEP_CODE_REVIEW, EXEC_STEP_ENRICH } from "./diffExecutionPlan.js";
9
9
  import { buildFullRepoRecommendations } from "./fullRepoCatalog.js";
10
10
  import { ANALYSIS_STEP_EXTRACT } from "./analysisOutputPrompt.js";
11
11
  import { TASK_GENERATE, buildExternalCoverageSet, externalDedupKey, isAttackSurfaceSecurityBoundary, taskRef, } from "./recommendationShared.js";
@@ -345,8 +345,15 @@ Treat the endpoint lists above as static hints. If source/diff inspection finds
345
345
  diffEndpointKeys.add(`${m.method} ${ep.path}`);
346
346
  }
347
347
  }
348
- const summaryLines = allEndpoints
349
- .flatMap((ep) => (ep.methods ?? []).flatMap((m) => (m.interactions ?? []).map((i) => ` ${m.method} ${ep.path} ${i.response.statusCode} (${i.type}): ${i.description}`)))
348
+ // SKYR-4188: `description` held exactly "<METHOD> <path>" in 11,273 of 11,273
349
+ // interaction lines on eval run 32227152533 a copy of the start of its own
350
+ // line. Emit it only when it says something the line does not already say.
351
+ const interactionLabel = (i, method, path) => i.description && i.description !== `${method} ${path}` ? `: ${i.description}` : "";
352
+ // detailBlocks repeats every summary line and adds the bodies. The two cover
353
+ // the same endpoints unless the scope is a diff, so outside a diff the summary
354
+ // is pure repetition.
355
+ const summaryLines = !isDiffScope ? "" : allEndpoints
356
+ .flatMap((ep) => (ep.methods ?? []).flatMap((m) => (m.interactions ?? []).map((i) => ` ${m.method} ${ep.path} → ${i.response.statusCode} (${i.type})${interactionLabel(i, m.method, ep.path)}`)))
350
357
  .join("\n");
351
358
  const detailEndpoints = isDiffScope
352
359
  ? allEndpoints.filter((ep) => (ep.methods ?? []).some((m) => diffEndpointKeys.has(`${m.method} ${ep.path}`)))
@@ -357,13 +364,12 @@ Treat the endpoint lists above as static hints. If source/diff inspection finds
357
364
  const qParams = i.request.queryParams ? `\n queryParams: ${JSON.stringify(i.request.queryParams)}` : "";
358
365
  const resBody = i.response.body ? `\n responseBody: ${JSON.stringify(i.response.body)}` : "";
359
366
  const headers = i.request.headers ? `\n headers: ${JSON.stringify(i.request.headers)}` : "";
360
- return ` ${m.method} ${ep.path} → ${i.response.statusCode} (${i.type}): ${i.description}${reqBody}${qParams}${resBody}${headers}`;
367
+ return ` ${m.method} ${ep.path} → ${i.response.statusCode} (${i.type})${interactionLabel(i, m.method, ep.path)}${reqBody}${qParams}${resBody}${headers}`;
361
368
  })))
362
369
  .join("\n");
363
370
  interactionSection = `
364
371
  ## Endpoint Interactions
365
- ${summaryLines}
366
-
372
+ ${summaryLines ? `${summaryLines}\n` : ""}
367
373
  ### Detailed (request/response bodies)
368
374
  ${isDiffScope ? "Changed endpoints only. " : ""}Use source code schemas (Zod/Pydantic/DTOs) for actual request bodies and query parameters.
369
375
  ${detailBlocks}
@@ -557,7 +563,7 @@ ${isDiffScope
557
563
  5. \`skyramp_ui_test_generation({ playwrightInput: "<absolute_path_to_zip>" })\`
558
564
 
559
565
  **For E2E tests:** Same browser recording flow, then call \`skyramp_e2e_test_generation\` with the zip.`
560
- : buildToolWorkflows(authHeaderValue, authTypeValue, workspaceAuthScheme))
566
+ : buildToolWorkflows(authHeaderValue, authTypeValue, workspaceAuthScheme, EXEC_STEP_ENRICH))
561
567
  : ""}
562
568
 
563
569
  ${mainSection}
@@ -55,7 +55,7 @@ export function parseRelatedRepositories(raw) {
55
55
  }
56
56
  }
57
57
  export function getTestbotPrompt(opts) {
58
- const { prTitle, prDescription, summaryOutputFile, repositoryPath, baseBranch, maxRecommendations = MAX_RECOMMENDATIONS, prNumber, userPrompt, services, uiCredentials, testsRepoDir, relatedRepositories, primaryRepo, planOnly = false, language, } = opts;
58
+ const { prTitle, prDescription, repositoryPath, baseBranch, maxRecommendations = MAX_RECOMMENDATIONS, prNumber, userPrompt, services, uiCredentials, testsRepoDir, relatedRepositories, primaryRepo, planOnly = false, language, } = opts;
59
59
  // maxCritical is intentionally unused (reserved) — see TestbotPromptOptions.
60
60
  const maxGenerate = Math.min(Math.max(opts.maxGenerate ?? MAX_TESTS_TO_GENERATE, 0), maxRecommendations);
61
61
  // TODO(SKYR-3636 follow-up): migrate Task 1 + Task 2 step bodies to PromptPlan
@@ -643,7 +643,7 @@ In these cases:
643
643
 
644
644
  ${task3CountRule}
645
645
 
646
- ${reportLanguageBlock}Call \`skyramp_submit_report\` with \`summaryOutputFile\`: "${summaryOutputFile}" and \`stateFile\` (from \`skyramp_analyze_changes\` output) — the stateFile is required for execution outcome tracking. Field names, types, and formats are defined in the tool's parameter schema — follow them exactly.
646
+ ${reportLanguageBlock}Call \`skyramp_submit_report\` with \`stateFile\` (from \`skyramp_analyze_changes\` output) — the stateFile is required for execution outcome tracking, and the report is written beside it. Field names, types, and formats are defined in the tool's parameter schema — follow them exactly.
647
647
 
648
648
  ${hasRelatedRepos ? `
649
649
  - **MULTI-REPO attribution**: Set the \`repository\` field (\`owner/repo\`) on EVERY \`newTestsCreated\`, \`testResults\`, \`issuesFound\`, and \`additionalRecommendations\` item — including items about the PRIMARY repo — so each finding is unambiguously attributed. The primary repo's \`repository\` is \`${primaryRepo || "<the primary repo's owner/repo>"}\`; items derived from a related repo's diff (from the \`<related_repositories>\` analysis) carry that repo's \`repository\` value. In \`businessCaseAnalysis\`, include a short per-repo subsection and call out any cross-repo correlations you found.
@@ -652,13 +652,10 @@ ${hasRelatedRepos ? `
652
652
  - For \`testType: "contract"\` entries: **\`primaryEndpoint\` is required** (e.g. \`"GET /api/v1/users/{user_id}"\`). The tool will reject the submission without it — do not omit it or you will be forced to resubmit.
653
653
 
654
654
  ${getTraceRecordingPromptText({ outputDir: `${repositoryPath}/.skyramp`, modularize: false })}`;
655
- // TODO: merge stateFile and summaryOutputFile into a single file (follow-up PR).
656
- // Currently the stateFile path is derived server-side from RUNNER_TEMP (GitHub Actions only);
657
- // other CI systems (Jenkins, GitLab, Buildkite) require the caller to set RUNNER_TEMP.
658
- // Once merged, the path flows through summaryOutputFile already testbot-controlled and
659
- // CI-agnostic — eliminating the RUNNER_TEMP dependency and the LLM confusion that motivated
660
- // removing stateOutputFile from the prompt schema. Remove the RUNNER_TEMP branch in
661
- // AnalysisStateManager.ts when this is done.
655
+ // Neither path reaches the agent any more: SKYR-4147 made the report derive from the
656
+ // state file's directory, and that directory comes from the environment via
657
+ // runArtifactDir(). Keep it that way a path the model retypes out of this prose is a
658
+ // path the model can corrupt, which is the defect SKYR-4147 fixed.
662
659
  }
663
660
  export function buildWorkspaceRecoveryPrefix(repositoryPath) {
664
661
  return `IMPORTANT: The existing .skyramp/workspace.yml failed to parse or validate. Before proceeding with any tasks below, you MUST call skyramp_init_scan with workspacePath "${repositoryPath}" and force: true, then call skyramp_init_workspace with workspacePath "${repositoryPath}", the discovered services, scanToken, and force: true to regenerate the workspace file.\n\n`;
@@ -670,9 +667,6 @@ export function registerTestbotPrompt(server) {
670
667
  argsSchema: {
671
668
  prTitle: z.string().describe("Pull request title"),
672
669
  prDescription: z.string().describe("Pull request description/body"),
673
- summaryOutputFile: z
674
- .string()
675
- .describe("File path where the agent should write the testbot summary report"),
676
670
  repositoryPath: z
677
671
  .string()
678
672
  .default(".")
@@ -737,7 +731,6 @@ export function registerTestbotPrompt(server) {
737
731
  let prompt = getTestbotPrompt({
738
732
  prTitle: args.prTitle,
739
733
  prDescription: args.prDescription,
740
- summaryOutputFile: args.summaryOutputFile,
741
734
  repositoryPath: args.repositoryPath,
742
735
  baseBranch: args.baseBranch,
743
736
  maxRecommendations: args.maxRecommendations,
@@ -27,5 +27,11 @@ export interface DiscriminatorValidation {
27
27
  * 1. `changedCodeAnchor` must be a non-trivial string (>= 8 chars after trim)
28
28
  * occurring verbatim in `diffText` — grounds the claim in the real change.
29
29
  * 2. The `kind`-specific structural predicate must hold over `steps[]`.
30
+ *
31
+ * `reposWithoutDiff` names repos in the run whose section carries no branch
32
+ * diff (e.g. a related repo that fell back to its default branch, SKYR-4203).
33
+ * An anchor those repos' changes could contain cannot be disproven, so an
34
+ * unmatched anchor then fails with a "could not be checked" reason instead of
35
+ * the "not grounded" one — still unverified (no boost), but distinguishable.
30
36
  */
31
- export declare function validateDiscriminator(scenario: DraftedScenario, declared: DiscriminatorClaim, diffText: string): DiscriminatorValidation;
37
+ export declare function validateDiscriminator(scenario: DraftedScenario, declared: DiscriminatorClaim, diffText: string, reposWithoutDiff?: string[]): DiscriminatorValidation;
@@ -14,10 +14,16 @@ const MIN_NEAR_MISS_LENGTH = 3;
14
14
  * 1. `changedCodeAnchor` must be a non-trivial string (>= 8 chars after trim)
15
15
  * occurring verbatim in `diffText` — grounds the claim in the real change.
16
16
  * 2. The `kind`-specific structural predicate must hold over `steps[]`.
17
+ *
18
+ * `reposWithoutDiff` names repos in the run whose section carries no branch
19
+ * diff (e.g. a related repo that fell back to its default branch, SKYR-4203).
20
+ * An anchor those repos' changes could contain cannot be disproven, so an
21
+ * unmatched anchor then fails with a "could not be checked" reason instead of
22
+ * the "not grounded" one — still unverified (no boost), but distinguishable.
17
23
  */
18
- export function validateDiscriminator(scenario, declared, diffText) {
24
+ export function validateDiscriminator(scenario, declared, diffText, reposWithoutDiff = []) {
19
25
  try {
20
- const anchorResult = verifyAnchor(declared?.changedCodeAnchor, diffText);
26
+ const anchorResult = verifyAnchor(declared?.changedCodeAnchor, diffText, reposWithoutDiff);
21
27
  if (!anchorResult.verified)
22
28
  return anchorResult;
23
29
  const steps = Array.isArray(scenario?.steps) ? scenario.steps : [];
@@ -43,7 +49,7 @@ export function validateDiscriminator(scenario, declared, diffText) {
43
49
  }
44
50
  }
45
51
  // ── Anchor grounding ──
46
- function verifyAnchor(rawAnchor, diffText) {
52
+ function verifyAnchor(rawAnchor, diffText, reposWithoutDiff = []) {
47
53
  const anchor = typeof rawAnchor === "string" ? rawAnchor.trim() : "";
48
54
  if (anchor.length < MIN_ANCHOR_LENGTH) {
49
55
  return {
@@ -57,6 +63,13 @@ function verifyAnchor(rawAnchor, diffText) {
57
63
  // text — compare whitespace-collapsed forms with diff markers stripped, or
58
64
  // legitimate multi-line anchors are systematically false-demoted.
59
65
  if (!diff.includes(anchor) && !normalizeForAnchorMatch(diff).includes(normalizeForAnchorMatch(anchor))) {
66
+ if (reposWithoutDiff.length > 0) {
67
+ return {
68
+ verified: false,
69
+ reason: `changedCodeAnchor "${anchor}" was not found in the available PR diffs, and no branch diff exists for ` +
70
+ `${reposWithoutDiff.join(", ")} (likely checked out on the default branch), so the claim could not be checked against those changes.`,
71
+ };
72
+ }
60
73
  return {
61
74
  verified: false,
62
75
  reason: `changedCodeAnchor "${anchor}" does not occur verbatim in the PR diff, so the discriminator claim is not grounded in the actual change.`,
@@ -29,7 +29,6 @@ export function registerTestbotResource(server) {
29
29
  const prompt = getTestbotPrompt({
30
30
  prTitle: param("prTitle", ""),
31
31
  prDescription: param("prDescription", ""),
32
- summaryOutputFile: param("summaryOutputFile", ""),
33
32
  repositoryPath,
34
33
  baseBranch: uri.searchParams.get("baseBranch") || undefined,
35
34
  maxRecommendations: isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec,
@@ -2,6 +2,7 @@ import { AUTH_PLACEHOLDER_TOKEN } from "../types/TestTypes.js";
2
2
  import { isAuthorizationHeaderName } from "../utils/workspaceAuth.js";
3
3
  import { inferExpectedStatus } from "../utils/httpDefaults.js";
4
4
  import { logger } from "../utils/logger.js";
5
+ import { deriveBasePath } from "../utils/urlPath.js";
5
6
  // Keys that trigger built-in prototype setters when used as bracket-notation
6
7
  // property names on a plain object — guard against prototype pollution from
7
8
  // LLM-controlled or user-controlled JSON input.
@@ -22,7 +23,10 @@ export class ScenarioGenerationService {
22
23
  let destination = params.destination;
23
24
  let scheme = "https";
24
25
  let port = 443;
25
- let basePath = "";
26
+ // Shared with generateBatchScenarioRestTool, which must predict this exact
27
+ // prefix to match workspace queryParamOverrides against the path this
28
+ // method will emit below (SKYR-4127). Keep it derived in one place.
29
+ const basePath = deriveBasePath(params.baseURL);
26
30
  if (params.baseURL) {
27
31
  try {
28
32
  const parsed = new URL(params.baseURL);
@@ -33,7 +37,6 @@ export class ScenarioGenerationService {
33
37
  : scheme === "https"
34
38
  ? 443
35
39
  : 80;
36
- basePath = parsed.pathname.replace(/\/$/, "");
37
40
  }
38
41
  catch {
39
42
  logger.warning("Could not parse baseURL, using destination param", {
@@ -9,6 +9,8 @@ import { logger } from "../utils/logger.js";
9
9
  import { TestExecutionStatus, } from "../types/TestExecution.js";
10
10
  import { TestType } from "../types/TestTypes.js";
11
11
  import { buildContainerEnv } from "./containerEnv.js";
12
+ import { getWorkspaceScopedQueryParams } from "../utils/workspaceAuth.js";
13
+ import { extractEnvVarNames } from "../workspace/queryParamResolution.js";
12
14
  import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
13
15
  import { walkDir } from "../utils/fileWalk.js";
14
16
  export { EXECUTOR_DOCKER_IMAGE };
@@ -817,7 +819,29 @@ export class TestExecutionService {
817
819
  mountedPaths.add(saveStorageTarget);
818
820
  }
819
821
  }
820
- const env = buildContainerEnv(options, saveStorageTargetPath);
822
+ // Forward the environment variables this workspace's query params declare
823
+ // via `env.VAR` (SKYR-4127). The references resolve where the test runs, so
824
+ // without this they resolve to nothing inside the container and the query
825
+ // param is dropped from the request silently.
826
+ //
827
+ // getWorkspaceScopedQueryParams already returns undefined on a read or
828
+ // parse failure, so those degrade quietly to the pre-existing behavior.
829
+ // This guard covers only an unexpected throw, and must not fail an
830
+ // otherwise valid execution.
831
+ let passthroughNames = [];
832
+ try {
833
+ const scoped = await getWorkspaceScopedQueryParams(workspacePath);
834
+ if (scoped) {
835
+ passthroughNames = extractEnvVarNames(scoped);
836
+ if (passthroughNames.length > 0) {
837
+ logger.debug(`Forwarding workspace-declared environment variables to the executor: ${passthroughNames.join(", ")}`);
838
+ }
839
+ }
840
+ }
841
+ catch (err) {
842
+ logger.warning(`Could not resolve workspace environment passthrough: ${err instanceof Error ? err.message : String(err)}`);
843
+ }
844
+ const env = buildContainerEnv(options, saveStorageTargetPath, process.env, passthroughNames);
821
845
  // Capture output
822
846
  let output = "";
823
847
  class DockerStream extends Writable {
@@ -2,7 +2,8 @@ import path from "path";
2
2
  import fs from "fs";
3
3
  import { SkyrampClient } from "@skyramp/skyramp";
4
4
  import { analyzeOpenAPIWithGivenEndpoint } from "../utils/analyze-openapi.js";
5
- import { isAuthorizationHeaderName, KNOWN_AUTH_HEADERS, resolveAuthFromWorkspace, getWorkspaceSkipTLSVerify, getWorkspaceDefaultQueryParams, mergeQueryParamsString, } from "../utils/workspaceAuth.js";
5
+ import { isAuthorizationHeaderName, KNOWN_AUTH_HEADERS, resolveAuthFromWorkspace, getWorkspaceSkipTLSVerify, getWorkspaceScopedQueryParams, mergeQueryParamsString, } from "../utils/workspaceAuth.js";
6
+ import { extractPathname, resolveQueryParamsForPath, contributesNothing } from "../workspace/queryParamResolution.js";
6
7
  import { getPathParameterValidationError, OUTPUT_DIR_FIELD_NAME, PATH_PARAMS_FIELD_NAME, QUERY_PARAMS_FIELD_NAME, FORM_PARAMS_FIELD_NAME, validateParams, validatePath, validateRequestData, } from "../utils/utils.js";
7
8
  import { getEntryPoint } from "../utils/telemetry.js";
8
9
  import { getLanguageSteps } from "../utils/language-helper.js";
@@ -364,17 +365,31 @@ The generated test file remains unchanged and ready to use as-is.
364
365
  logger.warning("Could not resolve skipTLSVerify from workspace config");
365
366
  }
366
367
  }
367
- // Workspace-declared default query params (SKYR-4050): api.defaultQueryParams
368
- // are attached to every generated request for the service. Existing
369
- // queryParams entries (explicit caller values) always win on a key collision.
368
+ // Workspace-declared query params (SKYR-4050, path-scoped in SKYR-4127):
369
+ // api.defaultQueryParams is the base map; api.queryParamOverrides layers
370
+ // the most-specific matching pathPattern on top. Existing queryParams
371
+ // entries (explicit caller values) always win on a key collision.
370
372
  if (!SKIP_DEFAULT_QUERY_PARAMS_TEST_TYPES.has(this.getTestType())) {
371
373
  try {
372
374
  const repoPath = generateOptions.outputDir || process.cwd();
373
- const defaults = await getWorkspaceDefaultQueryParams(repoPath);
374
- if (defaults) {
375
- generateOptions.queryParams = mergeQueryParamsString(generateOptions.queryParams, defaults);
376
- logger.info("Merged workspace defaultQueryParams into queryParams", {
377
- keys: Object.keys(defaults),
375
+ const scoped = await getWorkspaceScopedQueryParams(repoPath);
376
+ // Skip the merge entirely when the resolved config contributes
377
+ // nothing otherwise an empty/null defaultQueryParams declaration
378
+ // with no overrides would set queryParams to "" where the old code
379
+ // left the field untouched (Finding 4, SKYR-4127 final review).
380
+ if (scoped && !contributesNothing(scoped)) {
381
+ const pathname = extractPathname(generateOptions.uri);
382
+ if (!pathname && scoped.overrides.length > 0) {
383
+ logger.warning("queryParamOverrides configured but this generation call has no single endpoint path; " +
384
+ "applying api.defaultQueryParams only", { patterns: scoped.overrides.map((o) => o.pathPattern) });
385
+ }
386
+ const effective = pathname
387
+ ? resolveQueryParamsForPath(scoped, pathname)
388
+ : { ...scoped.base };
389
+ generateOptions.queryParams = mergeQueryParamsString(generateOptions.queryParams, effective);
390
+ logger.info("Merged workspace query params into queryParams", {
391
+ keys: Object.keys(effective),
392
+ pathname,
378
393
  });
379
394
  }
380
395
  }
@@ -8,7 +8,18 @@ import { TestExecutionOptions } from "../types/TestExecution.js";
8
8
  * host.docker.internal is mapped via ExtraHosts in TestExecutionService.
9
9
  */
10
10
  export declare function rewriteLocalhostForDocker(url: string): string;
11
+ /** Why a name is refused, or null when it is allowed. The two reasons need
12
+ * different messages: a reserved name is already provided by the executor, so
13
+ * the declaration is only redundant, while a credential name is refused. */
14
+ export declare function passthroughDenyReason(name: string): "reserved" | "credential" | null;
15
+ /** True when a name is refused regardless of who declared it. */
16
+ export declare function isDeniedPassthroughName(name: string): boolean;
17
+ /** A name is interpolated directly into a Docker `Env` entry, which is a plain
18
+ * `NAME=value` string. A name holding `=`, whitespace or a newline produces a
19
+ * malformed entry and can inject a second variable. workspace.yml supplies
20
+ * these names, so `env.FOO=BAR` would yield the name `FOO=BAR`. */
21
+ export declare function isValidEnvVarName(name: string): boolean;
11
22
  /**
12
23
  * Build the environment variable array for the Docker executor container.
13
24
  */
14
- export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>): string[];
25
+ export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>, passthroughNames?: string[]): string[];
@@ -1,3 +1,4 @@
1
+ import { logger } from "../utils/logger.js";
1
2
  /**
2
3
  * Rewrite localhost / 127.0.0.1 URLs to host.docker.internal so the Docker
3
4
  * executor container can reach services running on the host machine.
@@ -9,10 +10,67 @@
9
10
  export function rewriteLocalhostForDocker(url) {
10
11
  return url.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)([:/?#]|$)/, (_, scheme, _host, rest) => `${scheme}host.docker.internal${rest}`);
11
12
  }
13
+ /** Names a workspace declaration may never forward. Matching is
14
+ * case-insensitive: `process.env` is case-insensitive on Windows, so a
15
+ * lower-case declaration there returns the real value and would otherwise
16
+ * pass the checks below.
17
+ *
18
+ * Two distinct concerns share one list. The first is CI identity: testbot puts
19
+ * the workflow's GitHub token in the MCP process env so the agent's `gh` calls
20
+ * authenticate, and a declaration naming it would push it into the container
21
+ * and from there into a query string — where the SUT's own access log records
22
+ * it. The second is internal signals the executor sets for itself, which a
23
+ * declaration must not be able to spoof or override.
24
+ *
25
+ * Deliberately NOT a blanket `*_TOKEN`: a token for the customer's own SUT is
26
+ * exactly the kind of value an RBAC query param carries, and refusing it would
27
+ * break the feature this list exists to protect. */
28
+ // Names the executor sets for itself. API_KEY carries the Skyramp LLM key and
29
+ // is forwarded unconditionally by the built-in block below, so a declaration
30
+ // adds nothing. UI Smart LLM features keep working either way.
31
+ const PASSTHROUGH_RESERVED_EXACT = new Set([
32
+ "API_KEY",
33
+ "PYTEST_ADDOPTS",
34
+ "PLAYWRIGHT_SAVE_STORAGE_PATH",
35
+ ]);
36
+ const PASSTHROUGH_RESERVED_PREFIXES = ["SKYRAMP_"];
37
+ // CI identity and secret-shaped names. Testbot puts the workflow GitHub token
38
+ // in the MCP process env, and a declaration would send it to the container and
39
+ // into a query string, where the SUT access log records it.
40
+ const PASSTHROUGH_CREDENTIAL_EXACT = new Set(["GITHUB_TOKEN", "GH_TOKEN"]);
41
+ const PASSTHROUGH_CREDENTIAL_PREFIXES = ["ACTIONS_", "AWS_"];
42
+ const PASSTHROUGH_CREDENTIAL_SUBSTRINGS = ["_SECRET", "_PRIVATE_KEY"];
43
+ /** Why a name is refused, or null when it is allowed. The two reasons need
44
+ * different messages: a reserved name is already provided by the executor, so
45
+ * the declaration is only redundant, while a credential name is refused. */
46
+ export function passthroughDenyReason(name) {
47
+ const upper = name.toUpperCase();
48
+ if (PASSTHROUGH_CREDENTIAL_EXACT.has(upper) ||
49
+ PASSTHROUGH_CREDENTIAL_PREFIXES.some((p) => upper.startsWith(p)) ||
50
+ PASSTHROUGH_CREDENTIAL_SUBSTRINGS.some((s) => upper.includes(s))) {
51
+ return "credential";
52
+ }
53
+ if (PASSTHROUGH_RESERVED_EXACT.has(upper) ||
54
+ PASSTHROUGH_RESERVED_PREFIXES.some((p) => upper.startsWith(p))) {
55
+ return "reserved";
56
+ }
57
+ return null;
58
+ }
59
+ /** True when a name is refused regardless of who declared it. */
60
+ export function isDeniedPassthroughName(name) {
61
+ return passthroughDenyReason(name) !== null;
62
+ }
63
+ /** A name is interpolated directly into a Docker `Env` entry, which is a plain
64
+ * `NAME=value` string. A name holding `=`, whitespace or a newline produces a
65
+ * malformed entry and can inject a second variable. workspace.yml supplies
66
+ * these names, so `env.FOO=BAR` would yield the name `FOO=BAR`. */
67
+ export function isValidEnvVarName(name) {
68
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
69
+ }
12
70
  /**
13
71
  * Build the environment variable array for the Docker executor container.
14
72
  */
15
- export function buildContainerEnv(options, saveStoragePath, hostEnv = process.env) {
73
+ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.env, passthroughNames = []) {
16
74
  const env = [
17
75
  // Omit entirely when empty so os.getenv() returns None in the test —
18
76
  // unauthenticated endpoints won't send an empty auth header (E7).
@@ -68,6 +126,41 @@ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.en
68
126
  env.push(`${key}=${val}`);
69
127
  }
70
128
  }
129
+ // Environment variables the workspace's query params declare via `env.VAR`
130
+ // (SKYR-4127). These references resolve where the test RUNS — inside this
131
+ // container — so a name that does not reach it resolves to nothing, and an
132
+ // unresolved query param is dropped from the request silently, yielding a
133
+ // passing test that exercised the wrong identity.
134
+ //
135
+ // The list comes from what workspace.yml declared, never from enumerating the
136
+ // ambient environment: nothing is forwarded that the workspace did not name.
137
+ // Forwarded on presence, not truthiness, for the same reason as the
138
+ // credentials above — an empty value is a legitimate declaration.
139
+ const seen = new Set();
140
+ for (const name of passthroughNames) {
141
+ if (seen.has(name))
142
+ continue;
143
+ seen.add(name);
144
+ if (!isValidEnvVarName(name)) {
145
+ logger.warning(`Ignoring ${JSON.stringify(name)} declared in workspace.yml: it is not a valid environment variable name.`);
146
+ continue;
147
+ }
148
+ const denyReason = passthroughDenyReason(name);
149
+ if (denyReason === "reserved") {
150
+ logger.warning(`Ignoring the workspace.yml declaration of ${name}: the executor sets this variable itself. ` +
151
+ `Its value still reaches the test.`);
152
+ continue;
153
+ }
154
+ if (denyReason === "credential") {
155
+ logger.warning(`Refusing to forward ${name} to the test executor: the name is credential-shaped. ` +
156
+ `Declare a variable that carries only the value the request needs.`);
157
+ continue;
158
+ }
159
+ const val = hostEnv[name];
160
+ if (val !== undefined) {
161
+ env.push(`${name}=${val}`);
162
+ }
163
+ }
71
164
  if (hostEnv.SKYRAMP_DEBUG) {
72
165
  env.push(`SKYRAMP_DEBUG=${hostEnv.SKYRAMP_DEBUG}`);
73
166
  }
@@ -10,4 +10,13 @@ export type ContractExecutionMode = (typeof CONTRACT_EXECUTION_MODES)[number];
10
10
  */
11
11
  export declare function resolveEffectiveToken(unauthenticated?: boolean, paramToken?: string, envToken?: string): string;
12
12
  export declare function shouldInjectSkyrampBaseUrl(testType: TestType, contractMode?: ContractExecutionMode): boolean;
13
+ /**
14
+ * Append the recorded video path to execution output.
15
+ *
16
+ * Applied to the failure return as well as the success one (SKYR-4156): a video is
17
+ * most useful for a test that just failed, and surfacing it only on success meant a
18
+ * failing UI test reported no recording at all unless Playwright happened to print
19
+ * its own attachment line.
20
+ */
21
+ export declare function withVideoInfo(output: string, videoPath?: string): string;
13
22
  export declare function registerExecuteSkyrampTestTool(server: McpServer): void;