@mjasnikovs/pi-task 0.40.37 → 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.
|
@@ -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.
|
|
241
|
-
//
|
|
242
|
-
|
|
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,
|
|
@@ -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
|
-
/**
|
|
74
|
-
*
|
|
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
|
|
98
|
-
*
|
|
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
|
|
115
|
-
*
|
|
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
|
|
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
|
|
204
|
-
*
|
|
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'
|
|
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.
|
|
286
|
-
+ '
|
|
287
|
-
+ '
|
|
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.
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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
|
|
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
|
-
|
|
722
|
-
|
|
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) =>
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
:
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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.
|
|
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",
|