quadwork 1.10.0 → 1.11.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 (79) hide show
  1. package/bin/quadwork.js +84 -56
  2. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  3. package/bridges/discord/discord_bridge.py +58 -14
  4. package/out/404.html +1 -1
  5. package/out/__next.__PAGE__.txt +3 -3
  6. package/out/__next._full.txt +13 -13
  7. package/out/__next._head.txt +4 -4
  8. package/out/__next._index.txt +7 -7
  9. package/out/__next._tree.txt +2 -2
  10. package/out/_next/static/chunks/0a4.d381szseh.css +2 -0
  11. package/out/_next/static/chunks/{04ui63kyoqv4t.js → 0gaekhrfy94vz.js} +1 -1
  12. package/out/_next/static/chunks/{0uf3o~m9.vrpj.js → 0hirada7763yr.js} +3 -3
  13. package/out/_next/static/chunks/{16g.ca89g7fib.js → 0n~dq4kpx9xxx.js} +1 -1
  14. package/out/_next/static/chunks/0rxi-m9onh_sa.js +1 -0
  15. package/out/_next/static/chunks/turbopack-0qm-e3ifrz~2u.js +1 -0
  16. package/out/_not-found/__next._full.txt +12 -12
  17. package/out/_not-found/__next._head.txt +4 -4
  18. package/out/_not-found/__next._index.txt +7 -7
  19. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  20. package/out/_not-found/__next._not-found.txt +3 -3
  21. package/out/_not-found/__next._tree.txt +2 -2
  22. package/out/_not-found.html +1 -1
  23. package/out/_not-found.txt +12 -12
  24. package/out/app-shell/__next._full.txt +12 -12
  25. package/out/app-shell/__next._head.txt +4 -4
  26. package/out/app-shell/__next._index.txt +7 -7
  27. package/out/app-shell/__next._tree.txt +2 -2
  28. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  29. package/out/app-shell/__next.app-shell.txt +3 -3
  30. package/out/app-shell.html +1 -1
  31. package/out/app-shell.txt +12 -12
  32. package/out/index.html +1 -1
  33. package/out/index.txt +13 -13
  34. package/out/project/_/__next._full.txt +13 -13
  35. package/out/project/_/__next._head.txt +4 -4
  36. package/out/project/_/__next._index.txt +7 -7
  37. package/out/project/_/__next._tree.txt +2 -2
  38. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  39. package/out/project/_/__next.project.$d$id.txt +3 -3
  40. package/out/project/_/__next.project.txt +3 -3
  41. package/out/project/_/queue/__next._full.txt +13 -13
  42. package/out/project/_/queue/__next._head.txt +4 -4
  43. package/out/project/_/queue/__next._index.txt +7 -7
  44. package/out/project/_/queue/__next._tree.txt +2 -2
  45. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  46. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  47. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  48. package/out/project/_/queue/__next.project.txt +3 -3
  49. package/out/project/_/queue.html +1 -1
  50. package/out/project/_/queue.txt +13 -13
  51. package/out/project/_.html +1 -1
  52. package/out/project/_.txt +13 -13
  53. package/out/settings/__next._full.txt +13 -13
  54. package/out/settings/__next._head.txt +4 -4
  55. package/out/settings/__next._index.txt +7 -7
  56. package/out/settings/__next._tree.txt +2 -2
  57. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  58. package/out/settings/__next.settings.txt +3 -3
  59. package/out/settings.html +1 -1
  60. package/out/settings.txt +13 -13
  61. package/out/setup/__next._full.txt +13 -13
  62. package/out/setup/__next._head.txt +4 -4
  63. package/out/setup/__next._index.txt +7 -7
  64. package/out/setup/__next._tree.txt +2 -2
  65. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  66. package/out/setup/__next.setup.txt +3 -3
  67. package/out/setup.html +1 -1
  68. package/out/setup.txt +13 -13
  69. package/package.json +2 -2
  70. package/server/config.js +29 -5
  71. package/server/index.js +84 -25
  72. package/server/install-agentchattr.js +12 -11
  73. package/server/routes.js +38 -33
  74. package/out/_next/static/chunks/0yt_bs94icoma.js +0 -1
  75. package/out/_next/static/chunks/11r-w4ngz479i.css +0 -2
  76. package/out/_next/static/chunks/turbopack-0lcwh84lrj9gi.js +0 -1
  77. /package/out/_next/static/{mxlP6esPG86fhzv01dzCW → a1_CwwdhUp5-lHCPnFaTw}/_buildManifest.js +0 -0
  78. /package/out/_next/static/{mxlP6esPG86fhzv01dzCW → a1_CwwdhUp5-lHCPnFaTw}/_clientMiddlewareManifest.js +0 -0
  79. /package/out/_next/static/{mxlP6esPG86fhzv01dzCW → a1_CwwdhUp5-lHCPnFaTw}/_ssgManifest.js +0 -0
