@loucompanion/forge-bridge 0.1.1-dev.65ea6889412c → 0.1.1-dev.718bdb0018b9

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.
@@ -123,6 +123,7 @@ export class ForgeBridgeService {
123
123
  const mapping = findRepoMapping(dispatch, this.config.repoMappings);
124
124
  const localPath = mapping?.localPath?.trim();
125
125
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
126
+ const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
126
127
  return {
127
128
  ...dispatch,
128
129
  repo: {
@@ -133,7 +134,13 @@ export class ForgeBridgeService {
133
134
  serverLocal: {
134
135
  repoRoot,
135
136
  cleanupWorktrees: this.config.cleanupWorktrees
136
- }
137
+ },
138
+ qaBrowser: dispatch.qaBrowser
139
+ ? {
140
+ ...dispatch.qaBrowser,
141
+ launchTarget: qaLaunchTarget
142
+ }
143
+ : dispatch.qaBrowser
137
144
  };
138
145
  }
139
146
  async sendResult(leaseId, fencingToken, result) {
@@ -323,6 +330,27 @@ function findRepoMapping(dispatch, mappings) {
323
330
  (!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
324
331
  });
325
332
  }
333
+ function normalizeQaAppLaunchTarget(target) {
334
+ if (!target) {
335
+ return null;
336
+ }
337
+ const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
338
+ const normalized = {
339
+ url: cleanString(target.url),
340
+ command: cleanString(target.command),
341
+ working_directory: cleanString(target.working_directory),
342
+ ready_url: cleanString(target.ready_url),
343
+ routes,
344
+ notes: cleanString(target.notes)
345
+ };
346
+ return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
347
+ ? normalized
348
+ : null;
349
+ }
350
+ function cleanString(value) {
351
+ const clean = value?.trim();
352
+ return clean ? clean : null;
353
+ }
326
354
  function equalsIgnoreCase(left, right) {
327
355
  return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
328
356
  }
@@ -27,6 +27,15 @@ export interface BridgeRepoMapping {
27
27
  readonly name?: string | null;
28
28
  readonly remoteUrl?: string | null;
29
29
  readonly localPath?: string | null;
30
+ readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
31
+ }
32
+ export interface QaAppLaunchTarget {
33
+ readonly url?: string | null;
34
+ readonly command?: string | null;
35
+ readonly working_directory?: string | null;
36
+ readonly ready_url?: string | null;
37
+ readonly routes?: readonly string[] | null;
38
+ readonly notes?: string | null;
30
39
  }
31
40
  export interface BridgeProviderStatus {
32
41
  readonly signedIn?: boolean | null;
@@ -159,7 +159,8 @@ function normalizeRepoMappings(values) {
159
159
  repoId: cleanString(mapping.repoId),
160
160
  name: cleanString(mapping.name) ?? label,
161
161
  remoteUrl: cleanString(mapping.remoteUrl),
162
- localPath: path.resolve(mapping.localPath)
162
+ localPath: path.resolve(mapping.localPath),
163
+ qaAppLaunchTarget: normalizeQaAppLaunchTarget(mapping.qaAppLaunchTarget)
163
164
  };
164
165
  })
165
166
  .sort((left, right) => left.label.localeCompare(right.label));
@@ -171,6 +172,22 @@ function normalizeLabel(value) {
171
172
  }
172
173
  return label;
173
174
  }
175
+ function normalizeQaAppLaunchTarget(value) {
176
+ if (!value)
177
+ return undefined;
178
+ const routes = cleanList(value.routes);
179
+ const normalized = {
180
+ url: cleanString(value.url),
181
+ command: cleanString(value.command),
182
+ working_directory: cleanString(value.working_directory),
183
+ ready_url: cleanString(value.ready_url),
184
+ routes,
185
+ notes: cleanString(value.notes)
186
+ };
187
+ return normalized.url || normalized.command || normalized.ready_url || routes?.length
188
+ ? normalized
189
+ : undefined;
190
+ }
174
191
  function cleanString(value) {
175
192
  const clean = value?.trim();
176
193
  return clean ? clean : undefined;
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { redactSecrets } from "../shared/redaction/redaction.js";
6
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
6
7
  const toolServerName = "forge";
7
8
  const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
8
9
  const completionPhaseOrder = ["implementation", "self_review", "validation"];
@@ -304,6 +305,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
304
305
  const evidenceSource = qaEvidenceSource(payload);
305
306
  const commands = qaArray(payload, "commands", evidenceSource);
306
307
  const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
308
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource));
307
309
  const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
