test/agent-behavior-policy.test.js
Rama main · réplica de lectura del repositorio original.
// Personal Agent behavior policy: schema, per-account persistence, the
// authenticated settings route and the runtime gate. No provider, account or
// real storage is touched: every dependency is injected.
var test = require("node:test");
var assert = require("node:assert/strict");
var EventEmitter = require("node:events");
var schema = require("../lib/agent-behavior-schema");
var attachPrefs = require("../lib/users-agent-behavior-preferences").attachAgentBehaviorPreferences;
var attachSettings = require("../lib/server-agent-behavior-settings").attachAgentBehaviorSettings;
var attachGate = require("../lib/agent-behavior-gate").attachAgentBehavior;
var ALL_MODE_IDS = schema.SETTINGS.filter(function (s) { return s.type === "mode"; }).map(function (s) { return s.id; });
function prefsHarness(initial, failSave) {
var data = { users: initial || [{ id: "u1" }, { id: "u2" }] };
var saves = 0;
var prefs = attachPrefs({
loadUsers: function () { return JSON.parse(JSON.stringify(data)); },
saveUsers: function (next) { if (failSave && failSave()) throw new Error("disk full"); data = next; saves++; },
});
return { prefs: prefs, data: function () { return data; }, saves: function () { return saves; } };
}
function request(handler, method, url, body) {
return new Promise(function (resolve) {
var req = new EventEmitter();
req.method = method;
var res = { status: null, headers: null, body: "", writeHead: function (s, h) { this.status = s; this.headers = h; }, end: function (b) { this.body = b || ""; resolve({ status: this.status, json: this.body ? JSON.parse(this.body) : null }); } };
var handled = handler.handleRequest(req, res, url);
if (!handled) { resolve({ handled: false }); return; }
if (method === "PUT") setImmediate(function () { if (body !== undefined) req.emit("data", typeof body === "string" ? body : JSON.stringify(body)); req.emit("end"); });
});
}
test("defaults preserve existing behavior and every mode setting is typed", function () {
var defaults = schema.defaults();
ALL_MODE_IDS.forEach(function (id) { assert.equal(defaults[id], "auto", id); });
assert.equal(defaults.issueLinkedNotes, true);
assert.deepEqual(schema.normalize(undefined), defaults);
assert.deepEqual(schema.normalize("junk"), defaults);
assert.deepEqual(schema.normalize([1, 2]), defaults);
schema.SETTINGS.forEach(function (setting) {
assert.ok(setting.label && setting.description, setting.id);
if (setting.type === "mode") {
["off", "request", "auto"].forEach(function (mode) {
assert.ok(setting.modes[mode].consequence.length > 10, setting.id + ":" + mode);
assert.ok(setting.modes[mode].example.length > 5, setting.id + ":" + mode + " example");
});
}
});
var text = JSON.stringify(schema.catalog());
assert.doesNotMatch(text, new RegExp('[' + String.fromCharCode(0x2013) + String.fromCharCode(0x2014) + ']'), "catalog text must not contain en or em dashes");
});
test("normalize drops unknown keys and invalid values; validateChanges is strict", function () {
var out = schema.normalize({ meUse: "off", notes: "sometimes", issues: 3, issueLinkedNotes: "yes", rogue: "x", __proto__: { logs: "off" } });
assert.equal(out.meUse, "off");
assert.equal(out.notes, "auto");
assert.equal(out.issues, "auto");
assert.equal(out.issueLinkedNotes, true);
assert.equal(out.logs, "auto");
assert.equal(out.rogue, undefined);
assert.deepEqual(schema.validateChanges({ meUse: "request" }), { value: { meUse: "request" } });
assert.match(schema.validateChanges({ meUse: "maybe" }).error, /Invalid value/);
assert.match(schema.validateChanges({ rogue: "off" }).error, /Unknown setting/);
assert.match(schema.validateChanges({ issueLinkedNotes: "off" }).error, /Invalid value/);
assert.match(schema.validateChanges({ meUse: true }).error, /Invalid value/);
assert.match(schema.validateChanges({}).error, /at least one/);
assert.match(schema.validateChanges(null).error, /object/);
assert.match(schema.validateChanges(["meUse"]).error, /object/);
assert.match(schema.validateChanges({ constructor: "off" }).error, /Unknown setting/);
});
test("the linked-note checkbox is only effective with Notes Auto and Issues not Off", function () {
assert.equal(schema.effective({}).issueLinkedNotes, true);
assert.equal(schema.effective({ notes: "request" }).issueLinkedNotes, false);
assert.equal(schema.effective({ notes: "off" }).issueLinkedNotes, false);
assert.equal(schema.effective({ issues: "off" }).issueLinkedNotes, false);
assert.equal(schema.effective({ issues: "request", notes: "auto" }).issueLinkedNotes, true);
assert.equal(schema.effective({ issueLinkedNotes: false }).issueLinkedNotes, false);
});
test("persistence is per account, merges changes and ignores malformed stored values", function () {
var fixture = prefsHarness([{ id: "u1" }, { id: "u2", agentBehavior: { logs: "off", bogus: 1, notes: "nope" } }]);
assert.deepEqual(fixture.prefs.getAgentBehavior("u1"), schema.defaults());
assert.equal(fixture.prefs.getAgentBehavior("u2").logs, "off");
assert.equal(fixture.prefs.getAgentBehavior("u2").notes, "auto");
assert.deepEqual(fixture.prefs.getAgentBehavior("missing"), schema.defaults());
var first = fixture.prefs.updateAgentBehavior("u1", { meUse: "off" });
assert.equal(first.ok, true);
assert.equal(fixture.prefs.updateAgentBehavior("u1", { issues: "request" }).policy.meUse, "off", "a second tab's change keeps the first");
assert.equal(fixture.prefs.getAgentBehavior("u2").meUse, "auto", "another account is untouched");
assert.deepEqual(fixture.data().users[1].agentBehavior, { logs: "off", bogus: 1, notes: "nope" }, "foreign record is not rewritten");
var cleaned = fixture.prefs.updateAgentBehavior("u2", { sketch: "off" });
assert.deepEqual(cleaned.policy.sketch, "off");
assert.equal(fixture.data().users[1].agentBehavior.bogus, undefined, "malformed values are dropped on the next explicit save");
assert.match(fixture.prefs.updateAgentBehavior("u1", { meUse: "x" }).error, /Invalid/);
assert.match(fixture.prefs.updateAgentBehavior("nobody", { meUse: "off" }).error, /not found/);
});
test("a failed write reports an error and leaves the stored policy unchanged", function () {
var failing = false;
var fixture = prefsHarness(null, function () { return failing; });
fixture.prefs.updateAgentBehavior("u1", { meUse: "off" });
failing = true;
var result = fixture.prefs.updateAgentBehavior("u1", { meUse: "request" });
assert.match(result.error, /Could not save/);
assert.equal(fixture.prefs.getAgentBehavior("u1").meUse, "off");
});
function multiHandler(fixture, user, hooks) {
return attachSettings({
users: { isMultiUser: function () { return true; }, getAgentBehavior: fixture.prefs.getAgentBehavior, updateAgentBehavior: fixture.prefs.updateAgentBehavior },
getMultiUserFromReq: function () { return user; },
opts: {},
onAgentBehaviorChanged: hooks && hooks.changed,
});
}
test("the route requires an authenticated owner and answers only after persistence", async function () {
var fixture = prefsHarness();
var changed = [];
var handler = multiHandler(fixture, { id: "u1" }, { changed: function (id, policy) { changed.push([id, policy.meUse]); } });
var got = await request(handler, "GET", "/api/user/agent-behavior");
assert.equal(got.status, 200);
assert.equal(got.json.policy.meUse, "auto");
assert.equal(got.json.catalog.settings.length, schema.SETTINGS.length);
var put = await request(handler, "PUT", "/api/user/agent-behavior", { changes: { meUse: "off" }, requestId: "r1" });
assert.equal(put.status, 200);
assert.equal(put.json.policy.meUse, "off");
assert.equal(put.json.requestId, "r1");
assert.equal(fixture.prefs.getAgentBehavior("u1").meUse, "off", "persisted before the acknowledgement");
assert.deepEqual(changed, [["u1", "off"]]);
var anonymous = await request(multiHandler(fixture, null), "GET", "/api/user/agent-behavior");
assert.equal(anonymous.status, 401);
assert.equal((await request(multiHandler(fixture, null), "PUT", "/api/user/agent-behavior", { changes: { meUse: "off" } })).status, 401);
assert.equal(fixture.prefs.getAgentBehavior("u2").meUse, "auto");
});
test("the route rejects malformed, foreign-key and oversized input without saving", async function () {
var fixture = prefsHarness();
var handler = multiHandler(fixture, { id: "u1" });
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", "{not json")).status, 400);
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", { changes: { rogue: "off" } })).status, 400);
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", { changes: { meUse: "nope" } })).status, 400);
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", { changes: [] })).status, 400);
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", { userId: "u2", changes: { meUse: "off" } })).json.policy.meUse, "off");
assert.equal(fixture.prefs.getAgentBehavior("u2").meUse, "auto", "the target account always comes from the session, never the body");
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", "x".repeat(20000))).status, 413);
var unrelated = await request(handler, "POST", "/api/user/agent-behavior");
assert.equal(unrelated.handled, false);
});
test("authentication revoked, replaced or switched while the body arrives is refused before persistence", async function () {
var fixture = prefsHarness();
var current = { id: "u1" };
var handler = attachSettings({
users: { isMultiUser: function () { return true; }, getAgentBehavior: fixture.prefs.getAgentBehavior, updateAgentBehavior: fixture.prefs.updateAgentBehavior },
getMultiUserFromReq: function () { return current; }, opts: {},
});
async function attempt(next) {
current = { id: "u1" };
return new Promise(function (resolve) {
var req = new EventEmitter(); req.method = "PUT";
var res = { writeHead: function (s) { this.status = s; }, end: function (b) { resolve({ status: this.status, json: JSON.parse(b) }); } };
handler.handleRequest(req, res, "/api/user/agent-behavior");
setImmediate(function () { current = next; req.emit("data", JSON.stringify({ changes: { notes: "off" } })); req.emit("end"); });
});
}
assert.equal((await attempt(null)).status, 401, "revoked mid-request");
assert.equal((await attempt({ id: "u2" })).status, 401, "replaced by another account mid-request");
assert.equal(fixture.prefs.getAgentBehavior("u1").notes, "auto");
assert.equal(fixture.prefs.getAgentBehavior("u2").notes, "auto");
assert.equal((await attempt({ id: "u1" })).status, 200, "an unchanged identity still saves");
var mode = { multi: true };
var switching = attachSettings({
users: { isMultiUser: function () { return mode.multi; }, getAgentBehavior: fixture.prefs.getAgentBehavior, updateAgentBehavior: fixture.prefs.updateAgentBehavior },
getMultiUserFromReq: function () { return { id: "u1" }; }, opts: { onUpdateAgentBehavior: function () { return { ok: true, policy: schema.defaults() }; } },
});
var result = await new Promise(function (resolve) {
var req = new EventEmitter(); req.method = "PUT";
var res = { writeHead: function (s) { this.status = s; }, end: function () { resolve(this.status); } };
switching.handleRequest(req, res, "/api/user/agent-behavior");
setImmediate(function () { mode.multi = false; req.emit("data", JSON.stringify({ changes: { notes: "off" } })); req.emit("end"); });
});
assert.equal(result, 401, "account mode changed mid-request");
});
test("a storage failure returns 500 and never acknowledges the change", async function () {
var failing = false;
var fixture = prefsHarness(null, function () { return failing; });
var handler = multiHandler(fixture, { id: "u1" });
failing = true;
var res = await request(handler, "PUT", "/api/user/agent-behavior", { changes: { logs: "off" } });
assert.equal(res.status, 500);
assert.match(res.json.error, /Could not save/);
assert.equal(fixture.prefs.getAgentBehavior("u1").logs, "auto");
});
test("a solo server stores the policy through the daemon callbacks", async function () {
var stored = { agentBehavior: undefined };
var handler = attachSettings({
users: { isMultiUser: function () { return false; } },
getMultiUserFromReq: function () { return null; },
opts: {
onGetDaemonConfig: function () { return { agentBehavior: schema.normalize(stored.agentBehavior) }; },
onUpdateAgentBehavior: function (changes) {
var checked = schema.validateChanges(changes);
if (checked.error) return { error: checked.error };
stored.agentBehavior = Object.assign({}, schema.explicit(stored.agentBehavior), checked.value);
return { ok: true, policy: schema.normalize(stored.agentBehavior) };
},
},
});
assert.equal((await request(handler, "GET", "/api/user/agent-behavior")).json.policy.notes, "auto");
var res = await request(handler, "PUT", "/api/user/agent-behavior", { changes: { notes: "off" } });
assert.equal(res.json.policy.notes, "off");
assert.equal((await request(handler, "GET", "/api/user/agent-behavior")).json.policy.notes, "off");
assert.equal((await request(handler, "PUT", "/api/user/agent-behavior", { changes: { rogue: 1 } })).status, 400);
var noWriter = attachSettings({ users: { isMultiUser: function () { return false; } }, getMultiUserFromReq: function () { return null; }, opts: {} });
assert.equal((await request(noWriter, "PUT", "/api/user/agent-behavior", { changes: { notes: "off" } })).status, 400);
});
// --- gate ---------------------------------------------------------------
function tool(name, extra) {
var calls = [];
return Object.assign({ name: name, description: "Original " + name + ".", inputSchema: {}, calls: calls, handler: function (args) { calls.push(args); return Promise.resolve({ content: [{ type: "text", text: "ran " + name }] }); } }, extra || {});
}
function gateFor(policyRef, options) {
var opts = options || {};
return attachGate({
isMate: opts.isMate === true,
isMultiUser: function () { return opts.multi !== false; },
getPolicy: function (ownerId) { return policyRef.byOwner ? policyRef.byOwner[ownerId] : policyRef.value; },
});
}
var DRIVER = { localId: 1, ownerId: "u1" };
test("with no stored choice every tool keeps its text and handler result", async function () {
var gate = gateFor({ value: undefined });
var defs = [tool("create_note_x"), tool("write_note"), tool("create_log"), tool("shared_browser")];
var out = gate.filterTools(DRIVER, defs);
assert.deepEqual(out.map(function (d) { return d.name; }), defs.map(function (d) { return d.name; }));
out.forEach(function (d, i) { assert.equal(d.description, defs[i].description); });
assert.equal((await out[1].handler({ a: 1 })).content[0].text, "ran write_note");
assert.equal(gate.notice(DRIVER), "", "no override block when nothing is restricted");
assert.deepEqual(gate.excludedSkills(DRIVER), []);
});
test("Off removes the tools, refuses a handler held from before, and names the setting", async function () {
var policy = { value: {} };
var gate = gateFor(policy);
var held = gate.filterTools(DRIVER, [tool("write_note"), tool("list_notes"), tool("create_issue")]);
assert.equal(held.length, 3);
policy.value = { notes: "off" };
assert.deepEqual(gate.filterTools(DRIVER, [tool("write_note"), tool("list_notes"), tool("create_issue")]).map(function (d) { return d.name; }), ["create_issue"]);
var refused = await held[0].handler({ text: "late" });
assert.equal(refused.isError, true);
assert.match(refused.content[0].text, /Sticky Notes/);
assert.equal((await held[2].handler({})).isError, undefined, "other settings are unaffected");
var bridge = gate.callRefusal(DRIVER, "write_note");
assert.equal(bridge.isError, true);
assert.equal(gate.callRefusal(DRIVER, "create_issue"), null);
assert.equal(gate.callRefusal(DRIVER, "unmapped_tool"), null);
assert.match(gate.notice(DRIVER), /Off, do not do these: using Sticky Notes/);
});
test("On request keeps the tool, prefixes its description and adds the notice", async function () {
var gate = gateFor({ value: { issues: "request", sharedBrowser: "request" } });
var defs = gate.filterTools(DRIVER, [tool("create_issue"), tool("shared_browser"), tool("write_note")]);
assert.match(defs[0].description, /^\[Personal setting: Issues is On request\./);
assert.match(defs[0].description, /Original create_issue\.$/);
assert.match(defs[1].description, /Shared Browser is On request/);
assert.equal(defs[2].description, "Original write_note.");
assert.equal((await defs[0].handler({})).isError, undefined, "On request is guidance, not a wording check");
assert.match(gate.notice(DRIVER), /On request, only when the user's message asks for it: using Project Issues; using the Shared Browser/);
});
test("GitHub and Linear links are independent; Linear read tools stay when Linear links are Off", async function () {
var all = function () { return [tool("link_session_github"), tool("get_session_github"), tool("link_session_linear"), tool("unlink_session_linear"), tool("read_linear_issue"), tool("search_linear_issues")]; };
var linearOff = gateFor({ value: { linearLinks: "off" } }).filterTools(DRIVER, all());
assert.deepEqual(linearOff.map(function (d) { return d.name; }), ["link_session_github", "get_session_github", "read_linear_issue", "search_linear_issues"]);
assert.match(linearOff[2].description, /Linear session links is Off/);
assert.equal(linearOff[0].description, "Original link_session_github.", "GitHub is untouched by the Linear setting");
assert.equal((await linearOff[2].handler({})).isError, undefined);
var githubOff = gateFor({ value: { githubLinks: "off" } }).filterTools(DRIVER, all());
assert.deepEqual(githubOff.map(function (d) { return d.name; }), ["link_session_linear", "unlink_session_linear", "read_linear_issue", "search_linear_issues"]);
var request = gateFor({ value: { linearLinks: "request" } });
assert.match(request.filterTools(DRIVER, all())[2].description, /Linear session links is On request/);
assert.match(request.notice(DRIVER), /linking this session to Linear issues/);
assert.doesNotMatch(request.notice(DRIVER), /GitHub/);
});
test("the learning setting adds its own prefix to log writes without removing the tools", function () {
var gate = gateFor({ value: { logsLearning: "off" } });
var defs = gate.filterTools(DRIVER, [tool("create_log"), tool("update_log"), tool("read_log")]);
assert.equal(defs.length, 3);
assert.match(defs[0].description, /Learning records is Off/);
assert.match(defs[1].description, /Learning records is Off/);
assert.equal(defs[2].description, "Original read_log.");
});
test("policy is per owner and Mate or curator sessions never read it", async function () {
var policies = { byOwner: { u1: { meUse: "off" }, u2: {} } };
var gate = gateFor(policies);
assert.equal(gate.filterTools({ localId: 1, ownerId: "u1" }, [tool("search_user_knowledge")]).length, 0);
assert.equal(gate.filterTools({ localId: 2, ownerId: "u2" }, [tool("search_user_knowledge")]).length, 1);
assert.equal(gate.filterTools({ localId: 3, ownerId: null }, [tool("search_user_knowledge")]).length, 1, "an ownerless session in account mode is outside the feature");
assert.equal(gate.filterTools({ localId: 4, ownerId: "u1", youCurator: true }, [tool("search_user_knowledge")]).length, 1);
var mate = gateFor({ value: { meUse: "off", notes: "off", logs: "off" } }, { isMate: true });
var defs = [tool("search_user_knowledge"), tool("write_note"), tool("create_log")];
assert.strictEqual(mate.filterTools(DRIVER, defs), defs, "Mate catalogs are returned untouched");
assert.equal(mate.notice(DRIVER), "");
assert.equal(mate.view(DRIVER).active, false);
assert.equal(mate.view(DRIVER).mode("notes"), "auto");
assert.equal(mate.callRefusal(DRIVER, "write_note"), null);
assert.deepEqual(mate.excludedSkills(DRIVER), []);
assert.strictEqual(mate.gateAdapter({ createToolServer: function () {} }, DRIVER).createToolServer.length, 0);
});
test("solo mode reads the single policy for every session", function () {
var gate = gateFor({ value: { sketch: "off" } }, { multi: false });
assert.deepEqual(gate.filterTools({ localId: 1, ownerId: null }, [tool("present_wireframe"), tool("present_markdown_edit")]).map(function (d) { return d.name; }), ["present_markdown_edit"]);
assert.deepEqual(gate.excludedSkills({ localId: 1 }), ["clay-sketch"]);
});
test("a policy lookup that throws falls back to today's behavior instead of failing the turn", function () {
var gate = attachGate({ isMate: false, isMultiUser: function () { return true; }, getPolicy: function () { throw new Error("storage unavailable"); } });
assert.equal(gate.filterTools(DRIVER, [tool("write_note")]).length, 1);
});
test("gateAdapter mounts only kept tools and drops a server whose tools are all off", function () {
var mounted = [];
var adapter = { vendor: "x", createToolServer: function (spec) { mounted.push(spec); return { name: spec.name, tools: spec.tools }; } };
var gate = gateFor({ value: { notes: "off", issues: "off" } });
var gated = gate.gateAdapter(adapter, DRIVER);
assert.equal(gated.vendor, "x");
assert.equal(gated.createToolServer({ name: "clay-notes", tools: [tool("write_note"), tool("list_notes")] }), null);
var mixed = gated.createToolServer({ name: "clay-documents", tools: [tool("present_markdown_edit")] });
assert.equal(mixed.tools.length, 1);
assert.equal(gated.createToolServer({ name: "empty", tools: [] }).tools.length, 0);
assert.equal(mounted.length, 2);
});
test("bridge listings drop Off tools and prefix On request tools", function () {
var gate = gateFor({ value: { notes: "off", continuation: "request" } });
var list = [
{ server: "clay-notes", name: "write_note", description: "d", inputSchema: {} },
{ server: "clay-sessions", name: "propose_driver_continuation", description: "d", inputSchema: {} },
{ server: "x", name: "other", description: "d", inputSchema: {} },
];
var out = gate.filterBridgeTools(DRIVER, list);
assert.deepEqual(out.map(function (t) { return t.name; }), ["propose_driver_continuation", "other"]);
assert.match(out[0].description, /Continuation proposals is On request/);
assert.equal(out[1], list[2]);
});
test("autoPrompt includes module text only for Auto", function () {
var policy = { value: {} };
var gate = gateFor(policy);
function build() { return "TEXT"; }
assert.equal(gate.autoPrompt(DRIVER, "issues", build), "TEXT");
policy.value = { issues: "request" };
assert.equal(gate.autoPrompt(DRIVER, "issues", build), "");
policy.value = { issues: "off" };
assert.equal(gate.autoPrompt(DRIVER, "issues", build), "");
});
test("every governed tool name is covered by a catalog setting", function () {
var gateModule = require("../lib/agent-behavior-gate");
var ids = {};
schema.SETTINGS.forEach(function (s) { ids[s.id] = true; });
Object.keys(gateModule.TOOL_SETTING).forEach(function (name) { assert.ok(ids[gateModule.TOOL_SETTING[name].id], name); });
});