@mjasnikovs/pi-task 0.40.37 → 0.40.39

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.
@@ -215,6 +215,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
215
215
  let stdout = '';
216
216
  let stderr = '';
217
217
  let kill;
218
+ let stdinError;
218
219
  const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
219
220
  // Deliver the prompt on stdin, not argv. A large prompt — an inlined design
220
221
  // doc, say — exceeds the OS argv ceiling and the spawn fails outright rather
@@ -237,9 +238,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
237
238
  });
238
239
  if (usesStdin) {
239
240
  // A child killed before it read the prompt leaves the pipe broken, and an
240
- // unhandled EPIPE on stdin takes the whole process down. The child's own
241
- // exit is what reports that run; this write has nothing left to say.
242
- proc.stdin?.on?.('error', () => { });
241
+ // unhandled EPIPE on stdin takes the whole process down. Recorded rather
242
+ // than dropped: a child WE killed reports itself through `kill`, but one
243
+ // that read a truncated prompt and exited 0 would otherwise pass off an
244
+ // answer to half a spec as a clean run.
245
+ proc.stdin?.on?.('error', (e) => {
246
+ stdinError ??= e instanceof Error ? e : new Error(String(e));
247
+ });
243
248
  // pi reads the prompt from stdin and waits for EOF, so write then end.
244
249
  proc.stdin?.write(invocation.stdin);
245
250
  proc.stdin?.end();
@@ -405,10 +410,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
405
410
  if (sink)
406
411
  sink.flush();
407
412
  const text = sink ? sink.text : undefined;
413
+ // pi waits for EOF before it runs, so a stdin error on a child nobody
414
+ // killed means it ran on a prompt that never finished arriving. Its own
415
+ // exit describes that half-spec, so it cannot stand as the verdict.
416
+ const truncated = kill === undefined ? stdinError : undefined;
408
417
  settle({
409
418
  stdout,
410
- stderr,
411
- exitCode: code ?? 0,
419
+ stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
420
+ exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
412
421
  aborted: kill !== undefined,
413
422
  ...(kill ? { kill } : {}),
414
423
  text,
@@ -65,13 +65,19 @@ export declare function pinnedLocalPort(vars: Record<string, string>): number |
65
65
  export interface SessionRequest {
66
66
  method: string;
67
67
  path: string;
68
+ /** Where the chain ENDED — the path `status` and `mimeType` actually describe.
69
+ * Absent in a pre-existing recorded session, where it falls back to `path`. */
70
+ finalPath?: string;
68
71
  status: number | null;
69
72
  mimeType: string | null;
70
73
  failed: boolean;
71
74
  /** CDP resource type, collapsed: what ISSUED this request. */
72
75
  initiator: 'xhr' | 'document' | 'other';
73
- /** Before the submit, the sign-in request itself, or issued at or after the
74
- * submit. */
76
+ /** The chain redirected, so `status` and `mimeType` above describe a hop this
77
+ * entry does not name. Any rule that reads a status AS A FACT ABOUT `path`
78
+ * must skip these. */
79
+ redirected: boolean;
80
+ /** Before the sign-in request, the sign-in request itself, or after it. */
75
81
  phase: 'pre' | 'auth' | 'post';
76
82
  }
77
83
  export interface DeepSessionFacts {
@@ -93,11 +99,17 @@ export interface DeepSessionFacts {
93
99
  path: string;
94
100
  status: number | null;
95
101
  failed: boolean;
102
+ /** The sign-in chain redirected, so `status` is the hop it LANDED on. */
103
+ redirected?: boolean;
96
104
  } | null;
97
- /** Same-origin XHR/fetch requests issued at or after the submit, excluding the
98
- * sign-in request itself. Derived: `sessionRequests` in phase 'post'. */
105
+ /** Same-origin XHR/fetch requests issued at or after the sign-in request,
106
+ * excluding that request itself. Derived: `sessionRequests` in phase 'post'. */
99
107
  postAuthDataAttempted: number;
100
108
  postAuthData2xx: number;
109
+ /** The origin the sign-in left for, when the submit addressed this app and the
110
+ * chain ended somewhere else — an external identity provider, which no
111
+ * declared credential pair can drive. Optional: absent means "not recorded". */
112
+ signInLeftOrigin?: string | null;
101
113
  /** Origins the client called that are not the app's own, whose requests failed
102
114
  * (a bundle pinned to a build-time base URL that is not the port under test). */
103
115
  foreignOriginFailures: string[];
@@ -111,11 +123,8 @@ export interface DeepSessionFacts {
111
123
  }
112
124
  /**
113
125
  * The three request-shaped facts, computed from the log and from nothing else. The
114
- * driver records `sessionRequests` and calls this; the values are exactly what the
115
- * pre-log driver computed by filtering the same map (the sign-in request is the
116
- * first same-origin non-GET issued at or after the submit; the data requests are
117
- * the same-origin XHR/fetch issued at or after the sign-in request, that request
118
- * itself excluded).
126
+ * driver records `sessionRequests` and calls this; the phases carry the whole
127
+ * derivation, so this reads them and decides nothing of its own.
119
128
  */
120
129
  export declare function deriveLegacyFacts(log: SessionRequest[]): Pick<DeepSessionFacts, 'authRequest' | 'postAuthDataAttempted' | 'postAuthData2xx'>;
121
130
  /**
@@ -227,7 +236,7 @@ export interface DriveSessionOptions {
227
236
  /**
228
237
  * The session over an already-connected browser: navigate, inspect, sign in if the
229
238
  * landing is a wall and credentials exist, settle, phase the same-origin request
230
- * log against the submit, re-enter once the sign-in was accepted, and
239
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
231
240
  * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
232
241
  * socket — so every branch is testable against a fake `CdpLike`.
233
242
  *
@@ -200,17 +200,20 @@ export function pinnedLocalPort(vars) {
200
200
  }
201
201
  /**
202
202
  * The three request-shaped facts, computed from the log and from nothing else. The
203
- * driver records `sessionRequests` and calls this; the values are exactly what the
204
- * pre-log driver computed by filtering the same map (the sign-in request is the
205
- * first same-origin non-GET issued at or after the submit; the data requests are
206
- * the same-origin XHR/fetch issued at or after the sign-in request, that request
207
- * itself excluded).
203
+ * driver records `sessionRequests` and calls this; the phases carry the whole
204
+ * derivation, so this reads them and decides nothing of its own.
208
205
  */
209
206
  export function deriveLegacyFacts(log) {
210
207
  const auth = log.find(r => r.phase === 'auth') ?? null;
211
208
  const data = log.filter(r => r.phase === 'post' && r.initiator === 'xhr');
212
209
  return {
213
- authRequest: auth === null ? null : ({ method: auth.method, path: auth.path, status: auth.status, failed: auth.failed }),
210
+ authRequest: auth === null ? null : ({
211
+ method: auth.method,
212
+ path: auth.path,
213
+ status: auth.status,
214
+ failed: auth.failed,
215
+ redirected: auth.redirected
216
+ }),
214
217
  postAuthDataAttempted: data.length,
215
218
  postAuthData2xx: data.filter(r => r.status !== null && r.status >= 200 && r.status < 300)
216
219
  .length
@@ -220,6 +223,11 @@ export function deriveLegacyFacts(log) {
220
223
  * answered No. 501 is included because a server that routes but implements
221
224
  * nothing is the same dead call from the client's side. */
222
225
  const MISSING_ROUTE_STATUS = new Set([404, 405, 501]);
226
+ /** A redirected entry's `status` and `mimeType` belong to its LAST hop, so the two
227
+ * rules below may read them only where that hop is still the client's own business.
228
+ * Before and during sign-in, a 302 to the login page is the normal unauthenticated
229
+ * flow; after sign-in, that same redirect IS the defect. */
230
+ const lastHopJudgesTheClient = (r) => !r.redirected || r.phase === 'post';
223
231
  /**
224
232
  * Judge a recorded session. The ONE thing that may FAIL is a session the SERVER
225
233
  * authenticated (2xx on the sign-in request) whose client then could not use it:
@@ -264,6 +272,14 @@ export function judgeDeepSession(f) {
264
272
  };
265
273
  }
266
274
  if (f.authRequest === null) {
275
+ if (f.signInLeftOrigin) {
276
+ return {
277
+ outcome: 'skip',
278
+ note: `signing in leaves this app for ${f.signInLeftOrigin} — an external identity `
279
+ + 'provider cannot be driven with a declared credential pair, so the '
280
+ + 'authenticated half of the app was NOT observed'
281
+ };
282
+ }
267
283
  const pinned = f.foreignOriginFailures.length > 0 ?
268
284
  ` — the client calls ${f.foreignOriginFailures.join(', ')}, not the origin under test (a base URL baked in at build time)`
269
285
  : '';
@@ -277,25 +293,29 @@ export function judgeDeepSession(f) {
277
293
  // Deliberately narrow: 400/401/403/419/422 are a handler ANSWERING (a rejected
278
294
  // password, a missing permission — the app working), 5xx is the server failing,
279
295
  // and both keep their existing behaviour. Only "no such route" is here.
280
- const missingRoute = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && r.status !== null && MISSING_ROUTE_STATUS.has(r.status));
296
+ const missingRoute = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr'
297
+ && lastHopJudgesTheClient(r)
298
+ && r.status !== null
299
+ && MISSING_ROUTE_STATUS.has(r.status));
281
300
  if (missingRoute) {
301
+ const landed = missingRoute.finalPath ?? missingRoute.path;
302
+ const hop = landed === missingRoute.path ? '' : ` → \`${landed}\``;
282
303
  return {
283
304
  outcome: 'fail',
284
- detail: `\`${missingRoute.method} ${missingRoute.path}\` → ${String(missingRoute.status)}: `
285
- + 'the client sent this to a path the server does not route. The credentials were '
286
- + 'never evaluated. This is a dead client call — a base URL joined twice, a renamed '
287
- + 'route, a wrong method. No type or mock can produce a route that is not mounted.'
305
+ detail: `\`${missingRoute.method} ${missingRoute.path}\`${hop} → ${String(missingRoute.status)}: `
306
+ + 'the client sent this to a path the server does not route. This is a dead client '
307
+ + 'call — a base URL joined twice, a renamed route, a wrong method. No type or mock '
308
+ + 'can produce a route that is not mounted.'
288
309
  };
289
310
  }
290
311
  // Rule B — the SPA catch-all answering an API call. Any server with a
291
312
  // `GET /*` → index.html fallback returns 200 for a route it does not have, so
292
313
  // the status is healthy and the body is the app shell. A document navigation
293
314
  // answered with HTML is normal; an XHR asking for data and getting HTML is a
294
- // call that reached nothing. The sign-in request is exempt: it keeps the first
295
- // hop but carries the LAST hop's mime, so a fetch login that 302s to a page
296
- // reads as HTML here while being a sign-in that worked.
315
+ // call that reached nothing. A fetch login that 302s to a page reads as HTML
316
+ // while being a sign-in that worked, hence the last-hop guard.
297
317
  const swallowed = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr'
298
- && r.phase !== 'auth'
318
+ && lastHopJudgesTheClient(r)
299
319
  && (r.mimeType ?? '').startsWith('text/html'));
300
320
  if (swallowed) {
301
321
  return {
@@ -306,7 +326,7 @@ export function judgeDeepSession(f) {
306
326
  + 'sees a 200 it cannot parse. No status check can see this.'
307
327
  };
308
328
  }
309
- const { method, path: p, status, failed } = f.authRequest;
329
+ const { method, path: p, status, failed, redirected: authRedirected } = f.authRequest;
310
330
  if (failed || status === null || status < 200 || status >= 300) {
311
331
  return {
312
332
  outcome: 'skip',
@@ -317,6 +337,18 @@ export function judgeDeepSession(f) {
317
337
  }
318
338
  const signedIn = `signed in (\`${method} ${p}\` → ${status})`;
319
339
  if (!f.leftAuthWall) {
340
+ // A form sign-in that redirects lands its status on the hop it reached, so a
341
+ // 200 here is the landing page's, not a verdict on the credentials. Landing
342
+ // back on the wall is what a REJECTED password looks like, and the gate's one
343
+ // FAIL needs the server to have said yes.
344
+ if (authRedirected === true) {
345
+ return {
346
+ outcome: 'skip',
347
+ note: `the sign-in request (\`${method} ${p}\`) redirected, so its ${String(status)} `
348
+ + 'describes the page it landed on and not the credentials, and the client is '
349
+ + 'still on the wall — the authenticated half of the app was NOT observed'
350
+ };
351
+ }
320
352
  return {
321
353
  outcome: 'fail',
322
354
  detail: `${signedIn} but the client NEVER LEFT THE SIGN-IN WALL: the page is still `
@@ -641,7 +673,7 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
641
673
  /**
642
674
  * The session over an already-connected browser: navigate, inspect, sign in if the
643
675
  * landing is a wall and credentials exist, settle, phase the same-origin request
644
- * log against the submit, re-enter once the sign-in was accepted, and
676
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
645
677
  * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
646
678
  * socket — so every branch is testable against a fake `CdpLike`.
647
679
  *
@@ -712,18 +744,17 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
712
744
  if (!before)
713
745
  throw new Error('the page could not be inspected');
714
746
  const sameOrigin = (r) => r.finalUrl.startsWith(`${origin}/`) || r.finalUrl === origin;
747
+ /** Which origin the client ASKED for — the first hop, before any redirect. */
748
+ const addressedOrigin = (r) => r.url.startsWith(`${origin}/`) || r.url === origin;
715
749
  const isData = (r) => r.type === 'XHR' || r.type === 'Fetch';
716
750
  const foreignOriginFailures = () => {
717
751
  const out = new Set();
718
752
  for (const r of requests.values()) {
719
753
  if (sameOrigin(r) || !r.failed || !r.finalUrl.startsWith('http'))
720
754
  continue;
721
- try {
722
- out.add(new URL(r.finalUrl).origin);
723
- }
724
- catch {
725
- // unparseable url — nothing to name
726
- }
755
+ const o = originOf(r.finalUrl);
756
+ if (o)
757
+ out.add(o);
727
758
  }
728
759
  return [...out];
729
760
  };
@@ -735,19 +766,27 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
735
766
  * 'pre'; the sign-in request itself is 'auth', not 'post'. The boundary is the
736
767
  * sign-in request, not the submit: a CSRF token or a beacon the submit fires
737
768
  * BEFORE the login is not evidence about the authenticated client. */
738
- const sessionLog = (authId, postSeq) => [...requests]
739
- .filter(([, r]) => sameOrigin(r))
740
- .map(([id, r]) => ({
741
- method: r.method,
742
- path: pathOf(r.url),
743
- status: r.status,
744
- mimeType: r.mimeType,
745
- failed: r.failed,
746
- initiator: initiatorOf(r),
747
- phase: id === authId ? 'auth'
748
- : r.seq >= postSeq ? 'post'
749
- : 'pre'
750
- }));
769
+ const sessionLog = (authId, postSeq) => {
770
+ const out = [];
771
+ for (const [id, r] of requests) {
772
+ if (!sameOrigin(r))
773
+ continue;
774
+ out.push({
775
+ method: r.method,
776
+ path: pathOf(r.url),
777
+ finalPath: pathOf(r.finalUrl),
778
+ status: r.status,
779
+ mimeType: r.mimeType,
780
+ failed: r.failed,
781
+ initiator: initiatorOf(r),
782
+ redirected: r.finalUrl !== r.url,
783
+ phase: id === authId ? 'auth'
784
+ : r.seq >= postSeq ? 'post'
785
+ : 'pre'
786
+ });
787
+ }
788
+ return out;
789
+ };
751
790
  const facts = (log, over) => ({
752
791
  sessionRequests: log,
753
792
  ...deriveLegacyFacts(log),
@@ -755,6 +794,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
755
794
  credentialsFound: credentials !== null,
756
795
  submitted: false,
757
796
  foreignOriginFailures: foreignOriginFailures(),
797
+ signInLeftOrigin: null,
758
798
  leftAuthWall: false,
759
799
  urlBefore: before.url,
760
800
  urlAfter: before.url,
@@ -780,29 +820,35 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
780
820
  return judge(unsubmitted({ submitted: false }));
781
821
  lastActivity = Date.now();
782
822
  await settle(() => lastActivity, POST_SUBMIT_CAP_MS, quietMs);
823
+ const firstRequest = (pred) => {
824
+ for (const [id, r] of requests)
825
+ if (pred(r))
826
+ return id;
827
+ return null;
828
+ };
783
829
  // The sign-in request: the first same-origin non-GET issued by the submit. Its
784
830
  // own 2xx is the precondition for judging anything, and it is EXCLUDED from the
785
831
  // data evidence — a broken build satisfies "at least one same-origin 2xx" with
786
832
  // exactly this request and nothing else.
787
- let authId = null;
788
- for (const [id, r] of requests) {
789
- if (r.seq >= submitSeq && sameOrigin(r) && r.method !== 'GET') {
790
- authId = id;
791
- break;
792
- }
793
- }
794
- // Nothing after the submit: a form that submits from the fill's own input
795
- // events already sent it. Only a NAVIGATION qualifies here a form submission
796
- // is one, and the background traffic the fill races (beacons, telemetry) is
797
- // XHR or fetch, never a document.
798
- if (authId === null) {
799
- for (const [id, r] of requests) {
800
- if (r.seq >= fillSeq && sameOrigin(r) && r.method !== 'GET' && r.type === 'Document') {
801
- authId = id;
802
- break;
803
- }
804
- }
805
- }
833
+ //
834
+ // The fallback covers a form that submits from the fill's own input events, so
835
+ // nothing arrives after the submit at all. Only a NAVIGATION qualifies there —
836
+ // the background traffic the fill races (beacons, telemetry) is XHR or fetch,
837
+ // never a document. The two windows overlap; the predicates carry the
838
+ // distinction.
839
+ const authId = firstRequest(r => r.seq >= submitSeq && sameOrigin(r) && r.method !== 'GET')
840
+ ?? firstRequest(r => r.seq >= fillSeq && sameOrigin(r) && r.method !== 'GET' && r.type === 'Document');
841
+ // An SSO sign-in addresses this app and ends on the provider, so it is absent
842
+ // from the same-origin log above and "no request to our own origin" would
843
+ // misname it. Document-only for the same reason the authId fallback is: a
844
+ // telemetry beacon the fill races that happens to end off-origin would otherwise
845
+ // name a bogus identity provider.
846
+ const offsiteSignIn = authId !== null ? null : (firstRequest(r => r.seq >= fillSeq
847
+ && r.method !== 'GET'
848
+ && r.type === 'Document'
849
+ && addressedOrigin(r)
850
+ && !sameOrigin(r)
851
+ && r.finalUrl.startsWith('http')));
806
852
  const authReq = authId === null ? null : requests.get(authId);
807
853
  const now = await evaluate(INSPECT_EXPR);
808
854
  const domJudgment = judgeRenderedDom(now?.html ?? '');
@@ -826,7 +872,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
826
872
  }
827
873
  return judge(facts(sessionLog(authId, authReq?.seq ?? submitSeq), {
828
874
  submitted: true,
829
- foreignOriginFailures: foreignOriginFailures(),
875
+ signInLeftOrigin: offsiteSignIn && originOf(requests.get(offsiteSignIn).finalUrl),
830
876
  leftAuthWall,
831
877
  urlAfter: now?.url ?? before.url,
832
878
  postAuthDomOk: domJudgment.ok,
@@ -841,3 +887,11 @@ function pathOf(url) {
841
887
  return url;
842
888
  }
843
889
  }
890
+ function originOf(url) {
891
+ try {
892
+ return new URL(url).origin;
893
+ }
894
+ catch {
895
+ return null;
896
+ }
897
+ }
@@ -600,9 +600,9 @@ export function manifestPackages(cwd) {
600
600
  * re-export puts the name in the export list; a `module X` re-export puts
601
601
  * nothing there at all, which is why `shouldBe` is invisible to the first.
602
602
  *
603
- * The trigger is the hole, with no threshold. Swept over the 299 modules with
604
- * 5+ exports, the trigger count reads 33 at 50% and 18 at 95%, so the fraction
605
- * is not carrying the decision and any value picked would just sound right.
603
+ * The trigger is the hole itself, with no fraction threshold: a threshold moves
604
+ * the count without changing which modules are actually missing signatures, so
605
+ * any value picked would only sound principled.
606
606
  */
607
607
  const EXPORT_NAME_RE = /^[A-Za-z_][\w']*$/;
608
608
  /** `module X` inside an export list is a re-export; `Prelude` is base, and base is not fetched. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.37",
3
+ "version": "0.40.39",
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",