From 797f0d387cb31c04f7384568f96cbf09b4a4cd6c Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 7 Sep 2026 22:11:54 -0500 Subject: [PATCH] fix(remote): address review feedback on omp/claude respawn continuity - Remote omp command now renders through buildSpawnCommandFromRegistry (the mode-agnostic engine local/docker spawns use) instead of the buildOmpCommand() the CLI-registry refactor deleted. - Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip host-local ~/.omp resolution entirely for a remote session and fall back to --continue: that resolver only ever reads THIS host's filesystem, which is meaningless (and could wrongly alias an unrelated local conversation) for a conversation that lives on the remote host. - Remote-claude launch now honors an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand's shape), and validates sessionId the same way that sibling does before interpolating it into the remote shell command. - Add the still-missing header-cwd half of the trailing-slash test, and document respawn/reattach continuation + auto-reconnect-vs- clean-exit in docs/remote-sessions.md. Co-Authored-By: Claude Sonnet 5 --- docs/remote-sessions.md | 90 +++++++++++++++++++++------- src/session.ts | 18 ++++++ src/tmux-manager.ts | 29 ++++++--- test/omp-fresh-run-no-resume.test.ts | 46 +++++++++++++- test/omp-session-resolver.test.ts | 33 +++++++++- test/tmux-manager.test.ts | 22 +++++++ 6 files changed, 206 insertions(+), 32 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index d1232e1d..32b3b36b 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -24,14 +24,14 @@ custom port, identity file, `-J` jump host, `-o ProxyCommand`). Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`. -| Type | Role | -|------|------| -| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. | -| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). | -| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. | +| Type | Role | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. | +| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). | +| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. | | `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. | -| `RemoteCommandMode` | `Extract` — the modes that can run remotely. | -| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. | +| `RemoteCommandMode` | `Extract` — the modes that can run remotely. | +| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. | Persistence is two flat JSON arrays in the instance data dir: @@ -73,7 +73,7 @@ Rules that keep this safe — **do not bypass them by hand-building an ssh line single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors the one in `tmux-manager.ts`. - **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`), - *before* escaping — ssh does not expand `~` inside `-i`, and the escaped value + _before_ escaping — ssh does not expand `~` inside `-i`, and the escaped value never reaches a shell that would. - **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive @@ -112,7 +112,7 @@ Key points: asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman` socket** — they join sessions the remote's own Codeman manages, while owned durable launches live on `-L codeman-remote`. -- **`exec `** replaces the pane shell with the agent, so the pane PID *is* +- **`exec `** replaces the pane shell with the agent, so the pane PID _is_ the agent. The per-mode command comes from `remote.commands?.[mode]` or `defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` / `exec codex` / `exec gemini` / `exec agy` / `exec bash -l`). @@ -128,9 +128,9 @@ Because durable remote sessions require tmux on the remote host, `checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before** creating a remote case/session and returns a structured, never-throwing result: -- empty stdout / non-zero exit → *"remote host `` needs tmux installed for - durable remote sessions"* -- stderr present → *"could not verify tmux on remote host ``: ``"* +- empty stdout / non-zero exit → _"remote host `` needs tmux installed for + durable remote sessions"_ +- stderr present → _"could not verify tmux on remote host ``: ``"_ (a real connection failure, surfaced to the operator) - success → `{ ok: true, tmuxPath }` @@ -147,7 +147,7 @@ skipped; command construction is still asserted by unit tests. ## Ownership: launched vs. discovered-and-attached (COD-105) -COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it. +COD-104 (above) was Phase 1 — Codeman _launches_ a remote session and owns it. COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions already running on a remote host (created by the remote's own Codeman or another instance) and **attach** to one it didn't launch. Ownership decides what happens @@ -188,7 +188,7 @@ remote command line by ownership: - **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits `ssh … -t … 'tmux -L codeman attach -t '`. It uses **`attach`, - NOT `new-session -A`**, so it only *joins* an existing session and never creates + NOT `new-session -A`**, so it only _joins_ an existing session and never creates one. - **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above). @@ -196,24 +196,68 @@ remote command line by ownership: `TmuxManager.killSession()` has an **early return for non-owned remote sessions**: it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman -kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the +kill-session` on _this_ host's socket). Killing the local ssh sends SIGHUP to the remote `tmux attach`, which **detaches** — the durable remote session survives. The early return is a structural guarantee that **no code path can ever issue a remote `kill-session` for a session we don't own** — the only `kill-session` run is on the local socket, which never reaches the remote socket. +## Respawn / reattach continuation + +A dropped connection or a dead pane must reconnect to the **same conversation**, +not launch a fresh one — the whole point of a durable remote session. + +- **Claude**: the launch command is idempotent — `claude --session-id || +claude --resume ` (see `buildRemoteLaunchCommand`'s claude branch). The + first run creates the conversation under the deterministic session id; every + later reattach/respawn re-runs the same line, `--session-id` fails + ("already in use"), and the `||` fallback resumes it. +- **OMP**: `omp` has no equivalent idempotent single-line form, so + `Session._pinOmpRespawnId()` resolves and pins an explicit `--resume ` + before a respawn (mirroring the local/docker builders, rendered through the + same `buildSpawnCommandFromRegistry` engine — not a hand-rolled command and + not `appendResumeFlag()`, which is docker-only and cannot work here: appending + a flag after the quoted `-c 'omp'` hands the id to the login shell as `$0` + instead of to `omp`). ⚠️ **The resolver only ever reads THIS host's local + `~/.omp/agent/sessions/`**, which is meaningless for a remote session — the + conversation and its session file live on the remote host, under the remote + user's home. For a remote session, `_pinOmpRespawnId()` therefore skips local + resolution entirely and falls back to `omp`'s own ambiguous `--continue` + (`ompConfig.continueSession`), which the remote pane command already renders. + This is a known, accepted degradation versus the local/docker paths' exact + `--resume` pin — safe in practice because each remote respawn talks to + exactly one remote pane's own omp history, so "most recent" is normally + correct, but it can drift the same way `--continue` always could if two + remote sessions ever share one remote directory. + +## Auto-reconnect vs. a clean agent exit + +`remoteAutoReconnect` (default ON) watches for a dropped SSH connection and +reconnects with bounded backoff. It must **never** revive a session whose agent +exited cleanly (Ctrl-C, Ctrl-D, `exit`) — that tears down the durable remote +tmux session itself, and a transport-level `isPaneDead()` cannot tell that apart +from a plain network drop. `remoteTmuxSessionAlive()` (#355) resolves this by +probing the remote host directly: `tmux -L codeman-remote has-session -t +codeman-ssh-` over the same `buildSshConnectionArgs` as launch, classified +by **exit status alone** (`classifyRemoteAliveExit`: `0` = alive, ssh's `255` or +a timeout = unknown, anything else = gone) — `has-session` prints nothing on +success, so reading stdout would misclassify every live session as gone. An +unreachable host answers "unknown", which also means do not revive. The answer +is cached per session and cleared whenever the pane is next seen alive, so a +stale `true` from one transport drop can never revive the NEXT clean exit. + ## API Routes are registered in `src/web/routes/case-routes.ts`: -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/api/remote-hosts` | List saved hosts | -| `POST` | `/api/remote-hosts` | Create a host | -| `PUT` | `/api/remote-hosts/:id` | Update a host | -| `DELETE` | `/api/remote-hosts/:id` | Delete a host | -| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) | -| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) | +| Method | Path | Purpose | +| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `GET` | `/api/remote-hosts` | List saved hosts | +| `POST` | `/api/remote-hosts` | Create a host | +| `PUT` | `/api/remote-hosts/:id` | Update a host | +| `DELETE` | `/api/remote-hosts/:id` | Delete a host | +| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) | +| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) | Attaching to a discovered session is a **session-create** path, not a host route: `POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }` diff --git a/src/session.ts b/src/session.ts index 59caaf55..67935a5c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1775,6 +1775,19 @@ export class Session extends EventEmitter { // (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that // fix intact now that resolution has moved out of the eager options build. if (!this._muxSession) return; + // `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.omp/agent/sessions/`, which is + // meaningless for a remote session — the conversation and its session file live on the + // remote host, under the REMOTE user's home. Worse than a no-op: `this.workingDir` for a + // remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine + // happens to have its own omp history under a directory that mangles to the same name, + // this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a + // remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote + // pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there + // because each remote respawn talks to exactly one remote pane's own omp history. + if (this._remote) { + this._ompConfig = { ...this._ompConfig, continueSession: true }; + return; + } const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; @@ -2568,6 +2581,11 @@ export class Session extends EventEmitter { */ private _maybeCaptureOmpSessionId(): void { if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return; + // Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a + // remote session lives on the remote host, not here, so scanning locally risks aliasing + // this session onto an unrelated local omp conversation that happens to mangle to the + // same directory name. Never resolvable from here — skip. + if (this._remote) return; try { const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8e9456de..fd6bfac1 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -778,19 +778,34 @@ export function buildRemoteLaunchCommand(options: { modeCommand = override; } else if (mode === 'claude') { // Deterministic conversation pinning for SSH-remote claude (mirrors the - // docker-claude shape in claudeDockerPaneCommand): the FIRST run creates - // the conversation under --session-id ; a respawn / reattach - // re-runs the same idempotent command, --session-id exits non-zero - // ("already in use"), and the `||` fallback RESUMES that same + // docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct + // resumeId branch it declares — this used to only mirror the same-id + // fallback shape, silently dropping an explicit resumeSessionId that + // differs from sessionId, e.g. a resume-from-history launch): the FIRST + // run creates the conversation under --session-id ; a respawn + // / reattach re-runs the same idempotent command, --session-id exits + // non-zero ("already in use"), and the `||` fallback RESUMES that same // conversation. Without a pinned id, every reattach relaunched a bare // `claude` and started a NEW conversation (found live 2026-08-29: remote // claude ctrl-d / ctrl-c relaunched a fresh session). A per-host // `commands.claude` override stays authoritative (admin's explicit // choice) and skips this entirely. const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools); - modeCommand = remoteLoginShellCommand( - `claude${permFlags} --session-id ${sessionId} || claude${permFlags} --resume ${sessionId}` - ); + const cmd = `claude${permFlags}`; + // Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check: + // sessionId is server-minted and always safe in practice, but this command is built + // as a single shellescaped string and then executed as shell code on the remote + // host, so an unsafe value here is validated rather than trusted. + if (!RESUME_ID_SAFE.test(sessionId)) { + modeCommand = remoteLoginShellCommand(cmd); + } else { + const rid = resumeSessionId && RESUME_ID_SAFE.test(resumeSessionId) ? resumeSessionId : undefined; + modeCommand = remoteLoginShellCommand( + rid && rid !== sessionId + ? `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}` + : `${cmd} --session-id ${sessionId} || ${cmd} --resume ${sessionId}` + ); + } } else if (mode === 'omp') { // Remote OMP respawn must RESUME the same conversation instead of // relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched diff --git a/test/omp-fresh-run-no-resume.test.ts b/test/omp-fresh-run-no-resume.test.ts index 49c9f48e..fd1302ec 100644 --- a/test/omp-fresh-run-no-resume.test.ts +++ b/test/omp-fresh-run-no-resume.test.ts @@ -25,7 +25,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { Session } from '../src/session.js'; import { TmuxManager } from '../src/tmux-manager.js'; -import type { MuxSession } from '../src/types.js'; +import type { MuxSession, SessionRemote } from '../src/types.js'; describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => { const workingDir = join(homedir(), 'codeman-cases', 'resume-test'); @@ -126,4 +126,48 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid'); expect(session.claudeSessionId).toBe('real-omp-uuid'); }); + + it("a remote session never resolves --resume from this host's local ~/.omp, even when a same-named local session file exists", () => { + // Seed a LOCAL session file whose directory mangle happens to match this + // remote session's remotePath. If _pinOmpRespawnId() ever fell through to + // resolveAndClaimOmpSessionId() for a remote session, it would wrongly + // claim/pin this unrelated local conversation's id onto the remote respawn. + seedOmpSessionFile('wrong-local-conversation-id'); + + const remote: SessionRemote = { + hostId: 'remote-box', + label: 'remote-box', + host: 'remote-box', + username: 'someone', + remotePath: workingDir, + owned: true, + }; + + const muxSession: MuxSession = { + sessionId: 'placeholder', + muxName: 'codeman-deadbeef', + pid: 1, + createdAt: Date.now(), + workingDir, + mode: 'omp', + attached: false, + }; + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + muxSession, + remote, + }); + sessions.push(session); + + (session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId(); + + const state = session.toState(); + expect(state.ompConfig?.resumeSessionId).toBeUndefined(); + expect(state.ompConfig?.continueSession).toBe(true); + expect(session.claudeSessionId).toBe(session.id); + }); }); diff --git a/test/omp-session-resolver.test.ts b/test/omp-session-resolver.test.ts index d1d528b8..31089b81 100644 --- a/test/omp-session-resolver.test.ts +++ b/test/omp-session-resolver.test.ts @@ -20,7 +20,11 @@ import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js'; +import { + findLatestOmpSessionId, + mangleOmpWorkingDir, + resolveAndClaimOmpSessionId, +} from '../src/utils/omp-session-resolver.js'; import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js'; describe('mangleOmpWorkingDir', () => { @@ -78,6 +82,33 @@ describe('findLatestOmpSessionId', () => { }); }); +describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => { + // Sibling of the directory-mangle trailing-slash regression above, but for + // the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally + // verifies each candidate file's own header `cwd` against workingDir (the + // mangle is lossy, so the filename-derived id alone isn't enough — see the + // function's doc comment). A remote case's workingDir carries a trailing + // slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does; + // without stripTrailingSlash() on BOTH sides of that comparison, a real + // on-disk session would be found by directory but rejected by the cwd + // check, silently degrading pinning to the ambiguous `--continue`. + const workingDirNoSlash = join(homedir(), 'dotfiles'); + const workingDirWithSlash = `${workingDirNoSlash}/`; + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles'); + + afterEach(() => { + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + it('matches a header cwd with no trailing slash against a workingDir that has one', () => { + mkdirSync(sessionDir, { recursive: true }); + const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`; + writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header); + + expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid'); + }); +}); + describe('resolveOmpConfigForCreate', () => { // The exact pipeline "resume this OMP row from the history list" drives: // POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true} diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 27cea00d..e1b0f449 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -187,6 +187,28 @@ describe('TmuxManager (unit)', () => { expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456'); expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456'); }); + + it('resumes an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand)', () => { + // The docker-claude builder (claudeDockerPaneCommand) has always handled a + // resumeId that differs from sessionId — e.g. a resume-from-history launch — + // by leading with `--resume || --session-id `. The remote + // claude branch used to only mirror the SAME-id fallback shape and silently + // dropped a distinct resumeSessionId, so a remote resume-from-history claude + // launch created a brand-new conversation instead of resuming the named one. + const command = buildRemoteLaunchCommand({ + mode: 'claude', + remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' }, + sessionId: 'abc123def456', + resumeSessionId: 'old-conversation-uuid', + }); + expect(command).toContain('claude --dangerously-skip-permissions --resume old-conversation-uuid'); + expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456'); + // The resume attempt must lead — session-id is the fallback here, reversed + // from the same-id case. + const resumeIdx = command.indexOf('--resume old-conversation-uuid'); + const sessionIdIdx = command.indexOf('--session-id abc123def456'); + expect(resumeIdx).toBeLessThan(sessionIdIdx); + }); }); describe('remote kill command builder', () => {