quadwork 1.11.0 → 1.11.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.
Files changed (70) hide show
  1. package/out/404.html +1 -1
  2. package/out/__next.__PAGE__.txt +1 -1
  3. package/out/__next._full.txt +1 -1
  4. package/out/__next._head.txt +1 -1
  5. package/out/__next._index.txt +1 -1
  6. package/out/__next._tree.txt +1 -1
  7. package/out/_next/static/chunks/{0gaekhrfy94vz.js → 0a5314ra5t9bs.js} +1 -1
  8. package/out/_next/static/chunks/{0hirada7763yr.js → 0ge87xt6a9j~..js} +1 -1
  9. package/out/_not-found/__next._full.txt +1 -1
  10. package/out/_not-found/__next._head.txt +1 -1
  11. package/out/_not-found/__next._index.txt +1 -1
  12. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  13. package/out/_not-found/__next._not-found.txt +1 -1
  14. package/out/_not-found/__next._tree.txt +1 -1
  15. package/out/_not-found.html +1 -1
  16. package/out/_not-found.txt +1 -1
  17. package/out/app-shell/__next._full.txt +1 -1
  18. package/out/app-shell/__next._head.txt +1 -1
  19. package/out/app-shell/__next._index.txt +1 -1
  20. package/out/app-shell/__next._tree.txt +1 -1
  21. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  22. package/out/app-shell/__next.app-shell.txt +1 -1
  23. package/out/app-shell.html +1 -1
  24. package/out/app-shell.txt +1 -1
  25. package/out/index.html +1 -1
  26. package/out/index.txt +1 -1
  27. package/out/project/_/__next._full.txt +2 -2
  28. package/out/project/_/__next._head.txt +1 -1
  29. package/out/project/_/__next._index.txt +1 -1
  30. package/out/project/_/__next._tree.txt +1 -1
  31. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
  32. package/out/project/_/__next.project.$d$id.txt +1 -1
  33. package/out/project/_/__next.project.txt +1 -1
  34. package/out/project/_/queue/__next._full.txt +1 -1
  35. package/out/project/_/queue/__next._head.txt +1 -1
  36. package/out/project/_/queue/__next._index.txt +1 -1
  37. package/out/project/_/queue/__next._tree.txt +1 -1
  38. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  39. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  40. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  41. package/out/project/_/queue/__next.project.txt +1 -1
  42. package/out/project/_/queue.html +1 -1
  43. package/out/project/_/queue.txt +1 -1
  44. package/out/project/_.html +1 -1
  45. package/out/project/_.txt +2 -2
  46. package/out/settings/__next._full.txt +1 -1
  47. package/out/settings/__next._head.txt +1 -1
  48. package/out/settings/__next._index.txt +1 -1
  49. package/out/settings/__next._tree.txt +1 -1
  50. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  51. package/out/settings/__next.settings.txt +1 -1
  52. package/out/settings.html +1 -1
  53. package/out/settings.txt +1 -1
  54. package/out/setup/__next._full.txt +1 -1
  55. package/out/setup/__next._head.txt +1 -1
  56. package/out/setup/__next._index.txt +1 -1
  57. package/out/setup/__next._tree.txt +1 -1
  58. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  59. package/out/setup/__next.setup.txt +1 -1
  60. package/out/setup.html +1 -1
  61. package/out/setup.txt +1 -1
  62. package/package.json +1 -1
  63. package/server/__tests__/bridge-auto-stop-guard.test.js +134 -0
  64. package/server/__tests__/scrub-secrets.test.js +234 -0
  65. package/server/__tests__/v1110-security-qa.test.js +312 -0
  66. package/server/index.js +10 -51
  67. package/server/scrub-secrets.js +51 -0
  68. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → QmshV04af9o06krSyFHwf}/_buildManifest.js +0 -0
  69. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → QmshV04af9o06krSyFHwf}/_clientMiddlewareManifest.js +0 -0
  70. /package/out/_next/static/{a1_CwwdhUp5-lHCPnFaTw → QmshV04af9o06krSyFHwf}/_ssgManifest.js +0 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * #545: QA verification for #538 PTY scrollback secret scrubbing.
