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:
@@ -5,7 +5,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN npm install -g @bitwarden/cli
|
||||
# PINNED — do not float this back to `@bitwarden/cli` (kb#180, 2026-07-30).
|
||||
# An unpinned rebuild pulled 2026.7.0, whose WASM cipher deserializer rejects
|
||||
# any stored login object with `"uri": null` ("invalid type: JsValue(Object({...})),
|
||||
# expected a string") — the MATRIX_ADOLF_GATEWAY_TOKEN item in this vault has
|
||||
# exactly that shape. `bw list` then exits 1, server.js's init fails, and the
|
||||
# container crash-loops on restart. 2026.2.0 parses that item fine and is the
|
||||
# version the host CLI runs. Re-test against the live vault before bumping.
|
||||
RUN npm install -g @bitwarden/cli@2026.2.0
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY src/ ./src/
|
||||
|
||||
@@ -36,9 +36,40 @@ services:
|
||||
# inlined in this committed file. Empty object = no caller resolves to
|
||||
# any agent, i.e. fail-closed once ENFORCE is turned on.
|
||||
- AGAP_MCP_AGENT_TOKENS=${AGAP_MCP_AGENT_TOKENS:-{}}
|
||||
# kb#180 (DESIGN-a2a-agents.md §4) — authentication of the LISTENER
|
||||
# itself, a strictly larger gate than the vw_*-only one above. With
|
||||
# this on (the default in code), /mcp, /sse, /messages and
|
||||
# /capture-idea all require `Authorization: Bearer <token>` resolving
|
||||
# to an agent id in AGAP_MCP_AGENT_TOKENS; /health stays open for this
|
||||
# healthcheck. :3100 is bound on every interface (network_mode: host)
|
||||
# and the LAN carries VPN-terminated peers, so an open listener means
|
||||
# any peer can call ha_call_service / gitea_wiki_write / wiki_edit /
|
||||
# todoist writes.
|
||||
#
|
||||
# ACTIVATION IS NOT AUTOMATIC-SAFE: with auth on and
|
||||
# AGAP_MCP_AGENT_TOKENS empty, the process REFUSES TO START (loud
|
||||
# crash instead of denying every caller while /health says ok). So
|
||||
# AGAP_MCP_AGENT_TOKENS must be populated in this directory's .env
|
||||
# BEFORE the next restart of this service, and every caller
|
||||
# (Adolf/shared-mcp.json, Claude Code .claude.json, the
|
||||
# todoist-capture-plugin) must be given its token — see the kb#180
|
||||
# migration list. Set AGAP_MCP_REQUIRE_AUTH=0 in .env only as a
|
||||
# deliberate emergency rollback to the old open listener.
|
||||
- AGAP_MCP_REQUIRE_AUTH=${AGAP_MCP_REQUIRE_AUTH:-1}
|
||||
volumes:
|
||||
- /home/alvis/.config/Bitwarden CLI:/bw-data
|
||||
# Read-only: agent-registry.yaml is the version-controlled source of
|
||||
# truth for trust classes (kb#134/kb#147) — mounted, never copied, so
|
||||
# a registry edit takes effect on container restart with no rebuild.
|
||||
- /home/alvis/agap_git/openai/agent-registry.yaml:/agent-registry.yaml:ro
|
||||
# kb#190: /health responds 200 with no auth/side effects (confirmed).
|
||||
# This is a SEPARATE compose project from openai/docker-compose.yml
|
||||
# (network_mode: host, reached from adolf-llm etc. via
|
||||
# host.docker.internal), so it cannot be wired into that file's
|
||||
# depends_on/condition chain -- this only gives it its own status.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
58
agap-mcp/src/capture.js
Normal file
58
agap-mcp/src/capture.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Idea capture pipeline (kb#170): classify -> Todoist task.
|
||||
//
|
||||
// Deliberately does NOT create/move anything in Kanboard, Radicale, or
|
||||
// gitea — kb#170 component 4 ("Adolf предлагает, какие идеи созрели")
|
||||
// is a human-in-the-loop periodic review, not an automatic conversion.
|
||||
// This module only tags the idea so that later review has something to
|
||||
// act on (labels: area-*, urgency-*, and "decompose" when flagged).
|
||||
//
|
||||
// Project/label mapping is a deliberate v1 decision, not a guess left
|
||||
// unstated: Todoist's real projects today (Inbox, One-Off, Family,
|
||||
// Planning, Pending — confirmed live via todoist_list_projects) don't
|
||||
// line up with the 5 kb#170 areas except "семья" ~= "Family". Creating
|
||||
// four new Todoist projects to match Adolf/Welfare/дом/здоровье is a
|
||||
// structural change to the user's real Todoist account, so it is NOT done
|
||||
// here without explicit sign-off — see kb#170 report. Instead every idea
|
||||
// keeps its default project (Inbox, unless the caller passes one) and
|
||||
// gets an `area-*` label, which is purely additive/reversible (Todoist
|
||||
// auto-creates labels on first use, and any label can be deleted later
|
||||
// with zero data loss).
|
||||
import { classifyIdea } from './classifier.js';
|
||||
import { todoistCreateTask } from './todoist.js';
|
||||
|
||||
const FAMILY_PROJECT_NAME = 'Family';
|
||||
|
||||
// urgency label -> Todoist API priority (1=normal..4=urgent, inverse of
|
||||
// the Todoist UI's p1..p4 — see todoist.js header comment).
|
||||
const URGENCY_TO_PRIORITY = { high: 4, medium: 2, low: 1 };
|
||||
|
||||
export async function todoistCaptureIdea({ text, project_id } = {}, { listProjects = null, createTask = todoistCreateTask } = {}) {
|
||||
if (!text || !text.trim()) throw new Error('text is required');
|
||||
|
||||
const classification = await classifyIdea(text.trim());
|
||||
|
||||
const labels = [`area-${classification.area.label}`, `urgency-${classification.urgency.label}`];
|
||||
if (classification.decompose.label === 'needs-decomposition') labels.push('decompose');
|
||||
if (classification.area.ambiguous) labels.push('area-uncertain');
|
||||
|
||||
// Only auto-route to an existing project when it's an unambiguous, exact
|
||||
// match (семья -> Family) — never invent/select a project the classifier
|
||||
// merely guessed at, and never override a project_id the caller passed
|
||||
// explicitly.
|
||||
let resolvedProjectId = project_id;
|
||||
if (!resolvedProjectId && classification.area.label === 'семья' && !classification.area.ambiguous && typeof listProjects === 'function') {
|
||||
const projects = await listProjects();
|
||||
const family = projects.find((p) => p.name === FAMILY_PROJECT_NAME);
|
||||
if (family) resolvedProjectId = family.id;
|
||||
}
|
||||
|
||||
const task = await createTask({
|
||||
content: text.trim(),
|
||||
description: `Захвачено через AI-классификацию (kb#170): area=${classification.area.label} (${classification.area.score}), urgency=${classification.urgency.label} (${classification.urgency.score}), decompose=${classification.decompose.label} (${classification.decompose.score}).`,
|
||||
priority: URGENCY_TO_PRIORITY[classification.urgency.label],
|
||||
project_id: resolvedProjectId,
|
||||
labels,
|
||||
});
|
||||
|
||||
return { task, classification };
|
||||
}
|
||||
84
agap-mcp/src/capture.test.mjs
Normal file
84
agap-mcp/src/capture.test.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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`);
|
||||
228
agap-mcp/src/classifier.js
Normal file
228
agap-mcp/src/classifier.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// Todoist idea classifier (kb#170, component 2) — encoder-only, NOT a
|
||||
// classifier LLM call. Per DESIGN-a2a-agents.md v2.1 §3a/§3.1/theorem 24:
|
||||
// "routing classification is embedding-based on the local bge-m3 ... no
|
||||
// classifier LLM, no API spend". This module applies that same idea to
|
||||
// Todoist-capture classification: embed the idea text with bge-m3 (already
|
||||
// GPU-resident, never-evict per model-registry.yaml) and classify by
|
||||
// nearest-centroid against a small hand-labelled exemplar set — no Kimi/
|
||||
// gemma call, ~0 marginal cost, no metered API.
|
||||
//
|
||||
// Three independent classification axes (each idea gets one label per axis,
|
||||
// not a single combined class):
|
||||
// area — which part of life the idea belongs to (kb#170 spec)
|
||||
// urgency — how soon it stops being actionable
|
||||
// decompose — is this a single atomic action, or a multi-step project
|
||||
// that should eventually become a Kanboard task graph
|
||||
//
|
||||
// This is deliberately a NEAREST-CENTROID classifier, not a trained model:
|
||||
// no labelled training set exists (kb#170 orchestrator note — inventing one
|
||||
// would be guessing), so the "training data" IS the exemplar list below,
|
||||
// reviewed/editable in code (git-controlled, per DESIGN-a2a-agents.md
|
||||
// "Personas and Cards are code"). Extending accuracy later means adding
|
||||
// exemplars here, not retraining a model.
|
||||
|
||||
const DEFAULT_BGE_URL = process.env.BGE_M3_URL || 'http://host.docker.internal:11436/v1/embeddings';
|
||||
|
||||
// --- Exemplars -------------------------------------------------------------
|
||||
// Kept short and idiomatic (the kind of one-line idea a person actually
|
||||
// captures), Russian-first since that's the capture language (kb#170 desc).
|
||||
// Centroids are the mean of these exemplars' embeddings — adding more
|
||||
// exemplars per class only requires appending strings here.
|
||||
|
||||
const AREA_EXEMPLARS = {
|
||||
adolf: [
|
||||
'починить квоту Kimi у Adolf',
|
||||
'настроить cron задачу в Kanboard',
|
||||
'добавить новую MCP команду',
|
||||
'проверить логи agap-mcp контейнера',
|
||||
'написать воркер для очереди задач',
|
||||
'обновить конфиг openclaw.json',
|
||||
],
|
||||
welfare: [
|
||||
'продумать еженедельный ревью задач',
|
||||
'настроить трекер настроения и энергии',
|
||||
'сделать ежедневный брифинг по утрам',
|
||||
'завести журнал решений',
|
||||
'придумать систему напоминаний о важных вещах',
|
||||
'разобраться с личной продуктивностью',
|
||||
'спроектировать proactive-секретаря для себя',
|
||||
'построить систему, которая сама напоминает и планирует',
|
||||
'придумать, как автоматизировать личный распорядок дня',
|
||||
],
|
||||
'дом': [
|
||||
'купить новый пылесос',
|
||||
'почистить фильтр кондиционера',
|
||||
'вызвать сантехника починить кран',
|
||||
'заказать доставку воды',
|
||||
'разобрать кладовку',
|
||||
'поменять лампочку в коридоре',
|
||||
'оплатить счёт за квартиру',
|
||||
'оплатить интернет и коммуналку',
|
||||
],
|
||||
'семья': [
|
||||
'позвонить маме',
|
||||
'поздравить сестру с днём рождения',
|
||||
'купить подарок жене',
|
||||
'спланировать поездку с семьёй',
|
||||
'написать бабушке',
|
||||
'забрать детей из школы',
|
||||
],
|
||||
'здоровье': [
|
||||
'записаться к врачу',
|
||||
'сдать анализы крови',
|
||||
'начать бегать по утрам',
|
||||
'купить витамины',
|
||||
'сходить к стоматологу',
|
||||
'записаться на массаж',
|
||||
],
|
||||
};
|
||||
|
||||
const URGENCY_EXEMPLARS = {
|
||||
high: [
|
||||
'сделать это сегодня, срочно',
|
||||
'дедлайн завтра утром',
|
||||
'оплатить штраф до пятницы, иначе пени',
|
||||
'нужно решить прямо сейчас',
|
||||
],
|
||||
medium: [
|
||||
'сделать на этой неделе',
|
||||
'стоит сделать в ближайшие дни',
|
||||
'через пару дней надо разобраться',
|
||||
'неплохо бы успеть до конца месяца',
|
||||
],
|
||||
low: [
|
||||
'когда-нибудь было бы неплохо',
|
||||
'не к спеху, просто идея на будущее',
|
||||
'если будет время',
|
||||
'мысль про потом, без срока',
|
||||
],
|
||||
};
|
||||
|
||||
const DECOMPOSE_EXEMPLARS = {
|
||||
'needs-decomposition': [
|
||||
'организовать переезд на новую квартиру',
|
||||
'спроектировать и запустить новый сервис на сервере',
|
||||
'спланировать отпуск в другую страну',
|
||||
'построить систему проактивного секретаря',
|
||||
'провести ремонт в квартире',
|
||||
'подготовить и провести презентацию проекта',
|
||||
],
|
||||
'simple-task': [
|
||||
'позвонить маме',
|
||||
'купить хлеб',
|
||||
'оплатить счёт за интернет',
|
||||
'отправить один email',
|
||||
'поставить будильник',
|
||||
'записать одну мысль в заметки',
|
||||
],
|
||||
};
|
||||
|
||||
// --- Embeddings + cosine similarity -----------------------------------------
|
||||
|
||||
async function embed(text, bgeUrl = DEFAULT_BGE_URL) {
|
||||
const res = await fetch(bgeUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'bge-m3', input: text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`bge-m3 embeddings ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const vec = body?.data?.[0]?.embedding;
|
||||
if (!Array.isArray(vec)) throw new Error('bge-m3 embeddings: no vector in response');
|
||||
return vec;
|
||||
}
|
||||
|
||||
function dot(a, b) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
||||
return s;
|
||||
}
|
||||
|
||||
function norm(a) {
|
||||
return Math.sqrt(dot(a, a));
|
||||
}
|
||||
|
||||
function normalize(a) {
|
||||
const n = norm(a) || 1;
|
||||
return a.map((x) => x / n);
|
||||
}
|
||||
|
||||
function mean(vectors) {
|
||||
const dim = vectors[0].length;
|
||||
const out = new Array(dim).fill(0);
|
||||
for (const v of vectors) for (let i = 0; i < dim; i++) out[i] += v[i];
|
||||
return out.map((x) => x / vectors.length);
|
||||
}
|
||||
|
||||
function cosine(a, b) {
|
||||
return dot(a, b) / ((norm(a) || 1) * (norm(b) || 1));
|
||||
}
|
||||
|
||||
// --- Centroid cache ----------------------------------------------------------
|
||||
// Computed once per process (exemplars are static, embedding a few dozen
|
||||
// short strings at startup is cheap and happens lazily on first classify()
|
||||
// call, not at import time — keeps agap-mcp's init() path unaffected).
|
||||
|
||||
let _centroidsPromise = null;
|
||||
|
||||
async function buildCentroidSet(exemplarMap, bgeUrl) {
|
||||
const labels = Object.keys(exemplarMap);
|
||||
const centroids = {};
|
||||
for (const label of labels) {
|
||||
const vectors = await Promise.all(exemplarMap[label].map((t) => embed(t, bgeUrl).then(normalize)));
|
||||
centroids[label] = normalize(mean(vectors));
|
||||
}
|
||||
return centroids;
|
||||
}
|
||||
|
||||
async function getCentroids(bgeUrl = DEFAULT_BGE_URL) {
|
||||
if (!_centroidsPromise) {
|
||||
_centroidsPromise = Promise.all([
|
||||
buildCentroidSet(AREA_EXEMPLARS, bgeUrl),
|
||||
buildCentroidSet(URGENCY_EXEMPLARS, bgeUrl),
|
||||
buildCentroidSet(DECOMPOSE_EXEMPLARS, bgeUrl),
|
||||
]).then(([area, urgency, decompose]) => ({ area, urgency, decompose }));
|
||||
}
|
||||
return _centroidsPromise;
|
||||
}
|
||||
|
||||
// Test-only: let tests reset the cache (e.g. to inject a different BGE_URL).
|
||||
export function _resetCentroidCacheForTests() {
|
||||
_centroidsPromise = null;
|
||||
}
|
||||
|
||||
// nearestLabel: pick argmax cosine similarity; also report the runner-up
|
||||
// and the margin between them. A small margin means the idea sits between
|
||||
// two classes — surfaced as `ambiguous: true` rather than silently forced,
|
||||
// so the periodic-review pass (kb#170 component 4) can have Adolf confirm
|
||||
// instead of trusting a low-confidence auto-tag.
|
||||
function nearestLabel(vec, centroidMap) {
|
||||
const scored = Object.entries(centroidMap)
|
||||
.map(([label, centroid]) => ({ label, score: cosine(vec, centroid) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const [top, second] = scored;
|
||||
const margin = second ? top.score - second.score : 1;
|
||||
return {
|
||||
label: top.label,
|
||||
score: Number(top.score.toFixed(4)),
|
||||
margin: Number(margin.toFixed(4)),
|
||||
ambiguous: margin < 0.03, // empirical starting threshold — revisit once real captures accumulate (same posture as DESIGN-proactive-prioritization.md's tunable constants)
|
||||
};
|
||||
}
|
||||
|
||||
// classify: the one entry point. Embeds the idea text ONCE, reuses it
|
||||
// across all three axes (one bge-m3 call, not three) — consistent with
|
||||
// the "no metered/needless calls" cost discipline in DESIGN-a2a-agents.md.
|
||||
export async function classifyIdea(text, { bgeUrl = DEFAULT_BGE_URL } = {}) {
|
||||
if (!text || !text.trim()) throw new Error('text is required');
|
||||
const [vec, centroids] = await Promise.all([embed(text, bgeUrl).then(normalize), getCentroids(bgeUrl)]);
|
||||
return {
|
||||
area: nearestLabel(vec, centroids.area),
|
||||
urgency: nearestLabel(vec, centroids.urgency),
|
||||
decompose: nearestLabel(vec, centroids.decompose),
|
||||
};
|
||||
}
|
||||
|
||||
export const _internal = { AREA_EXEMPLARS, URGENCY_EXEMPLARS, DECOMPOSE_EXEMPLARS, cosine, embed, getCentroids };
|
||||
45
agap-mcp/src/classifier.test.mjs
Normal file
45
agap-mcp/src/classifier.test.mjs
Normal 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);
|
||||
@@ -3,6 +3,20 @@ import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Askpass helper: git invokes this script (path is what shows up in ps/args),
|
||||
// and it reads the actual token from an env var — never from argv or the URL.
|
||||
// This keeps the token out of the process table and out of any git error text.
|
||||
let _askpassPath = null;
|
||||
function askpassScript() {
|
||||
if (_askpassPath) return _askpassPath;
|
||||
const dir = join(tmpdir(), 'agap-mcp-wiki');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const scriptPath = join(dir, 'git-askpass.sh');
|
||||
writeFileSync(scriptPath, '#!/bin/sh\nprintf %s "$GITEA_ASKPASS_TOKEN"\n', { mode: 0o700 });
|
||||
_askpassPath = scriptPath;
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
const BASE = () => process.env.GITEA_URL || 'http://localhost:3000';
|
||||
let _token = null;
|
||||
|
||||
@@ -48,9 +62,19 @@ export async function giteaWikiRead(page, repo = 'alvis/AgapHost') {
|
||||
|
||||
export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') {
|
||||
const dir = join(tmpdir(), 'agap-mcp-wiki');
|
||||
const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`;
|
||||
// Username in the URL is not secret; the password/token is supplied out-of-band
|
||||
// via GIT_ASKPASS + GITEA_ASKPASS_TOKEN, so it never appears in the URL, the
|
||||
// execSync command string, ps/process args, or surfaced git error output.
|
||||
const wikiUrl = `${BASE().replace('http://', 'http://alvis@')}/alvis/AgapHost.wiki.git`;
|
||||
|
||||
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' };
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com',
|
||||
GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com',
|
||||
GIT_ASKPASS: askpassScript(),
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GITEA_ASKPASS_TOKEN: token(),
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||
|
||||
147
agap-mcp/src/listener-auth.js
Normal file
147
agap-mcp/src/listener-auth.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// listener-auth — kb#180: authenticate the agap-mcp :3100 listener itself,
|
||||
// not just the vault tools.
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// agap-mcp runs `network_mode: host` and binds :3100 on every interface. The
|
||||
// LAN is explicitly NOT a trust boundary here — DESIGN-a2a-agents.md v2.1 §4
|
||||
// ("Auth is mandatory on every A2A surface. The LAN is not trusted — the
|
||||
// xray/3x-ui VPN terminates other people's peers on it. No unauthenticated
|
||||
// JSON-RPC listener, ever: shared tokens minimum, mTLS preferred.").
|
||||
//
|
||||
// Before this module, the ONLY gate in the process was requireVaultAccess()
|
||||
// (kb#147), which covers vw_* tools alone and only at ENFORCE=1. Everything
|
||||
// else — ha_call_service, gitea_wiki_write, wiki_edit, radicale_*/todoist_*
|
||||
// writes, and the plain-REST POST /capture-idea — was callable by any LAN
|
||||
// peer with curl. This module closes that: the transport itself now requires
|
||||
// a bearer token that resolves to a known agent id.
|
||||
//
|
||||
// TWO DISTINCT GATES, DELIBERATELY LAYERED
|
||||
// 1. listener auth (this file) — "are you *an* agent at all?" → any id in
|
||||
// AGAP_MCP_AGENT_TOKENS passes; unknown/absent token = 401.
|
||||
// 2. vault trust gate (trust-gate.js / requireVaultAccess in server.js)
|
||||
// — "are you trust_class >= trusted?" → only then may vw_* run.
|
||||
// Both read the SAME token map, so one token per agent covers both. Gate 2
|
||||
// stays independently switchable (AGAP_MCP_ENFORCE_VAULT_TRUST) exactly as
|
||||
// kb#147 shipped it; turning on gate 1 does not turn on gate 2.
|
||||
//
|
||||
// FAIL-FAST, NOT FAIL-SILENT
|
||||
// Auth is ON by default (AGAP_MCP_REQUIRE_AUTH != '0'). If it is on and the
|
||||
// token map is empty, assertListenerAuthConfig() throws at boot rather than
|
||||
// letting the process serve 401 to literally everyone while /health says
|
||||
// "ok" — a missing .env value must look like a broken restart, not like a
|
||||
// quietly dead integration. See server.js boot path.
|
||||
//
|
||||
// SSE SESSION BINDING
|
||||
// The legacy SSE transport hands out a sessionId at GET /sse and accepts JSON-RPC
|
||||
// on POST /messages?sessionId=... . Previously /messages trusted ANY sessionId
|
||||
// with no credential — a session-id guess/leak was full tool access (hijack).
|
||||
// bindSseSession()/authorizeSseSession() below pin the caller identity captured
|
||||
// at handshake to the session, and /messages must present the same agent's
|
||||
// token or it is rejected.
|
||||
|
||||
import { resolveCallerAgent, authHeaderToken } from './trust-gate.js';
|
||||
|
||||
// ON unless explicitly disabled. The opposite default from kb#147's vault gate
|
||||
// on purpose: an unauthenticated JSON-RPC listener is the thing §4 forbids
|
||||
// outright, so "off" has to be a deliberate, visible opt-out.
|
||||
export function requireAuthEnabled(env = process.env) {
|
||||
return env.AGAP_MCP_REQUIRE_AUTH !== '0';
|
||||
}
|
||||
|
||||
// Routes that stay open even with auth on:
|
||||
// /health — the compose healthcheck calls it with no credential, it has no
|
||||
// side effects, and it returns only counts/booleans (no secrets, no tool
|
||||
// surface). Keeping it open is what lets a misconfigured token map still be
|
||||
// diagnosable from outside the container.
|
||||
export const PUBLIC_PATHS = new Set(['/health']);
|
||||
|
||||
export class ListenerAuthConfigError extends Error {}
|
||||
|
||||
// Called once at boot from server.js. Throws (crash loudly) instead of
|
||||
// booting an all-callers-denied service.
|
||||
export function assertListenerAuthConfig(tokenMap, env = process.env) {
|
||||
if (!requireAuthEnabled(env)) {
|
||||
console.error(
|
||||
'\n*** agap-mcp WARNING: AGAP_MCP_REQUIRE_AUTH=0 — the :3100 MCP listener is ' +
|
||||
'UNAUTHENTICATED. Every tool (ha_call_service, gitea_wiki_write, wiki_edit, ' +
|
||||
'radicale/todoist writes, POST /capture-idea) is callable by any LAN peer, and ' +
|
||||
'the LAN carries VPN-terminated peers. This violates DESIGN-a2a-agents.md §4 ' +
|
||||
'and is only acceptable as a temporary, deliberate rollback (kb#180). ***\n'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Object.keys(tokenMap || {}).length === 0) {
|
||||
throw new ListenerAuthConfigError(
|
||||
'AGAP_MCP_REQUIRE_AUTH is on (default) but AGAP_MCP_AGENT_TOKENS is empty/unset, ' +
|
||||
'so no caller could ever authenticate. Populate AGAP_MCP_AGENT_TOKENS in this ' +
|
||||
"container's .env with a JSON map {\"<bearer-token>\":\"<agent-id>\"} (agent ids " +
|
||||
'must exist in agent-registry.yaml), or set AGAP_MCP_REQUIRE_AUTH=0 to ' +
|
||||
'deliberately run the listener unauthenticated (kb#180, DESIGN-a2a-agents.md §4).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Express middleware factory. On success sets req.callerAgentId (string) and
|
||||
// req.callerToken, which the /mcp, /sse and /messages routes consume.
|
||||
// With auth disabled it sets req.callerAgentId from the token if one happens
|
||||
// to be present (so the vault gate keeps working) and lets the request through.
|
||||
export function listenerAuth(tokenMap, env = process.env) {
|
||||
const enabled = requireAuthEnabled(env);
|
||||
return function listenerAuthMiddleware(req, res, next) {
|
||||
const token = authHeaderToken(req);
|
||||
const agentId = resolveCallerAgent(token, tokenMap);
|
||||
req.callerToken = token;
|
||||
req.callerAgentId = agentId;
|
||||
|
||||
if (!enabled) return next();
|
||||
if (PUBLIC_PATHS.has(req.path)) return next();
|
||||
if (agentId) return next();
|
||||
|
||||
return denyUnauthenticated(req, res, token ? 'unknown-token' : 'no-credential');
|
||||
};
|
||||
}
|
||||
|
||||
// 401 body shape: JSON-RPC error for MCP routes (so an MCP client surfaces a
|
||||
// real protocol error rather than a parse failure), plain JSON elsewhere.
|
||||
export function denyUnauthenticated(req, res, reason) {
|
||||
const message =
|
||||
`unauthenticated: this endpoint requires an Authorization: Bearer <token> header ` +
|
||||
`resolving to a known agent (kb#180, DESIGN-a2a-agents.md §4) [${reason}]`;
|
||||
res.set('WWW-Authenticate', 'Bearer realm="agap-mcp"');
|
||||
if (isJsonRpcPath(req.path)) {
|
||||
return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message }, id: null });
|
||||
}
|
||||
return res.status(401).json({ error: message });
|
||||
}
|
||||
|
||||
export function isJsonRpcPath(path) {
|
||||
return path === '/mcp' || path === '/messages' || path === '/sse';
|
||||
}
|
||||
|
||||
// --- SSE session binding -------------------------------------------------
|
||||
// sessions: Map<sessionId, { transport, agentId, token }>
|
||||
|
||||
export function bindSseSession(sessions, sessionId, transport, req) {
|
||||
sessions.set(sessionId, {
|
||||
transport,
|
||||
agentId: req.callerAgentId || null,
|
||||
token: req.callerToken || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Returns { ok: true, transport } or { ok: false, status, reason }.
|
||||
// A /messages POST must (a) name a live session and (b) carry the SAME
|
||||
// caller identity that opened it. Comparing the agent id (not just "is
|
||||
// authenticated") is what stops agent B from driving agent A's session; the
|
||||
// token is compared too so two tokens mapped to the same agent id are still
|
||||
// treated as distinct sessions.
|
||||
export function authorizeSseSession(sessions, sessionId, req, env = process.env) {
|
||||
const entry = sessions.get(sessionId);
|
||||
if (!entry) return { ok: false, status: 400, reason: 'unknown-session' };
|
||||
if (!requireAuthEnabled(env)) return { ok: true, transport: entry.transport };
|
||||
if (!req.callerAgentId) return { ok: false, status: 401, reason: 'no-credential' };
|
||||
if (entry.agentId !== req.callerAgentId || entry.token !== req.callerToken) {
|
||||
return { ok: false, status: 403, reason: 'session-caller-mismatch' };
|
||||
}
|
||||
return { ok: true, transport: entry.transport };
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from
|
||||
import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js';
|
||||
import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js';
|
||||
import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js';
|
||||
import { todoistCaptureIdea } from './capture.js';
|
||||
import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js';
|
||||
import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js';
|
||||
import { listenerAuth, assertListenerAuthConfig, requireAuthEnabled, bindSseSession, authorizeSseSession } from './listener-auth.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3100');
|
||||
|
||||
@@ -28,6 +30,21 @@ const PORT = parseInt(process.env.PORT || '3100');
|
||||
// (see DESIGN-a2a-agents.md v2.1 §5 — vault access = trusted only).
|
||||
const ENFORCE_VAULT_TRUST = process.env.AGAP_MCP_ENFORCE_VAULT_TRUST === '1';
|
||||
const AGENT_TOKENS = loadTokenMap();
|
||||
const AGENT_TOKEN_COUNT = Object.keys(AGENT_TOKENS).length;
|
||||
|
||||
// kb#182: ENFORCE=1 with an empty token map is fail-closed by design (see
|
||||
// requireVaultAccess below) but that means it silently denies EVERY caller,
|
||||
// including Adolf itself — a self-inflicted vault brick with no signal
|
||||
// unless someone is watching the logs. Make that state loud on boot.
|
||||
if (ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0) {
|
||||
console.error(
|
||||
'\n*** agap-mcp WARNING: AGAP_MCP_ENFORCE_VAULT_TRUST=1 but AGAP_MCP_AGENT_TOKENS ' +
|
||||
'is empty/unset. Every caller — including Adolf — will be denied vault access. ' +
|
||||
'This is fail-closed, not a crash: the process will keep serving non-vault tools, ' +
|
||||
'but ALL vw_* calls will error until AGAP_MCP_AGENT_TOKENS is populated with real ' +
|
||||
'per-agent bearer tokens (kb#147/kb#182). Check /health for tokenMapSize. ***\n'
|
||||
);
|
||||
}
|
||||
|
||||
function requireVaultAccess(callerAgentId) {
|
||||
if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated
|
||||
@@ -80,8 +97,19 @@ function err(e) {
|
||||
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
||||
}
|
||||
|
||||
// Track tool registrations for /health endpoint
|
||||
let registeredToolCount = 0;
|
||||
|
||||
function createServer(callerAgentId = null) {
|
||||
const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
|
||||
const serverToolCount = { count: 0 };
|
||||
|
||||
// Wrap server.tool() to count registrations
|
||||
const originalTool = server.tool.bind(server);
|
||||
server.tool = function(name, description, params, handler) {
|
||||
serverToolCount.count++;
|
||||
return originalTool(name, description, params, handler);
|
||||
};
|
||||
|
||||
// --- Vaultwarden tools (kb#147: gated to trust_class >= trusted) ---
|
||||
server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() },
|
||||
@@ -325,6 +353,17 @@ function createServer(callerAgentId = null) {
|
||||
try { return ok(await todoistCompleteTask({ id })); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
// kb#170: capture an idea/quick task with lightweight (encoder-only, no
|
||||
// Kimi/gemma call) AI classification -- area/urgency/decompose-need --
|
||||
// then create it in Todoist tagged with the result. See capture.js header
|
||||
// for why this tags with labels rather than reassigning projects.
|
||||
server.tool('todoist_capture_idea', 'Capture a free-text idea: classify it (area: adolf/welfare/дом/семья/здоровье, urgency, whether it needs Kanboard decomposition) using local bge-m3 embeddings, then create it as a labelled Todoist task. No LLM call.', {
|
||||
text: z.string().describe('The idea, in free text (Russian or English).'),
|
||||
project_id: z.string().optional().describe('Force a specific Todoist project id; otherwise auto-routed only for an unambiguous семья match, else Inbox.'),
|
||||
}, async ({ text, project_id }) => {
|
||||
try { return ok(await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects })); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
// --- MediaWiki (family wiki / РодоВики) tools ---
|
||||
server.tool('wiki_search', 'Search the family wiki (РодоВики) for pages matching a query. Returns title + snippet.', {
|
||||
query: z.string().describe('Search text, e.g. a person\'s name or event.'),
|
||||
@@ -347,6 +386,10 @@ function createServer(callerAgentId = null) {
|
||||
try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
// Store tool count on the server for /health endpoint to access
|
||||
server._toolCount = serverToolCount.count;
|
||||
registeredToolCount = serverToolCount.count;
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -354,12 +397,24 @@ function createServer(callerAgentId = null) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// kb#180: authenticate the LISTENER, not just vault tools. Mounted before
|
||||
// every route below, so /mcp, /sse, /messages and /capture-idea all require
|
||||
// a bearer token resolving to a known agent (/health stays open — see
|
||||
// listener-auth.js PUBLIC_PATHS). This middleware also populates
|
||||
// req.callerAgentId, which replaces the per-route resolveCallerAgent() call
|
||||
// the /mcp and /sse handlers used to do inline; the kb#147 vault gate then
|
||||
// consumes that same id, so one token per agent serves both gates.
|
||||
app.use(listenerAuth(AGENT_TOKENS));
|
||||
|
||||
// sessionId -> { transport, agentId, token } (kb#180: the identity captured at
|
||||
// the /sse handshake is pinned to the session so /messages can't be hijacked
|
||||
// by anyone who merely learns/guesses the sessionId).
|
||||
const sseTransports = new Map();
|
||||
|
||||
// Streamable HTTP — stateless: fresh server per request, survives container restarts
|
||||
app.all('/mcp', async (req, res) => {
|
||||
try {
|
||||
const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
|
||||
const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
res.on('close', () => transport.close());
|
||||
await createServer(callerAgentId).connect(transport);
|
||||
@@ -374,22 +429,71 @@ app.all('/mcp', async (req, res) => {
|
||||
|
||||
// Legacy SSE — kept for backward compatibility
|
||||
app.get('/sse', async (req, res) => {
|
||||
const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
|
||||
const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
sseTransports.set(transport.sessionId, transport);
|
||||
bindSseSession(sseTransports, transport.sessionId, transport, req);
|
||||
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||
await createServer(callerAgentId).connect(transport);
|
||||
});
|
||||
|
||||
app.post('/messages', async (req, res) => {
|
||||
const transport = sseTransports.get(req.query.sessionId);
|
||||
if (!transport) return res.status(400).send('Unknown session');
|
||||
await transport.handlePostMessage(req, res);
|
||||
// kb#180: a live sessionId is no longer sufficient — the POST must carry the
|
||||
// same caller identity that opened the session at /sse.
|
||||
const auth = authorizeSseSession(sseTransports, req.query.sessionId, req);
|
||||
if (!auth.ok) return res.status(auth.status).json({ error: `/messages rejected: ${auth.reason} (kb#180)` });
|
||||
await auth.transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 30, vaultTrustEnforced: ENFORCE_VAULT_TRUST }));
|
||||
app.get('/health', (_, res) => res.json({
|
||||
status: 'ok',
|
||||
tools: registeredToolCount,
|
||||
vaultTrustEnforced: ENFORCE_VAULT_TRUST,
|
||||
tokenMapSize: AGENT_TOKEN_COUNT,
|
||||
// kb#182: surfaces the vault-brick footgun (ENFORCE=1 + no tokens = fail-closed
|
||||
// for everyone, including Adolf) directly in /health instead of only at boot log.
|
||||
vaultBrickRisk: ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0,
|
||||
// kb#180: whether the listener itself (not just vw_*) requires a bearer
|
||||
// token. false here means an unauthenticated JSON-RPC surface on the LAN.
|
||||
listenerAuthEnabled: requireAuthEnabled(),
|
||||
}));
|
||||
|
||||
init()
|
||||
// kb#170: plain-REST twin of the todoist_capture_idea MCP tool, added for
|
||||
// the todoist-capture-plugin native `/idea` command (openai/
|
||||
// todoist-capture-plugin) — a native-command handler doesn't speak MCP
|
||||
// JSON-RPC, so it needs a plain JSON endpoint to reach the same
|
||||
// classify+create logic (capture.js) the MCP tool already exposes to
|
||||
// Adolf/Claude's model-driven path. No new trust boundary: same
|
||||
// unauthenticated-on-localhost posture as every other route in this file
|
||||
// today (see trust-gate.js header for the tracked gap).
|
||||
app.post('/capture-idea', async (req, res) => {
|
||||
try {
|
||||
const { text, project_id } = req.body || {};
|
||||
const result = await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// kb#179: guard the real init()+listen() side effects so this module can be
|
||||
// `import`-ed by tests (trust-gate-http.test.mjs) to exercise the real
|
||||
// createServer()/requireVaultAccess()/app on a throwaway port WITHOUT
|
||||
// touching Vaultwarden/Gitea/HA/Zabbix or the live :3100 container. Only run
|
||||
// the side effects when server.js is executed directly (`node src/server.js`
|
||||
// / the production container entrypoint), never on import.
|
||||
const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
// kb#180: refuse to boot into an "authenticated but nobody can authenticate"
|
||||
// state (auth on + empty token map). Crashing here makes a missing
|
||||
// AGAP_MCP_AGENT_TOKENS look like a broken restart instead of a silently
|
||||
// dead integration. Only reached when run directly, never on import.
|
||||
try {
|
||||
assertListenerAuthConfig(AGENT_TOKENS);
|
||||
} catch (e) {
|
||||
console.error(`agap-mcp refusing to start: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
init()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`));
|
||||
})
|
||||
@@ -397,3 +501,13 @@ init()
|
||||
console.error('Init failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// --- kb#179 test-only exports -------------------------------------------
|
||||
// Exposes the exact functions/objects the production /mcp route uses so
|
||||
// integration tests can boot the real enforcement path (ENFORCE=1 + a
|
||||
// synthetic token map/registry) over real HTTP, instead of re-implementing
|
||||
// the gate inline. Importing these does not start the server or call init().
|
||||
// kb#180 additionally exports the live sse session map so the HTTP tests can
|
||||
// assert /messages session-binding without reaching into module internals.
|
||||
export { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports };
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
// kb#147 HTTP-layer proof, run with: node src/trust-gate-http.test.mjs
|
||||
// kb#179 HTTP-layer proof: exercises the REAL server.js /mcp handler, not a
|
||||
// reimplementation of it. Run with: node src/trust-gate-http.test.mjs
|
||||
//
|
||||
// Proves the Authorization-header -> agent-id -> trust-rank path end to end
|
||||
// over real HTTP, WITHOUT touching the live agap-mcp container (:3100),
|
||||
// LiteLLM, or Vaultwarden: this spins up a throwaway express app on an
|
||||
// ephemeral local port using the exact same trust-gate.js functions
|
||||
// server.js imports, with a synthetic registry + token map (no real bw
|
||||
// session, no real credentials). It exercises authHeaderToken() (the bit
|
||||
// trust-gate.test.mjs's pure unit tests can't reach, since it needs a real
|
||||
// `req` object) on top of the already-unit-tested trustRankOf/vaultAllowed.
|
||||
// kb#147's original version of this file re-implemented the gate inline
|
||||
// (its own express app + its own copy of the "if vault tool and not
|
||||
// allowed" check). That proved trust-gate.js's exported functions compose
|
||||
// correctly, but never proved the shipped server.js actually wires
|
||||
// requireVaultAccess() into every vw_* tool -- a vw_* tool registered
|
||||
// without the gate would still pass that test.
|
||||
//
|
||||
// This version instead:
|
||||
// 1. Sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 and a synthetic
|
||||
// AGAP_MCP_AGENT_TOKENS map BEFORE importing server.js (both are read
|
||||
// once at module-load time), then dynamically imports server.js so it
|
||||
// picks up ENFORCE=1 with a harness-only token map -- never the live
|
||||
// container's tokens.
|
||||
// 2. Overrides trust-gate's registry cache with a synthetic registry (no
|
||||
// real agent-registry.yaml read) via _resetRegistryCacheForTests --
|
||||
// the exact test hook trust-gate.js already exports for this purpose.
|
||||
// 3. Boots server.js's real `app` (its actual app.all('/mcp', ...)
|
||||
// handler, its real createServer()/requireVaultAccess()) on an
|
||||
// ephemeral local port, and drives it over real HTTP using the MCP
|
||||
// SDK's own Client + StreamableHTTPClientTransport -- real
|
||||
// initialize + tools/call JSON-RPC round trips, not raw fetch().
|
||||
// 4. Does NOT call init() (never touches Vaultwarden/Gitea/HA/Zabbix) and
|
||||
// never touches the live :3100 container -- server.js's init()+
|
||||
// app.listen() side effects are guarded behind an isMainModule check
|
||||
// specifically so this file can import the module safely (kb#179).
|
||||
//
|
||||
// Because init() never runs, the underlying vw* functions (vaultwarden.js)
|
||||
// are never given a bw session. For a TRUSTED caller the gate must let the
|
||||
// call through to that downstream code -- which then fails for its own
|
||||
// unrelated reason (no bw session) -- so "allowed" is asserted as "did NOT
|
||||
// fail with the gate's specific denial message", not "the vault call
|
||||
// succeeded". That's deliberate: it proves requireVaultAccess() did not
|
||||
// block the call, without shelling out to a real `bw` session anywhere in
|
||||
// this test.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import {
|
||||
loadTokenMap,
|
||||
resolveCallerAgent,
|
||||
vaultAllowed,
|
||||
authHeaderToken,
|
||||
isVaultTool,
|
||||
_resetRegistryCacheForTests,
|
||||
} from './trust-gate.js';
|
||||
|
||||
process.env.AGAP_MCP_ENFORCE_VAULT_TRUST = '1';
|
||||
process.env.AGAP_MCP_AGENT_TOKENS = JSON.stringify({
|
||||
'tok-adolf-e2e-test': 'adolf',
|
||||
'tok-torgash-e2e-test': 'torgash',
|
||||
});
|
||||
|
||||
const registry = {
|
||||
trust_classes: {
|
||||
@@ -31,39 +55,102 @@ const registry = {
|
||||
{ id: 'torgash', trust_class: 'sandboxed' },
|
||||
],
|
||||
};
|
||||
|
||||
const { _resetRegistryCacheForTests } = await import('./trust-gate.js');
|
||||
_resetRegistryCacheForTests(registry);
|
||||
|
||||
const tokenMap = loadTokenMap(JSON.stringify({
|
||||
'tok-adolf-e2e-test': 'adolf',
|
||||
'tok-torgash-e2e-test': 'torgash',
|
||||
}));
|
||||
const { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports } = await import('./server.js');
|
||||
const {
|
||||
listenerAuth,
|
||||
assertListenerAuthConfig,
|
||||
ListenerAuthConfigError,
|
||||
requireAuthEnabled,
|
||||
bindSseSession,
|
||||
authorizeSseSession,
|
||||
} = await import('./listener-auth.js');
|
||||
|
||||
// A minimal stand-in for server.js's app.all('/mcp', ...) handler: resolve
|
||||
// the caller from the Authorization header, then simulate a vw_get_password
|
||||
// tool call gated the same way requireVaultAccess() gates it in server.js.
|
||||
const app = express();
|
||||
app.post('/mcp', (req, res) => {
|
||||
const callerAgentId = resolveCallerAgent(authHeaderToken(req), tokenMap);
|
||||
const toolName = req.body?.tool || 'vw_get_password';
|
||||
if (isVaultTool(toolName) && !vaultAllowed(callerAgentId, registry)) {
|
||||
return res.status(200).json({ isError: true, error: `vault access denied for caller=${callerAgentId || '(none)'}` });
|
||||
}
|
||||
return res.status(200).json({ isError: false, caller: callerAgentId });
|
||||
});
|
||||
assert.equal(ENFORCE_VAULT_TRUST, true, 'sanity: server.js must have picked up AGAP_MCP_ENFORCE_VAULT_TRUST=1 at import time');
|
||||
assert.equal(Object.keys(AGENT_TOKENS).length, 2, 'sanity: server.js must have picked up the synthetic AGAP_MCP_AGENT_TOKENS');
|
||||
|
||||
const server = app.listen(0);
|
||||
const port = server.address().port;
|
||||
const VW_TOOLS = ['vw_get_password', 'vw_get_item', 'vw_list_items', 'vw_create_login', 'vw_update_password'];
|
||||
const DENIED_RE = /vault access denied/;
|
||||
|
||||
async function post(token) {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ tool: 'vw_get_password' }),
|
||||
// Bind explicitly to 127.0.0.1, with retries: `app.listen(0)` binds the IPv6
|
||||
// wildcard `::` on this host and intermittently fails EADDRINUSE under
|
||||
// ephemeral-port pressure, which made this harness flaky (~2 runs in 3)
|
||||
// regardless of what it asserts. Loopback-only is also the right posture for
|
||||
// a test that deliberately probes an unauthenticated endpoint.
|
||||
async function listenOnFreeLoopbackPort(attempts = 10) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const s = app.listen(0, '127.0.0.1');
|
||||
const outcome = await new Promise(resolve => {
|
||||
s.once('listening', () => resolve('ok'));
|
||||
s.once('error', e => resolve(e));
|
||||
});
|
||||
return res.json();
|
||||
if (outcome === 'ok') return s;
|
||||
if (outcome.code !== 'EADDRINUSE') throw outcome;
|
||||
}
|
||||
throw new Error('could not bind a free loopback port after multiple attempts');
|
||||
}
|
||||
|
||||
const server = await listenOnFreeLoopbackPort();
|
||||
const port = server.address().port;
|
||||
const baseUrl = `http://127.0.0.1:${port}/mcp`;
|
||||
|
||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
||||
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
||||
|
||||
// Minimal argument stubs matching each tool's zod schema -- the gate must
|
||||
// fire before any of these are used for real (requireVaultAccess is the
|
||||
// first statement in every vw_* handler in server.js), so their exact
|
||||
// values don't matter for the deny path.
|
||||
const ARGS = {
|
||||
vw_get_password: { name: 'probe' },
|
||||
vw_get_item: { name: 'probe' },
|
||||
vw_list_items: {},
|
||||
vw_create_login: { name: 'probe', password: 'x' },
|
||||
vw_update_password: { name: 'probe', password: 'x' },
|
||||
};
|
||||
|
||||
// callToolAsCaller: connects a fresh MCP client to the real /mcp route with
|
||||
// the given bearer token (or none), calls `tool`, and returns the tool
|
||||
// result. `undefined`/omitted mimics resolveCallerAgent's "no header at
|
||||
// all" path; a token string that isn't in AGENT_TOKENS mimics "unknown
|
||||
// token".
|
||||
//
|
||||
// kb#180 note: since the listener itself now requires auth, an unauthenticated
|
||||
// or unknown-token client is rejected at the HTTP layer (401) during
|
||||
// connect() — before any tool handler runs. That is a STRICTER outcome than
|
||||
// the tool-level "vault access denied" this file originally asserted for
|
||||
// those two cases, so those checks now assert the 401 instead. The
|
||||
// authenticated-but-sandboxed (torgash) case is what still proves the kb#147
|
||||
// vault gate itself, and the trusted (adolf) case still proves the gate lets
|
||||
// a trusted caller through.
|
||||
const AUTH_REJECTED = Symbol('listener-auth-rejected');
|
||||
|
||||
async function callToolAsCaller(token, tool) {
|
||||
const requestInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
||||
const transport = new StreamableHTTPClientTransport(new URL(baseUrl), { requestInit });
|
||||
const client = new Client({ name: 'kb179-test-client', version: '1.0.0' });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (e) {
|
||||
if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
return await client.callTool({ name: tool, arguments: ARGS[tool] });
|
||||
} catch (e) {
|
||||
if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED;
|
||||
throw e;
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function toolErrorText(result) {
|
||||
const block = result?.content?.find(c => c.type === 'text');
|
||||
return block?.text || '';
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
@@ -74,26 +161,199 @@ async function check(label, fn) {
|
||||
}
|
||||
|
||||
try {
|
||||
await check('trusted agent (adolf) bearer token -> vw_get_password allowed over real HTTP', async () => {
|
||||
const body = await post('tok-adolf-e2e-test');
|
||||
assert.equal(body.isError, false);
|
||||
assert.equal(body.caller, 'adolf');
|
||||
for (const tool of VW_TOOLS) {
|
||||
await check(`${tool}: no Authorization header -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => {
|
||||
const result = await callToolAsCaller(null, tool);
|
||||
assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unauthenticated MCP client');
|
||||
});
|
||||
|
||||
await check('sandboxed agent (torgash) bearer token -> vw_get_password DENIED over real HTTP', async () => {
|
||||
const body = await post('tok-torgash-e2e-test');
|
||||
assert.equal(body.isError, true);
|
||||
assert.match(body.error, /vault access denied/);
|
||||
await check(`${tool}: unknown/never-issued token -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => {
|
||||
const result = await callToolAsCaller('this-token-was-never-issued', tool);
|
||||
assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unknown bearer token');
|
||||
});
|
||||
|
||||
await check('no Authorization header at all -> vw_get_password DENIED over real HTTP', async () => {
|
||||
const body = await post(null);
|
||||
assert.equal(body.isError, true);
|
||||
await check(`${tool}: sandboxed agent (torgash) token -> DENIED over real HTTP`, async () => {
|
||||
const result = await callToolAsCaller('tok-torgash-e2e-test', tool);
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(toolErrorText(result), DENIED_RE);
|
||||
});
|
||||
|
||||
await check('garbage/unknown token -> vw_get_password DENIED over real HTTP', async () => {
|
||||
const body = await post('this-token-was-never-issued');
|
||||
assert.equal(body.isError, true);
|
||||
await check(`${tool}: trusted agent (adolf) token -> gate ALLOWS (not blocked by requireVaultAccess) over real HTTP`, async () => {
|
||||
const result = await callToolAsCaller('tok-adolf-e2e-test', tool);
|
||||
// The gate must not be what blocks this call. Downstream vaultwarden.js
|
||||
// has no bw session here (init() deliberately never ran), so the call
|
||||
// may still fail -- just not with the gate's denial message.
|
||||
assert.doesNotMatch(toolErrorText(result), DENIED_RE);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Guard test (kb#179 acceptance bar #2) ---------------------------
|
||||
// Fails if any vw_* tool is ever registered on createServer() without
|
||||
// calling requireVaultAccess(). Rather than re-deriving this from source
|
||||
// text (fragile to refactors), it drives the real registered handler for
|
||||
// every tool name starting with "vw_" as an untrusted/denied caller and
|
||||
// asserts the gate's specific denial message comes back. A vw_* tool
|
||||
// that forgot to call requireVaultAccess would either succeed or throw a
|
||||
// different (non-gate) error here, and this test would catch it.
|
||||
await check('guard: every registered vw_* tool enforces requireVaultAccess()', async () => {
|
||||
// A caller id that resolves to no agent in the (synthetic) registry --
|
||||
// trustRankOf() returns -1 for it, so vaultAllowed() must be false and
|
||||
// requireVaultAccess() must throw for every vw_* tool.
|
||||
const probeServer = createServer('this-agent-id-does-not-exist-in-registry');
|
||||
// McpServer keeps its registrations on `._registeredTools` (name ->
|
||||
// { handler, ... }); read the real registry createServer() just
|
||||
// populated rather than re-deriving tool names by hand, so a future
|
||||
// vw_* tool is covered automatically.
|
||||
const registeredTools = probeServer._registeredTools;
|
||||
assert.ok(registeredTools && Object.keys(registeredTools).length > 0,
|
||||
'expected createServer() to have registered tools onto the McpServer instance');
|
||||
|
||||
const vwToolNames = Object.keys(registeredTools).filter(n => n.startsWith('vw_'));
|
||||
assert.ok(vwToolNames.length >= VW_TOOLS.length, `expected at least ${VW_TOOLS.length} vw_* tools registered, found: ${vwToolNames.join(', ')}`);
|
||||
|
||||
for (const name of vwToolNames) {
|
||||
const handler = registeredTools[name].handler;
|
||||
assert.ok(typeof handler === 'function', `could not locate callable handler for ${name}`);
|
||||
const result = await handler(ARGS[name] || {}, {});
|
||||
assert.equal(result.isError, true, `${name}: expected denial for an unregistered agent id, got success -- is requireVaultAccess() missing?`);
|
||||
assert.match(toolErrorText(result), DENIED_RE, `${name}: expected the gate's denial message, got: ${toolErrorText(result)} -- is requireVaultAccess() missing or not the first check?`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- kb#180: the LISTENER is authenticated, not just vault tools --------
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const authed = { Authorization: 'Bearer tok-torgash-e2e-test' }; // sandboxed but *known*
|
||||
|
||||
await check('kb#180: unauthenticated POST /mcp -> 401 (raw curl-equivalent)', async () => {
|
||||
const res = await fetch(`${base}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.headers.get('www-authenticate'), 'Bearer realm="agap-mcp"');
|
||||
const body = await res.json();
|
||||
assert.equal(body.jsonrpc, '2.0', 'MCP routes must answer with a JSON-RPC error envelope');
|
||||
assert.match(body.error.message, /unauthenticated/);
|
||||
});
|
||||
|
||||
await check('kb#180: unauthenticated POST /capture-idea -> 401 (no Todoist write)', async () => {
|
||||
const res = await fetch(`${base}/capture-idea`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'unauthenticated probe — must never reach Todoist' }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /unauthenticated/);
|
||||
});
|
||||
|
||||
await check('kb#180: unknown token on /capture-idea -> 401', async () => {
|
||||
const res = await fetch(`${base}/capture-idea`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer nope' },
|
||||
body: JSON.stringify({ text: 'probe' }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
await check('kb#180: unauthenticated GET /sse -> 401', async () => {
|
||||
const res = await fetch(`${base}/sse`, { headers: { Accept: 'text/event-stream' } });
|
||||
assert.equal(res.status, 401);
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
await check('kb#180: unauthenticated POST /messages -> 401 (was: any sessionId accepted)', async () => {
|
||||
const res = await fetch(`${base}/messages?sessionId=whatever`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
await check('kb#180: /health stays open and reports listenerAuthEnabled=true', async () => {
|
||||
const res = await fetch(`${base}/health`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.listenerAuthEnabled, true);
|
||||
});
|
||||
|
||||
await check('kb#180: an authenticated (known-token) caller still reaches the tool surface', async () => {
|
||||
const res = await fetch(`${base}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...authed },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } },
|
||||
}),
|
||||
});
|
||||
assert.notEqual(res.status, 401, 'a known token must not be rejected by the listener gate');
|
||||
assert.ok(res.status < 500, `expected the request to be served, got ${res.status}`);
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
// --- kb#180: /messages must match its /sse handshake identity ----------
|
||||
await check('kb#180: /messages rejects a session opened by a DIFFERENT agent (hijack)', () => {
|
||||
const sessions = new Map();
|
||||
bindSseSession(sessions, 'sess-1', { id: 'transport-a' },
|
||||
{ callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
|
||||
|
||||
const sameCaller = authorizeSseSession(sessions, 'sess-1',
|
||||
{ callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
|
||||
assert.equal(sameCaller.ok, true, 'the agent that opened the session must keep using it');
|
||||
|
||||
const otherAgent = authorizeSseSession(sessions, 'sess-1',
|
||||
{ callerAgentId: 'torgash', callerToken: 'tok-torgash-e2e-test' });
|
||||
assert.equal(otherAgent.ok, false);
|
||||
assert.equal(otherAgent.status, 403);
|
||||
assert.equal(otherAgent.reason, 'session-caller-mismatch');
|
||||
|
||||
const noCreds = authorizeSseSession(sessions, 'sess-1', { callerAgentId: null, callerToken: null });
|
||||
assert.equal(noCreds.ok, false);
|
||||
assert.equal(noCreds.status, 401);
|
||||
|
||||
const unknownSession = authorizeSseSession(sessions, 'nope',
|
||||
{ callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
|
||||
assert.equal(unknownSession.ok, false);
|
||||
assert.equal(unknownSession.status, 400);
|
||||
});
|
||||
|
||||
await check('kb#180: the live server binds real /sse sessions to a caller identity', () => {
|
||||
// sseTransports is the exact map the /sse and /messages routes use; its
|
||||
// entries must be {transport, agentId, token}, not a bare transport.
|
||||
assert.ok(sseTransports instanceof Map);
|
||||
bindSseSession(sseTransports, 'probe-session', { probe: true },
|
||||
{ callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
|
||||
const entry = sseTransports.get('probe-session');
|
||||
assert.equal(entry.agentId, 'adolf');
|
||||
assert.equal(entry.token, 'tok-adolf-e2e-test');
|
||||
sseTransports.delete('probe-session');
|
||||
});
|
||||
|
||||
// --- kb#180: boot-time config guard ------------------------------------
|
||||
await check('kb#180: auth on + empty token map = refuse to boot (not silent all-deny)', () => {
|
||||
assert.throws(() => assertListenerAuthConfig({}, { }), ListenerAuthConfigError);
|
||||
assert.throws(() => assertListenerAuthConfig(Object.create(null), { AGAP_MCP_REQUIRE_AUTH: '1' }), ListenerAuthConfigError);
|
||||
// Explicit opt-out is allowed (warns, does not throw) — the rollback path.
|
||||
assert.doesNotThrow(() => assertListenerAuthConfig({}, { AGAP_MCP_REQUIRE_AUTH: '0' }));
|
||||
// Configured normally: fine.
|
||||
assert.doesNotThrow(() => assertListenerAuthConfig({ t: 'adolf' }, {}));
|
||||
});
|
||||
|
||||
await check('kb#180: AGAP_MCP_REQUIRE_AUTH=0 is the only way to get the old open listener', () => {
|
||||
assert.equal(requireAuthEnabled({}), true, 'auth must default ON');
|
||||
assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '1' }), true);
|
||||
assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '0' }), false);
|
||||
// ...and with it off, an unauthenticated request passes through the
|
||||
// middleware while still resolving an agent id when a token IS present.
|
||||
const mw = listenerAuth({ 'tok-adolf-e2e-test': 'adolf' }, { AGAP_MCP_REQUIRE_AUTH: '0' });
|
||||
let nexted = false;
|
||||
mw({ headers: {}, path: '/mcp' }, null, () => { nexted = true; });
|
||||
assert.equal(nexted, true);
|
||||
const req = { headers: { authorization: 'Bearer tok-adolf-e2e-test' }, path: '/mcp' };
|
||||
mw(req, null, () => {});
|
||||
assert.equal(req.callerAgentId, 'adolf', 'vault gate must still see the caller id when listener auth is off');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
|
||||
@@ -62,18 +62,30 @@ export function trustRankOf(agentId, registry = loadRegistry()) {
|
||||
// AGAP_MCP_AGENT_TOKENS (JSON), itself sourced from per-agent tokens stored in
|
||||
// Vaultwarden and injected via this container's .env, never inlined in git.
|
||||
export function loadTokenMap(raw = process.env.AGAP_MCP_AGENT_TOKENS) {
|
||||
if (!raw) return {};
|
||||
if (!raw) return Object.create(null);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return (parsed && typeof parsed === 'object') ? parsed : {};
|
||||
if (!parsed || typeof parsed !== 'object') return Object.create(null);
|
||||
// Rebuild onto a null-proto object so a token literally named
|
||||
// "__proto__"/"constructor"/"prototype" in AGAP_MCP_AGENT_TOKENS can
|
||||
// never merge into Object.prototype instead of becoming an own key.
|
||||
return Object.assign(Object.create(null), parsed);
|
||||
} catch (e) {
|
||||
console.error(`trust-gate: AGAP_MCP_AGENT_TOKENS is not valid JSON: ${e.message}`);
|
||||
return {};
|
||||
return Object.create(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCallerAgent(bearerToken, tokenMap) {
|
||||
if (!bearerToken) return null;
|
||||
if (!bearerToken || !tokenMap) return null;
|
||||
// Guard against prototype-key inputs: a bearer token of "__proto__",
|
||||
// "constructor", or "prototype" must never resolve via the object's
|
||||
// prototype chain (e.g. tokenMap['__proto__'] returning Object.prototype,
|
||||
// which is truthy and would silently "authenticate" as a non-existent
|
||||
// agent). Object.hasOwn only ever matches an actual own property that was
|
||||
// set from AGAP_MCP_AGENT_TOKENS, so a __proto__ probe resolves to null
|
||||
// explicitly regardless of the token map's shape (kb#182).
|
||||
if (!Object.hasOwn(tokenMap, bearerToken)) return null;
|
||||
return tokenMap[bearerToken] || null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user