@dmsdc-ai/aigentry-telepty 0.5.8 → 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/CHANGELOG.md +82 -0
- package/cli.js +392 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +415 -17
- package/install.js +367 -62
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/protocol/http-auth.js +36 -1
- package/src/submit-gate.js +130 -5
- package/src/transport/broker-client.js +498 -0
- package/src/transport/broker-protocol.js +155 -0
- package/src/transport/broker-server.js +505 -0
- package/src/win-resolve-executable.js +6 -1
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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
|
};
|