@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.1

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/daemon.js CHANGED
@@ -2,6 +2,7 @@ const express = require('express');
2
2
  const cors = require('cors');
3
3
  const pty = require('node-pty');
4
4
  const os = require('os');
5
+ const path = require('path');
5
6
  const crypto = require('crypto');
6
7
  const { getConfig } = require('./auth');
7
8
  const pkg = require('./package.json');
@@ -22,6 +23,8 @@ const lifecycle = require('./src/lifecycle');
22
23
  const { SURFACE_ORPHAN_SECONDS, SURFACE_MISMATCH_SECONDS, decideSurfaceGc, applySurfaceMismatchProbe } = lifecycle;
23
24
  const { loadTeleptyConfig } = require('./src/config-file');
24
25
  const sessionPersistence = require('./src/session-store/persistence');
26
+ const { createAuditWriter, readInjectLog } = require('./src/audit/inject-log');
27
+ const { mintSessionNonce, applyProvenance } = require('./src/audit/provenance');
25
28
 
26
29
  const config = getConfig();
27
30
  const EXPECTED_TOKEN = config.authToken;
@@ -134,6 +137,18 @@ function loadPersistedSessions() {
134
137
 
135
138
  const app = express();
136
139
  app.use(cors());
140
+
141
+ // #42 broker MVP (W3/T5) — broker-mode HTTP surface (spec §2F, §5). DEFAULT-OFF:
142
+ // only when TELEPTY_BROKER_MODE is set does the daemon mount the broker-server at
143
+ // /broker/*. Mounted BEFORE express.json so the broker reads the raw request stream
144
+ // itself (it has its own per-node JWT gate); the existing /api/* auth path is untouched.
145
+ // Fail-fast (loud throw) if a required broker env is missing. The HTTPS listener that
146
+ // serves this handler is created in the boot block below (TLS mandatory, §4.4).
147
+ let brokerServer = null;
148
+ if (brokerEnv().mode) {
149
+ brokerServer = mountBrokerMode(app);
150
+ }
151
+
137
152
  app.use(express.json());
138
153
 
139
154
  // Peer allowlist: comma-separated IPs/CIDRs in TELEPTY_PEER_ALLOWLIST env
@@ -146,6 +161,81 @@ const PEER_ALLOWLIST = (process.env.TELEPTY_PEER_ALLOWLIST || '').split(',').map
146
161
  const ORCHESTRATOR_SIDS = (process.env.AIGENTRY_ORCHESTRATOR_SIDS || 'orchestrator aigentry-orchestrator-claude')
147
162
  .split(/\s+/).map(s => s.trim()).filter(Boolean);
148
163
 
164
+ // #45 — defense-in-depth blast-radius cap for operator-lane fan-out (broadcast/
165
+ // multicast). Even legitimate operator fan-out is bounded so a single compromised
166
+ // fan-out call cannot hit an unbounded number of sessions in one hop. Generous
167
+ // default (operator broadcasts hit only the live mesh); tune via env.
168
+ const FANOUT_MAX_TARGETS = Math.max(1, Number(process.env.TELEPTY_FANOUT_MAX_TARGETS || 100));
169
+
170
+ // #43 — inject audit spine (spec §5/§8). One JSONL line per delivery into
171
+ // ~/.telepty/logs/injects.jsonl (0600). Defaults locked (hash-only preview, 30d / 50MB × 5);
172
+ // env-overridable. The writer is off the delivery hot path: auditAppend() never blocks or
173
+ // throws into a handler. P1 records claimed_from only (verified_sender_sid wired in P2).
174
+ const AUDIT_LOG_PATH = path.join(os.homedir(), '.telepty', 'logs', 'injects.jsonl');
175
+ const auditWriter = createAuditWriter({
176
+ path: AUDIT_LOG_PATH,
177
+ preview: process.env.TELEPTY_AUDIT_PREVIEW === '1',
178
+ previewBytes: Number(process.env.TELEPTY_AUDIT_PREVIEW_BYTES) || 200,
179
+ flushMs: Number(process.env.TELEPTY_AUDIT_FLUSH_MS) || 250,
180
+ queueMax: Number(process.env.TELEPTY_AUDIT_QUEUE_MAX) || 10000,
181
+ maxBytes: Number(process.env.TELEPTY_AUDIT_MAX_BYTES) || 50 * 1024 * 1024,
182
+ maxFiles: Number(process.env.TELEPTY_AUDIT_MAX_FILES) || 5,
183
+ maxAgeDays: Number(process.env.TELEPTY_AUDIT_MAX_AGE_DAYS) || 30
184
+ });
185
+ // Overflow is visible, never silent: surface a single bus event per drop (spec §8 T4).
186
+ auditWriter.on('audit_overflow', (info) => {
187
+ try {
188
+ broadcastBusEvent({ type: 'audit_overflow', sender: 'daemon', dropped: info.dropped, queue_max: info.queueMax, timestamp: new Date().toISOString() });
189
+ } catch { /* bus best-effort */ }
190
+ });
191
+ auditWriter.on('audit_error', (err) => {
192
+ console.warn(`[AUDIT] write error: ${err && err.message ? err.message : err}`);
193
+ });
194
+ // Fire-and-forget append — swallow any sync error so the audit log can never break delivery.
195
+ function auditAppend(record) {
196
+ try { auditWriter.append(record); } catch { /* audit must never throw into a handler */ }
197
+ }
198
+
199
+ // #43 P2 — per-session verified-sender tokens (spec §4, ADR D1). The daemon mints a random
200
+ // token at /api/sessions/register and maps token→sid; the `allow` wrapper carries it in the
201
+ // parent-hijack-protected env beside TELEPTY_SESSION_ID; `inject` presents x-telepty-session-token.
202
+ // `verified_sender_sid` = the mapped sid (the daemon's own truth) or null when unverifiable.
203
+ // Issuance is idempotent per sid so the periodic metadata re-register (cli.js
204
+ // updateDaemonProcessMetadata) does NOT rotate the token out from under the carried env.
205
+ const sessionTokens = new Map(); // token → sid
206
+ const sidTokens = new Map(); // sid → token
207
+ function mintSessionToken(sid) {
208
+ const existing = sidTokens.get(sid);
209
+ if (existing) return existing;
210
+ const token = crypto.randomBytes(32).toString('base64url');
211
+ sessionTokens.set(token, sid);
212
+ sidTokens.set(sid, token);
213
+ return token;
214
+ }
215
+
216
+ // #47 P4 — per-session provenance nonce (spec §6, ADR §3 D3). The daemon mints one nonce per
217
+ // sid at register and delivers it to the agent ONCE over the trusted bootstrap/onboarding channel
218
+ // (the protected env, not any deliverable payload). The receiving agent trusts a delivery's origin
219
+ // banner ONLY if it carries this nonce. Issuance is idempotent per sid so the periodic metadata
220
+ // re-register does not rotate the nonce out from under the carried env (matches the token above).
221
+ const sidNonces = new Map(); // sid → nonce
222
+ function ensureSessionNonce(sid) {
223
+ const existing = sidNonces.get(sid);
224
+ if (existing) return existing;
225
+ const nonce = mintSessionNonce();
226
+ sidNonces.set(sid, nonce);
227
+ return nonce;
228
+ }
229
+ function resolveVerifiedSender(token) {
230
+ if (!token) return null;
231
+ return sessionTokens.get(token) || null;
232
+ }
233
+ // Extract the presented session token from an inject request (header only — never the body,
234
+ // which is attacker-controlled). Returns the daemon-verified sid or null.
235
+ function verifiedSenderFromReq(req) {
236
+ return resolveVerifiedSender(req.headers && req.headers['x-telepty-session-token']);
237
+ }
238
+
149
239
  // Cross-machine bus relay: forward bus events to peer daemons
150
240
  const relayToPeers = createPeerRelay({
151
241
  relayPeers: relayPeersFromEnv(process.env),
@@ -170,6 +260,10 @@ app.get('/api/health', (req, res) => {
170
260
  app.use(createAuthMiddleware({ isAllowedPeer, expectedToken: EXPECTED_TOKEN, verifyJwt }));
171
261
 
172
262
  const PORT = process.env.PORT || 3848;
263
+ // Actual bound port. Equals PORT for a fixed port; when PORT=0 the OS assigns an
264
+ // ephemeral port and this is resolved to the real value in the listen callback.
265
+ // Reported by /api/meta so callers (e.g. the test harness) can read it back.
266
+ let boundPort = Number(PORT);
173
267
 
174
268
  const HOST = process.env.HOST || '0.0.0.0';
175
269
  process.title = 'telepty-daemon';
@@ -681,9 +775,15 @@ async function executeBootstrapInject(sessionId, session, op) {
681
775
 
682
776
  function parseSubmitRetryOptions(body = {}, injectedBody = null) {
683
777
  const hasExplicitRetries = body && body.retries !== undefined && body.retries !== null;
684
- const retries = Math.min(Math.max(Number(hasExplicitRetries ? body.retries : (injectedBody ? 1 : 0)) || 0, 0), 3);
685
- const retryDelayMs = Math.min(Math.max(Number(body?.retry_delay_ms) || 500, 100), 2000);
686
- const verifyTimeoutMs = Math.min(Math.max(Number(body?.verify_timeout_ms) || 1500, 200), 5000);
778
+ // #568: raise the retry-budget ceiling so a momentarily-busy CLI still gets a
779
+ // render-gated CR before we return 504 (each retry re-gates on input-ready, so
780
+ // more attempts = more quiet-window chances to land). Ceilings only — the
781
+ // default stays 1 for the injected case (behavior-preserving common path);
782
+ // heavier callers opt into the bigger budget via retries/retry_delay_ms/
783
+ // verify_timeout_ms. Total worst-case budget stays bounded (~10 attempts).
784
+ const retries = Math.min(Math.max(Number(hasExplicitRetries ? body.retries : (injectedBody ? 1 : 0)) || 0, 0), 10);
785
+ const retryDelayMs = Math.min(Math.max(Number(body?.retry_delay_ms) || 500, 100), 3000);
786
+ const verifyTimeoutMs = Math.min(Math.max(Number(body?.verify_timeout_ms) || 1500, 200), 8000);
687
787
  return { retries, retryDelayMs, verifyTimeoutMs };
688
788
  }
689
789
 
@@ -716,6 +816,29 @@ async function confirmSubmitAfterDispatch(id, session, injectedBody, submittedAt
716
816
  return submitGate.confirmSubmitAccepted(session, injectedBody, buildSubmitConfirmOptions(id, session, submittedAtMs, verifyTimeoutMs));
717
817
  }
718
818
 
819
+ // #568 — render-gate the submit CR. Before writing the bare 0x0D, wait (bounded)
820
+ // until the injected body is echoed in the PTY outputRing AND the render has gone
821
+ // quiet, so the CR does not land mid-render and get dropped (FM1; same gate before
822
+ // each retry CR, FM2). Best-effort + bounded: on timeout / no body / no ring we
823
+ // fall through and still write the CR — never worse than the pre-gate behavior.
824
+ // pty_cr stays the ONLY write path (terminalLevelSubmit); this only times WHEN.
825
+ // Per-request opt-out via `input_settle_gate: false` (rollback/parity escape hatch).
826
+ async function gatedTerminalSubmit(id, session, injectedBody, settleEnabled) {
827
+ if (injectedBody && injectedBody.length > 0 && settleEnabled !== false) {
828
+ const settle = await submitGate.awaitInputSettled(session, injectedBody, {
829
+ timeoutMs: 1200,
830
+ quietWindowMs: 100,
831
+ echoGraceMs: 400,
832
+ pollIntervalMs: 30,
833
+ stripAnsi: stripAnsiState,
834
+ });
835
+ if (!settle.ready) {
836
+ console.log(`[SUBMIT] input-settle gate timed out for ${id} (${settle.waited_ms}ms, echoed=${settle.echoed}) — sending CR best-effort`);
837
+ }
838
+ }
839
+ return terminalLevelSubmit(id, session);
840
+ }
841
+
719
842
  async function executeBootstrapSubmit(sessionId, session, op) {
720
843
  const body = op.body || {};
721
844
  const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
@@ -724,8 +847,9 @@ async function executeBootstrapSubmit(sessionId, session, op) {
724
847
  markPendingReportSubmitStarted(sessionId, injectedBody);
725
848
  }
726
849
 
850
+ const settleEnabled = body.input_settle_gate !== false;
851
+ let strategy = await gatedTerminalSubmit(sessionId, session, injectedBody, settleEnabled);
727
852
  const submittedAtMs = Date.now();
728
- let strategy = terminalLevelSubmit(sessionId, session);
729
853
  if (!strategy) {
730
854
  if (injectedBody) {
731
855
  markPendingReportSubmitUnconfirmed(sessionId, { reason: 'strategy_failed', attempts: 0, retryable: false });
@@ -745,8 +869,8 @@ async function executeBootstrapSubmit(sessionId, session, op) {
745
869
  let confirm = await confirmSubmitAfterDispatch(sessionId, session, injectedBody, submittedAtMs, verifyTimeoutMs);
746
870
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
747
871
  await sleep(retryDelayMs);
872
+ const retryStrategy = await gatedTerminalSubmit(sessionId, session, injectedBody, settleEnabled);
748
873
  const retrySubmittedAtMs = Date.now();
749
- const retryStrategy = terminalLevelSubmit(sessionId, session);
750
874
  if (!retryStrategy) break;
751
875
  strategy = retryStrategy;
752
876
  attempts++;
@@ -1340,12 +1464,27 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
1340
1464
  const from = options.from || 'daemon';
1341
1465
  const msgId = `${from}:${Date.now()}:${crypto.randomUUID().slice(0, 8)}`;
1342
1466
 
1467
+ // #47 P4 — capability-gated delivery provenance banner (spec §6). Default-OFF: only sessions
1468
+ // that registered as provenance-capable (and have a minted nonce) get the nonce-gated banner;
1469
+ // legacy/byte-exact-sensitive sessions receive `prompt` byte-for-byte (regression guard). The
1470
+ // audit log below still hashes the RAW `prompt`, not the banner — provenance is a delivery
1471
+ // wrapper, not a content change. `from`='daemon'/'inject' are routing sentinels, not real
1472
+ // claimed senders, so they are not surfaced as a `claimed:` label.
1473
+ const claimedSender = (from && from !== 'daemon' && from !== 'inject') ? from : null;
1474
+ const deliveredPrompt = applyProvenance(prompt, {
1475
+ capable: !!(session && session.provenanceCapable),
1476
+ nonce: session && session.provenanceNonce,
1477
+ verified: options.verifiedSenderSid || null,
1478
+ claimed: claimedSender,
1479
+ origin: options.origin
1480
+ }).payload;
1481
+
1343
1482
  try {
1344
1483
  const ack = mailbox.enqueue({
1345
1484
  msg_id: msgId,
1346
1485
  from,
1347
1486
  to: id,
1348
- payload: prompt,
1487
+ payload: deliveredPrompt,
1349
1488
  created_at: Math.floor(now / 1000),
1350
1489
  attempt: 0,
1351
1490
  });
@@ -1386,7 +1525,7 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
1386
1525
  } catch (err) {
1387
1526
  console.error(`[MAILBOX] Enqueue failed for ${id}: ${err.message}`);
1388
1527
  // Fallback: direct delivery (backward compat during migration)
1389
- const textResult = await writeDataToSession(id, session, prompt);
1528
+ const textResult = await writeDataToSession(id, session, deliveredPrompt);
1390
1529
  if (!textResult.success) return textResult;
1391
1530
 
1392
1531
  if (!options.noEnter && session.type !== 'aterm') {
@@ -1775,8 +1914,12 @@ app.post('/api/sessions/register', (req, res) => {
1775
1914
  applyIdleTtlMetadata(existing, parsedIdleTtl);
1776
1915
  applyTimestampMetadata(existing, req.body);
1777
1916
  initializeBootstrapState(existing);
1917
+ // #47 P4 — provenance capability is opt-in (default-OFF). Only flip it ON; never silently OFF
1918
+ // on a metadata re-register, or a session's delivered bytes would change mid-flight.
1919
+ if (req.body.provenance_capable === true) existing.provenanceCapable = true;
1920
+ existing.provenanceNonce = ensureSessionNonce(session_id);
1778
1921
  console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
1779
- return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true });
1922
+ return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true, session_token: mintSessionToken(session_id), session_nonce: existing.provenanceNonce, provenance_capable: !!existing.provenanceCapable });
1780
1923
  }
1781
1924
 
1782
1925
  const { delivery_type, delivery_endpoint, delivery } = req.body;
@@ -1809,6 +1952,10 @@ app.post('/api/sessions/register', (req, res) => {
1809
1952
  isClosing: false,
1810
1953
  outputRing: [],
1811
1954
  ready: true, // unknown commands remain injectable once registered (#150)
1955
+ // #47 P4 — provenance banner is opt-in per session (default-OFF, spec §6 rollout). A nonce is
1956
+ // always minted (cheap) but the capability-gated banner only wraps deliveries when capable.
1957
+ provenanceCapable: req.body.provenance_capable === true,
1958
+ provenanceNonce: ensureSessionNonce(session_id),
1812
1959
  };
1813
1960
  initializeBootstrapState(sessionRecord);
1814
1961
  applyTimestampMetadata(sessionRecord, req.body);
@@ -1849,7 +1996,7 @@ app.post('/api/sessions/register', (req, res) => {
1849
1996
 
1850
1997
  console.log(`[REGISTER] Registered wrapped session ${session_id}`);
1851
1998
  persistSessions();
1852
- res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd });
1999
+ res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd, session_token: mintSessionToken(session_id), session_nonce: sessionRecord.provenanceNonce, provenance_capable: sessionRecord.provenanceCapable });
1853
2000
  });
1854
2001
 
1855
2002
  app.get('/api/sessions', (req, res) => {
@@ -1862,6 +2009,27 @@ app.get('/api/sessions', (req, res) => {
1862
2009
  res.json(list);
1863
2010
  });
1864
2011
 
2012
+ // #43 P3 — token-gated historical inject audit query (spec §7). Behind the SAME shared auth
2013
+ // middleware as every /api/* route (app.use(createAuthMiddleware) above), so it is 401 for an
2014
+ // unauthorized non-local request and open to localhost/allowlisted peers. Filters: since/until,
2015
+ // to (alias-resolved), from (claimed OR verified), spoof; pagination via limit/cursor (newest
2016
+ // first). Reads the live injects.jsonl (one write path, file-backed) — separate lifecycle from
2017
+ // the ephemeral /api/events live bus, so the two are not conflated.
2018
+ app.get('/api/injects', (req, res) => {
2019
+ const q = req.query || {};
2020
+ const to = q.to ? (resolveSessionAlias(q.to) || q.to) : undefined;
2021
+ const result = readInjectLog(AUDIT_LOG_PATH, {
2022
+ since: q.since,
2023
+ until: q.until,
2024
+ to,
2025
+ from: q.from,
2026
+ spoof: q.spoof === '1' || q.spoof === 'true',
2027
+ limit: q.limit,
2028
+ cursor: q.cursor
2029
+ });
2030
+ res.json(result);
2031
+ });
2032
+
1865
2033
  app.get('/api/sessions/:id', (req, res) => {
1866
2034
  const requestedId = req.params.id;
1867
2035
  const resolvedId = resolveSessionAlias(requestedId);
@@ -1922,7 +2090,7 @@ app.get('/api/meta', (req, res) => {
1922
2090
  version: pkg.version,
1923
2091
  pid: process.pid,
1924
2092
  host: HOST,
1925
- port: Number(PORT),
2093
+ port: boundPort,
1926
2094
  machine_id: MACHINE_ID,
1927
2095
  terminal: DETECTED_TERMINAL,
1928
2096
  capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine', 'mailbox']
@@ -1991,26 +2159,92 @@ app.get('/api/peers', (req, res) => {
1991
2159
  }
1992
2160
  });
1993
2161
 
2162
+ // #45 — fan-out (broadcast/multicast) is OPERATOR/ORCHESTRATOR-ONLY. The single-inject
2163
+ // path hard-blocks off-policy peer→peer traffic via classifyPeerLaneInject; fan-out
2164
+ // one→many is strictly more dangerous (a worm/fan-out primitive), so the peer lane is
2165
+ // blocked OUTRIGHT here — even a sanctioned ask-envelope may not fan out. We reuse the
2166
+ // SAME classifier (DRY, no second policy) and gate on the LANE, not the per-target
2167
+ // decision: classify by SENDER (`to: null`) so the verdict is the sender's lane,
2168
+ // independent of any individual target. This is what makes broadcast all-or-nothing on
2169
+ // the lane — a peer cannot earn fan-out rights by listing the orchestrator as one
2170
+ // target. Operator lane (no `from`, or `from` ∈ orchestrator sids) and the fail-open
2171
+ // `disabled` lane proceed to delivery, exactly as single-inject allows them. #533 Phase 2.
2172
+ function isPeerLaneFanout(from, prompt) {
2173
+ return classifyPeerLaneInject({ from, to: null, prompt, orchestratorSids: ORCHESTRATOR_SIDS });
2174
+ }
2175
+
2176
+ // Reject a peer-lane fan-out: emit a per-target peer_inject_blocked bus event for every
2177
+ // intended target (mirrors the single-inject block event for reporting parity) and return
2178
+ // the same 403 PEER_INJECT_BLOCKED shape, reaching ZERO sessions. `targetIds` is the full
2179
+ // intended target set (broadcast = all sessions, multicast = requested session_ids).
2180
+ function rejectPeerLaneFanout(res, { from, reason, targetIds, source, verifiedSenderSid = null, prompt = '' }) {
2181
+ const inject_id = crypto.randomUUID();
2182
+ const failed = [];
2183
+ for (const id of targetIds) {
2184
+ broadcastSessionEvent('peer_inject_blocked', id, sessions[id] || null, {
2185
+ extra: {
2186
+ target_agent: id,
2187
+ from: from || null,
2188
+ reason,
2189
+ source,
2190
+ inject_id
2191
+ }
2192
+ });
2193
+ // #47 P5 — one shared-schema audit line per blocked target (mirrors the success per-target
2194
+ // fan-out audit), so a blocked fan-out's blast-radius is queryable just like a delivered one.
2195
+ auditAppend({
2196
+ ts: new Date().toISOString(), inject_id, kind: source, source,
2197
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2198
+ to: id, to_alias: null, origin: 'trusted-local', origin_host: MACHINE_ID,
2199
+ payload: prompt, delivery_result: `blocked:${reason}`
2200
+ });
2201
+ failed.push({ id, code: 'PEER_INJECT_BLOCKED', error: 'Peer-lane fan-out blocked' });
2202
+ }
2203
+ console.warn(`[PEER-GUARD] blocked peer-lane ${source} from ${from || '(none)'} → ${targetIds.length} target(s) (${reason})`);
2204
+ return respondWithError(res, 403, 'PEER_INJECT_BLOCKED',
2205
+ 'Peer-lane fan-out blocked: broadcast/multicast is operator-only. Use bin/ask.sh for peer→peer.',
2206
+ { reason, sanctioned_channel: 'bin/ask.sh', results: { successful: [], failed } });
2207
+ }
2208
+
1994
2209
  app.post('/api/sessions/multicast/inject', async (req, res) => {
1995
- const { session_ids, prompt } = req.body;
2210
+ const { session_ids, prompt, from } = req.body;
1996
2211
  if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
1997
2212
  if (!Array.isArray(session_ids)) return res.status(400).json({ error: 'session_ids must be an array' });
1998
2213
 
2214
+ // #45 — operator-only fan-out gate (peer lane blocked outright, before any delivery).
2215
+ const verdict = isPeerLaneFanout(from, prompt);
2216
+ if (verdict.lane === 'peer') {
2217
+ return rejectPeerLaneFanout(res, { from, reason: verdict.reason, targetIds: session_ids, source: 'multicast', verifiedSenderSid: verifiedSenderFromReq(req), prompt });
2218
+ }
2219
+ // #45 — defense-in-depth blast-radius cap (operator lane too).
2220
+ if (session_ids.length > FANOUT_MAX_TARGETS) {
2221
+ return respondWithError(res, 429, 'FANOUT_TARGET_CAP',
2222
+ `multicast target count ${session_ids.length} exceeds cap ${FANOUT_MAX_TARGETS}`,
2223
+ { cap: FANOUT_MAX_TARGETS, requested: session_ids.length });
2224
+ }
2225
+
1999
2226
  const results = { successful: [], failed: [] };
2227
+ // #43 — one inject_id for the whole fan-out; one audit line per target (group by inject_id).
2228
+ const inject_id = crypto.randomUUID();
2229
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2000
2230
 
2001
2231
  for (const id of session_ids) {
2002
2232
  const session = sessions[id];
2003
2233
  if (session) {
2004
2234
  try {
2005
2235
  const delivery = await deliverInjectionToSession(id, session, prompt, {
2006
- source: 'multicast'
2236
+ source: 'multicast',
2237
+ from: from || 'inject',
2238
+ verifiedSenderSid // #47 P4 — label the provenance banner with the verified sender
2007
2239
  });
2008
2240
  if (!delivery.success) {
2009
2241
  results.failed.push({ id, code: delivery.code, error: delivery.error });
2242
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, `failed:${delivery.code || 'DELIVERY_FAILED'}`);
2010
2243
  continue;
2011
2244
  }
2012
2245
 
2013
2246
  results.successful.push({ id, strategy: delivery.strategy });
2247
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'success');
2014
2248
 
2015
2249
  // Broadcast injection to bus
2016
2250
  broadcastBusEvent({
@@ -2022,35 +2256,70 @@ app.post('/api/sessions/multicast/inject', async (req, res) => {
2022
2256
  });
2023
2257
  } catch (err) {
2024
2258
  results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
2259
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'failed:DELIVERY_FAILED');
2025
2260
  }
2026
2261
  } else {
2027
2262
  results.failed.push({ id, code: 'SESSION_NOT_FOUND', error: 'Session not found' });
2263
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'failed:SESSION_NOT_FOUND');
2028
2264
  }
2029
2265
  }
2030
2266
 
2031
2267
  res.json({ success: true, results });
2032
2268
  });
2033
2269
 
2270
+ // #43 — shared per-target audit helper for the fan-out handlers (multicast/broadcast). One
2271
+ // JSONL line per target so blast-radius is queryable per session; all share `inject_id`.
2272
+ function auditMulticastTarget(inject_id, kind, from, verifiedSenderSid, id, prompt, delivery_result) {
2273
+ auditAppend({
2274
+ ts: new Date().toISOString(), inject_id, kind, source: kind,
2275
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2276
+ to: id, to_alias: null, origin: 'trusted-local', origin_host: MACHINE_ID,
2277
+ payload: prompt, delivery_result
2278
+ });
2279
+ }
2280
+
2034
2281
  app.post('/api/sessions/broadcast/inject', async (req, res) => {
2035
- const { prompt } = req.body;
2282
+ const { prompt, from } = req.body;
2036
2283
  if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
2037
2284
 
2285
+ // #45 — operator-only fan-out gate (peer lane blocked outright, before any delivery).
2286
+ // Broadcast is all-or-nothing on the lane: a peer-lane sender reaches ZERO sessions.
2287
+ const targetIds = Object.keys(sessions);
2288
+ const verdict = isPeerLaneFanout(from, prompt);
2289
+ if (verdict.lane === 'peer') {
2290
+ return rejectPeerLaneFanout(res, { from, reason: verdict.reason, targetIds, source: 'broadcast', verifiedSenderSid: verifiedSenderFromReq(req), prompt });
2291
+ }
2292
+ // #45 — defense-in-depth blast-radius cap (operator lane too).
2293
+ if (targetIds.length > FANOUT_MAX_TARGETS) {
2294
+ return respondWithError(res, 429, 'FANOUT_TARGET_CAP',
2295
+ `broadcast target count ${targetIds.length} exceeds cap ${FANOUT_MAX_TARGETS}`,
2296
+ { cap: FANOUT_MAX_TARGETS, requested: targetIds.length });
2297
+ }
2298
+
2038
2299
  const results = { successful: [], failed: [] };
2300
+ // #43 — one inject_id for the whole broadcast; one audit line per target.
2301
+ const inject_id = crypto.randomUUID();
2302
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2039
2303
 
2040
- for (const id of Object.keys(sessions)) {
2304
+ for (const id of targetIds) {
2041
2305
  const session = sessions[id];
2042
2306
  try {
2043
2307
  const delivery = await deliverInjectionToSession(id, session, prompt, {
2044
- source: 'broadcast'
2308
+ source: 'broadcast',
2309
+ from: from || 'inject',
2310
+ verifiedSenderSid // #47 P4 — label the provenance banner with the verified sender
2045
2311
  });
2046
2312
  if (!delivery.success) {
2047
2313
  results.failed.push({ id, code: delivery.code, error: delivery.error });
2314
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, `failed:${delivery.code || 'DELIVERY_FAILED'}`);
2048
2315
  continue;
2049
2316
  }
2050
2317
 
2051
2318
  results.successful.push({ id, strategy: delivery.strategy });
2319
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, 'success');
2052
2320
  } catch (err) {
2053
2321
  results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
2322
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, 'failed:DELIVERY_FAILED');
2054
2323
  }
2055
2324
  }
2056
2325
 
@@ -2391,12 +2660,13 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2391
2660
  console.log(`[SUBMIT] gate timeout ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
2392
2661
  }
2393
2662
 
2394
- // Step 2: dispatch Enter via existing kitty cmux → PTY chain.
2663
+ // Step 2: dispatch Enter via the PTY/context path, render-gated (#568).
2395
2664
  if (injectedBody) {
2396
2665
  markPendingReportSubmitStarted(id, injectedBody);
2397
2666
  }
2667
+ const settleEnabled = req.body?.input_settle_gate !== false;
2668
+ let strategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2398
2669
  let submittedAtMs = Date.now();
2399
- let strategy = terminalLevelSubmit(id, session);
2400
2670
  let attempts = strategy ? 1 : 0;
2401
2671
  if (!strategy) {
2402
2672
  if (injectedBody) {
@@ -2422,8 +2692,8 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2422
2692
  confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
2423
2693
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
2424
2694
  await new Promise(resolve => setTimeout(resolve, retryDelayMs));
2695
+ const retryStrategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2425
2696
  submittedAtMs = Date.now();
2426
- const retryStrategy = terminalLevelSubmit(id, session);
2427
2697
  if (!retryStrategy) break;
2428
2698
  strategy = retryStrategy;
2429
2699
  attempts++;
@@ -2522,6 +2792,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2522
2792
  // Routing metadata stays in session/bus state, not in the visible prompt text.
2523
2793
  const finalPrompt = prompt;
2524
2794
  const inject_id = crypto.randomUUID();
2795
+ // #43 P2 — daemon-verified sender identity (from the presented token, never body.from).
2796
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2525
2797
 
2526
2798
  // #533 Phase 2 — peer-lane inject guardrail (in-band hard block, before delivery).
2527
2799
  // Out-of-policy peer→peer injects (no sanctioned ask-request/ask-reply envelope)
@@ -2540,6 +2812,16 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2540
2812
  }
2541
2813
  });
2542
2814
  console.warn(`[PEER-GUARD] blocked peer inject ${from} → ${id} (${peerVerdict.reason})`);
2815
+ // #47 P5 — a blocked bypass attempt is auditable too, not just successful deliveries (spec
2816
+ // §5/§9). One shared-schema line with delivery_result:"blocked:<reason>" — the #45 gate logic
2817
+ // itself is unchanged; this only records the attempt.
2818
+ auditAppend({
2819
+ ts: new Date().toISOString(), inject_id, kind: 'inject', source: 'inject',
2820
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2821
+ to: id, to_alias: requestedId !== resolvedId ? requestedId : null,
2822
+ origin: 'trusted-local', origin_host: MACHINE_ID, ref_path: req.body.ref_path || null,
2823
+ payload: finalPrompt, delivery_result: `blocked:${peerVerdict.reason}`
2824
+ });
2543
2825
  return respondWithError(res, 403, 'PEER_INJECT_BLOCKED',
2544
2826
  'Peer-lane inject blocked: not a sanctioned ask-request/ask-reply envelope. Use bin/ask.sh.',
2545
2827
  { reason: peerVerdict.reason, sanctioned_channel: 'bin/ask.sh' });
@@ -2552,7 +2834,9 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2552
2834
  const delivery = await deliverInjectionToSession(id, session, finalPrompt, {
2553
2835
  noEnter: !!no_enter,
2554
2836
  source: 'inject',
2555
- from: from || 'inject'
2837
+ from: from || 'inject',
2838
+ // #47 P4 — the daemon-verified sender (never body.from) labels the provenance banner.
2839
+ verifiedSenderSid
2556
2840
  });
2557
2841
  if (!delivery.success) {
2558
2842
  emitInjectFailureEvent(id, delivery.code, delivery.error, {
@@ -2560,6 +2844,13 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2560
2844
  from: from || null,
2561
2845
  reply_to: reply_to || null
2562
2846
  }, session);
2847
+ auditAppend({
2848
+ ts: new Date().toISOString(), inject_id, kind: 'inject', source: 'inject',
2849
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2850
+ to: id, to_alias: requestedId !== resolvedId ? requestedId : null,
2851
+ origin: 'trusted-local', origin_host: MACHINE_ID, ref_path: req.body.ref_path || null,
2852
+ payload: finalPrompt, delivery_result: `failed:${delivery.code || 'DELIVERY_FAILED'}`
2853
+ });
2563
2854
  return respondWithError(res, delivery.httpStatus || 500, delivery.code || 'DELIVERY_FAILED', delivery.error);
2564
2855
  }
2565
2856
 
@@ -2570,6 +2861,14 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2570
2861
  console.log(`[INJECT] Wrote to session ${id} (inject_id: ${inject_id})`);
2571
2862
 
2572
2863
  const injectTimestamp = new Date().toISOString();
2864
+ // #43 P1/P2 — one audit line per delivery (claimed + daemon-verified sender, hash-only).
2865
+ auditAppend({
2866
+ ts: injectTimestamp, inject_id, kind: 'inject', source: 'inject',
2867
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2868
+ to: id, to_alias: requestedId !== resolvedId ? requestedId : null,
2869
+ origin: 'trusted-local', origin_host: MACHINE_ID, ref_path: req.body.ref_path || null,
2870
+ payload: finalPrompt, delivery_result: 'success'
2871
+ });
2573
2872
  broadcastSessionEvent('inject_written', id, session, {
2574
2873
  timestamp: injectTimestamp,
2575
2874
  extra: {
@@ -2577,6 +2876,10 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2577
2876
  target_agent: id,
2578
2877
  content: prompt,
2579
2878
  from: from || null,
2879
+ // #43 — live bus event enriched with daemon-verified provenance (spec §7).
2880
+ verified_sender_sid: verifiedSenderSid,
2881
+ spoof_suspected: !!(from && verifiedSenderSid && from !== verifiedSenderSid),
2882
+ origin: 'trusted-local',
2580
2883
  reply_to: reply_to || null,
2581
2884
  thread_id: thread_id || null,
2582
2885
  reply_expected: !!reply_expected
@@ -3244,17 +3547,173 @@ app.patch('/api/threads/:id', (req, res) => {
3244
3547
  res.json({ success: true, thread_id: thread.id, status: thread.status });
3245
3548
  });
3246
3549
 
3550
+ // === #42 broker MVP (W3/T5) — broker wiring helpers (spec §2F, §4.3, §5) =========
3551
+ // Additive + default-OFF. Factored as DI-seamed helpers (deps override factories /
3552
+ // env / readFile) so the wiring is unit-testable without booting the daemon or
3553
+ // touching the network. The broker-server / broker-client factories are REUSED
3554
+ // verbatim — no reimplementation here.
3555
+
3556
+ // Resolve broker config from the environment (all knobs default-OFF when absent).
3557
+ function brokerEnv(env = process.env) {
3558
+ const home = os.homedir();
3559
+ return {
3560
+ mode: env.TELEPTY_BROKER_MODE === '1' || env.TELEPTY_BROKER_MODE === 'true',
3561
+ jwtSecret: env.TELEPTY_JWT_SECRET || null,
3562
+ enrollSecret: env.TELEPTY_ENROLL_SECRET || null,
3563
+ tlsCert: env.TELEPTY_TLS_CERT || null,
3564
+ tlsKey: env.TELEPTY_TLS_KEY || null,
3565
+ aclPath: env.TELEPTY_BROKER_ACL || path.join(home, '.telepty', 'broker-acl.json'),
3566
+ revokedPath: env.TELEPTY_BROKER_REVOKED || path.join(home, '.telepty', 'broker-revoked.json'),
3567
+ configPath: env.TELEPTY_BROKER_CONFIG || path.join(home, '.telepty', 'broker.json'),
3568
+ maxNodes: Number(env.TELEPTY_ENROLL_MAX_NODES) || 256,
3569
+ url: env.TELEPTY_BROKER_URL || null,
3570
+ jwt: env.TELEPTY_BROKER_JWT || null,
3571
+ node: env.TELEPTY_BROKER_NODE || null,
3572
+ pin: env.TELEPTY_BROKER_PIN || null,
3573
+ };
3574
+ }
3575
+
3576
+ function readJsonFileSafe(filePath, readFile = fs.readFileSync) {
3577
+ try {
3578
+ return JSON.parse(readFile(filePath, 'utf8'));
3579
+ } catch {
3580
+ return null; // missing/invalid file ⇒ default (caller decides)
3581
+ }
3582
+ }
3583
+
3584
+ // Broker host: mount createBrokerServer at /broker/* (spec §2F-i). Loads the ACL +
3585
+ // revocation tables from disk (the broker-server is pure — the daemon owns the file
3586
+ // I/O and the TLS listener, per the module contract). Fail-fast loud if a required
3587
+ // broker env is missing (§5). Returns the broker instance (handler/close).
3588
+ function mountBrokerMode(app, deps = {}) {
3589
+ const env = deps.env || brokerEnv();
3590
+ const createServer = deps.createBrokerServer
3591
+ || require('./src/transport/broker-server').createBrokerServer;
3592
+ const readFile = deps.readFile || fs.readFileSync;
3593
+ const bus = deps.broadcastBusEvent || broadcastBusEvent;
3594
+ const requireTls = deps.requireTls !== undefined ? deps.requireTls : true;
3595
+
3596
+ const missing = [];
3597
+ if (!env.jwtSecret) missing.push('TELEPTY_JWT_SECRET');
3598
+ if (!env.enrollSecret) missing.push('TELEPTY_ENROLL_SECRET');
3599
+ if (!env.tlsCert) missing.push('TELEPTY_TLS_CERT');
3600
+ if (!env.tlsKey) missing.push('TELEPTY_TLS_KEY');
3601
+ if (missing.length) {
3602
+ throw new Error(`[BROKER] broker mode requires env: ${missing.join(', ')}`);
3603
+ }
3604
+
3605
+ const aclTable = readJsonFileSafe(env.aclPath, readFile) || {};
3606
+ const revokedRaw = readJsonFileSafe(env.revokedPath, readFile);
3607
+ const revokedNodes = new Set(
3608
+ Array.isArray(revokedRaw) ? revokedRaw : (revokedRaw && revokedRaw.revoked) || []
3609
+ );
3610
+
3611
+ const broker = createServer({
3612
+ jwtSecret: env.jwtSecret,
3613
+ enrollSecret: env.enrollSecret,
3614
+ aclTable,
3615
+ revokedNodes,
3616
+ maxNodes: env.maxNodes,
3617
+ requireTls,
3618
+ broadcastBusEvent: bus,
3619
+ // #47 P5 — funnel cross-machine deliveries through the SAME inject audit writer as local
3620
+ // ones (one schema, one file, three producers: local deliver, #45 block, broker). The
3621
+ // broker owns no fs (pure); the daemon owns the writer.
3622
+ onInjectAudit: deps.auditAppend || auditAppend,
3623
+ });
3624
+
3625
+ // Mount the raw handler at /broker/* (full path preserved so the broker router
3626
+ // matches, spec §3.0). Placed before express.json/auth by the call site above.
3627
+ app.use((req, res, next) => {
3628
+ if ((req.url || '').split('?')[0].startsWith('/broker/')) return broker.handler(req, res);
3629
+ return next();
3630
+ });
3631
+ return broker;
3632
+ }
3633
+
3634
+ // Node side: resolve broker connection config. Env (TELEPTY_BROKER_URL +
3635
+ // TELEPTY_BROKER_JWT) wins; else ~/.telepty/broker.json; else null (default-OFF).
3636
+ function loadNodeBrokerConfig(deps = {}) {
3637
+ const env = deps.env || brokerEnv();
3638
+ const readFile = deps.readFile || fs.readFileSync;
3639
+ if (env.url && env.jwt) {
3640
+ return { url: env.url, jwt: env.jwt, node: env.node || MACHINE_ID, pin: env.pin || null, accept_from: null };
3641
+ }
3642
+ const cfg = readJsonFileSafe(env.configPath, readFile);
3643
+ if (cfg && cfg.url && cfg.jwt) {
3644
+ return {
3645
+ url: cfg.url,
3646
+ jwt: cfg.jwt,
3647
+ node: cfg.node || MACHINE_ID,
3648
+ pin: cfg.pin || null,
3649
+ accept_from: cfg.accept_from === undefined ? null : cfg.accept_from,
3650
+ };
3651
+ }
3652
+ return null;
3653
+ }
3654
+
3655
+ // Node side: start the broker-client when broker config is present (spec §2F-ii).
3656
+ // No config ⇒ returns null and starts nothing (§5 default-OFF, zero new behavior).
3657
+ // CREDENTIAL BOUNDARY (§4.3): delivery is in-process via deliverInjectionToSession —
3658
+ // the local daemon token (EXPECTED_TOKEN) is NEVER passed to the client / on the wire.
3659
+ function startNodeBrokerClient(deps = {}) {
3660
+ const cfg = deps.config || loadNodeBrokerConfig(deps);
3661
+ if (!cfg) return null;
3662
+ const createClient = deps.createBrokerClient
3663
+ || require('./src/transport/broker-client').createBrokerClient;
3664
+ const deliver = deps.deliver || deliverInjectionToSession;
3665
+ const sessionMap = deps.sessions || sessions;
3666
+
3667
+ const client = createClient({
3668
+ url: cfg.url,
3669
+ node: cfg.node || MACHINE_ID,
3670
+ nodeJwt: cfg.jwt,
3671
+ pin: cfg.pin || null,
3672
+ acceptFrom: cfg.accept_from,
3673
+ deliver, // in-process delivery — §4.3 (no daemon token on the wire)
3674
+ getSession: (id) => sessionMap[id] || null,
3675
+ getSessions: () => Object.keys(sessionMap).map((id) => ({ id, peerName: MACHINE_ID, host: MACHINE_ID })),
3676
+ });
3677
+
3678
+ if (deps.autostart !== false && typeof client.start === 'function') {
3679
+ Promise.resolve(client.start()).catch((err) => {
3680
+ console.error(`[BROKER] node-mode client start failed: ${err && err.message ? err.message : err}`);
3681
+ });
3682
+ }
3683
+ return client;
3684
+ }
3685
+
3247
3686
  // Bind the port when launched as the daemon. A test can `require('./daemon.js')` to reach the
3248
3687
  // exported decision functions WITHOUT starting the daemon — it just must not set the env below.
3249
3688
  // The production CLI reaches daemon.js via require() (cli.js `cmd==='daemon'`), so require.main is
3250
3689
  // cli.js, never this module — hence the explicit AIGENTRY_TELEPTY_DAEMON_MAIN signal. Guarding on
3251
3690
  // require.main ALONE (0.5.0 regression) meant app.listen never ran in production → daemon exited 0.
3252
3691
  let server;
3692
+ let nodeBrokerClient = null;
3253
3693
  if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1') {
3254
- server = app.listen(PORT, HOST, () => {
3255
- console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
3256
- runStartupBootstrapRestore();
3257
- });
3694
+ if (brokerServer) {
3695
+ // Broker mode (§4.4): TLS mandatory serve the express app (with /broker/*
3696
+ // mounted) over HTTPS using the configured self-signed cert/key.
3697
+ const https = require('https');
3698
+ const benv = brokerEnv();
3699
+ const tlsOptions = { cert: fs.readFileSync(benv.tlsCert), key: fs.readFileSync(benv.tlsKey) };
3700
+ server = https.createServer(tlsOptions, app).listen(PORT, HOST, () => {
3701
+ const address = server.address();
3702
+ boundPort = (address && address.port) || Number(PORT);
3703
+ console.log(`🔐 aigentry-telepty broker listening on https://${HOST}:${boundPort} (/broker/*)`);
3704
+ runStartupBootstrapRestore();
3705
+ });
3706
+ } else {
3707
+ server = app.listen(PORT, HOST, () => {
3708
+ const address = server.address();
3709
+ boundPort = (address && address.port) || Number(PORT);
3710
+ console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${boundPort}`);
3711
+ runStartupBootstrapRestore();
3712
+ // #42 node-mode (§2F-ii): start the broker-client if broker config is present.
3713
+ // Absent ⇒ no-op (default-OFF). Started after listen so sessions/delivery are live.
3714
+ nodeBrokerClient = startNodeBrokerClient();
3715
+ });
3716
+ }
3258
3717
  }
3259
3718
 
3260
3719
  // #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
@@ -3589,6 +4048,13 @@ installWebSocketTransport({
3589
4048
  function shutdown(code) {
3590
4049
  mailboxDelivery.stop();
3591
4050
  mailboxNotifier.cancelAll();
4051
+ // #42: stop the node-mode broker-client (closes the held SSE) + the broker-server.
4052
+ if (nodeBrokerClient && typeof nodeBrokerClient.stop === 'function') {
4053
+ try { nodeBrokerClient.stop(); } catch { /* best-effort */ }
4054
+ }
4055
+ if (brokerServer && typeof brokerServer.close === 'function') {
4056
+ try { brokerServer.close(); } catch { /* best-effort */ }
4057
+ }
3592
4058
  clearDaemonState(process.pid);
3593
4059
  process.exit(code);
3594
4060
  }
@@ -3618,4 +4084,10 @@ module.exports = {
3618
4084
  decideSurfaceGc, // #17: surface-liveness verdict→action (incl. INV-17 unknown→skip)
3619
4085
  applySurfaceMismatchProbe, // surface_mismatched debounce + payload helper (deps DI: emit/clock)
3620
4086
  classifyPeerLaneInject, // #533 Phase 2: pure peer-lane inject policy verdict
4087
+ // #42 broker MVP (W3/T5): DI-seamed broker wiring (deps override factories/env/readFile).
4088
+ brokerEnv, // env→broker config resolver (default-OFF when absent)
4089
+ mountBrokerMode, // broker-mode: mount createBrokerServer at /broker/* + fail-fast
4090
+ loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
4091
+ startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
4092
+ deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
3621
4093
  };