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>
46 lines
2.5 KiB
JavaScript
46 lines
2.5 KiB
JavaScript
// 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);
|