From 91011d59d5062feaf6255a32aa33b2a182127648 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:21:19 +0200 Subject: [PATCH 1/4] (remote): send a prompt to an unattached remote session A live remote session that is not open in a terminal can now be given a prompt from its row. The text is written as one NDJSON line to the session's messaging socket through a single ssh, on stdin only, so it never reaches a command line. The socket path comes from the descriptor on the main side; the renderer sends only alias, session id and text. Refs #219 --- .ai/contexts/session-cache.md | 45 +++ CHANGELOG.md | 3 + docs/remote-hosts.md | 26 ++ eslint.config.js | 1 + main.js | 15 + preload.js | 1 + public/dialogs.js | 67 +++++ public/sidebar.js | 14 + public/style.css | 39 +++ remote-attach.js | 22 +- remote-send.js | 154 ++++++++++ test/dom-send-prompt-dialog.test.js | 105 +++++++ test/dom-sidebar-remote-send.test.js | 64 ++++ test/remote-run-input.test.js | 93 ++++++ test/remote-send.test.js | 431 +++++++++++++++++++++++++++ 15 files changed, 1073 insertions(+), 7 deletions(-) create mode 100644 remote-send.js create mode 100644 test/dom-send-prompt-dialog.test.js create mode 100644 test/dom-sidebar-remote-send.test.js create mode 100644 test/remote-run-input.test.js create mode 100644 test/remote-send.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index a02d197d..1388a2c2 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -744,6 +744,51 @@ created the `.jsonl`; a manual host refresh did not help. rel path, not its own, because `readSubagentMeta()` in the transcript's row is what actually needs re-deriving. +## Remote hosts — sending a prompt (issue #219) + +`remote-send.js` writes one prompt to a live, unattached remote session through +the CLI's own messaging socket. Send only: nothing is read back, the state comes +from the descriptor the refresh cycle already pulls. + +- **Protocol** (measured in the issue, CLI 2.1.263): NDJSON over a unix socket, + one line `{"type":"user","message":{"role":"user","content":...},"msgV":1,"session_id":...}` + terminated by ` +`, capped at 1 MiB, first line within 30 s. The connection is + one-way; the server never answers on it. No auth line on POSIX (the peer is + identified by `SO_PEERCRED`); on Windows the token lives in a `.key` file that + the descriptor fetch and the denylist exclude on purpose, so a `\.\pipe\` + path is refused, not worked around. +- **`session_id` is in the line** so a descriptor that outlived its process, whose + pid was reused, never has its prompt accepted by another session. +- **The text is stdin only.** `defaultRunRemoteCommand` takes an `input` option: + stdin becomes a pipe, `-n` (which points ssh's stdin at the null device) is + dropped, the line is written and stdin closed. Same spawn site as every other + remote ssh, so `remote-ssh-spawn-sites.test.js` is unchanged. The remote + command holds fixed text, the integer pid and the single-quoted path. +- **The path is main-side only.** `messagingSocketPath` stays in the descriptor + `parseSessions` keeps; the renderer sends `{alias, sessionId, text}` and + `handleSendRequest` looks the descriptor up. `validateSocketPath` is stricter + than `isSafeSocketPath` (which also guards tmux sockets): `^/[A-Za-z0-9._/-]+\.sock$`, + no `..`, at most 107 bytes (`sockaddr_un`). `buildSendCommand` throws on a path + it would refuse. +- **Exit codes** of the remote command: 7 the pid is no longer a `claude` + process, 8 the socket is gone, 127 no `ncat`/`nc`. Anything else is a failure + carrying ssh's stderr. A timeout (nc did not exit after the line was written) + is a failure saying nothing confirms the write, never a success. +- **30 s dedupe** is client-side and per host, session and text; it is armed only + by a send that succeeded, on an injectable clock. The server also has a + 30-token bucket refilling at 0.5/s; nothing here retries. +- **Entry point**: the `session-send-btn` on remote rows (CSS-gated like Stop: + shown for `.is-alive` and not `.has-running-pty`), and `showSendPromptDialog` + in `public/dialogs.js`. An attached session is refused main-side as well. +- Not done, on purpose: replies and idle notification (they need an inbox of our + own and a published key), Windows hosts, trigger files targeting remote ids, + and the attention state. +- Tests: `remote-send.test.js` (the line, the path, the command run through a real + `sh` with a fake `nc`, exit codes, byte cap, dedupe, IPC contract), + `remote-run-input.test.js`, `dom-sidebar-remote-send.test.js`, + `dom-send-prompt-dialog.test.js`. + ## Remote hosts — tmux attach (issue #221) `open-terminal` no longer refuses every remote session outright. When diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..e14aedb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New +- A live session on a remote host that is not open in a terminal has a Send a prompt… button on its row: type a text and it is written to the running session as a new prompt, without attaching. It needs `ncat` or an OpenBSD `nc` on the host, and is refused for a Windows host. The dialog says "Sent": the session's own status shows whether it picked the prompt up. (#219) + ## v0.0.86 — 2026-10-01 ### New diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 909e4193..3850c468 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -156,6 +156,32 @@ works, read-only, by running git over ssh in the session's directory. - **Delete** is refused: the mirrored transcript is a copy that the next pull would fetch again. +## Send a prompt + +A live session that is not attached in a terminal has a **Send a prompt…** +button next to Stop. It opens a small dialog; Send (or Ctrl+Enter) writes the +text to the running session as a new prompt. The dialog says *Sent*, never +*delivered*: nothing comes back on that channel, so Switchboard cannot know the +session read it. Read the result in the row's status, which the next refresh +picks up from the session's descriptor. + +How it works: the session's descriptor names a messaging socket +(`messagingSocketPath`). Switchboard runs one `ssh` to the host, checks that the +pid is still a `claude` process and that the socket exists, then pipes a single +line of JSON into the socket with `ncat --send-only -U` or `nc -N -U`. The text +travels on ssh's standard input only, never on a command line. + +- The host needs `ncat` or an OpenBSD `nc` that supports `-U` and closes on end + of input. A BusyBox `nc` has no `-U`; the dialog then says nc was not found. +- The socket path is read from the descriptor on the host, never typed or sent + by the interface, and must be an absolute `.sock` path of plain characters. +- A prompt is limited to 1 MiB once encoded. The same text sent to the same + session twice within 30 seconds is refused here, because the session would + drop it. +- A host running Windows is refused: its channel needs the session's key file, + which Switchboard does not read. +- A session attached in a terminal is refused: type in the terminal. + ## Known limits Session ids are not namespaced per host. Two hosts with a session of the same diff --git a/eslint.config.js b/eslint.config.js index 6a2a948c..19d5c026 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -121,6 +121,7 @@ const rendererCrossFileGlobals = { renderActivityTraceFiles: 'readonly', openActivityTraceFile: 'readonly', showResumeSessionDialog: 'readonly', + showSendPromptDialog: 'readonly', showJsonlViewer: 'readonly', showSubagentTranscript: 'readonly', narrowSessionsToSearch: 'readonly', diff --git a/main.js b/main.js index 795ca5e8..c811d2a5 100644 --- a/main.js +++ b/main.js @@ -80,6 +80,7 @@ const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); +const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send'); const { createGitChangesRunner } = require('./git-changes-runner'); const gitChangesTarget = require('./git-changes-target'); const terminalPathTarget = require('./terminal-path-target'); @@ -552,6 +553,9 @@ const remoteAttachAdapter = createTmuxAttachAdapter({ // see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") const remoteStopAdapter = createRemoteStopAdapter({ log }); +// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +const remoteSendAdapter = createRemoteSendAdapter({ log }); + // Joins the sidebar's remote sessions to the indexer's live descriptors so the // renderer can route a click without ever naming an attach mechanism itself // — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach"). @@ -1703,6 +1707,17 @@ ipcMain.handle('remote-stop-session', async (_event, payload) => { return result; }); +// --- IPC: remote-send-prompt --- +// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +ipcMain.handle('remote-send-prompt', (_event, payload) => handleSendRequest(payload, { + getDescriptor: (alias, sessionId) => remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId), + isAttached: (sessionId) => { + const attached = activeSessions.get(sessionId); + return !!(attached && attached.kind === 'remote-attach' && !attached.exited); + }, + adapter: remoteSendAdapter, +})); + // --- IPC: git-changes-status / git-changes-diff — see .ai/contexts/changes-view.md --- function resolveGitChangesTarget(sessionId) { return gitChangesTarget.resolveGitChangesTarget(sessionId, { diff --git a/preload.js b/preload.js index c2dd3d0d..1efcb553 100644 --- a/preload.js +++ b/preload.js @@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('api', { stopSession: (id) => ipcRenderer.invoke('stop-session', id), // see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") remoteStopSession: (alias, sessionId) => ipcRenderer.invoke('remote-stop-session', { alias, sessionId }), + remoteSendPrompt: (alias, sessionId, text) => ipcRenderer.invoke('remote-send-prompt', { alias, sessionId, text }), toggleStar: (id) => ipcRenderer.invoke('toggle-star', id), renameSession: (id, name) => ipcRenderer.invoke('rename-session', id, name), archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived), diff --git a/public/dialogs.js b/public/dialogs.js index 7eb13182..46349bad 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -448,6 +448,73 @@ async function showResumeSessionDialog(session) { document.addEventListener('keydown', onKey); } +// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +function showSendPromptDialog(session) { + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'new-session-dialog'; + + const title = document.createElement('h3'); + title.textContent = 'Send a prompt — ' + session.remoteAlias; + const textarea = document.createElement('textarea'); + textarea.className = 'send-prompt-textarea'; + textarea.rows = 6; + textarea.spellcheck = false; + textarea.placeholder = 'The text is written to the running session as a new prompt'; + const status = document.createElement('div'); + status.className = 'send-prompt-status'; + const actions = document.createElement('div'); + actions.className = 'new-session-actions'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'new-session-cancel-btn'; + cancelBtn.textContent = 'Close'; + const sendBtn = document.createElement('button'); + sendBtn.className = 'new-session-start-btn send-prompt-send-btn'; + sendBtn.textContent = 'Send'; + actions.append(cancelBtn, sendBtn); + dialog.append(title, textarea, status, actions); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + textarea.focus(); + + function close() { + overlay.remove(); + document.removeEventListener('keydown', onKey); + } + + async function send() { + const text = textarea.value; + if (!text.trim() || sendBtn.disabled) return; + sendBtn.disabled = true; + status.textContent = 'Sending…'; + let result; + try { + result = await window.api.remoteSendPrompt(session.remoteAlias, session.sessionId, text); + } catch (err) { + result = { ok: false, error: err && err.message ? err.message : 'unknown error' }; + } + sendBtn.disabled = false; + if (result && result.ok) { + textarea.value = ''; + status.textContent = 'Sent. The session reads it when it is next free.'; + } else { + status.textContent = (result && result.error) || 'unknown error'; + } + } + + cancelBtn.onclick = close; + sendBtn.onclick = send; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + function onKey(e) { + if (e.key === 'Escape') close(); + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send(); + } + document.addEventListener('keydown', onKey); +} + // Settings viewer is in settings-panel.js (openSettingsViewer / closeSettingsViewer) // Global settings button & add project button bindings are in app.js (need DOM refs) diff --git a/public/sidebar.js b/public/sidebar.js index 3a448323..80a9883d 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1272,6 +1272,14 @@ function rebindSidebarEvents(projects) { }; } + const sendBtn = item.querySelector('.session-send-btn'); + if (sendBtn) { + sendBtn.onclick = (e) => { + e.stopPropagation(); + showSendPromptDialog(session); + }; + } + const launchConfigBtn = item.querySelector('.session-launch-config-btn'); if (launchConfigBtn) { launchConfigBtn.onclick = (e) => { @@ -1475,6 +1483,11 @@ function buildSessionItem(session) { stopBtn.title = 'Stop session'; stopBtn.innerHTML = ''; + const sendBtn = document.createElement('button'); + sendBtn.className = 'session-send-btn'; + sendBtn.title = 'Send a prompt…'; + sendBtn.innerHTML = ''; + const archiveBtn = document.createElement('button'); archiveBtn.className = 'session-archive-btn'; archiveBtn.title = session.archived ? 'Unarchive' : 'Archive'; @@ -1506,6 +1519,7 @@ function buildSessionItem(session) { launchConfigBtn.innerHTML = ICONS.launchConfig(14); actions.appendChild(stopBtn); + if (session.remoteAlias) actions.appendChild(sendBtn); if (session.type !== 'terminal') { actions.appendChild(forkBtn); // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") diff --git a/public/style.css b/public/style.css index e2a811a2..a1414dd8 100644 --- a/public/style.css +++ b/public/style.css @@ -1166,6 +1166,7 @@ body { display: flex; flex-direction: column; } } .session-stop-btn, +.session-send-btn, .session-launch-config-btn, .session-fork-btn, .session-jsonl-btn, @@ -1195,6 +1196,15 @@ body { display: flex; flex-direction: column; } color: #9b4058; } +.session-send-btn { + color: #5a7a6a; +} + +.session-send-btn:hover { + color: #3ecf5a; + background: rgba(62,207,90,0.1); +} + .session-launch-config-btn { color: #5a7a6a; outline: none; @@ -1241,6 +1251,14 @@ body { display: flex; flex-direction: column; } display: flex; } +.session-send-btn { + display: none; +} + +.session-item.is-alive:not(.has-running-pty) .session-send-btn { + display: flex; +} + .session-item.has-running-pty .session-launch-config-btn { display: none; } @@ -4139,6 +4157,27 @@ body { display: flex; flex-direction: column; } color: rgba(224,80,112,0.5); } +.send-prompt-textarea { + width: 100%; + box-sizing: border-box; + resize: vertical; + background: var(--control-surface); + border: 1px solid var(--control-border); + border-radius: 6px; + color: inherit; + font-family: inherit; + font-size: 13px; + padding: 8px 10px; +} + +.send-prompt-status { + min-height: 18px; + margin-top: 8px; + font-size: 12px; + color: var(--text-muted); + word-break: break-word; +} + .new-session-actions { display: flex; justify-content: flex-end; diff --git a/remote-attach.js b/remote-attach.js index 4e2ffc0d..8628ad60 100644 --- a/remote-attach.js +++ b/remote-attach.js @@ -228,22 +228,25 @@ function parseDiscoveryProbeOutput(stdout) { } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", ConnectTimeout on the probe/restore ssh) -function buildRemoteCommandArgs(alias, command) { - return ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-n', alias, command]; +function buildRemoteCommandArgs(alias, command, { input } = {}) { + const head = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5']; + return typeof input === 'string' ? [...head, alias, command] : [...head, '-n', alias, command]; } // Default stdout cap for a single ssh exec — see .ai/contexts/changes-view.md ("Remote transport stdout cap"). const DEFAULT_MAX_STDOUT_BYTES = 8 * 1024 * 1024; // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") and .ai/contexts/changes-view.md ("Remote transport stdout cap") -function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, spawnFn, resolveSshPath = defaultResolveSshPath } = {}) { +// `input`: written to the child's stdin, then stdin is closed — see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, spawnFn, input, resolveSshPath = defaultResolveSshPath } = {}) { const spawn = spawnFn || require('child_process').spawn; const stdoutCap = typeof maxStdoutBytes === 'number' ? maxStdoutBytes : DEFAULT_MAX_STDOUT_BYTES; + const hasInput = typeof input === 'string'; return new Promise((resolve) => { let child; try { - child = spawn(resolveSshPath(), buildRemoteCommandArgs(alias, command), { - windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + child = spawn(resolveSshPath(), buildRemoteCommandArgs(alias, command, { input }), { + windowsHide: true, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'], }); } catch (err) { resolve({ code: -1, stdout: '', stderr: err.message }); @@ -254,7 +257,8 @@ function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, sp let stderr = ''; let settled = false; let overflowed = false; - const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, timeoutMs || DEFAULT_PROBE_TIMEOUT_MS); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs || DEFAULT_PROBE_TIMEOUT_MS); const finish = (code) => { if (settled) return; settled = true; @@ -263,8 +267,12 @@ function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, sp resolve({ code: -1, stdout: '', stderr: `stdout exceeded ${stdoutCap} bytes` }); return; } - resolve({ code, stdout, stderr: stderr.slice(0, 4096) }); + resolve(timedOut ? { code, stdout, stderr: stderr.slice(0, 4096), timedOut: true } : { code, stdout, stderr: stderr.slice(0, 4096) }); }; + if (hasInput && child.stdin) { + child.stdin.on('error', () => {}); + child.stdin.end(input); + } if (child.stdout) child.stdout.on('data', (c) => { if (overflowed) return; stdoutBytes += Buffer.byteLength(c); diff --git a/remote-send.js b/remote-send.js new file mode 100644 index 00000000..5f05d6c1 --- /dev/null +++ b/remote-send.js @@ -0,0 +1,154 @@ +// remote-send.js — see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +'use strict'; + +const crypto = require('crypto'); +const { + isValidPid, + buildProcCmdlineCheck, + shellSingleQuote, + defaultRunRemoteCommand, +} = require('./remote-attach'); + +const MAX_LINE_BYTES = 1024 * 1024; +const MAX_SOCKET_PATH_BYTES = 107; +const DEDUPE_WINDOW_MS = 30000; +const DEFAULT_SEND_TIMEOUT_MS = 15000; +const NOT_CLAUDE_EXIT_CODE = 7; +const NO_SOCKET_EXIT_CODE = 8; +const NC_MISSING_EXIT_CODE = 127; +const SOCKET_PATH_RE = /^\/[A-Za-z0-9._/-]+\.sock$/; +const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\'; + +function validateSocketPath(value) { + if (typeof value !== 'string' || !value) return { ok: false, error: 'the messaging socket path is empty' }; + if (value.startsWith(WINDOWS_PIPE_PREFIX)) { + return { ok: false, error: 'the channel needs the session\'s key file, which Switchboard does not read' }; + } + if (Buffer.byteLength(value) > MAX_SOCKET_PATH_BYTES) return { ok: false, error: 'the messaging socket path is too long' }; + if (!SOCKET_PATH_RE.test(value) || value.includes('..')) { + return { ok: false, error: 'the messaging socket path is not an absolute .sock path of plain characters' }; + } + return { ok: true }; +} + +function buildPromptLine(content, sessionId) { + return JSON.stringify({ type: 'user', message: { role: 'user', content }, msgV: 1, session_id: sessionId }) + '\n'; +} + +function buildDeliverSegment(socketPath) { + const p = shellSingleQuote(socketPath); + return `if command -v ncat >/dev/null 2>&1; then exec ncat --send-only -U ${p}; ` + + `elif command -v nc >/dev/null 2>&1; then exec nc -N -U ${p}; ` + + `else exit ${NC_MISSING_EXIT_CODE}; fi`; +} + +function buildSendCommand(pid, socketPath) { + if (!isValidPid(pid)) throw new Error('invalid pid'); + const checked = validateSocketPath(socketPath); + if (!checked.ok) throw new Error(checked.error); + return `alive=$(${buildProcCmdlineCheck(pid)}); if [ "$alive" != "1" ]; then exit ${NOT_CLAUDE_EXIT_CODE}; fi; ` + + `[ -S ${shellSingleQuote(socketPath)} ] || exit ${NO_SOCKET_EXIT_CODE}; ` + + buildDeliverSegment(socketPath); +} + +function createRemoteSendAdapter(opts = {}) { + const runRemoteCommand = opts.runRemoteCommand || defaultRunRemoteCommand; + const now = opts.now || Date.now; + const log = opts.log || { info() {}, warn() {}, error() {} }; + const recent = new Map(); + + function dedupeKey(alias, sessionId, content) { + return `${alias}\u0000${sessionId}\u0000${crypto.createHash('sha256').update(content).digest('hex')}`; + } + + function pruneRecent(at) { + for (const [key, sentAt] of recent) { + if (at - sentAt >= DEDUPE_WINDOW_MS) recent.delete(key); + } + } + + async function send(alias, descriptor, content) { + if (typeof content !== 'string' || !content.trim()) return { ok: false, error: 'nothing to send' }; + const socketPath = descriptor && descriptor.messagingSocketPath; + if (socketPath == null || socketPath === '') { + return { ok: false, error: 'session carries no messaging socket — a prompt cannot be sent to it' }; + } + const checked = validateSocketPath(socketPath); + if (!checked.ok) return { ok: false, error: checked.error }; + if (!isValidPid(descriptor.pid)) return { ok: false, error: 'session carries no readable pid — cannot send to it' }; + if (typeof descriptor.sessionId !== 'string' || !descriptor.sessionId) { + return { ok: false, error: 'session carries no session id — cannot send to it' }; + } + + const line = buildPromptLine(content, descriptor.sessionId); + if (Buffer.byteLength(line) > MAX_LINE_BYTES) return { ok: false, error: 'the prompt is over 1 MiB once encoded' }; + + const at = now(); + pruneRecent(at); + const key = dedupeKey(alias, descriptor.sessionId, content); + if (recent.has(key)) { + return { ok: false, error: 'the same text was sent to this session less than 30 s ago — the session drops it' }; + } + + let result; + try { + result = await runRemoteCommand(alias, buildSendCommand(descriptor.pid, socketPath), { + timeoutMs: DEFAULT_SEND_TIMEOUT_MS, + input: line, + }); + } catch (err) { + return { ok: false, error: `send failed: ${err.message}` }; + } + if (!result) return { ok: false, error: 'send failed: no response' }; + + if (result.timedOut) return { ok: false, error: 'send failed: no confirmation that the line was written (timed out)' }; + if (result.code === NOT_CLAUDE_EXIT_CODE) { + return { ok: false, error: `pid ${descriptor.pid} now belongs to a process that is not a claude CLI — the session is gone` }; + } + if (result.code === NO_SOCKET_EXIT_CODE) { + return { ok: false, error: 'the session\'s messaging socket is gone — it has exited or restarted' }; + } + if (result.code === NC_MISSING_EXIT_CODE) { + return { ok: false, error: 'nc with -U (unix socket) support was not found on the host — install netcat-openbsd or ncat' }; + } + if (result.code !== 0) { + const reason = (result.stderr || '').trim() || 'no stderr'; + return { ok: false, error: `send failed (exit ${result.code}): ${reason}` }; + } + + recent.set(key, at); + log.info(`[remote-send:${alias}] wrote ${Buffer.byteLength(line)} bytes to pid ${descriptor.pid}`); + return { ok: true }; + } + + return { send }; +} + +async function handleSendRequest(payload, deps) { + const alias = payload && payload.alias; + const sessionId = payload && payload.sessionId; + const text = payload && payload.text; + if (typeof alias !== 'string' || !alias || typeof sessionId !== 'string' || !sessionId || typeof text !== 'string') { + return { ok: false, error: 'invalid request' }; + } + const descriptor = deps.getDescriptor(alias, sessionId); + if (!descriptor) return { ok: false, error: 'session not found on that host' }; + if (deps.isAttached(sessionId)) { + return { ok: false, error: 'the session is attached in a terminal — type the prompt there' }; + } + return deps.adapter.send(alias, descriptor, text); +} + +module.exports = { + createRemoteSendAdapter, + handleSendRequest, + buildPromptLine, + buildSendCommand, + buildDeliverSegment, + validateSocketPath, + MAX_LINE_BYTES, + DEDUPE_WINDOW_MS, + NOT_CLAUDE_EXIT_CODE, + NO_SOCKET_EXIT_CODE, + NC_MISSING_EXIT_CODE, +}; diff --git a/test/dom-send-prompt-dialog.test.js b/test/dom-send-prompt-dialog.test.js new file mode 100644 index 00000000..30cec5ea --- /dev/null +++ b/test/dom-send-prompt-dialog.test.js @@ -0,0 +1,105 @@ +'use strict'; + +// The "Send a prompt…" dialog (issue #219): it sends {alias, sessionId, text} +// through window.api.remoteSendPrompt, never a socket path, says "Sent" and +// never "delivered", and keeps the text when the send fails. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const SESSION = { sessionId: 'remote-9', remoteAlias: 'planificator', summary: 'remote work' }; +const tick = () => new Promise((r) => setTimeout(r, 0)); + +function setup(sendResult) { + const dom = new JSDOM('', { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = []; + window.api = { remoteSendPrompt: (...args) => { calls.push(args); return Promise.resolve(sendResult); } }; + const stubs = { cachedProjects: [], cachedAllProjects: [], sessionMap: new Map(), launchNewSession() {}, openSession() {}, refreshSidebar() {}, pollActiveSessions() {} }; + for (const [k, v] of Object.entries(stubs)) { + Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); + } + for (const f of ['setting-defaults.js', 'utils.js', 'dialogs.js']) { + vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: f }); + } + return { window, document: window.document, calls, destroy() { window.close(); } }; +} + +test('the dialog names the host, sends {alias, sessionId, text} and reports "Sent"', async () => { + const ctx = setup({ ok: true }); + try { + ctx.window.showSendPromptDialog(SESSION); + const dialog = ctx.document.querySelector('.new-session-dialog'); + assert.ok(dialog); + assert.match(dialog.querySelector('h3').textContent, /planificator/); + const textarea = dialog.querySelector('textarea'); + textarea.value = ' fix the build\nthen stop '; + dialog.querySelector('.send-prompt-send-btn').click(); + await tick(); + assert.deepEqual(ctx.calls, [['planificator', 'remote-9', ' fix the build\nthen stop ']]); + const status = dialog.querySelector('.send-prompt-status').textContent; + assert.match(status, /Sent/); + assert.doesNotMatch(status, /deliver/i); + } finally { ctx.destroy(); } +}); + +test('an empty text sends nothing', async () => { + const ctx = setup({ ok: true }); + try { + ctx.window.showSendPromptDialog(SESSION); + const dialog = ctx.document.querySelector('.new-session-dialog'); + dialog.querySelector('textarea').value = ' \n'; + dialog.querySelector('.send-prompt-send-btn').click(); + await tick(); + assert.equal(ctx.calls.length, 0); + } finally { ctx.destroy(); } +}); + +test('a refusal shows the reason and keeps the text for a retry', async () => { + const ctx = setup({ ok: false, error: 'the messaging socket is gone' }); + try { + ctx.window.showSendPromptDialog(SESSION); + const dialog = ctx.document.querySelector('.new-session-dialog'); + const textarea = dialog.querySelector('textarea'); + textarea.value = 'keep me'; + const btn = dialog.querySelector('.send-prompt-send-btn'); + btn.click(); + await tick(); + const status = dialog.querySelector('.send-prompt-status').textContent; + assert.match(status, /messaging socket is gone/); + assert.doesNotMatch(status, /Sent/); + assert.equal(textarea.value, 'keep me'); + assert.equal(btn.disabled, false, 'the user can retry'); + } finally { ctx.destroy(); } +}); + +test('Cancel and Escape close the dialog without sending', () => { + const ctx = setup({ ok: true }); + try { + ctx.window.showSendPromptDialog(SESSION); + ctx.document.querySelector('.new-session-cancel-btn').click(); + assert.equal(ctx.document.querySelector('.new-session-overlay'), null); + ctx.window.showSendPromptDialog(SESSION); + ctx.document.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape' })); + assert.equal(ctx.document.querySelector('.new-session-overlay'), null); + assert.equal(ctx.calls.length, 0); + } finally { ctx.destroy(); } +}); + +test('the host name and the error are not rendered as HTML', async () => { + const ctx = setup({ ok: false, error: '' }); + try { + ctx.window.showSendPromptDialog({ ...SESSION, remoteAlias: 'hx' }); + const dialog = ctx.document.querySelector('.new-session-dialog'); + assert.equal(dialog.querySelector('h3 b'), null); + dialog.querySelector('textarea').value = 'x'; + dialog.querySelector('.send-prompt-send-btn').click(); + await tick(); + assert.equal(dialog.querySelector('.send-prompt-status img'), null); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-sidebar-remote-send.test.js b/test/dom-sidebar-remote-send.test.js new file mode 100644 index 00000000..e1ba853c --- /dev/null +++ b/test/dom-sidebar-remote-send.test.js @@ -0,0 +1,64 @@ +'use strict'; + +// Issue #219: a remote row offers "Send a prompt…" next to Stop. The button is +// shown by CSS only while the process is alive and no terminal is attached, +// exactly like Stop (.is-alive / .has-running-pty) — see .ai/contexts/session-state.md. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +const REMOTE = { + sessionId: 'remote-9', summary: 'remote work', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 4, projectPath: '/srv/x', remoteAlias: 'planificator', +}; +const LOCAL = { sessionId: 'local-9', summary: 'local work', modified: '2026-09-06T10:00:00.000Z', starred: false, archived: 0, messageCount: 2 }; + +function render(ctx, sessions) { + for (const s of sessions) ctx.window.sessionMap.set(s.sessionId, s); + const remote = sessions.filter((s) => s.remoteAlias); + const local = sessions.filter((s) => !s.remoteAlias); + const projects = []; + if (remote.length) { + projects.push(makeSampleProject({ + projectPath: '/srv/x', folder: 'planificator::-srv-x', remoteAlias: 'planificator', sessions: remote, + })); + } + if (local.length) projects.push(makeSampleProject({ sessions: local })); + ctx.sidebar.renderProjects(projects, true); +} + +test('a remote row carries a Send a prompt button; a local row does not', () => { + const ctx = setupSidebarDom(); + try { + render(ctx, [REMOTE, LOCAL]); + const remoteBtn = ctx.document.getElementById('si-remote-9').querySelector('.session-send-btn'); + assert.ok(remoteBtn, 'remote row offers the action'); + assert.match(remoteBtn.title, /Send a prompt/); + assert.equal(ctx.document.getElementById('si-local-9').querySelector('.session-send-btn'), null); + } finally { ctx.destroy(); } +}); + +test('clicking the button opens the dialog for that session and does not open the row', () => { + const ctx = setupSidebarDom(); + try { + render(ctx, [REMOTE]); + const dialogs = []; + const opened = []; + ctx.window.showSendPromptDialog = (s) => dialogs.push(s.sessionId); + ctx.window.openSession = (s) => opened.push(s.sessionId); + ctx.window.showJsonlViewer = (s) => opened.push(s.sessionId); + ctx.document.getElementById('si-remote-9').querySelector('.session-send-btn').click(); + assert.deepEqual(dialogs, ['remote-9']); + assert.deepEqual(opened, [], 'the click must not bubble to the row'); + } finally { ctx.destroy(); } +}); + +test('the stylesheet shows the button only for a live, unattached row, like Stop', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'style.css'), 'utf8'); + assert.match(css, /\.session-send-btn\s*\{\s*display:\s*none;?\s*\}/); + assert.match(css, /\.session-item\.is-alive:not\(\.has-running-pty\)\s+\.session-send-btn\s*\{\s*display:\s*flex;?\s*\}/); +}); diff --git a/test/remote-run-input.test.js b/test/remote-run-input.test.js new file mode 100644 index 00000000..b7762ba3 --- /dev/null +++ b/test/remote-run-input.test.js @@ -0,0 +1,93 @@ +'use strict'; + +// defaultRunRemoteCommand's `input` option (issue #219): the text goes to the +// child's stdin, stdin is closed after it, and `-n` (which points ssh's stdin at +// /dev/null) is dropped. Same spawn site as every other remote command — see +// test/remote-ssh-spawn-sites.test.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('events'); +const { Readable, Writable } = require('stream'); + +const { defaultRunRemoteCommand, buildRemoteCommandArgs } = require('../remote-attach'); + +function fakeSpawn({ exitOnEnd = true, code = 0, emitError = false } = {}) { + const record = { calls: [], written: [], ended: false, killed: false }; + const spawn = (file, args, options) => { + record.calls.push({ file, args, options }); + const child = new EventEmitter(); + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + child.stdin = new Writable({ + write(chunk, _enc, cb) { record.written.push(chunk.toString('utf8')); cb(); }, + final(cb) { + record.ended = true; + cb(); + if (exitOnEnd) setImmediate(() => { child.stdout.push(null); child.emit('close', code); }); + }, + }); + if (emitError) child.stdin.on('error', () => {}); + if (options.stdio[0] === 'ignore') { child.stdin = null; setImmediate(() => { child.stdout.push(null); child.emit('close', code); }); } + child.kill = () => { record.killed = true; setImmediate(() => child.emit('close', null)); }; + return child; + }; + return { spawn, record }; +} + +const RESOLVE = () => '/usr/bin/ssh'; + +test('buildRemoteCommandArgs: -n is present without input and absent with it', () => { + assert.ok(buildRemoteCommandArgs('h', 'cmd').includes('-n')); + assert.ok(buildRemoteCommandArgs('h', 'cmd', { input: 'x\n' }).every((a) => a !== '-n')); + const args = buildRemoteCommandArgs('h', 'cmd', { input: 'x\n' }); + assert.deepEqual(args.slice(-2), ['h', 'cmd'], 'alias and command stay last; the input is never an argument'); + assert.ok(!args.some((a) => a.includes('x\n'))); +}); + +test('a run with input pipes stdin, writes the line exactly, closes stdin, and passes no -n', async () => { + const { spawn, record } = fakeSpawn(); + const line = '{"a":"ZZQ\'$(touch x)"}\n'; + const res = await defaultRunRemoteCommand('host', 'the-command', { spawnFn: spawn, resolveSshPath: RESOLVE, input: line }); + assert.equal(res.code, 0); + const { file, args, options } = record.calls[0]; + assert.equal(file, '/usr/bin/ssh'); + assert.ok(!args.includes('-n')); + assert.ok(!args.some((a) => a.includes('ZZQ')), 'the text is on no command line'); + assert.equal(options.stdio[0], 'pipe'); + assert.equal(record.written.join(''), line); + assert.equal(record.ended, true); +}); + +test('a run without input keeps -n and an ignored stdin', async () => { + const { spawn, record } = fakeSpawn(); + await defaultRunRemoteCommand('host', 'cmd', { spawnFn: spawn, resolveSshPath: RESOLVE }).catch(() => {}); + const { args, options } = record.calls[0]; + assert.ok(args.includes('-n')); + assert.equal(options.stdio[0], 'ignore'); +}); + +test('when nc never exits after the line is written, the timeout kills it and the result says timedOut', async () => { + const { spawn, record } = fakeSpawn({ exitOnEnd: false }); + const res = await defaultRunRemoteCommand('host', 'cmd', { spawnFn: spawn, resolveSshPath: RESOLVE, input: 'x\n', timeoutMs: 20 }); + assert.equal(record.killed, true); + assert.equal(res.timedOut, true); + assert.notEqual(res.code, 0); +}); + +test('a normal exit carries no timedOut flag', async () => { + const { spawn } = fakeSpawn(); + const res = await defaultRunRemoteCommand('host', 'cmd', { spawnFn: spawn, resolveSshPath: RESOLVE, input: 'x\n' }); + assert.equal(res.timedOut, undefined); +}); + +test('a stdin error (ssh gone before reading) does not throw and the run still settles', async () => { + const { spawn } = fakeSpawn({ exitOnEnd: false }); + const wrapped = (...a) => { + const child = spawn(...a); + setImmediate(() => { child.stdin.emit('error', new Error('EPIPE')); child.emit('close', 255); }); + return child; + }; + const res = await defaultRunRemoteCommand('host', 'cmd', { spawnFn: wrapped, resolveSshPath: RESOLVE, input: 'x\n', timeoutMs: 1000 }); + assert.equal(res.code, 255); +}); diff --git a/test/remote-send.test.js b/test/remote-send.test.js new file mode 100644 index 00000000..91bc76c0 --- /dev/null +++ b/test/remote-send.test.js @@ -0,0 +1,431 @@ +'use strict'; + +// Send a prompt to an unattached remote session over its CLI messaging socket +// (issue #219, first PR) — see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt"). +// Fake runner for the adapter; a real `sh` with a fake `nc` on PATH for the +// command itself, where sh exists (same stance as remote-transport-shell.test.js). + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { + buildPromptLine, + buildSendCommand, + buildDeliverSegment, + validateSocketPath, + createRemoteSendAdapter, + handleSendRequest, + MAX_LINE_BYTES, + DEDUPE_WINDOW_MS, + NOT_CLAUDE_EXIT_CODE, + NO_SOCKET_EXIT_CODE, + NC_MISSING_EXIT_CODE, +} = require('../remote-send'); + +const silentLog = { info() {}, warn() {}, error() {} }; +const SOCKET = '/run/user/1000/cc-socks/4242.sock'; +const SESSION_ID = '11111111-2222-3333-4444-555555555555'; + +function descriptor(overrides = {}) { + return { pid: 4242, sessionId: SESSION_ID, messagingSocketPath: SOCKET, ...overrides }; +} + +function makeRunner(result = { code: 0, stdout: '', stderr: '' }) { + const calls = []; + const run = async (alias, command, opts) => { calls.push({ alias, command, opts }); return typeof result === 'function' ? result(calls.length) : result; }; + run.calls = calls; + return run; +} + +function makeAdapter(runRemoteCommand, now = () => 1_000_000) { + return createRemoteSendAdapter({ runRemoteCommand, now, log: silentLog }); +} + +// --- buildPromptLine ------------------------------------------------------- + +test('buildPromptLine: one NDJSON user message carrying the session id, ending in exactly one newline', () => { + const line = buildPromptLine('hello', SESSION_ID); + assert.ok(line.endsWith('\n')); + assert.ok(!line.slice(0, -1).includes('\n')); + assert.deepEqual(JSON.parse(line), { + type: 'user', message: { role: 'user', content: 'hello' }, msgV: 1, session_id: SESSION_ID, + }); +}); + +test('buildPromptLine: an embedded newline stays escaped inside the JSON string', () => { + const line = buildPromptLine('a\nb\r\nc', SESSION_ID); + assert.equal(line.split('\n').length, 2, 'only the terminator is a raw newline'); + assert.equal(line.indexOf('\n'), line.length - 1); + assert.equal(JSON.parse(line).message.content, 'a\nb\r\nc'); +}); + +// --- validateSocketPath ---------------------------------------------------- + +test('validateSocketPath: accepts the CLI socket shapes seen on hosts', () => { + for (const p of [SOCKET, '/var/tmp/my-host/cc-socks/9.sock', '/tmp/a_b.c/1.sock']) { + assert.equal(validateSocketPath(p).ok, true, p); + } +}); + +test('validateSocketPath: hostile, relative, dot-dot, odd-suffix and over-long paths are refused', () => { + const tooLong = '/' + 'a'.repeat(104) + '.sock'; + assert.equal(Buffer.byteLength(tooLong), 110); + const exactly107 = '/' + 'a'.repeat(101) + '.sock'; + assert.equal(Buffer.byteLength(exactly107), 107); + assert.equal(validateSocketPath(exactly107).ok, true, '107 bytes is the limit, inclusive'); + const exactly108 = '/' + 'a'.repeat(102) + '.sock'; + assert.equal(Buffer.byteLength(exactly108), 108); + assert.equal(validateSocketPath(exactly108).ok, false, '108 bytes does not fit sockaddr_un'); + const refused = [ + "/tmp/x'$(touch x).sock", '/tmp/a b.sock', '/tmp/a;rm.sock', '/tmp/`id`.sock', '/tmp/$HOME.sock', + 'relative/1.sock', './1.sock', '1.sock', '/tmp/../etc/1.sock', '/tmp/a..b.sock', '/tmp/1.sock/', + '/tmp/1.txt', '/tmp/1.sock\n', '/tmp/é.sock', tooLong, '', null, undefined, 42, {}, + ]; + for (const p of refused) assert.equal(validateSocketPath(p).ok, false, JSON.stringify(p)); +}); + +// --- buildSendCommand ------------------------------------------------------ + +test('buildSendCommand: fixed text, the integer pid and the single-quoted path only', () => { + const cmd = buildSendCommand(4242, SOCKET); + assert.ok(cmd.includes(`'${SOCKET}'`)); + assert.match(cmd, /\/proc\/4242\/cmdline/); + assert.match(cmd, /-S '\/run\/user\/1000\/cc-socks\/4242\.sock'/); + assert.match(cmd, /--send-only -U/); + assert.match(cmd, /nc -N -U/); + assert.ok(cmd.indexOf('/proc/4242/cmdline') < cmd.indexOf('-S '), 'the pid check runs before the socket test'); +}); + +test('buildSendCommand: refuses an unsafe path or a bad pid by throwing, never by building', () => { + assert.throws(() => buildSendCommand(4242, "/tmp/x'; touch y; '.sock")); + assert.throws(() => buildSendCommand(4242, '/tmp/../x.sock')); + assert.throws(() => buildSendCommand('4242; id', SOCKET)); + assert.throws(() => buildSendCommand(-1, SOCKET)); +}); + +// --- adapter: argv and stdin ----------------------------------------------- + +test('send: the prompt text travels as the input option only, never inside the command', async () => { + const hostile = "ZZQ'$(touch x)"; + const run = makeRunner(); + const res = await makeAdapter(run).send('planificator', descriptor(), hostile); + assert.deepEqual(res, { ok: true }); + assert.equal(run.calls.length, 1); + const { alias, command, opts } = run.calls[0]; + assert.equal(alias, 'planificator'); + assert.ok(!command.includes('ZZQ'), 'the text must not appear on the remote command line'); + assert.ok(!Object.entries(opts).some(([k, v]) => k !== 'input' && String(v).includes('ZZQ'))); + assert.equal(opts.input, buildPromptLine(hostile, SESSION_ID)); + assert.ok(opts.input.endsWith('\n') && opts.input.indexOf('\n') === opts.input.length - 1, + 'exactly one trailing newline'); + assert.ok(Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 && opts.timeoutMs <= 60000, 'a bounded timeout'); +}); + +// --- adapter: refusals before any ssh -------------------------------------- + +test('send: no messaging socket in the descriptor has its own message and spawns nothing', async () => { + const run = makeRunner(); + const res = await makeAdapter(run).send('h', descriptor({ messagingSocketPath: undefined }), 'hi'); + assert.equal(res.ok, false); + assert.match(res.error, /no messaging socket/); + assert.equal(run.calls.length, 0); +}); + +test('send: a Windows named pipe is refused with the key-file reason and spawns nothing', async () => { + const run = makeRunner(); + const res = await makeAdapter(run).send('h', descriptor({ messagingSocketPath: '\\\\.\\pipe\\cc-4242' }), 'hi'); + assert.equal(res.ok, false); + assert.match(res.error, /key file, which Switchboard does not read/); + assert.doesNotMatch(res.error, /no messaging socket/); + assert.equal(run.calls.length, 0); +}); + +test('send: a hostile or malformed socket path is refused and spawns nothing', async () => { + for (const p of ["/tmp/x'$(id).sock", '../x.sock', '/tmp/../x.sock', '/' + 'a'.repeat(120) + '.sock', 12]) { + const run = makeRunner(); + const res = await makeAdapter(run).send('h', descriptor({ messagingSocketPath: p }), 'hi'); + assert.equal(res.ok, false, String(p)); + assert.notEqual(res.error, undefined); + assert.equal(run.calls.length, 0, `no spawn for ${p}`); + } +}); + +test('send: a descriptor without a readable pid, or an empty text, spawns nothing', async () => { + const run = makeRunner(); + const adapter = makeAdapter(run); + assert.equal((await adapter.send('h', descriptor({ pid: 'x' }), 'hi')).ok, false); + assert.equal((await adapter.send('h', descriptor(), '')).ok, false); + assert.equal((await adapter.send('h', descriptor(), ' \n')).ok, false); + assert.equal((await adapter.send('h', descriptor(), 42)).ok, false); + assert.equal(run.calls.length, 0); +}); + +// --- adapter: size cap measured on bytes ----------------------------------- + +test('send: the 1 MiB cap is measured on the UTF-8 bytes of the whole line, boundary inclusive', async () => { + const overhead = Buffer.byteLength(buildPromptLine('', SESSION_ID)); + const exact = 'a'.repeat(MAX_LINE_BYTES - overhead); + assert.equal(Buffer.byteLength(buildPromptLine(exact, SESSION_ID)), MAX_LINE_BYTES); + const run = makeRunner(); + assert.equal((await makeAdapter(run).send('h', descriptor(), exact)).ok, true, 'exactly at the cap is sent'); + + const over = makeRunner(); + const tooBig = await makeAdapter(over).send('h', descriptor(), exact + 'a'); + assert.equal(tooBig.ok, false); + assert.match(tooBig.error, /1 MiB/); + assert.equal(over.calls.length, 0); + + const multibyte = 'é'.repeat(Math.ceil(MAX_LINE_BYTES / 2)); + assert.ok(multibyte.length < MAX_LINE_BYTES, 'fewer characters than the cap'); + const mb = makeRunner(); + const res = await makeAdapter(mb).send('h', descriptor(), multibyte); + assert.equal(res.ok, false, 'but more bytes than the cap'); + assert.equal(mb.calls.length, 0); +}); + +// --- adapter: exit codes --------------------------------------------------- + +test('send: exit codes map to their own messages and "sent" is never "delivered"', async () => { + const cases = [ + [NOT_CLAUDE_EXIT_CODE, /not a claude CLI.*gone|gone.*not a claude/is], + [NO_SOCKET_EXIT_CODE, /socket is gone|no longer there/i], + [NC_MISSING_EXIT_CODE, /nc.*-U.*not found|not found.*nc/is], + ]; + const seen = new Set(); + for (const [code, re] of cases) { + const res = await makeAdapter(makeRunner({ code, stdout: '', stderr: '' })).send('h', descriptor(), 'hi'); + assert.equal(res.ok, false); + assert.match(res.error, re, `exit ${code}`); + seen.add(res.error); + } + assert.equal(seen.size, 3, 'three distinct messages'); + + const other = await makeAdapter(makeRunner({ code: 255, stdout: '', stderr: 'ssh: Could not resolve hostname\n' })).send('h', descriptor(), 'hi'); + assert.equal(other.ok, false); + assert.match(other.error, /exit 255/); + assert.match(other.error, /Could not resolve hostname/); + assert.doesNotMatch(other.error, /hi$/); + + const ok = await makeAdapter(makeRunner()).send('h', descriptor(), 'hi'); + assert.deepEqual(ok, { ok: true }); + assert.ok(!JSON.stringify(ok).includes('deliver')); +}); + +test('send: a timeout is a failure saying nothing confirms the line was written', async () => { + const res = await makeAdapter(makeRunner({ code: -1, stdout: '', stderr: '', timedOut: true })).send('h', descriptor(), 'hi'); + assert.equal(res.ok, false); + assert.match(res.error, /no confirmation that the line was written/); +}); + +test('send: a runner that throws or answers nothing is a failure', async () => { + const thrower = async () => { throw new Error('boom'); }; + assert.equal((await makeAdapter(thrower).send('h', descriptor(), 'hi')).ok, false); + assert.equal((await makeAdapter(async () => null).send('h', descriptor(), 'hi')).ok, false); +}); + +test('send: an error message never carries the prompt text', async () => { + const secret = 'SECRET-PROMPT-TEXT'; + for (const result of [{ code: 1, stdout: '', stderr: 'x' }, { code: -1, timedOut: true, stdout: '', stderr: '' }, { code: 7, stdout: '', stderr: '' }]) { + const res = await makeAdapter(makeRunner(result)).send('h', descriptor(), secret); + assert.ok(!res.error.includes(secret)); + } +}); + +// --- adapter: 30 s dedupe on an injected clock ----------------------------- + +test('send: the same text to the same session less than 30 s later is refused, then allowed after', async () => { + let t = 5_000; + const run = makeRunner(); + const adapter = makeAdapter(run, () => t); + assert.equal((await adapter.send('h', descriptor(), 'same')).ok, true); + + t += 29_999; + const dup = await adapter.send('h', descriptor(), 'same'); + assert.equal(dup.ok, false); + assert.match(dup.error, /30 s/); + assert.equal(run.calls.length, 1, 'the duplicate spawns nothing'); + + t += 1; + assert.equal(DEDUPE_WINDOW_MS, 30_000); + assert.equal((await adapter.send('h', descriptor(), 'same')).ok, true, 'exactly 30 s later it goes through'); + assert.equal(run.calls.length, 2); +}); + +test('send: the dedupe is per session, per host and per text', async () => { + const run = makeRunner(); + const adapter = makeAdapter(run, () => 1); + assert.equal((await adapter.send('h', descriptor(), 'same')).ok, true); + assert.equal((await adapter.send('h', descriptor(), 'other')).ok, true); + assert.equal((await adapter.send('h', descriptor({ sessionId: 'another-session' }), 'same')).ok, true); + assert.equal((await adapter.send('h2', descriptor(), 'same')).ok, true); + assert.equal(run.calls.length, 4); +}); + +test('send: a failed send does not arm the dedupe window', async () => { + const run = makeRunner((n) => (n === 1 ? { code: 255, stdout: '', stderr: 'down' } : { code: 0, stdout: '', stderr: '' })); + const adapter = makeAdapter(run, () => 1); + assert.equal((await adapter.send('h', descriptor(), 'again')).ok, false); + assert.equal((await adapter.send('h', descriptor(), 'again')).ok, true, 'a retry after a failure is not a duplicate'); +}); + +// --- handleSendRequest: the IPC contract ----------------------------------- + +test('handleSendRequest: the descriptor, and so the socket path, comes from the main side only', async () => { + const sent = []; + const adapter = { send: async (alias, d, text) => { sent.push({ alias, d, text }); return { ok: true }; } }; + const mine = descriptor(); + const res = await handleSendRequest( + { alias: 'h', sessionId: SESSION_ID, text: 'hi', messagingSocketPath: '/tmp/evil.sock', pid: 1, descriptor: { messagingSocketPath: '/tmp/evil2.sock' } }, + { getDescriptor: (alias, id) => (alias === 'h' && id === SESSION_ID ? mine : undefined), isAttached: () => false, adapter }, + ); + assert.deepEqual(res, { ok: true }); + assert.equal(sent.length, 1); + assert.equal(sent[0].d, mine); + assert.equal(sent[0].d.messagingSocketPath, SOCKET); +}); + +test('handleSendRequest: a malformed payload, an unknown session or an attached one is refused before the adapter', async () => { + let calls = 0; + const adapter = { send: async () => { calls++; return { ok: true }; } }; + const deps = { getDescriptor: () => descriptor(), isAttached: () => false, adapter }; + for (const payload of [null, undefined, {}, { alias: 'h' }, { alias: 'h', sessionId: 'x' }, { alias: '', sessionId: 'x', text: 'a' }, + { alias: 'h', sessionId: 'x', text: 5 }, { alias: 5, sessionId: 'x', text: 'a' }]) { + const res = await handleSendRequest(payload, deps); + assert.equal(res.ok, false, JSON.stringify(payload)); + } + const unknown = await handleSendRequest({ alias: 'h', sessionId: 'x', text: 'a' }, { ...deps, getDescriptor: () => undefined }); + assert.equal(unknown.ok, false); + assert.match(unknown.error, /not found on that host/); + const attached = await handleSendRequest({ alias: 'h', sessionId: 'x', text: 'a' }, { ...deps, isAttached: () => true }); + assert.equal(attached.ok, false); + assert.match(attached.error, /attached/); + assert.equal(calls, 0); +}); + +// --- real sh with a fake nc ------------------------------------------------ + +function shAvailable() { + const r = spawnSync('sh', ['-c', 'exit 0']); + return !r.error && r.status === 0; +} +const SH_SKIP = shAvailable() ? false : 'sh is not available on this machine'; + +function sandbox() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-send-')); +} + +function writeFakeNc(binDir, name, outDir) { + const file = path.join(binDir, name); + const outPath = outDir.split(path.sep).join('/'); + fs.writeFileSync(file, `#!/bin/sh\nprintf '%s\\n' "${name} $*" > "${outPath}/${name}.argv"\ncat > "${outPath}/${name}.stdin"\nexit \${FAKE_NC_EXIT:-0}\n`, { mode: 0o755 }); +} + +function runSh(script, input, binDir, extraEnv = {}) { + return spawnSync('sh', ['-c', script], { + input, encoding: 'utf8', + env: { ...process.env, PATH: binDir + path.delimiter + process.env.PATH, ...extraEnv }, + }); +} + +test('deliver segment: the fake nc receives the exact line on stdin and the path in argv (nc variant)', { skip: SH_SKIP }, () => { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir); + const line = buildPromptLine("ZZQ'$(touch x)\nsecond", SESSION_ID); + const r = runSh(buildDeliverSegment(SOCKET), line, bin); + assert.equal(r.status, 0, r.stderr); + assert.equal(fs.readFileSync(path.join(dir, 'nc.stdin'), 'utf8'), line, 'stdin is byte-exact'); + assert.equal(fs.readFileSync(path.join(dir, 'nc.argv'), 'utf8').trim(), `nc -N -U ${SOCKET}`); + assert.ok(!fs.existsSync(path.join(dir, 'x')), 'nothing was evaluated'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('deliver segment: ncat wins over nc and uses --send-only', { skip: SH_SKIP }, () => { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir); + writeFakeNc(bin, 'ncat', dir); + const line = buildPromptLine('hello', SESSION_ID); + const r = runSh(buildDeliverSegment(SOCKET), line, bin); + assert.equal(r.status, 0, r.stderr); + assert.equal(fs.readFileSync(path.join(dir, 'ncat.stdin'), 'utf8'), line); + assert.equal(fs.readFileSync(path.join(dir, 'ncat.argv'), 'utf8').trim(), `ncat --send-only -U ${SOCKET}`); + assert.ok(!fs.existsSync(path.join(dir, 'nc.stdin'))); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('deliver segment: the exit status of nc is the exit status of the command', { skip: SH_SKIP }, () => { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir); + const r = runSh(buildDeliverSegment(SOCKET), 'x\n', bin, { FAKE_NC_EXIT: '3' }); + assert.equal(r.status, 3); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('send command: a pid that is not a claude process exits with the not-claude status before any nc', { skip: SH_SKIP }, () => { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir); + const r = runSh(buildSendCommand(2147483000, SOCKET), 'x\n', bin); + assert.equal(r.status, NOT_CLAUDE_EXIT_CODE); + assert.ok(!fs.existsSync(path.join(dir, 'nc.argv')), 'nc never ran'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +function procCmdlineWorks() { + try { return fs.existsSync('/proc/self/cmdline'); } catch { return false; } +} + +test('send command: a live claude pid with a missing socket exits with the no-socket status; with a socket the line reaches nc', { skip: SH_SKIP || (procCmdlineWorks() ? false : 'no /proc//cmdline here') }, async () => { + const dir = sandbox(); + const child = require('child_process').spawn(process.execPath, ['-e', 'setTimeout(()=>{},30000)', 'claude'], { stdio: 'ignore' }); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir); + const sock = path.join(dir, 'a.sock').split(path.sep).join('/'); + if (!validateSocketPath(sock).ok) return; + const missing = runSh(buildSendCommand(child.pid, sock), 'x\n', bin); + assert.equal(missing.status, NO_SOCKET_EXIT_CODE); + const server = require('net').createServer(); + await new Promise((resolve, reject) => { + server.listen(sock, () => { + const line = buildPromptLine('hi', SESSION_ID); + const r = runSh(buildSendCommand(child.pid, sock), line, bin); + server.close(); + try { + assert.equal(r.status, 0, r.stderr); + assert.equal(fs.readFileSync(path.join(dir, 'nc.stdin'), 'utf8'), line); + resolve(); + } catch (e) { reject(e); } + }); + }); + } finally { child.kill(); fs.rmSync(dir, { recursive: true, force: true }); } +}); + +// --- wiring: the renderer sends {alias, sessionId, text} and nothing else ----- + +test('wiring: preload forwards exactly {alias, sessionId, text} and main routes through handleSendRequest', () => { + const root = path.join(__dirname, '..'); + const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8'); + const m = /remoteSendPrompt:\s*\(([^)]*)\)\s*=>\s*ipcRenderer\.invoke\('remote-send-prompt',\s*(\{[^}]*\})\)/.exec(preload); + assert.ok(m, 'preload exposes remoteSendPrompt over remote-send-prompt'); + assert.equal(m[1].replace(/\s/g, ''), 'alias,sessionId,text'); + assert.equal(m[2].replace(/\s/g, ''), '{alias,sessionId,text}'); + + const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + const start = main.indexOf("ipcMain.handle('remote-send-prompt'"); + assert.notEqual(start, -1); + const handler = main.slice(start, main.indexOf('\n}));', start) + 5); + assert.match(handler, /handleSendRequest\(payload,/); + assert.match(handler, /remoteIndexer\.getRemoteSessions\(alias\)/); + assert.doesNotMatch(handler, /messagingSocketPath/, 'the socket path is never read at the IPC edge'); +}); From c503733c1a0fb0dff3fb8747746a7a15a5d048a2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 21:35:45 +0200 Subject: [PATCH 2/4] (remote): probe nc capability, dedupe on timeout and in flight The remote command now runs ncat only if it has --send-only and nc only if its usage line lists -N and -U, else exits 127, so busybox and netcat-traditional get the install hint instead of a usage error. The 30 s dedupe key is reserved before the spawn, kept on a timeout (the line may already be on the socket) and released on a definite failure. Also: bound the text length before encoding, scope the dialog's key handler to the dialog, and attribute dialogs.js to its real path in the coverage test. Refs #219 --- public/dialogs.js | 6 +- remote-attach.js | 3 +- remote-send.js | 17 ++++-- test/dom-send-prompt-dialog.test.js | 32 +++++++++- test/remote-send.test.js | 95 ++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 15 deletions(-) diff --git a/public/dialogs.js b/public/dialogs.js index 46349bad..c1238954 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -481,7 +481,6 @@ function showSendPromptDialog(session) { function close() { overlay.remove(); - document.removeEventListener('keydown', onKey); } async function send() { @@ -508,11 +507,10 @@ function showSendPromptDialog(session) { sendBtn.onclick = send; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - function onKey(e) { + overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send(); - } - document.addEventListener('keydown', onKey); + }); } // Settings viewer is in settings-panel.js (openSettingsViewer / closeSettingsViewer) diff --git a/remote-attach.js b/remote-attach.js index 8628ad60..7cd2303f 100644 --- a/remote-attach.js +++ b/remote-attach.js @@ -236,8 +236,7 @@ function buildRemoteCommandArgs(alias, command, { input } = {}) { // Default stdout cap for a single ssh exec — see .ai/contexts/changes-view.md ("Remote transport stdout cap"). const DEFAULT_MAX_STDOUT_BYTES = 8 * 1024 * 1024; -// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") and .ai/contexts/changes-view.md ("Remote transport stdout cap") -// `input`: written to the child's stdin, then stdin is closed — see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") and .ai/contexts/changes-view.md ("Remote transport stdout cap"); `input` — .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, spawnFn, input, resolveSshPath = defaultResolveSshPath } = {}) { const spawn = spawnFn || require('child_process').spawn; const stdoutCap = typeof maxStdoutBytes === 'number' ? maxStdoutBytes : DEFAULT_MAX_STDOUT_BYTES; diff --git a/remote-send.js b/remote-send.js index 5f05d6c1..aae5f7bf 100644 --- a/remote-send.js +++ b/remote-send.js @@ -17,6 +17,7 @@ const NOT_CLAUDE_EXIT_CODE = 7; const NO_SOCKET_EXIT_CODE = 8; const NC_MISSING_EXIT_CODE = 127; const SOCKET_PATH_RE = /^\/[A-Za-z0-9._/-]+\.sock$/; +const OPENBSD_NC_USAGE_RE = 'usage: nc \[-[0-9A-Za-z]*N[0-9A-Za-z]*U'; const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\'; function validateSocketPath(value) { @@ -37,8 +38,8 @@ function buildPromptLine(content, sessionId) { function buildDeliverSegment(socketPath) { const p = shellSingleQuote(socketPath); - return `if command -v ncat >/dev/null 2>&1; then exec ncat --send-only -U ${p}; ` + - `elif command -v nc >/dev/null 2>&1; then exec nc -N -U ${p}; ` + + return `if command -v ncat >/dev/null 2>&1 && ncat --help 2>&1 | grep -q -- --send-only; then exec ncat --send-only -U ${p}; ` + + `elif command -v nc >/dev/null 2>&1 && nc -h 2>&1 | grep -Eq '${OPENBSD_NC_USAGE_RE}'; then exec nc -N -U ${p}; ` + `else exit ${NC_MISSING_EXIT_CODE}; fi`; } @@ -89,6 +90,7 @@ function createRemoteSendAdapter(opts = {}) { if (recent.has(key)) { return { ok: false, error: 'the same text was sent to this session less than 30 s ago — the session drops it' }; } + recent.set(key, at); let result; try { @@ -97,11 +99,16 @@ function createRemoteSendAdapter(opts = {}) { input: line, }); } catch (err) { + recent.delete(key); return { ok: false, error: `send failed: ${err.message}` }; } - if (!result) return { ok: false, error: 'send failed: no response' }; + if (!result) { + recent.delete(key); + return { ok: false, error: 'send failed: no response' }; + } - if (result.timedOut) return { ok: false, error: 'send failed: no confirmation that the line was written (timed out)' }; + if (result.timedOut) return { ok: false, error: 'send failed: no confirmation that the line was written — it may have been sent (timed out)' }; + if (result.code !== 0) recent.delete(key); if (result.code === NOT_CLAUDE_EXIT_CODE) { return { ok: false, error: `pid ${descriptor.pid} now belongs to a process that is not a claude CLI — the session is gone` }; } @@ -116,7 +123,6 @@ function createRemoteSendAdapter(opts = {}) { return { ok: false, error: `send failed (exit ${result.code}): ${reason}` }; } - recent.set(key, at); log.info(`[remote-send:${alias}] wrote ${Buffer.byteLength(line)} bytes to pid ${descriptor.pid}`); return { ok: true }; } @@ -133,6 +139,7 @@ async function handleSendRequest(payload, deps) { } const descriptor = deps.getDescriptor(alias, sessionId); if (!descriptor) return { ok: false, error: 'session not found on that host' }; + if (text.length > MAX_LINE_BYTES) return { ok: false, error: 'the prompt is over 1 MiB once encoded' }; if (deps.isAttached(sessionId)) { return { ok: false, error: 'the session is attached in a terminal — type the prompt there' }; } diff --git a/test/dom-send-prompt-dialog.test.js b/test/dom-send-prompt-dialog.test.js index 30cec5ea..fc1023f9 100644 --- a/test/dom-send-prompt-dialog.test.js +++ b/test/dom-send-prompt-dialog.test.js @@ -25,7 +25,8 @@ function setup(sendResult) { Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); } for (const f of ['setting-defaults.js', 'utils.js', 'dialogs.js']) { - vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: f }); + const file = path.join(PUBLIC_DIR, f); + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); } return { window, document: window.document, calls, destroy() { window.close(); } }; } @@ -85,12 +86,39 @@ test('Cancel and Escape close the dialog without sending', () => { ctx.document.querySelector('.new-session-cancel-btn').click(); assert.equal(ctx.document.querySelector('.new-session-overlay'), null); ctx.window.showSendPromptDialog(SESSION); - ctx.document.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape' })); + ctx.document.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.notEqual(ctx.document.querySelector('.new-session-overlay'), null, 'an Escape aimed elsewhere does not close it'); + ctx.document.querySelector('textarea').dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); assert.equal(ctx.document.querySelector('.new-session-overlay'), null); assert.equal(ctx.calls.length, 0); } finally { ctx.destroy(); } }); +test('Ctrl+Enter in the textarea sends', async () => { + const ctx = setup({ ok: true }); + try { + ctx.window.showSendPromptDialog(SESSION); + const textarea = ctx.document.querySelector('textarea'); + textarea.value = 'go'; + textarea.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true })); + await tick(); + assert.equal(ctx.calls.length, 1); + } finally { ctx.destroy(); } +}); + +test('a rejected send shows its message instead of throwing', async () => { + const ctx = setup({ ok: true }); + try { + ctx.window.api.remoteSendPrompt = () => Promise.reject(new Error('ipc down')); + ctx.window.showSendPromptDialog(SESSION); + const dialog = ctx.document.querySelector('.new-session-dialog'); + dialog.querySelector('textarea').value = 'x'; + dialog.querySelector('.send-prompt-send-btn').click(); + await tick(); + assert.match(dialog.querySelector('.send-prompt-status').textContent, /ipc down/); + } finally { ctx.destroy(); } +}); + test('the host name and the error are not rendered as HTML', async () => { const ctx = setup({ ok: false, error: '' }); try { diff --git a/test/remote-send.test.js b/test/remote-send.test.js index 91bc76c0..1c891ad9 100644 --- a/test/remote-send.test.js +++ b/test/remote-send.test.js @@ -221,6 +221,55 @@ test('send: a timeout is a failure saying nothing confirms the line was written' assert.match(res.error, /no confirmation that the line was written/); }); +test('send: a timeout arms the dedupe window, since the line may already be on the socket', async () => { + let t = 1; + const run = makeRunner({ code: -1, stdout: '', stderr: '', timedOut: true }); + const adapter = makeAdapter(run, () => t); + const first = await adapter.send('h', descriptor(), 'maybe'); + assert.match(first.error, /may have been sent/); + const again = await adapter.send('h', descriptor(), 'maybe'); + assert.match(again.error, /30 s/); + assert.equal(run.calls.length, 1); + t += 30_000; + assert.match((await adapter.send('h', descriptor(), 'maybe')).error, /may have been sent/); + assert.equal(run.calls.length, 2); +}); + +test('send: two concurrent sends of the same text spawn once; a definite failure releases the reservation', async () => { + let release; + const gate = new Promise((r) => { release = r; }); + const calls = []; + const run = async (alias, command, opts) => { calls.push(opts); await gate; return { code: calls.length === 1 ? 255 : 0, stdout: '', stderr: 'down' }; }; + const adapter = makeAdapter(run, () => 1); + const a = adapter.send('h', descriptor(), 'twice'); + const b = adapter.send('h', descriptor(), 'twice'); + release(); + const [ra, rb] = await Promise.all([a, b]); + assert.equal(calls.length, 1, 'the second send is refused before any spawn'); + assert.equal(ra.ok, false); + assert.match(rb.error, /30 s/); + assert.equal((await adapter.send('h', descriptor(), 'twice')).ok, true, 'after the definite failure a retry is allowed'); +}); + +test('send: a thrown runner and an empty answer release the reservation', async () => { + const adapter1 = makeAdapter(async () => { throw new Error('boom'); }, () => 1); + await adapter1.send('h', descriptor(), 'x'); + const adapter2 = makeAdapter(async () => null, () => 1); + await adapter2.send('h', descriptor(), 'x'); + for (const [a, label] of [[adapter1, 'throw'], [adapter2, 'empty']]) { + const res = await a.send('h', descriptor(), 'x'); + assert.doesNotMatch(res.error, /30 s/, label); + } +}); + +test('send: a descriptor without a session id spawns nothing', async () => { + const run = makeRunner(); + const res = await makeAdapter(run).send('h', descriptor({ sessionId: undefined }), 'hi'); + assert.equal(res.ok, false); + assert.match(res.error, /no session id/); + assert.equal(run.calls.length, 0); +}); + test('send: a runner that throws or answers nothing is a failure', async () => { const thrower = async () => { throw new Error('boom'); }; assert.equal((await makeAdapter(thrower).send('h', descriptor(), 'hi')).ok, false); @@ -288,6 +337,15 @@ test('handleSendRequest: the descriptor, and so the socket path, comes from the assert.equal(sent[0].d.messagingSocketPath, SOCKET); }); +test('handleSendRequest: a text longer than the cap in characters is refused before the adapter', async () => { + let calls = 0; + const res = await handleSendRequest({ alias: 'h', sessionId: 'x', text: 'a'.repeat(MAX_LINE_BYTES + 1) }, + { getDescriptor: () => descriptor(), isAttached: () => false, adapter: { send: async () => { calls++; return { ok: true }; } } }); + assert.equal(res.ok, false); + assert.match(res.error, /1 MiB/); + assert.equal(calls, 0); +}); + test('handleSendRequest: a malformed payload, an unknown session or an attached one is refused before the adapter', async () => { let calls = 0; const adapter = { send: async () => { calls++; return { ok: true }; } }; @@ -318,10 +376,17 @@ function sandbox() { return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-send-')); } -function writeFakeNc(binDir, name, outDir) { +const OPENBSD_HELP = 'usage: nc [-46CDdFhklNnrStUuvZz] [-I length] [-i interval] [-M ttl]'; +const BUSYBOX_HELP = 'BusyBox v1.36.1 multi-call binary.\nUsage: nc [OPTIONS] HOST PORT - connect\n -l -p PORT -w SEC -s ADDR -e PROG'; +const TRADITIONAL_HELP = '[v1.10-47]\nconnect to somewhere:\tnc [-options] hostname port[s] [ports] ...\n\t-U\t\tUse UNIX domain socket'; +const NCAT_HELP = 'Ncat 7.93\n --send-only Only send data, ignoring received; quit on EOF'; + +function writeFakeNc(binDir, name, outDir, help) { const file = path.join(binDir, name); const outPath = outDir.split(path.sep).join('/'); - fs.writeFileSync(file, `#!/bin/sh\nprintf '%s\\n' "${name} $*" > "${outPath}/${name}.argv"\ncat > "${outPath}/${name}.stdin"\nexit \${FAKE_NC_EXIT:-0}\n`, { mode: 0o755 }); + const helpText = help || (name === 'ncat' ? NCAT_HELP : OPENBSD_HELP); + fs.writeFileSync(`${file}.help`, helpText); + fs.writeFileSync(file, `#!/bin/sh\ncase "$1" in -h|--help) cat "$0.help"; exit 0;; esac\nprintf '%s\\n' "${name} $*" > "${outPath}/${name}.argv"\ncat > "${outPath}/${name}.stdin"\nexit \${FAKE_NC_EXIT:-0}\n`, { mode: 0o755 }); } function runSh(script, input, binDir, extraEnv = {}) { @@ -360,6 +425,32 @@ test('deliver segment: ncat wins over nc and uses --send-only', { skip: SH_SKIP } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); +test('deliver segment: an nc without -N/-U (busybox, netcat-traditional) is never run and exits 127', { skip: SH_SKIP }, () => { + for (const help of [BUSYBOX_HELP, TRADITIONAL_HELP]) { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'nc', dir, help); + const r = runSh(buildDeliverSegment(SOCKET), 'x\n', bin); + assert.equal(r.status, NC_MISSING_EXIT_CODE, help); + assert.ok(!fs.existsSync(path.join(dir, 'nc.argv')), 'the unsupported nc never received the line'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } +}); + +test('deliver segment: an ncat without --send-only falls back to a capable nc', { skip: SH_SKIP }, () => { + const dir = sandbox(); + try { + const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); + writeFakeNc(bin, 'ncat', dir, 'Ncat 5.0 no such flag'); + writeFakeNc(bin, 'nc', dir); + const r = runSh(buildDeliverSegment(SOCKET), 'x\n', bin); + assert.equal(r.status, 0, r.stderr); + assert.ok(!fs.existsSync(path.join(dir, 'ncat.argv'))); + assert.ok(fs.existsSync(path.join(dir, 'nc.argv'))); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test('deliver segment: the exit status of nc is the exit status of the command', { skip: SH_SKIP }, () => { const dir = sandbox(); try { From 59a4a38652bd3cc99fe66ef6313d6e935f00941a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 21:53:19 +0200 Subject: [PATCH 3/4] (remote): document the nc probe and the dedupe reservation Refs #219 --- .ai/contexts/session-cache.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 1388a2c2..55c230dc 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -771,12 +771,17 @@ from the descriptor the refresh cycle already pulls. than `isSafeSocketPath` (which also guards tmux sockets): `^/[A-Za-z0-9._/-]+\.sock$`, no `..`, at most 107 bytes (`sockaddr_un`). `buildSendCommand` throws on a path it would refuse. -- **Exit codes** of the remote command: 7 the pid is no longer a `claude` +- **nc variants**: the command probes `ncat --help` for `--send-only` and + `nc -h` for an OpenBSD usage line carrying `N` and `U`; a BusyBox or + netcat-traditional `nc` is never run with flags it would reject, the command + exits 127 instead. **Exit codes** of the remote command: 7 the pid is no longer a `claude` process, 8 the socket is gone, 127 no `ncat`/`nc`. Anything else is a failure carrying ssh's stderr. A timeout (nc did not exit after the line was written) is a failure saying nothing confirms the write, never a success. -- **30 s dedupe** is client-side and per host, session and text; it is armed only - by a send that succeeded, on an injectable clock. The server also has a +- **30 s dedupe** is client-side and per host, session and text, on an injectable + clock. The key is reserved before the ssh spawns, so two concurrent sends of the + same text go once; a definite failure releases it, a timeout keeps it (the line + may already be on the socket). The server also has a 30-token bucket refilling at 0.5/s; nothing here retries. - **Entry point**: the `session-send-btn` on remote rows (CSS-gated like Stop: shown for `.is-alive` and not `.has-running-pty`), and `showSendPromptDialog` From 047a6241448b7d83661f6423ee6c84617c7dcca2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 22:36:20 +0200 Subject: [PATCH 4/4] (remote): run the send script under sh -c, keep dialog focus The remote command is now one single-quoted argument of sh -c, so a login shell such as fish never parses it. The nc usage pattern is the intended literal and is pinned through a real grep. The dialog overlay is focusable and clicks inside return focus to the textarea so Escape keeps working. Refs #219 --- .ai/contexts/session-cache.md | 4 ++++ public/dialogs.js | 2 ++ remote-send.js | 12 +++++++++--- test/dom-send-prompt-dialog.test.js | 30 +++++++++++++++++++++++++++++ test/remote-send.test.js | 26 ++++++++++++++++++++++++- 5 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 55c230dc..f853148c 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -765,6 +765,10 @@ from the descriptor the refresh cycle already pulls. dropped, the line is written and stdin closed. Same spawn site as every other remote ssh, so `remote-ssh-spawn-sites.test.js` is unchanged. The remote command holds fixed text, the integer pid and the single-quoted path. + The script is passed as `sh -c '