@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.0

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,7 @@ 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');
25
27
 
26
28
  const config = getConfig();
27
29
  const EXPECTED_TOKEN = config.authToken;
@@ -134,6 +136,18 @@ function loadPersistedSessions() {
134
136
 
135
137
  const app = express();
136
138
  app.use(cors());
139
+
140
+ // #42 broker MVP (W3/T5) — broker-mode HTTP surface (spec §2F, §5). DEFAULT-OFF:
141
+ // only when TELEPTY_BROKER_MODE is set does the daemon mount the broker-server at
142
+ // /broker/*. Mounted BEFORE express.json so the broker reads the raw request stream
143
+ // itself (it has its own per-node JWT gate); the existing /api/* auth path is untouched.
144
+ // Fail-fast (loud throw) if a required broker env is missing. The HTTPS listener that
145
+ // serves this handler is created in the boot block below (TLS mandatory, §4.4).
146
+ let brokerServer = null;
147
+ if (brokerEnv().mode) {
148
+ brokerServer = mountBrokerMode(app);
149
+ }
150
+
137
151
  app.use(express.json());
138
152
 
139
153
  // Peer allowlist: comma-separated IPs/CIDRs in TELEPTY_PEER_ALLOWLIST env
@@ -146,6 +160,67 @@ const PEER_ALLOWLIST = (process.env.TELEPTY_PEER_ALLOWLIST || '').split(',').map
146
160
  const ORCHESTRATOR_SIDS = (process.env.AIGENTRY_ORCHESTRATOR_SIDS || 'orchestrator aigentry-orchestrator-claude')
147
161
  .split(/\s+/).map(s => s.trim()).filter(Boolean);
148
162
 
163
+ // #45 — defense-in-depth blast-radius cap for operator-lane fan-out (broadcast/
164
+ // multicast). Even legitimate operator fan-out is bounded so a single compromised
165
+ // fan-out call cannot hit an unbounded number of sessions in one hop. Generous
166
+ // default (operator broadcasts hit only the live mesh); tune via env.
167
+ const FANOUT_MAX_TARGETS = Math.max(1, Number(process.env.TELEPTY_FANOUT_MAX_TARGETS || 100));
168
+
169
+ // #43 — inject audit spine (spec §5/§8). One JSONL line per delivery into
170
+ // ~/.telepty/logs/injects.jsonl (0600). Defaults locked (hash-only preview, 30d / 50MB × 5);
171
+ // env-overridable. The writer is off the delivery hot path: auditAppend() never blocks or
172
+ // throws into a handler. P1 records claimed_from only (verified_sender_sid wired in P2).
173
+ const AUDIT_LOG_PATH = path.join(os.homedir(), '.telepty', 'logs', 'injects.jsonl');
174
+ const auditWriter = createAuditWriter({
175
+ path: AUDIT_LOG_PATH,
176
+ preview: process.env.TELEPTY_AUDIT_PREVIEW === '1',
177
+ previewBytes: Number(process.env.TELEPTY_AUDIT_PREVIEW_BYTES) || 200,
178
+ flushMs: Number(process.env.TELEPTY_AUDIT_FLUSH_MS) || 250,
179
+ queueMax: Number(process.env.TELEPTY_AUDIT_QUEUE_MAX) || 10000,
180
+ maxBytes: Number(process.env.TELEPTY_AUDIT_MAX_BYTES) || 50 * 1024 * 1024,
181
+ maxFiles: Number(process.env.TELEPTY_AUDIT_MAX_FILES) || 5,
182
+ maxAgeDays: Number(process.env.TELEPTY_AUDIT_MAX_AGE_DAYS) || 30
183
+ });
184
+ // Overflow is visible, never silent: surface a single bus event per drop (spec §8 T4).
185
+ auditWriter.on('audit_overflow', (info) => {
186
+ try {
187
+ broadcastBusEvent({ type: 'audit_overflow', sender: 'daemon', dropped: info.dropped, queue_max: info.queueMax, timestamp: new Date().toISOString() });
188
+ } catch { /* bus best-effort */ }
189
+ });
190
+ auditWriter.on('audit_error', (err) => {
191
+ console.warn(`[AUDIT] write error: ${err && err.message ? err.message : err}`);
192
+ });
193
+ // Fire-and-forget append — swallow any sync error so the audit log can never break delivery.
194
+ function auditAppend(record) {
195
+ try { auditWriter.append(record); } catch { /* audit must never throw into a handler */ }
196
+ }
197
+
198
+ // #43 P2 — per-session verified-sender tokens (spec §4, ADR D1). The daemon mints a random
199
+ // token at /api/sessions/register and maps token→sid; the `allow` wrapper carries it in the
200
+ // parent-hijack-protected env beside TELEPTY_SESSION_ID; `inject` presents x-telepty-session-token.
201
+ // `verified_sender_sid` = the mapped sid (the daemon's own truth) or null when unverifiable.
202
+ // Issuance is idempotent per sid so the periodic metadata re-register (cli.js
203
+ // updateDaemonProcessMetadata) does NOT rotate the token out from under the carried env.
204
+ const sessionTokens = new Map(); // token → sid
205
+ const sidTokens = new Map(); // sid → token
206
+ function mintSessionToken(sid) {
207
+ const existing = sidTokens.get(sid);
208
+ if (existing) return existing;
209
+ const token = crypto.randomBytes(32).toString('base64url');
210
+ sessionTokens.set(token, sid);
211
+ sidTokens.set(sid, token);
212
+ return token;
213
+ }
214
+ function resolveVerifiedSender(token) {
215
+ if (!token) return null;
216
+ return sessionTokens.get(token) || null;
217
+ }
218
+ // Extract the presented session token from an inject request (header only — never the body,
219
+ // which is attacker-controlled). Returns the daemon-verified sid or null.
220
+ function verifiedSenderFromReq(req) {
221
+ return resolveVerifiedSender(req.headers && req.headers['x-telepty-session-token']);
222
+ }
223
+
149
224
  // Cross-machine bus relay: forward bus events to peer daemons
150
225
  const relayToPeers = createPeerRelay({
151
226
  relayPeers: relayPeersFromEnv(process.env),
@@ -681,9 +756,15 @@ async function executeBootstrapInject(sessionId, session, op) {
681
756
 
682
757
  function parseSubmitRetryOptions(body = {}, injectedBody = null) {
683
758
  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);
759
+ // #568: raise the retry-budget ceiling so a momentarily-busy CLI still gets a
760
+ // render-gated CR before we return 504 (each retry re-gates on input-ready, so
761
+ // more attempts = more quiet-window chances to land). Ceilings only — the
762
+ // default stays 1 for the injected case (behavior-preserving common path);
763
+ // heavier callers opt into the bigger budget via retries/retry_delay_ms/
764
+ // verify_timeout_ms. Total worst-case budget stays bounded (~10 attempts).
765
+ const retries = Math.min(Math.max(Number(hasExplicitRetries ? body.retries : (injectedBody ? 1 : 0)) || 0, 0), 10);
766
+ const retryDelayMs = Math.min(Math.max(Number(body?.retry_delay_ms) || 500, 100), 3000);
767
+ const verifyTimeoutMs = Math.min(Math.max(Number(body?.verify_timeout_ms) || 1500, 200), 8000);
687
768
  return { retries, retryDelayMs, verifyTimeoutMs };
688
769
  }
689
770
 
@@ -716,6 +797,29 @@ async function confirmSubmitAfterDispatch(id, session, injectedBody, submittedAt
716
797
  return submitGate.confirmSubmitAccepted(session, injectedBody, buildSubmitConfirmOptions(id, session, submittedAtMs, verifyTimeoutMs));
717
798
  }
718
799
 
800
+ // #568 — render-gate the submit CR. Before writing the bare 0x0D, wait (bounded)
801
+ // until the injected body is echoed in the PTY outputRing AND the render has gone
802
+ // quiet, so the CR does not land mid-render and get dropped (FM1; same gate before
803
+ // each retry CR, FM2). Best-effort + bounded: on timeout / no body / no ring we
804
+ // fall through and still write the CR — never worse than the pre-gate behavior.
805
+ // pty_cr stays the ONLY write path (terminalLevelSubmit); this only times WHEN.
806
+ // Per-request opt-out via `input_settle_gate: false` (rollback/parity escape hatch).
807
+ async function gatedTerminalSubmit(id, session, injectedBody, settleEnabled) {
808
+ if (injectedBody && injectedBody.length > 0 && settleEnabled !== false) {
809
+ const settle = await submitGate.awaitInputSettled(session, injectedBody, {
810
+ timeoutMs: 1200,
811
+ quietWindowMs: 100,
812
+ echoGraceMs: 400,
813
+ pollIntervalMs: 30,
814
+ stripAnsi: stripAnsiState,
815
+ });
816
+ if (!settle.ready) {
817
+ console.log(`[SUBMIT] input-settle gate timed out for ${id} (${settle.waited_ms}ms, echoed=${settle.echoed}) — sending CR best-effort`);
818
+ }
819
+ }
820
+ return terminalLevelSubmit(id, session);
821
+ }
822
+
719
823
  async function executeBootstrapSubmit(sessionId, session, op) {
720
824
  const body = op.body || {};
721
825
  const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
@@ -724,8 +828,9 @@ async function executeBootstrapSubmit(sessionId, session, op) {
724
828
  markPendingReportSubmitStarted(sessionId, injectedBody);
725
829
  }
726
830
 
831
+ const settleEnabled = body.input_settle_gate !== false;
832
+ let strategy = await gatedTerminalSubmit(sessionId, session, injectedBody, settleEnabled);
727
833
  const submittedAtMs = Date.now();
728
- let strategy = terminalLevelSubmit(sessionId, session);
729
834
  if (!strategy) {
730
835
  if (injectedBody) {
731
836
  markPendingReportSubmitUnconfirmed(sessionId, { reason: 'strategy_failed', attempts: 0, retryable: false });
@@ -745,8 +850,8 @@ async function executeBootstrapSubmit(sessionId, session, op) {
745
850
  let confirm = await confirmSubmitAfterDispatch(sessionId, session, injectedBody, submittedAtMs, verifyTimeoutMs);
746
851
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
747
852
  await sleep(retryDelayMs);
853
+ const retryStrategy = await gatedTerminalSubmit(sessionId, session, injectedBody, settleEnabled);
748
854
  const retrySubmittedAtMs = Date.now();
749
- const retryStrategy = terminalLevelSubmit(sessionId, session);
750
855
  if (!retryStrategy) break;
751
856
  strategy = retryStrategy;
752
857
  attempts++;
@@ -1776,7 +1881,7 @@ app.post('/api/sessions/register', (req, res) => {
1776
1881
  applyTimestampMetadata(existing, req.body);
1777
1882
  initializeBootstrapState(existing);
1778
1883
  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 });
1884
+ return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true, session_token: mintSessionToken(session_id) });
1780
1885
  }
1781
1886
 
1782
1887
  const { delivery_type, delivery_endpoint, delivery } = req.body;
@@ -1849,7 +1954,7 @@ app.post('/api/sessions/register', (req, res) => {
1849
1954
 
1850
1955
  console.log(`[REGISTER] Registered wrapped session ${session_id}`);
1851
1956
  persistSessions();
1852
- res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd });
1957
+ res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd, session_token: mintSessionToken(session_id) });
1853
1958
  });
