diff --git a/CHANGELOG.md b/CHANGELOG.md
index d43c531..4cc0585 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+# v0.4.43 (2026-05-15)
+
+## Features
+- Add Blackbox provider with `bb` alias (#1143)
+- Add Xiaomi token plan provider
+- Enhance model select modal UX + modal traffic lights (#1111)
+- Default Usage dashboard period to Today (#1141)
+
+## Fixes
+- Fix Cowork model selection and Windows CLI packaging (#1129)
+- Update provider name retrieval for compatibility provider (#1135)
+- Update JWT_SECRET handling
+
# v0.4.41 (2026-05-14)
## Features
diff --git a/cli/package.json b/cli/package.json
index aad50da..4ebef16 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "9router",
- "version": "0.4.41",
+ "version": "0.4.43",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js
index 4b5443f..760e1e8 100644
--- a/cli/scripts/build-cli.js
+++ b/cli/scripts/build-cli.js
@@ -12,14 +12,6 @@ const buildHomeDir = path.join(cliDir, ".build-home");
const buildDistDirName = ".next-cli-build";
const buildDistDir = path.join(appDir, buildDistDirName);
-function shouldUseWorkspaceTracingRoot() {
- const appNodeModules = path.join(appDir, "node_modules");
- const rootNodeModules = path.join(rootDir, "node_modules");
-
- // Only widen tracing when dependencies are actually hoisted above appDir.
- return !fs.existsSync(appNodeModules) && fs.existsSync(rootNodeModules);
-}
-
// Exclude patterns for files/folders we don't want to copy
const EXCLUDE_PATTERNS = [
"@img", // Sharp image processing (not needed with unoptimized images)
@@ -122,7 +114,7 @@ try {
APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
NEXT_DIST_DIR: buildDistDirName,
- NEXT_TRACING_ROOT_MODE: shouldUseWorkspaceTracingRoot() ? "workspace" : "project",
+ NEXT_TRACING_ROOT_MODE: "workspace",
}
});
console.log("✅ Next.js build completed\n");
diff --git a/package.json b/package.json
index 79b91f8..5ed0ca8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.4.41",
+ "version": "0.4.43",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
index 01d1aaa..a3b20bc 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
@@ -343,13 +343,6 @@ export default function CoworkToolCard({
))
)}
-
diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js
index 6fcf418..25fb237 100644
--- a/src/app/api/cli-tools/cowork-settings/route.js
+++ b/src/app/api/cli-tools/cowork-settings/route.js
@@ -8,8 +8,29 @@ import crypto from "crypto";
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { DATA_DIR } from "@/lib/dataDir";
+import { getConsistentMachineId } from "@/shared/utils/machineId";
const APP_PORT = UPDATER_CONFIG.appPort;
+const CLI_TOKEN_HEADER = "x-9r-cli-token";
+const CLI_TOKEN_SALT = "9r-cli-auth";
+const LOCAL_MCP_PREFIX = `http://localhost:${APP_PORT}/api/mcp/`;
+
+let cachedCliToken = null;
+const getCliToken = async () => {
+ if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
+ return cachedCliToken;
+};
+
+// Inject CLI token header into entries pointing at our local /api/mcp/ bridge.
+const injectAuthHeaders = async (entries) => {
+ const token = await getCliToken();
+ for (const e of entries) {
+ if (typeof e?.url === "string" && e.url.startsWith(LOCAL_MCP_PREFIX)) {
+ e.headers = { ...(e.headers || {}), [CLI_TOKEN_HEADER]: token };
+ }
+ }
+ return entries;
+};
const PROVIDER = "gateway";
@@ -328,8 +349,8 @@ export async function POST(request) {
} catch { /* ignore */ }
}
- const bridgeEntries = buildLocalBridgeEntries(localPluginNames);
- const customEntries = buildCustomEntries(customPluginsArray);
+ const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames));
+ const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray));
const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries];
const bootstrapped = await bootstrapDeploymentMode();
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 38681b6..4f7f0ef 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -33,12 +33,14 @@ const PROTECTED_API_PATHS = [
"/api/cli-tools",
"/api/mcp",
"/api/translator",
+ "/api/tunnel",
];
// Routes that spawn child processes — restrict to localhost regardless of auth.
const LOCAL_ONLY_PATHS = [
"/api/cli-tools/cowork-settings",
"/api/mcp/",
+ "/api/tunnel/tailscale-install",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js
index d943567..78734c5 100644
--- a/src/lib/tunnel/tailscale.js
+++ b/src/lib/tunnel/tailscale.js
@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import os from "os";
+import crypto from "crypto";
import { execSync, exec, spawn } from "child_process";
import { promisify } from "util";
import { execWithPassword } from "@/mitm/dns/dnsConfig";
@@ -302,6 +303,10 @@ async function installTailscaleMac(sudoPassword, log) {
}
async function installTailscaleLinux(sudoPassword, log) {
+ // Reject password containing newline → prevents stdin command injection
+ if (typeof sudoPassword !== "string" || sudoPassword.includes("\n")) {
+ throw new Error("Invalid sudo password");
+ }
log("Downloading install script...");
return new Promise((resolve, reject) => {
const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], {
@@ -315,7 +320,15 @@ async function installTailscaleLinux(sudoPassword, log) {
curlChild.on("exit", (code) => {
if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`));
log("Running install script...");
- const child = spawn("sudo", ["-S", "sh"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
+ // Persist script to temp file → exec by path (NOT via stdin) → sh never reads attacker-controlled stdin
+ const tmpScript = path.join(os.tmpdir(), `tailscale-install-${crypto.randomBytes(8).toString("hex")}.sh`);
+ try {
+ fs.writeFileSync(tmpScript, scriptContent, { mode: 0o700 });
+ } catch (e) {
+ return reject(new Error(`Failed to write install script: ${e.message}`));
+ }
+ const cleanup = () => { try { fs.unlinkSync(tmpScript); } catch {} };
+ const child = spawn("sudo", ["-S", "sh", tmpScript], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
let stderr = "";
child.stdout.on("data", (d) => {
const line = d.toString().trim();
@@ -323,6 +336,7 @@ async function installTailscaleLinux(sudoPassword, log) {
});
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.on("close", (c) => {
+ cleanup();
if (c === 0) resolve();
else {
const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry"))
@@ -331,9 +345,8 @@ async function installTailscaleLinux(sudoPassword, log) {
reject(new Error(msg));
}
});
- child.on("error", reject);
+ child.on("error", (e) => { cleanup(); reject(e); });
child.stdin.write(`${sudoPassword}\n`);
- child.stdin.write(scriptContent);
child.stdin.end();
});
curlChild.on("error", reject);
diff --git a/src/proxy.js b/src/proxy.js
index 59917b7..e92e5fd 100644
--- a/src/proxy.js
+++ b/src/proxy.js
@@ -13,5 +13,6 @@ export const config = {
"/api/cli-tools/:path*",
"/api/mcp/:path*",
"/api/translator/:path*",
+ "/api/tunnel/:path*",
],
};