3
+ *
4
+ * Tests the production scrubSecrets / scrubScrollback from server/scrub-secrets.js:
5
+ * - Redact secrets correctly (SECRET_NAME, API key prefixes, Bearer)
6
+ * - Pass through normal terminal output unchanged
7
+ * - Preserve ANSI escape codes on non-secret lines
8
+ * - Handle edge cases (empty input, very long lines, rapid multi-line output)
9
+ *
10
+ * Integration-level checklist items (resize, reconnect/replay flow,
11
+ * multi-agent isolation, two-tab behavior) are verified by code-path
12
+ * analysis in the PR description — they depend on WS + PTY runtime
13
+ * that cannot be unit-tested without a full server harness.
14
+ */
15
+
16
+ const { scrubSecrets, scrubScrollback, _REDACTED } = require("../scrub-secrets");
17
+
18
+ // ---- Tests ----
19
+
20
+ const assert = require("assert");
21
+ let passed = 0;
22
+ let failed = 0;
23
+
24
+ function test(name, fn) {
25
+ try {
26
+ fn();
27
+ passed++;
28
+ console.log(` PASS ${name}`);
29
+ } catch (e) {
30
+ failed++;
31
+ console.error(` FAIL ${name}`);
32
+ console.error(` ${e.message}`);
33
+ }
34
+ }
35
+
36
+ console.log("\n#545 QA — scrubSecrets / scrubScrollback (production module)\n");
37
+
38
+ // --- 1. Secret redaction (true positives) ---
39
+
40
+ test("redacts ANTHROPIC_API_KEY=value", () => {
41
+ const input = "ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxx";
42
+ const out = scrubSecrets(input);
43
+ assert(!out.includes("sk-ant-api03"), `got: ${out}`);
44
+ assert(out.includes(_REDACTED));
45
+ });
46
+
47
+ test("redacts DB_PASSWORD: hunter2", () => {
48
+ const out = scrubSecrets("DB_PASSWORD: hunter2");
49
+ assert(!out.includes("hunter2"), `got: ${out}`);
50
+ });
51
+
52
+ test("redacts GITHUB_TOKEN=ghp_...", () => {
53
+ const token = "ghp_" + "A".repeat(36);
54
+ const out = scrubSecrets(`export GITHUB_TOKEN=${token}`);
55
+ assert(!out.includes(token), `got: ${out}`);
56
+ });
57
+
58
+ test("redacts Bearer token header", () => {
59
+ const out = scrubSecrets("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig");
60
+ assert(!out.includes("eyJhbG"), `got: ${out}`);
61
+ assert(out.includes(_REDACTED));
62
+ });
63
+
64
+ test("redacts standalone Bearer (no Authorization: prefix)", () => {
65
+ const out = scrubSecrets("curl -H 'Bearer eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx'");
66
+ assert(!out.includes("eyJhbG"), `got: ${out}`);
67
+ assert(out.includes(`Bearer ${_REDACTED}`));
68
+ });
69
+
70
+ test("redacts sk- OpenAI key inline", () => {
71
+ const key = "sk-" + "a".repeat(48);
72
+ const out = scrubSecrets(`Using key ${key} for request`);
73
+ assert(!out.includes(key), `got: ${out}`);
74
+ });
75
+
76
+ test("redacts Slack xoxb token", () => {
77
+ const token = "xoxb-" + "1234567890-" + "A".repeat(20);
78
+ const out = scrubSecrets(`SLACK_TOKEN=${token}`);
79
+ assert(!out.includes(token), `got: ${out}`);
80
+ });
81
+
82
+ // --- 2. Normal output passes through unchanged ---
83
+
84
+ test("preserves plain text", () => {
85
+ const input = "Hello, world! This is normal output.";
86
+ assert.strictEqual(scrubSecrets(input), input);
87
+ });
88
+
89
+ test("preserves npm install output", () => {
90
+ const input = "added 1423 packages in 45s\n182 packages are looking for funding";
91
+ assert.strictEqual(scrubSecrets(input), input);
92
+ });
93
+
94
+ test("preserves git clone output", () => {
95
+ const input = "Cloning into 'repo'...\nremote: Enumerating objects: 1234, done.\nReceiving objects: 100% (1234/1234)";
96
+ assert.strictEqual(scrubSecrets(input), input);
97
+ });
98
+
99
+ test("preserves test runner output", () => {
100
+ const input = "Tests: 42 passed, 42 total\nTime: 3.456 s";
101
+ assert.strictEqual(scrubSecrets(input), input);
102
+ });
103
+
104
+ test("preserves JSON output without secrets", () => {
105
+ const input = '{"status":"ok","count":42,"items":["a","b","c"]}';
106
+ assert.strictEqual(scrubSecrets(input), input);
107
+ });
108
+
109
+ // --- 3. ANSI escape code handling ---
110
+
111
+ test("preserves ANSI colors on non-secret lines", () => {
112
+ const input = "\x1b[32m✓\x1b[0m test passed";
113
+ assert.strictEqual(scrubSecrets(input), input);
114
+ });
115
+
116
+ test("redacts secret even inside ANSI-styled line", () => {
117
+ const input = "\x1b[33mAPI_KEY=\x1b[31msecretvalue123\x1b[0m";
118
+ const out = scrubSecrets(input);
119
+ assert(!out.includes("secretvalue123"), `got: ${out}`);
120
+ assert(out.includes(_REDACTED));
121
+ });
122
+
123
+ test("ANSI bold output without secrets passes through", () => {
124
+ const input = "\x1b[1mBuilding project...\x1b[0m\nCompiled successfully.";
125
+ assert.strictEqual(scrubSecrets(input), input);
126
+ });
127
+
128
+ // --- 4. Edge cases ---
129
+
130
+ test("handles empty string", () => {
131
+ assert.strictEqual(scrubSecrets(""), "");
132
+ });
133
+
134
+ test("handles null/undefined", () => {
135
+ assert.strictEqual(scrubSecrets(null), null);
136
+ assert.strictEqual(scrubSecrets(undefined), undefined);
137
+ });
138
+
139
+ test("handles very long line (>1000 chars) without secrets", () => {
140
+ const longLine = "x".repeat(2000);
141
+ assert.strictEqual(scrubSecrets(longLine), longLine);
142
+ });
143
+
144
+ test("handles very long line with embedded secret", () => {
145
+ const prefix = "a".repeat(500);
146
+ const suffix = "b".repeat(500);
147
+ const key = "sk-" + "c".repeat(48);
148
+ const input = `${prefix} ${key} ${suffix}`;
149
+ const out = scrubSecrets(input);
150
+ assert(!out.includes(key), `key should be redacted`);
151
+ });
152
+
153
+ test("handles rapid multi-line output (100 lines)", () => {
154
+ const lines = Array.from({ length: 100 }, (_, i) => `line ${i}: output data here`);
155
+ const input = lines.join("\n");
156
+ assert.strictEqual(scrubSecrets(input), input);
157
+ });
158
+
159
+ test("handles multi-line with mixed secrets and normal output", () => {
160
+ const input = [
161
+ "Starting deploy...",
162
+ "AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE",
163
+ "Uploading artifacts...",
164
+ "Bearer eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx",
165
+ "Deploy complete!",
166
+ ].join("\n");
167
+ const out = scrubSecrets(input);
168
+ assert(out.includes("Starting deploy..."));
169
+ assert(out.includes("Uploading artifacts..."));
170
+ assert(out.includes("Deploy complete!"));
171
+ assert(!out.includes("AKIAIOSFODNN7EXAMPLE"));
172
+ assert(!out.includes("eyJhbGci"));
173
+ });
174
+
175
+ // --- 5. scrubScrollback (Buffer handling) ---
176
+
177
+ test("scrubScrollback returns Buffer", () => {
178
+ const buf = Buffer.from("SOME_SECRET_KEY=abc123\nnormal line");
179
+ const out = scrubScrollback(buf);
180
+ assert(Buffer.isBuffer(out), "should return a Buffer");
181
+ assert(!out.toString().includes("abc123"));
182
+ });
183
+
184
+ test("scrubScrollback handles empty buffer", () => {
185
+ const buf = Buffer.alloc(0);
186
+ const out = scrubScrollback(buf);
187
+ assert(Buffer.isBuffer(out));
188
+ assert.strictEqual(out.length, 0);
189
+ });
190
+
191
+ test("scrubScrollback handles null", () => {
192
+ assert.strictEqual(scrubScrollback(null), null);
193
+ });
194
+
195
+ test("scrubScrollback preserves non-secret content", () => {
196
+ const content = "Hello world\nBuild succeeded\n42 tests passed";
197
+ const buf = Buffer.from(content);
198
+ const out = scrubScrollback(buf);
199
+ assert.strictEqual(out.toString(), content);
200
+ });
201
+
202
+ // --- 6. Integration path verification ---
203
+ // These tests verify that server/index.js imports from the same module
204
+ // we're testing, ensuring no copy-paste drift.
205
+
206
+ test("server/index.js imports scrubSecrets from scrub-secrets.js", () => {
207
+ const serverSource = require("fs").readFileSync(
208
+ require("path").join(__dirname, "..", "index.js"), "utf-8"
209
+ );
210
+ assert(
211
+ serverSource.includes('require("./scrub-secrets")') ||
212
+ serverSource.includes("require('./scrub-secrets')"),
213
+ "server/index.js must import from ./scrub-secrets"
214
+ );
215
+ });
216
+
217
+ test("server/index.js uses scrubSecrets in PTY data handler", () => {
218
+ const serverSource = require("fs").readFileSync(
219
+ require("path").join(__dirname, "..", "index.js"), "utf-8"
220
+ );
221
+ assert(serverSource.includes("scrubSecrets(data)"), "live PTY path must call scrubSecrets");
222
+ });
223
+
224
+ test("server/index.js uses scrubScrollback in replay handler", () => {
225
+ const serverSource = require("fs").readFileSync(
226
+ require("path").join(__dirname, "..", "index.js"), "utf-8"
227
+ );
228
+ assert(serverSource.includes("scrubScrollback(session.scrollback)"), "replay path must call scrubScrollback");
229
+ });
230
+
231
+ // --- Summary ---
232
+
233
+ console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total\n`);
234
+ process.exit(failed > 0 ? 1 : 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
  }
@@ -0,0 +1,51 @@
1
+ // --- #538: PTY output secret scrubbing ---
2
+ // Redact likely secrets from both live PTY streaming and scrollback
3
+ // replay so echoed credentials are not exposed to dashboard clients.
4
+ //
5
+ // Threat model: QuadWork binds to 127.0.0.1 only. The scrub is
6
+ // defense-in-depth — it reduces exposure if a secret is accidentally
7
+ // echoed, but cannot catch every possible format. Operators who handle
8
+ // highly sensitive credentials should avoid echoing them in agent
9
+ // terminals.
10
+ //
11
+ // Live chunks from term.onData() are typically line-aligned (shell
12
+ // flushes on newline), so per-chunk scrubbing catches the vast majority
13
+ // of secrets. A secret split across two chunks is a theoretical edge
14
+ // case that the scrollback scrub (which sees the full buffer) covers
15
+ // on reconnect.
16
+
17
+ // Patterns that indicate a line contains a secret value.
18
+ const _SECRET_NAME_RE = /\b\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSPHRASE|AUTH)\w*\s*[=:]/i;
19
+ // Known API key prefixes (Anthropic, GitHub, OpenAI, etc.).
20
+ 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/;
21
+ // Bearer authorization headers.
22
+ const _BEARER_RE = /\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/i;
23
+ const _REDACTED = "[REDACTED]";
24
+
25
+ function scrubSecrets(text) {
26
+ if (!text) return text;
27
+ return text.split("\n").map((line) => {
28
+ // Strip ANSI escape codes for pattern matching, but redact the
29
+ // original line (preserves terminal formatting around non-secret
30
+ // lines while ensuring secrets inside styled output are caught).
31
+ const plain = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
32
+ if (_SECRET_NAME_RE.test(plain)) {
33
+ // Redact the value portion after the = or : delimiter.
34
+ return line.replace(/([=:])\s*\S.*/, `$1 ${_REDACTED}`);
35
+ }
36
+ if (_API_KEY_PREFIX_RE.test(plain)) {
37
+ return line.replace(_API_KEY_PREFIX_RE, _REDACTED);
38
+ }
39
+ if (_BEARER_RE.test(plain)) {
40
+ return line.replace(/\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/gi, `Bearer ${_REDACTED}`);
41
+ }
42
+ return line;
43
+ }).join("\n");
44
+ }
45
+
46
+ function scrubScrollback(buf) {
47
+ if (!buf || buf.length === 0) return buf;
48
+ return Buffer.from(scrubSecrets(buf.toString("utf-8")), "utf-8");
49
+ }
50
+
51
+ module.exports = { scrubSecrets, scrubScrollback, _REDACTED };