@mjasnikovs/pi-task 0.40.36 → 0.40.38

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.
@@ -9,6 +9,8 @@ export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-
9
9
  export interface WritableLike {
10
10
  write(chunk: string): boolean;
11
11
  end(): void;
12
+ /** Optional so a mock stdin stays two methods; a real pipe always has it. */
13
+ on?(event: 'error', listener: (err: unknown) => void): unknown;
12
14
  }
13
15
  export interface ProcLike extends EventEmitter {
14
16
  /** Present only when the child was spawned with stdin 'pipe' (prompt delivery). */
@@ -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
@@ -236,6 +237,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
236
237
  ...(invocation.env ? { env: invocation.env } : {})
237
238
  });
238
239
  if (usesStdin) {
240
+ // A child killed before it read the prompt leaves the pipe broken, and an
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
+ });
239
248
  // pi reads the prompt from stdin and waits for EOF, so write then end.
240
249
  proc.stdin?.write(invocation.stdin);
241
250
  proc.stdin?.end();
@@ -401,10 +410,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
401
410
  if (sink)
402
411
  sink.flush();
403
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;
404
417
  settle({
405
418
  stdout,
406
- stderr,
407
- exitCode: code ?? 0,
419
+ stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
420
+ exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
408
421
  aborted: kill !== undefined,
409
422
  ...(kill ? { kill } : {}),
410
423
  text,
@@ -70,8 +70,11 @@ export interface SessionRequest {
70
70
  failed: boolean;
71
71
  /** CDP resource type, collapsed: what ISSUED this request. */
72
72
  initiator: 'xhr' | 'document' | 'other';
73
- /** Before the submit, the sign-in request itself, or issued at or after the
74
- * submit. */
73
+ /** The chain redirected, so `status` and `mimeType` above describe a hop this
74
+ * entry does not name. Any rule that reads a status AS A FACT ABOUT `path`
75
+ * must skip these. */
76
+ redirected: boolean;
77
+ /** Before the sign-in request, the sign-in request itself, or after it. */
75
78
  phase: 'pre' | 'auth' | 'post';
76
79
  }
77
80
  export interface DeepSessionFacts {
@@ -94,10 +97,14 @@ export interface DeepSessionFacts {
94
97
  status: number | null;
95
98
  failed: boolean;
96
99
  } | 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'. */
100
+ /** Same-origin XHR/fetch requests issued at or after the sign-in request,
101
+ * excluding that request itself. Derived: `sessionRequests` in phase 'post'. */
99
102
  postAuthDataAttempted: number;
100
103
  postAuthData2xx: number;
104
+ /** The origin the sign-in left for, when the submit addressed this app and the
105
+ * chain ended somewhere else — an external identity provider, which no
106
+ * declared credential pair can drive. Optional: absent means "not recorded". */
107
+ signInLeftOrigin?: string | null;
101
108
  /** Origins the client called that are not the app's own, whose requests failed
102
109
  * (a bundle pinned to a build-time base URL that is not the port under test). */
103
110
  foreignOriginFailures: string[];
@@ -111,11 +118,8 @@ export interface DeepSessionFacts {
111
118
  }
112
119
  /**
113
120
  * 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).
121
+ * driver records `sessionRequests` and calls this; the phases carry the whole
122
+ * derivation, so this reads them and decides nothing of its own.
119
123
  */
120
124
  export declare function deriveLegacyFacts(log: SessionRequest[]): Pick<DeepSessionFacts, 'authRequest' | 'postAuthDataAttempted' | 'postAuthData2xx'>;
121
125
  /**
@@ -227,7 +231,7 @@ export interface DriveSessionOptions {
227
231
  /**
228
232
  * The session over an already-connected browser: navigate, inspect, sign in if the
229
233
  * 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
234
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
231
235
  * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
232
236
  * socket — so every branch is testable against a fake `CdpLike`.
233
237
  *
@@ -200,11 +200,8 @@ 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;
@@ -264,6 +261,14 @@ export function judgeDeepSession(f) {
264
261
  };
265
262
  }
266
263
  if (f.authRequest === null) {
264
+ if (f.signInLeftOrigin) {
265
+ return {
266
+ outcome: 'skip',
267
+ note: `signing in leaves this app for ${f.signInLeftOrigin} — an external identity `
268
+ + 'provider cannot be driven with a declared credential pair, so the '
269
+ + 'authenticated half of the app was NOT observed'
270
+ };
271
+ }
267
272
  const pinned = f.foreignOriginFailures.length > 0 ?
268
273
  ` — the client calls ${f.foreignOriginFailures.join(', ')}, not the origin under test (a base URL baked in at build time)`
269
274
  : '';
@@ -277,26 +282,27 @@ export function judgeDeepSession(f) {
277
282
  // Deliberately narrow: 400/401/403/419/422 are a handler ANSWERING (a rejected
278
283
  // password, a missing permission — the app working), 5xx is the server failing,
279
284
  // 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));
285
+ const missingRoute = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr'
286
+ && !r.redirected
287
+ && r.status !== null
288
+ && MISSING_ROUTE_STATUS.has(r.status));
281
289
  if (missingRoute) {
282
290
  return {
283
291
  outcome: 'fail',
284
292
  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.'
293
+ + 'the client sent this to a path the server does not route. This is a dead client '
294
+ + 'call — a base URL joined twice, a renamed route, a wrong method. No type or mock '
295
+ + 'can produce a route that is not mounted.'
288
296
  };
289
297
  }
290
298
  // Rule B — the SPA catch-all answering an API call. Any server with a
291
299
  // `GET /*` → index.html fallback returns 200 for a route it does not have, so
292
300
  // the status is healthy and the body is the app shell. A document navigation
293
301
  // 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.
297
- const swallowed = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr'
298
- && r.phase !== 'auth'
299
- && (r.mimeType ?? '').startsWith('text/html'));
302
+ // call that reached nothing. A redirected request is exempt because the mime
303
+ // came from the hop it ended on: a fetch login that 302s to a page reads as
304
+ // HTML while being a sign-in that worked.
305
+ const swallowed = (f.sessionRequests ?? []).find(r => r.initiator === 'xhr' && !r.redirected && (r.mimeType ?? '').startsWith('text/html'));
300
306
  if (swallowed) {
301
307
  return {
302
308
  outcome: 'fail',
@@ -641,7 +647,7 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
641
647
  /**
642
648
  * The session over an already-connected browser: navigate, inspect, sign in if the
643
649
  * 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
650
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
645
651
  * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
646
652
  * socket — so every branch is testable against a fake `CdpLike`.
647
653
  *
@@ -712,18 +718,17 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
712
718
  if (!before)
713
719
  throw new Error('the page could not be inspected');
714
720
  const sameOrigin = (r) => r.finalUrl.startsWith(`${origin}/`) || r.finalUrl === origin;
721
+ /** Which origin the client ASKED for — the first hop, before any redirect. */
722
+ const addressedOrigin = (r) => r.url.startsWith(`${origin}/`) || r.url === origin;
715
723
  const isData = (r) => r.type === 'XHR' || r.type === 'Fetch';
716
724
  const foreignOriginFailures = () => {
717
725
  const out = new Set();
718
726
  for (const r of requests.values()) {
719
727
  if (sameOrigin(r) || !r.failed || !r.finalUrl.startsWith('http'))
720
728
  continue;
721
- try {
722
- out.add(new URL(r.finalUrl).origin);
723
- }
724
- catch {
725
- // unparseable url — nothing to name
726
- }
729
+ const o = originOf(r.finalUrl);
730
+ if (o)
731
+ out.add(o);
727
732
  }
728
733
  return [...out];
729
734
  };
@@ -735,19 +740,26 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
735
740
  * 'pre'; the sign-in request itself is 'auth', not 'post'. The boundary is the
736
741
  * sign-in request, not the submit: a CSRF token or a beacon the submit fires
737
742
  * 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
- }));
743
+ const sessionLog = (authId, postSeq) => {
744
+ const out = [];
745
+ for (const [id, r] of requests) {
746
+ if (!sameOrigin(r))
747
+ continue;
748
+ out.push({
749
+ method: r.method,
750
+ path: pathOf(r.url),
751
+ status: r.status,
752
+ mimeType: r.mimeType,
753
+ failed: r.failed,
754
+ initiator: initiatorOf(r),
755
+ redirected: r.finalUrl !== r.url,
756
+ phase: id === authId ? 'auth'
757
+ : r.seq >= postSeq ? 'post'
758
+ : 'pre'
759
+ });
760
+ }
761
+ return out;
762
+ };
751
763
  const facts = (log, over) => ({
752
764
  sessionRequests: log,
753
765
  ...deriveLegacyFacts(log),
@@ -755,6 +767,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
755
767
  credentialsFound: credentials !== null,
756
768
  submitted: false,
757
769
  foreignOriginFailures: foreignOriginFailures(),
770
+ signInLeftOrigin: null,
758
771
  leftAuthWall: false,
759
772
  urlBefore: before.url,
760
773
  urlAfter: before.url,
@@ -780,29 +793,32 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
780
793
  return judge(unsubmitted({ submitted: false }));
781
794
  lastActivity = Date.now();
782
795
  await settle(() => lastActivity, POST_SUBMIT_CAP_MS, quietMs);
796
+ const firstRequest = (pred) => {
797
+ for (const [id, r] of requests)
798
+ if (pred(r))
799
+ return id;
800
+ return null;
801
+ };
783
802
  // The sign-in request: the first same-origin non-GET issued by the submit. Its
784
803
  // own 2xx is the precondition for judging anything, and it is EXCLUDED from the
785
804
  // data evidence — a broken build satisfies "at least one same-origin 2xx" with
786
805
  // 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
- }
806
+ //
807
+ // The fallback covers a form that submits from the fill's own input events, so
808
+ // nothing arrives after the submit at all. Only a NAVIGATION qualifies there —
809
+ // the background traffic the fill races (beacons, telemetry) is XHR or fetch,
810
+ // never a document. The two windows overlap; the predicates carry the
811
+ // distinction.
812
+ const authId = firstRequest(r => r.seq >= submitSeq && sameOrigin(r) && r.method !== 'GET')
813
+ ?? firstRequest(r => r.seq >= fillSeq && sameOrigin(r) && r.method !== 'GET' && r.type === 'Document');
814
+ // An SSO sign-in addresses this app and ends on the provider, so it is absent
815
+ // from the same-origin log above and "no request to our own origin" would
816
+ // misname it.
817
+ const offsiteSignIn = authId !== null ? null : (firstRequest(r => r.seq >= fillSeq
818
+ && r.method !== 'GET'
819
+ && addressedOrigin(r)
820
+ && !sameOrigin(r)
821
+ && r.finalUrl.startsWith('http')));
806
822
  const authReq = authId === null ? null : requests.get(authId);
807
823
  const now = await evaluate(INSPECT_EXPR);
808
824
  const domJudgment = judgeRenderedDom(now?.html ?? '');
@@ -826,7 +842,7 @@ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
826
842
  }
827
843
  return judge(facts(sessionLog(authId, authReq?.seq ?? submitSeq), {
828
844
  submitted: true,
829
- foreignOriginFailures: foreignOriginFailures(),
845
+ signInLeftOrigin: offsiteSignIn && originOf(requests.get(offsiteSignIn).finalUrl),
830
846
  leftAuthWall,
831
847
  urlAfter: now?.url ?? before.url,
832
848
  postAuthDomOk: domJudgment.ok,
@@ -841,3 +857,11 @@ function pathOf(url) {
841
857
  return url;
842
858
  }
843
859
  }
860
+ function originOf(url) {
861
+ try {
862
+ return new URL(url).origin;
863
+ }
864
+ catch {
865
+ return null;
866
+ }
867
+ }
@@ -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.36",
3
+ "version": "0.40.38",
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",