quadwork 1.19.2 → 2.0.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 (117) hide show
  1. package/README.md +19 -35
  2. package/bin/quadwork.js +48 -1118
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +14 -14
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
  10. package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
  11. package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
  12. package/out/_next/static/chunks/{153f.fj8jlvle.js → 0_lyyn..t63bc.js} +1 -1
  13. package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
  14. package/out/_next/static/chunks/0py7102i226n5.js +1 -0
  15. package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
  16. package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
  17. package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
  18. package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
  19. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  20. package/out/_not-found/__next._full.txt +13 -13
  21. package/out/_not-found/__next._head.txt +4 -4
  22. package/out/_not-found/__next._index.txt +8 -8
  23. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  24. package/out/_not-found/__next._not-found.txt +3 -3
  25. package/out/_not-found/__next._tree.txt +2 -2
  26. package/out/_not-found.html +1 -1
  27. package/out/_not-found.txt +13 -13
  28. package/out/app-shell/__next._full.txt +13 -13
  29. package/out/app-shell/__next._head.txt +4 -4
  30. package/out/app-shell/__next._index.txt +8 -8
  31. package/out/app-shell/__next._tree.txt +2 -2
  32. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  33. package/out/app-shell/__next.app-shell.txt +3 -3
  34. package/out/app-shell.html +1 -1
  35. package/out/app-shell.txt +13 -13
  36. package/out/index.html +1 -1
  37. package/out/index.txt +14 -14
  38. package/out/project/_/__next._full.txt +14 -14
  39. package/out/project/_/__next._head.txt +4 -4
  40. package/out/project/_/__next._index.txt +8 -8
  41. package/out/project/_/__next._tree.txt +2 -2
  42. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  43. package/out/project/_/__next.project.$d$id.txt +3 -3
  44. package/out/project/_/__next.project.txt +3 -3
  45. package/out/project/_/queue/__next._full.txt +14 -14
  46. package/out/project/_/queue/__next._head.txt +4 -4
  47. package/out/project/_/queue/__next._index.txt +8 -8
  48. package/out/project/_/queue/__next._tree.txt +2 -2
  49. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  50. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  52. package/out/project/_/queue/__next.project.txt +3 -3
  53. package/out/project/_/queue.html +1 -1
  54. package/out/project/_/queue.txt +14 -14
  55. package/out/project/_.html +1 -1
  56. package/out/project/_.txt +14 -14
  57. package/out/settings/__next._full.txt +14 -14
  58. package/out/settings/__next._head.txt +4 -4
  59. package/out/settings/__next._index.txt +8 -8
  60. package/out/settings/__next._tree.txt +2 -2
  61. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  62. package/out/settings/__next.settings.txt +3 -3
  63. package/out/settings.html +1 -1
  64. package/out/settings.txt +14 -14
  65. package/out/setup/__next._full.txt +14 -14
  66. package/out/setup/__next._head.txt +4 -4
  67. package/out/setup/__next._index.txt +8 -8
  68. package/out/setup/__next._tree.txt +2 -2
  69. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  70. package/out/setup/__next.setup.txt +3 -3
  71. package/out/setup.html +1 -1
  72. package/out/setup.txt +14 -14
  73. package/package.json +4 -2
  74. package/server/ac-restore.js +128 -0
  75. package/server/bridges/discord.js +183 -0
  76. package/server/bridges/telegram.js +210 -0
  77. package/server/config.js +4 -60
  78. package/server/file-chat.js +318 -0
  79. package/server/index.js +173 -1286
  80. package/server/install-agentchattr.js +3 -284
  81. package/server/mcp-chat-shim.js +171 -0
  82. package/server/migrate-ac.js +158 -0
  83. package/server/pty-dispatcher.js +188 -0
  84. package/server/routes.js +149 -1397
  85. package/templates/CLAUDE.md +2 -2
  86. package/templates/OVERNIGHT-QUEUE.md +1 -1
  87. package/templates/seeds/butler.CLAUDE.md +30 -62
  88. package/templates/seeds/dev.AGENTS.md +10 -1
  89. package/templates/seeds/head.AGENTS.md +3 -3
  90. package/templates/seeds/re1.AGENTS.md +3 -3
  91. package/templates/seeds/re2.AGENTS.md +3 -3
  92. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  93. package/bridges/discord/discord_bridge.py +0 -666
  94. package/bridges/discord/requirements.txt +0 -2
  95. package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
  96. package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
  97. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  98. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  99. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  100. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  101. package/server/__tests__/rate-limit-handling.test.js +0 -168
  102. package/server/__tests__/scrub-secrets.test.js +0 -235
  103. package/server/__tests__/v1110-security-qa.test.js +0 -312
  104. package/server/agentchattr-registry.js +0 -188
  105. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  106. package/server/queue-watcher.js +0 -171
  107. package/server/queue-watcher.test.js +0 -64
  108. package/server/routes.batchProgress.test.js +0 -94
  109. package/server/routes.chatWsSend.test.js +0 -161
  110. package/server/routes.discordBridge.test.js +0 -80
  111. package/server/routes.parseActiveBatch.test.js +0 -88
  112. package/server/routes.telegramBridge.test.js +0 -241
  113. package/templates/config.toml +0 -72
  114. package/templates/wrapper.py +0 -70
  115. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
  116. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
  117. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
