diff --git a/darknode.js b/darknode.js index b218ee3..dcee127 100755 --- a/darknode.js +++ b/darknode.js @@ -803,10 +803,11 @@ function serveDir(port, dir) { if (_rp !== _root && !_rp.startsWith(_root + path.sep)) { res.writeHead(403); return res.end("forbidden"); } fs.stat(p, (e, st) => { if (e) { res.writeHead(404); return res.end("not found"); } - if (st.isDirectory()) { const items = fs.readdirSync(p); res.writeHead(200, { "Content-Type": "text/html" }); const h = (x) => String(x).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";"), base = rel.replace(/\/$/, "").split("/").map(encodeURIComponent).join("/"); + if (st.isDirectory()) { let items; try { items = fs.readdirSync(p); } catch (_) { res.writeHead(403); return res.end("forbidden"); } res.writeHead(200, { "Content-Type": "text/html" }); const h = (x) => String(x).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";"), base = rel.replace(/\/$/, "").split("/").map(encodeURIComponent).join("/"); return res.end(items.map((i) => `${h(i)}`).join("
")); } // names are text, not markup, and #/?/space in a name stay part of the link console.log(" " + green("GET ") + req.url + gray(" " + (req.socket.remoteAddress || ""))); - res.writeHead(200); fs.createReadStream(p).pipe(res); + const rs = fs.createReadStream(p); // an unreadable file errored with no handler and took the server down + rs.on("open", () => { res.writeHead(200); rs.pipe(res); }); rs.on("error", () => { if (!res.headersSent) res.writeHead(403); res.end(); }); }); }); srv.listen(port, () => { h1("HTTP file server"); console.log(" serving " + bold(path.resolve(dir)) + " on " + cyan("http://0.0.0.0:" + port)); console.log(" " + gray("Ctrl-C to stop") + "\n"); }); @@ -836,7 +837,9 @@ function nexusUpdate(opts) { const hush = (msg) => (silent ? "" : msg); const cp = require("child_process"), fs = require("fs"), path = require("path"); const dir = __dirname; - const run = (c, a) => cp.spawnSync(c, a, { cwd: dir, encoding: "utf8", timeout: 120000 }); + // Windows npm is npm.cmd, which spawn can only run through a shell (args here are fixed or semver-checked). + const W = process.platform === "win32", sp = (c, a, o) => cp.spawnSync(W && c === "npm" ? "npm.cmd" : c, a, W && c === "npm" ? { ...o, shell: true } : o); + const run = (c, a) => sp(c, a, { cwd: dir, encoding: "utf8", timeout: 120000 }); if (!fs.existsSync(path.join(dir, ".git"))) { // installed from npm (global) — compare against the registry and, on apply, reinstall. if (!hasBin("npm")) return hush("installed at " + dir + "\nnot a git checkout and npm not found — reinstall to update."); @@ -846,7 +849,7 @@ function nexusUpdate(opts) { if (!semverGt(latest, VERSION)) return hush("up to date — v" + VERSION + " is the latest (npm)."); const L = ["update available: v" + VERSION + " → v" + latest + " (npm)"]; if (!apply) { L.push("run " + cyan("darknode update") + " to install (npm i -g darknode-cli@latest)."); return L.join("\n"); } - const ni = cp.spawnSync("npm", ["install", "-g", "darknode-cli@" + latest, "--no-audit", "--no-fund"], { encoding: "utf8", timeout: 600000 }); + const ni = sp("npm", ["install", "-g", "darknode-cli@" + latest, "--no-audit", "--no-fund"], { encoding: "utf8", timeout: 600000 }); if (ni.status !== 0) { L.push("auto-update failed (a global npm install may need elevated permissions): " + (((ni.stderr || "") + (ni.error ? ni.error.message : "")).trim().split("\n").slice(-2).join(" ") || "npm error") + "\n install manually: npm install -g darknode-cli@latest"); return L.join("\n"); } L.push(green("updated") + " to v" + latest + " — restart Nexus to load it."); return L.join("\n"); } @@ -872,7 +875,7 @@ function nexusUpdate(opts) { const pull = run("git", ["merge", "--ff-only", "--quiet", remote]); // the commit just compared: `git pull` re-resolved @{u}, which is missing when we fell back to origin/main if (pull.status !== 0) { L.push("update failed: " + (((pull.stderr || "") + (pull.error ? pull.error.message : "")).trim() || "git pull error")); return L.join("\n"); } const changed = (run("git", ["diff", "--name-only", local, "HEAD"]).stdout) || ""; - if (/package(-lock)?\.json/.test(changed) && hasBin("npm")) { const ni = cp.spawnSync("npm", ["install", "--silent", "--no-audit", "--no-fund", "--no-package-lock"], { cwd: dir, encoding: "utf8", timeout: 600000 }); if (ni.status !== 0) L.push("note: npm install after update reported an issue — run `npm install` in " + dir); } + if (/package(-lock)?\.json/.test(changed) && hasBin("npm")) { const ni = sp("npm", ["install", "--silent", "--no-audit", "--no-fund", "--no-package-lock"], { cwd: dir, encoding: "utf8", timeout: 600000 }); if (ni.status !== 0) L.push("note: npm install after update reported an issue — run `npm install` in " + dir); } let newV = VERSION; try { newV = JSON.parse(stripBom(fs.readFileSync(path.join(dir, "package.json"), "utf8"))).version || newV; } catch (_) {} L.push(green("updated") + " v" + VERSION + " → v" + newV + " — restart Nexus (" + cyan("/exit") + ", then " + cyan("darknode nexus") + ") to load it."); return L.join("\n"); @@ -1302,25 +1305,30 @@ async function githubDeviceFlow(clientId, log) { throw new Error("GitHub device login timed out"); } async function googleLoopbackFlow(clientId, clientSecret, log) { - const http = require("http"); + const http = require("http"), crypto = require("crypto"); + // state + PKCE: without them any local process or web page could hand this listener its + // own auth code and sign the user in as someone else (login CSRF). + const state = crypto.randomBytes(16).toString("hex"), verifier = crypto.randomBytes(32).toString("base64url"); + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); return await new Promise((resolve, reject) => { let done = false; const server = http.createServer(async (req, res) => { try { const u = new URL(req.url, "http://127.0.0.1"); const code = u.searchParams.get("code"), err = u.searchParams.get("error"); - if (err) { res.end("Login failed: " + err); done = true; server.close(); return reject(new Error("Google: " + err)); } + if ((code || err) && u.searchParams.get("state") !== state) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end("Login request did not come from this sign-in."); } + if (err) { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Login failed: " + err); done = true; server.close(); return reject(new Error("Google: " + err)); } if (!code) { res.end("Waiting…"); return; } res.writeHead(200, { "Content-Type": "text/html" }); res.end("

Nexus — signed in

You can close this tab and return to the terminal.

"); const port = server.address().port; done = true; server.close(); - const tk = await httpReq({ url: "https://oauth2.googleapis.com/token", method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "code=" + encodeURIComponent(code) + "&client_id=" + encodeURIComponent(clientId) + "&client_secret=" + encodeURIComponent(clientSecret) + "&redirect_uri=" + encodeURIComponent("http://127.0.0.1:" + port) + "&grant_type=authorization_code" }); + const tk = await httpReq({ url: "https://oauth2.googleapis.com/token", method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "code=" + encodeURIComponent(code) + "&client_id=" + encodeURIComponent(clientId) + "&client_secret=" + encodeURIComponent(clientSecret) + "&redirect_uri=" + encodeURIComponent("http://127.0.0.1:" + port) + "&grant_type=authorization_code&code_verifier=" + encodeURIComponent(verifier) }); if (!tk.json || !tk.json.id_token) return reject(new Error("Google token exchange failed" + (tk.json && tk.json.error ? ": " + (tk.json.error_description || tk.json.error) : ""))); resolve(tk.json.id_token); } catch (e) { reject(e); } }); server.listen(0, "127.0.0.1", () => { const port = server.address().port, redirect = "http://127.0.0.1:" + port; - const url = "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=" + encodeURIComponent(clientId) + "&redirect_uri=" + encodeURIComponent(redirect) + "&scope=" + encodeURIComponent("openid email profile") + "&access_type=offline&prompt=select_account"; + const url = "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=" + encodeURIComponent(clientId) + "&redirect_uri=" + encodeURIComponent(redirect) + "&scope=" + encodeURIComponent("openid email profile") + "&access_type=offline&prompt=select_account&state=" + state + "&code_challenge=" + challenge + "&code_challenge_method=S256"; log("opening browser to sign in with Google…"); openBrowser(url); }); setTimeout(() => { if (done) return; try { server.close(); } catch (_) {} reject(new Error("Google login timed out")); }, 180000); @@ -1408,7 +1416,7 @@ function loginConfigWrite(kv) { const cur = firebaseCfg() || {}; let n = 0; for function ollamaReachable() { return new Promise((res) => { const r = require("http").get({ host: ollamaAddr().host, port: ollamaAddr().port, path: "/api/tags", timeout: 1500 }, () => res(true)); r.on("error", () => res(false)); r.on("timeout", () => { r.destroy(); res(false); }); }); } function streamCmd(cmdOrBin, args, log, shell) { return new Promise((resolve) => { const cp = require("child_process"); let p; try { p = shell ? cp.spawn("sh", ["-c", cmdOrBin], { stdio: ["ignore", "pipe", "pipe"] }) : cp.spawn(cmdOrBin, args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { log(red(e.message)); return resolve(1); } const onData = (b) => String(b).split(/\r?\n/).forEach((l) => { if (l.trim()) log(gray(l.slice(0, 200))); }); p.stdout.on("data", onData); p.stderr.on("data", onData); p.on("close", (code) => resolve(code || 0)); p.on("error", (e) => { log(red(e.message)); resolve(1); }); }); } async function installOllama(log) { if (process.platform === "win32") { log("Windows: download the installer from " + cyan("https://ollama.com/download") + ", then re-run setup."); return false; } log("installing Ollama (official script)…"); return (await streamCmd("curl -fsSL https://ollama.com/install.sh | sh", null, log, true)) === 0; } -async function ensureOllamaServer(log) { if (await ollamaReachable()) return true; log("starting the Ollama server…"); try { require("child_process").spawn("ollama", ["serve"], { stdio: "ignore", detached: true }).unref(); } catch (_) {} for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 500)); if (await ollamaReachable()) return true; } return false; } +async function ensureOllamaServer(log) { if (await ollamaReachable()) return true; log("starting the Ollama server…"); let missing = false; try { const ch = require("child_process").spawn("ollama", ["serve"], { stdio: "ignore", detached: true }); ch.on("error", () => { missing = true; }); ch.unref(); } catch (_) { missing = true; } /* ollama not installed: spawn emits 'error' (ENOENT), which crashed Nexus when unhandled */ for (let i = 0; i < 20 && !missing; i++) { await new Promise((r) => setTimeout(r, 500)); if (await ollamaReachable()) return true; } return false; } async function ollamaPull(model, log) { log("pulling " + bold(model) + gray(" — several GB, this can take a while…")); return (await streamCmd("ollama", ["pull", model], log, false)) === 0; } function firstRunPending() { const st = readGlobal("state.json", null); return !st || !st.setupDone; } // The local models Nexus ships with — a strong coding model + an agentic/security @@ -2342,7 +2350,7 @@ async function deviceTool(name, a, cwd) { if (name === "find" || name === "find_files" || name === "glob") return require("./lib/nexus/search").findTool(a, cwd, rp); if (name === "http_fetch" || name === "web_fetch" || name === "fetch_url" || name === "http") return await require("./lib/nexus/http-fetch").httpFetchTool(a, { isLocal: _ssrfLocal }); // redirects re-checked by the SSRF guard on every hop if (name === "sysinfo" || name === "system_info") { - let disk = ""; try { disk = _cp.execSync("df -h " + JSON.stringify(cwd) + " 2>/dev/null", { encoding: "utf8" }).trim().split("\n").slice(-1)[0]; } catch (_) {} + let disk = ""; try { disk = _cp.execFileSync("df", ["-h", cwd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n").slice(-1)[0]; } catch (_) {} const c = os.cpus()[0] || {}; return { platform: os.platform(), arch: os.arch(), release: os.release(), hostname: os.hostname(), cpus: os.cpus().length + " x " + (c.model || "?"), memory: (os.totalmem() / 1e9).toFixed(1) + "GB total, " + (os.freemem() / 1e9).toFixed(1) + "GB free", loadavg: os.loadavg().map((n) => n.toFixed(2)).join(" "), uptime: (os.uptime() / 3600).toFixed(1) + "h", cwd, node: process.version, disk }; } diff --git a/lib/nexus/darknode-mcp.js b/lib/nexus/darknode-mcp.js index 2fd786f..181e3c7 100644 --- a/lib/nexus/darknode-mcp.js +++ b/lib/nexus/darknode-mcp.js @@ -43,7 +43,7 @@ function importSpecifiers(src) { function resolveSpec(spec, from) { const s = String(spec).split(/[?#]/)[0]; if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return null; // http:, data:, node: ... - if (s.startsWith("/js/")) return path.posix.normalize(s.slice(4)); + if (s.startsWith("/js/")) { const r = path.posix.normalize(s.slice(4)); return r.startsWith("../") || r === ".." ? null : r; } // never write outside the engines cache if (s.startsWith("./") || s.startsWith("../")) { const r = path.posix.normalize(path.posix.join(path.posix.dirname(from), s)); return r.startsWith("../") ? null : r; } return null; // bare package name: not ours } diff --git a/test/piped-stdin-audit.test.js b/test/piped-stdin-audit.test.js index bd17e00..d070a69 100644 --- a/test/piped-stdin-audit.test.js +++ b/test/piped-stdin-audit.test.js @@ -14,10 +14,24 @@ const ok = (name, cond) => { if (cond) pass++; else { fail++; console.log(" \x1 ok("the reader returns every line in order, then null at the end", JSON.stringify(got) === JSON.stringify(["a", "b", "c", null, null])); const home = fs.mkdtempSync(path.join(os.tmpdir(), "nx-pipe-")), dir = fs.mkdtempSync(path.join(os.tmpdir(), "nx-pipe-cwd-")); - const res = cp.spawnSync(process.execPath, [path.join(__dirname, "..", "darknode.js"), "nexus", "--new"], { cwd: dir, input: "/auto\n/auto\n/clear\n", encoding: "utf8", timeout: 30000, env: { ...process.env, HOME: home, NO_COLOR: "1", DARKNODE_FAST: "1" } }); + // A stand-in Ollama (model list only), so the session starts on machines with no engine + // installed (CI); no slash command here reaches the model. + const http = require("http"); + const fake = http.createServer((req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(req.url.startsWith("/api/tags") ? JSON.stringify({ models: [{ name: "qwen2.5-coder:7b", model: "qwen2.5-coder:7b", size: 1 }] }) : "{}"); }); + await new Promise((r) => fake.listen(0, "127.0.0.1", r)); + const res = await new Promise((resolve) => { + const ch = cp.spawn(process.execPath, [path.join(__dirname, "..", "darknode.js"), "nexus", "--new"], { cwd: dir, env: { ...process.env, HOME: home, NO_COLOR: "1", DARKNODE_FAST: "1", OLLAMA_HOST: "127.0.0.1", OLLAMA_PORT: String(fake.address().port) } }); + let stdout = "", stderr = ""; + ch.stdout.on("data", (d) => { stdout += d; }); ch.stderr.on("data", (d) => { stderr += d; }); + const t = setTimeout(() => ch.kill("SIGKILL"), 30000); + ch.on("close", (status, signal) => { clearTimeout(t); resolve({ status, signal, stdout, stderr }); }); + ch.stdin.end("/auto\n/auto\n/clear\n"); + }); + fake.close(); const out = res.stdout || ""; ok("all three piped commands ran", (out.match(/auto-approve ON/g) || []).length === 1 && (out.match(/auto-approve OFF/g) || []).length === 1 && /conversation cleared/.test(out)); ok("end of input ends the session cleanly", res.status === 0 && /stay sharp/.test(out)); + if (fail) console.log(" status=" + res.status + " signal=" + res.signal + " error=" + (res.error && res.error.message) + "\n stdout tail: " + JSON.stringify(out.slice(-600)) + "\n stderr tail: " + JSON.stringify(String(res.stderr || "").slice(-600))); fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true }); console.log("\n" + (fail ? "\x1b[31m" : "\x1b[32m") + pass + " passed, " + fail + " failed\x1b[0m (piped stdin)"); diff --git a/test/run.js b/test/run.js index 257ef56..f15feac 100644 --- a/test/run.js +++ b/test/run.js @@ -4137,7 +4137,7 @@ ok("shouldCheck: first launch runs; throttled within 4h; runs after", shouldChec if (req.url === "/split") { res.writeHead(200, { "content-type": "text/plain" }); const b = Buffer.from("añb"); res.write(b.subarray(0, 2)); setTimeout(() => res.end(b.subarray(2)), 20); return; } res.writeHead(404); res.end(); }); - await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + await new Promise((r) => srv.listen(0, r)); const base = "http://127.0.0.1:" + srv.address().port; const deps = { isLocal: (u) => /blocked\.internal/.test(u) }; // the real guard would also block 127.0.0.1; here only the redirect target is "private" let r = await HF.httpFetchTool({ url: base + "/r1" }, deps);