1854
1959
 
1855
1960
  app.get('/api/sessions', (req, res) => {
@@ -1862,6 +1967,27 @@ app.get('/api/sessions', (req, res) => {
1862
1967
  res.json(list);
1863
1968
  });
1864
1969
 
1970
+ // #43 P3 — token-gated historical inject audit query (spec §7). Behind the SAME shared auth
1971
+ // middleware as every /api/* route (app.use(createAuthMiddleware) above), so it is 401 for an
1972
+ // unauthorized non-local request and open to localhost/allowlisted peers. Filters: since/until,
1973
+ // to (alias-resolved), from (claimed OR verified), spoof; pagination via limit/cursor (newest
1974
+ // first). Reads the live injects.jsonl (one write path, file-backed) — separate lifecycle from
1975
+ // the ephemeral /api/events live bus, so the two are not conflated.
1976
+ app.get('/api/injects', (req, res) => {
1977
+ const q = req.query || {};
1978
+ const to = q.to ? (resolveSessionAlias(q.to) || q.to) : undefined;
1979
+ const result = readInjectLog(AUDIT_LOG_PATH, {
1980
+ since: q.since,
1981
+ until: q.until,
1982
+ to,
1983
+ from: q.from,
1984
+ spoof: q.spoof === '1' || q.spoof === 'true',
1985
+ limit: q.limit,
1986
+ cursor: q.cursor
1987
+ });
1988
+ res.json(result);
1989
+ });
1990
+
1865
1991
  app.get('/api/sessions/:id', (req, res) => {
1866
1992
  const requestedId = req.params.id;
1867
1993
  const resolvedId = resolveSessionAlias(requestedId);
@@ -1991,12 +2117,66 @@ app.get('/api/peers', (req, res) => {
1991
2117
  }
1992
2118
  });
1993
2119
 
2120
+ // #45 — fan-out (broadcast/multicast) is OPERATOR/ORCHESTRATOR-ONLY. The single-inject
2121
+ // path hard-blocks off-policy peer→peer traffic via classifyPeerLaneInject; fan-out
2122
+ // one→many is strictly more dangerous (a worm/fan-out primitive), so the peer lane is
2123
+ // blocked OUTRIGHT here — even a sanctioned ask-envelope may not fan out. We reuse the
2124
+ // SAME classifier (DRY, no second policy) and gate on the LANE, not the per-target
2125
+ // decision: classify by SENDER (`to: null`) so the verdict is the sender's lane,
2126
+ // independent of any individual target. This is what makes broadcast all-or-nothing on
2127
+ // the lane — a peer cannot earn fan-out rights by listing the orchestrator as one
2128
+ // target. Operator lane (no `from`, or `from` ∈ orchestrator sids) and the fail-open
2129
+ // `disabled` lane proceed to delivery, exactly as single-inject allows them. #533 Phase 2.
2130
+ function isPeerLaneFanout(from, prompt) {
2131
+ return classifyPeerLaneInject({ from, to: null, prompt, orchestratorSids: ORCHESTRATOR_SIDS });
2132
+ }
2133
+
2134
+ // Reject a peer-lane fan-out: emit a per-target peer_inject_blocked bus event for every
2135
+ // intended target (mirrors the single-inject block event for reporting parity) and return
2136
+ // the same 403 PEER_INJECT_BLOCKED shape, reaching ZERO sessions. `targetIds` is the full
2137
+ // intended target set (broadcast = all sessions, multicast = requested session_ids).
2138
+ function rejectPeerLaneFanout(res, { from, reason, targetIds, source }) {
2139
+ const inject_id = crypto.randomUUID();
2140
+ const failed = [];
2141
+ for (const id of targetIds) {
2142
+ broadcastSessionEvent('peer_inject_blocked', id, sessions[id] || null, {
2143
+ extra: {
2144
+ target_agent: id,
2145
+ from: from || null,
2146
+ reason,
2147
+ source,
2148
+ inject_id
2149
+ }
2150
+ });
2151
+ failed.push({ id, code: 'PEER_INJECT_BLOCKED', error: 'Peer-lane fan-out blocked' });
2152
+ }
2153
+ console.warn(`[PEER-GUARD] blocked peer-lane ${source} from ${from || '(none)'} → ${targetIds.length} target(s) (${reason})`);
2154
+ return respondWithError(res, 403, 'PEER_INJECT_BLOCKED',
2155
+ 'Peer-lane fan-out blocked: broadcast/multicast is operator-only. Use bin/ask.sh for peer→peer.',
2156
+ { reason, sanctioned_channel: 'bin/ask.sh', results: { successful: [], failed } });
2157
+ }
2158
+
1994
2159
  app.post('/api/sessions/multicast/inject', async (req, res) => {
1995
- const { session_ids, prompt } = req.body;
2160
+ const { session_ids, prompt, from } = req.body;
1996
2161
  if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
1997
2162
  if (!Array.isArray(session_ids)) return res.status(400).json({ error: 'session_ids must be an array' });
1998
2163
 
2164
+ // #45 — operator-only fan-out gate (peer lane blocked outright, before any delivery).
2165
+ const verdict = isPeerLaneFanout(from, prompt);
2166
+ if (verdict.lane === 'peer') {
2167
+ return rejectPeerLaneFanout(res, { from, reason: verdict.reason, targetIds: session_ids, source: 'multicast' });
2168
+ }
2169
+ // #45 — defense-in-depth blast-radius cap (operator lane too).
2170
+ if (session_ids.length > FANOUT_MAX_TARGETS) {
2171
+ return respondWithError(res, 429, 'FANOUT_TARGET_CAP',
2172
+ `multicast target count ${session_ids.length} exceeds cap ${FANOUT_MAX_TARGETS}`,
2173
+ { cap: FANOUT_MAX_TARGETS, requested: session_ids.length });
2174
+ }
2175
+
1999
2176
  const results = { successful: [], failed: [] };
2177
+ // #43 — one inject_id for the whole fan-out; one audit line per target (group by inject_id).
2178
+ const inject_id = crypto.randomUUID();
2179
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2000
2180
 
2001
2181
  for (const id of session_ids) {
2002
2182
  const session = sessions[id];
@@ -2007,10 +2187,12 @@ app.post('/api/sessions/multicast/inject', async (req, res) => {
2007
2187
  });
2008
2188
  if (!delivery.success) {
2009
2189
  results.failed.push({ id, code: delivery.code, error: delivery.error });
2190
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, `failed:${delivery.code || 'DELIVERY_FAILED'}`);
2010
2191
  continue;
2011
2192
  }
2012
2193
 
2013
2194
  results.successful.push({ id, strategy: delivery.strategy });
2195
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'success');
2014
2196
 
2015
2197
  // Broadcast injection to bus
2016
2198
  broadcastBusEvent({
@@ -2022,22 +2204,52 @@ app.post('/api/sessions/multicast/inject', async (req, res) => {
2022
2204
  });
2023
2205
  } catch (err) {
2024
2206
  results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
2207
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'failed:DELIVERY_FAILED');
2025
2208
  }
2026
2209
  } else {
2027
2210
  results.failed.push({ id, code: 'SESSION_NOT_FOUND', error: 'Session not found' });
2211
+ auditMulticastTarget(inject_id, 'multicast', from, verifiedSenderSid, id, prompt, 'failed:SESSION_NOT_FOUND');
2028
2212
  }
2029
2213
  }
2030
2214
 
2031
2215
  res.json({ success: true, results });
2032
2216
  });
