// 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); }); });