@dmsdc-ai/aigentry-telepty 0.4.0 → 0.4.2

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
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.4.2] - 2026-05-17
6
+
7
+ ### Fixed
8
+
9
+ - **#28** — SSH-peer routing for `telepty inject` / `list` / `enter`
10
+ cross-machine: file-backed `peers.json` fallback resolves the prior
11
+ `fetch failed` against SSH peers in fresh CLI subprocesses. Previously
12
+ `cross-machine.js` consulted only the in-memory `activePeers` Map, which
13
+ is process-local and empty for every CLI subprocess spawned after
14
+ `telepty connect`. New: `listSshPeers` + `getSshPeerHandle` helpers
15
+ (`cross-machine.js`) make SSH-peer discovery/inject symmetric with the
16
+ existing HTTP-peer path; `pickSessionTarget` (`session-routing.js`)
17
+ matches `<id>@<peerName>` against the peer alias; `resolveSessionTarget`
18
+ (`cli.js`) enriches synthetic targets with `peerName` when the host
19
+ matches a known SSH peer. 7 new unit tests
20
+ (`test/cross-machine-ssh-routing.test.js`) + 1 new peer-alias test
21
+ (`test/session-routing.test.js`). Scope: `inject` / `list` / `enter`;
22
+ `attach` / `read-screen` / `rename` / `destroy` / `state` /
23
+ `session info` share the same gap but are deferred to v0.4.3+.
24
+
25
+ ### Notes
26
+
27
+ - **Snyk SAST scan on changed files** — `cross-machine.js` +
28
+ `session-routing.js` + `test/cross-machine-ssh-routing.test.js` +
29
+ `test/session-routing.test.js` = **0 findings** (At-Inception clean).
30
+ `cli.js` shows **5 pre-existing findings** (2 Medium Command Injection
31
+ at `execSync` L469 + `pty.spawn` L1096, 3 Low Path Traversal at
32
+ L2308/L2310/L2619) with **identical fingerprints** vs HEAD~1 (5/5
33
+ verified). Line numbers for sinks below L543 shifted +21 from the
34
+ `resolveSessionTarget` enrichment block (cli.js L543–L566) — logical
35
+ source→sink unchanged; no new sink call sites added. Out of #28
36
+ surgical scope. Tracked in **dmsdc-ai/aigentry-telepty#26** for
37
+ follow-up PR.
38
+
39
+ ## [0.4.1] - 2026-05-17
40
+
41
+ ### Fixed
42
+
43
+ - **#25** — Windows PATHEXT resolution for `telepty allow`. npm-global CLIs
44
+ (`claude`, `codex`, `gemini`) now spawn correctly with bare names on
45
+ Windows. Previously `telepty allow … claude` failed with
46
+ `Cannot create process, error code: 2` (ERROR_FILE_NOT_FOUND) because
47
+ node-pty's `CreateProcessW` does not walk `%PATHEXT%` the way `cmd.exe`
48
+ does, so the npm-global `claude.cmd` shim was unreachable from the bare
49
+ name. New: `src/win-resolve-executable.js` resolver (Windows-only branch
50
+ walks `PATH` × `PATHEXT`; POSIX no-op) + 14 unit tests. macOS/Linux
51
+ behavior unchanged.
52
+
53
+ ### Notes
54
+
55
+ - **Snyk SAST scan on changed files** — `src/win-resolve-executable.js`
56
+ + `test/win-resolve-executable.test.js` = **0 findings** (At-Inception
57
+ clean). `cli.js` shows **5 pre-existing findings** (2 Medium Command
58
+ Injection at `execSync` L469 + `pty.spawn` L1075, 3 Low Path Traversal
59
+ at L2287/L2289/L2598) verified identical fingerprint vs HEAD~1 — out
60
+ of #25 surgical scope. Tracked in **dmsdc-ai/aigentry-telepty#26** for
61
+ follow-up PR.
62
+
5
63
  ## [0.4.0] — 2026-05-15
6
64
 
7
65
  ### Added — Phase 1 sidecar supervisor spike (M1–M5)
package/cli.js CHANGED
@@ -17,6 +17,7 @@ const { getRuntimeInfo } = require('./runtime-info');
17
17
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
18
18
  const { buildSharedContextPrompt, createSharedContextDescriptor, ensureSharedContextFile } = require('./shared-context');
