@decibelsystems/tools 2.1.2 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentCommands.d.ts +11 -0
- package/dist/agentCommands.d.ts.map +1 -0
- package/dist/agentCommands.js +267 -0
- package/dist/agentCommands.js.map +1 -0
- package/dist/agentInbox.d.ts +37 -0
- package/dist/agentInbox.d.ts.map +1 -0
- package/dist/agentInbox.js +128 -0
- package/dist/agentInbox.js.map +1 -0
- package/dist/agentPresence.d.ts +36 -0
- package/dist/agentPresence.d.ts.map +1 -0
- package/dist/agentPresence.js +227 -0
- package/dist/agentPresence.js.map +1 -0
- package/dist/agenticJobs.d.ts +49 -0
- package/dist/agenticJobs.d.ts.map +1 -0
- package/dist/agenticJobs.js +190 -0
- package/dist/agenticJobs.js.map +1 -0
- package/dist/architectAdrs.d.ts +7 -0
- package/dist/architectAdrs.d.ts.map +1 -1
- package/dist/architectAdrs.js +91 -28
- package/dist/architectAdrs.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +14 -0
- package/dist/daemon.js.map +1 -1
- package/dist/facades/definitions.d.ts.map +1 -1
- package/dist/facades/definitions.js +40 -6
- package/dist/facades/definitions.js.map +1 -1
- package/dist/hq/defineAgent.d.ts +11 -0
- package/dist/hq/defineAgent.d.ts.map +1 -0
- package/dist/hq/defineAgent.js +185 -0
- package/dist/hq/defineAgent.js.map +1 -0
- package/dist/hq/index.d.ts +3 -0
- package/dist/hq/index.d.ts.map +1 -0
- package/dist/hq/index.js +28 -0
- package/dist/hq/index.js.map +1 -0
- package/dist/hq/types.d.ts +59 -0
- package/dist/hq/types.d.ts.map +1 -0
- package/dist/hq/types.js +12 -0
- package/dist/hq/types.js.map +1 -0
- package/dist/httpServer.d.ts +1 -1
- package/dist/httpServer.d.ts.map +1 -1
- package/dist/httpServer.js +327 -19
- package/dist/httpServer.js.map +1 -1
- package/dist/kernel.d.ts +3 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +40 -9
- package/dist/kernel.js.map +1 -1
- package/dist/projectRegistry.d.ts +27 -0
- package/dist/projectRegistry.d.ts.map +1 -1
- package/dist/projectRegistry.js +191 -5
- package/dist/projectRegistry.js.map +1 -1
- package/dist/sentinelIssues.d.ts +5 -0
- package/dist/sentinelIssues.d.ts.map +1 -1
- package/dist/sentinelIssues.js +1 -1
- package/dist/sentinelIssues.js.map +1 -1
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -1
- package/dist/store/fsStore.d.ts +6 -0
- package/dist/store/fsStore.d.ts.map +1 -0
- package/dist/store/fsStore.js +77 -0
- package/dist/store/fsStore.js.map +1 -0
- package/dist/store/index.d.ts +8 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +23 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/markdown.d.ts +21 -0
- package/dist/store/markdown.d.ts.map +1 -0
- package/dist/store/markdown.js +175 -0
- package/dist/store/markdown.js.map +1 -0
- package/dist/store/supabaseStore.d.ts +6 -0
- package/dist/store/supabaseStore.d.ts.map +1 -0
- package/dist/store/supabaseStore.js +152 -0
- package/dist/store/supabaseStore.js.map +1 -0
- package/dist/store/types.d.ts +49 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +11 -0
- package/dist/store/types.js.map +1 -0
- package/dist/supabaseRetry.d.ts +21 -0
- package/dist/supabaseRetry.d.ts.map +1 -0
- package/dist/supabaseRetry.js +72 -0
- package/dist/supabaseRetry.js.map +1 -0
- package/dist/toolConfig.d.ts +49 -0
- package/dist/toolConfig.d.ts.map +1 -0
- package/dist/toolConfig.js +309 -0
- package/dist/toolConfig.js.map +1 -0
- package/dist/tools/agentic/agentQueue.d.ts.map +1 -1
- package/dist/tools/agentic/agentQueue.js +7 -5
- package/dist/tools/agentic/agentQueue.js.map +1 -1
- package/dist/tools/agentic/index.d.ts +3 -0
- package/dist/tools/agentic/index.d.ts.map +1 -1
- package/dist/tools/agentic/index.js +158 -1
- package/dist/tools/agentic/index.js.map +1 -1
- package/dist/tools/codereview/index.d.ts +4 -0
- package/dist/tools/codereview/index.d.ts.map +1 -0
- package/dist/tools/codereview/index.js +93 -0
- package/dist/tools/codereview/index.js.map +1 -0
- package/dist/tools/codereview.d.ts +56 -0
- package/dist/tools/codereview.d.ts.map +1 -0
- package/dist/tools/codereview.js +304 -0
- package/dist/tools/codereview.js.map +1 -0
- package/dist/tools/concepts.d.ts +38 -0
- package/dist/tools/concepts.d.ts.map +1 -0
- package/dist/tools/concepts.js +283 -0
- package/dist/tools/concepts.js.map +1 -0
- package/dist/tools/corpus/index.d.ts +3 -0
- package/dist/tools/corpus/index.d.ts.map +1 -1
- package/dist/tools/corpus/index.js +183 -1
- package/dist/tools/corpus/index.js.map +1 -1
- package/dist/tools/corpus.d.ts +52 -0
- package/dist/tools/corpus.d.ts.map +1 -1
- package/dist/tools/corpus.js +207 -2
- package/dist/tools/corpus.js.map +1 -1
- package/dist/tools/dojo.d.ts.map +1 -1
- package/dist/tools/dojo.js +11 -1
- package/dist/tools/dojo.js.map +1 -1
- package/dist/tools/git-sentinel.d.ts.map +1 -1
- package/dist/tools/git-sentinel.js +24 -8
- package/dist/tools/git-sentinel.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/oracle.d.ts +2 -0
- package/dist/tools/oracle.d.ts.map +1 -1
- package/dist/tools/oracle.js +146 -17
- package/dist/tools/oracle.js.map +1 -1
- package/dist/tools/peers.d.ts +15 -0
- package/dist/tools/peers.d.ts.map +1 -0
- package/dist/tools/peers.js +70 -0
- package/dist/tools/peers.js.map +1 -0
- package/dist/tools/registry/index.d.ts +4 -0
- package/dist/tools/registry/index.d.ts.map +1 -1
- package/dist/tools/registry/index.js +274 -1
- package/dist/tools/registry/index.js.map +1 -1
- package/dist/tools/roadmap.d.ts +4 -0
- package/dist/tools/roadmap.d.ts.map +1 -1
- package/dist/tools/roadmap.js.map +1 -1
- package/dist/tools/sentinel.d.ts +15 -1
- package/dist/tools/sentinel.d.ts.map +1 -1
- package/dist/tools/sentinel.js +198 -34
- package/dist/tools/sentinel.js.map +1 -1
- package/dist/tools/voice/index.d.ts.map +1 -1
- package/dist/tools/voice/index.js +2 -3
- package/dist/tools/voice/index.js.map +1 -1
- package/dist/tools/voice.d.ts +1 -1
- package/dist/tools/voice.js +2 -2
- package/dist/tools/voice.js.map +1 -1
- package/dist/tools/workflow.js +5 -5
- package/dist/tools/workflow.js.map +1 -1
- package/dist/transports/http.js +1 -1
- package/package.json +4 -2
package/dist/httpServer.js
CHANGED
|
@@ -25,16 +25,20 @@
|
|
|
25
25
|
* { "status": "error", "error": "...", "code": "..." }
|
|
26
26
|
*/
|
|
27
27
|
import { createServer } from 'http';
|
|
28
|
-
import { timingSafeEqual } from 'crypto';
|
|
28
|
+
import { timingSafeEqual, randomBytes } from 'crypto';
|
|
29
29
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
30
30
|
import { readFileSync } from 'fs';
|
|
31
31
|
import { fileURLToPath } from 'url';
|
|
32
32
|
import { dirname, join } from 'path';
|
|
33
33
|
import { log } from './config.js';
|
|
34
|
+
import { writeLocalAgentSession } from './agentPresence.js';
|
|
35
|
+
import { drainCommands, setSessionToken, getSessionToken, dropSessionToken, ackOwnerOf, clearAckOwner, } from './agentInbox.js';
|
|
36
|
+
import { settleCommandFromAck, ORG_ID } from './agentCommands.js';
|
|
34
37
|
import { isSupabaseConfigured, getSupabaseServiceClient } from './lib/supabase.js';
|
|
35
38
|
import { shouldQueueForAgent, parseToolCall } from './httpQueueDetection.js';
|
|
36
39
|
import { getLicenseValidator } from './license.js';
|
|
37
40
|
import { listProjects } from './projectRegistry.js';
|
|
41
|
+
import { setDaemonPort } from './daemon.js';
|
|
38
42
|
import { listEpics, listRepoIssues, isProjectResolutionError, } from './tools/sentinel.js';
|
|
39
43
|
import { voiceInboxAdd, } from './tools/voice.js';
|
|
40
44
|
import { generateImage, getImageStatus, meshyGenerate, getMeshyStatus, meshyDownload, tripoGenerate, getTripoStatus, tripoDownload, klingGenerateVideo, klingGenerateTextVideo, klingGenerateAvatar, getKlingStatus, listTasks, } from './tools/studio/index.js';
|
|
@@ -45,6 +49,12 @@ let startedAt = 0;
|
|
|
45
49
|
let sseConnectionCount = 0;
|
|
46
50
|
let agentRegistry;
|
|
47
51
|
let daemonConfig;
|
|
52
|
+
// Mirrored from startHttpServer options so module-scoped executeTool can reason
|
|
53
|
+
// about the connection's trust boundary (the 127.0.0.1 daemon bind vs a
|
|
54
|
+
// network-exposed --http server) and whether auth is enforced. Used by the
|
|
55
|
+
// queue-write authorization decoupling (principal, not label).
|
|
56
|
+
let serverIsDaemon = false;
|
|
57
|
+
let serverAuthToken;
|
|
48
58
|
// ============================================================================
|
|
49
59
|
// Security: Body Size Limit
|
|
50
60
|
// ============================================================================
|
|
@@ -103,6 +113,46 @@ function timingSafeTokenCompare(provided, expected) {
|
|
|
103
113
|
}
|
|
104
114
|
return timingSafeEqual(a, b);
|
|
105
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The connection's trust boundary for the /agents/* endpoints: ONLY a process on
|
|
118
|
+
* this machine may register/poll/ack a local runtime (the 127.0.0.1 bind is the
|
|
119
|
+
* principal — confused-deputy rule). We check the real socket peer address (not a
|
|
120
|
+
* spoofable header). An EMPTY remoteAddress is rejected (was previously allowed) —
|
|
121
|
+
* a genuine TCP loopback connection always presents 127.0.0.1/::1, so empty only
|
|
122
|
+
* arises from a destroyed/odd socket and must not be treated as local.
|
|
123
|
+
*/
|
|
124
|
+
function isLocalAgentPeer(req) {
|
|
125
|
+
const peer = (req.socket.remoteAddress || '').replace('::ffff:', '');
|
|
126
|
+
return peer === '127.0.0.1' || peer === '::1';
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Mint-or-return the per-session capability token (proof-of-possession the SDK must
|
|
130
|
+
* present on poll/ack). Idempotent per session_key so a heartbeat keeps the token
|
|
131
|
+
* stable; after a daemon restart the in-memory store is empty, so the next
|
|
132
|
+
* register/heartbeat mints a fresh one and the SDK adopts it from the response.
|
|
133
|
+
*/
|
|
134
|
+
function ensureSessionToken(sessionKey) {
|
|
135
|
+
let token = getSessionToken(sessionKey);
|
|
136
|
+
if (!token) {
|
|
137
|
+
token = randomBytes(32).toString('hex');
|
|
138
|
+
setSessionToken(sessionKey, token);
|
|
139
|
+
}
|
|
140
|
+
return token;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* True only for genuine localhost origins. Parses the Origin URL and compares the
|
|
144
|
+
* HOST exactly (localhost / 127.0.0.1 / [::1]) — NOT a startsWith prefix, which
|
|
145
|
+
* `https://localhost.evil.com` and `http://127.0.0.1.evil.com` both defeat.
|
|
146
|
+
*/
|
|
147
|
+
function isLocalhostOrigin(origin) {
|
|
148
|
+
try {
|
|
149
|
+
const { hostname } = new URL(origin);
|
|
150
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
106
156
|
// ============================================================================
|
|
107
157
|
// Version Info
|
|
108
158
|
// ============================================================================
|
|
@@ -167,8 +217,33 @@ function wrapSuccess(data) {
|
|
|
167
217
|
/**
|
|
168
218
|
* Wrap an error in status envelope
|
|
169
219
|
*/
|
|
220
|
+
/**
|
|
221
|
+
* Sanitize error messages to prevent information disclosure.
|
|
222
|
+
* Removes absolute file paths and replaces with generic placeholders.
|
|
223
|
+
* Preserves URLs and relative paths.
|
|
224
|
+
*/
|
|
225
|
+
function sanitizeErrorMessage(message) {
|
|
226
|
+
let sanitized = message;
|
|
227
|
+
// Step 1: Preserve paths containing .decibel (keep the .decibel part, strip prefix)
|
|
228
|
+
// /home/user/project/.decibel/foo -> .decibel/foo
|
|
229
|
+
sanitized = sanitized.replace(/(?:\/[^\s:'"]+)?\/\.decibel\//g, '.decibel/');
|
|
230
|
+
sanitized = sanitized.replace(/(?:[A-Z]:\\[^\s:'"]+)?\\\.decibel\\/gi, '.decibel\\');
|
|
231
|
+
// Step 2: Remove absolute Unix paths (must start with / and have at least one more /)
|
|
232
|
+
// But exclude URLs (http://, https://, file://)
|
|
233
|
+
// Match: /home/user/file.txt, /var/log/app.log
|
|
234
|
+
// Don't match: http://example.com, ./relative/path
|
|
235
|
+
sanitized = sanitized.replace(/(?<!:)\/(?:home|Users|var|tmp|opt|usr|etc|media|mnt)\/[^\s:'"]+/g, '[path]');
|
|
236
|
+
// Step 3: Remove Windows absolute paths (C:\, D:\, etc.)
|
|
237
|
+
sanitized = sanitized.replace(/[A-Z]:\\[^\s:'"]+/gi, '[path]');
|
|
238
|
+
// Step 4: Sanitize any remaining usernames in paths that weren't caught
|
|
239
|
+
sanitized = sanitized.replace(/\/home\/[^\/\s]+\//g, '/home/[user]/');
|
|
240
|
+
sanitized = sanitized.replace(/\/Users\/[^\/\s]+\//g, '/Users/[user]/');
|
|
241
|
+
sanitized = sanitized.replace(/C:\\Users\\[^\\]+\\/gi, 'C:\\Users\\[user]\\');
|
|
242
|
+
return sanitized;
|
|
243
|
+
}
|
|
170
244
|
function wrapError(error, code) {
|
|
171
|
-
|
|
245
|
+
const sanitized = sanitizeErrorMessage(error);
|
|
246
|
+
return { status: 'error', error: sanitized, ...(code && { code }) };
|
|
172
247
|
}
|
|
173
248
|
/**
|
|
174
249
|
* Send JSON response with status envelope
|
|
@@ -215,8 +290,32 @@ async function executeTool(tool, args, req, tierOverride) {
|
|
|
215
290
|
try {
|
|
216
291
|
// Extract agent context from HTTP headers
|
|
217
292
|
const headerAgentId = req?.headers['x-agent-id'];
|
|
218
|
-
// Queue detection: if this is a write call from a remote agent, queue instead of execute
|
|
219
|
-
|
|
293
|
+
// Queue detection: if this is a write call from a remote agent, queue instead of execute.
|
|
294
|
+
//
|
|
295
|
+
// AUTHORIZATION DECOUPLING (sec review 2026-06-07, issue B / confused-deputy):
|
|
296
|
+
// queueForAgent writes to agent_queue via SERVICE_ROLE (bypassing RLS) with
|
|
297
|
+
// created_by = agentId. That is a PRIVILEGED side effect, so the agent identity
|
|
298
|
+
// MUST come from an authenticated PRINCIPAL of the connection — never a
|
|
299
|
+
// caller-supplied X-Agent-Id label (which anyone can set, and — via the
|
|
300
|
+
// unauthenticated /connect — even pre-register, so registry membership is NOT
|
|
301
|
+
// an authenticated identity). The trust boundary differs by mode:
|
|
302
|
+
// - localhost daemon (serverIsDaemon): the 127.0.0.1 bind IS the principal
|
|
303
|
+
// boundary — only local processes reach it. A local process naming an
|
|
304
|
+
// agent_id is acceptable for a local-sync write. Keep the known-agent check
|
|
305
|
+
// as defense-in-depth.
|
|
306
|
+
// - hosted/--http (network-exposed): the principal is the bound auth token.
|
|
307
|
+
// A queue write is allowed ONLY if the request authenticated (serverAuthToken
|
|
308
|
+
// set AND this request passed it — it reached here past the auth gate). With
|
|
309
|
+
// no auth token configured, there is NO authenticated principal, so refuse to
|
|
310
|
+
// do a service-role write on a spoofable label; fall through to normal
|
|
311
|
+
// dispatch (tier/facade checks) instead.
|
|
312
|
+
const agentIsKnown = !!headerAgentId &&
|
|
313
|
+
((agentRegistry?.get(headerAgentId) !== undefined) ||
|
|
314
|
+
daemonConfig?.agents?.[headerAgentId] !== undefined);
|
|
315
|
+
const principalAuthorizesQueueWrite = serverIsDaemon
|
|
316
|
+
? agentIsKnown // local bind = principal; known-agent is DiD
|
|
317
|
+
: !!serverAuthToken && agentIsKnown; // hosted: require an authenticated request
|
|
318
|
+
if (headerAgentId && principalAuthorizesQueueWrite && shouldQueueForAgent(tool, args, headerAgentId)) {
|
|
220
319
|
const parsed = parseToolCall(tool, args);
|
|
221
320
|
const { action: _action, ...queueArgs } = args;
|
|
222
321
|
return await queueForAgent(parsed.facade, parsed.action, queueArgs, headerAgentId, args.projectId || 'default');
|
|
@@ -243,6 +342,7 @@ async function executeTool(tool, args, req, tierOverride) {
|
|
|
243
342
|
scope: req.headers['x-scope'],
|
|
244
343
|
engagementMode: req.headers['x-engagement-mode'],
|
|
245
344
|
userKey: req.headers['x-user-key'],
|
|
345
|
+
orgId: req.headers['x-org-key'],
|
|
246
346
|
requestId: req.headers['x-request-id'],
|
|
247
347
|
tier: tierOverride,
|
|
248
348
|
allowedFacades,
|
|
@@ -348,8 +448,13 @@ function getOpenAITools() {
|
|
|
348
448
|
* - Config-level key → use that as default
|
|
349
449
|
*/
|
|
350
450
|
async function resolveTier(req, configLicenseKey) {
|
|
351
|
-
// Dev mode bypass
|
|
352
|
-
|
|
451
|
+
// Dev mode bypass — EXPLICIT opt-in only. Previously this also fired on
|
|
452
|
+
// `NODE_ENV !== 'production'`, which fails OPEN: the default for
|
|
453
|
+
// `node dist/server.js --http`, most containers, and Render/Heroku is an unset
|
|
454
|
+
// NODE_ENV, so every unauthenticated hosted caller silently got 'pro'/'apps'.
|
|
455
|
+
// The request-time tier boundary must fail CLOSED to 'core' unless an operator
|
|
456
|
+
// explicitly opts in via DECIBEL_PRO=1. (Sec review 2026-06-04, ISS tier-gating.)
|
|
457
|
+
if (process.env.DECIBEL_PRO === '1') {
|
|
353
458
|
return 'pro';
|
|
354
459
|
}
|
|
355
460
|
// Extract license key from Authorization header (separate from auth token)
|
|
@@ -379,6 +484,8 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
379
484
|
startedAt = Date.now();
|
|
380
485
|
agentRegistry = optAgentRegistry;
|
|
381
486
|
daemonConfig = optDaemonConfig;
|
|
487
|
+
serverIsDaemon = isDaemon;
|
|
488
|
+
serverAuthToken = authToken;
|
|
382
489
|
log(`HTTP: Using kernel with ${kernel.toolCount} tools`);
|
|
383
490
|
// Rate limiter (clean up stale entries every 60s)
|
|
384
491
|
const rateLimiter = new RateLimiter(rateLimitRpm);
|
|
@@ -441,15 +548,22 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
441
548
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
442
549
|
}
|
|
443
550
|
else {
|
|
444
|
-
// Daemon mode: restrict REST endpoints to localhost origins
|
|
551
|
+
// Daemon mode: restrict REST endpoints to localhost origins.
|
|
552
|
+
// EXACT host match — a prefix/startsWith check is bypassable by an attacker
|
|
553
|
+
// origin like `https://localhost.evil.com` or `http://127.0.0.1.evil.com`
|
|
554
|
+
// (both startWith an allowed value), which would let a malicious page read
|
|
555
|
+
// localhost daemon data cross-origin. Parse the origin and compare host.
|
|
445
556
|
const origin = req.headers.origin || '';
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
557
|
+
if (!origin) {
|
|
558
|
+
// Non-CORS (curl, same-origin) — no ACAO needed.
|
|
559
|
+
}
|
|
560
|
+
else if (isLocalhostOrigin(origin)) {
|
|
561
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
449
562
|
}
|
|
563
|
+
// else: untrusted origin → no ACAO header (browser blocks the read).
|
|
450
564
|
}
|
|
451
565
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
452
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept, X-Agent-Id, X-Run-Id, X-License-Key, X-Allowed-Facades, X-Scope, X-Request-Id, X-Parent-Call-Id, X-Engagement-Mode, X-User-Key');
|
|
566
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept, X-Agent-Id, X-Run-Id, X-License-Key, X-Allowed-Facades, X-Scope, X-Request-Id, X-Parent-Call-Id, X-Engagement-Mode, X-User-Key, X-Org-Key');
|
|
453
567
|
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
454
568
|
// (a) Handle preflight OPTIONS requests
|
|
455
569
|
if (req.method === 'OPTIONS') {
|
|
@@ -457,6 +571,21 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
457
571
|
res.end();
|
|
458
572
|
return;
|
|
459
573
|
}
|
|
574
|
+
// HOSTED FAIL-CLOSED, checked EARLY (before the rate limiter) so an
|
|
575
|
+
// unauthenticated hosted caller can't consume/poison the rate-limit map or any
|
|
576
|
+
// downstream state before being refused. Public infra routes stay open so
|
|
577
|
+
// health/landing/discovery keep working; everything else needs auth in hosted
|
|
578
|
+
// (--http) mode. (Sec review 2026-06-07; the main gate below still enforces the
|
|
579
|
+
// token when one IS configured.)
|
|
580
|
+
const PUBLIC_HOSTED_ROUTES = new Set([
|
|
581
|
+
'/', '/health', '/ready', '/docs', '/tools', '/openapi.yaml', '/openapi.json',
|
|
582
|
+
'/.well-known/oauth-authorization-server', '/.well-known/openid-configuration',
|
|
583
|
+
'/oauth/authorize', '/oauth/token', '/oauth/register',
|
|
584
|
+
]);
|
|
585
|
+
if (!isDaemon && !authToken && !PUBLIC_HOSTED_ROUTES.has(path)) {
|
|
586
|
+
sendJson(res, 401, wrapError('This endpoint requires authentication. Hosted (--http) mode must be started with DECIBEL_AUTH_TOKEN set.', 'AUTH_NOT_CONFIGURED'));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
460
589
|
// Rate limiting (check before auth to prevent brute force)
|
|
461
590
|
const clientIp = (req.socket.remoteAddress || '127.0.0.1').replace('::ffff:', '');
|
|
462
591
|
const reqAgentId = req.headers['x-agent-id'];
|
|
@@ -487,8 +616,9 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
487
616
|
// Health check at /health too
|
|
488
617
|
if (path === '/health') {
|
|
489
618
|
const uptimeMs = Date.now() - startedAt;
|
|
490
|
-
// Determine pro status from config license key
|
|
491
|
-
|
|
619
|
+
// Determine pro status from config license key. Explicit opt-in only —
|
|
620
|
+
// do NOT infer from NODE_ENV (fails open on hosted; see resolveTier).
|
|
621
|
+
const proEnabled = process.env.DECIBEL_PRO === '1';
|
|
492
622
|
let licenseTier = proEnabled ? 'pro' : 'core';
|
|
493
623
|
if (configLicenseKey && !proEnabled) {
|
|
494
624
|
const cached = getLicenseValidator().getCachedResult(configLicenseKey);
|
|
@@ -528,6 +658,22 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
528
658
|
}
|
|
529
659
|
// GET /events — query dispatch event log (dispatch.jsonl)
|
|
530
660
|
if (path === '/events' && req.method === 'GET') {
|
|
661
|
+
// AUTH BEFORE DATA: /events returns operational telemetry (agent ids, tool
|
|
662
|
+
// names, run/request ids, timestamps, error strings). It sits above the main
|
|
663
|
+
// auth gate, so it must enforce auth itself (sec review 2026-06-04/06-07):
|
|
664
|
+
// - hosted (--http) with no token → fail closed (no authenticated principal);
|
|
665
|
+
// - any mode with a token → require it.
|
|
666
|
+
if (!isDaemon && !authToken) {
|
|
667
|
+
sendJson(res, 401, wrapError('This endpoint requires authentication. Hosted (--http) mode must be started with DECIBEL_AUTH_TOKEN set.', 'AUTH_NOT_CONFIGURED'));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (authToken) {
|
|
671
|
+
const authHeader = req.headers.authorization;
|
|
672
|
+
if (!authHeader || !timingSafeTokenCompare(authHeader, `Bearer ${authToken}`)) {
|
|
673
|
+
sendJson(res, 401, wrapError('Unauthorized', 'UNAUTHORIZED'));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
531
677
|
const dispatchLogPath = join(process.env.HOME || '~', '.decibel', 'logs', 'dispatch.jsonl');
|
|
532
678
|
try {
|
|
533
679
|
const content = readFileSync(dispatchLogPath, 'utf-8');
|
|
@@ -614,6 +760,18 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
614
760
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
615
761
|
return;
|
|
616
762
|
}
|
|
763
|
+
// HOSTED FAIL-CLOSED (sec review 2026-06-07, issue A): in --http (non-daemon)
|
|
764
|
+
// mode the server is network-exposed. If no auth token is configured there is
|
|
765
|
+
// NO authenticated principal, so every route that reaches this gate (the
|
|
766
|
+
// sensitive surface — /call, /connect, /batch, /mcp, /events, etc.; the public
|
|
767
|
+
// infra routes /, /health, /docs, /tools-html, /openapi, OAuth already returned
|
|
768
|
+
// above) MUST be refused rather than served open. In localhost daemon mode the
|
|
769
|
+
// 127.0.0.1 bind is the boundary, so an unset token stays allowed there.
|
|
770
|
+
if (!isDaemon && !authToken) {
|
|
771
|
+
log('HTTP: refusing request — hosted mode requires DECIBEL_AUTH_TOKEN');
|
|
772
|
+
sendJson(res, 401, wrapError('This endpoint requires authentication. Hosted (--http) mode must be started with DECIBEL_AUTH_TOKEN set.', 'AUTH_NOT_CONFIGURED'));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
617
775
|
// Auth check (timing-safe comparison to prevent timing attacks)
|
|
618
776
|
if (authToken) {
|
|
619
777
|
const authHeader = req.headers.authorization;
|
|
@@ -705,6 +863,138 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
705
863
|
}
|
|
706
864
|
return;
|
|
707
865
|
}
|
|
866
|
+
// POST /agents/register and /agents/heartbeat — generic local-runtime presence
|
|
867
|
+
// (swarm P2). The @decibel/hq SDK calls these so ANY local runtime (hermes,
|
|
868
|
+
// openclaw, codex, cursor, custom) appears on HQ's /agents board tagged with its
|
|
869
|
+
// runtime — generalizing the claude-peers presence path beyond Claude Code.
|
|
870
|
+
//
|
|
871
|
+
// IDENTITY = the localhost-bind connection principal (confused-deputy rule): only
|
|
872
|
+
// a LOCAL process may register a local runtime. We check the actual socket peer
|
|
873
|
+
// address (not a spoofable header). The runtime/session_key are untrusted LABELS
|
|
874
|
+
// from a trusted-LOCATION caller — fine for a local presence write. The hosted/BYO
|
|
875
|
+
// path uses an agent-token instead (P4), which is not this endpoint.
|
|
876
|
+
if ((path === '/agents/register' || path === '/agents/heartbeat') && req.method === 'POST') {
|
|
877
|
+
if (!isLocalAgentPeer(req)) {
|
|
878
|
+
sendJson(res, 403, wrapError('agents.register is local-only (the 127.0.0.1 bind is the principal). Hosted/BYO agents use the agent-token ingest path.', 'LOCAL_ONLY'));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
const body = await parseBody(req);
|
|
883
|
+
const session_key = body.session_key;
|
|
884
|
+
const runtime = body.runtime;
|
|
885
|
+
if (!session_key || !runtime) {
|
|
886
|
+
sendJson(res, 400, wrapError('Missing "session_key" and/or "runtime"', 'MISSING_FIELDS'));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const reqStatus = body.status === 'ended' ? 'ended' : 'active';
|
|
890
|
+
const ok = await writeLocalAgentSession({
|
|
891
|
+
session_key,
|
|
892
|
+
runtime,
|
|
893
|
+
agent: body.agent ?? null,
|
|
894
|
+
cwd: body.cwd ?? null,
|
|
895
|
+
summary: body.summary ?? null,
|
|
896
|
+
meta: body.meta ?? undefined,
|
|
897
|
+
status: reqStatus,
|
|
898
|
+
});
|
|
899
|
+
if (!ok) {
|
|
900
|
+
sendJson(res, 503, wrapError('Presence store unavailable (SUPABASE_URL/SERVICE_KEY not configured, or write failed).', 'PRESENCE_UNAVAILABLE'));
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// Capability token: on 'ended' forget it; otherwise mint-or-return the
|
|
904
|
+
// session's token and hand it back so the SDK can authorize poll/ack. The
|
|
905
|
+
// token is the secret that distinguishes THIS registrant from any other
|
|
906
|
+
// co-resident local process guessing the (public) session_key.
|
|
907
|
+
let token;
|
|
908
|
+
if (reqStatus === 'ended') {
|
|
909
|
+
dropSessionToken(session_key);
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
token = ensureSessionToken(session_key);
|
|
913
|
+
}
|
|
914
|
+
log(`HTTP: /agents/${path.endsWith('register') ? 'register' : 'heartbeat'} runtime=${runtime} session=${session_key}`);
|
|
915
|
+
sendJson(res, 200, wrapSuccess({ session_key, runtime, ok: true, ...(token && { token }) }));
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
919
|
+
sendJson(res, 400, wrapError(message, 'REGISTER_ERROR'));
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
// GET /agents/commands?session_key=... — SDK command inbox poll (swarm onCommand).
|
|
924
|
+
// A local SDK runtime polls for HQ commands the dispatcher enqueued for it, runs
|
|
925
|
+
// onCommand, then acks via POST /agents/commands/ack. LOCALHOST-ONLY (same
|
|
926
|
+
// principal as register): the daemon holds the service_role; the SDK never does.
|
|
927
|
+
if (path === '/agents/commands' && req.method === 'GET') {
|
|
928
|
+
if (!isLocalAgentPeer(req)) {
|
|
929
|
+
sendJson(res, 403, wrapError('agent command inbox is local-only', 'LOCAL_ONLY'));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const sessionKey = url.searchParams.get('session_key');
|
|
933
|
+
if (!sessionKey) {
|
|
934
|
+
sendJson(res, 400, wrapError('Missing "session_key" query param', 'MISSING_SESSION_KEY'));
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
// Capability check: session_key is a guessable label, so a co-resident local
|
|
938
|
+
// process could otherwise drain another runtime's inbox. The token proves the
|
|
939
|
+
// caller is the registrant. Timing-safe compare; unknown session → no token →
|
|
940
|
+
// reject. (drainCommands also org-scopes — defense-in-depth on cross-org.)
|
|
941
|
+
const token = url.searchParams.get('token') || '';
|
|
942
|
+
const expected = getSessionToken(sessionKey);
|
|
943
|
+
if (!expected || !timingSafeTokenCompare(token, expected)) {
|
|
944
|
+
sendJson(res, 403, wrapError('Invalid or missing session token', 'BAD_SESSION_TOKEN'));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const commands = drainCommands(sessionKey, ORG_ID);
|
|
948
|
+
sendJson(res, 200, wrapSuccess({ session_key: sessionKey, commands, count: commands.length }));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// POST /agents/commands/ack — SDK reports a command's outcome; daemon settles the
|
|
952
|
+
// hq.agent_commands row in Core (service_role). LOCALHOST-ONLY. Only done|failed.
|
|
953
|
+
if (path === '/agents/commands/ack' && req.method === 'POST') {
|
|
954
|
+
if (!isLocalAgentPeer(req)) {
|
|
955
|
+
sendJson(res, 403, wrapError('agent command ack is local-only', 'LOCAL_ONLY'));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
const body = await parseBody(req);
|
|
960
|
+
const id = body.id;
|
|
961
|
+
const status = body.status;
|
|
962
|
+
if (!id || (status !== 'done' && status !== 'failed')) {
|
|
963
|
+
sendJson(res, 400, wrapError('Require "id" and "status" in {done|failed}', 'INVALID_ACK'));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// Capability check: only the session this command was enqueued for may ack
|
|
967
|
+
// it (else any local process could forge a 'done' + arbitrary result for
|
|
968
|
+
// another runtime's command). Map id→owning session, verify its token.
|
|
969
|
+
const owner = ackOwnerOf(id);
|
|
970
|
+
const expected = owner ? getSessionToken(owner) : undefined;
|
|
971
|
+
const token = typeof body.token === 'string' ? body.token : '';
|
|
972
|
+
if (!owner || !expected || !timingSafeTokenCompare(token, expected)) {
|
|
973
|
+
sendJson(res, 403, wrapError('Ack not authorized for this command', 'BAD_SESSION_TOKEN'));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
// Defense-in-depth: cap the forged-able result blob (1MB body limit already
|
|
977
|
+
// applies; this bounds a single field written to Core under service_role).
|
|
978
|
+
const result = body.result ?? null;
|
|
979
|
+
if (result && Buffer.byteLength(JSON.stringify(result)) > 64 * 1024) {
|
|
980
|
+
sendJson(res, 400, wrapError('Ack "result" too large (max 64KB)', 'RESULT_TOO_LARGE'));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const errStr = typeof body.error === 'string' ? body.error : null;
|
|
984
|
+
const ok = await settleCommandFromAck(id, status, result, errStr);
|
|
985
|
+
if (!ok) {
|
|
986
|
+
sendJson(res, 503, wrapError('Command store unavailable (Supabase not configured)', 'STORE_UNAVAILABLE'));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
clearAckOwner(id);
|
|
990
|
+
sendJson(res, 200, wrapSuccess({ id, status, ok: true }));
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
994
|
+
sendJson(res, 400, wrapError(message, 'ACK_ERROR'));
|
|
995
|
+
}
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
708
998
|
// GET /agents — List connected agents
|
|
709
999
|
if (path === '/agents' && req.method === 'GET') {
|
|
710
1000
|
if (!agentRegistry) {
|
|
@@ -823,6 +1113,7 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
823
1113
|
scope: req.headers['x-scope'] || bodyContext.scope,
|
|
824
1114
|
engagementMode: req.headers['x-engagement-mode'] || bodyContext.engagementMode,
|
|
825
1115
|
userKey: req.headers['x-user-key'] || bodyContext.userKey,
|
|
1116
|
+
orgId: req.headers['x-org-key'] || bodyContext.orgId,
|
|
826
1117
|
requestId: req.headers['x-request-id'] || bodyContext.requestId,
|
|
827
1118
|
allowedFacades: bodyContext.allowedFacades,
|
|
828
1119
|
tier,
|
|
@@ -1762,6 +2053,13 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
1762
2053
|
httpServer.headersTimeout = timeoutMs + 1000; // Slightly longer than keepAliveTimeout
|
|
1763
2054
|
httpServer.listen(port, host, () => {
|
|
1764
2055
|
log(`HTTP Server listening on http://${host}:${port}`);
|
|
2056
|
+
// Advertise port + pid in daemon.meta so clients can discover the daemon.
|
|
2057
|
+
try {
|
|
2058
|
+
setDaemonPort(port);
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
log(`Daemon: failed to write port to meta: ${err instanceof Error ? err.message : String(err)}`);
|
|
2062
|
+
}
|
|
1765
2063
|
console.log(`
|
|
1766
2064
|
╔══════════════════════════════════════════════════════════════╗
|
|
1767
2065
|
║ Decibel MCP Server - HTTP Mode v${PKG.version}${' '.repeat(Math.max(0, 24 - PKG.version.length))}║
|
|
@@ -1782,7 +2080,7 @@ export async function startHttpServer(server, kernelInstance, options) {
|
|
|
1782
2080
|
║ POST /mcp Full MCP protocol ║
|
|
1783
2081
|
╠══════════════════════════════════════════════════════════════╣
|
|
1784
2082
|
║ Base URL: http://${host}:${port}${' '.repeat(Math.max(0, 40 - port.toString().length - host.length))}║
|
|
1785
|
-
${authToken ? '║ Auth: Bearer token required ║' : '║ Auth: None (
|
|
2083
|
+
${authToken ? '║ Auth: Bearer token required ║' : '║ Auth: None (set DECIBEL_AUTH_TOKEN env var) ║'}
|
|
1786
2084
|
╠══════════════════════════════════════════════════════════════╣
|
|
1787
2085
|
║ SSE Settings: ║
|
|
1788
2086
|
║ Keepalive: ${sseKeepaliveMs}ms${' '.repeat(Math.max(0, 43 - sseKeepaliveMs.toString().length))}║
|
|
@@ -1837,13 +2135,23 @@ ${authToken ? '║ Auth: Bearer token required
|
|
|
1837
2135
|
export function parseHttpArgs(args) {
|
|
1838
2136
|
const httpMode = args.includes('--http');
|
|
1839
2137
|
const portIndex = args.indexOf('--port');
|
|
1840
|
-
//
|
|
1841
|
-
|
|
1842
|
-
|
|
2138
|
+
// --port flag wins; else honor PORT env (Render sets it); else leave undefined
|
|
2139
|
+
// so daemonConfig's default (4888) applies downstream instead of being
|
|
2140
|
+
// short-circuited by a baked-in default here. See server.ts port/host resolution.
|
|
2141
|
+
const port = portIndex !== -1
|
|
2142
|
+
? parseInt(args[portIndex + 1], 10)
|
|
2143
|
+
: process.env.PORT
|
|
2144
|
+
? parseInt(process.env.PORT, 10)
|
|
2145
|
+
: undefined;
|
|
2146
|
+
// SECURITY: Prefer env var for auth token (CLI args visible in ps/history)
|
|
2147
|
+
// Fall back to --auth-token for backwards compatibility
|
|
1843
2148
|
const authIndex = args.indexOf('--auth-token');
|
|
1844
|
-
const authToken =
|
|
2149
|
+
const authToken = process.env.DECIBEL_AUTH_TOKEN ||
|
|
2150
|
+
(authIndex !== -1 ? args[authIndex + 1] : undefined);
|
|
1845
2151
|
const hostIndex = args.indexOf('--host');
|
|
1846
|
-
|
|
2152
|
+
// --host flag wins; else leave undefined so daemonConfig host (127.0.0.1, daemon
|
|
2153
|
+
// mode) or the transport default applies instead of being short-circuited here.
|
|
2154
|
+
const host = hostIndex !== -1 ? args[hostIndex + 1] : undefined;
|
|
1847
2155
|
// SSE/Connection tuning arguments
|
|
1848
2156
|
const keepaliveIndex = args.indexOf('--sse-keepalive');
|
|
1849
2157
|
const sseKeepaliveMs = keepaliveIndex !== -1 ? parseInt(args[keepaliveIndex + 1], 10) : undefined;
|