@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 facts = (over) => ({
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 judgeDeepSession(facts({}));
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 judgeDeepSession(facts({ submitted: false }));
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 judgeDeepSession(facts({ submitted: false }));
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
- return judgeDeepSession(facts({
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: !(now?.hasPassword ?? false) || (now?.pathname ?? '') !== before.pathname,
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.0",
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",