19
19
  const { runInteractiveSkillInstaller } = require('./skill-installer');
20
+ const { resolveWindowsExecutable } = require('./src/win-resolve-executable');
20
21
  const crossMachine = require('./cross-machine');
21
22
  const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
22
23
  const { FileMailbox } = require('./src/mailbox/index');
@@ -541,7 +542,28 @@ function isRemoteSession(session) {
541
542
 
542
543
  async function resolveSessionTarget(sessionRef, options = {}) {
543
544
  const sessions = options.sessions || await discoverSessions({ silent: true });
544
- return pickSessionTarget(sessionRef, sessions, REMOTE_HOST);
545
+ const target = pickSessionTarget(sessionRef, sessions, REMOTE_HOST);
546
+ // When <id>@<peerName> uses an SSH peer alias (e.g. `winserver`) and the
547
+ // session is not in `sessions` (discovery missed it, ControlMaster expired,
548
+ // or remote has no such session), pickSessionTarget returns a synthetic
549
+ // target with no peerName/remote flag. Detect SSH peer alias here so the
550
+ // caller routes through cross-machine.remoteInject (SSH path) rather than
551
+ // falling into the HTTP fetch path with `http://winserver:3848/...`. #411
552
+ if (
553
+ target &&
554
+ !target.peerName &&
555
+ target.host &&
556
+ target.host !== '127.0.0.1' &&
557
+ !target.host.includes(':') &&
558
+ !target.host.includes('@')
559
+ ) {
560
+ const sshPeer = crossMachine.getSshPeerHandle(target.host);
561
+ if (sshPeer) {
562
+ target.peerName = target.host;
563
+ target.remote = true;
564
+ }
565
+ }
566
+ return target;
545
567
  }
546
568
 
547
569
  async function ensureDaemonRunning(options = {}) {
@@ -1068,7 +1090,10 @@ async function main() {
1068
1090
  }
1069
1091
 
1070
1092
  function spawnChild() {
1071
- child = pty.spawn(command, cmdArgs, {
1093
+ // Windows: walk %PATHEXT% so bare names (`claude`, `codex`, `gemini`)
1094
+ // resolve to their npm-global `.cmd`/`.ps1` shims. POSIX: no-op. (#25)
1095
+ const resolvedCommand = resolveWindowsExecutable(command, process.env);
1096
+ child = pty.spawn(resolvedCommand, cmdArgs, {
1072
1097
  name: 'xterm-256color',
1073
1098
  cols: process.stdout.columns || 80,
1074
1099
  rows: process.stdout.rows || 30,
package/cross-machine.js CHANGED
@@ -37,6 +37,37 @@ function savePeers(data) {
37
37
  // In-memory active peers
38
38
  const activePeers = new Map(); // name -> { target, controlSocket, connectedAt, machineId }
39
39
 
40
+ // File-backed SSH peer enumeration. Required because CLI subprocesses (fresh
41
+ // node procs) start with an empty `activePeers` Map — only the process that
42
+ // called connect() has it populated. peers.json is the cross-process source of
43
+ // truth. controlPath(target) is deterministic, so any process can reuse the
44
+ // ControlMaster socket established by an earlier connect() as long as
45
+ // ControlPersist hasn't expired. See #411.
46
+ function listSshPeers() {
47
+ const peers = loadPeers().peers || {};
48
+ return Object.entries(peers)
49
+ .filter(([, entry]) => getPeerTransport(entry) === 'ssh' && entry && entry.target)
50
+ .map(([name, entry]) => ({
51
+ name,
52
+ target: entry.target,
53
+ machineId: entry.machineId || name,
54
+ lastConnected: entry.lastConnected
55
+ }));
56
+ }
57
+
58
+ function getSshPeerHandle(name) {
59
+ if (activePeers.has(name)) return activePeers.get(name);
60
+ const peers = loadPeers().peers || {};
61
+ const entry = peers[name];
62
+ if (!entry || getPeerTransport(entry) !== 'ssh' || !entry.target) return null;
63
+ return {
64
+ target: entry.target,
65
+ controlSocket: controlPath(entry.target),
66
+ name,
67
+ machineId: entry.machineId || name
68
+ };
69
+ }
70
+
40
71
  function shellQuote(value) {
41
72
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
42
73
  }
@@ -175,14 +206,11 @@ function disconnectAll() {
175
206
  * @returns {Array} sessions with host info
176
207
  */
177
208
  function listRemoteSessions(name) {
178
- const peer = activePeers.get(name);
209
+ const peer = getSshPeerHandle(name);
179
210
  if (!peer) return [];
180
211
 
181
212
  try {
182
- const output = execSync(
183
- `ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty list --json"`,
184
- { timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
185
- );
213
+ const output = runRemoteCommand(peer, 'telepty list --json', { timeout: 10000 });
186
214
  const sessions = JSON.parse(output);
187
215
  return sessions.map(s => ({ ...s, host: peer.target, peerName: name, remote: true }));
188
216
  } catch {
@@ -191,13 +219,21 @@ function listRemoteSessions(name) {
191
219
  }
192
220
 
193
221
  /**
194
- * Discover sessions across all connected peers.
222
+ * Discover sessions across all connected peers, including SSH peers that are
223
+ * persisted in peers.json but not in this process's activePeers Map. Fresh
224
+ * CLI subprocesses depend on the file-backed path — #411.
195
225
  * @returns {Array} all remote sessions
196
226
  */
197
227
  function discoverAllRemoteSessions() {
198
228
  const allSessions = [];
229
+ const seen = new Set();
199
230
  for (const [name] of activePeers) {
200
231
  allSessions.push(...listRemoteSessions(name));
232
+ seen.add(name);
233
+ }
234
+ for (const peer of listSshPeers()) {
235
+ if (seen.has(peer.name)) continue;
236
+ allSessions.push(...listRemoteSessions(peer.name));
201
237
  }
202
238
  return allSessions;
203
239
  }
@@ -206,7 +242,7 @@ function discoverAllRemoteSessions() {
206
242
  * Inject text into a remote session via SSH.
207
243
  */
208
244
  function remoteInject(name, sessionId, prompt, options = {}) {
209
- const peer = activePeers.get(name);
245
+ const peer = getSshPeerHandle(name);
210
246
  if (!peer) return { success: false, error: `Not connected to ${name}` };
211
247
 
212
248
  try {
@@ -227,7 +263,7 @@ function remoteInject(name, sessionId, prompt, options = {}) {
227
263
  }
228
264
 
229
265
  function remoteEnsureSharedContext(name, descriptor) {
230
- const peer = activePeers.get(name);
266
+ const peer = getSshPeerHandle(name);
231
267
  if (!peer) return { success: false, error: `Not connected to ${name}` };
232
268
 
233
269
  try {
@@ -454,6 +490,9 @@ module.exports = {
454
490
  listHttpPeers,
455
491
  listHttpRemoteSessions,
456
492
  discoverHttpRemoteSessions,
493
+ // File-backed SSH peer enumeration (cross-process — #411)
494
+ listSshPeers,
495
+ getSshPeerHandle,
457
496
  getPeerTransport,
458
497
  PEERS_PATH
459
498
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -33,9 +33,9 @@
33
33
  "CHANGELOG.md"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/host-spec.test.js test/cross-host-inject.test.js test/init.test.js && git diff --exit-code tests/snippet-protocol/v1/",
37
- "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/host-spec.test.js test/cross-host-inject.test.js test/init.test.js",
38
- "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/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/host-spec.test.js test/cross-host-inject.test.js test/init.test.js && git diff --exit-code tests/snippet-protocol/v1/",
36
+ "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/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 && git diff --exit-code tests/snippet-protocol/v1/",
37
+ "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/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",
38
+ "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/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/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 && git diff --exit-code tests/snippet-protocol/v1/",
39
39
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
40
40
  },
41
41
  "keywords": [
@@ -38,7 +38,12 @@ function pickSessionTarget(sessionRef, sessions, defaultHost = '127.0.0.1') {
38
38
  }
39
39
 
40
40
  if (parsed.host) {
41
- const exactMatch = sessions.find((session) => session.id === parsed.id && session.host === parsed.host);
41
+ // Match by host or by peerName alias (e.g. `sid@winserver` where
42
+ // winserver is the SSH peer name and session.host is `user@FQDN`). #411
43
+ const exactMatch = sessions.find((session) =>
44
+ session.id === parsed.id &&
45
+ (session.host === parsed.host || session.peerName === parsed.host)
46
+ );
42
47
  return exactMatch || { id: parsed.id, host: parsed.host };
43
48
  }
44
49
 
@@ -0,0 +1,87 @@
1
+ // src/win-resolve-executable.js — Windows PATHEXT-aware executable resolver
2
+ //
3
+ // Fixes #25: `telepty allow ... <bare-command>` on Windows fails with
4
+ // `ERROR_FILE_NOT_FOUND` because node-pty's `CreateProcessW` does not walk
5
+ // `%PATHEXT%` the way cmd.exe shell does. npm-global CLIs install as
6
+ // `<cmd>.cmd` / `<cmd>.ps1`, so the bare name `claude` resolves to nothing.
7
+ //
8
+ // On POSIX this resolver is a no-op (execve handles PATH lookup natively).
9
+ //
10
+ // Exports:
11
+ // resolveWindowsExecutable(command, env = process.env, opts = {})
12
+ // → resolved absolute path on Windows when bare command is found via
13
+ // PATH × PATHEXT walk, the original `command` on POSIX, or throws.
14
+ //
15
+ // Constraints honored:
16
+ // - Constitution §1 lightweight: ≤80 lines, fs + path + process only.
17
+ // - Constitution §2 cross-platform: POSIX behavior unchanged.
18
+ // - Constitution §17 무의존: no new dependencies.
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.JS';
26
+
27
+ function resolveWindowsExecutable(command, env, opts) {
28
+ if (typeof command !== 'string' || command.length === 0) {
29
+ throw new Error('telepty: resolveWindowsExecutable requires a non-empty command');
30
+ }
31
+ const e = env || process.env;
32
+ const o = opts || {};
33
+ const platform = o.platform || process.platform;
34
+ const existsSync = o.existsSync || fs.existsSync;
35
+
36
+ if (platform !== 'win32') return command;
37
+
38
+ // Always use win32 path semantics in the Windows branch so behavior is
39
+ // identical when the resolver runs on a Windows host AND when tests mock
40
+ // `platform: 'win32'` on a POSIX host.
41
+ const p = path.win32;
42
+
43
+ // Already extension-bearing absolute path → trust if it exists.
44
+ if (p.isAbsolute(command)) {
45
+ return existsSync(command) ? command : tryWithExt(command, e, existsSync, command);
46
+ }
47
+
48
+ // Contains a separator → resolve relative to cwd, then try with extensions.
49
+ if (command.includes('\\') || command.includes('/')) {
50
+ const abs = p.resolve(command);
51
+ return existsSync(abs) ? abs : tryWithExt(abs, e, existsSync, command);
52
+ }
53
+
54
+ // Bare name → walk PATH × PATHEXT.
55
+ const exts = parseExts(e.PATHEXT || DEFAULT_PATHEXT);
56
+ const dirs = (e.PATH || '').split(';').filter(Boolean);
57
+ for (const dir of dirs) {
58
+ for (const ext of exts) {
59
+ const candidate = p.join(dir, command + ext);
60
+ if (existsSync(candidate)) return candidate;
61
+ }
62
+ }
63
+ throw new Error(
64
+ `telepty: cannot find executable "${command}" on PATH (Windows PATHEXT walk failed)`
65
+ );
66
+ }
67
+
68
+ function tryWithExt(absPath, env, existsSync, originalCommand) {
69
+ const exts = parseExts(env.PATHEXT || DEFAULT_PATHEXT);
70
+ for (const ext of exts) {
71
+ if (ext === '') continue;
72
+ const candidate = absPath + ext;
73
+ if (existsSync(candidate)) return candidate;
74
+ }
75
+ throw new Error(
76
+ `telepty: cannot find executable "${originalCommand}" (Windows PATHEXT walk failed)`
77
+ );
78
+ }
79
+
80
+ function parseExts(pathext) {
81
+ // Always include empty string first so an already-extension-bearing command
82
+ // matches before the PATHEXT-suffixed candidates.
83
+ const list = pathext.split(';').map((s) => s.trim()).filter(Boolean);
84
+ return ['', ...list];
85
+ }
86
+
87
+ module.exports = { resolveWindowsExecutable };