@decibelsystems/tools 2.1.2 → 2.1.4

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.
Files changed (151) hide show
  1. package/README.md +144 -63
  2. package/dist/agentCommands.d.ts +11 -0
  3. package/dist/agentCommands.d.ts.map +1 -0
  4. package/dist/agentCommands.js +267 -0
  5. package/dist/agentCommands.js.map +1 -0
  6. package/dist/agentInbox.d.ts +37 -0
  7. package/dist/agentInbox.d.ts.map +1 -0
  8. package/dist/agentInbox.js +128 -0
  9. package/dist/agentInbox.js.map +1 -0
  10. package/dist/agentPresence.d.ts +36 -0
  11. package/dist/agentPresence.d.ts.map +1 -0
  12. package/dist/agentPresence.js +227 -0
  13. package/dist/agentPresence.js.map +1 -0
  14. package/dist/agenticJobs.d.ts +49 -0
  15. package/dist/agenticJobs.d.ts.map +1 -0
  16. package/dist/agenticJobs.js +190 -0
  17. package/dist/agenticJobs.js.map +1 -0
  18. package/dist/architectAdrs.d.ts +7 -0
  19. package/dist/architectAdrs.d.ts.map +1 -1
  20. package/dist/architectAdrs.js +91 -28
  21. package/dist/architectAdrs.js.map +1 -1
  22. package/dist/daemon.d.ts +6 -0
  23. package/dist/daemon.d.ts.map +1 -1
  24. package/dist/daemon.js +14 -0
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/facades/definitions.d.ts.map +1 -1
  27. package/dist/facades/definitions.js +40 -6
  28. package/dist/facades/definitions.js.map +1 -1
  29. package/dist/hq/defineAgent.d.ts +11 -0
  30. package/dist/hq/defineAgent.d.ts.map +1 -0
  31. package/dist/hq/defineAgent.js +185 -0
  32. package/dist/hq/defineAgent.js.map +1 -0
  33. package/dist/hq/index.d.ts +3 -0
  34. package/dist/hq/index.d.ts.map +1 -0
  35. package/dist/hq/index.js +28 -0
  36. package/dist/hq/index.js.map +1 -0
  37. package/dist/hq/types.d.ts +59 -0
  38. package/dist/hq/types.d.ts.map +1 -0
  39. package/dist/hq/types.js +12 -0
  40. package/dist/hq/types.js.map +1 -0
  41. package/dist/httpServer.d.ts +1 -1
  42. package/dist/httpServer.d.ts.map +1 -1
  43. package/dist/httpServer.js +327 -19
  44. package/dist/httpServer.js.map +1 -1
  45. package/dist/kernel.d.ts +3 -1
  46. package/dist/kernel.d.ts.map +1 -1
  47. package/dist/kernel.js +40 -9
  48. package/dist/kernel.js.map +1 -1
  49. package/dist/projectRegistry.d.ts +27 -0
  50. package/dist/projectRegistry.d.ts.map +1 -1
  51. package/dist/projectRegistry.js +191 -5
  52. package/dist/projectRegistry.js.map +1 -1
  53. package/dist/sentinelIssues.d.ts +5 -0
  54. package/dist/sentinelIssues.d.ts.map +1 -1
  55. package/dist/sentinelIssues.js +1 -1
  56. package/dist/sentinelIssues.js.map +1 -1
  57. package/dist/server.js +12 -0
  58. package/dist/server.js.map +1 -1
  59. package/dist/store/fsStore.d.ts +6 -0
  60. package/dist/store/fsStore.d.ts.map +1 -0
  61. package/dist/store/fsStore.js +77 -0
  62. package/dist/store/fsStore.js.map +1 -0
  63. package/dist/store/index.d.ts +8 -0
  64. package/dist/store/index.d.ts.map +1 -0
  65. package/dist/store/index.js +23 -0
  66. package/dist/store/index.js.map +1 -0
  67. package/dist/store/markdown.d.ts +21 -0
  68. package/dist/store/markdown.d.ts.map +1 -0
  69. package/dist/store/markdown.js +175 -0
  70. package/dist/store/markdown.js.map +1 -0
  71. package/dist/store/supabaseStore.d.ts +6 -0
  72. package/dist/store/supabaseStore.d.ts.map +1 -0
  73. package/dist/store/supabaseStore.js +152 -0
  74. package/dist/store/supabaseStore.js.map +1 -0
  75. package/dist/store/types.d.ts +49 -0
  76. package/dist/store/types.d.ts.map +1 -0
  77. package/dist/store/types.js +11 -0
  78. package/dist/store/types.js.map +1 -0
  79. package/dist/supabaseRetry.d.ts +21 -0
  80. package/dist/supabaseRetry.d.ts.map +1 -0
  81. package/dist/supabaseRetry.js +72 -0
  82. package/dist/supabaseRetry.js.map +1 -0
  83. package/dist/toolConfig.d.ts +49 -0
  84. package/dist/toolConfig.d.ts.map +1 -0
  85. package/dist/toolConfig.js +309 -0
  86. package/dist/toolConfig.js.map +1 -0
  87. package/dist/tools/agentic/agentQueue.d.ts.map +1 -1
  88. package/dist/tools/agentic/agentQueue.js +7 -5
  89. package/dist/tools/agentic/agentQueue.js.map +1 -1
  90. package/dist/tools/agentic/index.d.ts +3 -0
  91. package/dist/tools/agentic/index.d.ts.map +1 -1
  92. package/dist/tools/agentic/index.js +158 -1
  93. package/dist/tools/agentic/index.js.map +1 -1
  94. package/dist/tools/codereview/index.d.ts +4 -0
  95. package/dist/tools/codereview/index.d.ts.map +1 -0
  96. package/dist/tools/codereview/index.js +93 -0
  97. package/dist/tools/codereview/index.js.map +1 -0
  98. package/dist/tools/codereview.d.ts +56 -0
  99. package/dist/tools/codereview.d.ts.map +1 -0
  100. package/dist/tools/codereview.js +304 -0
  101. package/dist/tools/codereview.js.map +1 -0
  102. package/dist/tools/concepts.d.ts +38 -0
  103. package/dist/tools/concepts.d.ts.map +1 -0
  104. package/dist/tools/concepts.js +283 -0
  105. package/dist/tools/concepts.js.map +1 -0
  106. package/dist/tools/corpus/index.d.ts +3 -0
  107. package/dist/tools/corpus/index.d.ts.map +1 -1
  108. package/dist/tools/corpus/index.js +183 -1
  109. package/dist/tools/corpus/index.js.map +1 -1
  110. package/dist/tools/corpus.d.ts +52 -0
  111. package/dist/tools/corpus.d.ts.map +1 -1
  112. package/dist/tools/corpus.js +207 -2
  113. package/dist/tools/corpus.js.map +1 -1
  114. package/dist/tools/dojo.d.ts.map +1 -1
  115. package/dist/tools/dojo.js +11 -1
  116. package/dist/tools/dojo.js.map +1 -1
  117. package/dist/tools/git-sentinel.d.ts.map +1 -1
  118. package/dist/tools/git-sentinel.js +24 -8
  119. package/dist/tools/git-sentinel.js.map +1 -1
  120. package/dist/tools/index.d.ts.map +1 -1
  121. package/dist/tools/index.js +6 -0
  122. package/dist/tools/index.js.map +1 -1
  123. package/dist/tools/oracle.d.ts +2 -0
  124. package/dist/tools/oracle.d.ts.map +1 -1
  125. package/dist/tools/oracle.js +146 -17
  126. package/dist/tools/oracle.js.map +1 -1
  127. package/dist/tools/peers.d.ts +15 -0
  128. package/dist/tools/peers.d.ts.map +1 -0
  129. package/dist/tools/peers.js +70 -0
  130. package/dist/tools/peers.js.map +1 -0
  131. package/dist/tools/registry/index.d.ts +4 -0
  132. package/dist/tools/registry/index.d.ts.map +1 -1
  133. package/dist/tools/registry/index.js +274 -1
  134. package/dist/tools/registry/index.js.map +1 -1
  135. package/dist/tools/roadmap.d.ts +4 -0
  136. package/dist/tools/roadmap.d.ts.map +1 -1
  137. package/dist/tools/roadmap.js.map +1 -1
  138. package/dist/tools/sentinel.d.ts +15 -1
  139. package/dist/tools/sentinel.d.ts.map +1 -1
  140. package/dist/tools/sentinel.js +198 -34
  141. package/dist/tools/sentinel.js.map +1 -1
  142. package/dist/tools/voice/index.d.ts.map +1 -1
  143. package/dist/tools/voice/index.js +2 -3
  144. package/dist/tools/voice/index.js.map +1 -1
  145. package/dist/tools/voice.d.ts +1 -1
  146. package/dist/tools/voice.js +2 -2
  147. package/dist/tools/voice.js.map +1 -1
  148. package/dist/tools/workflow.js +5 -5
  149. package/dist/tools/workflow.js.map +1 -1
  150. package/dist/transports/http.js +1 -1
  151. package/package.json +4 -2
