@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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,88 @@ 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
|
+
|
|
66
|
+
## [0.5.9] - 2026-06-08
|
|
67
|
+
|
|
68
|
+
### Fixed — managed service install never started the daemon (#41)
|
|
69
|
+
|
|
70
|
+
- **The launchd/systemd/Windows service install generated an `env: node`
|
|
71
|
+
invocation that exited 127** under a minimal service-manager PATH (the daemon
|
|
72
|
+
never started when managed by launchd/systemd). **Fix:** `install.js` now uses
|
|
73
|
+
the absolute `process.execPath` + `cli.js` path for launchd/systemd/Windows
|
|
74
|
+
service generation, sets the daemon `PATH` via EnvironmentVariables, and adds
|
|
75
|
+
managed-instance live assertions so a managed daemon actually starts. Landed on
|
|
76
|
+
`main` at commit `7b2ab92`.
|
|
77
|
+
|
|
78
|
+
### Changed — CI test wiring
|
|
79
|
+
|
|
80
|
+
- Wired `test/install-service-generation.test.js` (the #41 regression test) into
|
|
81
|
+
the `test`, `test:ci`, and `test:watch` script file lists so CI's
|
|
82
|
+
`npm run test:ci` actually exercises the service-install generation.
|
|
83
|
+
|
|
84
|
+
### Docs
|
|
85
|
+
|
|
86
|
+
- Landed the #42 cross-machine relay/broker (hub) mode ADR and MVP
|
|
87
|
+
implementation spec.
|
|
88
|
+
|
|
7
89
|
## [0.5.2] - 2026-06-06
|
|
8
90
|
|
|
9
91
|
### Fixed — submit handshake confirmation (#507-B / #508)
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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
|
};
|