software-defence-factory 0.8.0 → 0.9.0

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.
@@ -152,6 +152,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
152
152
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
153
153
  SOFTWARE.
154
154
 
155
+
155
156
  ## @radix-ui/react-compose-refs 1.1.5
156
157
 
157
158
  MIT License
@@ -574,3 +575,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
574
575
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
575
576
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
576
577
  SOFTWARE.
578
+
579
+ ## Optional browser verification image
580
+
581
+ The operator-built Playwright runner image installs `playwright` and
582
+ `playwright-core` 1.63.0 from the exact lockfile in `factory/web/` under the
583
+ Apache License 2.0. The original license files are retained in the built image
584
+ under `/opt/factory-web/node_modules/playwright/LICENSE` and
585
+ `/opt/factory-web/node_modules/playwright-core/LICENSE`. Factory does not bundle
586
+ the built browser image in its npm package.
@@ -16,6 +16,7 @@ import { bootstrap, registerInstallation, VERSION } from '../factory/updates.mjs
16
16
  import { hasService, manageService, serviceDefinition, withServiceOperation, isManagedLaunch } from '../factory/services.mjs';
17
17
  import { runCandidateGit } from '../factory/git-environment.mjs';
18
18
  import { initializeDemoRepository } from '../factory/demo-fixture.mjs';
19
+ import { probeWebBrowser } from '../factory/web-readiness.mjs';
19
20
 
