feat(ssh): enable hook notifications for remote sessions#1288
feat(ssh): enable hook notifications for remote sessions#1288rabanspiegel merged 4 commits intomainfrom
Conversation
Remote agent sessions now send Notification and Stop hook events back to the local AgentEventService via a reverse SSH tunnel, enabling sounds and OS notifications for remote tasks. - Add reverse SSH tunnel (-R flag) to forward hook events from remote to local AgentEventService - Export EMDASH_HOOK_PORT/TOKEN/PTY_ID env vars in remote shell so hooks can reach the tunnel endpoint - Write Claude hook config (.claude/settings.local.json) on remote via ssh exec channel to avoid terminal line-buffer corruption - Make ClaudeHookService.makeHookCommand() public static so ptyIpc can build hook commands for the remote config - Add preProviderCommands support to buildRemoteInitKeystrokes
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR wires up reverse SSH tunnel support so that Claude Code running in a remote PTY session can fire Issues found:
Confidence Score: 2/5
Last reviewed commit: f85b23f |
src/main/services/ptyIpc.ts
Outdated
| function buildRemoteHookConfigCommand(cwd: string): string { | ||
| const notificationCmd = ClaudeHookService.makeHookCommand('notification'); | ||
| const stopCmd = ClaudeHookService.makeHookCommand('stop'); | ||
|
|
||
| const config = { | ||
| hooks: { | ||
| Notification: [{ hooks: [{ type: 'command', command: notificationCmd }] }], | ||
| Stop: [{ hooks: [{ type: 'command', command: stopCmd }] }], | ||
| }, | ||
| }; | ||
|
|
||
| const json = JSON.stringify(config); | ||
| const dir = `${cwd}/.claude`; | ||
| const filePath = `${dir}/settings.local.json`; | ||
|
|
||
| return `mkdir -p ${quoteShellArg(dir)} && printf '%s\\n' ${quoteShellArg(json)} > ${quoteShellArg(filePath)}`; |
There was a problem hiding this comment.
Remote settings.local.json is overwritten without merging
buildRemoteHookConfigCommand unconditionally overwrites the remote .claude/settings.local.json with a brand-new config containing only the emdash hook entries. It does not read the existing file first or preserve any user-defined hooks already present on the remote host.
This is inconsistent with the local ClaudeHookService.writeHookConfig(), which:
- Reads the existing file (lines 32–37)
- Strips only the emdash-owned entries by filtering on
EMDASH_HOOK_PORTmarker (lines 54–57) - Appends the fresh entries alongside user hooks (lines 58–62)
Result: Any hooks the user has configured in .claude/settings.local.json on the remote machine are silently destroyed every time an emdash SSH session is started.
Fix: Perform a read-modify-write on the remote (e.g., use jq or a small shell function via ssh exec to merge), or detect whether the file already contains an emdash entry and skip writing if present.
src/main/services/ptyIpc.ts
Outdated
| if (hookPort > 0) { | ||
| const remotePort = pickReverseTunnelPort(id); | ||
| ssh.args.push('-R', `127.0.0.1:${remotePort}:127.0.0.1:${hookPort}`); | ||
|
|
||
| // Use short `export` lines instead of a long `env K=V ...` prefix. | ||
| // Each line is resilient to SSH MOTD interleaving and the exports | ||
| // are inherited by the provider process launched via `exec`. | ||
| preProviderCommands.push( | ||
| `export EMDASH_HOOK_PORT=${quoteShellArg(String(remotePort))}`, | ||
| `export EMDASH_HOOK_TOKEN=${quoteShellArg(agentEventService.getToken())}`, | ||
| `export EMDASH_PTY_ID=${quoteShellArg(id)}` | ||
| ); | ||
|
|
||
| // For Claude, write hook config on the remote via ssh exec | ||
| // (not keystroke injection — long JSON lines get corrupted by | ||
| // terminal line-buffer limits when typed into the PTY). | ||
| if (providerId === 'claude' && cwd) { | ||
| try { | ||
| const hookCmd = buildRemoteHookConfigCommand(cwd); | ||
| await execFileAsync('ssh', [...ssh.args, ssh.target, hookCmd]); |
There was a problem hiding this comment.
-R tunnel flag inadvertently included in hook-config exec SSH call
ssh.args is mutated with push('-R', ...) on line 865 before the hook-config exec on line 882. Because the spread [...ssh.args, ssh.target, hookCmd] captures the already-mutated array, the config-writing exec connection also carries the reverse-tunnel flag.
This causes:
- SSH opens the exec connection and tries to bind the remote port for the tunnel
- The command finishes and the connection closes, releasing the port
- The main PTY (
startSshPty) then opens its own connection and tries to bind the same deterministic remote port
While these are sequential (the await ensures the exec completes first), passing -R to a short-lived exec that only needs to write a file is unintentional, adds overhead, and could cause a "remote port already in use" error if anything is already listening on that port.
Fix: Snapshot the original ssh.args before the -R push:
const baseArgs = [...ssh.args]; // snapshot before mutation
ssh.args.push('-R', `127.0.0.1:${remotePort}:127.0.0.1:${hookPort}`);
// ...
await execFileAsync('ssh', [...baseArgs, ssh.target, hookCmd]);
src/main/services/ptyIpc.ts
Outdated
| preProviderCommands.push( | ||
| `export EMDASH_HOOK_PORT=${quoteShellArg(String(remotePort))}`, | ||
| `export EMDASH_HOOK_TOKEN=${quoteShellArg(agentEventService.getToken())}`, | ||
| `export EMDASH_PTY_ID=${quoteShellArg(id)}` | ||
| ); |
There was a problem hiding this comment.
Hook token written as plaintext PTY keystrokes
EMDASH_HOOK_TOKEN is exported by typing the export line directly into the remote PTY shell, which means it will appear in the remote shell's command history (e.g., ~/.bash_history, ~/.zsh_history) and in any terminal-recording or audit-log tooling on the remote host.
Since the token is used to authenticate hook callbacks to AgentEventService, a token in shell history could potentially be read by other users or processes on the remote.
Consider: Passing the token out-of-band via SSH mechanisms like AcceptEnv/SendEnv, or by writing it to a temporary file through the already-established exec channel and sourcing that file from shell initialization (similar to how the hook config itself is written).
Move the -R push after the ssh exec call that writes the Claude hook config, so the short-lived exec connection doesn't unnecessarily bind the reverse tunnel port.
Read existing .claude/settings.local.json from the remote before writing, preserving user-defined hooks and settings. Uses the same merge logic as the local ClaudeHookService.writeHookConfig: strip old emdash entries by EMDASH_HOOK_PORT marker, append fresh ones.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
…eHookEntries Deduplicates the hook merging logic that was byte-for-byte identical in writeRemoteHookConfig (ptyIpc.ts) and writeHookConfig (ClaudeHookService.ts). Both now call the single ClaudeHookService.mergeHookEntries() static method.
Summary
-Rflag) so remote agent sessions can send Notification and Stop hook events back to the local AgentEventServiceEMDASH_HOOK_PORT/EMDASH_HOOK_TOKEN/EMDASH_PTY_IDenv vars in the remote shell for hook commands to reference.claude/settings.local.jsonhook config on the remote via SSH exec channel (avoids terminal line-buffer corruption from keystroke injection)ClaudeHookService.makeHookCommand()public static so it can be reused for remote config generationTest plan
Note
Medium Risk
Adds reverse SSH tunneling and remote hook configuration writes, which affects remote session startup and exposes a local hook token/port to the remote environment; failures could break remote agent launch or event delivery.
Overview
Remote
pty:startDirectnow optionally provisions hook event callbacks by adding anssh -Rreverse tunnel to the localAgentEventServiceand exportingEMDASH_HOOK_PORT/EMDASH_HOOK_TOKEN/EMDASH_PTY_IDin the remote init keystrokes.For Claude remote sessions, the PR writes/merges
.claude/settings.local.jsonon the remote viasshexec (read + write) to avoid PTY keystroke corruption, reusing newClaudeHookService.mergeHookEntries()andClaudeHookService.makeHookCommand()extracted from the previous inline logic.Unit tests extend
ptyIpccoverage for reverse-tunnel injection, hook env exports, Claude remote hook config writing, and the no-hook/no-Claude paths.Written by Cursor Bugbot for commit 024aa3f. This will update automatically on new commits. Configure here.