Listener auth (kb#180, DESIGN-a2a-agents.md §4)
-----------------------------------------------
agap-mcp binds :3100 on every interface (network_mode: host) and the LAN
carries VPN-terminated peers, so an unauthenticated JSON-RPC listener handed
ha_call_service / gitea_wiki_write / wiki_edit / radicale+todoist writes and
POST /capture-idea to any LAN peer. Only vw_* was gated before (kb#147), and
only at ENFORCE=1.
src/listener-auth.js now requires `Authorization: Bearer <token>` resolving to
a known agent id on every route except /health, which stays open so a
misconfigured token map is still diagnosable. Two gates stay deliberately
layered and independently switchable: "are you an agent at all?" (this file)
vs "are you trusted enough for the vault?" (trust-gate.js), both reading the
same token map.
Also closes an SSE session-hijack hole: /messages previously trusted any
sessionId with no credential, so a guessed or leaked id was full tool access.
Sessions are now pinned to the caller identity captured at the /sse handshake,
comparing agent id *and* token.
Auth defaults ON, and boot fails loudly if the token map is empty rather than
serving 401 to everyone while /health reports ok. Rollback is
AGAP_MCP_REQUIRE_AUTH=0.
Verified live: unauthenticated and bad-token /mcp -> 401, unauthenticated
/capture-idea -> 401, /health -> 200, both real agent tokens -> 200 with 36
tools, including from inside the adolf container.
Pin the bw CLI
--------------
The Dockerfile installed @bitwarden/cli unpinned. Rebuilding jumped
2026.2.0 -> 2026.7.0, whose WASM cipher deserializer rejects any stored login
carrying `"uri": null` ("invalid type: JsValue(Object({...})), expected a
string") -- 33 of 49 items in this vault have that shape. `bw list` then exits
1, server init fails, and the container crash-loops. Pinned to 2026.2.0.
Do not unpin: 2026.7.0 cannot authenticate against this Vaultwarden
(2025.12.0) at all -- it refuses plain HTTP outright and 404s on the identity
endpoint over HTTPS. Updating the CLI requires upgrading Vaultwarden first.
capture / classifier
--------------------
Adds the POST /capture-idea REST endpoint and the idea classifier behind it
(consumed by the todoist-capture plugin), with tests. Carried in the same
commit because server.js wires both this and the auth boot path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
3.2 KiB
JavaScript
85 lines
3.2 KiB
JavaScript
// Proof for kb#170 capture pipeline (classify -> Todoist task shape),
|
||
// run with:
|
||
// BGE_M3_URL=http://localhost:11436/v1/embeddings node src/capture.test.mjs
|
||
//
|
||
// Deliberately stubs createTask/listProjects instead of calling the real
|
||
// Todoist API — this proves the classify -> label/priority/project mapping
|
||
// logic without writing test data into the user's live Todoist account
|
||
// (kb#170 report: no live Todoist writes were made while proving this out).
|
||
|
||
import assert from 'node:assert/strict';
|
||
import { todoistCaptureIdea } from './capture.js';
|
||
|
||
let passed = 0;
|
||
function check(label, fn) {
|
||
fn();
|
||
passed++;
|
||
console.log(`ok - ${label}`);
|
||
}
|
||
|
||
const projects = [
|
||
{ id: '6CrfPQ8FXxf5ghrx', name: 'Inbox', is_inbox: true },
|
||
{ id: '6cg4j8CX3vj7H9rJ', name: 'Family' },
|
||
];
|
||
|
||
function makeStubCreateTask() {
|
||
const calls = [];
|
||
const createTask = async (args) => {
|
||
calls.push(args);
|
||
return { id: 'stub-1', content: args.content, project_id: args.project_id, priority: args.priority, labels: args.labels };
|
||
};
|
||
return { createTask, calls };
|
||
}
|
||
|
||
const { createTask: createTaskFamily, calls: callsFamily } = makeStubCreateTask();
|
||
const familyResult = await todoistCaptureIdea(
|
||
{ text: 'позвонить маме поздравить с днём рождения' },
|
||
{ listProjects: async () => projects, createTask: createTaskFamily }
|
||
);
|
||
|
||
check('семья idea auto-routes to the existing Family project', () => {
|
||
assert.equal(familyResult.classification.area.label, 'семья');
|
||
assert.equal(callsFamily[0].project_id, '6cg4j8CX3vj7H9rJ');
|
||
});
|
||
|
||
check('семья idea is labelled area-семья + urgency-*', () => {
|
||
assert.ok(callsFamily[0].labels.includes('area-семья'));
|
||
assert.ok(callsFamily[0].labels.some((l) => l.startsWith('urgency-')));
|
||
});
|
||
|
||
const { createTask: createTaskUrgent, calls: callsUrgent } = makeStubCreateTask();
|
||
await todoistCaptureIdea(
|
||
{ text: 'починить квоту Kimi у Adolf, срочно сегодня' },
|
||
{ listProjects: async () => projects, createTask: createTaskUrgent }
|
||
);
|
||
|
||
check('high-urgency idea gets Todoist priority 4 (urgent)', () => {
|
||
assert.equal(callsUrgent[0].priority, 4);
|
||
});
|
||
|
||
check('adolf-area idea is NOT auto-routed to a project (no matching project exists)', () => {
|
||
assert.equal(callsUrgent[0].project_id, undefined);
|
||
});
|
||
|
||
const { createTask: createTaskProject, calls: callsProject } = makeStubCreateTask();
|
||
await todoistCaptureIdea(
|
||
{ text: 'купить новый пылесос для дома', project_id: 'explicit-override' },
|
||
{ listProjects: async () => projects, createTask: createTaskProject }
|
||
);
|
||
|
||
check('an explicit project_id always wins over auto-routing', () => {
|
||
assert.equal(callsProject[0].project_id, 'explicit-override');
|
||
});
|
||
|
||
const { createTask: createTaskDecompose, calls: callsDecompose } = makeStubCreateTask();
|
||
await todoistCaptureIdea(
|
||
{ text: 'спроектировать и запустить proactive-секретаря на Agap' },
|
||
{ listProjects: async () => projects, createTask: createTaskDecompose }
|
||
);
|
||
|
||
check('a multi-step idea gets the "decompose" label', () => {
|
||
assert.ok(callsDecompose[0].labels.includes('decompose'));
|
||
});
|
||
|
||
console.log(`\n${passed} passed`);
|