package/server/index.js CHANGED
@@ -6,7 +6,7 @@ const os = require("os");
6
6
  const { WebSocketServer } = require("ws");
7
7
  const pty = require("node-pty");
8
8
  const { spawn } = require("child_process");
9
- const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, resolveChattrSpawn, syncChattrToken, CONFIG_PATH } = require("./config");
9
+ const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, resolveChattrSpawn, syncChattrToken, CONFIG_PATH, ensureSecureDir, writeSecureFile, writeConfig } = require("./config");
10
10
  const routes = require("./routes");
11
11
  const {
12
12
  patchAgentchattrConfigForDiscordBridge,
@@ -43,11 +43,11 @@ app.get("/api/health", (_req, res) => {
43
43
 
44
44
  // --- CLI status detection ---
45
45
 
46
- const { execSync } = require("child_process");
46
+ const { execFileSync } = require("child_process");
47
47
 
48
48
  function isCliInstalled(cmd) {
49
49
  try {
50
- execSync(`which ${cmd}`, { encoding: "utf-8", stdio: "pipe" });
50
+ execFileSync("which", [cmd], { encoding: "utf-8", stdio: "pipe" });
51
51
  return true;
52
52
  } catch {
53
53
  return false;
@@ -264,8 +264,8 @@ function readPersistedAgentToken(projectId, agentId) {
264
264
  function writePersistedAgentToken(projectId, agentId, token) {
265
265
  try {
266
266
  const configDir = path.join(os.homedir(), ".quadwork", projectId);
267
- if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
268
- fs.writeFileSync(_agentTokenPath(projectId, agentId), token, { mode: 0o600 });
267
+ ensureSecureDir(configDir);
268
+ writeSecureFile(_agentTokenPath(projectId, agentId), token);
269
269
  } catch {
270
270
  // non-fatal — stale-slot reclaim will degrade but registration still works
271
271
  }
@@ -278,7 +278,7 @@ function clearPersistedAgentToken(projectId, agentId) {
278
278
  function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
279
279
  const os = require("os");
280
280
  const configDir = path.join(os.homedir(), ".quadwork", projectId);
281
- if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
281
+ ensureSecureDir(configDir);
282
282
  const filePath = path.join(configDir, `mcp-${agentId}.json`);
283
283
  const url = `http://127.0.0.1:${mcpHttpPort}/mcp`;
284
284
  const config = {
@@ -290,7 +290,7 @@ function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
290
290
  },
291
291
  },
292
292
  };
293
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
293
+ writeSecureFile(filePath, JSON.stringify(config, null, 2));
294
294
  return filePath;
295
295
  }
296
296
 
@@ -431,7 +431,7 @@ function buildAgentEnv(projectId, agentId) {
431
431
  if (cliBase === "gemini" && project.mcp_http_port) {
432
432
  const os = require("os");
433
433
  const configDir = path.join(os.homedir(), ".quadwork", projectId);
434
- if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
434
+ ensureSecureDir(configDir);
435
435
  const settingsPath = path.join(configDir, `mcp-${agentId}-settings.json`);
436
436
  const url = `http://127.0.0.1:${project.mcp_http_port}/mcp`;
437
437
  const settings = {
@@ -443,7 +443,7 @@ function buildAgentEnv(projectId, agentId) {
443
443
  },
444
444
  },
445
445
  };
446
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
446
+ writeSecureFile(settingsPath, JSON.stringify(settings, null, 2));
447
447
  env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = settingsPath;
448
448
  }
449
449
 
@@ -555,6 +555,7 @@ async function spawnAgentPty(project, agent) {
555
555
  queueWatcherHandle: null,
556
556
  // #418: ring buffer of recent PTY output so reconnecting WS
557
557
  // clients see the terminal state instead of a blank panel.
558
+ // #538: scrollback is scrubbed of likely secrets before replay.
558
559
  scrollback: Buffer.alloc(0),
559
560
  };
560
561
  agentSessions.set(key, session);
@@ -730,7 +731,7 @@ const HISTORY_SNAPSHOT_LIMIT = 5;
730
731
  async function snapshotProjectHistory(projectId) {
731
732
  try {
732
733
  const snapDir = path.join(require("os").homedir(), ".quadwork", projectId, "history-snapshots");
733
- if (!fs.existsSync(snapDir)) fs.mkdirSync(snapDir, { recursive: true });
734
+ ensureSecureDir(snapDir);
734
735
  const res = await fetch(`http://127.0.0.1:${PORT}/api/project-history?project=${encodeURIComponent(projectId)}`, {
735
736
  signal: AbortSignal.timeout(30000),
736
737
  });
@@ -809,7 +810,7 @@ async function handleAgentChattr(req, res) {
809
810
  try {
810
811
  let content = fs.readFileSync(projectConfigToml, "utf-8");
811
812
  content = content.replace(/^port = \d+/m, `port = ${chattrPort}`);
812
- fs.writeFileSync(projectConfigToml, content);
813
+ writeSecureFile(projectConfigToml, content);
813
814
  } catch {}
814
815
  }
815
816
 
@@ -866,7 +867,7 @@ async function handleAgentChattr(req, res) {
866
867
  // lose their tracked reference when the Node process recycles).
867
868
  function killProcessOnPort(port) {
868
869
  try {
869
- const pids = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
870
+ const pids = execFileSync("lsof", ["-ti", `TCP:${port}`, "-sTCP:LISTEN"], {
870
871
  encoding: "utf-8",
871
872
  timeout: 5000,
872
873
  stdio: ["pipe", "pipe", "pipe"],
@@ -889,7 +890,7 @@ async function handleAgentChattr(req, res) {
889
890
  return new Promise((resolve) => {
890
891
  function check() {
891
892
  try {
892
- execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
893
+ execFileSync("lsof", ["-ti", `TCP:${port}`, "-sTCP:LISTEN"], {
893
894
  encoding: "utf-8",
894
895
  timeout: 2000,
895
896
  stdio: ["pipe", "pipe", "pipe"],
@@ -1045,8 +1046,6 @@ async function handleAgentChattr(req, res) {
1045
1046
  return res.status(400).json({ ok: false, error: "AgentChattr not installed at " + (acDir || "unknown") });
1046
1047
  }
1047
1048
  try {
1048
- const { execSync } = require("child_process");
1049
-
1050
1049
  // Stop running process before pulling. Snapshot first so a
1051
1050
  // botched git pull can still be rolled back from disk.
1052
1051
  // #424 / quadwork#304: best-effort.
@@ -1070,14 +1069,14 @@ async function handleAgentChattr(req, res) {
1070
1069
  await waitForPortFree(chattrPort, 3000);
1071
1070
  }
1072
1071
 
1073
- const pullResult = execSync("git pull 2>&1", { cwd: acDir, encoding: "utf-8", timeout: 30000 }).trim();
1072
+ const pullResult = execFileSync("git", ["pull"], { cwd: acDir, encoding: "utf-8", timeout: 30000, stdio: "pipe" }).trim();
1074
1073
  // #388: re-apply sender-overflow CSS patch after git pull
1075
1074
  patchAgentchattrCss(acDir);
1076
1075
  const venvPython = path.join(acDir, ".venv", "bin", "python");
1077
1076
  let pipResult = "";
1078
1077
  const reqFile = path.join(acDir, "requirements.txt");
1079
1078
  if (fs.existsSync(venvPython) && fs.existsSync(reqFile)) {
1080
- pipResult = execSync(`"${venvPython}" -m pip install -r requirements.txt 2>&1`, { cwd: acDir, encoding: "utf-8", timeout: 120000 }).trim();
1079
+ pipResult = execFileSync(venvPython, ["-m", "pip", "install", "-r", "requirements.txt"], { cwd: acDir, encoding: "utf-8", timeout: 120000, stdio: "pipe" }).trim();
1081
1080
  }
1082
1081
 
1083
1082
  // Restart if it was running before the update
@@ -1533,7 +1532,7 @@ app.post("/api/triggers/:project/start", (req, res) => {
1533
1532
  if (typeof message === "string" && message.length > 0) entry.trigger_message = message;
1534
1533
  if (Number.isFinite(interval) && interval > 0) entry.trigger_interval_min = interval;
1535
1534
  if (Number.isFinite(duration) && duration >= 0) entry.trigger_duration_min = duration;
1536
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
1535
+ writeConfig(cfg);
1537
1536
  }
1538
1537
  } catch (e) { /* non-fatal — timer still runs with its in-memory values */ }
1539
1538
 
@@ -1624,7 +1623,7 @@ app.put("/api/queue", express.json({ limit: "512kb" }), (req, res) => {
1624
1623
  if (content === null) return res.status(400).json({ error: "Missing content" });
1625
1624
  const p = queuePathFor(projectId);
1626
1625
  try {
1627
- fs.mkdirSync(path.dirname(p), { recursive: true });
1626
+ ensureSecureDir(path.dirname(p));
1628
1627
  fs.writeFileSync(p, content);
1629
1628
  return res.json({ ok: true });
1630
1629
  } catch (e) { return res.status(500).json({ error: e.message }); }
@@ -1642,7 +1641,7 @@ app.post("/api/queue", (req, res) => {
1642
1641
  let content = fs.readFileSync(tpl, "utf-8");
1643
1642
  content = content.replace(/\{\{project_name\}\}/g, project.name || projectId);
1644
1643
  content = content.replace(/\{\{repo\}\}/g, project.repo || "");
1645
- fs.mkdirSync(path.dirname(p), { recursive: true });
1644
+ ensureSecureDir(path.dirname(p));
1646
1645
  fs.writeFileSync(p, content);
1647
1646
  return res.json({ ok: true, existed: false });
1648
1647
  } catch (e) { return res.status(500).json({ error: e.message }); }
@@ -1719,6 +1718,56 @@ app.use((req, res, next) => {
1719
1718
  }
1720
1719
  });
1721
1720
 
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
+ }
1770
+
1722
1771
  // --- WebSocket + PTY ---
1723
1772
  // WS connects to an existing PTY session (started via lifecycle API)
1724
1773
  // or spawns a new one if none exists.
@@ -1760,10 +1809,10 @@ wss.on("connection", async (ws, req) => {
1760
1809
  // {"type":"replay"} to avoid the timing race where eager replay
1761
1810
  // arrived before the client's onmessage handler was registered.
1762
1811
 
1763
- // PTY → client
1812
+ // PTY → client (#538: scrub secrets from live output)
1764
1813
  const dataHandler = session.term.onData((data) => {
1765
1814
  if (ws.readyState === ws.OPEN) {
1766
- ws.send(data);
1815
+ ws.send(scrubSecrets(data));
1767
1816
  }
1768
1817
  });
1769
1818
 
@@ -1773,8 +1822,17 @@ wss.on("connection", async (ws, req) => {
1773
1822
  const str = msg.toString();
1774
1823
  try {
1775
1824
  const parsed = JSON.parse(str);
1776
- if (parsed.type === "resize" && parsed.cols && parsed.rows) {
1777
- session.term.resize(parsed.cols, parsed.rows);
1825
+ if (parsed.type === "resize") {
1826
+ // #541: strict numeric type check and bounds validation before
1827
+ // passing to PTY. The dashboard client (TerminalPanel.tsx) sends
1828
+ // xterm.js cols/rows which are always numbers. Reject anything
1829
+ // else at the boundary.
1830
+ if (typeof parsed.cols === "number" && typeof parsed.rows === "number" &&
1831
+ Number.isFinite(parsed.cols) && Number.isFinite(parsed.rows) &&
1832
+ parsed.cols >= 1 && parsed.cols <= 500 &&
1833
+ parsed.rows >= 1 && parsed.rows <= 500) {
1834
+ session.term.resize(parsed.cols, parsed.rows);
1835
+ }
1778
1836
  return;
1779
1837
  }
1780
1838
  // #461: client requests scrollback replay after xterm is fully
@@ -1784,7 +1842,8 @@ wss.on("connection", async (ws, req) => {
1784
1842
  // synthetic status line so the terminal isn't completely blank.
1785
1843
  if (parsed.type === "replay") {
1786
1844
  if (session.scrollback && session.scrollback.length > 0) {
1787
- ws.send(session.scrollback);
1845
+ // #538: scrub likely secrets before replaying accumulated output.
1846
+ ws.send(scrubScrollback(session.scrollback));
1788
1847
  } else {
1789
1848
  ws.send(`\x1b[2m[agent online — waiting for input]\x1b[0m\r\n`);
1790
1849
  }
@@ -14,7 +14,7 @@
14
14
  // Self-contained — depends only on Node built-ins so it's safe to require
15
15
  // from anywhere in the project (CLI bin, server routes, future tests).
16
16
 
17
- const { execSync } = require("child_process");
17
+ const { execFileSync } = require("child_process");
18
18
  const fs = require("fs");
19
19
  const path = require("path");
20
20
 
@@ -28,8 +28,8 @@ const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
28
28
  const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
29
29
  const INSTALL_LOCK_POLL_MS = 500;
30
30
 
31
- function _run(cmd, opts = {}) {
32
- try { return execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts }).trim(); }
31
+ function _run(cmd, args = [], opts = {}) {
32
+ try { return execFileSync(cmd, args, { encoding: "utf-8", stdio: "pipe", ...opts }).trim(); }
33
33
  catch { return null; }
34
34
  }
35
35
 
@@ -102,14 +102,15 @@ function installAgentChattr(dir) {
102
102
 
103
103
  // --- Per-target lock ---
104
104
  const lockFile = `${dir}.install.lock`;
105
- try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
105
+ try { fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 }); }
106
106
  catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
107
107
 
108
108
  let acquired = false;
109
109
  const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
110
110
  while (!acquired) {
111
111
  try {
112
- fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
112
+ fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { mode: 0o600, flag: "wx" });
113
+ try { fs.chmodSync(lockFile, 0o600); } catch {}
113
114
  acquired = true;
114
115
  } catch (e) {
115
116
  if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
@@ -129,7 +130,7 @@ function installAgentChattr(dir) {
129
130
  const info = _readLock(lockFile) || { pid: "?", ts: 0 };
130
131
  return setError(`Another install is in progress at ${dir} (pid ${info.pid}); timed out after ${INSTALL_LOCK_WAIT_TOTAL_MS}ms. Re-run after it finishes, or remove ${lockFile} if stale.`);
131
132
  }
132
- try { execSync(`sleep ${INSTALL_LOCK_POLL_MS / 1000}`); }
133
+ try { execFileSync("sleep", [String(INSTALL_LOCK_POLL_MS / 1000)], { stdio: "pipe" }); }
133
134
  catch { /* sleep interrupted; loop will recheck */ }
134
135
  }
135
136
  }
@@ -158,7 +159,7 @@ function _installAgentChattrLocked(dir, setError) {
158
159
  try { fs.rmSync(dir, { recursive: true, force: true }); }
159
160
  catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
160
161
  } else if (fs.existsSync(path.join(dir, ".git"))) {
161
- const remote = _run(`git -C "${dir}" remote get-url origin 2>/dev/null`);
162
+ const remote = _run("git", ["-C", dir, "remote", "get-url", "origin"]);
162
163
  if (remote && remote.includes("agentchattr")) {
163
164
  try { fs.rmSync(dir, { recursive: true, force: true }); }
164
165
  catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
@@ -169,16 +170,16 @@ function _installAgentChattrLocked(dir, setError) {
169
170
  return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
170
171
  }
171
172
  }
172
- try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
173
+ try { fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 }); }
173
174
  catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
174
- const cloneResult = _run(`git clone "${AGENTCHATTR_REPO}" "${dir}" 2>&1`, { timeout: 60000 });
175
+ const cloneResult = _run("git", ["clone", AGENTCHATTR_REPO, dir], { timeout: 60000 });
175
176
  if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
176
177
  if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
177
178
  }
178
179
 
179
180
  // 2. Create venv if missing.
180
181
  if (!fs.existsSync(venvPython)) {
181
- const venvResult = _run(`python3 -m venv "${path.join(dir, ".venv")}" 2>&1`, { timeout: 60000 });
182
+ const venvResult = _run("python3", ["-m", "venv", path.join(dir, ".venv")], { timeout: 60000 });
182
183
  if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
183
184
  if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
184
185
  venvJustCreated = true;
@@ -188,7 +189,7 @@ function _installAgentChattrLocked(dir, setError) {
188
189
  if (venvJustCreated) {
189
190
  const reqFile = path.join(dir, "requirements.txt");
190
191
  if (fs.existsSync(reqFile)) {
191
- const pipResult = _run(`"${venvPython}" -m pip install -r "${reqFile}" 2>&1`, { timeout: 120000 });
192
+ const pipResult = _run(venvPython, ["-m", "pip", "install", "-r", reqFile], { timeout: 120000 });
192
193
  if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
193
194
  }
194
195
  }
package/server/routes.js CHANGED
@@ -36,8 +36,8 @@ function readConfigFile() {
36
36
 
37
37
  function writeConfigFile(cfg) {
38
38
  const dir = path.dirname(CONFIG_PATH);
39
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
40
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
39
+ ensureSecureDir(dir);
40
+ writeConfig(cfg);
41
41
  }
42
42
 
43
43
  // ─── Config ────────────────────────────────────────────────────────────────
@@ -66,8 +66,8 @@ router.put("/api/config", (req, res) => {
66
66
  try {
67
67
  const body = req.body;
68
68
  const dir = path.dirname(CONFIG_PATH);
69
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
70
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(body, null, 2));
69
+ ensureSecureDir(dir);
70
+ writeConfig(body);
71
71
  // Trigger sync is handled internally since we're in the same process now
72
72
  if (typeof req.app.get("syncTriggers") === "function") {
73
73
  req.app.get("syncTriggers")();
@@ -80,7 +80,7 @@ router.put("/api/config", (req, res) => {
80
80
 
81
81
  // ─── Chat (AgentChattr proxy) ──────────────────────────────────────────────
82
82
 
83
- const { resolveProjectChattr, sanitizeOperatorName } = require("./config");
83
+ const { resolveProjectChattr, sanitizeOperatorName, ensureSecureDir, writeSecureFile, writeConfig } = require("./config");
84
84
  const { installAgentChattr, findAgentChattr } = require("./install-agentchattr");
85
85
 
86
86
  /**
@@ -96,7 +96,7 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
96
96
  if (fs.existsSync(queuePath)) return;
97
97
  const tpl = path.join(TEMPLATES_DIR, "OVERNIGHT-QUEUE.md");
98
98
  if (!fs.existsSync(tpl)) return;
99
- fs.mkdirSync(path.dirname(queuePath), { recursive: true });
99
+ ensureSecureDir(path.dirname(queuePath));
100
100
  let content = fs.readFileSync(tpl, "utf-8");
101
101
  content = content.replace(/\{\{project_name\}\}/g, projectName || projectId || "");
102
102
  content = content.replace(/\{\{repo\}\}/g, repo || "");
@@ -404,7 +404,7 @@ router.put("/api/loop-guard", async (req, res) => {
404
404
  const trailing = content.endsWith("\n") ? "" : "\n";
405
405
  content += `${trailing}\n[routing]\ndefault = "none"\nmax_agent_hops = ${value}\n`;
406
406
  }
407
- fs.writeFileSync(tomlPath, content);
407
+ writeSecureFile(tomlPath, content);
408
408
  } catch (err) {
409
409
  return res.status(500).json({ error: "Failed to write config.toml", detail: err.message });
410
410
  }
@@ -838,7 +838,7 @@ router.post("/api/activity/log", (req, res) => {
838
838
  const row = { agent, start, end: ts, duration_ms: Math.max(0, ts - start) };
839
839
  try {
840
840
  const p = activityLogPath(project);
841
- fs.mkdirSync(path.dirname(p), { recursive: true });
841
+ ensureSecureDir(path.dirname(p));
842
842
  fs.appendFileSync(p, JSON.stringify(row) + "\n");
843
843
  // Invalidate the stats cache so the next read sees the new row.
844
844
  _activityStatsCache.ts = 0;
@@ -1029,7 +1029,7 @@ const uploadStorage = multer.diskStorage({
1029
1029
  const projectId = req.query.project || "";
1030
1030
  if (!projectId || /[/\\]/.test(projectId)) return cb(new Error("Invalid project"));
1031
1031
  const dir = path.join(CONFIG_DIR, projectId, "uploads");
1032
- fs.mkdirSync(dir, { recursive: true });
1032
+ ensureSecureDir(dir);
1033
1033
  cb(null, dir);
1034
1034
  },
1035
1035
  filename: (_req, file, cb) => {
@@ -1340,7 +1340,7 @@ function readBatchSnapshot(projectId) {
1340
1340
  function writeBatchSnapshot(projectId, snapshot) {
1341
1341
  try {
1342
1342
  const p = batchSnapshotPath(projectId);
1343
- fs.mkdirSync(path.dirname(p), { recursive: true });
1343
+ ensureSecureDir(path.dirname(p));
1344
1344
  fs.writeFileSync(p, JSON.stringify(snapshot));
1345
1345
  } catch {
1346
1346
  // Non-fatal — panel still works from the live parse.
@@ -1850,8 +1850,8 @@ router.post("/api/setup/save-token", (req, res) => {
1850
1850
  if (!token) return res.status(400).json({ error: "Missing token" });
1851
1851
  const tokenPath = path.join(os.homedir(), ".quadwork", "reviewer-token");
1852
1852
  const dir = path.dirname(tokenPath);
1853
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1854
- fs.writeFileSync(tokenPath, token.trim() + "\n", { mode: 0o600 });
1853
+ ensureSecureDir(dir);
1854
+ writeSecureFile(tokenPath, token.trim() + "\n");
1855
1855
  try { fs.chmodSync(tokenPath, 0o600); } catch {}
1856
1856
  res.json({ ok: true, path: tokenPath });
1857
1857
  });
@@ -1890,7 +1890,7 @@ router.post("/api/setup", (req, res) => {
1890
1890
  const workingDir = body.workingDir;
1891
1891
  if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
1892
1892
  if (!fs.existsSync(path.join(workingDir, ".git"))) {
1893
- if (!fs.existsSync(workingDir)) fs.mkdirSync(workingDir, { recursive: true });
1893
+ if (!fs.existsSync(workingDir)) ensureSecureDir(workingDir);
1894
1894
  if (!REPO_RE.test(body.repo)) return res.json({ ok: false, error: "Invalid repo" });
1895
1895
  const clone = exec("gh", ["repo", "clone", body.repo, workingDir]);
1896
1896
  if (!clone.ok) return res.json({ ok: false, error: `Clone failed: ${clone.output}` });
@@ -2026,7 +2026,7 @@ router.post("/api/setup", (req, res) => {
2026
2026
  }
2027
2027
  }
2028
2028
  const dataDir = path.join(projectConfigDir, "data");
2029
- fs.mkdirSync(dataDir, { recursive: true });
2029
+ ensureSecureDir(dataDir);
2030
2030
  const tomlPath = path.join(projectConfigDir, "config.toml");
2031
2031
 
2032
2032
  // Resolve per-project ports: prefer explicit body params (from setup wizard),
@@ -2067,7 +2067,7 @@ router.post("/api/setup", (req, res) => {
2067
2067
  // operator to type /continue. AC clamps to [1, 50] internally.
2068
2068
  content += `[routing]\ndefault = "none"\nmax_agent_hops = 30\n\n`;
2069
2069
  content += `[mcp]\nhttp_port = ${mcp_http}\nsse_port = ${mcp_sse}\n`;
2070
- fs.writeFileSync(tomlPath, content);
2070
+ writeSecureFile(tomlPath, content);
2071
2071
 
2072
2072
  // Restart this project's AgentChattr instance (not global)
2073
2073
  try {
@@ -2158,8 +2158,8 @@ router.post("/api/setup", (req, res) => {
2158
2158
  agentchattr_dir: perProjectDir,
2159
2159
  });
2160
2160
  const dir = path.dirname(CONFIG_PATH);
2161
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2162
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
2161
+ ensureSecureDir(dir);
2162
+ writeConfig(cfg);
2163
2163
 
2164
2164
  // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
2165
2165
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
@@ -2315,7 +2315,7 @@ router.post("/api/rename", (req, res) => {
2315
2315
  const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
2316
2316
  // #444: pin agentchattr-telegram to a known commit (same pattern as
2317
2317
  // AGENTCHATTR_PIN in bin/quadwork.js for bcurts/agentchattr).
2318
- const AGENTCHATTR_TELEGRAM_PIN = "03753c5e4f4497fb7a4a4da639faf31a61d9a4ac";
2318
+ const AGENTCHATTR_TELEGRAM_PIN = "045ee18f6d5dbcd0bd45d5ab29f06e2a27382aaf";
2319
2319
 
2320
2320
  function telegramPidFile(projectId) {
2321
2321
  return path.join(CONFIG_DIR, `tg-bridge-${projectId}.pid`);
@@ -2364,7 +2364,8 @@ function buildTelegramBridgeToml(tg, projectId) {
2364
2364
  `bot_token = "${tg.bot_token}"\n` +
2365
2365
  `chat_id = "${tg.chat_id}"\n` +
2366
2366
  `agentchattr_url = "${tg.agentchattr_url}"\n` +
2367
- `cursor_file = "${cursorFile}"\n`
2367
+ `cursor_file = "${cursorFile}"\n` +
2368
+ `project_id = "${projectId}"\n`
2368
2369
  );
2369
2370
  }
2370
2371
 
@@ -2494,8 +2495,7 @@ function writeEnvToken(key, value) {
2494
2495
  const line = `${key}=${value}`;
2495
2496
  if (regex.test(content)) content = content.replace(regex, line);
2496
2497
  else content = content.trimEnd() + (content ? "\n" : "") + line + "\n";
2497
- fs.writeFileSync(ENV_PATH, content, { mode: 0o600 });
2498
- fs.chmodSync(ENV_PATH, 0o600);
2498
+ writeSecureFile(ENV_PATH, content);
2499
2499
  }
2500
2500
 
2501
2501
  function resolveToken(value) {
@@ -2557,7 +2557,7 @@ router.get("/api/telegram", async (req, res) => {
2557
2557
  if (data && data.ok && data.result && typeof data.result.username === "string") {
2558
2558
  botUsername = data.result.username;
2559
2559
  project.telegram.bot_username = botUsername;
2560
- try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); } catch {}
2560
+ try { writeConfig(cfg); } catch {}
2561
2561
  }
2562
2562
  }
2563
2563
  } catch { /* non-fatal — widget will just show no username */ }
@@ -2717,8 +2717,7 @@ router.post("/api/telegram", async (req, res) => {
2717
2717
  // #383 Bug 2: write agentchattr_url inside [telegram]; the
2718
2718
  // bridge's load_config only reads from that section.
2719
2719
  const tomlContent = buildTelegramBridgeToml(tg, projectId);
2720
- fs.writeFileSync(tomlPath, tomlContent, { mode: 0o600 });
2721
- fs.chmodSync(tomlPath, 0o600);
2720
+ writeSecureFile(tomlPath, tomlContent);
2722
2721
  // #353: pre-flight import check so a fresh install with no
2723
2722
  // `requests` module produces a readable error instead of the
2724
2723
  // Start → Running → Stopped flicker that the v1 code path
@@ -2830,6 +2829,9 @@ router.post("/api/telegram", async (req, res) => {
2830
2829
  if (pid) process.kill(pid, "SIGTERM");
2831
2830
  fs.unlinkSync(pf);
2832
2831
  }
2832
+ // #522: clear bridge log so last_error doesn't show stale
2833
+ // connection-refused messages after an intentional stop.
2834
+ try { fs.writeFileSync(telegramBridgeLog(projectId), ""); } catch {}
2833
2835
  return res.json({ ok: true, running: false });
2834
2836
  } catch (err) {
2835
2837
  return res.json({ ok: false, error: err.message || "Stop failed" });
@@ -2848,7 +2850,7 @@ router.post("/api/telegram", async (req, res) => {
2848
2850
  const project = cfg.projects?.find((p) => p.id === projectId);
2849
2851
  if (project?.telegram) {
2850
2852
  project.telegram.bot_token = `env:${envKey}`;
2851
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
2853
+ writeConfig(cfg);
2852
2854
  }
2853
2855
  } catch {}
2854
2856
  return res.json({ ok: true, env_key: envKey });
@@ -2881,7 +2883,7 @@ router.post("/api/telegram", async (req, res) => {
2881
2883
  // will re-fetch it from Telegram's getMe for the new token.
2882
2884
  bot_username: "",
2883
2885
  };
2884
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
2886
+ writeConfig(cfg);
2885
2887
  return res.json({ ok: true, env_key: envKey });
2886
2888
  } catch (err) {
2887
2889
  return res.json({ ok: false, error: err.message || "Config write failed" });
@@ -2923,7 +2925,8 @@ function buildDiscordBridgeToml(dc, projectId) {
2923
2925
  `bot_token = "${dc.bot_token}"\n` +
2924
2926
  `channel_id = "${dc.channel_id}"\n` +
2925
2927
  `agentchattr_url = "${dc.agentchattr_url}"\n` +
2926
- `cursor_file = "${cursorFile}"\n`
2928
+ `cursor_file = "${cursorFile}"\n` +
2929
+ `project_id = "${projectId}"\n`
2927
2930
  );
2928
2931
  }
2929
2932
 
@@ -3025,7 +3028,7 @@ router.get("/api/discord", async (req, res) => {
3025
3028
  if (r.ok && data.username) {
3026
3029
  botUsername = data.username;
3027
3030
  project.discord.bot_username = botUsername;
3028
- try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); } catch {}
3031
+ try { writeConfig(cfg); } catch {}
3029
3032
  }
3030
3033
  }
3031
3034
  } catch { /* non-fatal — widget will just show no username */ }
@@ -3083,7 +3086,7 @@ router.post("/api/discord", async (req, res) => {
3083
3086
  // #506: always copy bundled bridge files (not just on first install)
3084
3087
  // so re-installing after a QuadWork upgrade refreshes the script.
3085
3088
  if (!fs.existsSync(DISCORD_BRIDGE_DIR)) {
3086
- fs.mkdirSync(DISCORD_BRIDGE_DIR, { recursive: true });
3089
+ ensureSecureDir(DISCORD_BRIDGE_DIR);
3087
3090
  }
3088
3091
  fs.cpSync(
3089
3092
  path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
@@ -3160,8 +3163,7 @@ router.post("/api/discord", async (req, res) => {
3160
3163
  if (!dc || !dc.bot_token || !dc.channel_id) return res.json({ ok: false, error: "Save bot_token and channel_id in project settings first." });
3161
3164
  const tomlPath = discordConfigToml(projectId);
3162
3165
  const tomlContent = buildDiscordBridgeToml(dc, projectId);
3163
- fs.writeFileSync(tomlPath, tomlContent, { mode: 0o600 });
3164
- fs.chmodSync(tomlPath, 0o600);
3166
+ writeSecureFile(tomlPath, tomlContent);
3165
3167
  const depCheck = checkDiscordBridgePythonDeps(venvPython);
3166
3168
  if (!depCheck.ok) {
3167
3169
  const msg =
@@ -3238,6 +3240,9 @@ router.post("/api/discord", async (req, res) => {
3238
3240
  if (pid) process.kill(pid, "SIGTERM");
3239
3241
  fs.unlinkSync(pf);
3240
3242
  }
3243
+ // #522: clear bridge log so last_error doesn't show stale
3244
+ // connection-refused messages after an intentional stop.
3245
+ try { fs.writeFileSync(discordBridgeLog(projectId), ""); } catch {}
3241
3246
  return res.json({ ok: true, running: false });
3242
3247
  } catch (err) {
3243
3248
  return res.json({ ok: false, error: err.message || "Stop failed" });
@@ -3265,7 +3270,7 @@ router.post("/api/discord", async (req, res) => {
3265
3270
  channel_id,
3266
3271
  bot_username: "",
3267
3272
  };
3268
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
3273
+ writeConfig(cfg);
3269
3274
  return res.json({ ok: true, env_key: envKey });
3270
3275
  } catch (err) {
3271
3276
  return res.json({ ok: false, error: err.message || "Config write failed" });
@@ -3337,7 +3342,7 @@ router.put("/api/project/:projectId/agent-models/:agentId", (req, res) => {
3337
3342
  else a.reasoning_effort = reasoning;
3338
3343
  }
3339
3344
  project.agents[agentId] = a;
3340
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
3345
+ writeConfig(cfg);
3341
3346
  return res.json({ ok: true, agent: { agent_id: agentId, model: a.model || "", reasoning_effort: a.reasoning_effort || "" } });
3342
3347
  } catch (err) {
3343
3348
  return res.json({ ok: false, error: err.message || "write failed" });