308
310
  const findings = qaObjectArray(payload, "findings");
309
311
  const candidateBugs = qaObjectArray(payload, "candidate_bugs");
@@ -323,6 +325,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
323
325
  phase: "qa",
324
326
  commands,
325
327
  browser_checks: browserChecks,
328
+ sidecars,
326
329
  notes
327
330
  });
328
331
  const findingsJson = jsonText({
@@ -386,6 +389,57 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
386
389
  producedBy
387
390
  };
388
391
  }
392
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
393
+ if (sidecars.length === 0) {
394
+ return [];
395
+ }
396
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
397
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
398
+ const validated = [];
399
+ for (const [index, sidecar] of sidecars.entries()) {
400
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
401
+ throw new Error(`sidecars[${index}] must be an object.`);
402
+ }
403
+ const record = sidecar;
404
+ const sidecarPath = optionalString(record, "path");
405
+ const contentHash = optionalString(record, "content_hash");
406
+ const bytes = record.bytes;
407
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
408
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
409
+ }
410
+ if (path.isAbsolute(sidecarPath)) {
411
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
412
+ }
413
+ const fullPath = path.resolve(worktreePath, sidecarPath);
414
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
415
+ throw new Error(`sidecars[${index}].path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
416
+ }
417
+ const stat = await fs.lstat(fullPath).catch((error) => {
418
+ if (isNodeError(error) && error.code === "ENOENT") {
419
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
420
+ }
421
+ throw error;
422
+ });
423
+ if (!stat.isFile() || stat.isSymbolicLink()) {
424
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
425
+ }
426
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
427
+ if (stat.size !== bytes) {
428
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
429
+ }
430
+ const actualHash = await sha256File(fullPath);
431
+ if (actualHash !== contentHash.trim().toLowerCase()) {
432
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
433
+ }
434
+ validated.push({
435
+ ...record,
436
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
437
+ content_hash: actualHash,
438
+ bytes: stat.size
439
+ });
440
+ }
441
+ return validated;
442
+ }
389
443
  export async function readCompletionArtifact(worktreePath, workItemId, phase) {
390
444
  const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
391
445
  const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
@@ -753,15 +807,25 @@ function toolInputSchema(phase) {
753
807
  },
754
808
  browser_checks: {
755
809
  type: "array",
756
- description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status when applicable.",
757
- items: evidenceObjectSchema(["summary"], {
810
+ description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
811
+ items: evidenceObjectSchema(["summary", "status"], {
758
812
  summary: { type: "string" },
759
813
  url: { type: "string" },
814
+ route: { type: "string" },
760
815
  status: { type: "string" },
761
816
  screenshot: { type: "string" },
762
817
  trace: { type: "string" }
763
818
  })
764
819
  },
820
+ sidecars: {
821
+ type: "array",
822
+ description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
823
+ items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
824
+ path: { type: "string" },
825
+ content_hash: { type: "string" },
826
+ bytes: { type: "number" }
827
+ })
828
+ },
765
829
  notes: { type: "string", description: "Additional QA evidence notes." },
766
830
  evidence: {
767
831
  type: "object",
@@ -954,6 +1018,7 @@ function validateQaPayloadShape(payload, status) {
954
1018
  const evidenceSource = qaEvidenceSource(payload);
955
1019
  const commands = qaArray(payload, "commands", evidenceSource);
956
1020
  const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
1021
+ const sidecars = qaArray(payload, "sidecars", evidenceSource);
957
1022
  const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
958
1023
  const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
959
1024
  const findings = qaObjectArray(payload, "findings");
@@ -991,10 +1056,23 @@ function validateQaPayloadShape(payload, status) {
991
1056
  requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
992
1057
  const record = browserCheck;
993
1058
  requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
1059
+ requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
994
1060
  const checkStatus = optionalString(record, "status");
995
1061
  if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
996
1062
  throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
997
1063
  }
1064
+ if (!optionalString(record, "url") && !optionalString(record, "route")) {
1065
+ throw new Error(`browser_checks[${index}] must include url or route.`);
1066
+ }
1067
+ }
1068
+ for (const [index, sidecar] of sidecars.entries()) {
1069
+ requireEvidenceRecord(sidecar, `sidecars[${index}]`);
1070
+ const record = sidecar;
1071
+ requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
1072
+ requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
1073
+ if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
1074
+ throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
1075
+ }
998
1076
  }
999
1077
  for (const [index, finding] of findings.entries()) {
1000
1078
  requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
@@ -6,6 +6,7 @@ import readline from "node:readline";
6
6
  import { promisify } from "node:util";
7
7
  import { GitClient } from "../../shared/git/git.js";
8
8
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
9
10
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
10
11
  import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
11
12
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
@@ -171,7 +172,7 @@ export class ProcessBackedProviderCliService {
171
172
  eventType: "completion_tools_configured"
172
173
  });
173
174
  const outputState = createProviderOutputState(dispatch.execution.provider);
174
- const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
175
+ const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, dispatch, context.emit);
175
176
  let result;
176
177
  try {
177
178
  liveQaEvents.start();
@@ -211,6 +212,7 @@ export class ProcessBackedProviderCliService {
211
212
  ? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
212
213
  : "Provider CLI QA completed but did not call the Forge QA completion tool.");
213
214
  }
215
+ enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
214
216
  if (!liveQaEvents.hasEmitted()) {
215
217
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
216
218
  await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
@@ -356,7 +358,18 @@ export class DeterministicProviderCliService {
356
358
  content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
357
359
  eventType: "qa_plan"
358
360
  });
359
- const summary = "All checks passed. forge-bridge QA completed.";
361
+ const qaBrowser = dispatch.qaBrowser;
362
+ const launchTarget = qaBrowser?.launchTarget ?? null;
363
+ const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
364
+ const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
365
+ ? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
366
+ : null;
367
+ const browserChecks = browserSidecar && launchTarget
368
+ ? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
369
+ : [];
370
+ const summary = missingLaunchTarget
371
+ ? "QA blocked because browser testing is required but no app launch target was provided."
372
+ : "All checks passed. forge-bridge QA completed.";
360
373
  const qaArtifact = await writeQaArtifacts({
361
374
  worktreePath: worktree.worktreePath,
362
375
  workItemId: dispatch.workItem.id,
@@ -365,13 +378,17 @@ export class DeterministicProviderCliService {
365
378
  provider: dispatch.execution.provider,
366
379
  model: dispatch.execution.model,
367
380
  command: "forge-bridge deterministic QA",
368
- summary
381
+ summary,
382
+ status: missingLaunchTarget ? "blocked" : "pass",
383
+ blockerReason: missingLaunchTarget ? "missing_app_launch_target" : null,
384
+ browserChecks,
385
+ sidecarFiles: browserSidecar ? [browserSidecar] : []
369
386
  });
370
387
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
371
388
  await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
372
389
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
373
390
  return {
374
- summary: "forge-bridge QA passed.",
391
+ summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
375
392
  artifact: qaArtifact,
376
393
  artifacts: [qaArtifact]
377
394
  };
@@ -483,9 +500,62 @@ const qaArtifactFileNames = {
483
500
  verdict: "qa.verdict.json",
484
501
  report: "qa.report.md"
485
502
  };
503
+ function hasLaunchTarget(target) {
504
+ return Boolean(target?.url?.trim()
505
+ || target?.ready_url?.trim()
506
+ || target?.command?.trim()
507
+ || (target?.routes ?? []).some((route) => route.trim().length > 0));
508
+ }
509
+ function deterministicBrowserSidecar(workItemId, target) {
510
+ const route = primaryLaunchRoute(target);
511
+ return {
512
+ relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
513
+ content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
514
+ };
515
+ }
516
+ function deterministicBrowserCheck(target, screenshotPath) {
517
+ const route = primaryLaunchRoute(target);
518
+ return {
519
+ route,
520
+ url: route,
521
+ status: "pass",
522
+ summary: "Deterministic browser QA exercised the configured launch target.",
523
+ actions: ["open launch target", "walk acceptance criteria", "check console/network summaries", "check responsive viewport"],
524
+ screenshots: [screenshotPath],
525
+ console_errors: [],
526
+ network_errors: [],
527
+ responsive_checks: ["desktop", "mobile"]
528
+ };
529
+ }
530
+ function primaryLaunchRoute(target) {
531
+ return target.ready_url?.trim()
532
+ || target.url?.trim()
533
+ || target.routes.find((route) => route.trim().length > 0)?.trim()
534
+ || "app launch command";
535
+ }
536
+ async function writeQaSidecarFiles(worktreePath, files) {
537
+ const sidecars = [];
538
+ for (const file of files) {
539
+ const fullPath = path.resolve(worktreePath, file.relativePath);
540
+ const relativePath = path.relative(worktreePath, fullPath).split(path.sep).join("/");
541
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
542
+ throw new Error(`QA sidecar path must stay under the worktree: ${file.relativePath}`);
543
+ }
544
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
545
+ await fs.writeFile(fullPath, file.content, "utf8");
546
+ sidecars.push({
547
+ path: relativePath,
548
+ content_hash: sha256(file.content),
549
+ bytes: Buffer.byteLength(file.content, "utf8")
550
+ });
551
+ }
552
+ return sidecars;
553
+ }
486
554
  async function writeQaArtifacts(request) {
487
555
  const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
488
556
  await fs.mkdir(artifactRoot, { recursive: true });
557
+ const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
558
+ const status = request.status ?? "pass";
489
559
  const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
490
560
  const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
491
561
  const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
@@ -503,15 +573,18 @@ async function writeQaArtifacts(request) {
503
573
  const evidenceJson = jsonText({
504
574
  schema_version: 1,
505
575
  phase: "qa",
506
- commands: [
507
- {
508
- command: request.command,
509
- exit_code: 0,
510
- status: "pass",
511
- summary: `${request.provider} QA completed successfully.`
512
- }
513
- ],
514
- browser_checks: [],
576
+ commands: status === "blocked"
577
+ ? []
578
+ : [
579
+ {
580
+ command: request.command,
581
+ exit_code: 0,
582
+ status: "pass",
583
+ summary: `${request.provider} QA completed successfully.`
584
+ }
585
+ ],
586
+ browser_checks: request.browserChecks ?? [],
587
+ sidecars,
515
588
  notes: request.summary
516
589
  });
517
590
  const findingsJson = jsonText({
@@ -523,12 +596,12 @@ async function writeQaArtifacts(request) {
523
596
  const verdictJson = jsonText({
524
597
  schema_version: 1,
525
598
  phase: "qa",
526
- status: "pass",
599
+ status,
527
600
  summary: request.summary,
528
- tested_acceptance_criteria: [criterion],
601
+ tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
529
602
  finding_ids: [],
530
603
  candidate_bug_ids: [],
531
- blocker_reason: null,
604
+ blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
532
605
  evidence_path: relativeEvidencePath,
533
606
  findings_path: relativeFindingsPath,
534
607
  report_path: relativeReportPath
@@ -546,7 +619,7 @@ async function writeQaArtifacts(request) {
546
619
  verdictJson,
547
620
  report,
548
621
  producedBy: request.producedBy,
549
- status: "pass",
622
+ status,
550
623
  phase: "qa"
551
624
  };
552
625
  }
@@ -570,6 +643,7 @@ async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
570
643
  if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
571
644
  return null;
572
645
  }
646
+ await validateQaBundleSidecarReferences(worktreePath, workItemId);
573
647
  return artifact;
574
648
  }
575
649
  async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
@@ -590,6 +664,59 @@ async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
590
664
  }
591
665
  return true;
592
666
  }
667
+ function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
668
+ const qaBrowser = dispatch.qaBrowser;
669
+ if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
670
+ return;
671
+ }
672
+ const verdict = parseJsonLine(artifact.verdictJson) ?? {};
673
+ const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
674
+ const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
675
+ if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
676
+ throw new Error("QA browser testing is required but no app launch target was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
677
+ }
678
+ }
679
+ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
680
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
681
+ const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
682
+ const sidecars = arrayField(evidence, "sidecars");
683
+ if (sidecars.length === 0) {
684
+ return;
685
+ }
686
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
687
+ for (const [index, sidecar] of sidecars.entries()) {
688
+ const sidecarPath = stringField(sidecar, "path");
689
+ const expectedHash = stringField(sidecar, "content_hash");
690
+ const expectedBytes = sidecar.bytes;
691
+ if (!sidecarPath || !expectedHash || typeof expectedBytes !== "number" || !Number.isFinite(expectedBytes) || expectedBytes <= 0) {
692
+ throw new Error(`QA sidecar ${index} must include path, content_hash, and positive bytes.`);
693
+ }
694
+ if (path.isAbsolute(sidecarPath)) {
695
+ throw new Error(`QA sidecar ${index} path must be relative to the worktree.`);
696
+ }
697
+ const fullPath = path.resolve(worktreePath, sidecarPath);
698
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
699
+ throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
700
+ }
701
+ const stat = await fs.lstat(fullPath).catch((error) => {
702
+ if (isNodeError(error) && error.code === "ENOENT") {
703
+ throw new Error(`QA sidecar ${index} file does not exist: ${sidecarPath}`);
704
+ }
705
+ throw error;
706
+ });
707
+ if (!stat.isFile() || stat.isSymbolicLink()) {
708
+ throw new Error(`QA sidecar ${index} must reference a regular file.`);
709
+ }
710
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `QA sidecar ${index} path must not traverse symbolic links.`);
711
+ if (stat.size !== expectedBytes) {
712
+ throw new Error(`QA sidecar ${index} bytes do not match the referenced file.`);
713
+ }
714
+ const actualHash = await sha256File(fullPath);
715
+ if (actualHash !== expectedHash.trim().toLowerCase()) {
716
+ throw new Error(`QA sidecar ${index} content_hash does not match the referenced file.`);
717
+ }
718
+ }
719
+ }
593
720
  async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
594
721
  const artifactRoot = artifactRootFor(worktreePath, workItemId);
595
722
  const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
@@ -634,13 +761,15 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
634
761
  class LiveQaArtifactEmitter {
635
762
  worktreePath;
636
763
  workItemId;
764
+ dispatch;
637
765
  emit;
638
766
  emitted = false;
639
767
  timer = null;
640
768
  pending = Promise.resolve();
641
- constructor(worktreePath, workItemId, emit) {
769
+ constructor(worktreePath, workItemId, dispatch, emit) {
642
770
  this.worktreePath = worktreePath;
643
771
  this.workItemId = workItemId;
772
+ this.dispatch = dispatch;
644
773
  this.emit = emit;
645
774
  }
646
775
  start() {
@@ -673,6 +802,7 @@ class LiveQaArtifactEmitter {
673
802
  if (!artifact) {
674
803
  return;
675
804
  }
805
+ enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
676
806
  this.emitted = true;
677
807
  await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
678
808
  await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
@@ -1125,6 +1255,21 @@ function phaseWorkInstruction(phase) {
1125
1255
  }
1126
1256
  return "Implement the work item and keep the change focused.";
1127
1257
  }
1258
+ function qaBrowserInstruction(dispatch) {
1259
+ const qaBrowser = dispatch.qaBrowser;
1260
+ if (!qaBrowser) {
1261
+ return "Browser/app launch context: not provided by Forge.";
1262
+ }
1263
+ const launchTarget = qaBrowser.launchTarget;
1264
+ if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
1265
+ return `Browser/app launch context:\n- UI/browser QA is required for this item.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1266
+ }
1267
+ if (!launchTarget) {
1268
+ return `Browser/app launch context:\n- UI/browser QA is not required by Forge heuristics for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1269
+ }
1270
+ const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
1271
+ return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1272
+ }
1128
1273
  function buildQaPrompt(dispatch) {
1129
1274
  return `You are Forge's QA lane running locally in a prepared git worktree.
1130
1275
 
@@ -1143,9 +1288,10 @@ System info / environment notes:
1143
1288
  ${dispatch.workItem.systemInfo || "Not provided."}
1144
1289
 
1145
1290
  Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
1291
+ ${qaBrowserInstruction(dispatch)}
1146
1292
  ${qaReworkRetestInstruction(dispatch)}
1147
1293
  QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
1148
- Record concrete evidence. For UI/browser work, include browser_checks with URLs, actions, observed results, screenshots/traces when available, and console/network notes.
1294
+ Record concrete evidence. For UI/browser work, include browser_checks with URLs/routes, actions, observed results, screenshots/traces when available, console errors, failed network requests, and responsive checks. Store browser sidecars under .forge/work-items/${dispatch.workItem.id}/artifacts/qa/.
1149
1295
  Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
1150
1296
  Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
1151
1297
 
@@ -1508,6 +1654,9 @@ function stringField(source, key) {
1508
1654
  const value = source[key];
1509
1655
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1510
1656
  }
1657
+ function normalizedString(value) {
1658
+ return value.trim().toLowerCase().replace(/-/g, "_");
1659
+ }
1511
1660
  function isNodeError(error) {
1512
1661
  return error instanceof Error && "code" in error;
1513
1662
  }
@@ -0,0 +1,3 @@
1
+ export declare function isPathUnderRoot(root: string, target: string): boolean;
2
+ export declare function ensureNoSymbolicLinkPathSegments(root: string, targetDirectory: string, errorMessage: string): Promise<void>;
3
+ export declare function sha256File(filePath: string): Promise<string>;
@@ -0,0 +1,44 @@
1
+ import crypto from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ export function isPathUnderRoot(root, target) {
6
+ const relative = path.relative(path.resolve(root), path.resolve(target));
7
+ return Boolean(relative)
8
+ && relative !== ".."
9
+ && !relative.startsWith(`..${path.sep}`)
10
+ && !path.isAbsolute(relative);
11
+ }
12
+ export async function ensureNoSymbolicLinkPathSegments(root, targetDirectory, errorMessage) {
13
+ const resolvedRoot = path.resolve(root);
14
+ const resolvedTargetDirectory = path.resolve(targetDirectory);
15
+ const relative = path.relative(resolvedRoot, resolvedTargetDirectory);
16
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
17
+ throw new Error(errorMessage);
18
+ }
19
+ let current = resolvedRoot;
20
+ const rootStat = await fs.lstat(current);
21
+ if (rootStat.isSymbolicLink()) {
22
+ throw new Error(errorMessage);
23
+ }
24
+ if (!relative) {
25
+ return;
26
+ }
27
+ for (const segment of relative.split(path.sep)) {
28
+ if (!segment) {
29
+ continue;
30
+ }
31
+ current = path.join(current, segment);
32
+ const stat = await fs.lstat(current);
33
+ if (stat.isSymbolicLink()) {
34
+ throw new Error(errorMessage);
35
+ }
36
+ }
37
+ }
38
+ export async function sha256File(filePath) {
39
+ const hash = crypto.createHash("sha256");
40
+ for await (const chunk of createReadStream(filePath)) {
41
+ hash.update(chunk);
42
+ }
43
+ return hash.digest("hex");
44
+ }
@@ -11,8 +11,23 @@ export interface RunOnceDispatchPacket {
11
11
  readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
12
12
  readonly artifacts: readonly DispatchResolvedArtifact[];
13
13
  readonly warmPathOverrides?: readonly string[];
14
+ readonly qaBrowser?: DispatchQaBrowserContext | null;
14
15
  readonly qaRework?: DispatchQaReworkContext | null;
15
16
  }
