quadwork 1.10.1 → 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.
- package/bin/quadwork.js +84 -56
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +1 -1
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +1 -1
- package/out/__next._tree.txt +1 -1
- package/out/_next/static/chunks/{0gaekhrfy94vz.js → 0a5314ra5t9bs.js} +1 -1
- package/out/_next/static/chunks/{0hirada7763yr.js → 0ge87xt6a9j~..js} +1 -1
- package/out/_next/static/chunks/{16g.ca89g7fib.js → 0n~dq4kpx9xxx.js} +1 -1
- package/out/_next/static/chunks/turbopack-0qm-e3ifrz~2u.js +1 -0
- package/out/_not-found/__next._full.txt +1 -1
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +1 -1
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +1 -1
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +1 -1
- package/out/app-shell/__next._full.txt +1 -1
- package/out/app-shell/__next._head.txt +1 -1
- package/out/app-shell/__next._index.txt +1 -1
- package/out/app-shell/__next._tree.txt +1 -1
- package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
- package/out/app-shell/__next.app-shell.txt +1 -1
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +1 -1
- package/out/project/_/__next._full.txt +2 -2
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +1 -1
- package/out/project/_/__next._tree.txt +1 -1
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/queue/__next._full.txt +1 -1
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +1 -1
- package/out/project/_/queue/__next._tree.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +1 -1
- package/out/project/_.html +1 -1
- package/out/project/_.txt +2 -2
- package/out/settings/__next._full.txt +1 -1
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +1 -1
- package/out/settings/__next._tree.txt +1 -1
- package/out/settings/__next.settings.__PAGE__.txt +1 -1
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +1 -1
- package/out/setup/__next._full.txt +1 -1
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +1 -1
- package/out/setup/__next._tree.txt +1 -1
- package/out/setup/__next.setup.__PAGE__.txt +1 -1
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +1 -1
- package/package.json +2 -2
- package/server/__tests__/bridge-auto-stop-guard.test.js +134 -0
- package/server/__tests__/scrub-secrets.test.js +234 -0
- package/server/__tests__/v1110-security-qa.test.js +312 -0
- package/server/config.js +29 -5
- package/server/index.js +45 -27
- package/server/install-agentchattr.js +12 -11
- package/server/routes.js +27 -30
- package/server/scrub-secrets.js +51 -0
- package/out/_next/static/chunks/turbopack-0lcwh84lrj9gi.js +0 -1
- /package/out/_next/static/{MA2-1YByee5M0-bbLgqQD → QmshV04af9o06krSyFHwf}/_buildManifest.js +0 -0
- /package/out/_next/static/{MA2-1YByee5M0-bbLgqQD → QmshV04af9o06krSyFHwf}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{MA2-1YByee5M0-bbLgqQD → QmshV04af9o06krSyFHwf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #542 — Verify bridge auto-stop fires only on the transition from
|
|
3
|
+
* incomplete → complete, not on every polling tick.
|
|
4
|
+
*
|
|
5
|
+
* Since autoStopPollingTick is tightly coupled to HTTP calls, we test
|
|
6
|
+
* the guard logic by reading server/index.js and verifying the code
|
|
7
|
+
* structure, then simulating the guard in isolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const SERVER_PATH = path.join(__dirname, "..", "index.js");
|
|
14
|
+
const src = fs.readFileSync(SERVER_PATH, "utf-8");
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// 1. Code-structure assertions — verify the guard exists in source
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe("#542 bridge auto-stop transition guard (code analysis)", () => {
|
|
21
|
+
test("autoStopBridges in polling path is guarded by prev.complete check", () => {
|
|
22
|
+
// Find the autoStopPollingTick function body
|
|
23
|
+
const fnStart = src.indexOf("async function autoStopPollingTick()");
|
|
24
|
+
expect(fnStart).toBeGreaterThan(-1);
|
|
25
|
+
|
|
26
|
+
// Extract the function body (up to the next top-level function or setInterval)
|
|
27
|
+
const fnBody = src.slice(fnStart, src.indexOf("setInterval(autoStopPollingTick"));
|
|
28
|
+
|
|
29
|
+
// The autoStopBridges call must be guarded by a prev.complete check
|
|
30
|
+
const stopIdx = fnBody.indexOf("autoStopBridges(project.id");
|
|
31
|
+
expect(stopIdx).toBeGreaterThan(-1);
|
|
32
|
+
|
|
33
|
+
// Look at the surrounding context — the guard should appear before the call
|
|
34
|
+
const guardRegion = fnBody.slice(Math.max(0, stopIdx - 200), stopIdx);
|
|
35
|
+
expect(guardRegion).toMatch(/!prev\.complete/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("autoStartBridges in polling path is still transition-guarded", () => {
|
|
39
|
+
const fnStart = src.indexOf("async function autoStopPollingTick()");
|
|
40
|
+
const fnBody = src.slice(fnStart, src.indexOf("setInterval(autoStopPollingTick"));
|
|
41
|
+
const startIdx = fnBody.indexOf("autoStartBridges(");
|
|
42
|
+
expect(startIdx).toBeGreaterThan(-1);
|
|
43
|
+
|
|
44
|
+
const guardRegion = fnBody.slice(Math.max(0, startIdx - 200), startIdx);
|
|
45
|
+
expect(guardRegion).toMatch(/isNewBatch/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("_bridgeBatchPrev.set is called before the guard checks", () => {
|
|
49
|
+
const fnStart = src.indexOf("async function autoStopPollingTick()");
|
|
50
|
+
const fnBody = src.slice(fnStart, src.indexOf("setInterval(autoStopPollingTick"));
|
|
51
|
+
|
|
52
|
+
const setIdx = fnBody.indexOf("_bridgeBatchPrev.set(");
|
|
53
|
+
const stopIdx = fnBody.indexOf("autoStopBridges(project.id");
|
|
54
|
+
expect(setIdx).toBeGreaterThan(-1);
|
|
55
|
+
expect(setIdx).toBeLessThan(stopIdx);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("autoStopBridges in sendTriggerMessage is guarded by prev.complete check", () => {
|
|
59
|
+
const fnStart = src.indexOf("async function sendTriggerMessage(");
|
|
60
|
+
expect(fnStart).toBeGreaterThan(-1);
|
|
61
|
+
|
|
62
|
+
// Extract until the next top-level function
|
|
63
|
+
const fnBody = src.slice(fnStart, fnStart + 2000);
|
|
64
|
+
|
|
65
|
+
const stopIdx = fnBody.indexOf("autoStopBridges(");
|
|
66
|
+
expect(stopIdx).toBeGreaterThan(-1);
|
|
67
|
+
|
|
68
|
+
// The guard should appear before the call
|
|
69
|
+
const guardRegion = fnBody.slice(Math.max(0, stopIdx - 300), stopIdx);
|
|
70
|
+
expect(guardRegion).toMatch(/!prev\.complete/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("sendTriggerMessage updates _bridgeBatchPrev before the guard", () => {
|
|
74
|
+
const fnStart = src.indexOf("async function sendTriggerMessage(");
|
|
75
|
+
const fnBody = src.slice(fnStart, fnStart + 2000);
|
|
76
|
+
|
|
77
|
+
const setIdx = fnBody.indexOf("_bridgeBatchPrev.set(");
|
|
78
|
+
const stopIdx = fnBody.indexOf("autoStopBridges(");
|
|
79
|
+
expect(setIdx).toBeGreaterThan(-1);
|
|
80
|
+
expect(setIdx).toBeLessThan(stopIdx);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// 2. Guard-logic simulation — mirrors the if-condition in autoStopPollingTick
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe("#542 bridge auto-stop transition guard (logic simulation)", () => {
|
|
89
|
+
function shouldAutoStop(bp, prev) {
|
|
90
|
+
// Mirrors the production guard:
|
|
91
|
+
// if (hasBridgeAuto && (!prev || !prev.complete)) { autoStopBridges(...) }
|
|
92
|
+
if (!(bp && bp.complete)) return false;
|
|
93
|
+
return !prev || !prev.complete;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("fires on first tick when batch is already complete (no prev)", () => {
|
|
97
|
+
expect(shouldAutoStop({ complete: true }, undefined)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("fires on transition from incomplete to complete", () => {
|
|
101
|
+
expect(shouldAutoStop({ complete: true }, { complete: false, hasItems: true })).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("does NOT fire on repeated complete ticks", () => {
|
|
105
|
+
expect(shouldAutoStop({ complete: true }, { complete: true, hasItems: true })).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("does NOT fire when batch is not complete", () => {
|
|
109
|
+
expect(shouldAutoStop({ complete: false }, undefined)).toBe(false);
|
|
110
|
+
expect(shouldAutoStop({ complete: false }, { complete: false })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("manually restarted bridges survive repeated complete ticks", () => {
|
|
114
|
+
// Simulates: batch completes → tick 1 stops bridges → operator restarts →
|
|
115
|
+
// tick 2 should NOT stop again because prev.complete is already true
|
|
116
|
+
const tick1 = shouldAutoStop({ complete: true }, undefined);
|
|
117
|
+
expect(tick1).toBe(true); // first transition fires
|
|
118
|
+
|
|
119
|
+
// After tick 1, prev = { complete: true }
|
|
120
|
+
const tick2 = shouldAutoStop({ complete: true }, { complete: true });
|
|
121
|
+
expect(tick2).toBe(false); // no repeated stop
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("auto-stop fires again for a new batch cycle", () => {
|
|
125
|
+
// batch 1 completes → new batch starts → new batch completes
|
|
126
|
+
const batch1Complete = shouldAutoStop({ complete: true }, { complete: false });
|
|
127
|
+
expect(batch1Complete).toBe(true);
|
|
128
|
+
|
|
129
|
+
// new batch is in progress (prev was complete from batch 1)
|
|
130
|
+
// then new batch completes
|
|
131
|
+
const batch2Complete = shouldAutoStop({ complete: true }, { complete: false });
|
|
132
|
+
expect(batch2Complete).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -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);
|