2033
2217
 
2218
+ // #43 — shared per-target audit helper for the fan-out handlers (multicast/broadcast). One
2219
+ // JSONL line per target so blast-radius is queryable per session; all share `inject_id`.
2220
+ function auditMulticastTarget(inject_id, kind, from, verifiedSenderSid, id, prompt, delivery_result) {
2221
+ auditAppend({
2222
+ ts: new Date().toISOString(), inject_id, kind, source: kind,
2223
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2224
+ to: id, to_alias: null, origin: 'trusted-local', origin_host: MACHINE_ID,
2225
+ payload: prompt, delivery_result
2226
+ });
2227
+ }
2228
+
2034
2229
  app.post('/api/sessions/broadcast/inject', async (req, res) => {
2035
- const { prompt } = req.body;
2230
+ const { prompt, from } = req.body;
2036
2231
  if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
2037
2232
 
2233
+ // #45 — operator-only fan-out gate (peer lane blocked outright, before any delivery).
2234
+ // Broadcast is all-or-nothing on the lane: a peer-lane sender reaches ZERO sessions.
2235
+ const targetIds = Object.keys(sessions);
2236
+ const verdict = isPeerLaneFanout(from, prompt);
2237
+ if (verdict.lane === 'peer') {
2238
+ return rejectPeerLaneFanout(res, { from, reason: verdict.reason, targetIds, source: 'broadcast' });
2239
+ }
2240
+ // #45 — defense-in-depth blast-radius cap (operator lane too).
2241
+ if (targetIds.length > FANOUT_MAX_TARGETS) {
2242
+ return respondWithError(res, 429, 'FANOUT_TARGET_CAP',
2243
+ `broadcast target count ${targetIds.length} exceeds cap ${FANOUT_MAX_TARGETS}`,
2244
+ { cap: FANOUT_MAX_TARGETS, requested: targetIds.length });
2245
+ }
2246
+
2038
2247
  const results = { successful: [], failed: [] };
2248
+ // #43 — one inject_id for the whole broadcast; one audit line per target.
2249
+ const inject_id = crypto.randomUUID();
2250
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2039
2251
 
2040
- for (const id of Object.keys(sessions)) {
2252
+ for (const id of targetIds) {
2041
2253
  const session = sessions[id];
2042
2254
  try {
2043
2255
  const delivery = await deliverInjectionToSession(id, session, prompt, {
@@ -2045,12 +2257,15 @@ app.post('/api/sessions/broadcast/inject', async (req, res) => {
2045
2257
  });
2046
2258
  if (!delivery.success) {
2047
2259
  results.failed.push({ id, code: delivery.code, error: delivery.error });
2260
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, `failed:${delivery.code || 'DELIVERY_FAILED'}`);
2048
2261
  continue;
2049
2262
  }
2050
2263
 
2051
2264
  results.successful.push({ id, strategy: delivery.strategy });
2265
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, 'success');
2052
2266
  } catch (err) {
2053
2267
  results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
2268
+ auditMulticastTarget(inject_id, 'broadcast', from, verifiedSenderSid, id, prompt, 'failed:DELIVERY_FAILED');
2054
2269
  }
2055
2270
  }
2056
2271
 
@@ -2391,12 +2606,13 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2391
2606
  console.log(`[SUBMIT] gate timeout ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
2392
2607
  }
2393
2608
 
2394
- // Step 2: dispatch Enter via existing kitty cmux → PTY chain.
2609
+ // Step 2: dispatch Enter via the PTY/context path, render-gated (#568).
2395
2610
  if (injectedBody) {
2396
2611
  markPendingReportSubmitStarted(id, injectedBody);
2397
2612
  }
2613
+ const settleEnabled = req.body?.input_settle_gate !== false;
2614
+ let strategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2398
2615
  let submittedAtMs = Date.now();
2399
- let strategy = terminalLevelSubmit(id, session);
2400
2616
  let attempts = strategy ? 1 : 0;
2401
2617
  if (!strategy) {
2402
2618
  if (injectedBody) {
@@ -2422,8 +2638,8 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2422
2638
  confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
2423
2639
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
2424
2640
  await new Promise(resolve => setTimeout(resolve, retryDelayMs));
2641
+ const retryStrategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2425
2642
  submittedAtMs = Date.now();
2426
- const retryStrategy = terminalLevelSubmit(id, session);
2427
2643
  if (!retryStrategy) break;
2428
2644
  strategy = retryStrategy;
2429
2645
  attempts++;
@@ -2522,6 +2738,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2522
2738
  // Routing metadata stays in session/bus state, not in the visible prompt text.
2523
2739
  const finalPrompt = prompt;
2524
2740
  const inject_id = crypto.randomUUID();
2741
+ // #43 P2 — daemon-verified sender identity (from the presented token, never body.from).
2742
+ const verifiedSenderSid = verifiedSenderFromReq(req);
2525
2743
 
2526
2744
  // #533 Phase 2 — peer-lane inject guardrail (in-band hard block, before delivery).
2527
2745
  // Out-of-policy peer→peer injects (no sanctioned ask-request/ask-reply envelope)
@@ -2560,6 +2778,13 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2560
2778
  from: from || null,
2561
2779
  reply_to: reply_to || null
2562
2780
  }, session);
2781
+ auditAppend({
2782
+ ts: new Date().toISOString(), inject_id, kind: 'inject', source: 'inject',
2783
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2784
+ to: id, to_alias: requestedId !== resolvedId ? requestedId : null,
2785
+ origin: 'trusted-local', origin_host: MACHINE_ID, ref_path: req.body.ref_path || null,
2786
+ payload: finalPrompt, delivery_result: `failed:${delivery.code || 'DELIVERY_FAILED'}`
2787
+ });
2563
2788
  return respondWithError(res, delivery.httpStatus || 500, delivery.code || 'DELIVERY_FAILED', delivery.error);
2564
2789
  }
2565
2790
 
@@ -2570,6 +2795,14 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2570
2795
  console.log(`[INJECT] Wrote to session ${id} (inject_id: ${inject_id})`);
2571
2796
 
2572
2797
  const injectTimestamp = new Date().toISOString();
2798
+ // #43 P1/P2 — one audit line per delivery (claimed + daemon-verified sender, hash-only).
2799
+ auditAppend({
2800
+ ts: injectTimestamp, inject_id, kind: 'inject', source: 'inject',
2801
+ claimed_from: from || null, verified_sender_sid: verifiedSenderSid,
2802
+ to: id, to_alias: requestedId !== resolvedId ? requestedId : null,
2803
+ origin: 'trusted-local', origin_host: MACHINE_ID, ref_path: req.body.ref_path || null,
2804
+ payload: finalPrompt, delivery_result: 'success'
2805
+ });
2573
2806
  broadcastSessionEvent('inject_written', id, session, {
2574
2807
  timestamp: injectTimestamp,
2575
2808
  extra: {
@@ -2577,6 +2810,10 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2577
2810
  target_agent: id,
2578
2811
  content: prompt,
2579
2812
  from: from || null,
2813
+ // #43 — live bus event enriched with daemon-verified provenance (spec §7).
2814
+ verified_sender_sid: verifiedSenderSid,
2815
+ spoof_suspected: !!(from && verifiedSenderSid && from !== verifiedSenderSid),
2816
+ origin: 'trusted-local',
2580
2817
  reply_to: reply_to || null,
2581
2818
  thread_id: thread_id || null,
2582
2819
  reply_expected: !!reply_expected
@@ -3244,17 +3481,165 @@ app.patch('/api/threads/:id', (req, res) => {
3244
3481
  res.json({ success: true, thread_id: thread.id, status: thread.status });
3245
3482
  });
3246
3483
 
3484
+ // === #42 broker MVP (W3/T5) — broker wiring helpers (spec §2F, §4.3, §5) =========
3485
+ // Additive + default-OFF. Factored as DI-seamed helpers (deps override factories /
3486
+ // env / readFile) so the wiring is unit-testable without booting the daemon or
3487
+ // touching the network. The broker-server / broker-client factories are REUSED
3488
+ // verbatim — no reimplementation here.
3489
+
3490
+ // Resolve broker config from the environment (all knobs default-OFF when absent).
3491
+ function brokerEnv(env = process.env) {
3492
+ const home = os.homedir();
3493
+ return {
3494
+ mode: env.TELEPTY_BROKER_MODE === '1' || env.TELEPTY_BROKER_MODE === 'true',
3495
+ jwtSecret: env.TELEPTY_JWT_SECRET || null,
3496
+ enrollSecret: env.TELEPTY_ENROLL_SECRET || null,
3497
+ tlsCert: env.TELEPTY_TLS_CERT || null,
3498
+ tlsKey: env.TELEPTY_TLS_KEY || null,
3499
+ aclPath: env.TELEPTY_BROKER_ACL || path.join(home, '.telepty', 'broker-acl.json'),
3500
+ revokedPath: env.TELEPTY_BROKER_REVOKED || path.join(home, '.telepty', 'broker-revoked.json'),
3501
+ configPath: env.TELEPTY_BROKER_CONFIG || path.join(home, '.telepty', 'broker.json'),
3502
+ maxNodes: Number(env.TELEPTY_ENROLL_MAX_NODES) || 256,
3503
+ url: env.TELEPTY_BROKER_URL || null,
3504
+ jwt: env.TELEPTY_BROKER_JWT || null,
3505
+ node: env.TELEPTY_BROKER_NODE || null,
3506
+ pin: env.TELEPTY_BROKER_PIN || null,
3507
+ };
3508
+ }
3509
+
3510
+ function readJsonFileSafe(filePath, readFile = fs.readFileSync) {
3511
+ try {
3512
+ return JSON.parse(readFile(filePath, 'utf8'));
3513
+ } catch {
3514
+ return null; // missing/invalid file ⇒ default (caller decides)
3515
+ }
3516
+ }
3517
+
3518
+ // Broker host: mount createBrokerServer at /broker/* (spec §2F-i). Loads the ACL +
3519
+ // revocation tables from disk (the broker-server is pure — the daemon owns the file
3520
+ // I/O and the TLS listener, per the module contract). Fail-fast loud if a required
3521
+ // broker env is missing (§5). Returns the broker instance (handler/close).
3522
+ function mountBrokerMode(app, deps = {}) {
3523
+ const env = deps.env || brokerEnv();
3524
+ const createServer = deps.createBrokerServer
3525
+ || require('./src/transport/broker-server').createBrokerServer;
3526
+ const readFile = deps.readFile || fs.readFileSync;
3527
+ const bus = deps.broadcastBusEvent || broadcastBusEvent;
3528
+ const requireTls = deps.requireTls !== undefined ? deps.requireTls : true;
3529
+
3530
+ const missing = [];
3531
+ if (!env.jwtSecret) missing.push('TELEPTY_JWT_SECRET');
3532
+ if (!env.enrollSecret) missing.push('TELEPTY_ENROLL_SECRET');
3533
+ if (!env.tlsCert) missing.push('TELEPTY_TLS_CERT');
3534
+ if (!env.tlsKey) missing.push('TELEPTY_TLS_KEY');
3535
+ if (missing.length) {
3536
+ throw new Error(`[BROKER] broker mode requires env: ${missing.join(', ')}`);
3537
+ }
3538
+
3539
+ const aclTable = readJsonFileSafe(env.aclPath, readFile) || {};
3540
+ const revokedRaw = readJsonFileSafe(env.revokedPath, readFile);
3541
+ const revokedNodes = new Set(
3542
+ Array.isArray(revokedRaw) ? revokedRaw : (revokedRaw && revokedRaw.revoked) || []
3543
+ );
3544
+
3545
+ const broker = createServer({
3546
+ jwtSecret: env.jwtSecret,
3547
+ enrollSecret: env.enrollSecret,
3548
+ aclTable,
3549
+ revokedNodes,
3550
+ maxNodes: env.maxNodes,
3551
+ requireTls,
3552
+ broadcastBusEvent: bus,
3553
+ });
3554
+
3555
+ // Mount the raw handler at /broker/* (full path preserved so the broker router
3556
+ // matches, spec §3.0). Placed before express.json/auth by the call site above.
3557
+ app.use((req, res, next) => {
3558
+ if ((req.url || '').split('?')[0].startsWith('/broker/')) return broker.handler(req, res);
3559
+ return next();
3560
+ });
3561
+ return broker;
3562
+ }
3563
+
3564
+ // Node side: resolve broker connection config. Env (TELEPTY_BROKER_URL +
3565
+ // TELEPTY_BROKER_JWT) wins; else ~/.telepty/broker.json; else null (default-OFF).
3566
+ function loadNodeBrokerConfig(deps = {}) {
3567
+ const env = deps.env || brokerEnv();
3568
+ const readFile = deps.readFile || fs.readFileSync;
3569
+ if (env.url && env.jwt) {
3570
+ return { url: env.url, jwt: env.jwt, node: env.node || MACHINE_ID, pin: env.pin || null, accept_from: null };
3571
+ }
3572
+ const cfg = readJsonFileSafe(env.configPath, readFile);
3573
+ if (cfg && cfg.url && cfg.jwt) {
3574
+ return {
3575
+ url: cfg.url,
3576
+ jwt: cfg.jwt,
3577
+ node: cfg.node || MACHINE_ID,
3578
+ pin: cfg.pin || null,
3579
+ accept_from: cfg.accept_from === undefined ? null : cfg.accept_from,
3580
+ };
3581
+ }
3582
+ return null;
3583
+ }
3584
+
3585
+ // Node side: start the broker-client when broker config is present (spec §2F-ii).
3586
+ // No config ⇒ returns null and starts nothing (§5 default-OFF, zero new behavior).
3587
+ // CREDENTIAL BOUNDARY (§4.3): delivery is in-process via deliverInjectionToSession —
3588
+ // the local daemon token (EXPECTED_TOKEN) is NEVER passed to the client / on the wire.
3589
+ function startNodeBrokerClient(deps = {}) {
3590
+ const cfg = deps.config || loadNodeBrokerConfig(deps);
3591
+ if (!cfg) return null;
3592
+ const createClient = deps.createBrokerClient
3593
+ || require('./src/transport/broker-client').createBrokerClient;
3594
+ const deliver = deps.deliver || deliverInjectionToSession;
3595
+ const sessionMap = deps.sessions || sessions;
3596
+
3597
+ const client = createClient({
3598
+ url: cfg.url,
3599
+ node: cfg.node || MACHINE_ID,
3600
+ nodeJwt: cfg.jwt,
3601
+ pin: cfg.pin || null,
3602
+ acceptFrom: cfg.accept_from,
3603
+ deliver, // in-process delivery — §4.3 (no daemon token on the wire)
3604
+ getSession: (id) => sessionMap[id] || null,
3605
+ getSessions: () => Object.keys(sessionMap).map((id) => ({ id, peerName: MACHINE_ID, host: MACHINE_ID })),
3606
+ });
3607
+
3608
+ if (deps.autostart !== false && typeof client.start === 'function') {
3609
+ Promise.resolve(client.start()).catch((err) => {
3610
+ console.error(`[BROKER] node-mode client start failed: ${err && err.message ? err.message : err}`);
3611
+ });
3612
+ }
3613
+ return client;
3614
+ }
3615
+
3247
3616
  // Bind the port when launched as the daemon. A test can `require('./daemon.js')` to reach the
3248
3617
  // exported decision functions WITHOUT starting the daemon — it just must not set the env below.
3249
3618
  // The production CLI reaches daemon.js via require() (cli.js `cmd==='daemon'`), so require.main is
3250
3619
  // cli.js, never this module — hence the explicit AIGENTRY_TELEPTY_DAEMON_MAIN signal. Guarding on
3251
3620
  // require.main ALONE (0.5.0 regression) meant app.listen never ran in production → daemon exited 0.
3252
3621
  let server;
3622
+ let nodeBrokerClient = null;
3253
3623
  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
- });
3624
+ if (brokerServer) {
3625
+ // Broker mode (§4.4): TLS mandatory serve the express app (with /broker/*
3626
+ // mounted) over HTTPS using the configured self-signed cert/key.
3627
+ const https = require('https');
3628
+ const benv = brokerEnv();
3629
+ const tlsOptions = { cert: fs.readFileSync(benv.tlsCert), key: fs.readFileSync(benv.tlsKey) };
3630
+ server = https.createServer(tlsOptions, app).listen(PORT, HOST, () => {
3631
+ console.log(`🔐 aigentry-telepty broker listening on https://${HOST}:${PORT} (/broker/*)`);
3632
+ runStartupBootstrapRestore();
3633
+ });
3634
+ } else {
3635
+ server = app.listen(PORT, HOST, () => {
3636
+ console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
3637
+ runStartupBootstrapRestore();
3638
+ // #42 node-mode (§2F-ii): start the broker-client if broker config is present.
3639
+ // Absent ⇒ no-op (default-OFF). Started after listen so sessions/delivery are live.
3640
+ nodeBrokerClient = startNodeBrokerClient();
3641
+ });
3642
+ }
3258
3643
  }
