peaks-loop 4.0.36 → 4.0.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.
- package/CHANGELOG.md +42 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +41 -7
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/detect-ocr-18.d.ts +2 -0
- package/dist/services/lint/detect-ocr-18.js +36 -5
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/lint/ocr-multilang-adapter.js +9 -2
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
- package/dist/services/skills/hooks-settings-service.d.ts +26 -0
- package/dist/services/skills/hooks-settings-service.js +186 -62
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +59 -7
- package/dist/services/workspace/claude-settings-template.js +139 -67
- package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persistent login profile — its name guard, and the headed login that
|
|
3
|
+
* produces it (slice S4, file 20; design §2/§5/§10.2, orchestrator decision C1).
|
|
4
|
+
*
|
|
5
|
+
* Two halves, one responsibility ("a profile the user explicitly asked to keep"):
|
|
6
|
+
*
|
|
7
|
+
* 1. The PATH GUARD. `~/.peaks/web-profiles/<name>/storageState.json`, where
|
|
8
|
+
* `<name>` must match `PROFILE_NAME_RE` after lower-case folding, must not
|
|
9
|
+
* be a dot segment, must not BEGIN with a dot (`.con` has an empty stem, so
|
|
10
|
+
* the device-name test cannot see it, and `..foo` is a dot segment the
|
|
11
|
+
* charset lets through), must not end in a dot or a space (see
|
|
12
|
+
* `hasTrailingDotOrSpace`), must not be a Windows device name, and must
|
|
13
|
+
* resolve under the profile root. Re-derived from `resolveUserDataDir`
|
|
14
|
+
* (`src/cli/commands/playwright-commands.ts:462`), whose prefix-plus-separator
|
|
15
|
+
* containment test is what stops `/home/A/prof2` passing for `/home/A/prof`.
|
|
16
|
+
* The charset test alone is NOT the guard: `..` matches
|
|
17
|
+
* `^[a-z0-9._-]{1,64}$` — the dot-segment rejection is what catches it.
|
|
18
|
+
*
|
|
19
|
+
* 2. The HEADED LOGIN. C1 is binding: never `launchPersistentContext`, which
|
|
20
|
+
* would create one browser PROCESS per dispatch and violate Q8. One
|
|
21
|
+
* `chromium.launch({ headless: false })` the user drives, and the profile is
|
|
22
|
+
* captured as a Playwright storage state — not a Chromium `userDataDir`.
|
|
23
|
+
*
|
|
24
|
+
* This is the ONLY path in the feature that persists a login (PRD R7), so it is
|
|
25
|
+
* reached only from an explicit `peaks web login --profile <name>`. The daemon's
|
|
26
|
+
* browser is headless and its own; `login` neither needs a session binding nor
|
|
27
|
+
* goes through `routeOp`.
|
|
28
|
+
*
|
|
29
|
+
* THE INTERACTION PROTOCOL (user decision, 2026-09-10 — it REPLACED an explicit
|
|
30
|
+
* `login.confirmed` file):
|
|
31
|
+
*
|
|
32
|
+
* - "Done" is the user CLOSING THE HEADED WINDOW. It is the only signal that
|
|
33
|
+
* does not ask the user to do something they would not otherwise do, and
|
|
34
|
+
* `browser.on('disconnected')` is what the wait ends on.
|
|
35
|
+
* - The capture is IN MEMORY, and that is a hard constraint, not a preference:
|
|
36
|
+
* with a non-persistent context — which C1 mandates — the context is gone
|
|
37
|
+
* the moment the browser disconnects. Verified on the pinned
|
|
38
|
+
* playwright@1.63.0: `context.storageState()` after disconnect throws
|
|
39
|
+
* "Target page, context or browser has been closed". So the state is read
|
|
40
|
+
* INTO THIS PROCESS every `LOGIN_SNAPSHOT_MS` while the window is open
|
|
41
|
+
* (`storageState()` with no `path`, which returns the object), and written to
|
|
42
|
+
* `storageState.json` EXACTLY ONCE, at the moment of disconnect.
|
|
43
|
+
* - The publish is ATOMIC (user decision UD-7, 2026-09-10); it lives in
|
|
44
|
+
* `web-login-staging.ts`. The bytes land in a staging sibling IN THE SAME
|
|
45
|
+
* PROFILE DIRECTORY and a `renameSync` linearizes them onto
|
|
46
|
+
* `storageState.json`, so the previous profile is untouched unless that
|
|
47
|
+
* rename succeeds. That is the fix for what a single `writeFileSync` did:
|
|
48
|
+
* its default flag `w` is `O_TRUNC`, so it emptied the artifact AT OPEN —
|
|
49
|
+
* before the first byte — and a mid-write `ENOSPC`/`EIO` destroyed a working
|
|
50
|
+
* login while the failure message called it "unchanged". The staging name is
|
|
51
|
+
* PER-RUN (`storageState.json.<pid>.staging`, S4 R5) because a fixed one let
|
|
52
|
+
* two concurrent logins on one profile install each other's bytes.
|
|
53
|
+
* `browser-workflow.md` names the staging file as a carve-out with three
|
|
54
|
+
* conditions, all kept: it lives in the profile directory, it is deleted on
|
|
55
|
+
* every failure path, and nothing ever reads it as a profile. An unclosed or
|
|
56
|
+
* never-captured login publishes NOTHING at all.
|
|
57
|
+
* - An EMPTY capture is not a session. A run that captured NOTHING — no
|
|
58
|
+
* cookies AND no origins — writes nothing and fails, rather than replacing a
|
|
59
|
+
* working profile with an empty one and reporting a login that did not
|
|
60
|
+
* happen as a success. A state that holds only `origins` (a session kept in
|
|
61
|
+
* localStorage) IS a session and is published (S4 R5).
|
|
62
|
+
* - The persisted state can be up to one snapshot interval STALE: a cookie set
|
|
63
|
+
* in the last second before the window is closed may be missing. The capture
|
|
64
|
+
* is never exact and is never described as exact.
|
|
65
|
+
* - The wait is bounded by a MONOTONIC deadline (`LOGIN_TIMEOUT_MS`) taken
|
|
66
|
+
* BEFORE the launch — so launch, context and page creation SPEND that budget
|
|
67
|
+
* rather than sitting outside it — and the teardown is bounded too, so
|
|
68
|
+
* neither an abandoned login nor a `browser.close()` that never settles can
|
|
69
|
+
* hold the process (or a browser process) open for the rest of the machine's
|
|
70
|
+
* uptime. The residual, stated rather than implied: those setup steps are
|
|
71
|
+
* inside the budget but are NOT individually raced, so a `launch()` that
|
|
72
|
+
* never settles would still hang. Bounding it would mean abandoning a
|
|
73
|
+
* browser this code has no handle on yet.
|
|
74
|
+
*/
|
|
75
|
+
import { mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
76
|
+
import { join } from 'node:path';
|
|
77
|
+
import { performance } from 'node:perf_hooks';
|
|
78
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
79
|
+
import { assertUnder, userWebProfilesDir } from './web-artifact-paths.js';
|
|
80
|
+
import { loadPlaywright } from './playwright-loader.js';
|
|
81
|
+
import { discardStaging, publishState } from './web-login-staging.js';
|
|
82
|
+
/**
|
|
83
|
+
* A profile name is ONE path component: LOWER-case letters, digits, dot,
|
|
84
|
+
* underscore and hyphen, 1–64 of them. `..` matches this charset (it is two
|
|
85
|
+
* dots and nothing else), which is exactly why `resolveProfileName`'s
|
|
86
|
+
* dot-segment rejection is not redundant.
|
|
87
|
+
*
|
|
88
|
+
* This describes the CANONICAL form. NTFS and default APFS fold case, so `Work`
|
|
89
|
+
* and `work` are ONE directory on those filesystems; `resolveProfileName`
|
|
90
|
+
* therefore lower-cases what the caller typed and uses the folded result, rather
|
|
91
|
+
* than rejecting a name the caller means perfectly well. The fold is reported
|
|
92
|
+
* back so it is never silent.
|
|
93
|
+
*/
|
|
94
|
+
export const PROFILE_NAME_RE = /^[a-z0-9._-]{1,64}$/;
|
|
95
|
+
/** `.` would collide with the profile ROOT; `..` would climb out of it. */
|
|
96
|
+
const DOT_SEGMENTS = new Set(['.', '..']);
|
|
97
|
+
/**
|
|
98
|
+
* Windows device names, reserved as a path component even with an extension
|
|
99
|
+
* (`con.json` opens the device). A profile that lands on one writes nothing, so
|
|
100
|
+
* a "successful" login would silently persist no session at all.
|
|
101
|
+
*/
|
|
102
|
+
const RESERVED_DEVICE_NAMES = new Set([
|
|
103
|
+
'con',
|
|
104
|
+
'prn',
|
|
105
|
+
'aux',
|
|
106
|
+
'nul',
|
|
107
|
+
...Array.from({ length: 9 }, (_unused, index) => `com${String(index + 1)}`),
|
|
108
|
+
...Array.from({ length: 9 }, (_unused, index) => `lpt${String(index + 1)}`)
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* How much of an unvalidated or caller-typed name a message may echo back. A
|
|
112
|
+
* refusal is not a place to reproduce a 1 MB `--profile` (S1's bounded-output
|
|
113
|
+
* invariant), so both the guard here and the CLI's gate refusal cap it with the
|
|
114
|
+
* SAME function.
|
|
115
|
+
*/
|
|
116
|
+
const MAX_ECHOED_NAME_CHARS = 64;
|
|
117
|
+
/** Bound a caller-supplied name before it is embedded in a message. */
|
|
118
|
+
export function cappedEcho(raw) {
|
|
119
|
+
return raw.length > MAX_ECHOED_NAME_CHARS ? `${raw.slice(0, MAX_ECHOED_NAME_CHARS)}…` : raw;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether `raw` ends in a dot or a space.
|
|
123
|
+
*
|
|
124
|
+
* Windows strips both, so `work.` has no stable name of its own there — it is
|
|
125
|
+
* the `work` directory. Unlike case, which folds into a name the caller plainly
|
|
126
|
+
* meant, trimming would silently rewrite the name, so this is REJECTED rather
|
|
127
|
+
* than folded.
|
|
128
|
+
*/
|
|
129
|
+
function hasTrailingDotOrSpace(raw) {
|
|
130
|
+
return raw !== raw.replace(/[. ]+$/, '');
|
|
131
|
+
}
|
|
132
|
+
/** `<homedir>/.peaks/web-profiles/<name>/`. The name must be resolved first. */
|
|
133
|
+
export function webProfileDir(name) {
|
|
134
|
+
return join(userWebProfilesDir(), name);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validate a caller-supplied `--profile` and return the CANONICAL name — the
|
|
138
|
+
* input lower-cased, which is the one name the filesystem will actually store.
|
|
139
|
+
* `--profile Work` resolves to `work`.
|
|
140
|
+
*
|
|
141
|
+
* The fold is deliberate (user decision, 2026-09-10) and is never silent: the
|
|
142
|
+
* call site compares what it typed with what came back and reports the
|
|
143
|
+
* difference. What has no sensible canonical form is still refused — see
|
|
144
|
+
* `hasTrailingDotOrSpace`.
|
|
145
|
+
*
|
|
146
|
+
* Two refusals are DELIBERATE rather than incidental (S4 repair, security S6):
|
|
147
|
+
*
|
|
148
|
+
* - a LEADING dot is refused, because it makes `folded.split('.')[0]` empty.
|
|
149
|
+
* `.con` is the Windows device under a name the stem test cannot see, and
|
|
150
|
+
* `..foo` is a dot segment that `PROFILE_NAME_RE` accepts — it used to be
|
|
151
|
+
* stopped by `assertUnder` instead, which surfaced `WEB_PATH_ESCAPE` under
|
|
152
|
+
* the wrong code. Both now carry this function's own message.
|
|
153
|
+
* - the caller's input is echoed CAPPED, like the CLI's gate refusal, so a
|
|
154
|
+
* huge `--profile` cannot be reproduced whole in a message.
|
|
155
|
+
*
|
|
156
|
+
* Throws `WEB_PROFILE_NAME_INVALID`, and returns the NAME rather than a path so
|
|
157
|
+
* a call site cannot mistake one for the other.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveProfileName(raw) {
|
|
160
|
+
const folded = raw.toLowerCase();
|
|
161
|
+
const deviceName = folded.split('.')[0] ?? '';
|
|
162
|
+
if (!PROFILE_NAME_RE.test(folded) ||
|
|
163
|
+
DOT_SEGMENTS.has(folded) ||
|
|
164
|
+
deviceName === '' ||
|
|
165
|
+
hasTrailingDotOrSpace(raw) ||
|
|
166
|
+
RESERVED_DEVICE_NAMES.has(deviceName)) {
|
|
167
|
+
throw new Error(`WEB_PROFILE_NAME_INVALID: ${JSON.stringify(cappedEcho(raw))} must match ` +
|
|
168
|
+
`${PROFILE_NAME_RE.source} after lower-casing and must not begin with a dot, be a dot ` +
|
|
169
|
+
'segment, be a Windows device name, or end in a dot or a space');
|
|
170
|
+
}
|
|
171
|
+
assertUnder(webProfileDir(folded), userWebProfilesDir());
|
|
172
|
+
return folded;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* `<homedir>/.peaks/web-profiles/<name>/storageState.json` — the persisted
|
|
176
|
+
* artifact, and the only thing `login` writes.
|
|
177
|
+
*/
|
|
178
|
+
export function loginStorageStatePath(name) {
|
|
179
|
+
return join(webProfileDir(resolveProfileName(name)), 'storageState.json');
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* How long one headed login may stay open. Bounded so an abandoned `login`
|
|
183
|
+
* cannot leave a browser process behind indefinitely; ten minutes is generous
|
|
184
|
+
* for a password plus an MFA round trip.
|
|
185
|
+
*/
|
|
186
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
187
|
+
/**
|
|
188
|
+
* How often the session is read into memory while the window is open. This IS
|
|
189
|
+
* the staleness bound of what gets persisted (see the module docstring): a
|
|
190
|
+
* cookie set after the last read is not in the saved profile.
|
|
191
|
+
*/
|
|
192
|
+
const LOGIN_SNAPSHOT_MS = 1_000;
|
|
193
|
+
/**
|
|
194
|
+
* How long teardown may take before it is REPORTED instead of waited on. The
|
|
195
|
+
* docstring promises an abandoned login cannot hold a browser open forever; an
|
|
196
|
+
* unbounded `browser.close()` in the `finally` is exactly how that promise would
|
|
197
|
+
* be broken (S4 R3). A close that loses this race is not silent — the caller is
|
|
198
|
+
* told the browser may still be running.
|
|
199
|
+
*/
|
|
200
|
+
const TEARDOWN_TIMEOUT_MS = 5_000;
|
|
201
|
+
/**
|
|
202
|
+
* The profile DIRECTORY's mode. The tree holds live session cookies, so it gets
|
|
203
|
+
* the same owner-only treatment the daemon token does
|
|
204
|
+
* (`daemon-registry.ts:24-33`), for a strictly more valuable file. The
|
|
205
|
+
* ARTIFACT's mode — and the plain statement of what these calls are worth on
|
|
206
|
+
* Windows, where they are inert — lives with the write, in
|
|
207
|
+
* `web-login-staging.ts`.
|
|
208
|
+
*
|
|
209
|
+
* On POSIX this one is what it looks like: `mkdirSync(…, {mode: 0o700})` yields
|
|
210
|
+
* 0700 for any umask.
|
|
211
|
+
*/
|
|
212
|
+
const PROFILE_DIR_MODE = 0o700;
|
|
213
|
+
/** The code a completed-but-unreadable capture carries on an `ok` outcome. */
|
|
214
|
+
const STATE_UNREADABLE_CODE = 'WEB_LOGIN_STATE_UNREADABLE';
|
|
215
|
+
/**
|
|
216
|
+
* Open the headed browser, read the session into memory while the user works,
|
|
217
|
+
* and persist it when the user closes the window.
|
|
218
|
+
*
|
|
219
|
+
* Everything that happens once the browser is up comes back as `ok: false` with
|
|
220
|
+
* a code rather than a throw. A failure BEFORE that point — an invalid name, an
|
|
221
|
+
* unresolvable Playwright, a launch that will not start — does throw, because
|
|
222
|
+
* there is no browser to tear down and no outcome to describe; the CLI renders
|
|
223
|
+
* it with the same code and the same envelope shape, so the caller sees one
|
|
224
|
+
* contract either way.
|
|
225
|
+
*/
|
|
226
|
+
export async function runHeadedLogin(options) {
|
|
227
|
+
const profile = resolveProfileName(options.profile);
|
|
228
|
+
const dir = webProfileDir(profile);
|
|
229
|
+
const statePath = join(dir, 'storageState.json');
|
|
230
|
+
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
231
|
+
// Taken BEFORE the launch (S4 R3): the bound covers the whole command, not
|
|
232
|
+
// just the wait. Launch, context and page creation are time the user is not
|
|
233
|
+
// getting back, and a docstring claim about not holding a browser open forever
|
|
234
|
+
// has to include them.
|
|
235
|
+
const deadline = performance.now() + timeoutMs;
|
|
236
|
+
const warnings = [];
|
|
237
|
+
mkdirSync(dir, { recursive: true, mode: PROFILE_DIR_MODE });
|
|
238
|
+
const playwright = await loadPlaywright();
|
|
239
|
+
const browser = await playwright.chromium.launch({ headless: false });
|
|
240
|
+
let closed = false;
|
|
241
|
+
let captured = false;
|
|
242
|
+
let emptyCapture = false;
|
|
243
|
+
let failure = null;
|
|
244
|
+
let state = null;
|
|
245
|
+
let readErrorWhileOpen = null;
|
|
246
|
+
try {
|
|
247
|
+
// The disconnect listener is attached BEFORE the first await (S4 repair,
|
|
248
|
+
// code review F3): `disconnected` is not replayed, so a browser that dies
|
|
249
|
+
// while the context or the page is being created would otherwise be missed,
|
|
250
|
+
// `closed` would stay false, and the wait would spin out the full ten
|
|
251
|
+
// minutes on a browser that is already gone. `isConnected()` is the same
|
|
252
|
+
// defence for a browser that was already dead when the launch returned.
|
|
253
|
+
const disconnect = watchDisconnect(browser);
|
|
254
|
+
const context = await browser.newContext({ acceptDownloads: false });
|
|
255
|
+
await context.newPage();
|
|
256
|
+
options.announce();
|
|
257
|
+
const result = await snapshotUntilClosed(context, disconnect, deadline);
|
|
258
|
+
closed = result.closed;
|
|
259
|
+
captured = result.captured;
|
|
260
|
+
readErrorWhileOpen = result.readErrorWhileOpen;
|
|
261
|
+
// What is in memory IS what was captured: a session never read is never
|
|
262
|
+
// written, whatever else happened.
|
|
263
|
+
if (result.closed && result.captured) {
|
|
264
|
+
// The write site's own guard (tech-doc §7.2 rule 2), on top of the one
|
|
265
|
+
// `resolveProfileName` already ran on the directory.
|
|
266
|
+
assertUnder(statePath, userWebProfilesDir());
|
|
267
|
+
// Nothing captured is not a session (S4 R3, both lenses): publishing it
|
|
268
|
+
// would replace a working profile with an empty one AND report a login
|
|
269
|
+
// that did not happen as a success. Nothing is written, and the outcome
|
|
270
|
+
// below is a failure — so this cannot overwrite an existing profile
|
|
271
|
+
// either. What counts as nothing is BOTH arrays empty (S4 R5): an
|
|
272
|
+
// origins-only state is a localStorage session, not an empty capture.
|
|
273
|
+
if (isEmptyCapture(result.snapshot)) {
|
|
274
|
+
emptyCapture = true;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
publishState(statePath, result.snapshot, warnings);
|
|
278
|
+
state = readStateCounts(statePath);
|
|
279
|
+
if (state.failure !== null) {
|
|
280
|
+
warnings.push(`the storage state at ${statePath} could not be read back: ${state.failure}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
failure = { code: 'WEB_LOGIN_FAILED', message: getErrorMessage(error) };
|
|
287
|
+
if (captured) {
|
|
288
|
+
// The publish is what can fail here (a lock on the file, no space), and
|
|
289
|
+
// the freshly captured session then lives only in this process's memory
|
|
290
|
+
// and is dropped.
|
|
291
|
+
//
|
|
292
|
+
// The message claims only what this run can verify (S4 R5): our ONLY
|
|
293
|
+
// artifact-touching operation is the rename, and it threw, so THIS RUN did
|
|
294
|
+
// not modify the artifact. The older wording ("is unchanged") asserted
|
|
295
|
+
// something about the file that a concurrent login on the same profile can
|
|
296
|
+
// make false — and, before the staging publish, was false for a single
|
|
297
|
+
// `writeFileSync`, which truncated the artifact at open (S4 R3).
|
|
298
|
+
warnings.push(`the session captured for profile "${profile}" was NOT written; this run did not ` +
|
|
299
|
+
`modify ${statePath}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
// Teardown stays non-blocking but never silent (S1's F1): a browser that
|
|
304
|
+
// will not close — or will not confirm that it did — is reported rather than
|
|
305
|
+
// swallowed, and it is BOUNDED so a wedged close cannot pin the process.
|
|
306
|
+
await closeBounded(browser, timeoutMs, warnings);
|
|
307
|
+
// Every path out: published, refused, empty, timed out, never captured, or
|
|
308
|
+
// thrown. A staging file holds live cookies and must never outlive this run.
|
|
309
|
+
discardStaging(statePath, warnings);
|
|
310
|
+
}
|
|
311
|
+
const stats = state ?? { bytes: 0, cookies: 0, origins: 0, failure: null };
|
|
312
|
+
if (failure !== null) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
code: failure.code,
|
|
316
|
+
message: failure.message,
|
|
317
|
+
profile,
|
|
318
|
+
storageStatePath: statePath,
|
|
319
|
+
...zeroStats(),
|
|
320
|
+
warnings
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (!closed) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
code: 'WEB_LOGIN_NOT_CLOSED',
|
|
327
|
+
message: `the headed browser for profile "${profile}" was not closed within ` +
|
|
328
|
+
`${String(Math.round(timeoutMs / 1000))} s, so no storage state was written`,
|
|
329
|
+
profile,
|
|
330
|
+
storageStatePath: statePath,
|
|
331
|
+
...zeroStats(),
|
|
332
|
+
warnings
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (!captured) {
|
|
336
|
+
// The window closed, but no snapshot was ever taken — nothing was captured,
|
|
337
|
+
// so there is nothing to save and nothing that would make an empty file
|
|
338
|
+
// look like a session.
|
|
339
|
+
//
|
|
340
|
+
// WHY, though, is not always "it closed" (S4 R3). A `storageState()` that
|
|
341
|
+
// keeps failing while the browser is still up — a protocol error, a wedged
|
|
342
|
+
// context — used to be swallowed by a bare `catch` and reported as a close,
|
|
343
|
+
// forever, with the real cause never surfaced. `readErrorWhileOpen` is only
|
|
344
|
+
// set for a read that failed with the browser STILL CONNECTED and with an
|
|
345
|
+
// error that is not the close's own "Target … has been closed" (S4 R5), so a
|
|
346
|
+
// close cannot be mistaken for it — in either ordering.
|
|
347
|
+
const why = readErrorWhileOpen === null
|
|
348
|
+
? 'closed before its session could be read'
|
|
349
|
+
: `never returned a readable session (every attempt failed while it was open: ${readErrorWhileOpen})`;
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
code: 'WEB_LOGIN_NO_SNAPSHOT',
|
|
353
|
+
message: `the headed browser for profile "${profile}" ${why}, so no storage state was written`,
|
|
354
|
+
profile,
|
|
355
|
+
storageStatePath: statePath,
|
|
356
|
+
...zeroStats(),
|
|
357
|
+
warnings
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (emptyCapture) {
|
|
361
|
+
// The window closed and a state WAS read, but it holds NOTHING — neither
|
|
362
|
+
// cookies nor origins. For a verb whose whole purpose is persisting a
|
|
363
|
+
// session that is not a success, and writing it would replace a working
|
|
364
|
+
// profile with a useless one (S4 R3). Nothing was published: nothing was
|
|
365
|
+
// written by this run.
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
code: 'WEB_LOGIN_EMPTY_SNAPSHOT',
|
|
369
|
+
message: `the headed browser for profile "${profile}" was closed but the captured session holds ` +
|
|
370
|
+
'neither cookies nor origins, so no storage state was written — that is not a login',
|
|
371
|
+
profile,
|
|
372
|
+
storageStatePath: statePath,
|
|
373
|
+
...zeroStats(),
|
|
374
|
+
warnings
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
ok: true,
|
|
379
|
+
// The write landed, so this is not a failure — but an unreadable capture is
|
|
380
|
+
// not a clean success either, and `ok: true, cookies: 0` alone reads as one
|
|
381
|
+
// (R5). The code is what an ok-only consumer cannot miss.
|
|
382
|
+
code: stats.failure === null ? '' : STATE_UNREADABLE_CODE,
|
|
383
|
+
message: '',
|
|
384
|
+
profile,
|
|
385
|
+
storageStatePath: statePath,
|
|
386
|
+
bytes: stats.bytes,
|
|
387
|
+
cookies: stats.cookies,
|
|
388
|
+
origins: stats.origins,
|
|
389
|
+
warnings
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function zeroStats() {
|
|
393
|
+
return { bytes: 0, cookies: 0, origins: 0 };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* An empty capture — a parseable storage state that holds NOTHING: no cookies
|
|
397
|
+
* AND no origins. It is not a session, so it is never published and never
|
|
398
|
+
* reported as a success (S4 R3, both lenses).
|
|
399
|
+
*
|
|
400
|
+
* BOTH, not just cookies (S4 R5). A state that keeps its session in `origins`
|
|
401
|
+
* (a localStorage token) is a real, valid storage state, and refusing it made
|
|
402
|
+
* the persistent-login verb unable to persist a non-cookie session. The hazard
|
|
403
|
+
* this refusal exists for is publishing *nothing* over a working profile.
|
|
404
|
+
*
|
|
405
|
+
* A raw-string capture (`stateRaw`) is not an empty one: `typeof` excludes it,
|
|
406
|
+
* so the corrupt-artifact path still reaches the read-back check that reports
|
|
407
|
+
* it. Nor is a MALFORMED one (`{"cookies": 1}`) — the read-back check is what
|
|
408
|
+
* reports that, which is why this reads strictly for two empty arrays.
|
|
409
|
+
*/
|
|
410
|
+
function isEmptyCapture(snapshot) {
|
|
411
|
+
if (snapshot === null || typeof snapshot !== 'object') {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
const cookies = snapshot.cookies;
|
|
415
|
+
const origins = snapshot.origins;
|
|
416
|
+
return (Array.isArray(cookies) &&
|
|
417
|
+
cookies.length === 0 &&
|
|
418
|
+
Array.isArray(origins) &&
|
|
419
|
+
origins.length === 0);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Watch for the user closing the window (UD-4), from the moment the browser is
|
|
423
|
+
* up — before any other await, because `disconnected` is not replayed.
|
|
424
|
+
*
|
|
425
|
+
* `isConnected()` is consulted FIRST: a browser that is already gone must fail
|
|
426
|
+
* fast rather than run out the timeout, and there is no event left to wait for.
|
|
427
|
+
* Both members are optional on `PwBrowser`, so a browser that cannot report
|
|
428
|
+
* either is treated as never disconnecting — the pre-existing behaviour.
|
|
429
|
+
*/
|
|
430
|
+
function watchDisconnect(browser) {
|
|
431
|
+
let closed = browser.isConnected?.() === false;
|
|
432
|
+
let settleClosed = () => undefined;
|
|
433
|
+
const whenClosed = new Promise((settle) => {
|
|
434
|
+
settleClosed = settle;
|
|
435
|
+
});
|
|
436
|
+
if (closed) {
|
|
437
|
+
settleClosed();
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
browser.on?.('disconnected', () => {
|
|
441
|
+
closed = true;
|
|
442
|
+
settleClosed();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return { closed: () => closed, whenClosed };
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Read the session into memory every `LOGIN_SNAPSHOT_MS` until the user closes
|
|
449
|
+
* the window, and report how it ended plus the last successful read.
|
|
450
|
+
*
|
|
451
|
+
* The read has to happen WHILE the window is open: once the browser disconnects
|
|
452
|
+
* the non-persistent context (C1) cannot be read at all — `storageState()` then
|
|
453
|
+
* throws "Target page, context or browser has been closed", verified on the
|
|
454
|
+
* pinned playwright@1.63.0. So the state in memory at the moment of disconnect
|
|
455
|
+
* is the LAST read, up to one interval old, and that is the honest staleness
|
|
456
|
+
* bound of the saved profile.
|
|
457
|
+
*
|
|
458
|
+
* MONOTONIC deadline (R8): on wall-clock time a backward NTP/DST/VM-resume step
|
|
459
|
+
* moves the deadline away faster than `now()` advances, `now() >= deadline`
|
|
460
|
+
* never holds, and an abandoned login holds a headed browser open for the rest
|
|
461
|
+
* of the machine's uptime — the opposite of what the bound is for.
|
|
462
|
+
*/
|
|
463
|
+
async function snapshotUntilClosed(context, disconnect, deadline) {
|
|
464
|
+
let captured = false;
|
|
465
|
+
let snapshot = null;
|
|
466
|
+
let readErrorWhileOpen = null;
|
|
467
|
+
for (;;) {
|
|
468
|
+
const read = await readSession(context);
|
|
469
|
+
if (read.ok) {
|
|
470
|
+
captured = true;
|
|
471
|
+
snapshot = read.state;
|
|
472
|
+
}
|
|
473
|
+
if (disconnect.closed()) {
|
|
474
|
+
return { closed: true, captured, snapshot, readErrorWhileOpen };
|
|
475
|
+
}
|
|
476
|
+
// A failed read the CLOSE cannot explain: the browser is still connected
|
|
477
|
+
// (S4 R3), and the error is not Playwright's own "it is gone" (S4 R5).
|
|
478
|
+
//
|
|
479
|
+
// The check is AFTER the read, not before it, because the rejection can land
|
|
480
|
+
// before the `disconnected` event does — and with the check up front, that
|
|
481
|
+
// ordering recorded a close as a read failure and the outcome blamed the
|
|
482
|
+
// read. The wording check covers what the event still cannot: the error
|
|
483
|
+
// itself says the target is closed (F3).
|
|
484
|
+
if (!read.ok && !read.closedTarget) {
|
|
485
|
+
readErrorWhileOpen = read.error;
|
|
486
|
+
}
|
|
487
|
+
if (performance.now() >= deadline) {
|
|
488
|
+
return { closed: false, captured, snapshot, readErrorWhileOpen };
|
|
489
|
+
}
|
|
490
|
+
// Wakes on the disconnect rather than after the full interval, so closing
|
|
491
|
+
// the window ends the wait (and publishes) without an extra second's delay.
|
|
492
|
+
await Promise.race([sleep(LOGIN_SNAPSHOT_MS), disconnect.whenClosed]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function readSession(context) {
|
|
496
|
+
try {
|
|
497
|
+
return { ok: true, state: await context.storageState(), error: null, closedTarget: false };
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
const raw = getErrorMessage(error);
|
|
501
|
+
return { ok: false, state: null, error: cappedEcho(raw), closedTarget: isClosedTargetError(raw) };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Whether a failed read is EXPLAINED BY THE CLOSE — Playwright's own wording for
|
|
506
|
+
* "the target you asked about is gone" — rather than by a read that genuinely
|
|
507
|
+
* failed while the browser was up.
|
|
508
|
+
*
|
|
509
|
+
* `disconnect.closed()` alone cannot separate the two: the `disconnected` event
|
|
510
|
+
* can still be in flight when the rejection lands, which is how a close came to
|
|
511
|
+
* be reported as "every attempt failed while it was open: Target … has been
|
|
512
|
+
* closed" (S4 R5 / F3). The error's own text is the only other signal a rejected
|
|
513
|
+
* `storageState()` gives, and this is what it says.
|
|
514
|
+
*/
|
|
515
|
+
function isClosedTargetError(raw) {
|
|
516
|
+
return /has been closed|Target closed/i.test(raw);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Size plus cookie/origin COUNTS of the state just written.
|
|
520
|
+
*
|
|
521
|
+
* Counts only: the values are exactly what `browser-workflow.md` forbids putting
|
|
522
|
+
* in an artifact, and they would be printed to a terminal here. A read-back that
|
|
523
|
+
* fails is REPORTED rather than swallowed — the write already happened, so the
|
|
524
|
+
* caller must not be told the profile is fine when nothing could be read (S1's
|
|
525
|
+
* F1). It returns zero counts instead of throwing for the same reason: an
|
|
526
|
+
* unreadable file is news, not a reason to lose the path.
|
|
527
|
+
*
|
|
528
|
+
* "Unreadable" includes JSON that parses but is NOT a storage state: a file
|
|
529
|
+
* holding `null`, a string or `{"cookies": 1}` is not a capture, and reporting
|
|
530
|
+
* it as `cookies: 0` with no failure would make a corrupt artifact read exactly
|
|
531
|
+
* like a clean one that merely has no cookies.
|
|
532
|
+
*
|
|
533
|
+
* The failure text is OURS, never the parser's: V8 quotes the head of its input
|
|
534
|
+
* into `JSON.parse`'s message, and that input is a live session cookie value —
|
|
535
|
+
* so the message would carry a fragment of it to the terminal and the
|
|
536
|
+
* transcript. The byte count is the diagnostic instead (S4-3).
|
|
537
|
+
*/
|
|
538
|
+
function readStateCounts(path) {
|
|
539
|
+
let bytes = 0;
|
|
540
|
+
try {
|
|
541
|
+
// Kept outside the parse so an unparseable-but-present file still reports
|
|
542
|
+
// the size it really has.
|
|
543
|
+
bytes = statSync(path).size;
|
|
544
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
545
|
+
if (parsed === null ||
|
|
546
|
+
typeof parsed !== 'object' ||
|
|
547
|
+
!Array.isArray(parsed.cookies) ||
|
|
548
|
+
!Array.isArray(parsed.origins)) {
|
|
549
|
+
return { bytes, ...unreadable(bytes) };
|
|
550
|
+
}
|
|
551
|
+
return { bytes, cookies: parsed.cookies.length, origins: parsed.origins.length, failure: null };
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return { bytes, ...unreadable(bytes) };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** The one refusal shape, so both unreadable branches read the same. */
|
|
558
|
+
function unreadable(bytes) {
|
|
559
|
+
return {
|
|
560
|
+
cookies: 0,
|
|
561
|
+
origins: 0,
|
|
562
|
+
failure: `it is not a readable storage state (${String(bytes)} bytes on disk)`
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Close the headed browser, but never wait on it forever.
|
|
567
|
+
*
|
|
568
|
+
* `browser.close()` is a promise the browser can fail to settle — a wedged
|
|
569
|
+
* renderer, a process that was killed but not reaped — and an unbounded `await`
|
|
570
|
+
* in the `finally` is exactly how the module docstring's "cannot hold a browser
|
|
571
|
+
* open forever" would stop being true (S4 R3). So it is raced against a timer:
|
|
572
|
+
* `min(timeoutMs, TEARDOWN_TIMEOUT_MS)` — a test that shortens the login bound
|
|
573
|
+
* shortens teardown with it, a production run gets the constant.
|
|
574
|
+
*
|
|
575
|
+
* Losing the race is REPORTED, never silent. The caller has to know a browser
|
|
576
|
+
* process may still be on the machine, and the close is not retried or waited
|
|
577
|
+
* on: this command is over.
|
|
578
|
+
*/
|
|
579
|
+
async function closeBounded(browser, timeoutMs, warnings) {
|
|
580
|
+
const boundMs = Math.min(timeoutMs, TEARDOWN_TIMEOUT_MS);
|
|
581
|
+
let expired = false;
|
|
582
|
+
const bound = sleep(boundMs).then(() => {
|
|
583
|
+
expired = true;
|
|
584
|
+
});
|
|
585
|
+
try {
|
|
586
|
+
// The call is INSIDE the try: a synchronous throw from `close()` must be
|
|
587
|
+
// reported as a teardown warning, not escape and replace the outcome.
|
|
588
|
+
await Promise.race([browser.close(), bound]);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
warnings.push(`the headed browser did not close cleanly: ${getErrorMessage(error)}`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (expired) {
|
|
595
|
+
warnings.push(`the headed browser did not report closed within ${String(boundMs)} ms; it may still be running`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The interval timer is `unref`'d: whenever the disconnect wins the race (the
|
|
600
|
+
* common case) the timeout is orphaned, and an orphan that holds the event loop
|
|
601
|
+
* would keep the `peaks web login` process alive up to one interval past the
|
|
602
|
+
* command having logically finished (code review F4). It still fires normally
|
|
603
|
+
* while the process is alive, which is all the race needs.
|
|
604
|
+
*/
|
|
605
|
+
function sleep(ms) {
|
|
606
|
+
return new Promise((settle) => {
|
|
607
|
+
const timer = setTimeout(() => {
|
|
608
|
+
settle();
|
|
609
|
+
}, ms);
|
|
610
|
+
timer.unref?.();
|
|
611
|
+
});
|
|
612
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish the captured session ATOMICALLY (UD-7): stage the bytes beside the
|
|
3
|
+
* artifact, then `renameSync` them onto it.
|
|
4
|
+
*
|
|
5
|
+
* A rename within one directory is one filesystem operation, so
|
|
6
|
+
* `storageState.json` is either the previous profile or the complete new one —
|
|
7
|
+
* never a truncated half of either. That is what makes the failure message
|
|
8
|
+
* honest: a thrown write or a thrown rename leaves the artifact as it was, so
|
|
9
|
+
* "this run did not modify it" is a fact rather than an assertion.
|
|
10
|
+
*
|
|
11
|
+
* The staging file never survives this call: the `catch` unlinks it before
|
|
12
|
+
* rethrowing, and `runHeadedLogin`'s `finally` sweeps it on every path out.
|
|
13
|
+
* Nothing may ever READ the staging path; it exists only between these two
|
|
14
|
+
* calls.
|
|
15
|
+
*/
|
|
16
|
+
export declare function publishState(statePath: string, snapshot: unknown, warnings: string[]): void;
|
|
17
|
+
/**
|
|
18
|
+
* Delete this run's staging file, and sweep any a KILLED run left behind.
|
|
19
|
+
*
|
|
20
|
+
* Called on EVERY path out of `runHeadedLogin` — a thrown write, a failed
|
|
21
|
+
* rename, a timeout, a never-captured close, and after a successful publish
|
|
22
|
+
* (where the rename has already consumed the file, making this a no-op). The
|
|
23
|
+
* staging file holds live session cookies, and the carve-out allows it only
|
|
24
|
+
* while a publish is in flight: it must never outlive the command that created
|
|
25
|
+
* it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function discardStaging(statePath: string, warnings: string[]): void;
|