@dmsdc-ai/aigentry-telepty 0.5.1 → 0.5.3
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 +14 -0
- package/cli.js +37 -123
- package/daemon.js +332 -428
- package/package.json +9 -4
- package/src/cli/session-view.js +100 -0
- package/src/lifecycle.js +114 -1
- package/src/protocol/http-auth.js +71 -0
- package/src/session-store/persistence.js +88 -0
- package/src/submit-gate.js +157 -0
- package/src/transport/peer-relay.js +51 -0
- package/src/transport/websocket.js +295 -0
- package/terminal-backend.js +339 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"postinstall": "node scripts/postinstall.js",
|
|
38
|
-
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
39
|
-
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
|
|
40
|
-
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
38
|
+
"test": "node --test test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
39
|
+
"test:watch": "node --test --watch test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
|
|
40
|
+
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
41
42
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
42
43
|
},
|
|
43
44
|
"keywords": [
|
|
@@ -78,5 +79,9 @@
|
|
|
78
79
|
"uuid": "^9.0.0",
|
|
79
80
|
"ws": "^8.19.0",
|
|
80
81
|
"zod": "^3.24.0"
|
|
82
|
+
},
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"@types/node": "^20.19.41",
|
|
85
|
+
"typescript": "^6.0.3"
|
|
81
86
|
}
|
|
82
87
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const { formatHostLabel } = require('../../session-routing');
|
|
2
|
+
const lifecycle = require('../lifecycle');
|
|
3
|
+
|
|
4
|
+
function detectTerminalProgram(env = process.env) {
|
|
5
|
+
const rawTermProgram = typeof env.TERM_PROGRAM === 'string' ? env.TERM_PROGRAM.trim() : '';
|
|
6
|
+
if (rawTermProgram) {
|
|
7
|
+
return rawTermProgram;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (env.TMUX) {
|
|
11
|
+
return 'tmux';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const term = typeof env.TERM === 'string' ? env.TERM.toLowerCase() : '';
|
|
15
|
+
if (term.includes('kitty')) return 'kitty';
|
|
16
|
+
if (term.includes('ghostty')) return 'ghostty';
|
|
17
|
+
if (term.includes('tmux')) return 'tmux';
|
|
18
|
+
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatSessionTerminal(session) {
|
|
23
|
+
const terminal = session.terminal || session.termProgram || null;
|
|
24
|
+
const term = session.term || null;
|
|
25
|
+
if (terminal && term) {
|
|
26
|
+
return `${terminal} (${term})`;
|
|
27
|
+
}
|
|
28
|
+
return terminal || term || 'unknown';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatSessionHealth(session) {
|
|
32
|
+
const status = session.healthStatus || 'UNKNOWN';
|
|
33
|
+
const reason = session.healthReason || null;
|
|
34
|
+
if (reason && reason !== status) {
|
|
35
|
+
return `${status} (${reason})`;
|
|
36
|
+
}
|
|
37
|
+
return status;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function enrichSessionIdle(session, nowMs = Date.now()) {
|
|
41
|
+
const idleSeconds = typeof session.idleSeconds === 'number'
|
|
42
|
+
? session.idleSeconds
|
|
43
|
+
: lifecycle.computeIdleSeconds(session.lastActivityAt, nowMs);
|
|
44
|
+
return {
|
|
45
|
+
...session,
|
|
46
|
+
idleSeconds,
|
|
47
|
+
idle_seconds: idleSeconds
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatSessionStatusWithIdle(session) {
|
|
52
|
+
const base = formatSessionHealth(session);
|
|
53
|
+
const idleSeconds = typeof session.idleSeconds === 'number' ? session.idleSeconds : null;
|
|
54
|
+
if (idleSeconds !== null && idleSeconds > 60) {
|
|
55
|
+
return `${base} 💤 idle (${lifecycle.formatIdleDuration(idleSeconds)})`;
|
|
56
|
+
}
|
|
57
|
+
return base;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printSessionInfo(session, options = {}) {
|
|
61
|
+
const host = options.host || session.host || '127.0.0.1';
|
|
62
|
+
console.log('\x1b[1mSession Info:\x1b[0m');
|
|
63
|
+
console.log(` - ID: \x1b[36m${session.id}\x1b[0m`);
|
|
64
|
+
console.log(` Host: ${formatHostLabel(host)}`);
|
|
65
|
+
console.log(` Command: ${session.command}`);
|
|
66
|
+
console.log(` Type: ${session.type || 'unknown'}`);
|
|
67
|
+
console.log(` Status: ${formatSessionHealth(session)}`);
|
|
68
|
+
console.log(` Terminal: ${session.terminal || session.termProgram || 'unknown'}`);
|
|
69
|
+
console.log(` TERM: ${session.term || 'n/a'}`);
|
|
70
|
+
console.log(` CWD: ${session.cwd}`);
|
|
71
|
+
console.log(` Clients: ${session.active_clients ?? 0}`);
|
|
72
|
+
if (session.createdAt) {
|
|
73
|
+
console.log(` Started: ${new Date(session.createdAt).toLocaleString()}`);
|
|
74
|
+
}
|
|
75
|
+
if (session.lastActivityAt) {
|
|
76
|
+
console.log(` Last Activity: ${new Date(session.lastActivityAt).toLocaleString()}`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof session.idleSeconds === 'number') {
|
|
79
|
+
console.log(` Idle: ${session.idleSeconds}s`);
|
|
80
|
+
}
|
|
81
|
+
if (session.semantic && session.semantic.phase) {
|
|
82
|
+
console.log(` Phase: ${session.semantic.phase}`);
|
|
83
|
+
}
|
|
84
|
+
if (session.semantic && session.semantic.current_task) {
|
|
85
|
+
console.log(` Current Task: ${session.semantic.current_task}`);
|
|
86
|
+
}
|
|
87
|
+
if (session.semantic && session.semantic.blocker) {
|
|
88
|
+
console.log(` Blocker: ${session.semantic.blocker}`);
|
|
89
|
+
}
|
|
90
|
+
console.log('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
detectTerminalProgram,
|
|
95
|
+
formatSessionTerminal,
|
|
96
|
+
formatSessionHealth,
|
|
97
|
+
enrichSessionIdle,
|
|
98
|
+
formatSessionStatusWithIdle,
|
|
99
|
+
printSessionInfo
|
|
100
|
+
};
|
package/src/lifecycle.js
CHANGED
|
@@ -6,6 +6,12 @@ const os = require('node:os');
|
|
|
6
6
|
|
|
7
7
|
const { killWindowsProcess } = require('./win-kill-process');
|
|
8
8
|
|
|
9
|
+
// #17: grace window before a cmux session whose workspace was explicitly closed (bridge
|
|
10
|
+
// survived → headless zombie) is reclaimed. Shorter than the 300s disconnect-GC: the surface
|
|
11
|
+
// is confirmed gone (not merely disconnected). The window absorbs cmux transient hiccups.
|
|
12
|
+
const SURFACE_ORPHAN_SECONDS = Math.max(5, Number(process.env.TELEPTY_SURFACE_ORPHAN_SECONDS || 30));
|
|
13
|
+
const SURFACE_MISMATCH_SECONDS = Math.max(1, Number(process.env.TELEPTY_SURFACE_MISMATCH_SECONDS || 10));
|
|
14
|
+
|
|
9
15
|
const DURATION_RE = /^(\d+)(ms|s|m|h|d)$/i;
|
|
10
16
|
const UNIT_MS = {
|
|
11
17
|
ms: 1,
|
|
@@ -223,7 +229,110 @@ function selectCleanOlderThanTargets(sessions, options = {}) {
|
|
|
223
229
|
return targets;
|
|
224
230
|
}
|
|
225
231
|
|
|
232
|
+
// #17: pure verdict→action mapping for the surface-liveness GC, exposed for unit-testing.
|
|
233
|
+
// Returns 'mark' (start the grace window), 'reclaim' (grace elapsed → teardown), 'recover'
|
|
234
|
+
// (surface returned within grace → clear), or 'skip'. INV-17: 'unknown' (cmux unreachable)
|
|
235
|
+
// always maps to 'skip' — GC nothing. Pure, no side effects; the caller performs the action.
|
|
236
|
+
function decideSurfaceGc(liveness, session, nowMs, graceSeconds = SURFACE_ORPHAN_SECONDS) {
|
|
237
|
+
if (liveness === 'gone') {
|
|
238
|
+
if (!session.surfaceGoneAt) return 'mark';
|
|
239
|
+
const goneSeconds = Math.floor((nowMs - new Date(session.surfaceGoneAt).getTime()) / 1000);
|
|
240
|
+
return goneSeconds >= graceSeconds ? 'reclaim' : 'skip';
|
|
241
|
+
}
|
|
242
|
+
if (liveness === 'alive' && session.surfaceGoneAt) return 'recover';
|
|
243
|
+
return 'skip';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clearSurfaceMismatchState(session) {
|
|
247
|
+
if (!session) return;
|
|
248
|
+
session.surfaceMismatchAt = null;
|
|
249
|
+
session.surfaceMismatchObserved = null;
|
|
250
|
+
session.surfaceMismatchEmitted = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getExpectedPtyPidFromProbe(session, probe) {
|
|
254
|
+
const candidates = [
|
|
255
|
+
probe && probe.expectedPtyPid,
|
|
256
|
+
session && session.ptyPid,
|
|
257
|
+
session && session.pty_pid,
|
|
258
|
+
session && session.ptyProcess && session.ptyProcess.pid,
|
|
259
|
+
session && session.pid
|
|
260
|
+
];
|
|
261
|
+
for (const pid of candidates) {
|
|
262
|
+
const n = Number(pid);
|
|
263
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildSurfaceMismatchExtra(sessionId, session, probe, mismatchSeconds) {
|
|
269
|
+
return {
|
|
270
|
+
sid: sessionId,
|
|
271
|
+
backend: 'cmux',
|
|
272
|
+
cmuxWorkspaceId: session && session.cmuxWorkspaceId ? session.cmuxWorkspaceId : null,
|
|
273
|
+
expectedPtyPid: getExpectedPtyPidFromProbe(session, probe),
|
|
274
|
+
observedSurface: probe && probe.observedSurface ? probe.observedSurface : null,
|
|
275
|
+
mismatchSeconds
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getSurfaceMismatchObservedKey(probe) {
|
|
280
|
+
if (!probe || typeof probe !== 'object') return null;
|
|
281
|
+
return probe.observedSurfaceKey || probe.observedSurface || null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applySurfaceMismatchProbe(sessionId, session, probe, options = {}) {
|
|
285
|
+
if (!session) return { action: 'skip', reason: 'missing_session' };
|
|
286
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
287
|
+
const debounceSeconds = options.debounceSeconds ?? SURFACE_MISMATCH_SECONDS;
|
|
288
|
+
|
|
289
|
+
if (!probe || probe.status !== 'mismatch') {
|
|
290
|
+
const hadState = !!(session.surfaceMismatchAt || session.surfaceMismatchObserved || session.surfaceMismatchEmitted);
|
|
291
|
+
clearSurfaceMismatchState(session);
|
|
292
|
+
return { action: hadState ? 'recover' : 'skip', reason: (probe && (probe.reason || probe.status)) || 'not_mismatch' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const observedKey = getSurfaceMismatchObservedKey(probe);
|
|
296
|
+
if (!observedKey) {
|
|
297
|
+
clearSurfaceMismatchState(session);
|
|
298
|
+
return { action: 'skip', reason: 'observed_surface_unknown' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!session.surfaceMismatchAt || session.surfaceMismatchObserved !== observedKey) {
|
|
302
|
+
session.surfaceMismatchAt = new Date(nowMs).toISOString();
|
|
303
|
+
session.surfaceMismatchObserved = observedKey;
|
|
304
|
+
session.surfaceMismatchEmitted = false;
|
|
305
|
+
return { action: 'mark', reason: probe.reason || 'mismatch', mismatchSeconds: 0 };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const startedMs = new Date(session.surfaceMismatchAt).getTime();
|
|
309
|
+
if (!Number.isFinite(startedMs)) {
|
|
310
|
+
session.surfaceMismatchAt = new Date(nowMs).toISOString();
|
|
311
|
+
session.surfaceMismatchObserved = observedKey;
|
|
312
|
+
session.surfaceMismatchEmitted = false;
|
|
313
|
+
return { action: 'mark', reason: 'invalid_start_reset', mismatchSeconds: 0 };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const mismatchSeconds = Math.max(0, Math.floor((nowMs - startedMs) / 1000));
|
|
317
|
+
if (session.surfaceMismatchEmitted) {
|
|
318
|
+
return { action: 'skip', reason: 'already_emitted', mismatchSeconds };
|
|
319
|
+
}
|
|
320
|
+
if (mismatchSeconds < debounceSeconds) {
|
|
321
|
+
return { action: 'skip', reason: 'debouncing', mismatchSeconds };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const extra = buildSurfaceMismatchExtra(sessionId, session, probe, mismatchSeconds);
|
|
325
|
+
let event = null;
|
|
326
|
+
if (typeof options.emit === 'function') {
|
|
327
|
+
event = options.emit(extra);
|
|
328
|
+
}
|
|
329
|
+
session.surfaceMismatchEmitted = true;
|
|
330
|
+
return { action: 'emit', reason: probe.reason || 'mismatch', mismatchSeconds, extra, event };
|
|
331
|
+
}
|
|
332
|
+
|
|
226
333
|
module.exports = {
|
|
334
|
+
SURFACE_ORPHAN_SECONDS,
|
|
335
|
+
SURFACE_MISMATCH_SECONDS,
|
|
227
336
|
parseDuration,
|
|
228
337
|
computeIdleSeconds,
|
|
229
338
|
formatIdleDuration,
|
|
@@ -233,5 +342,9 @@ module.exports = {
|
|
|
233
342
|
cleanupSessionArtifacts,
|
|
234
343
|
effectiveIdleTtlMs,
|
|
235
344
|
selectIdleTtlVictims,
|
|
236
|
-
selectCleanOlderThanTargets
|
|
345
|
+
selectCleanOlderThanTargets,
|
|
346
|
+
decideSurfaceGc,
|
|
347
|
+
clearSurfaceMismatchState,
|
|
348
|
+
buildSurfaceMismatchExtra,
|
|
349
|
+
applySurfaceMismatchProbe
|
|
237
350
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function createVerifyJwt(JWT_SECRET) {
|
|
6
|
+
function verifyJwt(token) {
|
|
7
|
+
if (!JWT_SECRET || !token) return false;
|
|
8
|
+
try {
|
|
9
|
+
// Simple HS256 JWT verification (no external deps)
|
|
10
|
+
const [headerB64, payloadB64, sigB64] = token.split('.');
|
|
11
|
+
if (!headerB64 || !payloadB64 || !sigB64) return false;
|
|
12
|
+
const expected = crypto.createHmac('sha256', JWT_SECRET)
|
|
13
|
+
.update(`${headerB64}.${payloadB64}`).digest('base64url');
|
|
14
|
+
if (sigB64 !== expected) return false;
|
|
15
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
16
|
+
if (payload.exp && Date.now() / 1000 > payload.exp) return false;
|
|
17
|
+
return payload;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return verifyJwt;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createIsAllowedPeer(PEER_ALLOWLIST) {
|
|
25
|
+
function isAllowedPeer(ip) {
|
|
26
|
+
if (!ip) return false;
|
|
27
|
+
const cleanIp = ip.replace('::ffff:', '');
|
|
28
|
+
// Localhost always allowed (includes SSH tunnel traffic)
|
|
29
|
+
if (cleanIp === '127.0.0.1' || ip === '::1') return true;
|
|
30
|
+
// Peer allowlist
|
|
31
|
+
if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
|
|
32
|
+
// No allowlist = allow all authenticated
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return isAllowedPeer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createAuthMiddleware(options) {
|
|
40
|
+
const EXPECTED_TOKEN = options.expectedToken;
|
|
41
|
+
const isAllowedPeer = options.isAllowedPeer;
|
|
42
|
+
const verifyJwt = options.verifyJwt;
|
|
43
|
+
|
|
44
|
+
return (req, res, next) => {
|
|
45
|
+
const clientIp = req.ip;
|
|
46
|
+
|
|
47
|
+
if (isAllowedPeer(clientIp)) {
|
|
48
|
+
return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const token = req.headers['x-telepty-token'] || req.query.token;
|
|
52
|
+
if (token === EXPECTED_TOKEN) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// JWT Bearer token
|
|
57
|
+
const authHeader = req.headers['authorization'] || '';
|
|
58
|
+
if (authHeader.startsWith('Bearer ') && verifyJwt(authHeader.slice(7))) {
|
|
59
|
+
return next();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.warn(`[AUTH] Rejected unauthorized request from ${clientIp}`);
|
|
63
|
+
res.status(401).json({ error: 'Unauthorized: Invalid or missing token.', code: 'PERMISSION_DENIED' });
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
createAuthMiddleware,
|
|
69
|
+
createIsAllowedPeer,
|
|
70
|
+
createVerifyJwt
|
|
71
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function defaultSessionPersistPath(homeDir = os.homedir()) {
|
|
8
|
+
return path.join(homeDir, '.config', 'aigentry-telepty', 'sessions.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function serializePersistedSessions(sessions) {
|
|
12
|
+
const data = {};
|
|
13
|
+
for (const [id, s] of Object.entries(sessions)) {
|
|
14
|
+
data[id] = {
|
|
15
|
+
id,
|
|
16
|
+
type: s.type,
|
|
17
|
+
command: s.command,
|
|
18
|
+
cwd: s.cwd,
|
|
19
|
+
backend: s.backend || null,
|
|
20
|
+
cmuxWorkspaceId: s.cmuxWorkspaceId || null,
|
|
21
|
+
cmuxSurfaceId: s.cmuxSurfaceId || null,
|
|
22
|
+
termProgram: s.termProgram || null,
|
|
23
|
+
term: s.term || null,
|
|
24
|
+
delivery: s.delivery || null,
|
|
25
|
+
deliveryEndpoint: s.deliveryEndpoint || null,
|
|
26
|
+
createdAt: s.createdAt,
|
|
27
|
+
lastActivityAt: s.lastActivityAt || null,
|
|
28
|
+
lastConnectedAt: s.lastConnectedAt || null,
|
|
29
|
+
lastDisconnectedAt: s.lastDisconnectedAt || null,
|
|
30
|
+
lastStateReportAt: s.lastStateReportAt || null,
|
|
31
|
+
stateReport: s.stateReport || null,
|
|
32
|
+
idleTtl: s.idleTtl || null,
|
|
33
|
+
idleTtlMs: s.idleTtlMs == null ? null : s.idleTtlMs,
|
|
34
|
+
ownerPid: s.ownerPid || null,
|
|
35
|
+
ptyPid: s.ptyPid || null
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function savePersistedSessions(sessions, persistPath = defaultSessionPersistPath()) {
|
|
42
|
+
try {
|
|
43
|
+
const data = serializePersistedSessions(sessions);
|
|
44
|
+
fs.mkdirSync(path.dirname(persistPath), { recursive: true });
|
|
45
|
+
fs.writeFileSync(persistPath, JSON.stringify(data, null, 2));
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadPersistedSessions(persistPath = defaultSessionPersistPath()) {
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(persistPath)) return {};
|
|
52
|
+
return JSON.parse(fs.readFileSync(persistPath, 'utf8'));
|
|
53
|
+
} catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildRestoredWrappedSession(id, meta, options = {}) {
|
|
57
|
+
if (meta.type !== 'wrapped') return null;
|
|
58
|
+
|
|
59
|
+
const cwd = options.cwd || process.cwd();
|
|
60
|
+
const nowIso = options.nowIso || (() => new Date().toISOString());
|
|
61
|
+
return {
|
|
62
|
+
id, type: 'wrapped', ptyProcess: null, ownerWs: null,
|
|
63
|
+
command: meta.command || 'wrapped', cwd: meta.cwd || cwd,
|
|
64
|
+
backend: meta.backend || 'kitty',
|
|
65
|
+
cmuxWorkspaceId: meta.cmuxWorkspaceId || null,
|
|
66
|
+
cmuxSurfaceId: meta.cmuxSurfaceId || null,
|
|
67
|
+
termProgram: meta.termProgram || null,
|
|
68
|
+
term: meta.term || null,
|
|
69
|
+
createdAt: meta.createdAt || nowIso(),
|
|
70
|
+
lastActivityAt: meta.lastActivityAt || nowIso(),
|
|
71
|
+
lastConnectedAt: meta.lastConnectedAt || null,
|
|
72
|
+
lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || nowIso(),
|
|
73
|
+
lastStateReportAt: meta.lastStateReportAt || null,
|
|
74
|
+
stateReport: meta.stateReport || null,
|
|
75
|
+
idleTtl: meta.idleTtl || null,
|
|
76
|
+
idleTtlMs: meta.idleTtlMs == null ? null : meta.idleTtlMs,
|
|
77
|
+
ownerPid: meta.ownerPid || null,
|
|
78
|
+
ptyPid: meta.ptyPid || null,
|
|
79
|
+
clients: new Set(), isClosing: false, outputRing: [], ready: true, };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
defaultSessionPersistPath,
|
|
84
|
+
serializePersistedSessions,
|
|
85
|
+
savePersistedSessions,
|
|
86
|
+
loadPersistedSessions,
|
|
87
|
+
buildRestoredWrappedSession
|
|
88
|
+
};
|
package/src/submit-gate.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Exports:
|
|
9
9
|
// - awaitReplReady(sessionId, stateManager, opts) → Promise<{ ready, last_state, waited_ms, reason? }>
|
|
10
10
|
// - verifyBodyConsumed(session, bodyText, opts) → Promise<{ consumed, waited_ms, reason? }>
|
|
11
|
+
// - confirmSubmitAccepted(session, bodyText, opts) → Promise<{ accepted, retryable, waited_ms, reason? }>
|
|
11
12
|
// - isReady(state, minConfidence) (test surface)
|
|
12
13
|
// - isFailed(state) (test surface)
|
|
13
14
|
// - READY_STATES, FAIL_STATES (test surface)
|
|
@@ -21,6 +22,7 @@ const READY_STATES = new Set(['idle', 'waiting']);
|
|
|
21
22
|
|
|
22
23
|
// States where waiting will never produce readiness; resolve immediately.
|
|
23
24
|
const FAIL_STATES = new Set(['dead', 'error', 'restarting']);
|
|
25
|
+
const ACCEPTED_AFTER_SUBMIT_STATES = new Set(['working', 'thinking']);
|
|
24
26
|
|
|
25
27
|
function isReady(state, minConfidence) {
|
|
26
28
|
if (!state) return false;
|
|
@@ -167,6 +169,158 @@ async function verifyBodyConsumed(session, bodyText, opts = {}) {
|
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Confirm that a submitted prompt was accepted by the target TUI.
|
|
174
|
+
*
|
|
175
|
+
* Accepted signals short-circuit immediately:
|
|
176
|
+
* - the injected body is absent from the current screen/output tail;
|
|
177
|
+
* - the session transitions to working/thinking after the CR was sent.
|
|
178
|
+
*
|
|
179
|
+
* The only retryable failure is confirmed-unsubmitted: the body stayed visible
|
|
180
|
+
* for the whole bounded window. Ambiguous/no-observable cases are treated as
|
|
181
|
+
* success for back-compat, but marked `ambiguous` so callers can report that
|
|
182
|
+
* the success was optimistic. This keeps CR resend idempotent: resend only
|
|
183
|
+
* when `retryable === true`.
|
|
184
|
+
*
|
|
185
|
+
* @param {{ outputRing?: string[], backend?: string, cmuxWorkspaceId?: string|null }} session
|
|
186
|
+
* @param {string} bodyText
|
|
187
|
+
* @param {{ timeoutMs?: number, intervalMs?: number, tailBytes?: number, stripAnsi?: Function, readScreen?: Function, tailLines?: number, getState?: Function, submittedAtMs?: number, now?: Function, sleep?: Function }} [opts]
|
|
188
|
+
* @returns {Promise<{ accepted: boolean, retryable: boolean, waited_ms: number, reason?: string, ambiguous?: boolean, visibility?: object, state?: object }>}
|
|
189
|
+
*/
|
|
190
|
+
async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
191
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
|
|
192
|
+
const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : 50;
|
|
193
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
194
|
+
const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
195
|
+
const getState = typeof opts.getState === 'function' ? opts.getState : null;
|
|
196
|
+
const submittedAtMs = Number.isFinite(opts.submittedAtMs) ? opts.submittedAtMs : now();
|
|
197
|
+
const start = now();
|
|
198
|
+
|
|
199
|
+
let lastVisibility = null;
|
|
200
|
+
let everVisible = false;
|
|
201
|
+
|
|
202
|
+
while (true) {
|
|
203
|
+
const state = getState ? getState() : null;
|
|
204
|
+
if (isAcceptedSubmitState(state, submittedAtMs)) {
|
|
205
|
+
return {
|
|
206
|
+
accepted: true,
|
|
207
|
+
retryable: false,
|
|
208
|
+
waited_ms: now() - start,
|
|
209
|
+
reason: `state_${state.state}`,
|
|
210
|
+
state,
|
|
211
|
+
visibility: lastVisibility || undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const visibility = observeBodyVisibility(session, bodyText, opts);
|
|
216
|
+
lastVisibility = visibility;
|
|
217
|
+
if (visibility.reason === 'empty_body') {
|
|
218
|
+
return {
|
|
219
|
+
accepted: true,
|
|
220
|
+
retryable: false,
|
|
221
|
+
waited_ms: now() - start,
|
|
222
|
+
reason: 'empty_body',
|
|
223
|
+
visibility,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!visibility.observable) {
|
|
227
|
+
return {
|
|
228
|
+
accepted: true,
|
|
229
|
+
retryable: false,
|
|
230
|
+
waited_ms: now() - start,
|
|
231
|
+
reason: visibility.reason || 'no_observable',
|
|
232
|
+
ambiguous: true,
|
|
233
|
+
visibility,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (visibility.visible) {
|
|
237
|
+
everVisible = true;
|
|
238
|
+
} else {
|
|
239
|
+
return {
|
|
240
|
+
accepted: true,
|
|
241
|
+
retryable: false,
|
|
242
|
+
waited_ms: now() - start,
|
|
243
|
+
reason: everVisible ? 'body_consumed' : 'body_absent',
|
|
244
|
+
visibility,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (now() - start >= timeoutMs) {
|
|
249
|
+
return {
|
|
250
|
+
accepted: false,
|
|
251
|
+
retryable: true,
|
|
252
|
+
waited_ms: now() - start,
|
|
253
|
+
reason: 'body_still_visible',
|
|
254
|
+
visibility,
|
|
255
|
+
state: state || undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await sleep(intervalMs);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isAcceptedSubmitState(state, submittedAtMs) {
|
|
264
|
+
if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
|
|
265
|
+
if (!Number.isFinite(submittedAtMs)) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (Number.isFinite(state.since_ms) && state.since_ms >= submittedAtMs) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
const lastOutputMs = state.last_output_at ? new Date(state.last_output_at).getTime() : NaN;
|
|
272
|
+
if (Number.isFinite(lastOutputMs) && lastOutputMs >= submittedAtMs) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
if (Number.isFinite(state.since_ms) && state.since_ms < submittedAtMs) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function observeBodyVisibility(session, bodyText, opts = {}) {
|
|
282
|
+
const needle = normalize(bodyText);
|
|
283
|
+
if (!needle) {
|
|
284
|
+
return { observable: true, visible: false, source: 'body', reason: 'empty_body' };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
|
|
288
|
+
const screen = readCurrentScreen(session, opts);
|
|
289
|
+
if (typeof screen === 'string' && screen.length > 0) {
|
|
290
|
+
const haystack = normalize(stripAnsi(screen));
|
|
291
|
+
return {
|
|
292
|
+
observable: true,
|
|
293
|
+
visible: haystack.indexOf(needle) !== -1,
|
|
294
|
+
source: 'screen',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!session || !Array.isArray(session.outputRing)) {
|
|
299
|
+
return { observable: false, visible: false, source: 'none', reason: 'no_ring' };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
|
|
303
|
+
const haystack = normalize(stripAnsi(readTail(session, tailBytes)));
|
|
304
|
+
return {
|
|
305
|
+
observable: true,
|
|
306
|
+
visible: haystack.indexOf(needle) !== -1,
|
|
307
|
+
source: 'output_ring',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readCurrentScreen(session, opts = {}) {
|
|
312
|
+
const readScreen = typeof opts.readScreen === 'function'
|
|
313
|
+
? opts.readScreen
|
|
314
|
+
: (session && session.backend === 'cmux' && session.cmuxWorkspaceId ? defaultReadScreen : null);
|
|
315
|
+
if (!readScreen || !session || !session.cmuxWorkspaceId) return null;
|
|
316
|
+
const tailLines = Number.isFinite(opts.tailLines) ? opts.tailLines : 30;
|
|
317
|
+
try {
|
|
318
|
+
return readScreen(session.cmuxWorkspaceId, tailLines);
|
|
319
|
+
} catch (_err) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
170
324
|
function normalize(s) {
|
|
171
325
|
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
172
326
|
}
|
|
@@ -266,10 +420,13 @@ function defaultReadScreen(workspaceId, lines) {
|
|
|
266
420
|
module.exports = {
|
|
267
421
|
awaitReplReady,
|
|
268
422
|
verifyBodyConsumed,
|
|
423
|
+
confirmSubmitAccepted,
|
|
424
|
+
observeBodyVisibility,
|
|
269
425
|
awaitPromptSymbol,
|
|
270
426
|
defaultReadScreen,
|
|
271
427
|
isReady,
|
|
272
428
|
isFailed,
|
|
273
429
|
READY_STATES,
|
|
274
430
|
FAIL_STATES,
|
|
431
|
+
ACCEPTED_AFTER_SUBMIT_STATES,
|
|
275
432
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function relayPeersFromEnv(env = process.env) {
|
|
6
|
+
return (env.TELEPTY_RELAY_PEERS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createPeerRelay(options) {
|
|
10
|
+
const {
|
|
11
|
+
relayPeers,
|
|
12
|
+
relaySeen = new Set(),
|
|
13
|
+
machineId,
|
|
14
|
+
expectedToken,
|
|
15
|
+
getPort,
|
|
16
|
+
fetchImpl = fetch,
|
|
17
|
+
randomUUID = crypto.randomUUID,
|
|
18
|
+
timeoutSignal = (ms) => AbortSignal.timeout(ms)
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
return function relayToPeers(msg) {
|
|
22
|
+
if (relayPeers.length === 0) return;
|
|
23
|
+
if (!msg.message_id) msg.message_id = randomUUID();
|
|
24
|
+
if (relaySeen.has(msg.message_id)) return; // already relayed
|
|
25
|
+
relaySeen.add(msg.message_id);
|
|
26
|
+
// Prevent unbounded growth
|
|
27
|
+
if (relaySeen.size > 10000) {
|
|
28
|
+
const arr = [...relaySeen];
|
|
29
|
+
arr.splice(0, 5000);
|
|
30
|
+
relaySeen.clear();
|
|
31
|
+
arr.forEach(id => relaySeen.add(id));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
msg.source_host = msg.source_host || machineId;
|
|
35
|
+
msg._relayed_from = machineId;
|
|
36
|
+
|
|
37
|
+
for (const peer of relayPeers) {
|
|
38
|
+
fetchImpl(`http://${peer}:${getPort()}/api/bus/publish`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json', 'x-telepty-token': expectedToken },
|
|
41
|
+
body: JSON.stringify(msg),
|
|
42
|
+
signal: timeoutSignal(3000)
|
|
43
|
+
}).catch(() => {}); // fire-and-forget
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
createPeerRelay,
|
|
50
|
+
relayPeersFromEnv
|
|
51
|
+
};
|