3259
3644
 
3260
3645
  // #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
@@ -3589,6 +3974,13 @@ installWebSocketTransport({
3589
3974
  function shutdown(code) {
3590
3975
  mailboxDelivery.stop();
3591
3976
  mailboxNotifier.cancelAll();
3977
+ // #42: stop the node-mode broker-client (closes the held SSE) + the broker-server.
3978
+ if (nodeBrokerClient && typeof nodeBrokerClient.stop === 'function') {
3979
+ try { nodeBrokerClient.stop(); } catch { /* best-effort */ }
3980
+ }
3981
+ if (brokerServer && typeof brokerServer.close === 'function') {
3982
+ try { brokerServer.close(); } catch { /* best-effort */ }
3983
+ }
3592
3984
  clearDaemonState(process.pid);
3593
3985
  process.exit(code);
3594
3986
  }
@@ -3618,4 +4010,10 @@ module.exports = {
3618
4010
  decideSurfaceGc, // #17: surface-liveness verdict→action (incl. INV-17 unknown→skip)
3619
4011
  applySurfaceMismatchProbe, // surface_mismatched debounce + payload helper (deps DI: emit/clock)
3620
4012
  classifyPeerLaneInject, // #533 Phase 2: pure peer-lane inject policy verdict
4013
+ // #42 broker MVP (W3/T5): DI-seamed broker wiring (deps override factories/env/readFile).
4014
+ brokerEnv, // env→broker config resolver (default-OFF when absent)
4015
+ mountBrokerMode, // broker-mode: mount createBrokerServer at /broker/* + fail-fast
4016
+ loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
4017
+ startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
4018
+ deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
3621
4019
  };