diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index e57c150e..7eeb3b6c 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -540,6 +540,32 @@ export function remoteDisplayPath( return `${remote.username}@${remote.host}:${path}`; } +/** + * Refresh HOST-level config on a RESTORED `SessionRemote`. + * + * A session's `remote` block is persisted at launch time (mux-sessions.json / + * state.json) and recovery uses that snapshot, so a field ADDED to the host config + * later never reaches an already-running session — not even across a Codeman + * restart. That is exactly how a `wakeCommand` added to `remote-hosts.json` would + * silently do nothing until the session is relaunched (which for an owned remote + * session means killing the remote tmux). + * + * Deliberately narrow: ONLY `wakeCommand` is taken from the host config, and the + * host is authoritative for it (removing it in the config turns the feature off + * again). The other host-level fields (`commands`, ssh options) stay as persisted + * so this cannot silently change how an existing pane connects. + */ +export function rehydrateRemoteHostFields( + remote: SessionRemote | undefined, + hostsById: ReadonlyMap +): SessionRemote | undefined { + if (!remote) return remote; + const host = hostsById.get(remote.hostId); + if (!host) return remote; + if (remote.wakeCommand === host.wakeCommand) return remote; + return { ...remote, wakeCommand: host.wakeCommand }; +} + export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote { return { hostId: host.id, diff --git a/src/web/server.ts b/src/web/server.ts index a863cb37..ffc13acd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -41,6 +41,7 @@ import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import { hostname as getHostname } from 'node:os'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; +import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js'; import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js'; import { GLYPH, palette } from '../cli-style.js'; import { getHookSecret } from '../config/hook-secret.js'; @@ -2867,6 +2868,9 @@ export class WebServer extends EventEmitter { // For each alive mux session, create a Session object if it doesn't exist const muxSessions = this.mux.getSessions(); + // Host-level config lives in remote-hosts.json, not in the persisted session + // snapshot, so refresh the fields that only exist there (see the helper). + const remoteHostsById = new Map((await readRemoteHosts(getDataDir())).map((host) => [host.id, host])); for (const muxSession of muxSessions) { if (!this.sessions.has(muxSession.sessionId)) { // Restore session settings from state.json (single source of truth) @@ -2943,7 +2947,9 @@ export class WebServer extends EventEmitter { // respawn rebuilds a LOCAL command, breaking the pane and silently // erasing `remote` from state.json on the next persist. mux-sessions.json // round-trips MuxSession.remote; state.json carries SessionState.remote. - remote: muxSession.remote ?? savedState?.remote, + // Host-level fields are refreshed from remote-hosts.json on top, or a + // field added to the host config after launch would never arrive. + remote: rehydrateRemoteHostFields(muxSession.remote ?? savedState?.remote, remoteHostsById), // Docker metadata round-trips the same way (mux-sessions.json carries // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 6883cd65..1395283c 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -6,6 +6,7 @@ import { defaultRemoteCommandForMode, readRemoteCases, readRemoteHosts, + rehydrateRemoteHostFields, remoteDisplayPath, remoteSshTarget, toSessionRemote, @@ -115,4 +116,53 @@ describe('remote-hosts domain', () => { expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false); }); }); + + describe('rehydrateRemoteHostFields', () => { + const persisted = { + hostId: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + remotePath: '/home/j/work', + }; + const hosts = (wakeCommand?: string) => + new Map([ + [ + 'hufflepuff', + { + id: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + ...(wakeCommand ? { wakeCommand } : {}), + }, + ], + ]); + + it('adds a wake command that only exists in the host config', () => { + // The pre-existing-session case: the field was added to remote-hosts.json after + // this session was persisted, so recovery is the only place it can arrive. + expect(rehydrateRemoteHostFields(persisted, hosts('/home/joe/bin/whuff'))?.wakeCommand).toBe( + '/home/joe/bin/whuff' + ); + }); + + it('treats the host config as authoritative (removing it turns the feature off)', () => { + const remote = { ...persisted, wakeCommand: '/home/joe/bin/whuff' }; + expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined(); + }); + + it('leaves the block untouched when the host is gone or the session is local', () => { + expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted); + expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined(); + }); + + it('keeps the other host-level fields as persisted', () => { + // Only wakeCommand is refreshed: silently re-pointing an existing pane's ssh + // options would be a behavior change nobody asked for. + const remote = { ...persisted, identityFile: '~/.ssh/pinned_key' }; + const rehydrated = rehydrateRemoteHostFields(remote, hosts('/home/joe/bin/whuff')); + expect(rehydrated?.identityFile).toBe('~/.ssh/pinned_key'); + }); + }); });