quadwork 1.11.0 → 1.12.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.
Files changed (75) hide show
  1. package/README.md +7 -0
  2. package/out/404.html +1 -1
  3. package/out/__next.__PAGE__.txt +1 -1
  4. package/out/__next._full.txt +2 -2
  5. package/out/__next._head.txt +1 -1
  6. package/out/__next._index.txt +2 -2
  7. package/out/__next._tree.txt +2 -2
  8. package/out/_next/static/chunks/0nk3kw~5j75~v.css +2 -0
  9. package/out/_next/static/chunks/{0gaekhrfy94vz.js → 11h7y0f5o9.hx.js} +1 -1
  10. package/out/_next/static/chunks/{0hirada7763yr.js → 13w.n.3zipzvz.js} +9 -9
  11. package/out/_not-found/__next._full.txt +2 -2
  12. package/out/_not-found/__next._head.txt +1 -1
  13. package/out/_not-found/__next._index.txt +2 -2
  14. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  15. package/out/_not-found/__next._not-found.txt +1 -1
  16. package/out/_not-found/__next._tree.txt +2 -2
  17. package/out/_not-found.html +1 -1
  18. package/out/_not-found.txt +2 -2
  19. package/out/app-shell/__next._full.txt +2 -2
  20. package/out/app-shell/__next._head.txt +1 -1
  21. package/out/app-shell/__next._index.txt +2 -2
  22. package/out/app-shell/__next._tree.txt +2 -2
  23. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  24. package/out/app-shell/__next.app-shell.txt +1 -1
  25. package/out/app-shell.html +1 -1
  26. package/out/app-shell.txt +2 -2
  27. package/out/index.html +1 -1
  28. package/out/index.txt +2 -2
  29. package/out/project/_/__next._full.txt +3 -3
  30. package/out/project/_/__next._head.txt +1 -1
  31. package/out/project/_/__next._index.txt +2 -2
  32. package/out/project/_/__next._tree.txt +2 -2
  33. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
  34. package/out/project/_/__next.project.$d$id.txt +1 -1
  35. package/out/project/_/__next.project.txt +1 -1
  36. package/out/project/_/queue/__next._full.txt +2 -2
  37. package/out/project/_/queue/__next._head.txt +1 -1
  38. package/out/project/_/queue/__next._index.txt +2 -2
  39. package/out/project/_/queue/__next._tree.txt +2 -2
  40. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  41. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  42. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  43. package/out/project/_/queue/__next.project.txt +1 -1
  44. package/out/project/_/queue.html +1 -1
  45. package/out/project/_/queue.txt +2 -2
  46. package/out/project/_.html +1 -1
  47. package/out/project/_.txt +3 -3
  48. package/out/settings/__next._full.txt +2 -2
  49. package/out/settings/__next._head.txt +1 -1
  50. package/out/settings/__next._index.txt +2 -2
  51. package/out/settings/__next._tree.txt +2 -2
  52. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  53. package/out/settings/__next.settings.txt +1 -1
  54. package/out/settings.html +1 -1
  55. package/out/settings.txt +2 -2
  56. package/out/setup/__next._full.txt +2 -2
  57. package/out/setup/__next._head.txt +1 -1
  58. package/out/setup/__next._index.txt +2 -2
  59. package/out/setup/__next._tree.txt +2 -2
  60. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  61. package/out/setup/__next.setup.txt +1 -1
  62. package/out/setup.html +1 -1
  63. package/out/setup.txt +2 -2
  64. package/package.json +7 -7
  65. package/server/__tests__/bridge-auto-stop-guard.test.js +134 -0
  66. package/server/__tests__/rate-limit-handling.test.js +168 -0
  67. package/server/__tests__/scrub-secrets.test.js +234 -0
  68. package/server/__tests__/v1110-security-qa.test.js +312 -0
  69. package/server/index.js +10 -51
  70. package/server/routes.js +168 -73
  71. package/server/scrub-secrets.js +51 -0
  72. package/out/_next/static/chunks/0a4.d381szseh.css +0 -2
  73. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → nkNB54Q5aOvoEsUmAlro2}/_buildManifest.js +0 -0
  74. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → nkNB54Q5aOvoEsUmAlro2}/_clientMiddlewareManifest.js +0 -0
  75. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → nkNB54Q5aOvoEsUmAlro2}/_ssgManifest.js +0 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * #549: QA verification for v1.11.0 security hardening.
