From d65ed84edc7e3c841173e9ad98feade79b407958 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Tue, 8 Sep 2026 15:21:58 +0200 Subject: [PATCH] feat(remote): attach a terminal to a live remote session over tmux The CLI's own descriptor already carries a tmux target (issue #211); this adds the adapter that turns it into a real PTY instead of refusing every remote resume outright. Sized at window height + status lines (measured: the bare height leaves the tmux window one row short after the client detaches) and detached with Ctrl-B d before the local ssh client ends, so the remote session is never left resized or killed. Populates the session-handle seam from #220: host/kind/handle are now set for real, picked up unmodified by the existing getPtyForSession branch. Refs #221 --- .ai/contexts/session-cache.md | 105 ++++++++++++- .ai/contexts/trigger-watcher.md | 7 +- main.js | 266 ++++++++++++++++++-------------- remote-attach.js | 200 ++++++++++++++++++++++++ test/dom-sandbox-toggle.test.js | 4 +- test/remote-attach.test.js | 156 +++++++++++++++++++ 6 files changed, 616 insertions(+), 122 deletions(-) create mode 100644 remote-attach.js create mode 100644 test/remote-attach.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index e2c74e72..6a50e305 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -286,8 +286,109 @@ or deleted from here. and read back through `getRemoteSessions(alias)` (defaults to `[]` for an alias never refreshed). `pruneUnknownAliases()` deletes its entries for any alias no longer declared, the same pass that prunes folder keys, so the map - cannot grow unboundedly across host-list edits. No IPC, no renderer surface - and no attach/injection exist yet for this data — that is issue #212's job. + cannot grow unboundedly across host-list edits. Attach now exists off this + data (issue #221, below); capacity tiers and a liveness badge in the UI + (#218, #212) still don't. + +## Remote hosts — tmux attach (issue #221) + +`open-terminal` no longer refuses every remote session outright. When +`isRemoteFolder(cachedFolder)` is true, it now looks up that session's own +descriptor via `remoteIndexer.getRemoteSessions(alias)` and asks +`remote-attach.js`'s adapter whether it can attach. Only if the descriptor +carries no usable multiplexer field does it still return `REMOTE_READ_ONLY`. +Launching a new remote session (#222) and injection over the messaging socket +(#219) are untouched — this is attach-to-an-already-running-CLI only. + +- **The adapter is indexed on the descriptor's own field, never on host + detection.** `remote-attach.js`'s `createTmuxAttachAdapter().supports(descriptor)` + and `.attach(alias, descriptor)` both key off `descriptor.tmux` — a host + whose CLI never writes that field (no multiplexer, or a different one) is + refused before any ssh call, not probed. **The word "tmux" is confined to + this one file by construction** — `main.js` never inspects `descriptor.tmux` + itself, it only calls `supports()`/`attach()`. A second adapter for a + different multiplexer would slot in beside this one without `main.js` + changing at all. + +- **Sizing rule, measured on tmux 3.6 against a window never pinned to a + size (`window-size latest`):** `cols = window_width`, `rows = window_height + + status_lines`, where `status_lines` is 1 when the `status` option is + `on`, 0 when `off`, and the rendered count otherwise (tmux allows a + multi-line status bar). Attaching at the bare height instead measurably + leaves the window one row short **after the client detaches**, not just + while attached — a client that sized itself to the true height (200x51 on + a 200x50 usable pane) left the window at 200x50 once it left; the naive + 200x50 client left it at 200x49. `parseProbeOutput()` in `remote-attach.js` + applies the correction; `test/remote-attach.test.js` proves it by mutation + (dropping `+ statusLines` reddens 3 of 11 tests). + - `attach -f ignore-size` was tried and rejected: measured to resize the + window anyway. + - `resize-window` was tried and rejected: it sets `window-size manual` on + the window, silently, on a session this app does not own. + +- **The probe and the attach are two separate ssh calls, deliberately not + combined with the mirror's own inventory ssh.** The probe + (`buildProbeCommand`) runs `tmux -L display-message -p -t + '#{window_width}x#{window_height}'`, then a `PROBE_SEP` control-byte separator, then `tmux + -L show-options -A -t status` — one non-interactive ssh + round trip, parsed by `parseProbeOutput`. The attach itself + (`buildAttachCommand`) is `tmux -L attach -t `, run over a + **second**, interactive `ssh -tt …` that becomes the actual PTY — + it cannot be the same call as the probe because the probe must complete and + return a size before the interactive PTY is even spawned. + +- **The `tmux` field's own shape is treated as the socket name too.** The CLI + writes e.g. `"main:@0.%0"` — a `session:window.pane` target string. This + adapter reads the part before `:` (`"main"`) as both the tmux socket + (`-L main`) and the session to query status on, on the assumption the CLI + always names its socket after its session. **This is an assumption, not + something measured against the CLI's own socket-naming code** — if a + future CLI version uses a socket name that differs from the session name, + `show-options -A -t ` would query the wrong (or a nonexistent) + session and this adapter would need a real socket field instead of + deriving one. + +- **`TMUX_FIELD_RE` is the injection guard, not shell quoting.** Same posture + as `remote-hosts.js`'s `isSafeRelPath`: the descriptor field is matched + against `^([A-Za-z0-9._-]{1,64}):(@?\d{1,10}(?:\.%?\d{1,10})?)$` before it + ever reaches a command string, so a field forged to include a semicolon or + backtick is refused outright (`parseTmuxField` returns `null`) rather than + escaped. Descriptor content besides `pid`/`sessionId` is otherwise + untyped — see "Session descriptors ride the same ssh call…" above. + +- **Detach sends Ctrl-B d before ending the local ssh client — it does not + just kill the connection.** `ptyProcess.kill()` on the returned wrapper + writes `DETACH_KEYS` (`\x02d`, tmux's default prefix + detach) to the + attach PTY, waits `DETACH_GRACE_MS` (150 ms) for tmux to process it, then + kills the local `ssh -tt` client. Killing immediately, without the + keystroke, races tmux's own cleanup and risks the same window-corruption + failure mode the sizing rule fixes on the other end. No new IPC or + `main.js` call site was added for this — `stop-session` already calls + `killPty(session, sessionId)` → `session.pty.kill()` through the existing + `pty-ops.js` seam, so the clean detach is just what that seam now reaches. + +- **`main.js`'s onData/onExit wiring (OSC parsing, busy detection, output + buffering, `activeSessions` cleanup) is shared between local spawn and + remote attach.** Extracted into `wireSessionPty(session, sessionId, + ptyProcess)`, called once from the local-spawn tail and once from the new + remote-attach branch — the same code path, not a parallel copy that can + drift. A remote session's `ptyProcess` (from `remote-attach.js`) exposes + the same `write/resize/kill/onData/onExit/pid` shape node-pty does, so + `pty-ops.js` (`writePty`/`resizePty`/`killPty`) and this wiring need no + remote-awareness of their own; `resize()` is a deliberate no-op — see the + sizing rule above for why a remote attach is never resized mid-session. + +- **This is the first thing to populate the session-handle seam from issue + #220** (see `.ai/contexts/trigger-watcher.md`, "Session handle"): a + remote-attach entry sets `host: alias`, `kind: 'remote-attach'`, and + `handle: attachResult.ptyProcess` — the same wrapper object also stored as + `session.pty`. That works without a second object because the wrapper + already exposes `write`/`isAlive` alongside the pty-duck-type methods + (`resize`/`kill`/`onData`/`onExit`/`pid`) `pty-ops.js` and `wireSessionPty` + need; `getPtyForSession` takes it as `session.handle` given, unmodified, + exactly the branch #220 left unexercised. Proven in + `test/remote-attach.test.js` ("pilots the fake remote pty through write() + and kill()") with a bare fake pty, no real node-pty involved. ### `stop()` cancels, `dispose()` ends -- they are not the same thing diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 8dc8d121..00f2e874 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -65,10 +65,9 @@ probes liveness through a `handle` — `{ write(data), isAlive() }` — that `isAlive()` entirely (same as it overrode `defaultIsPtyAlive` before) — tests use this to simulate death without a real dying process. -This is a seam, not a remote implementation: nothing sets `host` to anything -but `null`, and nothing constructs a non-local handle in production. It only -makes the write/liveness paths a property of the entry instead of an -assumption baked into `trigger-watcher.js`. +Was a seam only: as of issue #221, `main.js`'s tmux-attach branch is the +first production caller to set a non-null `host` and a real `session.handle` +— see `.ai/contexts/session-cache.md`, "Remote hosts — tmux attach". ## The submission contract diff --git a/main.js b/main.js index 9c43a176..cc7b0167 100644 --- a/main.js +++ b/main.js @@ -80,6 +80,7 @@ const { setPtyOpLogger, resizePty, killPty } = require('./pty-ops'); const { createComposerState } = require('./composer-state'); const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); +const { createTmuxAttachAdapter } = require('./remote-attach'); setPtyOpLogger(log); @@ -458,7 +459,7 @@ const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCache const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); // --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md --- -const { isRemoteFolder } = require('./remote-hosts'); +const { isRemoteFolder, parseFolderKey } = require('./remote-hosts'); const REMOTE_READ_ONLY = 'remote sessions are read-only — this build observes them, it does not attach to them'; const { createSshTransport } = require('./remote-transport'); const { createRemoteIndexer } = require('./remote-index'); @@ -477,6 +478,12 @@ const remoteIndexer = createRemoteIndexer({ log, }); +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +const remoteAttachAdapter = createTmuxAttachAdapter({ + spawnPty: (file, args, ptyOpts) => spawnPty(file, args, { ...ptyOpts, cwd: os.homedir(), env: cleanPtyEnv }), + log, +}); + /** Directory holding a folder key's transcripts, local or mirrored. */ function projectsDirForFolder(folder) { return resolveFolderDir(folder); @@ -1836,6 +1843,122 @@ function sandboxBindEnv(dirs) { return usable.length ? usable.join(':') : undefined; } +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +function wireSessionPty(session, sessionId, ptyProcess) { + ptyProcess.onData(data => { + const currentId = session.realSessionId || sessionId; + + // Parse OSC sequences (title changes, progress, notifications, etc.) + if (data.includes('\x1b]')) { + const oscMatches = data.matchAll(/\x1b\](\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/g); + for (const m of oscMatches) { + const code = m[1]; + const payload = m[2].slice(0, 120); + // Detect Claude CLI busy state from the OSC 0 title — see .ai/contexts/ipc-bridge.md + if (code === '0') { + const { busy: isBusy, idle: isIdle, via } = classifyTitleActivity(payload, { allowFallback: !session.isPlainTerminal }); + log.debug(`[OSC 0] session=${currentId} cp=${codePoints(payload, 1)} rule=${via} busy=${isBusy} idle=${isIdle} wasBusy=${!!session._cliBusy}`); + if (TRACE.on) trace('osc.title', currentId, { cp: codePoints(payload, 3), title: payload.slice(0, 60), busy: isBusy, idle: isIdle, rule: via, was: !!session._cliBusy, decision: busyDecision(isBusy, isIdle, !!session._cliBusy) }); + if (isBusy && !session._cliBusy) { + session._cliBusy = true; + session._oscIdle = false; + log.debug(`[OSC 0] session=${currentId} → BUSY`); + if (TRACE.on) trace('busy.emit', currentId, { busy: true, via: 'osc0', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli-busy-state', currentId, true); + } + } else if (isIdle && session._cliBusy) { + session._cliBusy = false; + session._oscIdle = true; + log.debug(`[OSC 0] session=${currentId} → IDLE`); + if (TRACE.on) trace('busy.emit', currentId, { busy: false, via: 'osc0', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli-busy-state', currentId, false); + } + } + } + } + // Parse iTerm2 OSC 9 sequences (terminated by BEL \x07 or ST \x1b\\) + const osc9Matches = data.matchAll(/\x1b\]9;([^\x07\x1b]*)(?:\x07|\x1b\\)/g); + for (const osc9 of osc9Matches) { + const payload = osc9[1]; + // OSC 9;4 progress: 4;0; = clear/done, 4;1;N = running at N%, 4;2;N = error, 4;3; = indeterminate + if (payload.startsWith('4;')) { + const level = payload.split(';')[1]; + if (level === '0') continue; // 4;0 is also used for clearing, making it unreliable as an idle signal + log.debug(`[OSC 9;4] session=${currentId} level=${level} payload="${payload}" wasBusy=${!!session._cliBusy}`); + if (TRACE.on) trace('osc.progress', currentId, { level, payload: payload.slice(0, 60), was: !!session._cliBusy, decision: progressDecision(level, !!session._cliBusy) }); + if ((level === '1' || level === '2' || level === '3') && !session._cliBusy) { + session._cliBusy = true; + session._oscIdle = false; + log.debug(`[OSC 9;4] session=${currentId} → BUSY`); + if (TRACE.on) trace('busy.emit', currentId, { busy: true, via: 'osc9.4', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli-busy-state', currentId, true); + } + } + } else { + // Regular notification (attention, permission, etc.) + log.info(`[OSC 9] session=${currentId} message="${payload}"`); + if (TRACE.on) trace('osc.notify', currentId, { message: payload.slice(0, 120), sent: !!(mainWindow && !mainWindow.isDestroyed()) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('terminal-notification', currentId, payload); + } + } + } + } + + // Standalone BEL (not part of an OSC sequence) + if (data.includes('\x07') && !data.includes('\x1b]')) { + log.info(`[BEL] session=${currentId}`); + } + + // Track alternate screen mode (only if data contains the marker) + if (data.includes('\x1b[?')) { + if (data.includes('\x1b[?1049h') || data.includes('\x1b[?47h')) { + session.altScreen = true; + log.info(`[altscreen] session=${currentId} ON`); + } + if (data.includes('\x1b[?1049l') || data.includes('\x1b[?47l')) { + session.altScreen = false; + log.info(`[altscreen] session=${currentId} OFF`); + } + } + + // Buffer output (skip resize-triggered redraws for plain terminals) + if (!session._suppressBuffer) { + appendToOutputBuffer(session, data, MAX_BUFFER_SIZE); + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('terminal-data', currentId, data); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + session.exited = true; + // Clean up MCP server + const mcpId = session.realSessionId || sessionId; + shutdownMcpServer(mcpId); + session.mcpServer = null; + + const realId = session.realSessionId || sessionId; + if (TRACE.on) trace('pty.exit', realId, { exitCode, alsoUnder: realId !== sessionId ? sessionId : null, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) }); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('process-exited', realId, exitCode); + // If a fork transition re-keyed this session under realId but the PTY + // exited before transition detection ran, also notify the renderer for + // the original sessionId so it doesn't stay stuck as "Running". + if (realId !== sessionId && activeSessions.has(sessionId)) { + mainWindow.webContents.send('process-exited', sessionId, exitCode); + } + } + activeSessions.delete(realId); + // Clean up the original key too in case transition detection hasn't run yet + activeSessions.delete(sessionId); + }); +} + // --- IPC: open-terminal --- ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions, initialSize) => { if (!mainWindow) return { ok: false, error: 'no window' }; @@ -1865,11 +1988,35 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se return { ok: true, reattached: true, mcpActive: !!session.mcpServer, sandbox: !!session.sandbox }; } - // A mirrored transcript has no local cwd to resume in. see .ai/contexts/session-cache.md ("Remote SSH hosts") + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") if (!isNew) { let cachedFolder = null; try { cachedFolder = getCachedFolder(sessionId); } catch {} - if (isRemoteFolder(cachedFolder)) return { ok: false, error: REMOTE_READ_ONLY }; + if (isRemoteFolder(cachedFolder)) { + const { alias } = parseFolderKey(cachedFolder); + const descriptor = remoteIndexer.getRemoteSessions(alias).find(s => s.sessionId === sessionId); + const attachResult = descriptor + ? await remoteAttachAdapter.attach(alias, descriptor) + : { ok: false, error: REMOTE_READ_ONLY }; + if (!attachResult.ok) return { ok: false, error: attachResult.error || REMOTE_READ_ONLY }; + + const remoteCwd = (descriptor && typeof descriptor.cwd === 'string') ? descriptor.cwd : null; + const remoteSession = { + pty: attachResult.ptyProcess, + // handle: {write, isAlive} — see .ai/contexts/trigger-watcher.md, "Session handle" + handle: attachResult.ptyProcess, + host: alias, kind: 'remote-attach', + rendererAttached: true, exited: false, + outputBuffer: [], outputBufferSize: 0, altScreen: false, + projectPath, firstResize: true, + cwd: remoteCwd, + isPlainTerminal: false, + _openedAt: Date.now(), + }; + activeSessions.set(sessionId, remoteSession); + wireSessionPty(remoteSession, sessionId, attachResult.ptyProcess); + return { ok: true, reattached: false, remote: true, sandbox: false }; + } } // For a Claude resume, spawn in the session's real recorded cwd (e.g. its @@ -2149,118 +2296,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se if (typeof retry.unref === 'function') retry.unref(); } - ptyProcess.onData(data => { - const currentId = session.realSessionId || sessionId; - - // Parse OSC sequences (title changes, progress, notifications, etc.) - if (data.includes('\x1b]')) { - const oscMatches = data.matchAll(/\x1b\](\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/g); - for (const m of oscMatches) { - const code = m[1]; - const payload = m[2].slice(0, 120); - // Detect Claude CLI busy state from the OSC 0 title — see .ai/contexts/ipc-bridge.md - if (code === '0') { - const { busy: isBusy, idle: isIdle, via } = classifyTitleActivity(payload, { allowFallback: !session.isPlainTerminal }); - log.debug(`[OSC 0] session=${currentId} cp=${codePoints(payload, 1)} rule=${via} busy=${isBusy} idle=${isIdle} wasBusy=${!!session._cliBusy}`); - if (TRACE.on) trace('osc.title', currentId, { cp: codePoints(payload, 3), title: payload.slice(0, 60), busy: isBusy, idle: isIdle, rule: via, was: !!session._cliBusy, decision: busyDecision(isBusy, isIdle, !!session._cliBusy) }); - if (isBusy && !session._cliBusy) { - session._cliBusy = true; - session._oscIdle = false; - log.debug(`[OSC 0] session=${currentId} → BUSY`); - if (TRACE.on) trace('busy.emit', currentId, { busy: true, via: 'osc0', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('cli-busy-state', currentId, true); - } - } else if (isIdle && session._cliBusy) { - session._cliBusy = false; - session._oscIdle = true; - log.debug(`[OSC 0] session=${currentId} → IDLE`); - if (TRACE.on) trace('busy.emit', currentId, { busy: false, via: 'osc0', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('cli-busy-state', currentId, false); - } - } - } - } - // Parse iTerm2 OSC 9 sequences (terminated by BEL \x07 or ST \x1b\\) - const osc9Matches = data.matchAll(/\x1b\]9;([^\x07\x1b]*)(?:\x07|\x1b\\)/g); - for (const osc9 of osc9Matches) { - const payload = osc9[1]; - // OSC 9;4 progress: 4;0; = clear/done, 4;1;N = running at N%, 4;2;N = error, 4;3; = indeterminate - if (payload.startsWith('4;')) { - const level = payload.split(';')[1]; - if (level === '0') continue; // 4;0 is also used for clearing, making it unreliable as an idle signal - log.debug(`[OSC 9;4] session=${currentId} level=${level} payload="${payload}" wasBusy=${!!session._cliBusy}`); - if (TRACE.on) trace('osc.progress', currentId, { level, payload: payload.slice(0, 60), was: !!session._cliBusy, decision: progressDecision(level, !!session._cliBusy) }); - if ((level === '1' || level === '2' || level === '3') && !session._cliBusy) { - session._cliBusy = true; - session._oscIdle = false; - log.debug(`[OSC 9;4] session=${currentId} → BUSY`); - if (TRACE.on) trace('busy.emit', currentId, { busy: true, via: 'osc9.4', sent: !!(mainWindow && !mainWindow.isDestroyed()) }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('cli-busy-state', currentId, true); - } - } - } else { - // Regular notification (attention, permission, etc.) - log.info(`[OSC 9] session=${currentId} message="${payload}"`); - if (TRACE.on) trace('osc.notify', currentId, { message: payload.slice(0, 120), sent: !!(mainWindow && !mainWindow.isDestroyed()) }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('terminal-notification', currentId, payload); - } - } - } - } - - // Standalone BEL (not part of an OSC sequence) - if (data.includes('\x07') && !data.includes('\x1b]')) { - log.info(`[BEL] session=${currentId}`); - } - - // Track alternate screen mode (only if data contains the marker) - if (data.includes('\x1b[?')) { - if (data.includes('\x1b[?1049h') || data.includes('\x1b[?47h')) { - session.altScreen = true; - log.info(`[altscreen] session=${currentId} ON`); - } - if (data.includes('\x1b[?1049l') || data.includes('\x1b[?47l')) { - session.altScreen = false; - log.info(`[altscreen] session=${currentId} OFF`); - } - } - - // Buffer output (skip resize-triggered redraws for plain terminals) - if (!session._suppressBuffer) { - appendToOutputBuffer(session, data, MAX_BUFFER_SIZE); - } - - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('terminal-data', currentId, data); - } - }); - - ptyProcess.onExit(({ exitCode }) => { - session.exited = true; - // Clean up MCP server - const mcpId = session.realSessionId || sessionId; - shutdownMcpServer(mcpId); - session.mcpServer = null; - - const realId = session.realSessionId || sessionId; - if (TRACE.on) trace('pty.exit', realId, { exitCode, alsoUnder: realId !== sessionId ? sessionId : null, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('process-exited', realId, exitCode); - // If a fork transition re-keyed this session under realId but the PTY - // exited before transition detection ran, also notify the renderer for - // the original sessionId so it doesn't stay stuck as "Running". - if (realId !== sessionId && activeSessions.has(sessionId)) { - mainWindow.webContents.send('process-exited', sessionId, exitCode); - } - } - activeSessions.delete(realId); - // Clean up the original key too in case transition detection hasn't run yet - activeSessions.delete(sessionId); - }); + wireSessionPty(session, sessionId, ptyProcess); if (sessionOptions?.forkFrom) { log.info(`[fork-spawn] tempId=${sessionId} forkFrom=${sessionOptions.forkFrom} folder=${projectFolder} knownFiles=${knownJsonlFiles.size}`); diff --git a/remote-attach.js b/remote-attach.js new file mode 100644 index 00000000..4de760a6 --- /dev/null +++ b/remote-attach.js @@ -0,0 +1,200 @@ +// remote-attach.js — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +'use strict'; + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") + +const TMUX_FIELD_RE = /^([A-Za-z0-9._-]{1,64}):(@?\d{1,10}(?:\.%?\d{1,10})?)$/; +const PROBE_SEP = '\u0001'; +const DETACH_KEYS = '\x02d'; // Ctrl-B d — tmux default prefix, then detach +const DETACH_GRACE_MS = 150; +const DEFAULT_PROBE_TIMEOUT_MS = 15000; +const DEFAULT_STATUS_LINES = 1; + +/** Parse the CLI-written `tmux` descriptor field, e.g. "main:@0.%0". */ +function parseTmuxField(value) { + if (typeof value !== 'string') return null; + const m = TMUX_FIELD_RE.exec(value); + if (!m) return null; + return { socket: m[1], target: value }; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", sizing rule) +function parseProbeOutput(stdout) { + const text = typeof stdout === 'string' ? stdout : ''; + const idx = text.indexOf(PROBE_SEP); + const sizePart = idx === -1 ? text : text.slice(0, idx); + const statusPart = idx === -1 ? '' : text.slice(idx + PROBE_SEP.length); + + const sizeMatch = /(\d+)x(\d+)/.exec(sizePart); + if (!sizeMatch) return null; + const width = Number.parseInt(sizeMatch[1], 10); + const height = Number.parseInt(sizeMatch[2], 10); + + let statusLines = DEFAULT_STATUS_LINES; + const statusMatch = /status\s+(\S+)/.exec(statusPart); + if (statusMatch) { + if (statusMatch[1] === 'off') statusLines = 0; + else if (statusMatch[1] === 'on') statusLines = 1; + else { + const n = Number.parseInt(statusMatch[1], 10); + if (Number.isFinite(n) && n >= 0) statusLines = n; + } + } + + return { cols: width, rows: height + statusLines }; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", injection guard) +function buildProbeCommand(parsed) { + return `tmux -L ${parsed.socket} display-message -p -t ${parsed.target} '#{window_width}x#{window_height}'` + + `; printf '${PROBE_SEP}'; tmux -L ${parsed.socket} show-options -A -t ${parsed.socket} status 2>/dev/null`; +} + +function buildAttachCommand(parsed) { + return `tmux -L ${parsed.socket} attach -t ${parsed.target}`; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +function defaultResolveSshPath() { + if (process.env.SWITCHBOARD_SSH_PATH) return process.env.SWITCHBOARD_SSH_PATH; + const fs = require('fs'); + const path = require('path'); + const candidates = process.platform === 'win32' + ? [ + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe'), + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'usr', 'bin', 'ssh.exe'), + ] + : ['/usr/bin/ssh']; + for (const c of candidates) { + try { if (fs.existsSync(c)) return c; } catch {} + } + return 'ssh'; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +function defaultRunRemoteCommand(alias, command, { timeoutMs } = {}) { + const { spawn } = require('child_process'); + return new Promise((resolve) => { + let child; + try { + child = spawn('ssh', ['-o', 'BatchMode=yes', '-n', alias, command], { + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + resolve({ code: -1, stdout: '', stderr: err.message }); + return; + } + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, timeoutMs || DEFAULT_PROBE_TIMEOUT_MS); + const finish = (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, stdout, stderr: stderr.slice(0, 4096) }); + }; + if (child.stdout) child.stdout.on('data', (c) => { stdout += c; }); + if (child.stderr) child.stderr.on('data', (c) => { if (stderr.length < 4096) stderr += c; }); + child.on('error', (err) => { stderr += err.message; finish(-1); }); + child.on('close', (code) => finish(code == null ? -1 : code)); + }); +} + +/** + * Adapter for attaching a local PTY to a remote session through its tmux + * multiplexer. Indexed on the descriptor's `tmux` field, never on host + * detection — a descriptor without it is refused before any ssh call. + * + * @param {object} opts + * @param {function} opts.spawnPty (file, args, ptyOpts) => IPty-like + * { onData, onExit, write, resize, kill, pid } — the caller's own + * node-pty wrapper (conpty selection, env, cwd are the caller's concern). + * @param {function} [opts.runRemoteCommand] (alias, command, {timeoutMs}) + * => Promise<{code, stdout, stderr}> — non-interactive ssh exec, injected + * for tests; defaults to a real ssh child process. + * @param {function} [opts.resolveSshPath] () => string + * @param {object} [opts.log] + */ +function createTmuxAttachAdapter(opts = {}) { + const spawnPtyFn = opts.spawnPty; + if (typeof spawnPtyFn !== 'function') { + throw new Error('createTmuxAttachAdapter requires opts.spawnPty'); + } + const runRemoteCommand = opts.runRemoteCommand || defaultRunRemoteCommand; + const resolveSshPath = opts.resolveSshPath || defaultResolveSshPath; + const log = opts.log || { info() {}, warn() {}, error() {} }; + + /** Whether this descriptor names a multiplexer this adapter can attach to. */ + function supports(descriptor) { + return !!(descriptor && parseTmuxField(descriptor.tmux)); + } + + async function attach(alias, descriptor) { + const parsed = descriptor && parseTmuxField(descriptor.tmux); + if (!parsed) { + return { ok: false, error: 'session carries no tmux target — attach is not supported for this host' }; + } + + let probe; + try { + probe = await runRemoteCommand(alias, buildProbeCommand(parsed), { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS }); + } catch (err) { + return { ok: false, error: `size probe failed: ${err.message}` }; + } + if (!probe || probe.code !== 0) { + const reason = (probe && probe.stderr || '').trim() || 'no stderr'; + return { ok: false, error: `size probe failed (exit ${probe ? probe.code : 'n/a'}): ${reason}` }; + } + const size = parseProbeOutput(probe.stdout); + if (!size) { + return { ok: false, error: 'could not parse the remote window size' }; + } + + const sshPath = resolveSshPath(); + const argv = ['-tt', '-o', 'BatchMode=yes', alias, buildAttachCommand(parsed)]; + + let raw; + try { + raw = spawnPtyFn(sshPath, argv, { name: 'xterm-256color', cols: size.cols, rows: size.rows }); + } catch (err) { + return { ok: false, error: `attach spawn failed: ${err.message}` }; + } + + let alive = true; + raw.onExit(() => { alive = false; }); + + let detaching = false; + function detach() { + if (detaching || !alive) return; + detaching = true; + try { raw.write(DETACH_KEYS); } catch {} + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") + setTimeout(() => { try { raw.kill(); } catch {} }, DETACH_GRACE_MS); + } + + const ptyProcess = { + write(data) { if (alive) raw.write(data); }, + resize() {}, // fixed at attach time — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") + kill: detach, + onData(cb) { return raw.onData(cb); }, + onExit(cb) { return raw.onExit(cb); }, + isAlive() { return alive; }, + get pid() { return raw.pid; }, + }; + + log.info(`[remote-attach:${alias}] attached ${parsed.target} at ${size.cols}x${size.rows}`); + return { ok: true, ptyProcess, cols: size.cols, rows: size.rows }; + } + + return { supports, attach }; +} + +module.exports = { + createTmuxAttachAdapter, + parseTmuxField, + parseProbeOutput, + buildProbeCommand, + buildAttachCommand, + DETACH_KEYS, +}; diff --git a/test/dom-sandbox-toggle.test.js b/test/dom-sandbox-toggle.test.js index b88db158..4a64f889 100644 --- a/test/dom-sandbox-toggle.test.js +++ b/test/dom-sandbox-toggle.test.js @@ -319,7 +319,9 @@ test('sandbox badge: main.js reports the sandbox state on both open-terminal ret const src = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); const returns = src.match(/return \{ ok: true, reattached: (?:true|false)[^}]*\}/g) || []; - assert.equal(returns.length, 2, 'open-terminal has exactly two success returns'); + // Local reattach, local spawn, and remote attach (issue #221) — each must + // report the sandbox state so the renderer badge never reads `undefined`. + assert.equal(returns.length, 3, 'open-terminal has exactly three success returns'); for (const r of returns) { assert.match(r, /sandbox:/, `success return must report sandbox state: ${r}`); } diff --git a/test/remote-attach.test.js b/test/remote-attach.test.js new file mode 100644 index 00000000..2aafa962 --- /dev/null +++ b/test/remote-attach.test.js @@ -0,0 +1,156 @@ +'use strict'; + +// The tmux attach adapter, fully injected: no ssh, no node-pty, no network. +// Three properties matter for issue #221 and each is proven capable of +// catching its own violation (see .ai/contexts/session-cache.md, "Remote +// hosts -- tmux attach"): +// 1. PTY size = window height + status lines, not the bare height. +// 2. A descriptor with no multiplexer field never attempts an attach. +// 3. The returned ptyProcess is pilotable (write/kill) without assuming +// any real local pty underneath it. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('events'); + +const { + createTmuxAttachAdapter, + parseTmuxField, + parseProbeOutput, +} = require('../remote-attach'); + +const PROBE_SEP = ''; +const silentLog = { info() {}, warn() {}, error() {} }; + +/** A minimal IPty-like double: onData/onExit/write/kill/pid. */ +function fakeRawPty() { + const emitter = new EventEmitter(); + const writes = []; + let killed = 0; + const pty = { + write: (d) => writes.push(d), + onData: (cb) => emitter.on('data', cb), + onExit: (cb) => emitter.on('exit', cb), + kill: () => { killed++; emitter.emit('exit', { exitCode: 0 }); }, + pid: 4242, + }; + return { pty, writes, emitter, killedCount: () => killed }; +} + +function makeAdapter({ probeStdout, probeCode = 0, spawnCalls = [], rawPtyFactory } = {}) { + const runRemoteCommand = async () => ({ code: probeCode, stdout: probeStdout || '', stderr: '' }); + const spawnPty = (file, args, ptyOpts) => { + spawnCalls.push({ file, args, ptyOpts }); + return (rawPtyFactory || (() => fakeRawPty().pty))(); + }; + return createTmuxAttachAdapter({ spawnPty, runRemoteCommand, log: silentLog }); +} + +test('parseTmuxField accepts the CLI-written format and rejects the rest', () => { + assert.deepEqual(parseTmuxField('main:@0.%0'), { socket: 'main', target: 'main:@0.%0' }); + assert.equal(parseTmuxField(undefined), null); + assert.equal(parseTmuxField(''), null); + assert.equal(parseTmuxField('no-colon-here'), null); + assert.equal(parseTmuxField('main:@0; rm -rf /'), null, 'shell metacharacters must be refused, not escaped'); +}); + +// Property 1 -- sizing rule. +test('parseProbeOutput sizes rows as height plus status lines (status on)', () => { + assert.deepEqual(parseProbeOutput('200x51' + PROBE_SEP + 'status on'), { cols: 200, rows: 52 }); +}); + +test('parseProbeOutput sizes rows as height plus 0 when status is off', () => { + assert.deepEqual(parseProbeOutput('200x50' + PROBE_SEP + 'status off'), { cols: 200, rows: 50 }); +}); + +test('parseProbeOutput honors a rendered status line count beyond on/off', () => { + assert.deepEqual(parseProbeOutput('200x51' + PROBE_SEP + 'status 2'), { cols: 200, rows: 53 }); +}); + +test('parseProbeOutput returns null when the size cannot be parsed', () => { + assert.equal(parseProbeOutput('garbage'), null); +}); + +// Property 1, through the adapter: the size handed to spawnPty must already +// carry the status-line correction, not the bare tmux window height. +test('attach() spawns the pty at window height plus status lines, never the bare height', async () => { + const spawnCalls = []; + const adapter = makeAdapter({ probeStdout: '200x50' + PROBE_SEP + 'status on', spawnCalls }); + const result = await adapter.attach('vps', { sessionId: 's1', tmux: 'main:@0.%0' }); + + assert.equal(result.ok, true); + assert.equal(spawnCalls.length, 1); + assert.deepEqual( + { cols: spawnCalls[0].ptyOpts.cols, rows: spawnCalls[0].ptyOpts.rows }, + { cols: 200, rows: 51 }, + 'a client attaching at the bare tmux height (50) leaves the window one row short after detach -- see .ai/contexts/session-cache.md', + ); +}); + +// Property 2 -- a descriptor naming no multiplexer is refused up front. +test('attach() refuses a descriptor with no tmux field, before any ssh call', async () => { + const spawnCalls = []; + let probeCalls = 0; + const runRemoteCommand = async () => { probeCalls++; return { code: 0, stdout: '' }; }; + const adapter = createTmuxAttachAdapter({ + spawnPty: (...args) => { spawnCalls.push(args); return fakeRawPty().pty; }, + runRemoteCommand, + log: silentLog, + }); + + const result = await adapter.attach('vps', { sessionId: 's1' }); + + assert.equal(result.ok, false); + assert.match(result.error, /tmux/i); + assert.equal(probeCalls, 0, 'no size probe should ever be sent for a host with no declared multiplexer'); + assert.equal(spawnCalls.length, 0, 'no attach pty should ever be spawned for a host with no declared multiplexer'); +}); + +test('supports() reports false for a descriptor without a usable tmux field', () => { + const adapter = makeAdapter({}); + assert.equal(adapter.supports({ sessionId: 's1' }), false); + assert.equal(adapter.supports({ sessionId: 's1', tmux: 'not valid' }), false); + assert.equal(adapter.supports({ sessionId: 's1', tmux: 'main:@0.%0' }), true); +}); + +// Property 3 -- the returned ptyProcess is pilotable without any real local +// node-pty: writes reach the underlying process, and kill() detaches cleanly +// (Ctrl-B d) before ending the local ssh client, rather than killing outright. +test('the returned ptyProcess pilots the fake remote pty through write() and kill()', async () => { + const raw = fakeRawPty(); + const adapter = makeAdapter({ probeStdout: '200x50' + PROBE_SEP + 'status on', rawPtyFactory: () => raw.pty }); + const result = await adapter.attach('vps', { sessionId: 's1', tmux: 'main:@0.%0' }); + + assert.equal(result.ok, true); + const { ptyProcess } = result; + + ptyProcess.write('echo hi\n'); + assert.deepEqual(raw.writes, ['echo hi\n'], 'write() must reach the underlying process verbatim'); + assert.equal(ptyProcess.isAlive(), true); + assert.equal(ptyProcess.pid, 4242); + + ptyProcess.kill(); + // Detach sends Ctrl-B d before ending the local client -- see DETACH_KEYS. + assert.equal(raw.writes[raw.writes.length - 1], '\x02d', 'kill() must send the tmux detach sequence, not just end the process'); + assert.equal(raw.killedCount(), 0, 'the local client must not be ended immediately -- see the detach grace period'); + + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(raw.killedCount(), 1, 'the local client must be ended once the detach keystroke has had time to land'); + assert.equal(ptyProcess.isAlive(), false); +}); + +test('resize() is a no-op -- a fixed-size attach is never resized mid-session', async () => { + const raw = fakeRawPty(); + const adapter = makeAdapter({ probeStdout: '200x50' + PROBE_SEP + 'status on', rawPtyFactory: () => raw.pty }); + const { ptyProcess } = await adapter.attach('vps', { sessionId: 's1', tmux: 'main:@0.%0' }); + assert.doesNotThrow(() => ptyProcess.resize(80, 24)); + assert.deepEqual(raw.writes, []); +}); + +test('attach() surfaces a failed size probe without spawning anything', async () => { + const spawnCalls = []; + const adapter = makeAdapter({ probeCode: 1, spawnCalls }); + const result = await adapter.attach('vps', { sessionId: 's1', tmux: 'main:@0.%0' }); + assert.equal(result.ok, false); + assert.equal(spawnCalls.length, 0); +});