Files
AgapHost/agap-mcp/src/capture.test.mjs
alvis fc4e1c75ed 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>
2026-07-30 04:40:51 +00:00

85 lines
3.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`);