@mjasnikovs/pi-task 0.37.0 → 0.37.1
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.
|
@@ -49,14 +49,40 @@ export declare function findLoginCredentials(vars: Record<string, string>): Logi
|
|
|
49
49
|
* FREE — the caller checks freeness and falls back to a reserved port otherwise.
|
|
50
50
|
*/
|
|
51
51
|
export declare function pinnedLocalPort(vars: Record<string, string>): number | null;
|
|
52
|
+
/**
|
|
53
|
+
* One same-origin request the session issued, as the wire saw it. This is the whole
|
|
54
|
+
* evidence base: `authRequest`, `postAuthDataAttempted` and `postAuthData2xx` below
|
|
55
|
+
* are DERIVED from this list (deriveLegacyFacts), never recorded separately.
|
|
56
|
+
*
|
|
57
|
+
* `mimeType` is here because status alone cannot see a misrouted GET: an SPA
|
|
58
|
+
* catch-all answers every unmatched GET with index.html at 200, so a dead API call
|
|
59
|
+
* looks healthy to any status rule and only the content type gives it away.
|
|
60
|
+
*/
|
|
61
|
+
export interface SessionRequest {
|
|
62
|
+
method: string;
|
|
63
|
+
path: string;
|
|
64
|
+
status: number | null;
|
|
65
|
+
mimeType: string | null;
|
|
66
|
+
failed: boolean;
|
|
67
|
+
/** CDP resource type, collapsed: what ISSUED this request. */
|
|
68
|
+
initiator: 'xhr' | 'document' | 'other';
|
|
69
|
+
/** Relative to the sign-in request: before it, it, or after it. */
|
|
70
|
+
phase: 'pre' | 'auth' | 'post';
|
|
71
|
+
}
|
|
52
72
|
export interface DeepSessionFacts {
|
|
73
|
+
/** Every same-origin request of the whole session, in order. Optional only so
|
|
74
|
+
* that a hand-written or pre-existing recorded session stays valid: absent
|
|
75
|
+
* means "not recorded", and the request-log rules simply do not fire. The live
|
|
76
|
+
* driver always populates it. */
|
|
77
|
+
sessionRequests?: SessionRequest[];
|
|
53
78
|
/** The landing page presented a sign-in wall (a visible password input). */
|
|
54
79
|
landingHadAuthWall: boolean;
|
|
55
80
|
/** A credential pair was declared by the project. */
|
|
56
81
|
credentialsFound: boolean;
|
|
57
82
|
/** The form could be filled and submitted. */
|
|
58
83
|
submitted: boolean;
|
|
59
|
-
/** The sign-in request the SUBMIT issued, when one was issued at all.
|
|
84
|
+
/** The sign-in request the SUBMIT issued, when one was issued at all.
|
|
85
|
+
* Derived: the `sessionRequests` entry in phase 'auth'. */
|
|
60
86
|
authRequest: {
|
|
61
87
|
method: string;
|
|
62
88
|
path: string;
|
|
@@ -64,7 +90,7 @@ export interface DeepSessionFacts {
|
|
|
64
90
|
failed: boolean;
|
|
65
91
|
} | null;
|
|
66
92
|
/** Same-origin XHR/fetch requests issued AFTER the sign-in response, excluding
|
|
67
|
-
* the sign-in request itself. */
|
|
93
|
+
* the sign-in request itself. Derived: `sessionRequests` in phase 'post'. */
|
|
68
94
|
postAuthDataAttempted: number;
|
|
69
95
|
postAuthData2xx: number;
|
|
70
96
|
/** Origins the client called that are not the app's own, whose requests failed
|
|
@@ -78,6 +104,14 @@ export interface DeepSessionFacts {
|
|
|
78
104
|
postAuthDomOk: boolean;
|
|
79
105
|
postAuthDomDetail: string;
|
|
80
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The three request-shaped facts, computed from the log and from nothing else. The
|
|
109
|
+
* driver records `sessionRequests` and calls this; the values are exactly what the
|
|
110
|
+
* pre-log driver computed by filtering the same map (the sign-in request is the
|
|
111
|
+
* first same-origin non-GET after submit; the data requests are the same-origin
|
|
112
|
+
* XHR/fetch issued at or after it, itself excluded).
|
|
113
|
+
*/
|
|
114
|
+
export declare function deriveLegacyFacts(log: SessionRequest[]): Pick<DeepSessionFacts, 'authRequest' | 'postAuthDataAttempted' | 'postAuthData2xx'>;
|
|
81
115
|
/**
|
|
82
116
|
* Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
|
|
83
117
|
* authenticated (2xx on the sign-in request) whose client then could not use it:
|
|
@@ -109,4 +143,7 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
|
|
|
109
143
|
credentials?: LoginCredentials | null;
|
|
110
144
|
timeoutMs?: number;
|
|
111
145
|
env?: NodeJS.ProcessEnv;
|
|
146
|
+
/** Recorder hook: receives the facts the verdict was made on. Used by the
|
|
147
|
+
* corpus builder; the gate itself never passes it. */
|
|
148
|
+
onFacts?: (f: DeepSessionFacts) => void;
|
|
112
149
|
}): Promise<DeepRenderOutcome>;
|
|
@@ -194,6 +194,27 @@ export function pinnedLocalPort(vars) {
|
|
|
194
194
|
}
|
|
195
195
|
return ports.length > 0 ? Math.min(...ports) : null;
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The three request-shaped facts, computed from the log and from nothing else. The
|
|
199
|
+
* driver records `sessionRequests` and calls this; the values are exactly what the
|
|
200
|
+
* pre-log driver computed by filtering the same map (the sign-in request is the
|
|
201
|
+
* first same-origin non-GET after submit; the data requests are the same-origin
|
|
202
|
+
* XHR/fetch issued at or after it, itself excluded).
|
|
203
|
+
*/
|
|
204
|
+
export function deriveLegacyFacts(log) {
|
|
205
|
+
const auth = log.find(r => r.phase === 'auth') ?? null;
|
|
206
|
+
const data = log.filter(r => r.phase === 'post' && r.initiator === 'xhr');
|
|
207
|
+
return {
|
|
208
|
+
authRequest: auth === null ? null : ({ method: auth.method, path: auth.path, status: auth.status, failed: auth.failed }),
|
|
209
|
+
postAuthDataAttempted: data.length,
|
|
210
|
+
postAuthData2xx: data.filter(r => r.status !== null && r.status >= 200 && r.status < 300)
|
|
211
|
+
.length
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Statuses that mean "no handler is mounted here", as opposed to a handler that
|
|
215
|
+
* answered No. 501 is included because a server that routes but implements
|
|
216
|
+
* nothing is the same dead call from the client's side. */
|
|
217
|
+
const MISSING_ROUTE_STATUS = new Set([404, 405, 501]);
|
|
197
218
|
/**
|
|
198
219
|
* Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
|
|
199
220
|
* authenticated (2xx on the sign-in request) whose client then could not use it:
|
|
@@ -241,6 +262,36 @@ export function judgeDeepSession(f) {
|
|
|
241
262
|
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
263
|
};
|
|
243
264
|
}
|
|
265
|
+
// Rule A — a route the server does not have. The client addressed a path
|
|
266
|
+
// nothing is mounted on, so the request was never evaluated by any handler.
|
|
267
|
+
// Deliberately narrow: 400/401/403/419/422 are a handler ANSWERING (a rejected
|
|
268
|
+
// password, a missing permission — the app working), 5xx is the server failing,
|
|
269
|
+
// and both keep their existing behaviour. Only "no such route" is here.
|
|
270
|
+
const missingRoute = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && r.status !== null && MISSING_ROUTE_STATUS.has(r.status));
|
|
271
|
+
if (missingRoute) {
|
|
272
|
+
return {
|
|
273
|
+
outcome: 'fail',
|
|
274
|
+
detail: `\`${missingRoute.method} ${missingRoute.path}\` → ${String(missingRoute.status)}: `
|
|
275
|
+
+ 'the client sent this to a path the server does not route. The credentials were '
|
|
276
|
+
+ 'never evaluated. This is a dead client call — a base URL joined twice, a renamed '
|
|
277
|
+
+ 'route, a wrong method. No type or mock can produce a route that is not mounted.'
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// Rule B — the SPA catch-all answering an API call. Any server with a
|
|
281
|
+
// `GET /*` → index.html fallback returns 200 for a route it does not have, so
|
|
282
|
+
// the status is healthy and the body is the app shell. A document navigation
|
|
283
|
+
// answered with HTML is normal; an XHR asking for data and getting HTML is a
|
|
284
|
+
// call that reached nothing.
|
|
285
|
+
const swallowed = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && (r.mimeType ?? '').startsWith('text/html'));
|
|
286
|
+
if (swallowed) {
|
|
287
|
+
return {
|
|
288
|
+
outcome: 'fail',
|
|
289
|
+
detail: `\`${swallowed.method} ${swallowed.path}\` → ${String(swallowed.status)} `
|
|
290
|
+
+ `${swallowed.mimeType ?? ''}: an XHR asked this server for data and got the SPA `
|
|
291
|
+
+ 'shell. The route is not mounted and the catch-all answered instead, so the client '
|
|
292
|
+
+ 'sees a 200 it cannot parse. No status check can see this.'
|
|
293
|
+
};
|
|
294
|
+
}
|
|
244
295
|
const { method, path: p, status, failed } = f.authRequest;
|
|
245
296
|
if (failed || status === null || status < 200 || status >= 300) {
|
|
246
297
|
return {
|
|
@@ -293,6 +344,9 @@ const QUIET_MS = 1_200;
|
|
|
293
344
|
/** Caps for the two settle windows (initial load, post-submit). */
|
|
294
345
|
const SETTLE_CAP_MS = 8_000;
|
|
295
346
|
const POST_SUBMIT_CAP_MS = 12_000;
|
|
347
|
+
/** Cap for the one authenticated re-entry into the landing URL (see `drive`). Kept
|
|
348
|
+
* small so the three settle windows together stay inside DEEP_RENDER_TIMEOUT_MS. */
|
|
349
|
+
const RE_NAV_CAP_MS = 6_000;
|
|
296
350
|
/** Minimal DevTools-protocol client: request/response ids over one socket, plus
|
|
297
351
|
* event fan-out. Everything the driver needs and nothing more. */
|
|
298
352
|
class Cdp {
|
|
@@ -460,7 +514,7 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
|
|
|
460
514
|
child = c;
|
|
461
515
|
}, s => {
|
|
462
516
|
socket = s;
|
|
463
|
-
}), budget);
|
|
517
|
+
}, opts.onFacts), budget);
|
|
464
518
|
}
|
|
465
519
|
catch (e) {
|
|
466
520
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -486,8 +540,14 @@ function withTimeout(p, ms) {
|
|
|
486
540
|
});
|
|
487
541
|
});
|
|
488
542
|
}
|
|
489
|
-
async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket) {
|
|
543
|
+
async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket, onFacts) {
|
|
490
544
|
const origin = new URL(url).origin;
|
|
545
|
+
/** Every verdict goes through here, so a recorder sees the same facts the judge
|
|
546
|
+
* does — the corpus is what the gate itself read, not a reconstruction. */
|
|
547
|
+
const judge = (f) => {
|
|
548
|
+
onFacts?.(f);
|
|
549
|
+
return judgeDeepSession(f);
|
|
550
|
+
};
|
|
491
551
|
const child = spawn(bin, [
|
|
492
552
|
'--headless',
|
|
493
553
|
'--disable-gpu',
|
|
@@ -531,6 +591,7 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
|
|
|
531
591
|
method: String(req?.method ?? 'GET'),
|
|
532
592
|
type: String(p.type ?? ''),
|
|
533
593
|
status: null,
|
|
594
|
+
mimeType: null,
|
|
534
595
|
failed: false,
|
|
535
596
|
at: Date.now()
|
|
536
597
|
});
|
|
@@ -541,6 +602,9 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
|
|
|
541
602
|
const res = p.response;
|
|
542
603
|
if (r) {
|
|
543
604
|
r.status = typeof res?.status === 'number' ? res.status : r.status;
|
|
605
|
+
if (typeof res?.mimeType === 'string' && res.mimeType.length > 0) {
|
|
606
|
+
r.mimeType = res.mimeType;
|
|
607
|
+
}
|
|
544
608
|
if (p.type)
|
|
545
609
|
r.type = String(p.type);
|
|
546
610
|
}
|
|
@@ -582,13 +646,36 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
|
|
|
582
646
|
}
|
|
583
647
|
return [...out];
|
|
584
648
|
};
|
|
585
|
-
const
|
|
649
|
+
const initiatorOf = (r) => isData(r) ? 'xhr'
|
|
650
|
+
: r.type === 'Document' ? 'document'
|
|
651
|
+
: 'other';
|
|
652
|
+
/** The same-origin request log, phased against the sign-in request. `authAt` is
|
|
653
|
+
* Infinity before the submit, so every request so far is 'pre'. */
|
|
654
|
+
const sessionLog = (authId, authAt) => {
|
|
655
|
+
const out = [];
|
|
656
|
+
for (const [id, r] of requests) {
|
|
657
|
+
if (!sameOrigin(r))
|
|
658
|
+
continue;
|
|
659
|
+
out.push({
|
|
660
|
+
method: r.method,
|
|
661
|
+
path: pathOf(r.url),
|
|
662
|
+
status: r.status,
|
|
663
|
+
mimeType: r.mimeType,
|
|
664
|
+
failed: r.failed,
|
|
665
|
+
initiator: initiatorOf(r),
|
|
666
|
+
phase: id === authId ? 'auth'
|
|
667
|
+
: r.at >= authAt ? 'post'
|
|
668
|
+
: 'pre'
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
};
|
|
673
|
+
const facts = (log, over) => ({
|
|
674
|
+
sessionRequests: log,
|
|
675
|
+
...deriveLegacyFacts(log),
|
|
586
676
|
landingHadAuthWall: before.hasPassword,
|
|
587
677
|
credentialsFound: credentials !== null,
|
|
588
678
|
submitted: false,
|
|
589
|
-
authRequest: null,
|
|
590
|
-
postAuthDataAttempted: 0,
|
|
591
|
-
postAuthData2xx: 0,
|
|
592
679
|
foreignOriginFailures: foreignOriginFailures(),
|
|
593
680
|
leftAuthWall: false,
|
|
594
681
|
urlBefore: before.url,
|
|
@@ -597,18 +684,19 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
|
|
|
597
684
|
postAuthDomDetail: '',
|
|
598
685
|
...over
|
|
599
686
|
});
|
|
687
|
+
const unsubmitted = (over) => facts(sessionLog(null, Number.POSITIVE_INFINITY), over);
|
|
600
688
|
if (!before.hasPassword || credentials === null)
|
|
601
|
-
return
|
|
689
|
+
return judge(unsubmitted({}));
|
|
602
690
|
const submitMark = Date.now();
|
|
603
691
|
const filled = await evaluate(fillExpr(credentials.identifier, credentials.password));
|
|
604
692
|
if (!filled?.ok)
|
|
605
|
-
return
|
|
693
|
+
return judge(unsubmitted({ submitted: false }));
|
|
606
694
|
// Separate turn: the fill's input events schedule framework state updates that
|
|
607
695
|
// the submit handler must already see.
|
|
608
696
|
await sleep(300);
|
|
609
697
|
const submitted = await evaluate(SUBMIT_EXPR);
|
|
610
698
|
if (!submitted?.ok)
|
|
611
|
-
return
|
|
699
|
+
return judge(unsubmitted({ submitted: false }));
|
|
612
700
|
lastActivity = Date.now();
|
|
613
701
|
await settle(() => lastActivity, POST_SUBMIT_CAP_MS);
|
|
614
702
|
// The sign-in request: the first same-origin non-GET issued by the submit. Its
|
|
@@ -625,25 +713,30 @@ async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket)
|
|
|
625
713
|
}
|
|
626
714
|
const authReq = authId !== null ? after.get(authId) : null;
|
|
627
715
|
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
716
|
const now = await evaluate(INSPECT_EXPR);
|
|
632
717
|
const domJudgment = judgeRenderedDom(now?.html ?? '');
|
|
633
|
-
|
|
718
|
+
const leftAuthWall = !(now?.hasPassword ?? false) || (now?.pathname ?? '') !== before.pathname;
|
|
719
|
+
// Exercise the authenticated app once. A sign-in page that ends on a success
|
|
720
|
+
// card — mx5's does — issues NOTHING after the login POST, so the authenticated
|
|
721
|
+
// data path is never observed at all and every request-shaped fact below is a
|
|
722
|
+
// fact about the login form. Re-entering the landing URL with the session cookie
|
|
723
|
+
// is the cheapest way to make the app fetch its own data. Deliberately gated on
|
|
724
|
+
// an accepted sign-in that left the wall: every session that SKIPs or FAILs
|
|
725
|
+
// without it takes exactly the path it took before, request logs included.
|
|
726
|
+
const authAccepted = authReq !== null
|
|
727
|
+
&& !authReq.failed
|
|
728
|
+
&& authReq.status !== null
|
|
729
|
+
&& authReq.status >= 200
|
|
730
|
+
&& authReq.status < 300;
|
|
731
|
+
if (authAccepted && leftAuthWall) {
|
|
732
|
+
await cdp.send('Page.navigate', { url }, sessionId);
|
|
733
|
+
lastActivity = Date.now();
|
|
734
|
+
await settle(() => lastActivity, RE_NAV_CAP_MS);
|
|
735
|
+
}
|
|
736
|
+
return judge(facts(sessionLog(authId, authAt), {
|
|
634
737
|
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
738
|
foreignOriginFailures: foreignOriginFailures(),
|
|
646
|
-
leftAuthWall
|
|
739
|
+
leftAuthWall,
|
|
647
740
|
urlAfter: now?.url ?? before.url,
|
|
648
741
|
postAuthDomOk: domJudgment.ok,
|
|
649
742
|
postAuthDomDetail: domJudgment.detail
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.1",
|
|
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",
|