@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/CHANGELOG.md +86 -0
- package/cli.js +404 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +495 -23
- package/install.js +156 -26
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/audit/provenance.js +86 -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 +531 -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,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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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
|
};
|