20
21
  try {
21
22
  const handled = await bootstrap(process.argv.slice(2));
@@ -190,8 +191,9 @@ try {
190
191
  else if(command==='status') { const snapshot=await api(state,'/api/v1/status');delete snapshot.csrf_token;console.log(JSON.stringify(snapshot,null,2)); }
191
192
  else if(command==='doctor') {
192
193
  const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
193
- console.log(JSON.stringify({node:process.version,docker:dockerVersion,engineInstalled:imageStatus.installed,image:imageStatus.image,repo:config.repo,harness:harnessOf(config),agent:harnessOf(config),checksConfigured:!!config.check?.trim(),inference:'Not called or verified',qualification:{model:'not assessed',toolchain:'not assessed'},dashboard:`http://127.0.0.1:${config.port}`},null,2));
194
- if(!imageStatus.installed)process.exitCode=1;
194
+ const webVerification=config.webVerification?.enabled?probeWebBrowser(config,state):null;
195
+ console.log(JSON.stringify({node:process.version,docker:dockerVersion,engineInstalled:imageStatus.installed,image:imageStatus.image,repo:config.repo,harness:harnessOf(config),agent:harnessOf(config),checksConfigured:!!config.check?.trim(),inference:'Not called or verified',qualification:{model:'not assessed',toolchain:'not assessed'},dashboard:`http://127.0.0.1:${config.port}`,...(webVerification?{web_verification:webVerification}:{})},null,2));
196
+ if(!imageStatus.installed||webVerification&&!webVerification.ready)process.exitCode=1;
195
197
  } else if(command==='issue') {
196
198
  const action=positional[0], sourceURL=flags.url || flags.github;
197
199
  if(action==='list') {
@@ -263,11 +265,20 @@ try {
263
265
  initializeDemoRepository(repo);
264
266
  init(repo,'mock',"test \"$(cat value.txt)\" = fixed",Number(flags.port || 7332));
265
267
  } else if(harnessOf(configAt(state))!=='mock')throw new Error('Demo requires a mock configuration');
268
+ save(join(state,'synthetic-demo.json'),{version:1,createdAt:new Date().toISOString(),purpose:'Disposable Factory runtime qualification only'});
266
269
  await withServiceOperation('demo startup',async()=>{await install();await up();});console.log(JSON.stringify(await submit('software','Synthetic installation qualification: fix value.txt. No inference is used.')));
267
270
  console.log('Review the synthetic change in the dashboard and approve its handoff.');
268
271
  } else if(['version','--version','-v'].includes(command))console.log(VERSION);
269
272
  else if(command==='qualify') {
270
273
  await stream(process.execPath,[join(ROOT,'scripts/probe-platform.mjs'),state]);
274
+ } else if(command==='qualify-web') {
275
+ if(!flags.image)throw new Error('qualify-web requires --image sha256:<local browser image ID>');
276
+ await stream(process.execPath,[join(ROOT,'scripts/probe-web.mjs'),state,flags.image]);
277
+ } else if(command==='web') {
278
+ if(positional[0]!=='probe'||positional.length!==1)throw new Error('Use web probe to execute the configured browser readiness check');
279
+ const readiness=probeWebBrowser(configAt(state),state);
280
+ console.log(JSON.stringify(readiness,null,2));
281
+ if(readiness.enabled&&!readiness.ready)process.exitCode=1;
271
282
  } else if(command==='kit') {
272
283
  if(!flags.output)throw new Error('kit requires --output NEW_DIRECTORY');
273
284
  await stream(process.execPath,[join(ROOT,'scripts/export-kit.mjs'),resolve(flags.output)]);
@@ -276,11 +287,13 @@ try {
276
287
  kit --output NEW_DIRECTORY Export the portable method without a runtime
277
288
  demo Install and run a synthetic sample (no model key)
278
289
  qualify --state PATH Exercise recovery and isolation with a stopped demo job
290
+ qualify-web --state PATH --image ID Exercise Playwright Verify with a synthetic delayed-action fixture
279
291
  init --repo PATH --harness codex|pi|custom --check "npm ci && npm test" [--source-ref REF]
280
292
  [--inference-provider PROVIDER]
281
293
  [--delivery-provider github --delivery-repository https://github.com/OWNER/REPO --delivery-target main|dev]
282
294
  install [--image LOCAL_REF] Build the standard image, or select an existing local image
283
295
  doctor | up | status | stop Inspect / operate your private installation
296
+ web probe --state PATH Execute the pinned local Chromium readiness probe
284
297
  foundation Read the operator setup skill; no installation required
285
298
  definition | agents | skills Inspect roles, instructions and installation settings
286
299
  inbox | infrastructure | automations Inspect live tasks, host/worker and automation state
@@ -34,6 +34,7 @@ shell endpoint or a second scheduler.
34
34
  | Synthetic qualification | `demo`, `qualify` | No qualification endpoint | Synthetic disclosure only | Explicit separate state; never target an application accidentally |
35
35
  | Immutable source admission | `init --source-ref`, `run --source-ref`, `issue start --source-ref`; status and build evidence carry the resolved SHA | `POST /api/v1/jobs` resolves/retains before acknowledgement; shared source metadata in status | New issue and revision forms accept a ref; task detail shows requested ref, resolved SHA and prior source commits | Build/retry use retained objects; revisions keep the recorded source unless a new ref is explicit; legacy source remains unknown |
36
36
  | Trusted PR handoff | `publish JOB_ID` publishes/reconciles; `abandon-delivery JOB_ID --branch-sha SHA` records a checked local resolution for a pre-write branch collision | Authenticated `POST /api/v1/jobs/:id/publish` and `/abandon-delivery`; shared receipt, conflict identity and removal policy | Publish/reconcile and explicit “Abandon local delivery; keep remote branch” actions share controller state; errors/results and inspected branch identity are visible | New writes require matching protected Codex/Pi build/review provenance, deterministic verify/handoff provenance, non-synthetic bound artifacts and a qualified GitHub Actions tree. Shared delivery status exposes `workflow_qualification` and the same reason blocks CLI/API/dashboard capability and publication/retry. Candidate workflow changes, unsupported triggers/syntax, or active generated-push, selected-ref-dispatch and PR jobs with write/secrets/environment/OIDC/deploy access, self-hosted runners or ambiguous privileged guards refuse trusted writes. Supported ASCII guard comparisons follow GitHub's case-insensitive string semantics; unknown PR refs, non-ASCII mismatches, and glob/escaped branch filters cannot prove a privileged job inactive. The shared summary's `action_mode` distinguishes new/resumable publication from read-only reconciliation and drives idle and pending task button wording. Branch-only collisions and unknown/abandoned states offer neither; known PR receipts and pending PR-creation checkpoints retain read-only reconciliation. Abandonment checks the current run, saved intent, exact branch head and absence of an associated PR; it writes no provider data, preserves the remote branch/evidence, disables republishing and permits local removal. Uncertain effects and incompatible evidence stay blocked. Destination remains private operator config; patch-only remains default |
37
+ | Optional trusted web verification | `web probe` performs a real local Chromium interaction; `doctor` reports readiness | Verify stores a shared story summary and protected JSON artifact in the run | Task history shows passed/failed/unavailable/inconclusive plus tool, candidate, policy and story hashes | Disabled by default. Required operator stories and Playwright/Chromium image ID are frozen in attempt policy. Linux Chromium proof cannot qualify native/mobile OS behavior; see [the browser contract](web-verification.md) |
37
38
 
38
39
  The current generic task form can name the Defence workflow; that is not a
39
40
  substitute for the CLI's validated incident admission. Treat the typed intake
package/docs/recovery.md CHANGED
@@ -29,6 +29,21 @@ alongside the cleanup error.
29
29
  Inspect that retained attempt before manual removal; never substitute the source
30
30
  candidate path for the scratch path.
31
31
 
32
+ When trusted web verification is enabled, Verify keeps the copied check output
33
+ as disposable preview scratch and creates separate preview and browser
34
+ containers. The preview uses `network none`; the browser shares only that exact
35
+ preview's isolated network namespace for loopback. Their mounts and PID
36
+ namespaces remain separate, and only the browser mounts its private result and
37
+ screenshot directory. The `web-policy.json`, preview scratch, browser output
38
+ and active fence remain until both exact labelled containers are confirmed
39
+ removed. Normal stop/retry recovery reconciles all Factory-labelled containers
40
+ before removing those files. An unknown stop, deadline or cancellation keeps
41
+ the state and fence for recovery. A missing browser, unsupported Linux
42
+ capability, interrupted story or malformed evidence blocks acceptance; never
43
+ retry around a retained writer or reuse earlier story evidence for a changed
44
+ candidate or policy. Browser traces and screenshots are private evidence and
45
+ are presented through the shared interfaces. See [the web contract](web-verification.md).
46
+
32
47
  ## Review feedback or phase retry
33
48
 
34
49
  Use **Request changes** on a failed software review only when its validated
package/docs/setup.md CHANGED
@@ -169,6 +169,14 @@ Checkpoint: record the exact source revision, image ID, check command, resource
169
169
  limits, inference connectivity and CI result. Keep product controllers stopped
170
170
  until their tasks are explicitly ready to run.
171
171
 
172
+ Optional web verification is configured separately from the selected harness
173
+ and model. The execution-host owner prepares and pins the Playwright/Chromium
174
+ image, writes trusted stories in private `factory.json`, then runs `web probe`
175
+ and `doctor` on that installation. Use [the browser setup and story contract](web-verification.md)
176
+ for the recipe, dependency preparation and limits. Verify uses separate isolated
177
+ Linux preview and browser containers and cannot qualify native/mobile operating
178
+ systems.
179
+
172
180
  ### Optional trusted PR delivery
173
181
 
174
182
  Patch-only handoff is the default. When the operator intends to enable GitHub
@@ -0,0 +1,167 @@
1
+ # Optional trusted web verification
2
+
3
+ Browser verification is an operator-controlled extension of the existing
4
+ Verify phase. It is disabled when `webVerification` is absent. Issue text,
5
+ candidate files and generated tests cannot enable it or change its required
6
+ stories. The selected harness and model remain independent of this capability.
7
+
8
+ ## Prepare and qualify Playwright/Chromium
9
+
10
+ The first adapter uses the Playwright 1.63.0 package and Chromium version from
11
+ the matching Microsoft Playwright image. Build it explicitly on the execution
12
+ host from the installed Factory package (or a trusted Factory source checkout):
13
+
14
+ ```sh
15
+ docker build --pull -f factory/web/Dockerfile \
16
+ -t software-defence-factory-playwright:1.63.0 factory/web
17
+ docker image inspect --format '{{.Id}}' software-defence-factory-playwright:1.63.0
18
+ ```
19
+
20
+ Copy the resulting `sha256:…` image ID into private `factory.json`. The
21
+ Dockerfile uses the versioned `mcr.microsoft.com/playwright:v1.63.0-noble`
22
+ base and `npm ci` with the committed lockfile. Factory resolves no tag, pulls
23
+ no image, installs no package and falls back to no other browser during a job.
24
+ Rebuild and deliberately select a new image ID when upgrading the base or
25
+ Playwright version.
26
+
27
+ For a global npm installation, resolve the packaged recipe directory first:
28
+
29
+ ```sh
30
+ factory_package="$(npm root -g)/software-defence-factory"
31
+ docker build --pull -f "$factory_package/factory/web/Dockerfile" \
32
+ -t software-defence-factory-playwright:1.63.0 "$factory_package/factory/web"
33
+ browser_image="$(docker image inspect --format '{{.Id}}' software-defence-factory-playwright:1.63.0)"
34
+ ```
35
+
36
+ Run the readiness check from the selected installation:
37
+
38
+ ```sh
39
+ software-defence-factory web probe --state /private/state/project
40
+ software-defence-factory doctor --state /private/state/project
41
+ ```
42
+
43
+ The probe runs a real headless Chromium click and result assertion in a bounded,
44
+ read-only, `network none` Docker container as the controller UID. Its real
45
+ wall-clock deadline stops the exact labelled probe, and readiness is reported
46
+ only after that container is confirmed removed. A missing image, unsupported
47
+ adapter/version or browser launch failure reports unavailable. The probe never
48
+ downloads or builds an image.
49
+
50
+ ## Configure trusted stories
51
+
52
+ Stop the installation before editing its private `factory.json`. Add an
53
+ operator-authored value like this and adapt names, routes, actions and result
54
+ text to the application:
55
+
56
+ ```json
57
+ {
58
+ "webVerification": {
59
+ "enabled": true,
60
+ "adapter": "playwright",
61
+ "version": "1.63.0",
62
+ "image": "sha256:REPLACE_WITH_LOCAL_IMAGE_ID",
63
+ "previewCommand": ["npm", "run", "preview", "--", "--host", "127.0.0.1", "--port", "4173"],
64
+ "port": 4173,
65
+ "timeoutSeconds": 90,
66
+ "themes": ["light", "dark"],
67
+ "stories": []
68
+ }
69
+ }
70
+ ```
71
+
72
+ `stories` is a small declarative contract; the empty array in this sketch is a
73
+ placeholder and is rejected until the complete trusted story set is supplied.
74
+ Every selected theme requires an interactive `desktop-<theme>` and
75
+ `narrow-<theme>` story. All installations
76
+ also require these named stories, using the first configured theme at desktop
77
+ size:
78
+
79
+ - `busy-disabled`: click an action, observe its disabled state, wait for it to
80
+ become enabled, and check the result text.
81
+ - `result`: exercise an action and assert its result text.
82
+ - `failure-retry`: assert a `role: "alert"` failure, click Retry, then assert a
83
+ `role: "status"` result.
84
+ - `keyboard-focus`: press a supported key and assert the expected control has
85
+ focus.
86
+
87
+ Each story names `viewport` (`desktop` or `narrow`), `theme` (`light` or
88
+ `dark`), a local `path` beginning with `/`, and ordered `steps`. Supported
89
+ steps are `click`, `expect-visible`, `expect-enabled`, `expect-disabled`,
90
+ `expect-text`, `press` and `expect-focused`. Actions and assertions use exact
91
+ accessible roles and names. Arbitrary selectors, JavaScript, source tests and
92
+ candidate-supplied story files are not accepted. `factory/web/qualification-fixture.mjs`
93
+ shows the complete eight-story contract used by the disposable installed
94
+ qualification.
95
+
96
+ `execution.json` freezes and hashes this complete operator configuration with
97
+ the attempt policy. The check evidence records the candidate head/tree, Verify
98
+ attempt, policy hash, immutable browser image ID, adapter/tool/browser versions,
99
+ story-set hash and each story's content hash. Any changed tree, tool, story or
100
+ policy makes prior evidence stale. Missing, failed, unavailable, inconclusive
101
+ or malformed required evidence blocks Review, handoff and trusted PR delivery.
102
+ The run summary and `web-verification.json` artifact are shared through CLI,
103
+ API and dashboard; the dashboard renders traces as text and retained PNGs as
104
+ images. It does not execute candidate HTML in the controller origin.
105
+
106
+ ## Package and preview guidance
107
+
108
+ Use the configured project check for dependency preparation and build output.
109
+ For Node projects, install from the committed lockfile with the project's
110
+ frozen install command (for example `npm ci --ignore-scripts`), run the build,
111
+ then configure `previewCommand` to start the already-prepared local preview
112
+ from the project root in the disposable check copy.
113
+ The preview phase reuses that check's disposable scratch copy. It does not run
114
+ another install or contact a package registry. The qualified project job image
115
+ provides the preview runtime. Do not add secrets to the preview environment or
116
+ image.
117
+
118
+ Verify starts two containers with separate mount and PID namespaces. The
119
+ preview uses the configured project job image, the candidate at `/workspace`
120
+ read-only, and the writable `/scratch` copy. It runs as the controller UID with
121
+ `network none`, dropped capabilities, a read-only root and bounded resources.
122
+ The browser uses the pinned Playwright image as the same UID, with its own
123
+ read-only root, writable temporary profile and a separate private output mount.
124
+ It receives the frozen trusted stories on stdin and does not mount the
125
+ candidate, project scratch or controller results. The browser shares only the
126
+ exact preview container's `network none` namespace to reach its loopback port;
127
+ each container retains its own PID namespace. Neither container gets host
128
+ networking, published ports, extra capabilities, a Docker socket, controller,
129
+ forge or inference credentials, or a user profile.
130
+
131
+ The controller enforces a real wall-clock deadline, stops and reconciles both
132
+ exact labelled containers, then reads the bounded result and PNG screenshots
133
+ from the browser-only mount. The private result contains candidate/tree,
134
+ attempt, policy, pinned tool/image and story hashes, bounded action traces and
135
+ screenshot hashes. CLI, API and dashboard expose the same evidence; screenshots
136
+ can be viewed as images, and no candidate HTML is served on the controller
137
+ origin. Chromium is headless and uses the outer Docker boundary; this is not a
138
+ hosted browser service.
139
+
140
+ This adapter proves only Linux Chromium interaction with a local web preview.
141
+ It does not qualify Android/iOS, desktop-native applications, Safari, Firefox,
142
+ mobile operating systems, production access or an application's overall
143
+ correctness. Deterministic checks and independent Review remain separate.
144
+
145
+ ## Installed synthetic fixture qualification
146
+
147
+ The release lead owns this Docker/browser qualification on the installed
148
+ execution host. Use a separate demo state, complete and inspect the demo's
149
+ normal sample handoff, stop that controller, then run the exact qualification
150
+ command with the already-built browser image ID:
151
+
152
+ ```sh
153
+ software-defence-factory demo --state /private/state/sdf-web-proof --port 7348
154
+ # Review and approve the synthetic demo task in its dashboard.
155
+ software-defence-factory stop --state /private/state/sdf-web-proof
156
+ browser_image="$(docker image inspect --format '{{.Id}}' software-defence-factory-playwright:1.63.0)"
157
+ software-defence-factory qualify-web --state /private/state/sdf-web-proof --image "$browser_image"
158
+ ```
159
+
160
+ The installed command probes actual Chromium interaction, then runs a
161
+ disposable delayed-save/failure-retry fixture through mock Build/Review and
162
+ real Docker Verify. Its deliberate variant removes the busy disabled state and
163
+ changes the result text; Verify must fail and no acceptance record may appear.
164
+ The command records a private `qualification.json` under the demo state's
165
+ private qualification directory. It never targets an application or calls a
166
+ model/provider. Do not report this as passed until the lead observes the
167
+ installed invocation and its result.
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { ROOT, digest, harnessOf } from './lib.mjs';
4
+ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
4
5
 
5
6
  // Execution order is shared with the queue; presentation cannot invent phases.
6
7
  export const WORKFLOWS = Object.freeze({
@@ -9,7 +10,7 @@ export const WORKFLOWS = Object.freeze({
9
10
  });
10
11
  const phaseInfo = {
11
12
  build: { title: 'Implement', owner: 'agent', skills: ['factory-implement'], description: 'Implement the accepted scope in an isolated checkout. Produce a candidate and evidence.' },
12
- verify: { title: 'Check', owner: 'factory', skills: [], description: 'Run the project check command against the candidate. A failure stops delivery.' },
13
+ verify: { title: 'Check', owner: 'factory', skills: [], description: 'Run project checks and any optional trusted browser stories against the candidate. Missing or non-passing required proof stops delivery.' },
13
14
  review: { title: 'Review', owner: 'agent', skills: ['factory-review', 'factory-security'], description: 'Review the candidate and evidence in a separate agent invocation. Security review applies when required by the accepted scope.' },
14
15
  handoff: { title: 'Accept & hand off', owner: 'operator', skills: [], description: 'Wait for operator approval, then confirm the candidate and policy still match the checks and review. Record acceptance; do not push, merge or deploy.' },
15
16
  defence: { title: 'Investigate', owner: 'agent', skills: ['factory-security'], description: 'Investigate supplied incident evidence within the accepted scope. Produce a private draft with findings and unknowns. No production access or recovery action is granted.' },
@@ -40,7 +41,13 @@ export function factoryDefinition(config) {
40
41
  skills,
41
42
  configuration: { harness, agent: harness, // agent is a v1 compatibility alias
42
43
  model: config.model || null, check: config.check, timeoutSeconds: config.timeoutSeconds,
43
- memoryMiB: config.memoryMiB, cpus: config.cpus || 2 },
44
+ memoryMiB: config.memoryMiB, cpus: config.cpus || 2,
45
+ web_verification: config.webVerification?.enabled ? {
46
+ enabled: true, adapter: config.webVerification.adapter, version: config.webVerification.version,
47
+ browser: 'chromium', image: config.webVerification.image, policy_hash: webPolicyHash(config.webVerification),
48
+ required_stories: expectedWebStories(config.webVerification), platform: 'linux-container', coverage: 'web',
49
+ } : { enabled: false },
50
+ },
44
51
  method: {
45
52
  preparation: ['factory-triage', 'factory-spec'], evaluation: ['factory-evaluate'],
46
53
  instructions: 'All six skills are available read-only to agent phases. A skill is an instruction set, not a separate agent or an automatic workflow step.',
@@ -10,6 +10,7 @@ import { publicSourceAdmission } from './source-admission.mjs';
10
10
  import { QueueError } from './queue.mjs';
11
11
  import { readProjectLinks } from './project-links.mjs';
12
12
  import { qualifyGitHubActions } from './workflow-qualification.mjs';
13
+ import { assertCurrentWebEvidence, assertCurrentWebArtifacts } from './web-verification.mjs';
13
14
 
14
15
  const MAX_PATCH_BYTES = 8 * 1024 * 1024;
15
16
  const MAX_CHANGED_FILES = 500;
@@ -269,6 +270,16 @@ function acceptanceSummary(job, state, config, sourceAdmission) {
269
270
  const byID = id => job.runs.find(run => run.id === id);
270
271
  const buildRun = byID(accepted.build_run_id), checkRun = byID(accepted.checks_run_id), reviewRun = byID(accepted.review_run_id);
271
272
  const handoffRun = byID(accepted.handoff_run_id);
273
+ let browserEvidenceCurrent = true;
274
+ if (config.webVerification?.enabled) {
275
+ try {
276
+ assertCurrentWebEvidence(config.webVerification, checks.web_verification,
277
+ { job: job.id, attempt: checkRun?.id, meta: candidate, policyHash: expectedPolicy });
278
+ assertCurrentWebArtifacts(config.webVerification, checks.web_verification,
279
+ join(state, 'jobs', job.id, 'artifacts', checks.web_verification.attempt));
280
+ }
281
+ catch { browserEvidenceCurrent = false; }
282
+ }
272
283
  const phaseEvidence = trustedPhaseEvidence(state, job, expectedPolicy,
273
284
  { build: buildRun, verify: checkRun, review: reviewRun, handoff: handoffRun }, candidate, checks, review);
274
285
  if (!phaseEvidence.trusted) return { state: 'blocked', reason: phaseEvidence.reason };
@@ -282,7 +293,7 @@ function acceptanceSummary(job, state, config, sourceAdmission) {
282
293
  && accepted.base === job.source_admission?.resolved_sha && accepted.policyHash === expectedPolicy
283
294
  && accepted.checks_run_id === checkRun.id && accepted.review_run_id === reviewRun.id
284
295
  && checks.run_id === checkRun.id && checks.passed === true && checks.head === accepted.head
285
- && checks.tree === accepted.tree && checks.policyHash === expectedPolicy && checks.command === config.check
296
+ && checks.tree === accepted.tree && checks.policyHash === expectedPolicy && checks.command === config.check && browserEvidenceCurrent
286
297
  && review.run_id === reviewRun.id && review.verdict === 'pass' && review.head === accepted.head
287
298
  && review.tree === accepted.tree && review.policyHash === expectedPolicy && reviewRun.review_verdict === 'pass';
288
299
  if (!bound) return { state: 'blocked', reason: 'Candidate, checks, review or approval evidence is stale or does not match the current Factory policy.' };
@@ -515,6 +526,15 @@ export class DeliveryService {
515
526
  || review.run_id !== reviewRun.id || review.verdict !== 'pass' || review.head !== accepted.head || review.tree !== accepted.tree
516
527
  || review.policyHash !== expectedPolicy || reviewRun.review_verdict !== 'pass')
517
528
  throw new QueueError('Current successful checks and independent review for this candidate are required.');
529
+ if (config.webVerification?.enabled) {
530
+ try {
531
+ assertCurrentWebEvidence(config.webVerification, checks.web_verification,
532
+ { job: job.id, attempt: checkRun.id, meta: candidate, policyHash: expectedPolicy });
533
+ assertCurrentWebArtifacts(config.webVerification, checks.web_verification,
534
+ join(this.state, 'jobs', job.id, 'artifacts', checks.web_verification.attempt));
535
+ }
536
+ catch { throw new QueueError('Required browser evidence is missing, stale or non-passing; publication is blocked.'); }
537
+ }
518
538
  if (!SHA1.test(accepted.base || '') || !SHA1.test(accepted.head || '') || !SHA1.test(accepted.tree || '')
519
539
  || accepted.head === accepted.base || candidate.parent !== accepted.base)
520
540
  throw new QueueError('Candidate base, head or tree is invalid for trusted PR delivery.');
@@ -1,7 +1,11 @@
1
- export function assertCurrentHandoffEvidence(meta, checks, review, policyHash) {
1
+ import { assertCurrentWebEvidence } from './web-verification.mjs';
2
+
3
+ export function assertCurrentHandoffEvidence(meta, checks, review, policyHash, config = {}, job) {
2
4
  if (review.verdict !== 'pass' || review.head !== meta.head || review.tree !== meta.tree
3
5
  || !checks.passed || checks.head !== meta.head || checks.tree !== meta.tree
4
6
  || checks.policyHash !== policyHash || review.policyHash !== policyHash
5
7
  || meta.build_policy_hash !== policyHash)
6
8
  throw new Error('Build/checks/review do not cover candidate and current policy');
9
+ if (config.webVerification?.enabled) assertCurrentWebEvidence(config.webVerification, checks.web_verification,
10
+ { job, attempt: checks.run_id, meta, policyHash });
7
11
  }
@@ -4,6 +4,7 @@ import { digest } from './lib.mjs';
4
4
  import { VERSION } from './updates.mjs';
5
5
  import { usageFields } from './usage.mjs';
6
6
  import { effectiveInferenceProvider } from './model-environment.mjs';
7
+ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
7
8
 
8
9
  export function withRequestedModel(configuration, requestedModel) {
9
10
  const config = structuredClone(configuration);
@@ -33,13 +34,19 @@ export function executionProfile(config, phase) {
33
34
  const applicable = !deterministic && harnessOf(config) !== 'mock';
34
35
  const provider = applicable && ['codex', 'pi'].includes(harnessOf(config));
35
36
  const requestedModel = provider ? config.model || null : null;
36
- return {
37
+ const profile = {
37
38
  version: 1, phase, executor: deterministic ? 'deterministic' : harnessOf(config),
38
39
  requestedModel,
39
40
  modelSelection: !applicable ? 'not_applicable' : !provider ? 'unknown' : requestedModel ? 'explicit' : 'provider_default',
40
41
  runtimeVersion: VERSION, image: phase === 'handoff' ? null : config.image,
41
42
  policyHash: digest(JSON.stringify(config)), hostName: hostname(),
42
43
  };
44
+ if (config.webVerification?.enabled) profile.webVerification = {
45
+ enabled: true, adapter: config.webVerification.adapter, version: config.webVerification.version,
46
+ browser: 'chromium', image: config.webVerification.image, policyHash: webPolicyHash(config.webVerification),
47
+ requiredStories: expectedWebStories(config.webVerification), platform: 'linux-container',
48
+ };
49
+ return profile;
43
50
  }
44
51
 
45
52
  export function attemptPresentation(attempt, recoveredUsage) {