@mjasnikovs/pi-task 0.21.7 → 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)
|
|
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:
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.21.
|
|
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",
|