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
package/server/config.js
CHANGED
|
@@ -74,7 +74,7 @@ function migrateAgentKeys(config) {
|
|
|
74
74
|
}
|
|
75
75
|
if (changed) {
|
|
76
76
|
try {
|
|
77
|
-
|
|
77
|
+
writeSecureFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
78
78
|
} catch {}
|
|
79
79
|
}
|
|
80
80
|
return config;
|
|
@@ -89,9 +89,9 @@ function readConfig() {
|
|
|
89
89
|
// Config file doesn't exist — create default
|
|
90
90
|
const dir = path.dirname(CONFIG_PATH);
|
|
91
91
|
if (!fs.existsSync(dir)) {
|
|
92
|
-
|
|
92
|
+
ensureSecureDir(dir);
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
writeSecureFile(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
95
95
|
return { ...DEFAULT_CONFIG };
|
|
96
96
|
}
|
|
97
97
|
throw new Error(`Cannot read config at ${CONFIG_PATH}: ${err.message}`);
|
|
@@ -198,10 +198,34 @@ async function syncChattrToken(projectId) {
|
|
|
198
198
|
const realToken = match[1];
|
|
199
199
|
if (project.agentchattr_token !== realToken) {
|
|
200
200
|
project.agentchattr_token = realToken;
|
|
201
|
-
|
|
201
|
+
writeSecureFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
} catch {}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
// --- #540: Secure file/directory helpers ---
|
|
208
|
+
// All paths under ~/.quadwork/ may contain secrets (tokens, configs,
|
|
209
|
+
// chat exports). Use these helpers instead of raw fs calls to ensure
|
|
210
|
+
// restrictive permissions on multi-user systems.
|
|
211
|
+
|
|
212
|
+
/** Create a directory with 0o700 (owner-only). Hardens existing dirs too. */
|
|
213
|
+
function ensureSecureDir(dir) {
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
215
|
+
// mkdirSync mode only applies on creation — chmod existing dirs.
|
|
216
|
+
try { fs.chmodSync(dir, 0o700); } catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Write a file with 0o600 (owner-only read/write). Hardens existing files too. */
|
|
220
|
+
function writeSecureFile(filePath, data, extraOpts = {}) {
|
|
221
|
+
fs.writeFileSync(filePath, data, { mode: 0o600, ...extraOpts });
|
|
222
|
+
// writeFileSync mode only applies on creation — chmod existing files.
|
|
223
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Write config.json atomically with 0o600 permissions. */
|
|
227
|
+
function writeConfig(cfg) {
|
|
228
|
+
writeSecureFile(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, resolveChattrSpawn, syncChattrToken, sanitizeOperatorName, CONFIG_PATH, ensureSecureDir, writeSecureFile, writeConfig };
|
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 {
|
|
46
|
+
const { execFileSync } = require("child_process");
|
|
47
47
|
|
|
48
48
|
function isCliInstalled(cmd) {
|
|
49
49
|
try {
|
|
50
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -1403,7 +1402,12 @@ async function sendTriggerMessage(projectId) {
|
|
|
1403
1402
|
console.log(`[auto-trigger] ${projectId}: caffeinate auto-stopped (no active triggers remain)`);
|
|
1404
1403
|
}
|
|
1405
1404
|
// #518: also stop bridges when batch completes
|
|
1406
|
-
|
|
1405
|
+
// #542: transition guard — only stop if not already stopped for this completion
|
|
1406
|
+
const prev = _bridgeBatchPrev.get(projectId);
|
|
1407
|
+
_bridgeBatchPrev.set(projectId, { complete: true, hasItems: !!(bp.items && bp.items.length) });
|
|
1408
|
+
if (!prev || !prev.complete) {
|
|
1409
|
+
await autoStopBridges(projectId, project, qwPort);
|
|
1410
|
+
}
|
|
1407
1411
|
return;
|
|
1408
1412
|
}
|
|
1409
1413
|
}
|
|
@@ -1533,7 +1537,7 @@ app.post("/api/triggers/:project/start", (req, res) => {
|
|
|
1533
1537
|
if (typeof message === "string" && message.length > 0) entry.trigger_message = message;
|
|
1534
1538
|
if (Number.isFinite(interval) && interval > 0) entry.trigger_interval_min = interval;
|
|
1535
1539
|
if (Number.isFinite(duration) && duration >= 0) entry.trigger_duration_min = duration;
|
|
1536
|
-
|
|
1540
|
+
writeConfig(cfg);
|
|
1537
1541
|
}
|
|
1538
1542
|
} catch (e) { /* non-fatal — timer still runs with its in-memory values */ }
|
|
1539
1543
|
|
|
@@ -1624,7 +1628,7 @@ app.put("/api/queue", express.json({ limit: "512kb" }), (req, res) => {
|
|
|
1624
1628
|
if (content === null) return res.status(400).json({ error: "Missing content" });
|
|
1625
1629
|
const p = queuePathFor(projectId);
|
|
1626
1630
|
try {
|
|
1627
|
-
|
|
1631
|
+
ensureSecureDir(path.dirname(p));
|
|
1628
1632
|
fs.writeFileSync(p, content);
|
|
1629
1633
|
return res.json({ ok: true });
|
|
1630
1634
|
} catch (e) { return res.status(500).json({ error: e.message }); }
|
|
@@ -1642,7 +1646,7 @@ app.post("/api/queue", (req, res) => {
|
|
|
1642
1646
|
let content = fs.readFileSync(tpl, "utf-8");
|
|
1643
1647
|
content = content.replace(/\{\{project_name\}\}/g, project.name || projectId);
|
|
1644
1648
|
content = content.replace(/\{\{repo\}\}/g, project.repo || "");
|
|
1645
|
-
|
|
1649
|
+
ensureSecureDir(path.dirname(p));
|
|
1646
1650
|
fs.writeFileSync(p, content);
|
|
1647
1651
|
return res.json({ ok: true, existed: false });
|
|
1648
1652
|
} catch (e) { return res.status(500).json({ error: e.message }); }
|
|
@@ -1719,6 +1723,9 @@ app.use((req, res, next) => {
|
|
|
1719
1723
|
}
|
|
1720
1724
|
});
|
|
1721
1725
|
|
|
1726
|
+
// --- #538: PTY output secret scrubbing (extracted to scrub-secrets.js) ---
|
|
1727
|
+
const { scrubSecrets, scrubScrollback } = require("./scrub-secrets");
|
|
1728
|
+
|
|
1722
1729
|
// --- WebSocket + PTY ---
|
|
1723
1730
|
// WS connects to an existing PTY session (started via lifecycle API)
|
|
1724
1731
|
// or spawns a new one if none exists.
|
|
@@ -1760,10 +1767,10 @@ wss.on("connection", async (ws, req) => {
|
|
|
1760
1767
|
// {"type":"replay"} to avoid the timing race where eager replay
|
|
1761
1768
|
// arrived before the client's onmessage handler was registered.
|
|
1762
1769
|
|
|
1763
|
-
// PTY → client
|
|
1770
|
+
// PTY → client (#538: scrub secrets from live output)
|
|
1764
1771
|
const dataHandler = session.term.onData((data) => {
|
|
1765
1772
|
if (ws.readyState === ws.OPEN) {
|
|
1766
|
-
ws.send(data);
|
|
1773
|
+
ws.send(scrubSecrets(data));
|
|
1767
1774
|
}
|
|
1768
1775
|
});
|
|
1769
1776
|
|
|
@@ -1773,8 +1780,17 @@ wss.on("connection", async (ws, req) => {
|
|
|
1773
1780
|
const str = msg.toString();
|
|
1774
1781
|
try {
|
|
1775
1782
|
const parsed = JSON.parse(str);
|
|
1776
|
-
if (parsed.type === "resize"
|
|
1777
|
-
|
|
1783
|
+
if (parsed.type === "resize") {
|
|
1784
|
+
// #541: strict numeric type check and bounds validation before
|
|
1785
|
+
// passing to PTY. The dashboard client (TerminalPanel.tsx) sends
|
|
1786
|
+
// xterm.js cols/rows which are always numbers. Reject anything
|
|
1787
|
+
// else at the boundary.
|
|
1788
|
+
if (typeof parsed.cols === "number" && typeof parsed.rows === "number" &&
|
|
1789
|
+
Number.isFinite(parsed.cols) && Number.isFinite(parsed.rows) &&
|
|
1790
|
+
parsed.cols >= 1 && parsed.cols <= 500 &&
|
|
1791
|
+
parsed.rows >= 1 && parsed.rows <= 500) {
|
|
1792
|
+
session.term.resize(parsed.cols, parsed.rows);
|
|
1793
|
+
}
|
|
1778
1794
|
return;
|
|
1779
1795
|
}
|
|
1780
1796
|
// #461: client requests scrollback replay after xterm is fully
|
|
@@ -1784,7 +1800,8 @@ wss.on("connection", async (ws, req) => {
|
|
|
1784
1800
|
// synthetic status line so the terminal isn't completely blank.
|
|
1785
1801
|
if (parsed.type === "replay") {
|
|
1786
1802
|
if (session.scrollback && session.scrollback.length > 0) {
|
|
1787
|
-
|
|
1803
|
+
// #538: scrub likely secrets before replaying accumulated output.
|
|
1804
|
+
ws.send(scrubScrollback(session.scrollback));
|
|
1788
1805
|
} else {
|
|
1789
1806
|
ws.send(`\x1b[2m[agent online — waiting for input]\x1b[0m\r\n`);
|
|
1790
1807
|
}
|
|
@@ -1874,7 +1891,8 @@ async function autoStopPollingTick() {
|
|
|
1874
1891
|
}
|
|
1875
1892
|
}
|
|
1876
1893
|
// #518: also stop bridges when batch completes
|
|
1877
|
-
|
|
1894
|
+
// #542: only fire on the transition (incomplete→complete), not every tick
|
|
1895
|
+
if (hasBridgeAuto && (!prev || !prev.complete)) {
|
|
1878
1896
|
await autoStopBridges(project.id, project, qwPort);
|
|
1879
1897
|
}
|
|
1880
1898
|
}
|
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1854
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2162
|
-
|
|
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.
|
|
@@ -2495,8 +2495,7 @@ function writeEnvToken(key, value) {
|
|
|
2495
2495
|
const line = `${key}=${value}`;
|
|
2496
2496
|
if (regex.test(content)) content = content.replace(regex, line);
|
|
2497
2497
|
else content = content.trimEnd() + (content ? "\n" : "") + line + "\n";
|
|
2498
|
-
|
|
2499
|
-
fs.chmodSync(ENV_PATH, 0o600);
|
|
2498
|
+
writeSecureFile(ENV_PATH, content);
|
|
2500
2499
|
}
|
|
2501
2500
|
|
|
2502
2501
|
function resolveToken(value) {
|
|
@@ -2558,7 +2557,7 @@ router.get("/api/telegram", async (req, res) => {
|
|
|
2558
2557
|
if (data && data.ok && data.result && typeof data.result.username === "string") {
|
|
2559
2558
|
botUsername = data.result.username;
|
|
2560
2559
|
project.telegram.bot_username = botUsername;
|
|
2561
|
-
try {
|
|
2560
|
+
try { writeConfig(cfg); } catch {}
|
|
2562
2561
|
}
|
|
2563
2562
|
}
|
|
2564
2563
|
} catch { /* non-fatal — widget will just show no username */ }
|
|
@@ -2718,8 +2717,7 @@ router.post("/api/telegram", async (req, res) => {
|
|
|
2718
2717
|
// #383 Bug 2: write agentchattr_url inside [telegram]; the
|
|
2719
2718
|
// bridge's load_config only reads from that section.
|
|
2720
2719
|
const tomlContent = buildTelegramBridgeToml(tg, projectId);
|
|
2721
|
-
|
|
2722
|
-
fs.chmodSync(tomlPath, 0o600);
|
|
2720
|
+
writeSecureFile(tomlPath, tomlContent);
|
|
2723
2721
|
// #353: pre-flight import check so a fresh install with no
|
|
2724
2722
|
// `requests` module produces a readable error instead of the
|
|
2725
2723
|
// Start → Running → Stopped flicker that the v1 code path
|
|
@@ -2852,7 +2850,7 @@ router.post("/api/telegram", async (req, res) => {
|
|
|
2852
2850
|
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
2853
2851
|
if (project?.telegram) {
|
|
2854
2852
|
project.telegram.bot_token = `env:${envKey}`;
|
|
2855
|
-
|
|
2853
|
+
writeConfig(cfg);
|
|
2856
2854
|
}
|
|
2857
2855
|
} catch {}
|
|
2858
2856
|
return res.json({ ok: true, env_key: envKey });
|
|
@@ -2885,7 +2883,7 @@ router.post("/api/telegram", async (req, res) => {
|
|
|
2885
2883
|
// will re-fetch it from Telegram's getMe for the new token.
|
|
2886
2884
|
bot_username: "",
|
|
2887
2885
|
};
|
|
2888
|
-
|
|
2886
|
+
writeConfig(cfg);
|
|
2889
2887
|
return res.json({ ok: true, env_key: envKey });
|
|
2890
2888
|
} catch (err) {
|
|
2891
2889
|
return res.json({ ok: false, error: err.message || "Config write failed" });
|
|
@@ -3030,7 +3028,7 @@ router.get("/api/discord", async (req, res) => {
|
|
|
3030
3028
|
if (r.ok && data.username) {
|
|
3031
3029
|
botUsername = data.username;
|
|
3032
3030
|
project.discord.bot_username = botUsername;
|
|
3033
|
-
try {
|
|
3031
|
+
try { writeConfig(cfg); } catch {}
|
|
3034
3032
|
}
|
|
3035
3033
|
}
|
|
3036
3034
|
} catch { /* non-fatal — widget will just show no username */ }
|
|
@@ -3088,7 +3086,7 @@ router.post("/api/discord", async (req, res) => {
|
|
|
3088
3086
|
// #506: always copy bundled bridge files (not just on first install)
|
|
3089
3087
|
// so re-installing after a QuadWork upgrade refreshes the script.
|
|
3090
3088
|
if (!fs.existsSync(DISCORD_BRIDGE_DIR)) {
|
|
3091
|
-
|
|
3089
|
+
ensureSecureDir(DISCORD_BRIDGE_DIR);
|
|
3092
3090
|
}
|
|
3093
3091
|
fs.cpSync(
|
|
3094
3092
|
path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
|
|
@@ -3165,8 +3163,7 @@ router.post("/api/discord", async (req, res) => {
|
|
|
3165
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." });
|
|
3166
3164
|
const tomlPath = discordConfigToml(projectId);
|
|
3167
3165
|
const tomlContent = buildDiscordBridgeToml(dc, projectId);
|
|
3168
|
-
|
|
3169
|
-
fs.chmodSync(tomlPath, 0o600);
|
|
3166
|
+
writeSecureFile(tomlPath, tomlContent);
|
|
3170
3167
|
const depCheck = checkDiscordBridgePythonDeps(venvPython);
|
|
3171
3168
|
if (!depCheck.ok) {
|
|
3172
3169
|
const msg =
|
|
@@ -3273,7 +3270,7 @@ router.post("/api/discord", async (req, res) => {
|
|
|
3273
3270
|
channel_id,
|
|
3274
3271
|
bot_username: "",
|
|
3275
3272
|
};
|
|
3276
|
-
|
|
3273
|
+
writeConfig(cfg);
|
|
3277
3274
|
return res.json({ ok: true, env_key: envKey });
|
|
3278
3275
|
} catch (err) {
|
|
3279
3276
|
return res.json({ ok: false, error: err.message || "Config write failed" });
|
|
@@ -3345,7 +3342,7 @@ router.put("/api/project/:projectId/agent-models/:agentId", (req, res) => {
|
|
|
3345
3342
|
else a.reasoning_effort = reasoning;
|
|
3346
3343
|
}
|
|
3347
3344
|
project.agents[agentId] = a;
|
|
3348
|
-
|
|
3345
|
+
writeConfig(cfg);
|
|
3349
3346
|
return res.json({ ok: true, agent: { agent_id: agentId, model: a.model || "", reasoning_effort: a.reasoning_effort || "" } });
|
|
3350
3347
|
} catch (err) {
|
|
3351
3348
|
return res.json({ ok: false, error: err.message || "write failed" });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// --- #538: PTY output secret scrubbing ---
|
|
2
|
+
// Redact likely secrets from both live PTY streaming and scrollback
|
|
3
|
+
// replay so echoed credentials are not exposed to dashboard clients.
|
|
4
|
+
//
|
|
5
|
+
// Threat model: QuadWork binds to 127.0.0.1 only. The scrub is
|
|
6
|
+
// defense-in-depth — it reduces exposure if a secret is accidentally
|
|
7
|
+
// echoed, but cannot catch every possible format. Operators who handle
|
|
8
|
+
// highly sensitive credentials should avoid echoing them in agent
|
|
9
|
+
// terminals.
|
|
10
|
+
//
|
|
11
|
+
// Live chunks from term.onData() are typically line-aligned (shell
|
|
12
|
+
// flushes on newline), so per-chunk scrubbing catches the vast majority
|
|
13
|
+
// of secrets. A secret split across two chunks is a theoretical edge
|
|
14
|
+
// case that the scrollback scrub (which sees the full buffer) covers
|
|
15
|
+
// on reconnect.
|
|
16
|
+
|
|
17
|
+
// Patterns that indicate a line contains a secret value.
|
|
18
|
+
const _SECRET_NAME_RE = /\b\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSPHRASE|AUTH)\w*\s*[=:]/i;
|
|
19
|
+
// Known API key prefixes (Anthropic, GitHub, OpenAI, etc.).
|
|
20
|
+
const _API_KEY_PREFIX_RE = /\b(sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36,}|ghu_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,}|xoxb-[A-Za-z0-9-]{20,}|xoxp-[A-Za-z0-9-]{20,})\b/;
|
|
21
|
+
// Bearer authorization headers.
|
|
22
|
+
const _BEARER_RE = /\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/i;
|
|
23
|
+
const _REDACTED = "[REDACTED]";
|
|
24
|
+
|
|
25
|
+
function scrubSecrets(text) {
|
|
26
|
+
if (!text) return text;
|
|
27
|
+
return text.split("\n").map((line) => {
|
|
28
|
+
// Strip ANSI escape codes for pattern matching, but redact the
|
|
29
|
+
// original line (preserves terminal formatting around non-secret
|
|
30
|
+
// lines while ensuring secrets inside styled output are caught).
|
|
31
|
+
const plain = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
32
|
+
if (_SECRET_NAME_RE.test(plain)) {
|
|
33
|
+
// Redact the value portion after the = or : delimiter.
|
|
34
|
+
return line.replace(/([=:])\s*\S.*/, `$1 ${_REDACTED}`);
|
|
35
|
+
}
|
|
36
|
+
if (_API_KEY_PREFIX_RE.test(plain)) {
|
|
37
|
+
return line.replace(_API_KEY_PREFIX_RE, _REDACTED);
|
|
38
|
+
}
|
|
39
|
+
if (_BEARER_RE.test(plain)) {
|
|
40
|
+
return line.replace(/\bBearer\s+[A-Za-z0-9_.+/=-]{20,}/gi, `Bearer ${_REDACTED}`);
|
|
41
|
+
}
|
|
42
|
+
return line;
|
|
43
|
+
}).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scrubScrollback(buf) {
|
|
47
|
+
if (!buf || buf.length === 0) return buf;
|
|
48
|
+
return Buffer.from(scrubSecrets(buf.toString("utf-8")), "utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { scrubSecrets, scrubScrollback, _REDACTED };
|