fix: cli tool page to update the config correctly

This commit is contained in:
2026-07-12 18:16:52 +07:00
parent be7c210161
commit 268e2ac21f
13 changed files with 141 additions and 34 deletions
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecrets } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -68,7 +69,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings: settings, settings: redactSecrets(settings),
has9Router: has9Router, has9Router: has9Router,
settingsPath: getClaudeSettingsPath(), settingsPath: getClaudeSettingsPath(),
}); });
@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { parseTOML, stringifyTOML } from "confbox"; import { parseTOML, stringifyTOML } from "confbox";
import { redactSecretsInText } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -96,7 +97,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
config, config: redactSecretsInText(config),
has9Router: has9RouterConfig(config), has9Router: has9RouterConfig(config),
configPath: getCodexConfigPath(), configPath: getCodexConfigPath(),
}); });
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecrets } from "@/lib/security/redactSecrets";
// Resolve chatLanguageModels.json path per OS // Resolve chatLanguageModels.json path per OS
const getConfigPath = () => { const getConfigPath = () => {
@@ -48,7 +49,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
config, config: redactSecrets(config),
has9Router: has9RouterConfig(config), has9Router: has9RouterConfig(config),
configPath: getConfigPath(), configPath: getConfigPath(),
currentModel: entry?.models?.[0]?.id || null, currentModel: entry?.models?.[0]?.id || null,
@@ -8,6 +8,7 @@ import crypto from "crypto";
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
import { UPDATER_CONFIG } from "@/shared/constants/config"; import { UPDATER_CONFIG } from "@/shared/constants/config";
import { getConsistentMachineId } from "@/shared/utils/machineId"; import { getConsistentMachineId } from "@/shared/utils/machineId";
import { redactSecrets } from "@/lib/security/redactSecrets";
const APP_PORT = UPDATER_CONFIG.appPort; const APP_PORT = UPDATER_CONFIG.appPort;
const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_HEADER = "x-9r-cli-token";
@@ -273,7 +274,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
config, config: redactSecrets(config),
has9Router, has9Router,
configPath, configPath,
cowork: { cowork: {
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecretsInText } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -112,7 +113,7 @@ export async function GET() {
const config = parseToml(toml); const config = parseToml(toml);
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings: config, settings: redactSecretsInText(config),
has9Router: has9RouterConfig(config), has9Router: has9RouterConfig(config),
configPath: getDeepSeekConfigPath(), configPath: getDeepSeekConfigPath(),
}); });
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecrets } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -69,7 +70,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings, settings: redactSecrets(settings),
has9Router: has9RouterConfig(settings), has9Router: has9RouterConfig(settings),
settingsPath: getDroidSettingsPath(), settingsPath: getDroidSettingsPath(),
}); });
@@ -7,6 +7,7 @@ import os from "os";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { parseTOML, stringifyTOML } from "confbox"; import { parseTOML, stringifyTOML } from "confbox";
import { redactSecrets } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -122,7 +123,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
config, config: redactSecrets(config),
has9Router, has9Router,
configPath: getConfigPath(), configPath: getConfigPath(),
}); });
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecrets } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -103,8 +104,8 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings, settings: redactSecrets(settings),
agents: enrichedAgents, agents: redactSecrets(enrichedAgents),
has9Router: has9RouterConfig(settings), has9Router: has9RouterConfig(settings),
settingsPath: getOpenClawSettingsPath(), settingsPath: getOpenClawSettingsPath(),
}); });
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { redactSecrets } from "@/lib/security/redactSecrets";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -72,7 +73,7 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
config, config: redactSecrets(config),
has9Router: has9RouterConfig(config), has9Router: has9RouterConfig(config),
configPath: getConfigPath(), configPath: getConfigPath(),
opencode: { opencode: {
+18 -5
View File
@@ -45,12 +45,14 @@ const ALWAYS_PROTECTED = [
]; ];
// User administration is never exposed to normal users, even if dashboard login // User administration is never exposed to normal users, even if dashboard login
// is disabled for local single-user deployments. // is disabled for local single-user deployments. CLI Tools directly read and
const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe"]; // mutate the account running 9Router's local CLI configuration, so they are
// host administration rather than per-user dashboard preferences.
const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe", "/api/cli-tools"];
// Dashboard paths requiring an administrator. Combo access is handled by its // Dashboard paths requiring an administrator. Combo access is handled by its
// owner-scoped API routes and is available to authenticated users. // owner-scoped API routes and is available to authenticated users.
const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe"]; const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe", "/dashboard/cli-tools"];
// Require auth, but allow through if requireLogin is disabled // Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [ const PROTECTED_API_PATHS = [
@@ -75,8 +77,6 @@ const PROTECTED_API_PATHS = [
// Routes that spawn child processes or read host secrets — restrict to localhost. // Routes that spawn child processes or read host secrets — restrict to localhost.
const LOCAL_ONLY_PATHS = [ const LOCAL_ONLY_PATHS = [
"/api/cli-tools/cowork-settings",
"/api/cli-tools/antigravity-mitm",
"/api/mcp/", "/api/mcp/",
"/api/tunnel/tailscale-install", "/api/tunnel/tailscale-install",
"/api/tunnel/tailscale-enable", "/api/tunnel/tailscale-enable",
@@ -210,6 +210,19 @@ export const __test__ = {
export async function proxy(request) { export async function proxy(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// CLI Tools access the server process's home directory and, in the MITM
// case, can change privileged system networking. Do not allow a shared CLI
// bearer token to become a remote host-administration credential: only a
// local, authenticated administrator may use these endpoints.
if (pathname === "/api/cli-tools" || pathname.startsWith("/api/cli-tools/")) {
if (!isLocalRequest(request)) {
return NextResponse.json({ error: "CLI Tools are available only from the local machine" }, { status: 403 });
}
if (!(await isAdmin(request))) {
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
}
}
// Local-only gate for spawn-capable / host-secret routes. // Local-only gate for spawn-capable / host-secret routes.
if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) { if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) {
if (!(await canAccessLocalOnlyRoute(request))) { if (!(await canAccessLocalOnlyRoute(request))) {
+30
View File
@@ -0,0 +1,30 @@
const SECRET_KEY_PATTERN = /(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|bearer|cookie|private[_-]?key)/i;
const REDACTED_VALUE = "[REDACTED]";
/**
* Return a deep copy suitable for an administrative status response.
* Configuration values are retained, but values stored under known credential
* keys are redacted before crossing the server-to-browser boundary.
*/
export function redactSecrets(value) {
if (Array.isArray(value)) return value.map(redactSecrets);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SECRET_KEY_PATTERN.test(key) ? REDACTED_VALUE : redactSecrets(child),
]),
);
}
/** Redact credential assignments embedded in text-based configuration formats. */
export function redactSecretsInText(value) {
if (typeof value !== "string") return value;
return value
.replace(/^(\s*(?:[A-Za-z0-9_.-]*?(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\s*[=:]\s*)([^\r\n#]+)/gim, `$1${REDACTED_VALUE}`)
.replace(/("(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|private[_-]?key)"\s*:\s*")[^"]*(")/gim, `$1${REDACTED_VALUE}$2`);
}
export { REDACTED_VALUE };
+28 -17
View File
@@ -224,7 +224,7 @@ describe("dashboard guard local-only access", () => {
expect(response.body.error).toBe("Local only: CLI token required"); expect(response.body.error).toBe("Local only: CLI token required");
}); });
it("allows local-only route on loopback when requireLogin=false", async () => { it("requires an administrator for CLI Tools even when dashboard login is disabled", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false }); mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", { const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
@@ -232,7 +232,8 @@ describe("dashboard guard local-only access", () => {
origin: "http://localhost:20128", origin: "http://localhost:20128",
})); }));
expect(response).toBe(mocks.nextResponse); expect(response.status).toBe(403);
expect(response.body.error).toBe("Administrator access required");
}); });
it("rejects local-only route from tunnel host even when requireLogin=false", async () => { it("rejects local-only route from tunnel host even when requireLogin=false", async () => {
@@ -266,7 +267,7 @@ describe("dashboard guard local-only access", () => {
}); });
}); });
describe("dashboard guard combo administration access", () => { describe("dashboard guard CLI Tools administration access", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireLogin: true }); mocks.getSettings.mockResolvedValue({ requireLogin: true });
@@ -277,28 +278,38 @@ describe("dashboard guard combo administration access", () => {
mocks.verifyDashboardAuthToken.mockResolvedValue(true); mocks.verifyDashboardAuthToken.mockResolvedValue(true);
}); });
it("rejects normal users from every combos API operation", async () => { it("rejects normal users from host-level CLI Tools operations", async () => {
for (const pathname of ["/api/combos", "/api/combos/combo-1"]) { const response = await proxy(request("/api/cli-tools/claude-settings", {
const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token")); host: "localhost:20128",
origin: "http://localhost:20128",
}, "user-token"));
expect(response.status).toBe(403); expect(response.status).toBe(403);
expect(response.body.error).toBe("Administrator access required"); expect(response.body.error).toBe("Administrator access required");
}
}); });
it("redirects normal users away from the combos dashboard page", async () => { it("rejects remote CLI Tools access even with an administrator session", async () => {
const response = await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "user-token"));
expect(response.status).toBe(307);
expect(response.url).toBe("http://localhost/dashboard");
});
it("allows administrators to access the combos page and API", async () => {
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" }); mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" });
expect(await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); const response = await proxy(request("/api/cli-tools/claude-settings", {
expect(await proxy(request("/api/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); host: "router.example.com",
}, "admin-token"));
expect(response.status).toBe(403);
expect(response.body.error).toBe("CLI Tools are available only from the local machine");
}); });
it("allows a local administrator to use CLI Tools", async () => {
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" });
const response = await proxy(request("/api/cli-tools/claude-settings", {
host: "localhost:20128",
origin: "http://localhost:20128",
}, "admin-token"));
expect(response).toBe(mocks.nextResponse);
});
}); });
describe("dashboard guard token saver administration access", () => { describe("dashboard guard token saver administration access", () => {
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { REDACTED_VALUE, redactSecrets, redactSecretsInText } from "../../src/lib/security/redactSecrets.js";
describe("CLI configuration secret redaction", () => {
it("redacts nested credential fields without mutating other settings", () => {
const input = {
endpoint: "http://localhost:20128/v1",
env: {
ANTHROPIC_AUTH_TOKEN: "sk-sensitive",
CUSTOM_SETTING: "preserved",
},
providers: [{ apiKey: "another-secret", baseURL: "https://api.example.com" }],
};
expect(redactSecrets(input)).toEqual({
endpoint: "http://localhost:20128/v1",
env: {
ANTHROPIC_AUTH_TOKEN: REDACTED_VALUE,
CUSTOM_SETTING: "preserved",
},
providers: [{ apiKey: REDACTED_VALUE, baseURL: "https://api.example.com" }],
});
expect(input.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-sensitive");
});
it("redacts TOML, YAML, dotenv, and JSON credential assignments", () => {
const config = [
'api_key = "sk-sensitive"',
"ANTHROPIC_AUTH_TOKEN=token-value",
'"refresh_token": "refresh-value",',
"base_url = \"http://localhost:20128/v1\"",
].join("\n");
const result = redactSecretsInText(config);
expect(result).not.toContain("sk-sensitive");
expect(result).not.toContain("token-value");
expect(result).not.toContain("refresh-value");
expect(result).toContain(`api_key = ${REDACTED_VALUE}`);
expect(result).toContain(`ANTHROPIC_AUTH_TOKEN=${REDACTED_VALUE}`);
expect(result).toContain(`"refresh_token": "${REDACTED_VALUE}"`);
expect(result).toContain('base_url = "http://localhost:20128/v1"');
});
});