@mjasnikovs/pi-task 0.21.6 → 0.21.8

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.
@@ -0,0 +1,112 @@
1
+ export type DeepRenderOutcome = {
2
+ outcome: 'pass';
3
+ detail: string;
4
+ } | {
5
+ outcome: 'fail';
6
+ detail: string;
7
+ } | {
8
+ outcome: 'skip';
9
+ note: string;
10
+ };
11
+ export interface LoginCredentials {
12
+ identifier: string;
13
+ password: string;
14
+ /** Key names only — the value is never logged or surfaced anywhere. */
15
+ identifierKey: string;
16
+ passwordKey: string;
17
+ }
18
+ /**
19
+ * Parse a dotenv-style file into a plain record. Deliberately minimal (KEY=VALUE,
20
+ * `export ` prefix, # comments, optional matching quotes) — this reads the same
21
+ * file the app's own runtime reads, and anything it cannot parse simply yields no
22
+ * credentials, which is a SKIP.
23
+ */
24
+ export declare function parseEnvFile(text: string): Record<string, string>;
25
+ /** The variables the booted app itself sees: its dotenv files, overlaid by the real
26
+ * process environment (bun/node never let a file override an exported var). */
27
+ export declare function collectProjectEnv(cwd: string, env?: NodeJS.ProcessEnv): Record<string, string>;
28
+ /**
29
+ * A declared app-account credential PAIR sharing one prefix (`ADMIN_PHONE` +
30
+ * `ADMIN_PASSWORD` — exactly what the launch contract's seed step consumes), or
31
+ * null when the project declares none. Null is a SKIP, never a FAIL (I3): a gate
32
+ * that failed for missing credentials would fail every project that has no login.
33
+ */
34
+ export declare function findLoginCredentials(vars: Record<string, string>): LoginCredentials | null;
35
+ /**
36
+ * The LOCAL port the project's own client was built to call, when its dotenv pins
37
+ * one (`APP_URL=http://localhost:3000`, `VITE_API_URL=…`), else null.
38
+ *
39
+ * Why the boot check wants it (measured on mx5@373e88d, both directions): a bundler
40
+ * bakes that base URL into the client at BUILD time, so a client served on the
41
+ * gate's freshly-reserved private port calls an origin nothing is listening on. The
42
+ * app is then unusable for reasons that have nothing to do with the code, and the
43
+ * authenticated assertions cannot run at all — the sign-in POST leaves for the dead
44
+ * origin and never reaches the server we booted. Serving on the app's own declared
45
+ * port makes the session same-origin and the evidence real.
46
+ *
47
+ * This deliberately narrows the private-port ownership evidence of run 14, so it
48
+ * only applies when the port is LOCAL, DECLARED by the project itself, and CURRENTLY
49
+ * FREE — the caller checks freeness and falls back to a reserved port otherwise.
50
+ */
51
+ export declare function pinnedLocalPort(vars: Record<string, string>): number | null;
52
+ export interface DeepSessionFacts {
53
+ /** The landing page presented a sign-in wall (a visible password input). */
54
+ landingHadAuthWall: boolean;
55
+ /** A credential pair was declared by the project. */
56
+ credentialsFound: boolean;
57
+ /** The form could be filled and submitted. */
58
+ submitted: boolean;
59
+ /** The sign-in request the SUBMIT issued, when one was issued at all. */
60
+ authRequest: {
61
+ method: string;
62
+ path: string;
63
+ status: number | null;
64
+ failed: boolean;
65
+ } | null;
66
+ /** Same-origin XHR/fetch requests issued AFTER the sign-in response, excluding
67
+ * the sign-in request itself. */
68
+ postAuthDataAttempted: number;
69
+ postAuthData2xx: number;
70
+ /** Origins the client called that are not the app's own, whose requests failed
71
+ * (a bundle pinned to a build-time base URL that is not the port under test). */
72
+ foreignOriginFailures: string[];
73
+ /** No visible password input after settle, or the URL path changed. */
74
+ leftAuthWall: boolean;
75
+ urlBefore: string;
76
+ urlAfter: string;
77
+ /** judgeRenderedDom over the post-sign-in DOM. */
78
+ postAuthDomOk: boolean;
79
+ postAuthDomDetail: string;
80
+ }
81
+ /**
82
+ * Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
83
+ * authenticated (2xx on the sign-in request) whose client then could not use it:
84
+ *
85
+ * - never left the wall → the run-17 signature exactly;
86
+ * - data calls attempted, none 2xx → the same class one page deeper;
87
+ * - post-sign-in page renders blank → the run-16 class behind the wall.
88
+ *
89
+ * Everything else is an environment or shape gap and SKIPs. Note what is NOT a
90
+ * failure: zero data calls attempted after sign-in. A server-rendered app that
91
+ * redirects to a fresh document legitimately issues no XHR at all, so the missing
92
+ * half is reported UNOBSERVED in the detail instead. (The task text asked for
93
+ * "≥1 same-origin /api/* 2xx during the session" as a hard assertion; STEP 0
94
+ * refuted that wording — the BROKEN mx5 build satisfies it with the probe's own
95
+ * login POST — so the assertion is registered post-auth and excludes the sign-in
96
+ * request. See the scratch REGISTERED-METRIC record quoted in the commit.)
97
+ */
98
+ export declare function judgeDeepSession(f: DeepSessionFacts): DeepRenderOutcome;
99
+ /** Whole-session wall-clock cap, including browser launch (I4). */
100
+ export declare const DEEP_RENDER_TIMEOUT_MS = 45000;
101
+ /**
102
+ * Drive one authenticated session against `url` and judge it. `cwd` is the project
103
+ * whose dotenv declares the account. Every failure mode of the DRIVER itself
104
+ * (no browser, launch failure, protocol error, timeout) resolves to SKIP: this
105
+ * check may only report on the app, never on itself.
106
+ */
107
+ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
108
+ browser?: string | null;
109
+ credentials?: LoginCredentials | null;
110
+ timeoutMs?: number;
111
+ env?: NodeJS.ProcessEnv;
112
+ }): Promise<DeepRenderOutcome>;
@@ -0,0 +1,659 @@
1
+ /**
2
+ * deep-render-check — sign in on the booted app and prove the AUTHENTICATED half
3
+ * of it is alive (mx5 run 17).
4
+ *
5
+ * The failure class this closes: the shallow render check (render-check.ts) loads
6
+ * ONE url and judges the rendered DOM. mx5 run 17 satisfies it completely while
7
+ * being unusable — GET / redirects to /login, the login page renders fully, and
8
+ * that is the whole check. Behind it, src/client/hooks/useAuth.ts called
9
+ * `typedClient.api.auth.me.get()`; hono RPC methods are `$get`/`$post`, so a bare
10
+ * `.get` is just another path segment in hono's createProxy and the call returns a
11
+ * request BUILDER that never issues a request. Login POSTs 200 and sets the
12
+ * session cookie, zero /api/auth/me requests are ever made, and the app bounces to
13
+ * /login forever. Seven call sites were dead the same way, mixed in the same files
14
+ * with correct `$get` ones — the signature of code no runtime ever executed. The
15
+ * server was healthy throughout: 134/134 tests, tsc clean, login 200, me-with-
16
+ * cookie 200. Nothing static could see it.
17
+ *
18
+ * The generic instrument is a NETWORK FACT: after a real sign-in, did the client
19
+ * actually leave the wall, and did its data calls actually reach the server. No
20
+ * cast, mock, replica or type assertion can fake a 2xx on the wire. Deliberately
21
+ * NOT a hono `.get`-vs-`$get` linter: that is a point fix for one library, while
22
+ * the runtime assertion catches the whole dead-client-call class for every client
23
+ * library.
24
+ *
25
+ * SCOPE HONESTY: this is WEB-ONLY. It runs only behind detectsServedApp() and does
26
+ * nothing for C++, Godot, CLI or library projects, which are most of the fleet. It
27
+ * is here because the class is the most expensive one observed — it ended run 16
28
+ * (blank page) and run 17 (dead login) outright.
29
+ *
30
+ * Mechanism, dependency-free: the same discovered Chrome-family binary the shallow
31
+ * check uses, driven over the DevTools protocol through the `ws` dependency the
32
+ * remote server already ships. Nothing is installed; no browser on the box → SKIP.
33
+ *
34
+ * WHAT MAY FAIL, and nothing else: only a session where the server ITSELF accepted
35
+ * our credentials (the sign-in request returned 2xx) and the client still could not
36
+ * use them. Every other outcome — no browser, no declared credentials, a form we
37
+ * could not drive, a sign-in the server rejected, a client pinned to another origin
38
+ * — is an environment gap and SKIPs with an UNOBSERVED note. See judgeDeepSession.
39
+ */
40
+ import { spawn } from 'node:child_process';
41
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
42
+ import * as os from 'node:os';
43
+ import * as path from 'node:path';
44
+ import WebSocket from 'ws';
45
+ import { findHeadlessBrowser, judgeRenderedDom } from './render-check.js';
46
+ /** Identifier halves of a credential pair, in preference order. */
47
+ const IDENTIFIER_SUFFIXES = ['PHONE', 'EMAIL', 'USERNAME', 'USER', 'LOGIN', 'IDENTIFIER'];
48
+ const PASSWORD_SUFFIXES = ['PASSWORD', 'PASSWD', 'PASS'];
49
+ /** Prefixes whose `_PASSWORD` belongs to infrastructure, not to an app account.
50
+ * `DB_USER`/`DB_PASSWORD` pair perfectly and would otherwise be tried as a login. */
51
+ const INFRA_PREFIX_RE = /^(DATABASE|DB|POSTGRES|POSTGRESQL|PG|MYSQL|MARIADB|MONGO|MONGODB|REDIS|RABBIT|RABBITMQ|AMQP|KAFKA|SMTP|IMAP|MAIL|MAILER|S3|MINIO|AWS|GCP|AZURE|DOCKER|REGISTRY|NPM|GITHUB|GITLAB|PROXY|LDAP|VAULT|GRAFANA|SENTRY)$/i;
52
+ /** Prefixes that name a seeded APP account, tried before any other pair. */
53
+ const ACCOUNT_PREFIX_ORDER = ['ADMIN', 'TEST', 'E2E', 'SEED', 'DEV', 'DEFAULT', 'USER', 'LOGIN'];
54
+ /** Split `ADMIN_PHONE` into prefix `ADMIN` + suffix `PHONE`; '' prefix for a bare
55
+ * `PASSWORD`. Returns null when the key does not end in one of `suffixes`. */
56
+ function splitKey(key, suffixes) {
57
+ const upper = key.toUpperCase();
58
+ for (const suffix of suffixes) {
59
+ if (upper === suffix)
60
+ return { prefix: '', suffix };
61
+ if (upper.endsWith(`_${suffix}`)) {
62
+ return { prefix: upper.slice(0, -(suffix.length + 1)), suffix };
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * Parse a dotenv-style file into a plain record. Deliberately minimal (KEY=VALUE,
69
+ * `export ` prefix, # comments, optional matching quotes) — this reads the same
70
+ * file the app's own runtime reads, and anything it cannot parse simply yields no
71
+ * credentials, which is a SKIP.
72
+ */
73
+ export function parseEnvFile(text) {
74
+ const out = {};
75
+ for (const raw of text.split(/\r?\n/)) {
76
+ const line = raw.trim();
77
+ if (line.length === 0 || line.startsWith('#'))
78
+ continue;
79
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
80
+ if (!m)
81
+ continue;
82
+ let value = m[2].trim();
83
+ const quote = value[0];
84
+ if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
85
+ value = value.slice(1, -1);
86
+ }
87
+ else {
88
+ value = value.split(' #')[0].trim();
89
+ }
90
+ out[m[1]] = value;
91
+ }
92
+ return out;
93
+ }
94
+ /** The variables the booted app itself sees: its dotenv files, overlaid by the real
95
+ * process environment (bun/node never let a file override an exported var). */
96
+ export function collectProjectEnv(cwd, env = process.env) {
97
+ const merged = {};
98
+ for (const name of ['.env', '.env.local', '.env.development']) {
99
+ const file = path.join(cwd, name);
100
+ if (!existsSync(file))
101
+ continue;
102
+ try {
103
+ Object.assign(merged, parseEnvFile(readFileSync(file, 'utf8')));
104
+ }
105
+ catch {
106
+ // unreadable dotenv → no credentials from it, never a failure
107
+ }
108
+ }
109
+ for (const [k, v] of Object.entries(env)) {
110
+ if (typeof v === 'string' && v.length > 0)
111
+ merged[k] = v;
112
+ }
113
+ return merged;
114
+ }
115
+ /**
116
+ * A declared app-account credential PAIR sharing one prefix (`ADMIN_PHONE` +
117
+ * `ADMIN_PASSWORD` — exactly what the launch contract's seed step consumes), or
118
+ * null when the project declares none. Null is a SKIP, never a FAIL (I3): a gate
119
+ * that failed for missing credentials would fail every project that has no login.
120
+ */
121
+ export function findLoginCredentials(vars) {
122
+ const passwords = new Map(); // prefix → key
123
+ const identifiers = new Map(); // prefix → keys, preference order
124
+ for (const key of Object.keys(vars)) {
125
+ if ((vars[key] ?? '').length === 0)
126
+ continue;
127
+ const pw = splitKey(key, PASSWORD_SUFFIXES);
128
+ if (pw && !INFRA_PREFIX_RE.test(pw.prefix) && !passwords.has(pw.prefix)) {
129
+ passwords.set(pw.prefix, key);
130
+ continue;
131
+ }
132
+ const id = splitKey(key, IDENTIFIER_SUFFIXES);
133
+ if (id && !INFRA_PREFIX_RE.test(id.prefix)) {
134
+ const list = identifiers.get(id.prefix) ?? [];
135
+ list.push(key);
136
+ identifiers.set(id.prefix, list);
137
+ }
138
+ }
139
+ const rank = (prefix) => {
140
+ const i = ACCOUNT_PREFIX_ORDER.indexOf(prefix);
141
+ return i === -1 ? ACCOUNT_PREFIX_ORDER.length : i;
142
+ };
143
+ const prefixes = [...passwords.keys()]
144
+ .filter(p => (identifiers.get(p) ?? []).length > 0)
145
+ .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
146
+ const prefix = prefixes[0];
147
+ if (prefix === undefined)
148
+ return null;
149
+ const idKeys = identifiers.get(prefix) ?? [];
150
+ const bySuffix = (key) => {
151
+ const s = splitKey(key, IDENTIFIER_SUFFIXES);
152
+ return s ? IDENTIFIER_SUFFIXES.indexOf(s.suffix) : IDENTIFIER_SUFFIXES.length;
153
+ };
154
+ const identifierKey = [...idKeys].sort((a, b) => bySuffix(a) - bySuffix(b))[0];
155
+ const passwordKey = passwords.get(prefix);
156
+ if (!identifierKey || !passwordKey)
157
+ return null;
158
+ return {
159
+ identifier: vars[identifierKey],
160
+ password: vars[passwordKey],
161
+ identifierKey,
162
+ passwordKey
163
+ };
164
+ }
165
+ /**
166
+ * The LOCAL port the project's own client was built to call, when its dotenv pins
167
+ * one (`APP_URL=http://localhost:3000`, `VITE_API_URL=…`), else null.
168
+ *
169
+ * Why the boot check wants it (measured on mx5@373e88d, both directions): a bundler
170
+ * bakes that base URL into the client at BUILD time, so a client served on the
171
+ * gate's freshly-reserved private port calls an origin nothing is listening on. The
172
+ * app is then unusable for reasons that have nothing to do with the code, and the
173
+ * authenticated assertions cannot run at all — the sign-in POST leaves for the dead
174
+ * origin and never reaches the server we booted. Serving on the app's own declared
175
+ * port makes the session same-origin and the evidence real.
176
+ *
177
+ * This deliberately narrows the private-port ownership evidence of run 14, so it
178
+ * only applies when the port is LOCAL, DECLARED by the project itself, and CURRENTLY
179
+ * FREE — the caller checks freeness and falls back to a reserved port otherwise.
180
+ */
181
+ export function pinnedLocalPort(vars) {
182
+ const ports = [];
183
+ for (const [key, value] of Object.entries(vars)) {
184
+ if (!/URL$/i.test(key) || typeof value !== 'string')
185
+ continue;
186
+ const m = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(?::(\d{2,5}))?(?:\/|$)/i.exec(value.trim());
187
+ if (!m)
188
+ continue;
189
+ const port = m[1] ? Number(m[1])
190
+ : value.trim().toLowerCase().startsWith('https') ? 443
191
+ : 80;
192
+ if (Number.isInteger(port) && port > 1024 && port < 65536)
193
+ ports.push(port);
194
+ }
195
+ return ports.length > 0 ? Math.min(...ports) : null;
196
+ }
197
+ /**
198
+ * Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
199
+ * authenticated (2xx on the sign-in request) whose client then could not use it:
200
+ *
201
+ * - never left the wall → the run-17 signature exactly;
202
+ * - data calls attempted, none 2xx → the same class one page deeper;
203
+ * - post-sign-in page renders blank → the run-16 class behind the wall.
204
+ *
205
+ * Everything else is an environment or shape gap and SKIPs. Note what is NOT a
206
+ * failure: zero data calls attempted after sign-in. A server-rendered app that
207
+ * redirects to a fresh document legitimately issues no XHR at all, so the missing
208
+ * half is reported UNOBSERVED in the detail instead. (The task text asked for
209
+ * "≥1 same-origin /api/* 2xx during the session" as a hard assertion; STEP 0
210
+ * refuted that wording — the BROKEN mx5 build satisfies it with the probe's own
211
+ * login POST — so the assertion is registered post-auth and excludes the sign-in
212
+ * request. See the scratch REGISTERED-METRIC record quoted in the commit.)
213
+ */
214
+ export function judgeDeepSession(f) {
215
+ if (!f.landingHadAuthWall) {
216
+ return {
217
+ outcome: 'pass',
218
+ detail: 'the landing page is not a sign-in wall — the authenticated assertions do not apply'
219
+ };
220
+ }
221
+ if (!f.credentialsFound) {
222
+ return {
223
+ outcome: 'skip',
224
+ note: 'the landing page is a sign-in wall but the project declares no account credentials '
225
+ + '(no matching <PREFIX>_PHONE/EMAIL/USER + <PREFIX>_PASSWORD pair) — the authenticated '
226
+ + 'half of the app was NOT observed'
227
+ };
228
+ }
229
+ if (!f.submitted) {
230
+ return {
231
+ outcome: 'skip',
232
+ note: 'the sign-in form could not be driven (no submit control found) — the authenticated half of the app was NOT observed'
233
+ };
234
+ }
235
+ if (f.authRequest === null) {
236
+ const pinned = f.foreignOriginFailures.length > 0 ?
237
+ ` — the client calls ${f.foreignOriginFailures.join(', ')}, not the origin under test (a base URL baked in at build time)`
238
+ : '';
239
+ return {
240
+ outcome: 'skip',
241
+ note: `submitting the sign-in form issued no request to the app's own origin${pinned} — the authenticated half of the app was NOT observed`
242
+ };
243
+ }
244
+ const { method, path: p, status, failed } = f.authRequest;
245
+ if (failed || status === null || status < 200 || status >= 300) {
246
+ return {
247
+ outcome: 'skip',
248
+ note: `the server did not accept the declared credentials (\`${method} ${p}\` → `
249
+ + `${failed ? 'request failed' : String(status)}) — with no authenticated session there is `
250
+ + 'nothing to assert; the authenticated half of the app was NOT observed'
251
+ };
252
+ }
253
+ const signedIn = `signed in (\`${method} ${p}\` → ${status})`;
254
+ if (!f.leftAuthWall) {
255
+ return {
256
+ outcome: 'fail',
257
+ detail: `${signedIn} but the client NEVER LEFT THE SIGN-IN WALL: the page is still `
258
+ + `${f.urlAfter} with a password field, and ${f.postAuthData2xx} of `
259
+ + `${f.postAuthDataAttempted} same-origin data request(s) after sign-in succeeded. `
260
+ + 'The server authenticated the session and the client could not use it — the dead '
261
+ + 'client-call class (a request builder that is never sent, a wrong RPC method name, '
262
+ + 'a handler that never fires). No type, cast or mock can produce the missing 2xx.'
263
+ };
264
+ }
265
+ if (!f.postAuthDomOk) {
266
+ return {
267
+ outcome: 'fail',
268
+ detail: `${signedIn} and reached ${f.urlAfter}, but ${f.postAuthDomDetail}`
269
+ };
270
+ }
271
+ if (f.postAuthDataAttempted > 0 && f.postAuthData2xx === 0) {
272
+ return {
273
+ outcome: 'fail',
274
+ detail: `${signedIn} and reached ${f.urlAfter}, but EVERY same-origin data request the `
275
+ + `authenticated client made failed: 0 of ${f.postAuthDataAttempted} returned 2xx. `
276
+ + 'The page rendered, its data did not.'
277
+ };
278
+ }
279
+ const dataEvidence = f.postAuthDataAttempted > 0 ?
280
+ `${f.postAuthData2xx}/${f.postAuthDataAttempted} same-origin data requests returned 2xx`
281
+ : 'UNOBSERVED: the authenticated page issued no same-origin data request, so the '
282
+ + 'network half could not be checked (a server-rendered app legitimately issues none)';
283
+ return {
284
+ outcome: 'pass',
285
+ detail: `${signedIn}, left the wall for ${f.urlAfter}, ${dataEvidence}`
286
+ };
287
+ }
288
+ // ── CDP driver ───────────────────────────────────────────────────────────────
289
+ /** Whole-session wall-clock cap, including browser launch (I4). */
290
+ export const DEEP_RENDER_TIMEOUT_MS = 45_000;
291
+ /** No new request for this long ⇒ the page has settled. */
292
+ const QUIET_MS = 1_200;
293
+ /** Caps for the two settle windows (initial load, post-submit). */
294
+ const SETTLE_CAP_MS = 8_000;
295
+ const POST_SUBMIT_CAP_MS = 12_000;
296
+ /** Minimal DevTools-protocol client: request/response ids over one socket, plus
297
+ * event fan-out. Everything the driver needs and nothing more. */
298
+ class Cdp {
299
+ ws;
300
+ nextId = 1;
301
+ pending = new Map();
302
+ handlers = new Map();
303
+ constructor(ws) {
304
+ this.ws = ws;
305
+ ws.on('message', (data) => {
306
+ let msg;
307
+ try {
308
+ msg = JSON.parse(String(data));
309
+ }
310
+ catch {
311
+ return;
312
+ }
313
+ if (typeof msg.id === 'number') {
314
+ const p = this.pending.get(msg.id);
315
+ if (!p)
316
+ return;
317
+ this.pending.delete(msg.id);
318
+ if (msg.error)
319
+ p.reject(new Error(msg.error.message ?? 'CDP error'));
320
+ else
321
+ p.resolve(msg.result ?? {});
322
+ return;
323
+ }
324
+ if (!msg.method)
325
+ return;
326
+ for (const h of this.handlers.get(msg.method) ?? [])
327
+ h(msg.params ?? {});
328
+ });
329
+ }
330
+ on(method, cb) {
331
+ const list = this.handlers.get(method) ?? [];
332
+ list.push(cb);
333
+ this.handlers.set(method, list);
334
+ }
335
+ send(method, params = {}, sessionId) {
336
+ const id = this.nextId++;
337
+ return new Promise((resolve, reject) => {
338
+ this.pending.set(id, { resolve, reject });
339
+ try {
340
+ this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
341
+ }
342
+ catch (e) {
343
+ this.pending.delete(id);
344
+ reject(e instanceof Error ? e : new Error(String(e)));
345
+ }
346
+ });
347
+ }
348
+ }
349
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
350
+ /** Page-side probe: what the user would see right now. */
351
+ const INSPECT_EXPR = `(() => {
352
+ const visible = el => {
353
+ const r = el.getBoundingClientRect()
354
+ const s = getComputedStyle(el)
355
+ return s.display !== 'none' && s.visibility !== 'hidden' && (r.width > 0 || r.height > 0)
356
+ }
357
+ const pw = [...document.querySelectorAll('input[type=password]')].filter(visible)
358
+ return {
359
+ hasPassword: pw.length > 0,
360
+ url: location.href,
361
+ pathname: location.pathname,
362
+ html: document.documentElement.outerHTML.slice(0, 400000)
363
+ }
364
+ })()`;
365
+ /** Page-side fill: native value setters + input/change events, so a controlled
366
+ * React/Vue/Svelte input actually updates its state (a bare `el.value = …` does
367
+ * not, and the form then submits empty). */
368
+ function fillExpr(identifier, password) {
369
+ return `(() => {
370
+ const setValue = (el, v) => {
371
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
372
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
373
+ if (desc && desc.set) desc.set.call(el, v)
374
+ else el.value = v
375
+ el.dispatchEvent(new Event('input', {bubbles: true}))
376
+ el.dispatchEvent(new Event('change', {bubbles: true}))
377
+ }
378
+ const pw = document.querySelector('input[type=password]')
379
+ if (!pw) return {ok: false, reason: 'no password input'}
380
+ const scope = pw.form ?? document
381
+ const skip = ['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'image', 'reset', 'password']
382
+ const ident = [...scope.querySelectorAll('input')].find(i => !skip.includes(i.type))
383
+ if (ident) { ident.focus(); setValue(ident, ${JSON.stringify(identifier)}) }
384
+ pw.focus()
385
+ setValue(pw, ${JSON.stringify(password)})
386
+ return {ok: true, identifierFilled: !!ident, hasForm: !!pw.form}
387
+ })()`;
388
+ }
389
+ /** Page-side submit, run as a SEPARATE evaluation so the framework has flushed the
390
+ * state updates the fill scheduled before the handler reads them. */
391
+ const SUBMIT_EXPR = `(() => {
392
+ const pw = document.querySelector('input[type=password]')
393
+ if (!pw) return {ok: false, reason: 'no password input'}
394
+ const scope = pw.form ?? document
395
+ const submit =
396
+ scope.querySelector('button[type=submit], input[type=submit]')
397
+ ?? [...scope.querySelectorAll('button')].find(b => !b.disabled)
398
+ ?? null
399
+ if (pw.form && typeof pw.form.requestSubmit === 'function') {
400
+ pw.form.requestSubmit(submit && submit.tagName === 'BUTTON' ? submit : undefined)
401
+ return {ok: true, how: 'requestSubmit'}
402
+ }
403
+ if (submit) { submit.click(); return {ok: true, how: 'click'} }
404
+ return {ok: false, reason: 'no submit control'}
405
+ })()`;
406
+ /** Wait until `ms` of silence pass with no new request, or `cap` elapses. */
407
+ async function settle(lastActivity, cap, quiet = QUIET_MS) {
408
+ const deadline = Date.now() + cap;
409
+ for (;;) {
410
+ const idleFor = Date.now() - lastActivity();
411
+ if (idleFor >= quiet || Date.now() >= deadline)
412
+ return;
413
+ await sleep(Math.min(200, quiet - idleFor));
414
+ }
415
+ }
416
+ /**
417
+ * Drive one authenticated session against `url` and judge it. `cwd` is the project
418
+ * whose dotenv declares the account. Every failure mode of the DRIVER itself
419
+ * (no browser, launch failure, protocol error, timeout) resolves to SKIP: this
420
+ * check may only report on the app, never on itself.
421
+ */
422
+ export async function runDeepRenderCheck(url, cwd, opts = {}) {
423
+ const credentials = opts.credentials !== undefined ?
424
+ opts.credentials
425
+ : findLoginCredentials(collectProjectEnv(cwd, opts.env));
426
+ const bin = opts.browser === undefined ? findHeadlessBrowser() : opts.browser;
427
+ if (!bin) {
428
+ return {
429
+ outcome: 'skip',
430
+ note: 'no headless Chrome-family browser found on this box (PATH, CHROME_BIN, Playwright cache) — the authenticated half of the app was NOT observed'
431
+ };
432
+ }
433
+ const budget = opts.timeoutMs ?? DEEP_RENDER_TIMEOUT_MS;
434
+ const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'pi-task-deep-render-'));
435
+ let child = null;
436
+ let socket = null;
437
+ const cleanup = () => {
438
+ try {
439
+ socket?.close();
440
+ }
441
+ catch {
442
+ // socket already gone
443
+ }
444
+ try {
445
+ if (child?.pid)
446
+ process.kill(-child.pid, 'SIGKILL');
447
+ }
448
+ catch {
449
+ // group already gone
450
+ }
451
+ try {
452
+ rmSync(userDataDir, { recursive: true, force: true });
453
+ }
454
+ catch {
455
+ // best-effort temp cleanup
456
+ }
457
+ };
458
+ try {
459
+ return await withTimeout(drive(url, bin, userDataDir, credentials, c => {
460
+ child = c;
461
+ }, s => {
462
+ socket = s;
463
+ }), budget);
464
+ }
465
+ catch (e) {
466
+ const why = e instanceof Error ? e.message : String(e);
467
+ return {
468
+ outcome: 'skip',
469
+ note: `the authenticated render session could not run (${why}) — the authenticated half of the app was NOT observed`
470
+ };
471
+ }
472
+ finally {
473
+ cleanup();
474
+ }
475
+ }
476
+ function withTimeout(p, ms) {
477
+ return new Promise((resolve, reject) => {
478
+ const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
479
+ t.unref?.();
480
+ p.then(v => {
481
+ clearTimeout(t);
482
+ resolve(v);
483
+ }, e => {
484
+ clearTimeout(t);
485
+ reject(e instanceof Error ? e : new Error(String(e)));
486
+ });
487
+ });
488
+ }
489
+ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket) {
490
+ const origin = new URL(url).origin;
491
+ const child = spawn(bin, [
492
+ '--headless',
493
+ '--disable-gpu',
494
+ '--no-sandbox',
495
+ '--disable-dev-shm-usage',
496
+ '--no-first-run',
497
+ '--no-default-browser-check',
498
+ '--disable-extensions',
499
+ `--user-data-dir=${userDataDir}`,
500
+ '--remote-debugging-port=0',
501
+ 'about:blank'
502
+ ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
503
+ holdChild(child);
504
+ child.unref();
505
+ const wsUrl = await new Promise((resolve, reject) => {
506
+ let buf = '';
507
+ const onData = (d) => {
508
+ buf += String(d);
509
+ const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
510
+ if (m)
511
+ resolve(m[1]);
512
+ };
513
+ child.stderr?.on('data', onData);
514
+ child.stdout?.on('data', onData);
515
+ child.on('error', e => reject(e));
516
+ child.on('exit', code => reject(new Error(`browser exited ${code} before listening`)));
517
+ });
518
+ const socket = new WebSocket(wsUrl, { perMessageDeflate: false, maxPayload: 128 * 1024 * 1024 });
519
+ holdSocket(socket);
520
+ await new Promise((resolve, reject) => {
521
+ socket.once('open', () => resolve());
522
+ socket.once('error', e => reject(e instanceof Error ? e : new Error(String(e))));
523
+ });
524
+ const cdp = new Cdp(socket);
525
+ const requests = new Map();
526
+ let lastActivity = Date.now();
527
+ cdp.on('Network.requestWillBeSent', p => {
528
+ const req = p.request;
529
+ requests.set(String(p.requestId), {
530
+ url: String(req?.url ?? ''),
531
+ method: String(req?.method ?? 'GET'),
532
+ type: String(p.type ?? ''),
533
+ status: null,
534
+ failed: false,
535
+ at: Date.now()
536
+ });
537
+ lastActivity = Date.now();
538
+ });
539
+ cdp.on('Network.responseReceived', p => {
540
+ const r = requests.get(String(p.requestId));
541
+ const res = p.response;
542
+ if (r) {
543
+ r.status = typeof res?.status === 'number' ? res.status : r.status;
544
+ if (p.type)
545
+ r.type = String(p.type);
546
+ }
547
+ lastActivity = Date.now();
548
+ });
549
+ cdp.on('Network.loadingFailed', p => {
550
+ const r = requests.get(String(p.requestId));
551
+ if (r)
552
+ r.failed = true;
553
+ lastActivity = Date.now();
554
+ });
555
+ const { targetId } = (await cdp.send('Target.createTarget', { url: 'about:blank' }));
556
+ const { sessionId } = (await cdp.send('Target.attachToTarget', { targetId, flatten: true }));
557
+ await cdp.send('Page.enable', {}, sessionId);
558
+ await cdp.send('Runtime.enable', {}, sessionId);
559
+ await cdp.send('Network.enable', {}, sessionId);
560
+ const evaluate = async (expression) => {
561
+ const r = (await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
562
+ return r.result?.value;
563
+ };
564
+ await cdp.send('Page.navigate', { url }, sessionId);
565
+ await settle(() => lastActivity, SETTLE_CAP_MS);
566
+ const before = await evaluate(INSPECT_EXPR);
567
+ if (!before)
568
+ throw new Error('the page could not be inspected');
569
+ const sameOrigin = (r) => r.url.startsWith(`${origin}/`) || r.url === origin;
570
+ const isData = (r) => r.type === 'XHR' || r.type === 'Fetch';
571
+ const foreignOriginFailures = () => {
572
+ const out = new Set();
573
+ for (const r of requests.values()) {
574
+ if (sameOrigin(r) || !r.failed || !r.url.startsWith('http'))
575
+ continue;
576
+ try {
577
+ out.add(new URL(r.url).origin);
578
+ }
579
+ catch {
580
+ // unparseable url — nothing to name
581
+ }
582
+ }
583
+ return [...out];
584
+ };
585
+ const facts = (over) => ({
586
+ landingHadAuthWall: before.hasPassword,
587
+ credentialsFound: credentials !== null,
588
+ submitted: false,
589
+ authRequest: null,
590
+ postAuthDataAttempted: 0,
591
+ postAuthData2xx: 0,
592
+ foreignOriginFailures: foreignOriginFailures(),
593
+ leftAuthWall: false,
594
+ urlBefore: before.url,
595
+ urlAfter: before.url,
596
+ postAuthDomOk: true,
597
+ postAuthDomDetail: '',
598
+ ...over
599
+ });
600
+ if (!before.hasPassword || credentials === null)
601
+ return judgeDeepSession(facts({}));
602
+ const submitMark = Date.now();
603
+ const filled = await evaluate(fillExpr(credentials.identifier, credentials.password));
604
+ if (!filled?.ok)
605
+ return judgeDeepSession(facts({ submitted: false }));
606
+ // Separate turn: the fill's input events schedule framework state updates that
607
+ // the submit handler must already see.
608
+ await sleep(300);
609
+ const submitted = await evaluate(SUBMIT_EXPR);
610
+ if (!submitted?.ok)
611
+ return judgeDeepSession(facts({ submitted: false }));
612
+ lastActivity = Date.now();
613
+ await settle(() => lastActivity, POST_SUBMIT_CAP_MS);
614
+ // The sign-in request: the first same-origin non-GET issued by the submit. Its
615
+ // own 2xx is the precondition for judging anything, and it is excluded from the
616
+ // data evidence (STEP 0: the broken build satisfies "≥1 same-origin 2xx" with
617
+ // exactly this request and nothing else).
618
+ const after = new Map([...requests].filter(([, r]) => r.at >= submitMark));
619
+ let authId = null;
620
+ for (const [id, r] of after) {
621
+ if (sameOrigin(r) && r.method !== 'GET') {
622
+ authId = id;
623
+ break;
624
+ }
625
+ }
626
+ const authReq = authId !== null ? after.get(authId) : null;
627
+ const authAt = authReq?.at ?? submitMark;
628
+ const postAuthData = [...after]
629
+ .filter(([id, r]) => id !== authId && sameOrigin(r) && isData(r) && r.at >= authAt)
630
+ .map(([, r]) => r);
631
+ const now = await evaluate(INSPECT_EXPR);
632
+ const domJudgment = judgeRenderedDom(now?.html ?? '');
633
+ return judgeDeepSession(facts({
634
+ submitted: true,
635
+ authRequest: authReq ?
636
+ {
637
+ method: authReq.method,
638
+ path: pathOf(authReq.url),
639
+ status: authReq.status,
640
+ failed: authReq.failed
641
+ }
642
+ : null,
643
+ postAuthDataAttempted: postAuthData.length,
644
+ postAuthData2xx: postAuthData.filter(r => r.status !== null && r.status >= 200 && r.status < 300).length,
645
+ foreignOriginFailures: foreignOriginFailures(),
646
+ leftAuthWall: !(now?.hasPassword ?? false) || (now?.pathname ?? '') !== before.pathname,
647
+ urlAfter: now?.url ?? before.url,
648
+ postAuthDomOk: domJudgment.ok,
649
+ postAuthDomDetail: domJudgment.detail
650
+ }));
651
+ }
652
+ function pathOf(url) {
653
+ try {
654
+ return new URL(url).pathname;
655
+ }
656
+ catch {
657
+ return url;
658
+ }
659
+ }
@@ -1,6 +1,7 @@
1
1
  import { type HealthCommand } from './repo-health-check.js';
2
2
  import { type AcceptDebt } from './accept-debt.js';
3
3
  import { type RenderOutcome } from './render-check.js';
4
+ import { type DeepRenderOutcome } from './deep-render-check.js';
4
5
  import { taskThatIntroduced } from './task-provenance.js';
5
6
  export interface FinalGateOutcome {
6
7
  /** true → statics and every runnable integration command passed (or nothing to run). */
@@ -88,7 +89,9 @@ export declare function discoverBootCommand(cwd: string): HealthCommand | null;
88
89
  type BootOutcome = {
89
90
  outcome: 'skip' | 'pass';
90
91
  /** Set when the render check could not OBSERVE the served page (no browser,
91
- * undeterminable port) surfaced by the gate as an UNOBSERVED warning. */
92
+ * undeterminable port) or its AUTHENTICATED half (no declared credentials,
93
+ * an undrivable sign-in form, credentials the server rejected) — surfaced
94
+ * by the gate as an UNOBSERVED warning. */
92
95
  renderNote?: string;
93
96
  /** skip only: the boot command never spawned (ENOENT) — feeds the
94
97
  * full-blindness guard (mx5 run 16), unlike a 127 where the runner ran. */
@@ -134,6 +137,17 @@ export interface BootDeps {
134
137
  * the gate wires runRenderCheck by default for served apps.
135
138
  */
136
139
  renderProbe?: (url: string) => RenderOutcome;
140
+ /**
141
+ * SIGN IN on the served page and judge the AUTHENTICATED half of the app (mx5
142
+ * run 17). Runs only after `renderProbe` PASSED — the shallow blank-page rule
143
+ * keeps its own RED/GREEN-proven verdict and is never shadowed by this one.
144
+ * Absent → the boot check behaves exactly as before; the gate wires
145
+ * runDeepRenderCheck by default for served apps. May only FAIL when the SERVER
146
+ * itself authenticated the session (see deep-render-check.judgeDeepSession);
147
+ * anything else — no browser, no declared credentials, an undrivable form,
148
+ * rejected credentials — is an env gap and skips with an UNOBSERVED note.
149
+ */
150
+ deepRenderProbe?: (url: string) => DeepRenderOutcome | Promise<DeepRenderOutcome>;
137
151
  /**
138
152
  * Can this box enumerate listeners with pids AT ALL (ss/netstat/lsof)? False
139
153
  * means the served-app requirement is UNOBSERVABLE here and must degrade to the
@@ -146,6 +160,13 @@ export interface BootDeps {
146
160
  * pgid attribution alone). Injected for tests.
147
161
  */
148
162
  pickPort?: () => Promise<number | null>;
163
+ /**
164
+ * The port the project's own client was BUILT to call, when it declares one and
165
+ * nothing is holding it — preferred over a freshly reserved port so the served
166
+ * origin and the origin the client calls are the same one (see pinnedLocalPort).
167
+ * null → use the reserved private port exactly as before.
168
+ */
169
+ preferredPort?: () => Promise<number | null>;
149
170
  /** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
150
171
  httpProbe?: (port: number) => boolean;
151
172
  }
@@ -189,6 +210,15 @@ export declare function resetListenerToolCapability(): void;
189
210
  * conventional :3000 and passed checks the app had not earned).
190
211
  */
191
212
  export declare function pickFreePort(): Promise<number | null>;
213
+ /** Can we bind 127.0.0.1:`port` right now? (Free ⇒ the boot child can have it.) */
214
+ export declare function isPortFree(port: number): Promise<boolean>;
215
+ /**
216
+ * The project's own declared local port, but only if nothing is holding it — the
217
+ * default `preferredPort` for the gate. A declared port that is BUSY falls back to
218
+ * a reserved one rather than colliding: a stranger's server on :3000 must never be
219
+ * mistaken for the app we just booted.
220
+ */
221
+ export declare function preferredDeclaredPort(cwd: string): Promise<number | null>;
192
222
  /**
193
223
  * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
194
224
  * command's own fate within the grace window decides:
@@ -47,6 +47,7 @@ import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtN
47
47
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
48
48
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
49
49
  import { runRenderCheck } from './render-check.js';
50
+ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
50
51
  import { resolveRunner, runnerEnv } from './runner-resolve.js';
51
52
  import { taskThatIntroduced } from './task-provenance.js';
52
53
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
@@ -388,6 +389,31 @@ export function pickFreePort() {
388
389
  }
389
390
  });
390
391
  }
392
+ /** Can we bind 127.0.0.1:`port` right now? (Free ⇒ the boot child can have it.) */
393
+ export function isPortFree(port) {
394
+ return new Promise(resolve => {
395
+ try {
396
+ const srv = net.createServer();
397
+ srv.once('error', () => resolve(false));
398
+ srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
399
+ }
400
+ catch {
401
+ resolve(false);
402
+ }
403
+ });
404
+ }
405
+ /**
406
+ * The project's own declared local port, but only if nothing is holding it — the
407
+ * default `preferredPort` for the gate. A declared port that is BUSY falls back to
408
+ * a reserved one rather than colliding: a stranger's server on :3000 must never be
409
+ * mistaken for the app we just booted.
410
+ */
411
+ export async function preferredDeclaredPort(cwd) {
412
+ const port = pinnedLocalPort(collectProjectEnv(cwd));
413
+ if (port === null)
414
+ return null;
415
+ return (await isPortFree(port)) ? port : null;
416
+ }
391
417
  /**
392
418
  * Does anything answer HTTP on 127.0.0.1:`port`? Any response at all (404, 500 —
393
419
  * a status is a listener) counts; only a connection error or timeout is a no.
@@ -534,7 +560,14 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
534
560
  const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
535
561
  // Only served apps get an assigned port: a CLI project has nothing to bind, and
536
562
  // an unexpected PORT in its env is noise.
537
- const assignedPort = expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null;
563
+ // The app's OWN declared local port wins when it is free (see pinnedLocalPort):
564
+ // a client whose base URL was baked in at build time calls that origin and no
565
+ // other, so serving it anywhere else makes the whole authenticated half
566
+ // unobservable. Anything else — no declaration, a port already held — falls back
567
+ // to the freshly reserved private port that run 14's ownership evidence needs.
568
+ const noPreference = () => Promise.resolve(null);
569
+ const preferred = expectServer ? await (opts.deps?.preferredPort ?? noPreference)() : null;
570
+ const assignedPort = preferred ?? (expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null);
538
571
  // Runner resolution (mx5 run 16): same contract as runGateCommand — resolve
539
572
  // the runner and carry its directory on PATH so the boot script's own chain
540
573
  // can re-invoke it.
@@ -605,9 +638,13 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
605
638
  // check against the LIVE listener (mx5 runs 8/11: a listener that serves a
606
639
  // permanently blank page passed every curl-shaped check), then PASS/FAIL.
607
640
  // The probe is spawnSync, so the interval cannot re-enter mid-check.
641
+ // The deep probe is asynchronous (it drives a browser session), so the
642
+ // interval body must not re-enter while one is in flight — a second session
643
+ // would race the first for the same still-booting child.
644
+ let probing = false;
608
645
  const poll = expectServer ?
609
646
  setInterval(() => {
610
- if (settled || !child.pid)
647
+ if (settled || probing || !child.pid)
611
648
  return;
612
649
  // pgid attribution first (precise, cheap). If it saw nothing — or
613
650
  // cannot see anything here — fall back to the private assigned
@@ -627,14 +664,48 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
627
664
  if (port === null) {
628
665
  return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
629
666
  }
630
- const rr = probe(`http://127.0.0.1:${port}/`);
667
+ const url = `http://127.0.0.1:${port}/`;
668
+ const rr = probe(url);
631
669
  if (rr.outcome === 'fail') {
632
670
  return failAndKill(`listens on :${port} but ${rr.detail}`);
633
671
  }
634
- passAndKill(rr.outcome === 'skip' ? `render check UNOBSERVED: ${rr.note}` : undefined);
672
+ const deep = opts.deps?.deepRenderProbe;
673
+ if (rr.outcome !== 'pass' || !deep) {
674
+ return passAndKill(rr.outcome === 'skip' ?
675
+ `render check UNOBSERVED: ${rr.note}`
676
+ : undefined);
677
+ }
678
+ // The page renders. Now sign in and prove the AUTHENTICATED half
679
+ // is alive (mx5 run 17): the server accepted the login and the
680
+ // client never used it. Async, so the interval is held off by
681
+ // `probing` until this settles.
682
+ probing = true;
683
+ void Promise.resolve(deep(url)).then(dr => {
684
+ if (settled)
685
+ return;
686
+ if (dr.outcome === 'fail') {
687
+ return failAndKill(`listens on :${port} but ${dr.detail}`);
688
+ }
689
+ passAndKill(dr.outcome === 'skip' ?
690
+ `authenticated render check UNOBSERVED: ${dr.note}`
691
+ : undefined);
692
+ }, () => {
693
+ // The deep probe may never fail the gate on its own fault.
694
+ if (!settled)
695
+ passAndKill();
696
+ });
635
697
  }, 500)
636
698
  : null;
637
- const timer = setTimeout(() => {
699
+ const onGrace = () => {
700
+ // A browser session in flight outlives the grace window by design (it
701
+ // signs in and waits for the app's data calls). Settling here would kill
702
+ // the server under it and discard its verdict, so the window re-arms
703
+ // until the probe resolves — which it always does, on its own hard
704
+ // timeout (DEEP_RENDER_TIMEOUT_MS).
705
+ if (probing) {
706
+ timer = setTimeout(onGrace, 500);
707
+ return;
708
+ }
638
709
  if (expectServer && !listenerSeen) {
639
710
  // Blind here (no enumeration tool, and the assigned port never
640
711
  // answered) ⇒ we cannot tell "never listened" from "ignores PORT".
@@ -651,7 +722,8 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
651
722
  return;
652
723
  }
653
724
  passAndKill();
654
- }, graceMs);
725
+ };
726
+ let timer = setTimeout(onGrace, graceMs);
655
727
  child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
656
728
  child.on('exit', (status, signal) => {
657
729
  if (status === 0) {
@@ -1045,9 +1117,19 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1045
1117
  // blank-mount app passed every prior "renders" check. Default to the real
1046
1118
  // probe; tests inject their own. runRenderCheck env-gap-SKIPs when no
1047
1119
  // browser exists, so a box without one never gets a false FAIL.
1120
+ // Authenticated deep-render check (mx5 run 17): the page above renders, so
1121
+ // now sign in with the account the project's own dotenv declares (the same
1122
+ // ADMIN_PHONE/ADMIN_PASSWORD the launch contract's seed step consumes) and
1123
+ // require the session to actually work. WEB-ONLY by construction — it hangs
1124
+ // off the served-app branch and never runs for C++, Godot, CLI or library
1125
+ // projects. It may only FAIL when the SERVER authenticated us; no browser,
1126
+ // no credentials, an undrivable form or rejected credentials all skip as
1127
+ // env gaps (judgeDeepSession).
1048
1128
  const bootDepsWithRender = {
1049
1129
  ...bootDeps,
1050
- renderProbe: bootDeps.renderProbe ?? runRenderCheck
1130
+ renderProbe: bootDeps.renderProbe ?? runRenderCheck,
1131
+ deepRenderProbe: bootDeps.deepRenderProbe ?? (url => runDeepRenderCheck(url, cwd)),
1132
+ preferredPort: bootDeps.preferredPort ?? (() => preferredDeclaredPort(cwd))
1051
1133
  };
1052
1134
  let b = await runBootCheck(cwd, boot, bootGraceMs, {
1053
1135
  expectServer,
@@ -149,6 +149,38 @@ export declare function buildOwnedRequirementsBlock(owned: OwnedRequirement[]):
149
149
  * quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
150
150
  * No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
151
151
  * only on specs the shape gate already accepted.
152
+ *
153
+ * NOT EXTENDED TO CONSUMER TASKS — REFUTED AT STEP 0, 2026-07-27. The proposal
154
+ * was to classify owned requirements INVARIANT (prohibition-shaped) vs
155
+ * DELIVERABLE and propagate the INVARIANTs from here to every task whose spec
156
+ * names the same symbol/file, because mx5 run 17 gave all three Hono-RPC
157
+ * obligations to TASK_0021 (which complied perfectly) while the four CONSUMER
158
+ * tasks that never saw them — 0027/0031/0033/0034 — hand-wrote casts and shipped
159
+ * 7 dead client call sites. It was not built, for two measured reasons
160
+ * (scripts/owned-consumer-generality-step0.ts, re-runnable):
161
+ *
162
+ * 1. IT DOES NOT GENERALIZE. The task's own kill condition was <20% of a second
163
+ * stack's OWNED requirements being prohibition-shaped. IAR1, 8 live
164
+ * regenerations of the real plan-time pipeline over its real 10-task list:
165
+ * 2/42 pooled = 4.8% (narrow four-phrase reading 1/42 = 2.4%). mx5 itself is
166
+ * 7/33 = 21.2% only under the BROAD rule above; under "never/don't/must
167
+ * not/do not" it is 2/33 = 6.1%. The structural reason is in accountCoverage
168
+ * right here: a prohibition the map leaves NONE is already carried
169
+ * cross-cutting to every task, so in the five reps that logged the split 18
170
+ * of IAR1's 19 prohibition-shaped requirements were never owner-only in the
171
+ * first place. mx5's 7 leaked because the model mapped them to a TASK.
172
+ * 2. THE TARGETING RULE MISSES ITS OWN MOTIVATING CASE. Symbol/file relevance
173
+ * would not have reached the four violators for the clause they actually
174
+ * broke ("If a call isn't fully typed end-to-end via `hc`, fix the route
175
+ * chaining/export, don't paper over it…"): its only extractable symbol is
176
+ * `chaining/export`, which no consumer spec contains — 0 consumers. Its
177
+ * siblings would have reached all four, attaching to 13/41 tasks each; across
178
+ * mx5's 33 owned requirements the mean attach rate is 21% of all tasks and
179
+ * 5/33 would attach to more than half of them (the task's own I1 trigger).
180
+ *
181
+ * Do not re-open on mx5 evidence alone. A future attempt needs a second stack
182
+ * where prohibition-shaped requirements actually land OWNED, and a targeting rule
183
+ * that survives a clause whose symbols are prose.
152
184
  */
153
185
  export declare function appendOwnedConstraints(spec: string, owned: OwnedRequirement[]): string;
154
186
  /** The decompose-prompt ledger block (goal E's belt): the grounded requirement
@@ -556,6 +556,38 @@ export function buildOwnedRequirementsBlock(owned) {
556
556
  * quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
557
557
  * No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
558
558
  * only on specs the shape gate already accepted.
559
+ *
560
+ * NOT EXTENDED TO CONSUMER TASKS — REFUTED AT STEP 0, 2026-07-27. The proposal
561
+ * was to classify owned requirements INVARIANT (prohibition-shaped) vs
562
+ * DELIVERABLE and propagate the INVARIANTs from here to every task whose spec
563
+ * names the same symbol/file, because mx5 run 17 gave all three Hono-RPC
564
+ * obligations to TASK_0021 (which complied perfectly) while the four CONSUMER
565
+ * tasks that never saw them — 0027/0031/0033/0034 — hand-wrote casts and shipped
566
+ * 7 dead client call sites. It was not built, for two measured reasons
567
+ * (scripts/owned-consumer-generality-step0.ts, re-runnable):
568
+ *
569
+ * 1. IT DOES NOT GENERALIZE. The task's own kill condition was <20% of a second
570
+ * stack's OWNED requirements being prohibition-shaped. IAR1, 8 live
571
+ * regenerations of the real plan-time pipeline over its real 10-task list:
572
+ * 2/42 pooled = 4.8% (narrow four-phrase reading 1/42 = 2.4%). mx5 itself is
573
+ * 7/33 = 21.2% only under the BROAD rule above; under "never/don't/must
574
+ * not/do not" it is 2/33 = 6.1%. The structural reason is in accountCoverage
575
+ * right here: a prohibition the map leaves NONE is already carried
576
+ * cross-cutting to every task, so in the five reps that logged the split 18
577
+ * of IAR1's 19 prohibition-shaped requirements were never owner-only in the
578
+ * first place. mx5's 7 leaked because the model mapped them to a TASK.
579
+ * 2. THE TARGETING RULE MISSES ITS OWN MOTIVATING CASE. Symbol/file relevance
580
+ * would not have reached the four violators for the clause they actually
581
+ * broke ("If a call isn't fully typed end-to-end via `hc`, fix the route
582
+ * chaining/export, don't paper over it…"): its only extractable symbol is
583
+ * `chaining/export`, which no consumer spec contains — 0 consumers. Its
584
+ * siblings would have reached all four, attaching to 13/41 tasks each; across
585
+ * mx5's 33 owned requirements the mean attach rate is 21% of all tasks and
586
+ * 5/33 would attach to more than half of them (the task's own I1 trigger).
587
+ *
588
+ * Do not re-open on mx5 evidence alone. A future attempt needs a second stack
589
+ * where prohibition-shaped requirements actually land OWNED, and a targeting rule
590
+ * that survives a clause whose symbols are prose.
559
591
  */
560
592
  export function appendOwnedConstraints(spec, owned) {
561
593
  if (owned.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.21.6",
3
+ "version": "0.21.8",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",