Files
AgentHub/server/__tests__/cc-mutate-settings.test.js
nntrivi2001 5fe5d51b1b feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
2026-10-06 22:48:09 +07:00

760 lines
22 KiB
JavaScript

/**
* @file cc-mutate-settings.test.js
* @description Comprehensive tests for cc-mutate.js patchSettings — the per-key
* merge for settings.json layers. Tests deep merge, unset, prototype pollution
* guards, redaction marker handling (including nested), backup atomicity,
* large files, invalid JSON, array/null rejection, and key path validation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// Build fixture BEFORE requiring modules that read CLAUDE_HOME
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-settings-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude");
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
// Now set env and require
process.env.CLAUDE_HOME = FAKE_HOME;
const ccMutate = require("../lib/cc-mutate");
after(() => {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
});
describe("patchSettings — happy path", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
});
it("creates a new settings.json with a single key", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "opus" },
});
assert(result.ok === undefined); // Returns { path, backupPath, created, changed }
assert.equal(result.created, true);
assert.equal(result.backupPath, null);
assert.deepEqual(result.changed, ["model"]);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "opus");
});
it("merges into an existing settings.json without clobbering untouched keys", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus", persist: "keep-me" }));
const result = ccMutate.patchSettings({
scope: "user",
set: { apiKey: "secret" },
});
assert.equal(result.created, false);
assert.notEqual(result.backupPath, null);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "opus");
assert.equal(content.persist, "keep-me");
assert.equal(content.apiKey, "secret");
});
it("unsets a top-level key", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus", temp: "remove" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["temp"],
});
assert.deepEqual(result.changed, ["temp"]);
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.model, "opus");
assert.equal(content.temp, undefined);
});
it("unsets a key that does not exist (no error, not listed)", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["nonexistent"],
});
assert.deepEqual(result.changed, []);
});
});
describe("patchSettings — deep merge", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("merges nested objects without clobbering siblings", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash"], deny: ["network"] },
other: "data",
})
);
const result = ccMutate.patchSettings({
scope: "user",
set: { "permissions.allow": ["Bash", "Python"] },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(content.permissions.allow, ["Bash", "Python"]);
assert.deepEqual(content.permissions.deny, ["network"]);
assert.equal(content.other, "data");
});
it("creates deeply nested paths in a fresh file", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { "a.b.c.d": 42 },
});
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.a.b.c.d, 42);
});
it("overwrites a non-object intermediate value during nested set", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ config: "string-value" }));
ccMutate.patchSettings({
scope: "user",
set: { "config.nested.key": "new-value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.config.nested.key, "new-value");
});
it("unsets a nested key without touching siblings", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(
file,
JSON.stringify({
hooks: {
PreToolUse: [{ command: "x" }],
PostToolUse: [{ command: "y" }],
},
})
);
const result = ccMutate.patchSettings({
scope: "user",
unset: ["hooks.PreToolUse"],
});
assert.deepEqual(result.changed, ["hooks.PreToolUse"]);
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.hooks.PreToolUse, undefined);
assert.deepEqual(content.hooks.PostToolUse, [{ command: "y" }]);
});
it("fails to unset a nested key whose parent is not an object", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ config: "string" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["config.nested.key"],
});
// unsetDeep returns false, so the key is not listed in changed
assert.deepEqual(result.changed, []);
});
});
describe("patchSettings — redaction marker", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a value containing <redacted> at top level", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { apiKey: "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
assert.match(err.message, /placeholder/);
return true;
}
);
});
it("rejects a value containing <redacted> at nested position", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "auth.token": "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an object", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { config: { secret: "<redacted>", other: "ok" } },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { items: ["a", "<redacted>", "b"] },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("accepts a value that merely contains the string redacted (no angle brackets)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { notes: "this value is redacted in the UI" },
});
assert.deepEqual(result.changed, ["notes"]);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.notes, "this value is redacted in the UI");
});
});
describe("patchSettings — key path validation", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("accepts alphanumeric keys with dots for nesting", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { "my_key.sub-key": "value" },
});
assert.deepEqual(result.changed, ["my_key.sub-key"]);
});
it("accepts $ in key paths (for $schema, $docs)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { $schema: "http://example.com" },
});
assert.deepEqual(result.changed, ["$schema"]);
});
it("rejects __proto__", () => {
// Built via JSON.parse, not an object literal: a literal `__proto__:` key
// sets the prototype instead of an own property, so it would never reach
// the validator. An HTTP body arrives parsed, i.e. as an own key.
const set = JSON.parse('{"__proto__": {"polluted": true}}');
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set,
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects prototype", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { prototype: "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects constructor", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { constructor: {} },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects __proto__ in a nested path", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "config.__proto__.polluted": true },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects empty key paths", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "": "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects keys with invalid characters", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "bad@key": "value" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects empty segments in dotted paths", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "config..key": "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
});
describe("patchSettings — scopes", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
});
it("writes to user scope (~/.claude/settings.json)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "sonnet" },
});
assert(result.path.includes(".claude/settings.json"));
assert(fs.existsSync(result.path));
});
it("writes to project scope (./.claude/settings.json)", () => {
const result = ccMutate.patchSettings({
scope: "project",
cwd: FAKE_PROJECT,
set: { model: "haiku" },
});
assert(result.path.includes(".claude/settings.json"));
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "haiku");
});
it("writes to project-local scope (./.claude/settings.local.json)", () => {
const result = ccMutate.patchSettings({
scope: "project-local",
cwd: FAKE_PROJECT,
set: { permissions: { allow: ["Bash"] } },
});
assert(result.path.includes("settings.local.json"));
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.deepEqual(content.permissions.allow, ["Bash"]);
});
it("rejects unknown scope", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "unknown",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADSCOPE");
return true;
}
);
});
});
describe("patchSettings — file format validation", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a settings file that is not valid JSON", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "not json at all {{{");
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
assert.match(err.message, /not valid JSON/);
return true;
}
);
});
it("rejects a settings file that parses to an array", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify(["a", "b", "c"]));
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
assert.match(err.message, /does not contain a JSON object/);
return true;
}
);
});
it("rejects a settings file that parses to null", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "null");
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
return true;
}
);
});
it("rejects a settings file that parses to a plain string", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, '"not-an-object"');
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
return true;
}
);
});
it("allows empty files to be treated as {}", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "");
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.key, "value");
});
it("allows files with only whitespace to be treated as {}", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, " \n \t ");
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.key, "value");
});
});
describe("patchSettings — size limits", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a settings file larger than MAX_FILE_BYTES", () => {
const file = path.join(FAKE_HOME, "settings.json");
// The MAX_FILE_BYTES is typically 1MB; create a string close to that
// We don't have direct access to MAX_FILE_BYTES here, so read it from the discovery module
const discovery = require("../lib/cc-discovery");
const largeString = "x".repeat(discovery.MAX_FILE_BYTES + 100);
fs.writeFileSync(file, JSON.stringify({ data: largeString }));
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "ETOOLARGE");
return true;
}
);
});
it("rejects a write that would produce output exceeding MAX_FILE_BYTES", () => {
const discovery = require("../lib/cc-discovery");
const nearLimit = Math.floor(discovery.MAX_FILE_BYTES / 2);
const largeValue = "y".repeat(nearLimit);
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { a: largeValue, b: largeValue },
}),
(err) => {
assert.equal(err.code, "ETOOLARGE");
return true;
}
);
});
});
describe("patchSettings — backup and atomicity", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("creates a backup of an existing file before writing", () => {
const file = path.join(FAKE_HOME, "settings.json");
const original = { model: "opus", data: "original" };
fs.writeFileSync(file, JSON.stringify(original));
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "sonnet" },
});
assert.notEqual(result.backupPath, null);
assert(fs.existsSync(result.backupPath));
const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8"));
assert.deepEqual(backup, original);
});
it("writes and backs up nothing when the patch changes nothing", () => {
// Re-attaching the same AI Proxy profile sends the same eight env keys
// every time. With an unconditional backup that left a `.bak` per apply
// piling up inside the user's own project — a dozen untracked files in a
// minute of switching lanes.
const file = path.join(FAKE_HOME, "settings.json");
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
const before = fs.readFileSync(file, "utf8");
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
assert.equal(result.backupPath, null);
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
assert.equal(fs.readFileSync(file, "utf8"), before);
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
});
it("returns null backupPath for a fresh file", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
assert.equal(result.backupPath, null);
});
it("backup path includes the settings file name and a timestamp", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ existing: true }));
const result = ccMutate.patchSettings({
scope: "user",
set: { new: "key" },
});
assert(result.backupPath.includes("settings.json"));
assert(result.backupPath.includes(".bak"));
});
it("backup is stored under .cc-config-backups/settings/", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ existing: true }));
const result = ccMutate.patchSettings({
scope: "user",
set: { new: "key" },
});
assert(result.backupPath.includes(".cc-config-backups/settings/"));
});
});
describe("patchSettings — edge cases", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects when both set and unset are empty", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: {},
unset: [],
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
assert.match(err.message, /nothing to change/);
return true;
}
);
});
it("rejects when set is not an object", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: "not an object",
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("rejects when set is an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: ["a", "b"],
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("rejects when unset is not an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
unset: "not an array",
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("accepts various JSON types as values", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: {
string: "value",
number: 42,
boolean: true,
null_value: null,
array: [1, 2, 3],
object: { nested: "data" },
},
});
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.string, "value");
assert.equal(content.number, 42);
assert.equal(content.boolean, true);
assert.equal(content.null_value, null);
assert.deepEqual(content.array, [1, 2, 3]);
assert.deepEqual(content.object, { nested: "data" });
});
it("lists only keys that were actually changed in the response", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ unchanged: "kept", changed: "old" }));
const result = ccMutate.patchSettings({
scope: "user",
set: { changed: "new" },
});
assert.deepEqual(result.changed, ["changed"]);
assert(!result.changed.includes("unchanged"));
});
});