@selvakumaresra/specship 0.3.0 → 0.4.0
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/commands/ss-design-implement.md +5 -0
- package/commands/ss-design-loop.md +125 -0
- package/dist/bin/specship.js +66 -0
- package/dist/bin/specship.js.map +1 -1
- package/dist/designer/artifact-store.js +54 -0
- package/dist/designer/browser.js +141 -0
- package/dist/designer/cdp-ensure.js +60 -0
- package/dist/designer/cdp-env.js +18 -0
- package/dist/designer/cdp-trace.js +599 -0
- package/dist/designer/cross-platform.js +74 -0
- package/dist/designer/designer-controller.js +1413 -0
- package/dist/designer/file-panel.js +39 -0
- package/dist/designer/interstitials.js +97 -0
- package/dist/designer/oopif-reader.js +176 -0
- package/dist/designer/package-meta.js +18 -0
- package/dist/designer/preview-host.js +50 -0
- package/dist/designer/repo-root.js +31 -0
- package/dist/designer/run-state.js +353 -0
- package/dist/designer/session-store.js +59 -0
- package/dist/designer/ui-anchors.js +651 -0
- package/dist/installer/index.d.ts +5 -0
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +3 -2
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/instructions-template.d.ts +17 -0
- package/dist/installer/instructions-template.d.ts.map +1 -1
- package/dist/installer/instructions-template.js +31 -1
- package/dist/installer/instructions-template.js.map +1 -1
- package/dist/installer/targets/claude.d.ts +19 -0
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +98 -1
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/installer/targets/shared.d.ts +14 -0
- package/dist/installer/targets/shared.d.ts.map +1 -1
- package/dist/installer/targets/shared.js +49 -0
- package/dist/installer/targets/shared.js.map +1 -1
- package/dist/installer/targets/types.d.ts +8 -0
- package/dist/installer/targets/types.d.ts.map +1 -1
- package/dist/mcp/designer-tools.d.ts +33 -0
- package/dist/mcp/designer-tools.d.ts.map +1 -0
- package/dist/mcp/designer-tools.js +313 -0
- package/dist/mcp/designer-tools.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +22 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/web/{chunk-JT7P3DEK.js → chunk-2YUJNZ2Y.js} +3 -3
- package/dist/web/{chunk-JN6W7HCN.js → chunk-45QHGCB4.js} +1 -1
- package/dist/web/{chunk-RAAMPHPJ.js → chunk-A5R3MJMO.js} +1 -1
- package/dist/web/{chunk-2DHIGIOI.js → chunk-ASZ77FMZ.js} +1 -1
- package/dist/web/{chunk-TWXZK6XM.js → chunk-B3YPFY6A.js} +1 -1
- package/dist/web/chunk-D5OCNEJA.js +2 -0
- package/dist/web/{chunk-3SEJX2BK.js → chunk-FHZHD2ZG.js} +1 -1
- package/dist/web/chunk-GR72OOCN.js +1 -0
- package/dist/web/{chunk-DA6SNNAF.js → chunk-GWPVKJIY.js} +1 -1
- package/dist/web/{chunk-YAWCRPHV.js → chunk-NZEZCT65.js} +1 -1
- package/dist/web/{chunk-BCZM5AXU.js → chunk-UBOZGQNK.js} +1 -1
- package/dist/web/{chunk-BPECIDVO.js → chunk-WCKHQIYN.js} +1 -1
- package/dist/web/{chunk-JFYVCXK3.js → chunk-WLIMNDS3.js} +1 -1
- package/dist/web/{chunk-LV4G6QFG.js → chunk-YAMRN47K.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/main-R53HA54V.js +1 -0
- package/dist/web/sw.js +69 -0
- package/dist/workflows/defaults/claude-design-implement.yaml +138 -49
- package/hooks/hooks.json +11 -0
- package/package.json +7 -3
- package/selectors.json +41 -0
- package/dist/web/chunk-2OKMB4KX.js +0 -2
- package/dist/web/chunk-4N5DWG46.js +0 -1
- package/dist/web/main-WVI3YTDU.js +0 -1
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CdpTraceRecorder = exports.CdpSession = void 0;
|
|
7
|
+
exports.scrubSecrets = scrubSecrets;
|
|
8
|
+
exports.asRec = asRec;
|
|
9
|
+
exports.redact = redact;
|
|
10
|
+
exports.listTargets = listTargets;
|
|
11
|
+
exports.findDesignTarget = findDesignTarget;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const cdp_ensure_1 = require("./cdp-ensure");
|
|
15
|
+
// CDP network trace recorder — spike-grade groundwork for the future
|
|
16
|
+
// network-first run-state observer (RUNNING/FINISHED/STALLED/BLOCKED).
|
|
17
|
+
//
|
|
18
|
+
// Attaches a second CDP client directly to the design tab's page target
|
|
19
|
+
// (agent-browser keeps driving the page through its own client; CDP allows
|
|
20
|
+
// multiple simultaneous clients) and streams Network/Page events to JSONL.
|
|
21
|
+
//
|
|
22
|
+
// Uses the native global WebSocket (stable since Node 22.4). The package
|
|
23
|
+
// declares engines >=22 so runtime code can use it without a `ws` dependency.
|
|
24
|
+
//
|
|
25
|
+
// Known blind spot: the claudeusercontent.com preview iframe is an
|
|
26
|
+
// out-of-process frame — its document fetches never reach this page target's
|
|
27
|
+
// Network domain. Generation API traffic originates in the main frame, which
|
|
28
|
+
// is what we're here for. Upgrade path if traces show a gap:
|
|
29
|
+
// Target.setAutoAttach({flatten:true}) + per-session Network.enable; send()
|
|
30
|
+
// and event handling already tolerate a sessionId field for that reason.
|
|
31
|
+
const DEFAULT_PORT = process.env.DESIGNER_CDP || '9222';
|
|
32
|
+
const DESIGN_URL_PATTERN = /^https:\/\/claude\.ai\/design/;
|
|
33
|
+
const REDACT_KEY_PATTERN = /^(cookie|set-cookie|authorization|proxy-authorization|x-api-key)$/i;
|
|
34
|
+
const STREAMABLE_RESOURCE_TYPES = new Set(['XHR', 'Fetch', 'EventSource']);
|
|
35
|
+
// URLs whose request/response bodies we never capture — auth/session exchanges
|
|
36
|
+
// can carry credentials as plaintext JSON values that header-key redaction
|
|
37
|
+
// can't see. The generation traffic we care about (OmeletteService) is not here.
|
|
38
|
+
const AUTH_URL_DENYLIST = /\/(oauth|auth|login|logout|sign[_-]?in|sign[_-]?out|sign[_-]?up|register|token|sessions?|account|credential|password|mfa|totp|verify)\b/i;
|
|
39
|
+
// Value-level secret scrub for captured *bodies* (postData, response bodies, WS
|
|
40
|
+
// frame payloads). redact() only matches header *key names*; a token sitting in
|
|
41
|
+
// a JSON value or form field is a value blob it can't reach. Best-effort: catch
|
|
42
|
+
// the common token shapes before they hit disk. Exported for testability.
|
|
43
|
+
function scrubSecrets(s) {
|
|
44
|
+
return s
|
|
45
|
+
.replace(/eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[redacted-jwt]')
|
|
46
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted-key]')
|
|
47
|
+
.replace(/(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?key|sessionKey|api[_-]?key|client[_-]?secret|secret|password|passwd|pwd)["']?\s*[:=]\s*["']?)[^"'&,;\s}]+/gi, '$1[redacted]')
|
|
48
|
+
.replace(/\bsessionKey=[^;"'\s&]+/gi, 'sessionKey=[redacted]')
|
|
49
|
+
.replace(/\b([Bb]earer)\s+[A-Za-z0-9._~+/=-]{12,}/g, '$1 [redacted]');
|
|
50
|
+
}
|
|
51
|
+
// Events persisted verbatim (post-redaction). Everything else — notably the
|
|
52
|
+
// Network.*ExtraInfo pair, which carries real Cookie/Set-Cookie headers — is
|
|
53
|
+
// only counted in droppedByMethod.
|
|
54
|
+
const PERSIST_METHODS = new Set([
|
|
55
|
+
'Network.requestWillBeSent',
|
|
56
|
+
'Network.responseReceived',
|
|
57
|
+
'Network.dataReceived',
|
|
58
|
+
'Network.loadingFinished',
|
|
59
|
+
'Network.loadingFailed',
|
|
60
|
+
'Network.requestServedFromCache',
|
|
61
|
+
'Network.eventSourceMessageReceived',
|
|
62
|
+
'Network.webSocketCreated',
|
|
63
|
+
'Network.webSocketWillSendHandshakeRequest',
|
|
64
|
+
'Network.webSocketHandshakeResponseReceived',
|
|
65
|
+
'Network.webSocketFrameSent',
|
|
66
|
+
'Network.webSocketFrameReceived',
|
|
67
|
+
'Network.webSocketClosed',
|
|
68
|
+
'Page.frameNavigated',
|
|
69
|
+
'Page.frameStartedLoading',
|
|
70
|
+
'Page.frameStoppedLoading',
|
|
71
|
+
'Page.lifecycleEvent'
|
|
72
|
+
]);
|
|
73
|
+
// Narrow an unknown CDP payload to a record before keyed access. Shared by the
|
|
74
|
+
// CDP subclasses (run-state, oopif-reader) that parse event/response shapes.
|
|
75
|
+
function asRec(v) {
|
|
76
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
77
|
+
}
|
|
78
|
+
function isCdpTarget(v) {
|
|
79
|
+
const r = asRec(v);
|
|
80
|
+
return (typeof r.id === 'string' &&
|
|
81
|
+
typeof r.type === 'string' &&
|
|
82
|
+
typeof r.title === 'string' &&
|
|
83
|
+
typeof r.url === 'string' &&
|
|
84
|
+
typeof r.webSocketDebuggerUrl === 'string');
|
|
85
|
+
}
|
|
86
|
+
/** Deep-walk redaction of sensitive header keys. Exported for testability. */
|
|
87
|
+
function redact(value) {
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
return value.map(redact);
|
|
90
|
+
if (value && typeof value === 'object') {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [k, v] of Object.entries(value)) {
|
|
93
|
+
out[k] = REDACT_KEY_PATTERN.test(k) ? '[redacted]' : redact(v);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
async function listTargets(port = DEFAULT_PORT) {
|
|
100
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(3000) });
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`CDP /json/list on :${port} returned ${res.status}`);
|
|
103
|
+
const body = await res.json();
|
|
104
|
+
if (!Array.isArray(body))
|
|
105
|
+
throw new Error(`CDP /json/list on :${port} returned a non-array payload`);
|
|
106
|
+
return body.filter(isCdpTarget);
|
|
107
|
+
}
|
|
108
|
+
async function findDesignTarget({ port = DEFAULT_PORT, urlPattern = DESIGN_URL_PATTERN, preferUrlPrefix = null } = {}) {
|
|
109
|
+
const targets = await listTargets(port);
|
|
110
|
+
const candidates = targets.filter((t) => t.type === 'page' && urlPattern.test(t.url) && t.webSocketDebuggerUrl);
|
|
111
|
+
if (candidates.length === 0) {
|
|
112
|
+
throw new Error(`No page target matching ${urlPattern} on CDP :${port}. Open claude.ai/design first.`);
|
|
113
|
+
}
|
|
114
|
+
if (preferUrlPrefix) {
|
|
115
|
+
// Exact URL first: the home URL (https://claude.ai/design) is a *prefix* of
|
|
116
|
+
// every /design/p/<uuid> tab, so a startsWith match alone could bind to an
|
|
117
|
+
// arbitrary project tab instead of the exact tab the caller is on (#66).
|
|
118
|
+
const exactMatches = candidates.filter((t) => t.url === preferUrlPrefix);
|
|
119
|
+
const onlyExact = exactMatches[0];
|
|
120
|
+
if (exactMatches.length === 1 && onlyExact)
|
|
121
|
+
return onlyExact;
|
|
122
|
+
if (exactMatches.length > 1) {
|
|
123
|
+
// An exact URL match still isn't an exact TAB match: two tabs at the same
|
|
124
|
+
// URL (e.g. duplicate claude.ai/design home tabs) can't be told apart by
|
|
125
|
+
// URL, so fail rather than bind an arbitrary (possibly idle) one (#66).
|
|
126
|
+
// Callers that tolerate it (RunStateObserver.attach) degrade to null.
|
|
127
|
+
throw new Error(`Multiple tabs open at exactly ${preferUrlPrefix} — close the duplicate(s) so the right tab can be identified.`);
|
|
128
|
+
}
|
|
129
|
+
const preferred = candidates.find((t) => t.url.startsWith(preferUrlPrefix));
|
|
130
|
+
if (preferred)
|
|
131
|
+
return preferred;
|
|
132
|
+
}
|
|
133
|
+
const only = candidates[0];
|
|
134
|
+
if (candidates.length === 1 && only)
|
|
135
|
+
return only;
|
|
136
|
+
throw new Error(`Multiple design tabs match — pass --target-url to disambiguate:\n` +
|
|
137
|
+
candidates.map((t) => ` ${t.url}`).join('\n'));
|
|
138
|
+
}
|
|
139
|
+
class CdpSession {
|
|
140
|
+
ws;
|
|
141
|
+
target;
|
|
142
|
+
sessionOpts;
|
|
143
|
+
stopped = false;
|
|
144
|
+
reconnects = 0;
|
|
145
|
+
pending = new Map();
|
|
146
|
+
socketClosed = false;
|
|
147
|
+
nextId = 0;
|
|
148
|
+
constructor(ws, target, opts = {}) {
|
|
149
|
+
this.ws = ws;
|
|
150
|
+
this.target = target;
|
|
151
|
+
this.sessionOpts = {
|
|
152
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
153
|
+
urlPattern: opts.urlPattern ?? DESIGN_URL_PATTERN,
|
|
154
|
+
preferUrlPrefix: opts.preferUrlPrefix ?? null,
|
|
155
|
+
reconnect: opts.reconnect ?? true
|
|
156
|
+
};
|
|
157
|
+
this.wire(ws);
|
|
158
|
+
}
|
|
159
|
+
static async connectTarget(opts = {}) {
|
|
160
|
+
await (0, cdp_ensure_1.ensureCdpUp)();
|
|
161
|
+
const target = await findDesignTarget({
|
|
162
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
163
|
+
urlPattern: opts.urlPattern,
|
|
164
|
+
preferUrlPrefix: opts.preferUrlPrefix ?? null
|
|
165
|
+
});
|
|
166
|
+
const ws = await this.openSocket(target.webSocketDebuggerUrl);
|
|
167
|
+
return { ws, target };
|
|
168
|
+
}
|
|
169
|
+
static openSocket(url, timeoutMs = 5000) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const ws = new WebSocket(url);
|
|
172
|
+
// A half-open WS (e.g. a wedged debug Chrome) would otherwise leave this
|
|
173
|
+
// pending forever — attach() never resolves/rejects, defeating callers'
|
|
174
|
+
// "degrade, don't hang" contract (#67 review). Bound the open and close the
|
|
175
|
+
// socket on timeout. Reject is handled by every caller (attach -> null /
|
|
176
|
+
// throw; the reconnect loop keeps polling).
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
cleanup();
|
|
179
|
+
try {
|
|
180
|
+
ws.close();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* already closing */
|
|
184
|
+
}
|
|
185
|
+
reject(new Error(`CDP WebSocket open timed out after ${timeoutMs}ms: ${url}`));
|
|
186
|
+
}, timeoutMs);
|
|
187
|
+
const onOpen = () => {
|
|
188
|
+
cleanup();
|
|
189
|
+
resolve(ws);
|
|
190
|
+
};
|
|
191
|
+
const onError = () => {
|
|
192
|
+
cleanup();
|
|
193
|
+
reject(new Error(`Failed to open CDP WebSocket ${url}`));
|
|
194
|
+
};
|
|
195
|
+
const cleanup = () => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
ws.removeEventListener('open', onOpen);
|
|
198
|
+
ws.removeEventListener('error', onError);
|
|
199
|
+
};
|
|
200
|
+
ws.addEventListener('open', onOpen);
|
|
201
|
+
ws.addEventListener('error', onError);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
targetInfo() {
|
|
205
|
+
return { url: this.target.url, wsUrl: this.target.webSocketDebuggerUrl, port: this.sessionOpts.port };
|
|
206
|
+
}
|
|
207
|
+
async enableDomains() {
|
|
208
|
+
await this.send('Network.enable', { maxTotalBufferSize: 100_000_000, maxResourceBufferSize: 50_000_000 });
|
|
209
|
+
}
|
|
210
|
+
send(method, params, sessionId) {
|
|
211
|
+
const id = ++this.nextId;
|
|
212
|
+
const msg = { id, method };
|
|
213
|
+
if (params !== undefined)
|
|
214
|
+
msg.params = params;
|
|
215
|
+
if (sessionId)
|
|
216
|
+
msg.sessionId = sessionId;
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
this.pending.set(id, { resolve, reject });
|
|
219
|
+
try {
|
|
220
|
+
this.ws.send(JSON.stringify(msg));
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
this.pending.delete(id);
|
|
224
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
close() {
|
|
229
|
+
this.closeSocket();
|
|
230
|
+
}
|
|
231
|
+
closeSocket() {
|
|
232
|
+
if (this.socketClosed)
|
|
233
|
+
return false;
|
|
234
|
+
this.socketClosed = true;
|
|
235
|
+
this.stopped = true;
|
|
236
|
+
this.rejectPending(new Error('CDP WebSocket closed'));
|
|
237
|
+
try {
|
|
238
|
+
this.ws.close();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* already closed */
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
onSocketGap(_detail) { }
|
|
246
|
+
onSocketReconnected(_target) { }
|
|
247
|
+
onSocketReconnectFailed(_detail) { }
|
|
248
|
+
wire(ws) {
|
|
249
|
+
ws.addEventListener('message', (ev) => {
|
|
250
|
+
this.onMessage(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
|
251
|
+
});
|
|
252
|
+
ws.addEventListener('close', () => {
|
|
253
|
+
void this.handleClose();
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
onMessage(raw) {
|
|
257
|
+
let msg;
|
|
258
|
+
try {
|
|
259
|
+
msg = JSON.parse(raw);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg))
|
|
265
|
+
return;
|
|
266
|
+
const rec = msg;
|
|
267
|
+
if (typeof rec.id === 'number') {
|
|
268
|
+
const p = this.pending.get(rec.id);
|
|
269
|
+
if (!p)
|
|
270
|
+
return;
|
|
271
|
+
this.pending.delete(rec.id);
|
|
272
|
+
if (rec.error)
|
|
273
|
+
p.reject(new Error(`CDP ${JSON.stringify(rec.error)}`));
|
|
274
|
+
else
|
|
275
|
+
p.resolve(rec.result);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const method = typeof rec.method === 'string' ? rec.method : '';
|
|
279
|
+
if (!method)
|
|
280
|
+
return;
|
|
281
|
+
this.onEvent(method, rec.params, typeof rec.sessionId === 'string' ? rec.sessionId : undefined);
|
|
282
|
+
}
|
|
283
|
+
rejectPending(error) {
|
|
284
|
+
for (const [, p] of this.pending)
|
|
285
|
+
p.reject(error);
|
|
286
|
+
this.pending.clear();
|
|
287
|
+
}
|
|
288
|
+
async handleClose() {
|
|
289
|
+
this.rejectPending(new Error('CDP WebSocket closed'));
|
|
290
|
+
if (this.stopped)
|
|
291
|
+
return;
|
|
292
|
+
this.onSocketGap({ reason: 'socket-closed' });
|
|
293
|
+
if (!this.sessionOpts.reconnect)
|
|
294
|
+
return;
|
|
295
|
+
for (let i = 0; i < 30 && !this.stopped; i++) {
|
|
296
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
297
|
+
try {
|
|
298
|
+
const target = await findDesignTarget({
|
|
299
|
+
port: this.sessionOpts.port,
|
|
300
|
+
urlPattern: this.sessionOpts.urlPattern,
|
|
301
|
+
preferUrlPrefix: this.sessionOpts.preferUrlPrefix
|
|
302
|
+
});
|
|
303
|
+
const ws = await CdpSession.openSocket(target.webSocketDebuggerUrl);
|
|
304
|
+
this.ws = ws;
|
|
305
|
+
this.target = target;
|
|
306
|
+
this.wire(ws);
|
|
307
|
+
await this.enableDomains();
|
|
308
|
+
this.reconnects++;
|
|
309
|
+
this.onSocketReconnected(target);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// target not back yet — keep polling
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!this.stopped)
|
|
317
|
+
this.onSocketReconnectFailed({ gaveUpAfterMs: 30_000 });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
exports.CdpSession = CdpSession;
|
|
321
|
+
class CdpTraceRecorder extends CdpSession {
|
|
322
|
+
opts;
|
|
323
|
+
out;
|
|
324
|
+
requests = new Map();
|
|
325
|
+
wsSocketUrls = new Map();
|
|
326
|
+
pendingBodies = new Set();
|
|
327
|
+
startedAt = Date.now();
|
|
328
|
+
total = 0;
|
|
329
|
+
bodyCaptures = 0;
|
|
330
|
+
ended = false;
|
|
331
|
+
byMethod = {};
|
|
332
|
+
droppedByMethod = {};
|
|
333
|
+
constructor(ws, target, opts) {
|
|
334
|
+
super(ws, target, opts);
|
|
335
|
+
this.opts = {
|
|
336
|
+
outFile: opts.outFile,
|
|
337
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
338
|
+
urlPattern: opts.urlPattern ?? DESIGN_URL_PATTERN,
|
|
339
|
+
preferUrlPrefix: opts.preferUrlPrefix ?? null,
|
|
340
|
+
captureBodiesFor: opts.captureBodiesFor ?? /^https:\/\/claude\.ai\//,
|
|
341
|
+
maxBodyBytesPerRequest: opts.maxBodyBytesPerRequest ?? 2 * 1024 * 1024,
|
|
342
|
+
reconnect: opts.reconnect ?? true
|
|
343
|
+
};
|
|
344
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(opts.outFile), { recursive: true });
|
|
345
|
+
this.out = node_fs_1.default.createWriteStream(opts.outFile, { flags: 'a' });
|
|
346
|
+
// Swallow stream errors (disk full, write-after-end races) — an unhandled
|
|
347
|
+
// 'error' event on the stream would otherwise crash the process.
|
|
348
|
+
this.out.on('error', () => { });
|
|
349
|
+
}
|
|
350
|
+
static async attach(opts) {
|
|
351
|
+
if (typeof WebSocket === 'undefined') {
|
|
352
|
+
throw new Error('Native WebSocket unavailable — cdp-trace requires Node >= 22.');
|
|
353
|
+
}
|
|
354
|
+
const { ws, target } = await CdpTraceRecorder.connectTarget(opts);
|
|
355
|
+
return new CdpTraceRecorder(ws, target, opts);
|
|
356
|
+
}
|
|
357
|
+
async start() {
|
|
358
|
+
this.startedAt = Date.now();
|
|
359
|
+
await this.enableDomains();
|
|
360
|
+
this.writeLine({
|
|
361
|
+
ts: Date.now(),
|
|
362
|
+
kind: 'recorder',
|
|
363
|
+
event: 'attach',
|
|
364
|
+
detail: { targetUrl: this.target.url, wsUrl: this.target.webSocketDebuggerUrl }
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async enableDomains() {
|
|
368
|
+
await super.enableDomains();
|
|
369
|
+
await this.send('Page.enable');
|
|
370
|
+
await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
371
|
+
}
|
|
372
|
+
marker(name, detail) {
|
|
373
|
+
this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'marker', detail: { name, ...asRec(detail) } });
|
|
374
|
+
}
|
|
375
|
+
record(ev) {
|
|
376
|
+
this.writeLine({ ...ev, ts: ev.ts || Date.now() });
|
|
377
|
+
}
|
|
378
|
+
async stop() {
|
|
379
|
+
this.stopped = true;
|
|
380
|
+
// Give in-flight body fetches a moment to land, then move on.
|
|
381
|
+
await Promise.race([
|
|
382
|
+
Promise.allSettled([...this.pendingBodies]),
|
|
383
|
+
new Promise((r) => setTimeout(r, 3000))
|
|
384
|
+
]);
|
|
385
|
+
this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'detach' });
|
|
386
|
+
this.close();
|
|
387
|
+
// Mark ended before end() so any late body-fetch .catch (rejected by close())
|
|
388
|
+
// is a no-op in writeLine rather than a write-after-end stream error.
|
|
389
|
+
this.ended = true;
|
|
390
|
+
await new Promise((resolve) => this.out.end(resolve));
|
|
391
|
+
return {
|
|
392
|
+
durationMs: Date.now() - this.startedAt,
|
|
393
|
+
total: this.total,
|
|
394
|
+
byMethod: this.byMethod,
|
|
395
|
+
droppedByMethod: this.droppedByMethod,
|
|
396
|
+
reconnects: this.reconnects,
|
|
397
|
+
bodyCaptures: this.bodyCaptures
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
onEvent(method, rawParams, sessionId) {
|
|
401
|
+
if (!PERSIST_METHODS.has(method)) {
|
|
402
|
+
this.droppedByMethod[method] = (this.droppedByMethod[method] || 0) + 1;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const params = asRec(rawParams);
|
|
406
|
+
this.trackRequest(method, params);
|
|
407
|
+
const shaped = this.shapePayloads(method, params);
|
|
408
|
+
const ev = { ts: Date.now(), kind: 'cdp', method, params: redact(shaped) };
|
|
409
|
+
if (sessionId)
|
|
410
|
+
ev.sessionId = sessionId;
|
|
411
|
+
this.writeLine(ev);
|
|
412
|
+
this.byMethod[method] = (this.byMethod[method] || 0) + 1;
|
|
413
|
+
if (method === 'Network.responseReceived')
|
|
414
|
+
this.maybeStreamContent(params);
|
|
415
|
+
if (method === 'Network.loadingFinished')
|
|
416
|
+
this.maybeFetchBody(params);
|
|
417
|
+
}
|
|
418
|
+
trackRequest(method, params) {
|
|
419
|
+
const requestId = typeof params.requestId === 'string' ? params.requestId : null;
|
|
420
|
+
if (!requestId)
|
|
421
|
+
return;
|
|
422
|
+
if (method === 'Network.requestWillBeSent') {
|
|
423
|
+
const req = asRec(params.request);
|
|
424
|
+
this.requests.set(requestId, {
|
|
425
|
+
url: String(req.url || ''),
|
|
426
|
+
resourceType: typeof params.type === 'string' ? params.type : null,
|
|
427
|
+
mimeType: null,
|
|
428
|
+
streamed: false,
|
|
429
|
+
bodyBytes: 0,
|
|
430
|
+
bodyFetched: false
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
else if (method === 'Network.responseReceived') {
|
|
434
|
+
const info = this.requests.get(requestId);
|
|
435
|
+
if (info) {
|
|
436
|
+
const resp = asRec(params.response);
|
|
437
|
+
info.mimeType = typeof resp.mimeType === 'string' ? resp.mimeType : null;
|
|
438
|
+
if (typeof params.type === 'string')
|
|
439
|
+
info.resourceType = params.type;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (method === 'Network.webSocketCreated') {
|
|
443
|
+
this.wsSocketUrls.set(requestId, String(params.url || ''));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Strip or size-cap payload-bearing fields before persistence. */
|
|
447
|
+
shapePayloads(method, params) {
|
|
448
|
+
if (method === 'Network.requestWillBeSent') {
|
|
449
|
+
const req = asRec(params.request);
|
|
450
|
+
if (typeof req.postData === 'string') {
|
|
451
|
+
// Off-origin: strip entirely. Kept (claude.ai) bodies are value-scrubbed
|
|
452
|
+
// so a credential in a sign-in/token POST body never lands verbatim.
|
|
453
|
+
if (!this.opts.captureBodiesFor.test(String(req.url || ''))) {
|
|
454
|
+
return { ...params, request: { ...req, postData: undefined, postDataBytes: req.postData.length } };
|
|
455
|
+
}
|
|
456
|
+
return { ...params, request: { ...req, postData: scrubSecrets(req.postData) } };
|
|
457
|
+
}
|
|
458
|
+
return params;
|
|
459
|
+
}
|
|
460
|
+
if (method === 'Network.webSocketFrameSent' || method === 'Network.webSocketFrameReceived') {
|
|
461
|
+
const requestId = String(params.requestId || '');
|
|
462
|
+
const socketUrl = this.wsSocketUrls.get(requestId) || '';
|
|
463
|
+
const resp = asRec(params.response);
|
|
464
|
+
if (typeof resp.payloadData === 'string') {
|
|
465
|
+
if (!this.opts.captureBodiesFor.test(socketUrl)) {
|
|
466
|
+
return { ...params, response: { ...resp, payloadData: undefined, payloadBytes: resp.payloadData.length } };
|
|
467
|
+
}
|
|
468
|
+
return { ...params, response: { ...resp, payloadData: scrubSecrets(resp.payloadData) } };
|
|
469
|
+
}
|
|
470
|
+
return params;
|
|
471
|
+
}
|
|
472
|
+
if (method === 'Network.dataReceived' && typeof params.data === 'string') {
|
|
473
|
+
const requestId = String(params.requestId || '');
|
|
474
|
+
const info = this.requests.get(requestId);
|
|
475
|
+
const bytes = Math.floor((params.data.length * 3) / 4);
|
|
476
|
+
if (info) {
|
|
477
|
+
if (info.bodyBytes + bytes > this.opts.maxBodyBytesPerRequest) {
|
|
478
|
+
return { ...params, data: undefined, dataDroppedBytes: bytes, truncated: true };
|
|
479
|
+
}
|
|
480
|
+
info.bodyBytes += bytes;
|
|
481
|
+
}
|
|
482
|
+
return params;
|
|
483
|
+
}
|
|
484
|
+
return params;
|
|
485
|
+
}
|
|
486
|
+
// Streaming responses (SSE / chunked fetch with no Content-Length) lose
|
|
487
|
+
// their bodies once the stream is consumed — getResponseBody after the
|
|
488
|
+
// fact usually fails. Network.streamResourceContent (experimental) asks
|
|
489
|
+
// Chrome to buffer + forward chunks as base64 on dataReceived. Best-effort:
|
|
490
|
+
// on older Chrome the command just rejects and chunk timing remains the
|
|
491
|
+
// primary record.
|
|
492
|
+
maybeStreamContent(params) {
|
|
493
|
+
const requestId = typeof params.requestId === 'string' ? params.requestId : null;
|
|
494
|
+
if (!requestId)
|
|
495
|
+
return;
|
|
496
|
+
const info = this.requests.get(requestId);
|
|
497
|
+
if (!info || info.streamed)
|
|
498
|
+
return;
|
|
499
|
+
if (!this.opts.captureBodiesFor.test(info.url))
|
|
500
|
+
return;
|
|
501
|
+
if (AUTH_URL_DENYLIST.test(info.url))
|
|
502
|
+
return;
|
|
503
|
+
if (!info.resourceType || !STREAMABLE_RESOURCE_TYPES.has(info.resourceType))
|
|
504
|
+
return;
|
|
505
|
+
const resp = asRec(params.response);
|
|
506
|
+
const headers = asRec(resp.headers);
|
|
507
|
+
const hasContentLength = Object.keys(headers).some((k) => k.toLowerCase() === 'content-length');
|
|
508
|
+
const isSse = info.mimeType === 'text/event-stream';
|
|
509
|
+
if (!isSse && hasContentLength)
|
|
510
|
+
return;
|
|
511
|
+
info.streamed = true;
|
|
512
|
+
const p = this.send('Network.streamResourceContent', { requestId })
|
|
513
|
+
.then((result) => {
|
|
514
|
+
const buffered = String(asRec(result).bufferedData || '');
|
|
515
|
+
const bytes = Math.floor((buffered.length * 3) / 4);
|
|
516
|
+
const truncated = bytes > this.opts.maxBodyBytesPerRequest;
|
|
517
|
+
info.bodyBytes += Math.min(bytes, this.opts.maxBodyBytesPerRequest);
|
|
518
|
+
this.bodyCaptures++;
|
|
519
|
+
this.writeLine({
|
|
520
|
+
ts: Date.now(),
|
|
521
|
+
kind: 'body',
|
|
522
|
+
requestId,
|
|
523
|
+
url: info.url,
|
|
524
|
+
source: 'streamBuffered',
|
|
525
|
+
base64: truncated ? buffered.slice(0, Math.ceil((this.opts.maxBodyBytesPerRequest * 4) / 3)) : buffered,
|
|
526
|
+
truncated,
|
|
527
|
+
bytes
|
|
528
|
+
});
|
|
529
|
+
})
|
|
530
|
+
.catch(() => {
|
|
531
|
+
info.streamed = false; // let loadingFinished try getResponseBody instead
|
|
532
|
+
})
|
|
533
|
+
.finally(() => this.pendingBodies.delete(p));
|
|
534
|
+
this.pendingBodies.add(p);
|
|
535
|
+
}
|
|
536
|
+
maybeFetchBody(params) {
|
|
537
|
+
const requestId = typeof params.requestId === 'string' ? params.requestId : null;
|
|
538
|
+
if (!requestId || this.stopped)
|
|
539
|
+
return;
|
|
540
|
+
const info = this.requests.get(requestId);
|
|
541
|
+
if (!info || info.streamed || info.bodyFetched)
|
|
542
|
+
return;
|
|
543
|
+
if (!this.opts.captureBodiesFor.test(info.url))
|
|
544
|
+
return;
|
|
545
|
+
if (AUTH_URL_DENYLIST.test(info.url))
|
|
546
|
+
return;
|
|
547
|
+
if (!info.resourceType || !STREAMABLE_RESOURCE_TYPES.has(info.resourceType))
|
|
548
|
+
return;
|
|
549
|
+
info.bodyFetched = true;
|
|
550
|
+
const p = this.send('Network.getResponseBody', { requestId })
|
|
551
|
+
.then((result) => {
|
|
552
|
+
const r = asRec(result);
|
|
553
|
+
const isB64 = r.base64Encoded === true;
|
|
554
|
+
// Text bodies are value-scrubbed before persistence; base64 (binary)
|
|
555
|
+
// bodies are opaque, so the auth-URL denylist above is their guard.
|
|
556
|
+
const body = isB64 ? String(r.body || '') : scrubSecrets(String(r.body || ''));
|
|
557
|
+
const bytes = isB64 ? Math.floor((body.length * 3) / 4) : body.length;
|
|
558
|
+
const truncated = bytes > this.opts.maxBodyBytesPerRequest;
|
|
559
|
+
const cap = isB64 ? Math.ceil((this.opts.maxBodyBytesPerRequest * 4) / 3) : this.opts.maxBodyBytesPerRequest;
|
|
560
|
+
this.bodyCaptures++;
|
|
561
|
+
this.writeLine({
|
|
562
|
+
ts: Date.now(),
|
|
563
|
+
kind: 'body',
|
|
564
|
+
requestId,
|
|
565
|
+
url: info.url,
|
|
566
|
+
source: 'getResponseBody',
|
|
567
|
+
base64: isB64 ? (truncated ? body.slice(0, cap) : body) : Buffer.from(truncated ? body.slice(0, cap) : body).toString('base64'),
|
|
568
|
+
truncated,
|
|
569
|
+
bytes
|
|
570
|
+
});
|
|
571
|
+
})
|
|
572
|
+
.catch((e) => {
|
|
573
|
+
this.writeLine({
|
|
574
|
+
ts: Date.now(),
|
|
575
|
+
kind: 'recorder',
|
|
576
|
+
event: 'error',
|
|
577
|
+
detail: { op: 'getResponseBody', requestId, url: info.url, message: e.message }
|
|
578
|
+
});
|
|
579
|
+
})
|
|
580
|
+
.finally(() => this.pendingBodies.delete(p));
|
|
581
|
+
this.pendingBodies.add(p);
|
|
582
|
+
}
|
|
583
|
+
onSocketGap() {
|
|
584
|
+
this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'gap', detail: { reason: 'socket-closed' } });
|
|
585
|
+
}
|
|
586
|
+
onSocketReconnected(target) {
|
|
587
|
+
this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'reconnect', detail: { targetUrl: target.url } });
|
|
588
|
+
}
|
|
589
|
+
onSocketReconnectFailed(detail) {
|
|
590
|
+
this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'error', detail: { op: 'reconnect', ...detail } });
|
|
591
|
+
}
|
|
592
|
+
writeLine(ev) {
|
|
593
|
+
if (this.ended)
|
|
594
|
+
return; // a late body-fetch .catch can fire after stop() ended the stream
|
|
595
|
+
this.out.write(JSON.stringify(ev) + '\n');
|
|
596
|
+
this.total++;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
exports.CdpTraceRecorder = CdpTraceRecorder;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.nodeSpawnSync = exports.nodeSpawn = exports.QUIT_CHROME_HINT = exports.WHICH = exports.xspawnSync = exports.xspawn = exports.IS_MAC = exports.IS_WIN = void 0;
|
|
7
|
+
exports.defaultChromeBin = defaultChromeBin;
|
|
8
|
+
exports.isChromeRunning = isChromeRunning;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
Object.defineProperty(exports, "nodeSpawn", { enumerable: true, get: function () { return node_child_process_1.spawn; } });
|
|
13
|
+
Object.defineProperty(exports, "nodeSpawnSync", { enumerable: true, get: function () { return node_child_process_1.spawnSync; } });
|
|
14
|
+
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
15
|
+
exports.IS_WIN = process.platform === 'win32';
|
|
16
|
+
exports.IS_MAC = process.platform === 'darwin';
|
|
17
|
+
// Drop-in replacements for `child_process.spawn` / `spawnSync`.
|
|
18
|
+
//
|
|
19
|
+
// On Windows, npm-installed CLIs are `<name>.cmd` shims (sometimes `.ps1`)
|
|
20
|
+
// that Node ≥ 21 refuses to spawn directly (security policy: `EINVAL`), and
|
|
21
|
+
// that misbehave under `shell: true` when args contain shell metacharacters
|
|
22
|
+
// (parens, quotes, redirects — common in JS code passed to `agent-browser eval`).
|
|
23
|
+
//
|
|
24
|
+
// `cross-spawn` resolves shim paths and invokes them via `cmd /c` with proper
|
|
25
|
+
// argv quoting. Used by 100M+ npm packages; this is the standard fix.
|
|
26
|
+
//
|
|
27
|
+
// On macOS/Linux it's a passthrough to `child_process` — no behavior change.
|
|
28
|
+
exports.xspawn = cross_spawn_1.default;
|
|
29
|
+
exports.xspawnSync = cross_spawn_1.default.sync;
|
|
30
|
+
// Returns the `which` / `where` command name for the current OS.
|
|
31
|
+
exports.WHICH = exports.IS_WIN ? 'where' : 'which';
|
|
32
|
+
// Default Chrome binary path per OS. Override with the CHROME_BIN env var.
|
|
33
|
+
function defaultChromeBin() {
|
|
34
|
+
if (exports.IS_WIN) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
node_path_1.default.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
37
|
+
node_path_1.default.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
38
|
+
node_path_1.default.join(process.env['LOCALAPPDATA'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
39
|
+
];
|
|
40
|
+
for (const c of candidates)
|
|
41
|
+
if (c && node_fs_1.default.existsSync(c))
|
|
42
|
+
return c;
|
|
43
|
+
return candidates[0] ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
|
44
|
+
}
|
|
45
|
+
if (exports.IS_MAC)
|
|
46
|
+
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
47
|
+
for (const c of ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser']) {
|
|
48
|
+
if (node_fs_1.default.existsSync(c))
|
|
49
|
+
return c;
|
|
50
|
+
}
|
|
51
|
+
return '/usr/bin/google-chrome';
|
|
52
|
+
}
|
|
53
|
+
// Cross-platform "is a non-debug Chrome currently running?" check.
|
|
54
|
+
function isChromeRunning() {
|
|
55
|
+
if (exports.IS_WIN) {
|
|
56
|
+
const r = (0, node_child_process_1.spawnSync)('tasklist', ['/FI', 'IMAGENAME eq chrome.exe', '/NH', '/FO', 'CSV'], { stdio: 'pipe' });
|
|
57
|
+
if (r.status !== 0)
|
|
58
|
+
return false;
|
|
59
|
+
const out = r.stdout?.toString() || '';
|
|
60
|
+
return out.toLowerCase().includes('chrome.exe');
|
|
61
|
+
}
|
|
62
|
+
if (exports.IS_MAC) {
|
|
63
|
+
const r = (0, node_child_process_1.spawnSync)('pgrep', ['-f', 'Google Chrome.app/Contents/MacOS/Google Chrome'], { stdio: 'pipe' });
|
|
64
|
+
return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
|
|
65
|
+
}
|
|
66
|
+
const r = (0, node_child_process_1.spawnSync)('pgrep', ['-f', 'chrome'], { stdio: 'pipe' });
|
|
67
|
+
return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
|
|
68
|
+
}
|
|
69
|
+
// User-friendly "press X to quit Chrome" hint per OS.
|
|
70
|
+
exports.QUIT_CHROME_HINT = exports.IS_WIN
|
|
71
|
+
? 'Close all Chrome windows (or end chrome.exe in Task Manager).'
|
|
72
|
+
: exports.IS_MAC
|
|
73
|
+
? 'Cmd+Q on the Chrome menu, then close Activity Monitor entries if any.'
|
|
74
|
+
: 'Close all Chrome windows or `pkill chrome`.';
|