agap-mcp: authenticate the :3100 listener (kb#180), pin bw CLI, add capture/classifier

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>
This commit is contained in:
2026-07-30 04:40:51 +00:00
parent 5b649123a8
commit fc4e1c75ed
11 changed files with 1089 additions and 79 deletions

View File

@@ -0,0 +1,45 @@
// Proof for kb#170 component 2 — run with:
// BGE_M3_URL=http://localhost:11436/v1/embeddings node src/classifier.test.mjs
// (default BGE_M3_URL assumes host.docker.internal, which only resolves
// inside a container; override to localhost when running on the Agap host
// directly, same pattern as the bge-m3 curl checks elsewhere in this repo).
//
// This is a LIVE test against the real bge-m3 embedder (no mock) — the
// point of an encoder-only classifier is that it's cheap enough to just
// call for real (~30 short strings embedded once, then one embedding per
// test case). It does not touch Todoist, Kanboard, or any other live
// service.
import assert from 'node:assert/strict';
import { classifyIdea } from './classifier.js';
const cases = [
{ text: 'позвонить маме поздравить с днём рождения', expectArea: 'семья' },
{ text: 'купить новый пылесос для дома', expectArea: 'дом' },
{ text: 'записаться на приём к стоматологу', expectArea: 'здоровье' },
{ text: 'починить квоту Kimi у Adolf, срочно сегодня', expectArea: 'adolf', expectUrgency: 'high' },
{ text: 'спроектировать и запустить proactive-секретаря на Agap', expectArea: 'welfare', expectDecompose: 'needs-decomposition' },
{ text: 'оплатить счёт за интернет', expectDecompose: 'simple-task' },
{ text: 'организовать переезд на новую квартиру, когда-нибудь', expectDecompose: 'needs-decomposition', expectUrgency: 'low' },
];
let passed = 0;
let failed = 0;
for (const c of cases) {
const result = await classifyIdea(c.text);
const row = `"${c.text}" -> area=${result.area.label}(${result.area.score}) urgency=${result.urgency.label}(${result.urgency.score}) decompose=${result.decompose.label}(${result.decompose.score})`;
try {
if (c.expectArea) assert.equal(result.area.label, c.expectArea, `area mismatch for "${c.text}"`);
if (c.expectUrgency) assert.equal(result.urgency.label, c.expectUrgency, `urgency mismatch for "${c.text}"`);
if (c.expectDecompose) assert.equal(result.decompose.label, c.expectDecompose, `decompose mismatch for "${c.text}"`);
console.log(`ok - ${row}`);
passed++;
} catch (e) {
console.log(`FAIL - ${row}\n ${e.message}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);