@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/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-06-09
8
+
9
+ ### Added — inject audit log + verified sender identity (#43, P1–P3)
10
+
11
+ - **Append-only inject audit log** (`src/audit/inject-log.js`): every delivery is
12
+ recorded to `~/.telepty/logs/injects.jsonl` (schema v1, one line per delivery —
13
+ one line per target for multicast/broadcast), file `0600` / dir `0700`. Default
14
+ is **hash-only** (`payload_sha256` always; no payload content on disk) — opt into
15
+ a truncated preview with `TELEPTY_AUDIT_PREVIEW=1`. Bounded async writer with
16
+ size+age rotation (`TELEPTY_AUDIT_*` env, default 30 days / 50 MB × 5); never
17
+ blocks the delivery hot path.
18
+ - **Verified sender identity**: a per-session token is minted at
19
+ `/api/sessions/register` and carried in the parent-hijack-protected env beside
20
+ `TELEPTY_SESSION_ID`; the daemon maps it to a `verified_sender_sid` and flags
21
+ `spoof_suspected` when the caller-supplied `--from` disagrees with the verified
22
+ identity. Both `claimed_from` and `verified_sender_sid` are logged.
23
+ - **Read API + CLI**: token-gated `GET /api/injects` (filter by `since`/`until`/
24
+ `to`/`from`/`spoof`, with cursor pagination) and a `telepty injects
25
+ [--tail --since --to --from --spoof --json]` subcommand for incident response.
26
+ - The delivery provenance wrapper (P4) and broker/#45 audit seams (P5) are tracked
27
+ separately in #47 (deferred).
28
+
29
+ ### Security — operator-only fan-out (#45)
30
+
31
+ - **`broadcast` / `multicast` now go through the peer-lane guardrail.** Previously
32
+ these handlers called delivery directly, bypassing `classifyPeerLaneInject` — a
33
+ fan-out escape past the peer-inject policy. **Behavior change:** fan-out
34
+ (`broadcast`/`multicast`) is now **operator/orchestrator-only**; a peer-lane
35
+ sender is rejected with `403 PEER_INJECT_BLOCKED` reaching **zero** sessions
36
+ (gate is by lane, not envelope). Per-target `peer_inject_blocked` bus events and
37
+ a `TELEPTY_FANOUT_MAX_TARGETS` (default 100) blast-radius cap were added.
38
+
39
+ ### Fixed
40
+
41
+ - **Daemon restart never stopped the running daemon on macOS/Linux (#44).** The
42
+ daemon's `process.title = 'telepty-daemon'` defeated the `isLikelyTeleptyDaemon`
43
+ command-line heuristic, so the restart path could not find the old daemon to
44
+ stop. Recognize the title token (additive; the `aigentry-telepty` path is
45
+ preserved) and name the surviving state-file PID / port-3848 owner in the restart
46
+ failure message. Windows (`Win32_Process.CommandLine`) is unaffected.
47
+ - **`[Windows] resolveWindowsExecutable` picked the extensionless npm shim (#46).**
48
+ The bare-name `PATH × PATHEXT` walk tried `''` first and matched npm's
49
+ extensionless `/bin/sh` shim before `.CMD`, crashing `CreateProcessW` with
50
+ `ERROR_BAD_EXE_FORMAT (193)`. The bare-name walk now tries real `PATHEXT`
51
+ extensions first (`''` last); the absolute/relative-path case is unchanged.
52
+ - **Spurious daemon restart on a transient health-probe timeout (#567)** — ships
53
+ the previously-unreleased fix (meta-primary decision + bounded retry; restart
54
+ only on a real version/capability mismatch).
55
+ - **`--submit` PTY 0x0D Enter intermittently not consumed (#568)** — ships the
56
+ previously-unreleased fix (render-gate input-ready before each CR +
57
+ state-transition-primary confirm + adaptive retry; telepty-PTY only).
58
+
59
+ ### Experimental (opt-in, default-OFF, not GA)
60
+
61
+ - **Cross-machine relay/broker (hub) mode (#42)** is included in the package but is
62
+ **disabled by default** and opt-in only (enable via `AIGENTRY_BROKER_*` env). It
63
+ is not yet generally available or supported for general use; the code is dormant
64
+ unless explicitly enabled. GA gating remains pending a real-topology validation.
65
+
7
66
  ## [0.5.9] - 2026-06-08
8
67
 
9
68
  ### Fixed — managed service install never started the daemon (#41)
package/cli.js CHANGED
@@ -11,7 +11,7 @@ const prompts = require('prompts');
11
11
  const updateNotifier = require('update-notifier');
12
12
  const pkg = require('./package.json');
13
13
  const { getConfig } = require('./auth');
14
- const { cleanupDaemonProcesses } = require('./daemon-control');
14
+ const { cleanupDaemonProcesses, readDaemonState, findPortOwnerPid } = require('./daemon-control');
15
15
  const { attachInteractiveTerminal, getTerminalSize, restoreTerminalModes } = require('./interactive-terminal');
16
16
  const { getRuntimeInfo } = require('./runtime-info');
17
17
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
@@ -161,9 +161,106 @@ function getAuthToken() {
161
161
 
162
162
  const fetchWithAuth = (url, options = {}) => {
163
163
  const headers = { ...options.headers, 'x-telepty-token': getAuthToken() };
164
+ // #43 P2 — present the per-session verified-sender token (minted at register, carried in the
165
+ // parent-hijack-protected env beside TELEPTY_SESSION_ID) so the daemon can map token→sid and
166
+ // record verified_sender_sid. Header only, never the body. Absent for operator/human shells.
167
+ const sessionToken = process.env.TELEPTY_SESSION_TOKEN;
168
+ if (sessionToken) headers['x-telepty-session-token'] = sessionToken;
164
169
  return fetch(url, { ...options, headers });
165
170
  };
166
171
 
172
+ function normalizeBrokerUrl(url) {
173
+ const value = String(url || '').trim();
174
+ if (!value) return '';
175
+ try {
176
+ return new URL(value).toString().replace(/\/+$/, '');
177
+ } catch {
178
+ return '';
179
+ }
180
+ }
181
+
182
+ function readBrokerEnrollSecret(value) {
183
+ const spec = String(value || '');
184
+ if (!spec) throw new Error('--enroll-secret is required');
185
+ if (spec.startsWith('env:')) {
186
+ const name = spec.slice(4);
187
+ const secret = process.env[name];
188
+ if (!secret) throw new Error(`Environment variable ${name} is empty or unset`);
189
+ return secret;
190
+ }
191
+ if (spec.startsWith('@')) {
192
+ return fs.readFileSync(spec.slice(1), 'utf8').trim();
193
+ }
194
+ return spec;
195
+ }
196
+
197
+ function readJsonFile(filePath, fallback) {
198
+ try {
199
+ if (!fs.existsSync(filePath)) return fallback;
200
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
201
+ } catch {
202
+ return fallback;
203
+ }
204
+ }
205
+
206
+ function writeJsonFile(filePath, value) {
207
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
208
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
209
+ try { fs.chmodSync(filePath, 0o600); } catch {}
210
+ }
211
+
212
+ function brokerAclPath() {
213
+ return process.env.TELEPTY_BROKER_ACL || path.join(os.homedir(), '.telepty', 'broker-acl.json');
214
+ }
215
+
216
+ function brokerRevokedPath() {
217
+ return process.env.TELEPTY_BROKER_REVOKED || path.join(os.homedir(), '.telepty', 'broker-revoked.json');
218
+ }
219
+
220
+ function brokerAllow(fromNode, toNodes) {
221
+ const acl = readJsonFile(brokerAclPath(), {});
222
+ const existing = Array.isArray(acl[fromNode]) ? acl[fromNode] : [];
223
+ acl[fromNode] = Array.from(new Set([...existing, ...toNodes]));
224
+ writeJsonFile(brokerAclPath(), acl);
225
+ return acl[fromNode];
226
+ }
227
+
228
+ function brokerDeny(node) {
229
+ const acl = readJsonFile(brokerAclPath(), {});
230
+ delete acl[node];
231
+ writeJsonFile(brokerAclPath(), acl);
232
+ }
233
+
234
+ function brokerRevoke(node) {
235
+ const revoked = readJsonFile(brokerRevokedPath(), []);
236
+ const list = Array.isArray(revoked) ? revoked : [];
237
+ if (!list.includes(node)) list.push(node);
238
+ writeJsonFile(brokerRevokedPath(), list);
239
+ return list;
240
+ }
241
+
242
+ async function enrollBrokerNode(url, node, enrollSecret, pin) {
243
+ const brokerUrl = normalizeBrokerUrl(url);
244
+ if (!brokerUrl) throw new Error('connect-broker requires a valid broker URL');
245
+ const res = await fetch(`${brokerUrl}/broker/enroll`, {
246
+ method: 'POST',
247
+ signal: AbortSignal.timeout(10000),
248
+ headers: {
249
+ 'Content-Type': 'application/json',
250
+ 'x-telepty-enroll': enrollSecret
251
+ },
252
+ body: JSON.stringify({ node, pin_ack: pin || null })
253
+ });
254
+ const data = await res.json().catch(() => ({}));
255
+ if (!res.ok) {
256
+ throw new Error(data.error || data.code || `Broker enroll failed with HTTP ${res.status}`);
257
+ }
258
+ if (!data || typeof data.jwt !== 'string' || !data.jwt) {
259
+ throw new Error('Broker enroll response did not include a node JWT');
260
+ }
261
+ return { ...data, url: brokerUrl };
262
+ }
263
+
167
264
  function isSubmitForceDefaultEnabled(env = process.env) {
168
265
  const value = (env.TELEPTY_SUBMIT_FORCE_DEFAULT || '').trim().toLowerCase();
169
266
  return value === '1' || value === 'true' || value === 'yes' || value === 'on';
@@ -377,7 +474,18 @@ async function restartDaemonGraceful(options = {}) {
377
474
  }
378
475
  }
379
476
 
380
- console.error(`\x1b[31m❌ Daemon restart failed after ${maxAttempts} attempts. Run "telepty daemon" manually to start.\x1b[0m`);
477
+ // telepty#44: name the surviving daemon (state-file pid and/or current port owner) so
478
+ // manual recovery is obvious — `kill <pid>` then `telepty daemon`. Best-effort only.
479
+ let survivor = '';
480
+ try {
481
+ const statePid = (readDaemonState() || {}).pid;
482
+ const portOwner = findPortOwnerPid(3848);
483
+ const parts = [];
484
+ if (Number.isInteger(statePid) && statePid > 0) parts.push(`state-file pid ${statePid}`);
485
+ if (Number.isInteger(portOwner) && portOwner > 0 && portOwner !== statePid) parts.push(`port 3848 owner pid ${portOwner}`);
486
+ if (parts.length) survivor = ` Old daemon still alive (${parts.join(', ')}) — run "kill ${portOwner || statePid}" then "telepty daemon".`;
487
+ } catch {}
488
+ console.error(`\x1b[31m❌ Daemon restart failed after ${maxAttempts} attempts. Run "telepty daemon" manually to start.${survivor}\x1b[0m`);
381
489
  return { success: false, meta: null, attempt: maxAttempts };
382
490
  }
383
491
 
@@ -517,6 +625,15 @@ async function discoverSessions(options = {}) {
517
625
  // HTTP peer discovery is best-effort.
518
626
  }
519
627
 
628
+ // Remote peer sessions via broker relay. Default-OFF: without a
629
+ // transport='broker' peer in peers.json, this performs no broker call.
630
+ try {
631
+ const brokerSessions = await crossMachine.discoverBrokerRemoteSessions();
632
+ allSessions.push(...brokerSessions);
633
+ } catch {
634
+ // Broker peer discovery is best-effort.
635
+ }
636
+
520
637
  return allSessions;
521
638
  }
522
639
 
@@ -550,41 +667,99 @@ async function resolveSessionTarget(sessionRef, options = {}) {
550
667
  return target;
551
668
  }
552
669
 
670
+ // #567: pure restart-decision policy. Separates a "slow-but-healthy" daemon from a
671
+ // "genuinely-wrong/dead" one so a transient health-probe timeout under concurrent-
672
+ // spawn load never kills a correct daemon. Exposed for unit-testing (no I/O).
673
+ //
674
+ // meta - daemon /api/meta object ({version, capabilities}) or null
675
+ // requiredCapabilities - capabilities this spawn needs
676
+ // cliVersion - local CLI version (pkg.version)
677
+ // sessionsReachable - whether /api/sessions answered; ONLY consulted when meta
678
+ // is null, to tell an older daemon that lacks /api/meta apart
679
+ // from no daemon at all
680
+ function decideDaemonAction({ meta, requiredCapabilities = [], cliVersion, sessionsReachable = false } = {}) {
681
+ if (meta && meta.version) {
682
+ // PRIMARY, definitive signal. A daemon reporting a matching version AND all
683
+ // required capabilities is healthy+correct → never restart, even if a follow-up
684
+ // probe (e.g. /api/sessions) is slow or times out (#567 core fix). Version policy
685
+ // is delegated to decideVersionAction so newer-wins semantics stay unchanged.
686
+ const decision = decideVersionAction({ daemonVersion: meta.version, cliVersion });
687
+ if (decision.action === 'restart') {
688
+ return { action: 'restart', reason: `version-${decision.reason}` };
689
+ }
690
+ const missingCap = requiredCapabilities.find((cap) => !(meta.capabilities || []).includes(cap));
691
+ if (missingCap) {
692
+ return { action: 'restart', reason: `capability-missing:${missingCap}` };
693
+ }
694
+ return { action: 'noop', reason: 'healthy' };
695
+ }
696
+
697
+ // No meta after probing. An older daemon (answers /api/sessions, lacks /api/meta)
698
+ // gets a legit restart; a genuinely absent/unreachable daemon gets auto-started.
699
+ if (sessionsReachable) {
700
+ return { action: 'restart', reason: 'legacy-daemon-no-meta' };
701
+ }
702
+ return { action: 'start', reason: 'daemon-unreachable' };
703
+ }
704
+
553
705
  async function ensureDaemonRunning(options = {}) {
554
706
  if (REMOTE_HOST !== '127.0.0.1') return; // Only auto-start local daemon
555
707
 
556
708
  const requiredCapabilities = options.requiredCapabilities || [];
709
+ // Injectable seams (default to the real implementations) so the restart decision
710
+ // is unit-testable without touching a real daemon or making a real network call (#567).
711
+ const getMeta = options._getDaemonMeta || getDaemonMeta;
712
+ const fetchAuth = options._fetchWithAuth || fetchWithAuth;
713
+ const doRestart = options._restartDaemonGraceful || restartDaemonGraceful;
714
+ const probe = options._probe || {};
715
+ const attempts = probe.attempts || 3;
716
+ const backoffMs = probe.backoffMs == null ? 200 : probe.backoffMs;
717
+
718
+ // (1) PRIMARY signal: the daemon version/capability meta, with bounded retries to
719
+ // ride out a transient timeout under concurrent-spawn load before concluding (#567).
720
+ // getDaemonMeta already swallows timeouts/refusals and returns null, so a null here
721
+ // means "not (yet) confirmed healthy", not "definitely dead".
722
+ let meta = null;
723
+ for (let attempt = 1; attempt <= attempts; attempt++) {
724
+ meta = await getMeta('127.0.0.1');
725
+ if (meta && meta.version) break;
726
+ if (attempt < attempts) {
727
+ await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
728
+ }
729
+ }
730
+
731
+ // (2) Only when meta never came back do we consult /api/sessions — purely to tell an
732
+ // older daemon (answers sessions, lacks /api/meta) apart from no daemon at all. A slow
733
+ // sessions probe on an otherwise-confirmed daemon is irrelevant and never reached here.
734
+ let sessionsReachable = false;
735
+ if (!(meta && meta.version)) {
736
+ try {
737
+ const sessionsRes = await fetchAuth(`${DAEMON_URL}/api/sessions`, {
738
+ signal: AbortSignal.timeout(5000)
739
+ });
740
+ sessionsReachable = !!(sessionsRes && sessionsRes.ok);
741
+ } catch {
742
+ sessionsReachable = false; // timeout/refused while probing the legacy fallback
743
+ }
744
+ }
557
745
 
558
- try {
559
- const meta = await getDaemonMeta('127.0.0.1');
560
- const hasCapabilities = meta && requiredCapabilities.every((item) => meta.capabilities.includes(item));
561
-
562
- const sessionsRes = await fetchWithAuth(`${DAEMON_URL}/api/sessions`, {
563
- signal: AbortSignal.timeout(1500)
564
- });
746
+ const decision = decideDaemonAction({ meta, requiredCapabilities, cliVersion: pkg.version, sessionsReachable });
565
747
 
566
- if (sessionsRes.ok && hasCapabilities) {
567
- // Delegate decision to pure-functional handshake so the policy is unit-testable
568
- // and consistent across CLI invocations.
569
- const decision = decideVersionAction({ daemonVersion: meta && meta.version, cliVersion: pkg.version });
570
- if (decision.action === 'restart') {
571
- // stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
572
- process.stderr.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
573
- await restartDaemonGraceful({ requiredCapabilities });
574
- return;
575
- }
576
- return;
577
- } else if (sessionsRes.ok && !meta) {
578
- process.stderr.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
579
- } else if (sessionsRes.ok && meta) {
580
- process.stderr.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
581
- }
582
- } catch (e) {
583
- // Continue to auto-start below.
748
+ if (decision.action === 'noop') {
749
+ return; // healthy + correct version + all capabilities → leave the daemon alone (#567)
584
750
  }
585
751
 
586
- process.stderr.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
587
- await restartDaemonGraceful({ requiredCapabilities });
752
+ // stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
753
+ if (decision.action === 'restart' && decision.reason.startsWith('version-')) {
754
+ process.stderr.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
755
+ } else if (decision.reason === 'legacy-daemon-no-meta') {
756
+ process.stderr.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
757
+ } else if (decision.action === 'restart') {
758
+ process.stderr.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
759
+ } else {
760
+ process.stderr.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
761
+ }
762
+ await doRestart({ requiredCapabilities });
588
763
  }
589
764
 
590
765
  async function manageInteractiveAttach(sessionId, targetHost) {
@@ -892,6 +1067,57 @@ async function main() {
892
1067
  return;
893
1068
  }
894
1069
 
1070
+ if (cmd === 'broker') {
1071
+ const subcmd = args[1];
1072
+ if (subcmd === 'allow') {
1073
+ const fromNode = args[2];
1074
+ const toFlag = args.indexOf('--to');
1075
+ const toNodes = toFlag !== -1 && args[toFlag + 1]
1076
+ ? args[toFlag + 1].split(',').map((item) => item.trim()).filter(Boolean)
1077
+ : [];
1078
+ if (!fromNode || toNodes.length === 0) {
1079
+ console.error('❌ Usage: telepty broker allow <fromNode> --to <nodeA,nodeB>');
1080
+ process.exit(1);
1081
+ }
1082
+ const allowed = brokerAllow(fromNode, toNodes);
1083
+ console.log(`\x1b[32m✅ Broker ACL: ${fromNode} may inject ${allowed.join(', ')}\x1b[0m`);
1084
+ return;
1085
+ }
1086
+
1087
+ if (subcmd === 'deny') {
1088
+ const node = args[2];
1089
+ if (!node) {
1090
+ console.error('❌ Usage: telepty broker deny <node>');
1091
+ process.exit(1);
1092
+ }
1093
+ brokerDeny(node);
1094
+ console.log(`\x1b[32m✅ Broker ACL: ${node} denied\x1b[0m`);
1095
+ return;
1096
+ }
1097
+
1098
+ if (subcmd === 'revoke') {
1099
+ const node = args[2];
1100
+ if (!node) {
1101
+ console.error('❌ Usage: telepty broker revoke <node>');
1102
+ process.exit(1);
1103
+ }
1104
+ brokerRevoke(node);
1105
+ console.log(`\x1b[32m✅ Broker revocation: ${node} revoked\x1b[0m`);
1106
+ return;
1107
+ }
1108
+
1109
+ if (subcmd) {
1110
+ console.error('❌ Usage: telepty broker [allow <fromNode> --to <nodeA,nodeB> | deny <node> | revoke <node>]');
1111
+ process.exit(1);
1112
+ }
1113
+
1114
+ console.log('Starting telepty broker...');
1115
+ process.env.TELEPTY_BROKER_MODE = '1';
1116
+ process.env.AIGENTRY_TELEPTY_DAEMON_MAIN = '1';
1117
+ require('./daemon.js');
1118
+ return;
1119
+ }
1120
+
895
1121
  if (cmd === 'tui' || cmd === 'dashboard') {
896
1122
  const { TuiDashboard } = require('./tui');
897
1123
  new TuiDashboard();
@@ -944,6 +1170,90 @@ async function main() {
944
1170
  return;
945
1171
  }
946
1172
 
1173
+ if (cmd === 'injects') {
1174
+ // #43 P3 — query the inject audit log (GET /api/injects). Filters: --to/--from/--since/--spoof;
1175
+ // --json for piping; --tail follows live (poll). Mirrors the list/status command blocks.
1176
+ function flagValue(name) {
1177
+ const i = args.indexOf(name);
1178
+ return i !== -1 && args[i + 1] ? args[i + 1] : null;
1179
+ }
1180
+ const asJson = args.includes('--json');
1181
+ const tail = args.includes('--tail');
1182
+ const toFilter = flagValue('--to');
1183
+ const fromFilter = flagValue('--from');
1184
+ const spoofOnly = args.includes('--spoof');
1185
+ const limit = flagValue('--limit');
1186
+ let since = flagValue('--since');
1187
+ // --since accepts a relative duration ("1h", "30m") or an ISO/epoch value. Convert a
1188
+ // duration to an absolute ISO timestamp using the same parser as --idle-ttl (reuse).
1189
+ if (since) {
1190
+ try {
1191
+ const ms = lifecycle.parseDuration(since, { fieldName: 'since' });
1192
+ if (Number.isFinite(ms) && ms > 0) since = new Date(Date.now() - ms).toISOString();
1193
+ } catch { /* not a duration — pass through as ISO/epoch */ }
1194
+ }
1195
+
1196
+ function buildQuery(cursor) {
1197
+ const p = new URLSearchParams();
1198
+ if (since) p.set('since', since);
1199
+ if (toFilter) p.set('to', toFilter);
1200
+ if (fromFilter) p.set('from', fromFilter);
1201
+ if (spoofOnly) p.set('spoof', '1');
1202
+ if (limit) p.set('limit', limit);
1203
+ if (cursor != null) p.set('cursor', String(cursor));
1204
+ const qs = p.toString();
1205
+ return `${DAEMON_URL}/api/injects${qs ? `?${qs}` : ''}`;
1206
+ }
1207
+
1208
+ function formatRow(l) {
1209
+ const spoofTag = l.spoof_suspected ? ' \x1b[31m⚠ SPOOF\x1b[0m' : '';
1210
+ const verified = l.verified_sender_sid || '\x1b[90mnull\x1b[0m';
1211
+ return ` ${l.ts} \x1b[36m${l.claimed_from || '-'}\x1b[0m→${l.to} verified=${verified} ${l.kind}/${l.delivery_result}${spoofTag}`;
1212
+ }
1213
+
1214
+ try {
1215
+ if (tail) {
1216
+ // Poll newest lines and print rows as they appear (dedup by inject_id+to).
1217
+ const seen = new Set();
1218
+ let firstPass = true;
1219
+ console.log('\x1b[1mTailing inject audit log (Ctrl-C to stop)...\x1b[0m');
1220
+ for (;;) {
1221
+ const res = await fetchWithAuth(buildQuery());
1222
+ if (res.ok) {
1223
+ const data = await res.json();
1224
+ const fresh = (data.injects || []).slice().reverse(); // oldest→newest for display
1225
+ for (const l of fresh) {
1226
+ const key = `${l.inject_id}|${l.to}`;
1227
+ if (seen.has(key)) continue;
1228
+ seen.add(key);
1229
+ if (!firstPass) console.log(asJson ? JSON.stringify(l) : formatRow(l));
1230
+ }
1231
+ if (firstPass) {
1232
+ // Print the initial window once, then stream only newer lines.
1233
+ for (const l of fresh) console.log(asJson ? JSON.stringify(l) : formatRow(l));
1234
+ firstPass = false;
1235
+ }
1236
+ }
1237
+ await new Promise((r) => setTimeout(r, 1000));
1238
+ }
1239
+ }
1240
+
1241
+ const res = await fetchWithAuth(buildQuery());
1242
+ const data = await res.json();
1243
+ if (!res.ok) { console.error(`❌ ${formatApiError(data)}`); process.exit(1); }
1244
+ if (asJson) { console.log(JSON.stringify(data, null, 2)); return; }
1245
+ const injects = data.injects || [];
1246
+ if (injects.length === 0) { console.log('No inject audit records found.'); return; }
1247
+ console.log('\x1b[1mInject audit log (newest first):\x1b[0m');
1248
+ injects.forEach((l) => console.log(formatRow(l)));
1249
+ if (data.next_cursor != null) console.log(` … more (--limit/cursor ${data.next_cursor})`);
1250
+ } catch (e) {
1251
+ console.error(`❌ ${e.message || 'Failed to query inject audit log.'}`);
1252
+ process.exit(1);
1253
+ }
1254
+ return;
1255
+ }
1256
+
947
1257
  if (cmd === 'spawn') {
948
1258
  const idIndex = args.indexOf('--id');
949
1259
  if (idIndex === -1 || !args[idIndex + 1]) { console.error('❌ Usage: telepty spawn --id <session_id> <command> [args...]'); process.exit(1); }
@@ -1032,6 +1342,10 @@ async function main() {
1032
1342
  delete process.env.TELEPTY_SESSION_ID;
1033
1343
  process.env.TELEPTY_SESSION_ID = sessionId;
1034
1344
  process.env.TELEPTY_AVAILABLE = 'true';
1345
+ // #43 P2 — drop any inherited verified-sender token so a parent process cannot smuggle one
1346
+ // in; the daemon mints the real one at register (below) and we set it into the same
1347
+ // protected env. (Leaves room for a future per-session nonce — banner is P4, not built.)
1348
+ delete process.env.TELEPTY_SESSION_TOKEN;
1035
1349
 
1036
1350
  await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
1037
1351
 
@@ -1071,6 +1385,9 @@ async function main() {
1071
1385
  console.error(`❌ Error: ${data.error}`);
1072
1386
  process.exit(1);
1073
1387
  }
1388
+ // #43 P2 — store the daemon-minted verified-sender token beside TELEPTY_SESSION_ID so the
1389
+ // wrapped CLI (and any `telepty inject` it spawns) inherits it via sessionEnv below.
1390
+ if (data.session_token) process.env.TELEPTY_SESSION_TOKEN = data.session_token;
1074
1391
  } catch (e) {
1075
1392
  console.error('❌ Failed to register with daemon:', e.message);
1076
1393
  process.exit(1);
@@ -1079,7 +1396,7 @@ async function main() {
1079
1396
  // Spawn local PTY (preserves isTTY, env, shell config)
1080
1397
  const pty = require('node-pty');
1081
1398
  const sessionCwd = process.cwd();
1082
- const sessionEnv = { ...process.env, TELEPTY_SESSION_ID: sessionId, TELEPTY_AVAILABLE: 'true' };
1399
+ const sessionEnv = { ...process.env, TELEPTY_SESSION_ID: sessionId, TELEPTY_AVAILABLE: 'true', ...(process.env.TELEPTY_SESSION_TOKEN ? { TELEPTY_SESSION_TOKEN: process.env.TELEPTY_SESSION_TOKEN } : {}) };
1083
1400
  let child = null;
1084
1401
  let sessionStartTime = Date.now();
1085
1402
  let crashCount = 0;
@@ -3237,6 +3554,47 @@ Discuss the following topic from your project's perspective. Engage with other s
3237
3554
  return;
3238
3555
  }
3239
3556
 
3557
+ // telepty connect-broker <url> --node <name> --enroll-secret <secret|@file|env:VAR> [--pin <sha256>]
3558
+ // One-click broker enrollment: POST /broker/enroll with the fleet enroll
3559
+ // secret, then persist the minted node JWT in ~/.telepty/broker.json (0600)
3560
+ // and a non-secret transport='broker' peer in peers.json.
3561
+ if (cmd === 'connect-broker') {
3562
+ const target = args[1];
3563
+ const nodeFlag = args.indexOf('--node');
3564
+ const secretFlag = args.indexOf('--enroll-secret');
3565
+ const pinFlag = args.indexOf('--pin');
3566
+ const node = nodeFlag !== -1 ? args[nodeFlag + 1] : null;
3567
+ const secretSpec = secretFlag !== -1 ? args[secretFlag + 1] : null;
3568
+ const pin = pinFlag !== -1 ? args[pinFlag + 1] : null;
3569
+ if (!target || !node || !secretSpec) {
3570
+ console.error('❌ Usage: telepty connect-broker <url> --node <name> --enroll-secret <secret|@file|env:VAR> [--pin <sha256>]');
3571
+ process.exit(1);
3572
+ }
3573
+
3574
+ process.stdout.write(`\x1b[36m🔗 Enrolling ${node} with broker ${target}...\x1b[0m\n`);
3575
+ try {
3576
+ const enrollSecret = readBrokerEnrollSecret(secretSpec);
3577
+ const enroll = await enrollBrokerNode(target, node, enrollSecret, pin);
3578
+ const result = await crossMachine.connectBroker(enroll.url, {
3579
+ node,
3580
+ jwt: enroll.jwt,
3581
+ pin
3582
+ });
3583
+ if (result.success) {
3584
+ console.log(`\x1b[32m✅ Connected ${result.node} to broker\x1b[0m`);
3585
+ console.log(` Broker: ${result.url}`);
3586
+ console.log(`\nBroker sessions are now discoverable via \x1b[36mtelepty list\x1b[0m`);
3587
+ } else {
3588
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
3589
+ process.exit(1);
3590
+ }
3591
+ } catch (err) {
3592
+ console.error(`\x1b[31m❌ ${err.message}\x1b[0m`);
3593
+ process.exit(1);
3594
+ }
3595
+ return;
3596
+ }
3597
+
3240
3598
  // telepty disconnect [<name> | --all]
3241
3599
  if (cmd === 'disconnect') {
3242
3600
  if (args[1] === '--all') {
@@ -3394,6 +3752,8 @@ Discuss the following topic from your project's perspective. Engage with other s
3394
3752
  \x1b[1mCross-Machine:\x1b[0m
3395
3753
  telepty connect <user@host> [--name N] [--port P] SSH tunnel to remote host
3396
3754
  telepty connect-http <host>[:port] [--name N] [--token T] Register remote daemon via HTTP (no SSH)
3755
+ telepty connect-broker <url> --node N --enroll-secret S [--pin P] Enroll with broker relay
3756
+ telepty broker [allow <from> --to a,b | deny <node> | revoke <node>] Start/admin broker
3397
3757
  telepty disconnect <name> | --all Disconnect remote host
3398
3758
  telepty peers [--remove <name>] List connected peers
3399
3759
 
@@ -3447,4 +3807,6 @@ module.exports = {
3447
3807
  classifyBackend, // #29: TERM_PROGRAM/CMUX/kitty → backend string
3448
3808
  isDaemonDestroyClose, // #17 OQ-2: 1000 'Session destroyed' → terminate-not-reconnect
3449
3809
  sanitizePathArg, // #26: path-arg validation/normalization
3810
+ decideDaemonAction, // #567: pure restart-decision policy (meta-primary; no I/O)
3811
+ ensureDaemonRunning, // #567: orchestrator (injectable probes for unit-testing)
3450
3812
  };