@gleapai/kai-bridge 0.9.1 → 0.10.1
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/README.md +2 -2
- package/npm-shrinkwrap.json +105 -101
- package/package.json +7 -7
- package/runner/acp-runner.mjs +15 -53
- package/runner/lib/acp/harnesses.mjs +15 -1
- package/runner/lib/acp/mapper.mjs +13 -31
- package/runner/lib/acp/transcripts.mjs +41 -0
- package/runner/lib/contract.mjs +1 -16
- package/runner/tools/patch-claude-acp.mjs +1 -1
- package/scripts/postinstall.mjs +7 -0
- package/src/api.mjs +16 -102
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +607 -1054
- package/src/executor.mjs +2 -2
- package/src/gateway.mjs +217 -0
- package/src/harnesses.mjs +16 -3
- package/src/playwright-patch.mjs +84 -0
- package/src/preview-errors.mjs +0 -29
- package/src/preview.mjs +291 -101
- package/src/service.mjs +0 -11
- package/src/setup.mjs +5 -5
- package/src/tunnel-binary.mjs +109 -0
- package/src/tunnel.mjs +227 -0
- package/src/workspace.mjs +1 -1
- package/runner/personas/claude/kai-verifier.md +0 -84
- package/runner/personas/codex/kai-verifier.md +0 -84
- package/runner/tools/verify-mcp.mjs +0 -442
- package/src/preview-login.mjs +0 -610
- package/src/verify.mjs +0 -387
package/src/executor.mjs
CHANGED
|
@@ -73,8 +73,8 @@ export function buildRunnerArgs(turn, workDir, profile) {
|
|
|
73
73
|
*/
|
|
74
74
|
export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = null) {
|
|
75
75
|
const env = { ...process.env, KAI_RUNNER_DEBUG: process.env.KAI_RUNNER_DEBUG || "0", KAI_PERSONA_DIR: join(dirname(RUNNER), "personas") };
|
|
76
|
-
// Host-only
|
|
77
|
-
//
|
|
76
|
+
// Host-only extras for the runner process (tests inject fixture paths
|
|
77
|
+
// and scenarios here). Never part of the prompt.
|
|
78
78
|
for (const [k, v] of Object.entries(extraEnv || {})) if (v != null && v !== "") env[k] = String(v);
|
|
79
79
|
// Never leak the Gleap device token into the harness.
|
|
80
80
|
delete env.KAI_DEVICE_TOKEN;
|
package/src/gateway.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The preview gateway — a small reverse proxy the bridge puts IN FRONT of
|
|
2
|
+
// every web service it starts. The service's public port (the one the
|
|
3
|
+
// dashboard and the agent's browser tools address) is the gateway's; the
|
|
4
|
+
// app itself runs on a second, internal port. That indirection is what
|
|
5
|
+
// makes the public port stable across restarts of the app (a fix that
|
|
6
|
+
// restarts the dev server keeps its URL), lets the gateway answer with a
|
|
7
|
+
// "starting…" page while the app boots or compiles for the first time
|
|
8
|
+
// (`hold()` — the warm-up), and smooths over a cold dev server's transient
|
|
9
|
+
// connection resets with a few retries. Everything else is a dumb pipe,
|
|
10
|
+
// WebSocket upgrades included.
|
|
11
|
+
|
|
12
|
+
import { Agent as HttpAgent, createServer as createHttpServer, request as httpRequest } from "node:http";
|
|
13
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
14
|
+
import { connect as netConnect } from "node:net";
|
|
15
|
+
import { connect as tlsConnect } from "node:tls";
|
|
16
|
+
|
|
17
|
+
/** The warm-up browser sends this header so the hold page does not apply to it. */
|
|
18
|
+
export const WARMUP_HEADER = "x-kai-warmup";
|
|
19
|
+
/** Transient upstream resets are retried this many times (cold dev server under a burst). */
|
|
20
|
+
export const PROXY_RETRIES = 4;
|
|
21
|
+
export const PROXY_RETRY_BACKOFF_MS = [50, 150, 350, 700];
|
|
22
|
+
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
23
|
+
|
|
24
|
+
/** A navigation to an HTML document (not an XHR, a module, an image)? */
|
|
25
|
+
export function wantsHtmlDocument(headers) {
|
|
26
|
+
const dest = String(headers["sec-fetch-dest"] || "").toLowerCase();
|
|
27
|
+
if (dest) return dest === "document" || dest === "iframe" || dest === "frame";
|
|
28
|
+
return /text\/html/i.test(String(headers.accept || ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── the proxy ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One gateway per fronted service. `listen()` binds the public port and
|
|
37
|
+
* proxies to `127.0.0.1:<upstreamPort>` (https upstream accepted with a
|
|
38
|
+
* self-signed cert). `hold(note)` parks browser tabs on a reloading page
|
|
39
|
+
* until `release()`.
|
|
40
|
+
*/
|
|
41
|
+
export class Gateway {
|
|
42
|
+
constructor({ listenPort, upstreamPort, upstreamProtocol = "http", host = "0.0.0.0", name = null, log = () => {} }) {
|
|
43
|
+
this.listenPort = listenPort;
|
|
44
|
+
this.upstreamPort = upstreamPort;
|
|
45
|
+
this.upstreamProtocol = upstreamProtocol === "https" ? "https" : "http";
|
|
46
|
+
this.host = host;
|
|
47
|
+
this.name = name;
|
|
48
|
+
this.log = log;
|
|
49
|
+
// While the bridge warms a cold app (its first compile), browser tabs get a
|
|
50
|
+
// friendly auto-reloading page instead of stampeding the server. `null` = open.
|
|
51
|
+
this.holding = null; // { note }
|
|
52
|
+
this.server = null;
|
|
53
|
+
this.sockets = new Set();
|
|
54
|
+
// Own keep-alive pool to the app (a dev server answers thousands of
|
|
55
|
+
// module requests; reusing connections matters) — destroyed with the
|
|
56
|
+
// gateway so no idle socket outlives it on the app's side.
|
|
57
|
+
this.agent = this.upstreamProtocol === "https" ? new HttpsAgent({ keepAlive: true, rejectUnauthorized: false }) : new HttpAgent({ keepAlive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async listen() {
|
|
61
|
+
this.server = createHttpServer((req, res) => void this.onRequest(req, res));
|
|
62
|
+
this.server.on("upgrade", (req, socket, head) => this.onUpgrade(req, socket, head));
|
|
63
|
+
this.server.on("connection", (s) => {
|
|
64
|
+
this.sockets.add(s);
|
|
65
|
+
s.once("close", () => this.sockets.delete(s));
|
|
66
|
+
});
|
|
67
|
+
this.server.keepAliveTimeout = 65_000;
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
this.server.once("error", reject);
|
|
70
|
+
this.server.listen(this.listenPort, this.host, () => {
|
|
71
|
+
this.server.off("error", reject);
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
this.log("info", "gateway.listen", { name: this.name, port: this.listenPort, upstream: this.upstreamPort });
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async close() {
|
|
80
|
+
const srv = this.server;
|
|
81
|
+
this.server = null;
|
|
82
|
+
if (!srv) return;
|
|
83
|
+
for (const s of this.sockets) s.destroy();
|
|
84
|
+
this.sockets.clear();
|
|
85
|
+
this.agent.destroy();
|
|
86
|
+
await new Promise((r) => srv.close(() => r()));
|
|
87
|
+
this.log("info", "gateway.close", { name: this.name, port: this.listenPort });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Hold document requests on a "warming up" page (the warm-up browser itself passes). */
|
|
91
|
+
hold(note) {
|
|
92
|
+
this.holding = { note: String(note || "The preview is starting.") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
release() {
|
|
96
|
+
this.holding = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async onRequest(req, res) {
|
|
100
|
+
try {
|
|
101
|
+
if (this.holding && req.method === "GET" && wantsHtmlDocument(req.headers) && !req.headers[WARMUP_HEADER]) {
|
|
102
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
|
|
103
|
+
return res.end(holdPage(this.holding.note));
|
|
104
|
+
}
|
|
105
|
+
return this.proxy(req, res);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
this.log("warn", "gateway.request.failed", { name: this.name, error: err?.message });
|
|
108
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
|
|
109
|
+
res.end("gateway error");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
proxy(req, res) {
|
|
114
|
+
// Hop-by-hop headers stay out; the original Host stays in (dev servers
|
|
115
|
+
// compare it with Origin — Next server actions, Vite allowedHosts).
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
118
|
+
if (HOP_BY_HOP.has(k)) continue;
|
|
119
|
+
headers[k] = v;
|
|
120
|
+
}
|
|
121
|
+
const request = this.upstreamProtocol === "https" ? httpsRequest : httpRequest;
|
|
122
|
+
// A cold dev server (a big Vite app compiling its first requests) resets
|
|
123
|
+
// or refuses connections under a burst — several browser tabs at once,
|
|
124
|
+
// say. For a bodyless idempotent request that reset is transient, not
|
|
125
|
+
// "app down": retry a few times before the 503 so one module 503 does
|
|
126
|
+
// not break the whole page load. Requests WITH a body are not replayable.
|
|
127
|
+
const hasBody = req.headers["content-length"] || req.headers["transfer-encoding"];
|
|
128
|
+
const idempotent = req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS";
|
|
129
|
+
const canRetry = idempotent && !hasBody;
|
|
130
|
+
const attempt = (tries) => {
|
|
131
|
+
const up = request({ host: "127.0.0.1", port: this.upstreamPort, method: req.method, path: req.url, headers, agent: this.agent, ...(this.upstreamProtocol === "https" ? { rejectUnauthorized: false } : {}) });
|
|
132
|
+
up.on("response", (upRes) => {
|
|
133
|
+
const outHeaders = {};
|
|
134
|
+
for (const [k, v] of Object.entries(upRes.headers)) if (!HOP_BY_HOP.has(k)) outHeaders[k] = v;
|
|
135
|
+
res.writeHead(upRes.statusCode, outHeaders);
|
|
136
|
+
upRes.pipe(res);
|
|
137
|
+
});
|
|
138
|
+
up.on("error", (err) => {
|
|
139
|
+
if (res.headersSent) return res.end();
|
|
140
|
+
const transient = err?.code === "ECONNREFUSED" || err?.code === "ECONNRESET" || err?.code === "ECONNABORTED" || err?.code === "EPIPE";
|
|
141
|
+
if (transient && canRetry && tries < PROXY_RETRIES) {
|
|
142
|
+
setTimeout(() => attempt(tries + 1), PROXY_RETRY_BACKOFF_MS[tries] ?? 300);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (transient) {
|
|
146
|
+
// Still failing after retries: the app is (re)starting. A page that
|
|
147
|
+
// reloads itself; module/XHR fetches just get the 503 and the app's
|
|
148
|
+
// own retry (or the user's reload) picks up once the server is warm.
|
|
149
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" });
|
|
150
|
+
return res.end(`<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2"><title>Starting…</title><body style="font:15px system-ui;padding:32px;color:#444">The preview is starting — this page retries automatically.</body>`);
|
|
151
|
+
}
|
|
152
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
153
|
+
res.end(`gateway: ${err?.message || err}`);
|
|
154
|
+
});
|
|
155
|
+
if (canRetry) {
|
|
156
|
+
up.end();
|
|
157
|
+
} else {
|
|
158
|
+
req.on("aborted", () => up.destroy());
|
|
159
|
+
req.pipe(up);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
attempt(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** WebSocket & co.: a raw splice to the upstream, headers replayed as received. */
|
|
166
|
+
onUpgrade(req, socket, head) {
|
|
167
|
+
const connect = () => (this.upstreamProtocol === "https" ? tlsConnect({ host: "127.0.0.1", port: this.upstreamPort, rejectUnauthorized: false }) : netConnect({ host: "127.0.0.1", port: this.upstreamPort }));
|
|
168
|
+
const up = connect();
|
|
169
|
+
this.sockets.add(up);
|
|
170
|
+
up.once("close", () => this.sockets.delete(up));
|
|
171
|
+
const fail = () => {
|
|
172
|
+
try {
|
|
173
|
+
socket.destroy();
|
|
174
|
+
} catch {
|
|
175
|
+
/* gone */
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
up.once("error", fail);
|
|
179
|
+
socket.once("error", () => up.destroy());
|
|
180
|
+
// Either half closing closes the other: a lingering upstream half would
|
|
181
|
+
// keep the app's listener from ever closing (and leak a socket per tab).
|
|
182
|
+
socket.once("close", () => up.destroy());
|
|
183
|
+
up.once("close", fail);
|
|
184
|
+
up.once(this.upstreamProtocol === "https" ? "secureConnect" : "connect", () => {
|
|
185
|
+
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
|
|
186
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
187
|
+
up.write(lines.join("\r\n") + "\r\n\r\n");
|
|
188
|
+
if (head?.length) up.write(head);
|
|
189
|
+
socket.pipe(up).pipe(socket);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const escapeHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
195
|
+
|
|
196
|
+
/** The page a tab sees while the app compiles for the first time: reloads itself every 3 s. */
|
|
197
|
+
export function holdPage(note) {
|
|
198
|
+
return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>Starting…</title><body style="font:15px/1.5 system-ui;padding:40px;max-width:520px;color:#333"><p style="font-size:17px;font-weight:600;margin:0 0 6px">Kai is starting the preview</p><p style="margin:0;color:#666">${escapeHtml(note)}</p><p style="margin:14px 0 0;color:#999;font-size:13px">This page reloads on its own — no need to refresh.</p></body>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Wait until the gateway's upstream answers (used by tests and the pre-warm). */
|
|
202
|
+
export async function waitForUpstream(port, { timeoutMs = 10_000 } = {}) {
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const ok = await new Promise((resolve) => {
|
|
206
|
+
const s = netConnect({ host: "127.0.0.1", port });
|
|
207
|
+
s.once("connect", () => {
|
|
208
|
+
s.destroy();
|
|
209
|
+
resolve(true);
|
|
210
|
+
});
|
|
211
|
+
s.once("error", () => resolve(false));
|
|
212
|
+
});
|
|
213
|
+
if (ok) return true;
|
|
214
|
+
await sleep(100);
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
package/src/harnesses.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export const HARNESS_INFO = {
|
|
|
49
49
|
const CURSOR_ROOT = (kaiHome) => join(kaiHome, "harnesses", "cursor");
|
|
50
50
|
const CURSOR_CURRENT = (kaiHome) => join(CURSOR_ROOT(kaiHome), "current");
|
|
51
51
|
/** Version + channel published by Cursor's install script (https://cursor.com/install). */
|
|
52
|
-
export const CURSOR_AGENT_VERSION = process.env.KAI_CURSOR_AGENT_VERSION || "2026.
|
|
52
|
+
export const CURSOR_AGENT_VERSION = process.env.KAI_CURSOR_AGENT_VERSION || "2026.09.18-9a7762b";
|
|
53
53
|
|
|
54
54
|
function cursorPlatform() {
|
|
55
55
|
const os = platform() === "darwin" ? "darwin" : platform() === "win32" ? "windows" : "linux";
|
|
@@ -254,12 +254,25 @@ export function probeHarnessAuth(harness, configDir, kaiHome) {
|
|
|
254
254
|
return { state: "signed_in", account };
|
|
255
255
|
}
|
|
256
256
|
} catch (err) {
|
|
257
|
-
|
|
258
|
-
return { state: "signed_out" };
|
|
257
|
+
return classifyAuthProbeError(err, harness);
|
|
259
258
|
}
|
|
260
259
|
return { state: "unknown" };
|
|
261
260
|
}
|
|
262
261
|
|
|
262
|
+
/**
|
|
263
|
+
* A non-zero EXIT is an answer (`claude auth status` exits 1 when signed
|
|
264
|
+
* out; `cursor status` likewise), and codex without a readable auth.json
|
|
265
|
+
* is signed out. A probe that never ran to completion — the CLI timed out
|
|
266
|
+
* (`ETIMEDOUT`, no exit status) or could not be spawned (`ENOENT`) — is
|
|
267
|
+
* NOT: report `unknown` so callers keep the last known state instead of
|
|
268
|
+
* showing "signed out" until the next good probe.
|
|
269
|
+
*/
|
|
270
|
+
export function classifyAuthProbeError(err, harness) {
|
|
271
|
+
if (harness === "codex") return { state: "signed_out" };
|
|
272
|
+
if (typeof err?.status === "number") return { state: "signed_out" };
|
|
273
|
+
return { state: "unknown", error: err?.code || err?.message || String(err) };
|
|
274
|
+
}
|
|
275
|
+
|
|
263
276
|
/** Spawn a detached process that keeps running after the CLI exits (installer helper). */
|
|
264
277
|
export function spawnDetached(cmd, args, opts = {}) {
|
|
265
278
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true, ...opts });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Raise Playwright's client-side default action timeout in the bundled
|
|
2
|
+
// playwright-core that the browser MCP (and the daemon's own probe) run on.
|
|
3
|
+
//
|
|
4
|
+
// @playwright/mcp captures an aria snapshot after EVERY tool call
|
|
5
|
+
// (`page.ariaSnapshot`, `page.title`) without a timeout option, so those
|
|
6
|
+
// calls fall back to `DEFAULT_PLAYWRIGHT_TIMEOUT` — 30 s. A heavy dev build
|
|
7
|
+
// (the Gleap dashboard: 3,400 requests, main thread busy for a minute on a
|
|
8
|
+
// loaded machine) cannot answer in 30 s, and every navigate / snapshot
|
|
9
|
+
// "fails" with `Timeout 30000ms exceeded` while the page keeps loading
|
|
10
|
+
// (seen 2026-09-15/16: the agent's browser never got past its first page).
|
|
11
|
+
// `--timeout-action` / `--timeout-navigation` do not reach these calls and
|
|
12
|
+
// nothing in the MCP config does, so the constant is raised in place —
|
|
13
|
+
// idempotent, marked, re-applied after every install (a self-update
|
|
14
|
+
// reinstalls the package) and on daemon start, best-effort like the ACP
|
|
15
|
+
// patch (src/acp-patch.mjs). Explicit timeouts (locator actions with
|
|
16
|
+
// `--timeout-action`, navigations with `--timeout-navigation`) are untouched.
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const PLAYWRIGHT_DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
|
+
const MARKER = "/* kai-bridge: default timeout raised */";
|
|
23
|
+
const ORIGINAL = /DEFAULT_PLAYWRIGHT_TIMEOUT = 3e4;/g;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every `playwright-core/lib/coreBundle.js` this package can reach: the
|
|
27
|
+
* one the MCP CLI loads and the one `playwright` (the probe) loads —
|
|
28
|
+
* usually the same hoisted file, deduplicated.
|
|
29
|
+
*/
|
|
30
|
+
export function bundledPlaywrightCoreBundles() {
|
|
31
|
+
const files = new Set();
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const roots = [];
|
|
34
|
+
try {
|
|
35
|
+
roots.push(dirname(require.resolve("@playwright/mcp/package.json")));
|
|
36
|
+
} catch {
|
|
37
|
+
/* not installed */
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
roots.push(dirname(require.resolve("playwright/package.json")));
|
|
41
|
+
} catch {
|
|
42
|
+
/* not installed */
|
|
43
|
+
}
|
|
44
|
+
for (const root of roots) {
|
|
45
|
+
try {
|
|
46
|
+
const local = createRequire(join(root, "package.json"));
|
|
47
|
+
files.add(join(dirname(local.resolve("playwright-core/package.json")), "lib", "coreBundle.js"));
|
|
48
|
+
} catch {
|
|
49
|
+
/* no playwright-core from there */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...files];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure: `{ source, status }` — status `patched` | `already` | `unrecognized`. */
|
|
56
|
+
export function patchPlaywrightTimeoutSource(source, timeoutMs = PLAYWRIGHT_DEFAULT_TIMEOUT_MS) {
|
|
57
|
+
if (source.includes(MARKER)) return { source, status: "already" };
|
|
58
|
+
const matches = source.match(ORIGINAL) || [];
|
|
59
|
+
if (matches.length !== 1) return { source, status: "unrecognized" };
|
|
60
|
+
return { source: source.replace(ORIGINAL, `DEFAULT_PLAYWRIGHT_TIMEOUT = ${timeoutMs}; ${MARKER}`), status: "patched" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply the patch where needed. Returns `[{ file, status }]` — status
|
|
65
|
+
* `patched` | `already` | `unrecognized` | `error`; empty when no bundle
|
|
66
|
+
* is installed.
|
|
67
|
+
*/
|
|
68
|
+
export function ensurePlaywrightTimeoutPatched({ log, files = bundledPlaywrightCoreBundles() } = {}) {
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
try {
|
|
72
|
+
const { source, status } = patchPlaywrightTimeoutSource(readFileSync(file, "utf8"));
|
|
73
|
+
if (status === "patched") {
|
|
74
|
+
writeFileSync(file, source);
|
|
75
|
+
log?.("info", "playwright.patch.applied", { file, timeoutMs: PLAYWRIGHT_DEFAULT_TIMEOUT_MS });
|
|
76
|
+
} else if (status !== "already") log?.("warn", "playwright.patch.skipped", { file, status });
|
|
77
|
+
results.push({ file, status });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log?.("warn", "playwright.patch.failed", { file, error: err?.message ?? String(err) });
|
|
80
|
+
results.push({ file, status: "error" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
package/src/preview-errors.mjs
CHANGED
|
@@ -252,32 +252,3 @@ function safeRead(read, path) {
|
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
const PLACEHOLDER_RE = /^(?:changeme|change-me|change_me|replace-?me|todo|tbd|xxx+|your[-_ ].*|<[^>]*>|\$\{[^}]*\}|example.*|.*example\.(?:com|org|net).*|placeholder|secret|password|true|false|null|undefined|none|localhost.*|0+|\d+)$/i;
|
|
256
|
-
|
|
257
|
-
/** Is this env value worth redacting (≥ 12 chars, not a placeholder, not a URL/number/bool)? */
|
|
258
|
-
export function isRedactableEnvValue(value) {
|
|
259
|
-
if (typeof value !== "string") return false;
|
|
260
|
-
const text = value.trim();
|
|
261
|
-
if (text.length < 12) return false;
|
|
262
|
-
if (PLACEHOLDER_RE.test(text)) return false;
|
|
263
|
-
// URLs / connection strings: only ones carrying `user:password@` are secrets.
|
|
264
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return /\/\/[^/@\s]+:[^/@\s]+@/.test(text);
|
|
265
|
-
if (/^[\d.,\s-]+$/.test(text)) return false;
|
|
266
|
-
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return false;
|
|
267
|
-
return true;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Every value from the given dotenv texts that looks like a secret (the
|
|
272
|
-
* redaction set grows by these so a leaked `.env` value never reaches the
|
|
273
|
-
* Server in a tool row). Longest first, like `redactionSet`.
|
|
274
|
-
*/
|
|
275
|
-
export function envRedactionValues(texts) {
|
|
276
|
-
const values = new Set();
|
|
277
|
-
for (const text of texts || []) {
|
|
278
|
-
for (const value of Object.values(parseDotenv(text))) {
|
|
279
|
-
if (isRedactableEnvValue(value)) values.add(value.trim());
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return new Set([...values].sort((a, b) => b.length - a.length));
|
|
283
|
-
}
|