17
+ export interface DispatchQaBrowserContext {
18
+ readonly required: boolean;
19
+ readonly launchTarget?: DispatchQaAppLaunchTarget | null;
20
+ readonly artifactsPath: string;
21
+ readonly missingTargetReason?: string | null;
22
+ }
23
+ export interface DispatchQaAppLaunchTarget {
24
+ readonly url?: string | null;
25
+ readonly command?: string | null;
26
+ readonly working_directory?: string | null;
27
+ readonly ready_url?: string | null;
28
+ readonly routes: readonly string[];
29
+ readonly notes?: string | null;
30
+ }
16
31
  export interface DispatchQaReworkContext {
17
32
  readonly attempt: number;
18
33
  readonly maxAttempts: number;
@@ -80,6 +95,7 @@ export interface DispatchExecution {
80
95
  readonly role: string;
81
96
  readonly lane: string;
82
97
  readonly requiresApiKeySecretGrant: boolean;
98
+ readonly runnerRepoLocalPath?: string | null;
83
99
  }
84
100
  export interface ApiKeySecretGrant {
85
101
  readonly grantId: string;
@@ -18,6 +18,7 @@ export function parseRunOnceDispatchPacket(value) {
18
18
  apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant),
19
19
  artifacts: parseArtifacts(packet.artifacts),
20
20
  warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides),
21
+ qaBrowser: parseQaBrowser(packet.qaBrowser),
21
22
  qaRework: parseQaRework(packet.qaRework)
22
23
  };