@@ -1,312 +0,0 @@
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);
@@ -1,188 +0,0 @@
1
- /**
2
- * AgentChattr registration helper (#238).
3
- *
4
- * Talks to a per-project AgentChattr instance's registration API to obtain
5
- * per-agent tokens for MCP auth. Each function takes serverPort explicitly
6
- * so the helper is project-agnostic.
7
- *
8
- * Reference: /Users/cho/Projects/agentchattr/wrapper.py _register_instance.
9
- */
10
-
11
- const DEFAULT_TIMEOUT_MS = 5000;
12
-
13
- // Tokens returned from registerAgent are cached per (port, name) so the
14
- // two-arg deregisterAgent(serverPort, name) form from the #238 contract
15
- // works without the caller having to thread the token through.
16
- const _tokenCache = new Map();
17
-
18
- function fetchWithTimeout(url, opts = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
19
- const ctrl = new AbortController();
20
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
21
- return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(t));
22
- }
23
-
24
- /**
25
- * Poll GET / on the AgentChattr server until it responds 200, or until
26
- * timeoutMs elapses. Returns true on success, false on timeout.
27
- */
28
- async function waitForAgentChattrReady(serverPort, timeoutMs = 10000) {
29
- const deadline = Date.now() + timeoutMs;
30
- while (Date.now() < deadline) {
31
- try {
32
- const r = await fetchWithTimeout(`http://127.0.0.1:${serverPort}/`, {}, 2000);
33
- if (r.ok) return true;
34
- } catch {
35
- // not ready yet
36
- }
37
- await new Promise((res) => setTimeout(res, 250));
38
- }
39
- return false;
40
- }
41
-
42
- /**
43
- * Register an agent with AgentChattr. Returns {name, token, slot} on
44
- * success, null on failure (with registerAgent.lastError populated).
45
- */
46
- async function registerAgent(serverPort, base, label = null, { force = false } = {}) {
47
- registerAgent.lastError = null;
48
- try {
49
- const r = await fetchWithTimeout(
50
- `http://127.0.0.1:${serverPort}/api/register`,
51
- {
52
- method: "POST",
53
- headers: { "Content-Type": "application/json" },
54
- body: JSON.stringify({ base, label, force }),
55
- },
56
- );
57
- if (!r.ok) {
58
- registerAgent.lastError = `register ${base}: HTTP ${r.status}`;
59
- return null;
60
- }
61
- const data = await r.json();
62
- if (!data || !data.name || !data.token) {
63
- registerAgent.lastError = `register ${base}: malformed response`;
64
- return null;
65
- }
66
- _tokenCache.set(`${serverPort}:${data.name}`, data.token);
67
- return { name: data.name, token: data.token, slot: data.slot };
68
- } catch (err) {
69
- registerAgent.lastError = `register ${base}: ${err.message || err}`;
70
- return null;
71
- }
72
- }
73
- registerAgent.lastError = null;
74
-
75
- /**
76
- * Best-effort deregister. Failures are non-fatal (e.g. AgentChattr already
77
- * shut down). Returns true on a 2xx response, false otherwise.
78
- *
79
- * AgentChattr's /api/deregister/{name} requires the agent's own bearer
80
- * token for "family" names (head/dev/re1/re2) — see
81
- * app.py:2123-2135. The token is looked up automatically from the cache
82
- * populated by registerAgent; an explicit token may be passed as a third
83
- * argument to override (e.g. recovering a session).
84
- */
85
- async function deregisterAgent(serverPort, name, token) {
86
- try {
87
- const headers = {};
88
- const tok = token || _tokenCache.get(`${serverPort}:${name}`);
89
- if (tok) headers["Authorization"] = `Bearer ${tok}`;
90
- const r = await fetchWithTimeout(
91
- `http://127.0.0.1:${serverPort}/api/deregister/${encodeURIComponent(name)}`,
92
- { method: "POST", headers },
93
- );
94
- if (r.ok) _tokenCache.delete(`${serverPort}:${name}`);
95
- return r.ok;
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- /**
102
- * Start a per-agent heartbeat that POSTs /api/heartbeat/{name} every 5s
103
- * with bearer auth. AgentChattr considers an agent crashed and removes
104
- * it after ~120s without a heartbeat, so without this every registered
105
- * QuadWork agent silently disappears from the channel one minute after
106
- * registration.
107
- *
108
- * `getName` and `getToken` are read on every tick (either as plain
109
- * values or as zero-arg functions) so the sub-D 409 recovery path can
110
- * swap them in place after re-registration without restarting the
111
- * interval. Pass an `onConflict` callback to handle 409 responses
112
- * (AgentChattr restart wiped the in-memory registry — re-register and
113
- * the next tick will use the new credentials). Other errors are
114
- * swallowed.
115
- *
116
- * Returns an opaque handle suitable for stopHeartbeat.
117
- *
118
- * Reference: /Users/cho/Projects/agentchattr/wrapper.py lines 715-744.
119
- */
120
- function startHeartbeat(serverPort, getName, getToken, { onConflict, intervalMs = 5000 } = {}) {
121
- // Sub-D guard: avoid re-entering onConflict while a previous recovery
122
- // attempt is still in flight. Without this, a slow re-register would
123
- // be triggered once per 5s tick and stack up duplicate registrations.
124
- let recovering = false;
125
- const resolve = (v) => (typeof v === "function" ? v() : v);
126
- const tick = async () => {
127
- const name = resolve(getName);
128
- const token = resolve(getToken);
129
- if (!name) return;
130
- const url = `http://127.0.0.1:${serverPort}/api/heartbeat/${encodeURIComponent(name)}`;
131
- try {
132
- const r = await fetchWithTimeout(
133
- url,
134
- { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {} },
135
- DEFAULT_TIMEOUT_MS,
136
- );
137
- if (r.status === 409 && typeof onConflict === "function" && !recovering) {
138
- recovering = true;
139
- try {
140
- await onConflict();
141
- } catch {
142
- // recovery is best-effort; next tick retries on its own
143
- } finally {
144
- recovering = false;
145
- }
146
- }
147
- } catch {
148
- // swallow — next tick will retry
149
- }
150
- };
151
- // Fire one immediately so a fresh agent is held alive without waiting
152
- // a full interval, then poll on the timer.
153
- tick();
154
- const handle = setInterval(tick, intervalMs);
155
- return handle;
156
- }
157
-
158
- /**
159
- * Stop a heartbeat started by startHeartbeat. Safe to call with null.
160
- */
161
- function stopHeartbeat(handle) {
162
- if (handle) clearInterval(handle);
163
- }
164
-
165
- /**
166
- * Register an agent with retries and exponential backoff.
167
- * Returns {name, token, slot} on success, null after all attempts fail.
168
- * Uses registerAgent internally so registerAgent.lastError is populated.
169
- */
170
- async function registerAgentWithRetry(serverPort, base, label = null, { force = false, attempts = 3, delayMs = 2000 } = {}) {
171
- for (let i = 0; i < attempts; i++) {
172
- const result = await registerAgent(serverPort, base, label, { force });
173
- if (result) return result;
174
- if (i < attempts - 1) {
175
- await new Promise((res) => setTimeout(res, delayMs * Math.pow(2, i)));
176
- }
177
- }
178
- return null;
179
- }
180
-
181
- module.exports = {
182
- waitForAgentChattrReady,
183
- registerAgent,
184
- registerAgentWithRetry,
185
- deregisterAgent,
186
- startHeartbeat,
187
- stopHeartbeat,
188
- };
@@ -1,71 +0,0 @@
1
- // #629: patchCrashTimeout tests. Plain node:assert — run with
2
- // `node server/install-agentchattr.patchCrashTimeout.test.js`.
3
-
4
- const assert = require("node:assert/strict");
5
- const fs = require("fs");
6
- const path = require("path");
7
- const os = require("os");
8
- const { patchCrashTimeout } = require("./install-agentchattr");
9
-
10
- const UNPATCHED_APP_PY = [
11
- "import time",
12
- "",
13
- "# Crash timeout: if a wrapper hasn't heartbeated for 60s,",
14
- "# consider it dead and deregister.",
15
- "_CRASH_TIMEOUT = 15",
16
- "",
17
- "def check_heartbeats():",
18
- " now = time.time()",
19
- " for name, last_seen in list(_heartbeats.items()):",
20
- " if last_seen > 0 and now - last_seen > _CRASH_TIMEOUT:",
21
- ' log.info(f"Crash timeout: deregistering {name} (no heartbeat for {_CRASH_TIMEOUT}s)")',
22
- ].join("\n");
23
-
24
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qw-test-629-"));
25
-
26
- function setup(content) {
27
- const dir = fs.mkdtempSync(path.join(tmpDir, "ac-"));
28
- if (content !== undefined) {
29
- fs.writeFileSync(path.join(dir, "app.py"), content);
30
- }
31
- return dir;
32
- }
33
-
34
- // 1) Patches _CRASH_TIMEOUT from 15 to 120
35
- {
36
- const dir = setup(UNPATCHED_APP_PY);
37
- patchCrashTimeout(dir);
38
- const result = fs.readFileSync(path.join(dir, "app.py"), "utf-8");
39
- assert.ok(result.includes("_CRASH_TIMEOUT = 120"), "timeout patched to 120");
40
- assert.ok(!result.includes("_CRASH_TIMEOUT = 15"), "old value removed");
41
- assert.ok(result.includes("heartbeated for 120s"), "comment updated");
42
- assert.ok(!result.includes("heartbeated for 60s"), "old comment removed");
43
- }
44
-
45
- // 2) Idempotent — already-patched file is untouched
46
- {
47
- const dir = setup(UNPATCHED_APP_PY.replace("_CRASH_TIMEOUT = 15", "_CRASH_TIMEOUT = 120")
48
- .replace("heartbeated for 60s", "heartbeated for 120s"));
49
- const before = fs.readFileSync(path.join(dir, "app.py"), "utf-8");
50
- patchCrashTimeout(dir);
51
- const after = fs.readFileSync(path.join(dir, "app.py"), "utf-8");
52
- assert.equal(before, after, "already-patched file unchanged");
53
- }
54
-
55
- // 3) No app.py — no crash
56
- {
57
- const dir = setup();
58
- patchCrashTimeout(dir);
59
- assert.ok(!fs.existsSync(path.join(dir, "app.py")), "no file created");
60
- }
61
-
62
- // 4) Null/undefined dir — no crash
63
- {
64
- patchCrashTimeout(null);
65
- patchCrashTimeout(undefined);
66
- }
67
-
68
- // Cleanup
69
- fs.rmSync(tmpDir, { recursive: true, force: true });
70
-
71
- console.log("patchCrashTimeout: all tests passed");