3
+ *
4
+ * Covers PRs #543, #546, #547, #548:
5
+ * - #543: execFileSync refactor (no shell interpolation)
6
+ * - #546: Next.js upgrade to 16.2.4
7
+ * - #547: File/directory permission hardening (0o700/0o600)
8
+ * - #548: WebSocket resize input validation
9
+ *
10
+ * Integration-level items (wizard flow, browser open, AC start,
11
+ * multi-tab WS) require a live server and are verified by code-path
12
+ * analysis in the PR description.
13
+ */
14
+
15
+ const assert = require("assert");
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ let passed = 0;
20
+ let failed = 0;
21
+
22
+ function test(name, fn) {
23
+ try {
24
+ fn();
25
+ passed++;
26
+ console.log(` PASS ${name}`);
27
+ } catch (e) {
28
+ failed++;
29
+ console.error(` FAIL ${name}`);
30
+ console.error(` ${e.message}`);
31
+ }
32
+ }
33
+
34
+ const ROOT = path.resolve(__dirname, "../..");
35
+ const SERVER_DIR = path.resolve(__dirname, "..");
36
+
37
+ console.log("\n#549 QA — v1.11.0 security hardening\n");
38
+
39
+ // ============================================================================
40
+ // PR #543 — execFileSync refactor
41
+ // ============================================================================
42
+
43
+ console.log("--- PR #543: execFileSync refactor ---\n");
44
+
45
+ test("bin/quadwork.js has no execSync import", () => {
46
+ const src = fs.readFileSync(path.join(ROOT, "bin/quadwork.js"), "utf-8");
47
+ // execFileSync is OK, execSync is not
48
+ const lines = src.split("\n");
49
+ const badLines = lines.filter(
50
+ (l) => /\bexecSync\b/.test(l) && !/execFileSync/.test(l)
51
+ );
52
+ assert.strictEqual(badLines.length, 0, `Found execSync: ${badLines[0]}`);
53
+ });
54
+
55
+ test("server/index.js has no execSync import", () => {
56
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
57
+ const lines = src.split("\n");
58
+ const badLines = lines.filter(
59
+ (l) => /\bexecSync\b/.test(l) && !/execFileSync/.test(l)
60
+ );
61
+ assert.strictEqual(badLines.length, 0, `Found execSync: ${badLines[0]}`);
62
+ });
63
+
64
+ test("server/install-agentchattr.js has no execSync import", () => {
65
+ const src = fs.readFileSync(path.join(SERVER_DIR, "install-agentchattr.js"), "utf-8");
66
+ const lines = src.split("\n");
67
+ const badLines = lines.filter(
68
+ (l) => /\bexecSync\b/.test(l) && !/execFileSync/.test(l)
69
+ );
70
+ assert.strictEqual(badLines.length, 0, `Found execSync: ${badLines[0]}`);
71
+ });
72
+
73
+ test("scripts/e2e-per-project.js has no execSync import", () => {
74
+ const src = fs.readFileSync(path.join(ROOT, "scripts/e2e-per-project.js"), "utf-8");
75
+ const lines = src.split("\n");
76
+ const badLines = lines.filter(
77
+ (l) => /\bexecSync\b/.test(l) && !/execFileSync/.test(l)
78
+ );
79
+ assert.strictEqual(badLines.length, 0, `Found execSync: ${badLines[0]}`);
80
+ });
81
+
82
+ test("bin/quadwork.js run() uses execFileSync with array args", () => {
83
+ const src = fs.readFileSync(path.join(ROOT, "bin/quadwork.js"), "utf-8");
84
+ assert(src.includes("function run(cmd, args = [], opts = {})"), "run() signature should accept array args");
85
+ assert(src.includes("execFileSync(cmd, args,"), "run() should call execFileSync with cmd, args");
86
+ });
87
+
88
+ test("bin/quadwork.js which() uses run() with array args", () => {
89
+ const src = fs.readFileSync(path.join(ROOT, "bin/quadwork.js"), "utf-8");
90
+ assert(src.includes('run("which", [cmd])'), "which() should use array args");
91
+ });
92
+
93
+ test("bin/quadwork.js browser-open uses execFileSync (no shell)", () => {
94
+ const src = fs.readFileSync(path.join(ROOT, "bin/quadwork.js"), "utf-8");
95
+ // Windows path: cmd /c start
96
+ assert(src.includes('execFileSync("cmd", ["/c", "start"'), "Windows browser open should use execFileSync");
97
+ // macOS/Linux: open or xdg-open
98
+ assert(src.includes('execFileSync(process.platform === "darwin" ? "open" : "xdg-open"'), "macOS/Linux browser open should use execFileSync");
99
+ });
100
+
101
+ test("server/index.js isCliInstalled uses execFileSync", () => {
102
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
103
+ assert(src.includes('execFileSync("which", [cmd]'), "isCliInstalled should use execFileSync");
104
+ });
105
+
106
+ test("server/index.js killProcessOnPort uses execFileSync", () => {
107
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
108
+ assert(src.includes('execFileSync("lsof", ["-ti"'), "killProcessOnPort should use execFileSync");
109
+ });
110
+
111
+ // ============================================================================
112
+ // PR #546 — Next.js upgrade
113
+ // ============================================================================
114
+
115
+ console.log("\n--- PR #546: Next.js upgrade ---\n");
116
+
117
+ test("package.json specifies Next.js >= 16.2.4 (not vulnerable 16.2.1-16.2.3)", () => {
118
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8"));
119
+ const nextVersion = pkg.dependencies?.next || "";
120
+ // Strip leading ^ or ~ for comparison
121
+ const bare = nextVersion.replace(/^[~^]+/, "");
122
+ const [major, minor, patch] = bare.split(".").map(Number);
123
+ assert(
124
+ major > 16 || (major === 16 && minor > 2) || (major === 16 && minor === 2 && patch >= 4),
125
+ `Expected next >= 16.2.4, got ${nextVersion}`
126
+ );
127
+ });
128
+
129
+ test("package-lock.json has Next.js 16.2.4+ (resolved version)", () => {
130
+ const lock = JSON.parse(fs.readFileSync(path.join(ROOT, "package-lock.json"), "utf-8"));
131
+ const nextPkg = lock.packages?.["node_modules/next"];
132
+ assert(nextPkg, "next should be in package-lock.json");
133
+ const version = nextPkg.version;
134
+ const [major, minor, patch] = version.split(".").map(Number);
135
+ assert(
136
+ major > 16 || (major === 16 && minor > 2) || (major === 16 && minor === 2 && patch >= 4),
137
+ `Expected >= 16.2.4, got ${version}`
138
+ );
139
+ });
140
+
141
+ test("next build succeeds (runs build from scratch)", () => {
142
+ // Actually run the build to verify Next.js 16.2.4 works.
143
+ const { execFileSync } = require("child_process");
144
+ try {
145
+ execFileSync("npx", ["next", "build"], {
146
+ cwd: ROOT,
147
+ stdio: "pipe",
148
+ timeout: 120000,
149
+ });
150
+ } catch (e) {
151
+ assert.fail(`next build failed: ${e.stderr?.toString().slice(-500) || e.message}`);
152
+ }
153
+ const outIndex = path.join(ROOT, "out", "index.html");
154
+ assert(fs.existsSync(outIndex), "out/index.html should exist after build");
155
+ });
156
+
157
+ // ============================================================================
158
+ // PR #547 — File/directory permission hardening
159
+ // ============================================================================
160
+
161
+ console.log("\n--- PR #547: File/directory permission hardening ---\n");
162
+
163
+ test("server/config.js exports ensureSecureDir", () => {
164
+ const config = require(path.join(SERVER_DIR, "config.js"));
165
+ assert(typeof config.ensureSecureDir === "function", "ensureSecureDir should be exported");
166
+ });
167
+
168
+ test("server/config.js exports writeSecureFile", () => {
169
+ const config = require(path.join(SERVER_DIR, "config.js"));
170
+ assert(typeof config.writeSecureFile === "function", "writeSecureFile should be exported");
171
+ });
172
+
173
+ test("server/config.js exports writeConfig", () => {
174
+ const config = require(path.join(SERVER_DIR, "config.js"));
175
+ assert(typeof config.writeConfig === "function", "writeConfig should be exported");
176
+ });
177
+
178
+ test("ensureSecureDir creates dir with 0o700", () => {
179
+ const { ensureSecureDir } = require(path.join(SERVER_DIR, "config.js"));
180
+ const tmpDir = path.join(require("os").tmpdir(), `qw-qa-test-${Date.now()}`);
181
+ try {
182
+ ensureSecureDir(tmpDir);
183
+ const stat = fs.statSync(tmpDir);
184
+ assert(stat.isDirectory(), "should be a directory");
185
+ assert.strictEqual(stat.mode & 0o777, 0o700, `expected 0o700, got ${(stat.mode & 0o777).toString(8)}`);
186
+ } finally {
187
+ try { fs.rmdirSync(tmpDir); } catch {}
188
+ }
189
+ });
190
+
191
+ test("writeSecureFile creates file with 0o600", () => {
192
+ const { writeSecureFile } = require(path.join(SERVER_DIR, "config.js"));
193
+ const tmpFile = path.join(require("os").tmpdir(), `qw-qa-test-${Date.now()}.txt`);
194
+ try {
195
+ writeSecureFile(tmpFile, "test content");
196
+ const stat = fs.statSync(tmpFile);
197
+ assert(stat.isFile(), "should be a file");
198
+ assert.strictEqual(stat.mode & 0o777, 0o600, `expected 0o600, got ${(stat.mode & 0o777).toString(8)}`);
199
+ assert.strictEqual(fs.readFileSync(tmpFile, "utf-8"), "test content");
200
+ } finally {
201
+ try { fs.unlinkSync(tmpFile); } catch {}
202
+ }
203
+ });
204
+
205
+ test("server/index.js imports ensureSecureDir and writeSecureFile", () => {
206
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
207
+ assert(src.includes("ensureSecureDir"), "should import ensureSecureDir");
208
+ assert(src.includes("writeSecureFile"), "should import writeSecureFile");
209
+ assert(src.includes("writeConfig"), "should import writeConfig");
210
+ });
211
+
212
+ test("server/routes.js imports ensureSecureDir and writeSecureFile", () => {
213
+ const src = fs.readFileSync(path.join(SERVER_DIR, "routes.js"), "utf-8");
214
+ assert(src.includes("ensureSecureDir"), "should import ensureSecureDir");
215
+ assert(src.includes("writeSecureFile"), "should import writeSecureFile");
216
+ assert(src.includes("writeConfig"), "should import writeConfig");
217
+ });
218
+
219
+ test("no raw mkdirSync without mode in server files", () => {
220
+ const files = ["index.js", "routes.js", "config.js"];
221
+ for (const file of files) {
222
+ const src = fs.readFileSync(path.join(SERVER_DIR, file), "utf-8");
223
+ const lines = src.split("\n");
224
+ const badLines = lines.filter(
225
+ (l) =>
226
+ /fs\.mkdirSync\(/.test(l) &&
227
+ !l.includes("mode:") &&
228
+ !l.includes("ensureSecureDir") &&
229
+ !l.trim().startsWith("//")
230
+ );
231
+ assert.strictEqual(
232
+ badLines.length,
233
+ 0,
234
+ `${file} has raw mkdirSync without mode: ${badLines[0]}`
235
+ );
236
+ }
237
+ });
238
+
239
+ test("bin/quadwork.js uses ensureSecureDir for directory creation", () => {
240
+ const src = fs.readFileSync(path.join(ROOT, "bin/quadwork.js"), "utf-8");
241
+ assert(src.includes("ensureSecureDir"), "bin/quadwork.js should use ensureSecureDir");
242
+ });
243
+
244
+ test("install-agentchattr.js uses mode: 0o700 for mkdirSync", () => {
245
+ const src = fs.readFileSync(path.join(SERVER_DIR, "install-agentchattr.js"), "utf-8");
246
+ const lines = src.split("\n");
247
+ const mkdirLines = lines.filter((l) => /fs\.mkdirSync\(/.test(l) && !l.trim().startsWith("//"));
248
+ for (const line of mkdirLines) {
249
+ assert(line.includes("mode: 0o700"), `Missing mode: 0o700 in: ${line.trim()}`);
250
+ }
251
+ });
252
+
253
+ test("install-agentchattr.js uses mode: 0o600 for writeFileSync (security-sensitive files)", () => {
254
+ const src = fs.readFileSync(path.join(SERVER_DIR, "install-agentchattr.js"), "utf-8");
255
+ const lines = src.split("\n");
256
+ const writeLines = lines.filter(
257
+ (l) =>
258
+ /fs\.writeFileSync\(/.test(l) &&
259
+ !l.trim().startsWith("//") &&
260
+ // CSS/JS patches are non-sensitive (cosmetic overrides to AC UI)
261
+ !l.includes("cssPath") &&
262
+ !l.includes("jsPath")
263
+ );
264
+ for (const line of writeLines) {
265
+ assert(line.includes("mode: 0o600"), `Missing mode: 0o600 in: ${line.trim()}`);
266
+ }
267
+ });
268
+
269
+ // ============================================================================
270
+ // PR #548 — WebSocket resize validation
271
+ // ============================================================================
272
+
273
+ console.log("\n--- PR #548: WebSocket resize validation ---\n");
274
+
275
+ test("server/index.js has typeof number check for resize cols", () => {
276
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
277
+ assert(src.includes('typeof parsed.cols === "number"'), "should check typeof cols");
278
+ });
279
+
280
+ test("server/index.js has typeof number check for resize rows", () => {
281
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
282
+ assert(src.includes('typeof parsed.rows === "number"'), "should check typeof rows");
283
+ });
284
+
285
+ test("server/index.js validates Number.isFinite for resize", () => {
286
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
287
+ assert(src.includes("Number.isFinite(parsed.cols)"), "should validate isFinite for cols");
288
+ assert(src.includes("Number.isFinite(parsed.rows)"), "should validate isFinite for rows");
289
+ });
290
+
291
+ test("server/index.js bounds-checks resize to 1-500", () => {
292
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
293
+ assert(src.includes("parsed.cols >= 1"), "should lower-bound cols");
294
+ assert(src.includes("parsed.cols <= 500"), "should upper-bound cols");
295
+ assert(src.includes("parsed.rows >= 1"), "should lower-bound rows");
296
+ assert(src.includes("parsed.rows <= 500"), "should upper-bound rows");
297
+ });
298
+
299
+ test("resize validation is inside the resize handler block", () => {
300
+ const src = fs.readFileSync(path.join(SERVER_DIR, "index.js"), "utf-8");
301
+ // Verify the validation is between 'parsed.type === "resize"' and 'session.term.resize'
302
+ const resizeIdx = src.indexOf('parsed.type === "resize"');
303
+ const termResizeIdx = src.indexOf("session.term.resize(parsed.cols, parsed.rows)");
304
+ const typeofIdx = src.indexOf('typeof parsed.cols === "number"');
305
+ assert(resizeIdx > 0 && termResizeIdx > resizeIdx && typeofIdx > resizeIdx && typeofIdx < termResizeIdx,
306
+ "validation should be between resize type check and term.resize call");
307
+ });
308
+
309
+ // ============================================================================
310
+
311
+ console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total\n`);
312
+ process.exit(failed > 0 ? 1 : 0);
package/server/index.js CHANGED
@@ -1402,7 +1402,12 @@ async function sendTriggerMessage(projectId) {
1402
1402
  console.log(`[auto-trigger] ${projectId}: caffeinate auto-stopped (no active triggers remain)`);
1403
1403
  }
1404
1404
  // #518: also stop bridges when batch completes
1405
- await autoStopBridges(projectId, project, qwPort);
1405
+ // #542: transition guard — only stop if not already stopped for this completion
1406
+ const prev = _bridgeBatchPrev.get(projectId);
1407
+ _bridgeBatchPrev.set(projectId, { complete: true, hasItems: !!(bp.items && bp.items.length) });
1408
+ if (!prev || !prev.complete) {
1409
+ await autoStopBridges(projectId, project, qwPort);
1410
+ }
1406
1411
  return;
1407
1412
  }
1408
1413
  }
@@ -1718,55 +1723,8 @@ app.use((req, res, next) => {
1718
1723
  }
1719
1724
  });
1720
1725
 
1721
- // --- #538: PTY output secret scrubbing ---
1722
- // Redact likely secrets from both live PTY streaming and scrollback
1723
- // replay so echoed credentials are not exposed to dashboard clients.
1724
- //
1725
- // Threat model: QuadWork binds to 127.0.0.1 only. The scrub is
1726
- // defense-in-depth — it reduces exposure if a secret is accidentally
1727
- // echoed, but cannot catch every possible format. Operators who handle
1728
- // highly sensitive credentials should avoid echoing them in agent
1729
- // terminals.
1730
- //
1731
- // Live chunks from term.onData() are typically line-aligned (shell
1732
- // flushes on newline), so per-chunk scrubbing catches the vast majority
1733
- // of secrets. A secret split across two chunks is a theoretical edge
1734
- // case that the scrollback scrub (which sees the full buffer) covers
1735
- // on reconnect.
1736
-
1737
- // Patterns that indicate a line contains a secret value.
1738
- const _SECRET_NAME_RE = /\b\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSPHRASE|AUTH)\w*\s*[=:]/i;
1739
- // Known API key prefixes (Anthropic, GitHub, OpenAI, etc.).
1740
- const _API_KEY_PREFIX_RE = /\b(sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36,}|ghu_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,}|xoxb-[A-Za-z0-9-]{20,}|xoxp-[A-Za-z0-9-]{20,})\b/;
1741
- // Bearer authorization headers.
1742
- const _BEARER_RE = /\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/i;
1743
- const _REDACTED = "[REDACTED]";
1744
-
1745
- function scrubSecrets(text) {
1746
- if (!text) return text;
1747
- return text.split("\n").map((line) => {
1748
- // Strip ANSI escape codes for pattern matching, but redact the
1749
- // original line (preserves terminal formatting around non-secret
1750
- // lines while ensuring secrets inside styled output are caught).
1751
- const plain = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
1752
- if (_SECRET_NAME_RE.test(plain)) {
1753
- // Redact the value portion after the = or : delimiter.
1754
- return line.replace(/([=:])\s*\S.*/, `$1 ${_REDACTED}`);
1755
- }
1756
- if (_API_KEY_PREFIX_RE.test(plain)) {
1757
- return line.replace(_API_KEY_PREFIX_RE, _REDACTED);
1758
- }
1759
- if (_BEARER_RE.test(plain)) {
1760
- return line.replace(/\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/gi, `Bearer ${_REDACTED}`);
1761
- }
1762
- return line;
1763
- }).join("\n");
1764
- }
1765
-
1766
- function scrubScrollback(buf) {
1767
- if (!buf || buf.length === 0) return buf;
1768
- return Buffer.from(scrubSecrets(buf.toString("utf-8")), "utf-8");
1769
- }
1726
+ // --- #538: PTY output secret scrubbing (extracted to scrub-secrets.js) ---
1727
+ const { scrubSecrets, scrubScrollback } = require("./scrub-secrets");
1770
1728
 
1771
1729
  // --- WebSocket + PTY ---
1772
1730
  // WS connects to an existing PTY session (started via lifecycle API)
@@ -1933,7 +1891,8 @@ async function autoStopPollingTick() {
1933
1891
  }
1934
1892
  }
1935
1893
  // #518: also stop bridges when batch completes
1936
- if (hasBridgeAuto) {
1894
+ // #542: only fire on the transition (incomplete→complete), not every tick
1895
+ if (hasBridgeAuto && (!prev || !prev.complete)) {
1937
1896
  await autoStopBridges(project.id, project, qwPort);
1938
1897
  }
1939
1898
  }