23
24
  }
@@ -94,7 +95,8 @@ function parseExecution(value) {
94
95
  repoId: requiredString(execution, "repoId"),
95
96
  role: requiredString(execution, "role"),
96
97
  lane: requiredString(execution, "lane"),
97
- requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant")
98
+ requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant"),
99
+ runnerRepoLocalPath: optionalString(execution, "runnerRepoLocalPath")
98
100
  };
99
101
  }
100
102
  function parseApiKeySecretGrant(value) {
@@ -139,6 +141,43 @@ function parseWarmPathOverride(entry, index) {
139
141
  }
140
142
  return segments.join("/");
141
143
  }
144
+ function parseQaBrowser(value) {
145
+ if (value === undefined || value === null) {
146
+ return null;
147
+ }
148
+ const qaBrowser = asObject(value, "qaBrowser");
149
+ return {
150
+ required: optionalBoolean(qaBrowser, "required") ?? false,
151
+ launchTarget: parseQaAppLaunchTarget(qaBrowser.launchTarget ?? qaBrowser.launch_target),
152
+ artifactsPath: optionalString(qaBrowser, "artifactsPath") ?? optionalString(qaBrowser, "artifacts_path") ?? "",
153
+ missingTargetReason: optionalString(qaBrowser, "missingTargetReason") ?? optionalString(qaBrowser, "missing_target_reason")
154
+ };
155
+ }
156
+ function parseQaAppLaunchTarget(value) {
157
+ if (value === undefined || value === null) {
158
+ return null;
159
+ }
160
+ const launchTarget = asObject(value, "qaBrowser.launchTarget");
161
+ const routes = launchTarget.routes;
162
+ if (routes !== undefined && routes !== null && !Array.isArray(routes)) {
163
+ throw new Error("qaBrowser.launchTarget.routes must be an array when provided.");
164
+ }
165
+ return {
166
+ url: optionalString(launchTarget, "url"),
167
+ command: optionalString(launchTarget, "command"),
168
+ working_directory: optionalString(launchTarget, "working_directory"),
169
+ ready_url: optionalString(launchTarget, "ready_url"),
170
+ routes: Array.isArray(routes)
171
+ ? routes.map((entry, index) => {
172
+ if (typeof entry !== "string") {
173
+ throw new Error(`qaBrowser.launchTarget.routes[${index}] must be a string.`);
174
+ }
175
+ return entry;
176
+ }).filter((entry) => entry.trim().length > 0)
177
+ : [],
178
+ notes: optionalString(launchTarget, "notes")
179
+ };
180
+ }
142
181
  function parseQaRework(value) {
143
182
  if (value === undefined || value === null) {
144
183
  return null;
@@ -217,6 +256,16 @@ function requiredBoolean(source, key) {
217
256
  }
218
257
  return value;
219
258
  }
259
+ function optionalBoolean(source, key) {
260
+ const value = source[key];
261
+ if (value === undefined || value === null) {
262
+ return null;
263
+ }
264
+ if (typeof value !== "boolean") {
265
+ throw new Error(`${key} must be a boolean when provided.`);
266
+ }
267
+ return value;
268
+ }
220
269
  function optionalPositiveInteger(source, key) {
221
270
  const value = source[key];
222
271
  if (value === undefined || value === null) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.1-dev.65ea6889412c";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.718bdb0018b9";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.1-dev.65ea6889412c";
1
+ export const forgeBridgeVersion = "0.1.1-dev.718bdb0018b9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.65ea6889412c",
3
+ "version": "0.1.1-dev.718bdb0018b9",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",