@@ -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
- return { status: 'error', error, ...(code && { code }) };
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
- if (headerAgentId && shouldQueueForAgent(tool, args, headerAgentId)) {
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
- if (process.env.DECIBEL_PRO === '1' || process.env.NODE_ENV !== 'production') {
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
- const localhostOrigins = ['http://localhost', 'http://127.0.0.1', 'https://localhost', 'https://127.0.0.1'];
447
- if (localhostOrigins.some(lo => origin.startsWith(lo)) || !origin) {
448
- res.setHeader('Access-Control-Allow-Origin', origin || 'http://localhost');
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
- const proEnabled = process.env.DECIBEL_PRO === '1' || process.env.NODE_ENV !== 'production';
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 (use --auth-token for security) ║'}
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
- // Render sets PORT env var - use it if available
1841
- const defaultPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 8787;
1842
- const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : defaultPort;
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 = authIndex !== -1 ? args[authIndex + 1] : undefined;
2149
+ const authToken = process.env.DECIBEL_AUTH_TOKEN ||
2150
+ (authIndex !== -1 ? args[authIndex + 1] : undefined);
1845
2151
  const hostIndex = args.indexOf('--host');
1846
- const host = hostIndex !== -1 ? args[hostIndex + 1] : '0.0.0.0';
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;