Skip to content

feat(ssh): enable hook notifications for remote sessions#1288

Merged
rabanspiegel merged 4 commits intomainfrom
emdash/remote-ssh-4az
Mar 5, 2026
Merged

feat(ssh): enable hook notifications for remote sessions#1288
rabanspiegel merged 4 commits intomainfrom
emdash/remote-ssh-4az

Conversation

@rabanspiegel
Copy link
Contributor

@rabanspiegel rabanspiegel commented Mar 5, 2026

Summary

  • Adds reverse SSH tunnel (-R flag) so remote agent sessions can send Notification and Stop hook events back to the local AgentEventService
  • Exports EMDASH_HOOK_PORT/EMDASH_HOOK_TOKEN/EMDASH_PTY_ID env vars in the remote shell for hook commands to reference
  • Writes Claude's .claude/settings.local.json hook config on the remote via SSH exec channel (avoids terminal line-buffer corruption from keystroke injection)
  • Makes ClaudeHookService.makeHookCommand() public static so it can be reused for remote config generation

Test plan

  • Unit tests for reverse tunnel injection, env var exports, Claude hook config via ssh exec, and non-Claude provider skip
  • All 392 tests pass, type-check clean, lint clean
  • Manual E2E test: SSH to localhost, created remote Claude task, confirmed notification and completion sounds fire

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:startDirect now optionally provisions hook event callbacks by adding an ssh -R reverse tunnel to the local AgentEventService and exporting EMDASH_HOOK_PORT/EMDASH_HOOK_TOKEN/EMDASH_PTY_ID in the remote init keystrokes.

For Claude remote sessions, the PR writes/merges .claude/settings.local.json on the remote via ssh exec (read + write) to avoid PTY keystroke corruption, reusing new ClaudeHookService.mergeHookEntries() and ClaudeHookService.makeHookCommand() extracted from the previous inline logic.

Unit tests extend ptyIpc coverage 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.

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
@vercel
Copy link

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 5, 2026 1:50am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR wires up reverse SSH tunnel support so that Claude Code running in a remote PTY session can fire Notification and Stop hook events back to the local AgentEventService. It adds deterministic remote-port selection (SHA-256 of ptyId), env-var exports injected as PTY keystrokes (EMDASH_HOOK_PORT, EMDASH_HOOK_TOKEN, EMDASH_PTY_ID), a remote .claude/settings.local.json write via a dedicated SSH exec channel (avoiding PTY line-buffer corruption), and promotes makeHookCommand to a public static method so it can be reused outside ClaudeHookService.

Issues found:

  • Remote settings.local.json is overwritten without mergingbuildRemoteHookConfigCommand uses a plain shell redirect (>), discarding any user-defined hooks already present in the file on the remote host. The local counterpart (ClaudeHookService.writeHookConfig) reads and merges before writing; this remote path does not. Result: user hooks are silently destroyed on each SSH session start.
  • -R tunnel flag is inadvertently included in the hook-config exec SSH callssh.args is mutated with the -R spec before the exec call that writes the config file, so the short-lived exec connection unnecessarily attempts to establish (and immediately tears down) a reverse tunnel on the deterministic port. This adds overhead and could cause port-binding errors.
  • Hook token written as PTY keystroke — exporting EMDASH_HOOK_TOKEN as a typed shell command leaves the token value in the remote shell history and any terminal audit logs, potentially exposing it to other users/processes on the remote.

Confidence Score: 2/5

  • Not safe to merge. The PR contains two critical logic bugs in the reverse-tunnel and remote-config setup that affect correctness, plus a secondary security concern about token exposure.
  • Two blocking issues prevent merge: (1) remote settings.local.json silently overwrites user hooks every session, destroying user configuration; (2) the -R tunnel flag is unnecessarily included in the hook-config exec call, adding overhead and risking port conflicts. A third secondary issue—hook token in shell history—also warrants attention but is less immediately blocking. The tunnel wiring and ClaudeHookService refactor are otherwise clean, but these logic bugs must be resolved before the PR is safe to merge.
  • src/main/services/ptyIpc.ts — specifically the buildRemoteHookConfigCommand function (needs read-merge-write logic), the ssh.args mutation timing (snapshot original args before push), and the hook-token export mechanism (consider out-of-band passing).

Last reviewed commit: f85b23f

Comment on lines +126 to +141
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)}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reads the existing file (lines 32–37)
  2. Strips only the emdash-owned entries by filtering on EMDASH_HOOK_PORT marker (lines 54–57)
  3. 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.

Comment on lines +863 to +882
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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-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]);

Comment on lines +870 to +874
preProviderCommands.push(
`export EMDASH_HOOK_PORT=${quoteShellArg(String(remotePort))}`,
`export EMDASH_HOOK_TOKEN=${quoteShellArg(agentEventService.getToken())}`,
`export EMDASH_PTY_ID=${quoteShellArg(id)}`
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@rabanspiegel rabanspiegel merged commit 6efb1f1 into main Mar 5, 2026
5 checks passed
@rabanspiegel rabanspiegel deleted the emdash/remote-ssh-4az branch March 5, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant