fix(remote): a proxied host is reachability-unknown; scope remote: SSE per session

Review round 2 on #439.

1. The bare TCP probe connects to host:port, which a host behind a jump host
   or SOCKS proxy does not answer even while ssh works. Acting on that
   verdict drew a permanent banner over a healthy session, replaced a real
   "needs tmux" error with "not reachable" in quick-start, and - with a wake
   target - buffered every HTTP input for the life of the session, since the
   readiness poll could never succeed. `WakeableRemote` now carries
   `jumpHost`/`socksProxy`/`extraSshOptions`, and `isProbeable()` turns such
   a host into reachability-UNKNOWN: input is delivered, `checkReachable` /
   `checkHostReachable` answer `null` (never `false`), `ensureHostAwake`
   returns `'unprobeable'` (handled like `'no-target'`), the quick-start gate
   fires on `=== false` only, and `GET …/reachability` reports
   `reachable: null, probeable: false` so the banner has nothing to key on.
   A wake target can still be fired for it, blind: no readiness poll, no
   reattach, no toast - the response says only whether the packet went out.

2. `'remote:'` joins the session-scoped SSE prefixes. The create/attach wake
   has no session yet, so the registry names the requesting user
   (`ensureHostAwake({ requestedBy })` -> `username` in the payload) and
   `deriveSseHint` routes on it; with neither it fails closed to admins.
   Single-user mode is unaffected.

Smaller, from the same review:

- A flush write that fails now drops the remaining buffer (logged) instead
  of retaining it: the wake still resolved and marked the host reachable, so
  the retained chunk waited for the NEXT wake and was replayed hours later,
  after everything typed since. Same policy as the oversized paste.
- The banner polls on tab activation (a user action) and on its 30 s timer
  only for a host with a wake target; a timer connecting to a host Codeman
  cannot wake is the traffic invariant #2 rejects keepalives for. A proxied
  host is never polled.
- `probeRemoteHostReachable`, `runRemoteWakeCommand` and the default UDP
  socket refuse under VITEST, as remote-files.ts does. The guard caught a
  leak on the spot: `createDefaultRemoteWakeDeps({ probe })` overrode the
  probe but still polled readiness with the real one, so the shutdown test
  had been connecting to a production address. The poll now uses the
  injected probe.
- docs/remote-sessions.md is additions only again (the reformatting is
  gone); the architecture-invariants overlap resolved itself in the merge.

Live, against a throwaway instance with a non-routable ghost host: proxied
-> no probe, no wake, the genuine ssh error after 10 s; direct (control) ->
probe, magic packet, "did not come back" after the 40 s budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
Randalix
2026-09-18 22:46:11 +02:00
co-authored by Claude Opus 5
parent e271a65e79
commit 1040f6c489
12 changed files with 609 additions and 83 deletions
+138 -24
View File
@@ -147,6 +147,29 @@ export interface WakeableRemote {
label: string;
host: string;
port?: number;
/** SSH jump host (`-J`): the host is reached THROUGH it, never directly. */
jumpHost?: string;
/** SOCKS5 proxy (`ProxyCommand=nc -X 5 …`): same, the direct address may not even route. */
socksProxy?: string;
/** Extra `-o KEY=VALUE` options; a `ProxyCommand`/`ProxyJump` in here proxies the host too. */
extraSshOptions?: string[];
}
/**
* Whether the bare TCP probe can answer for this host at all. Pure.
*
* The probe connects straight to `host:port`. A host behind a jump host or a SOCKS
* proxy (the cloudflared case) is reachable ONLY through that proxy, so the direct
* connect fails while ssh works — and every consumer of the verdict would then act on
* a "sleeping" host that is fine: a permanent banner, a create-path gate that hides the
* real ssh error, and (with a wake target) input buffered for the life of the session
* because the readiness poll can never succeed. Such a host is reachability-UNKNOWN:
* the registry never buffers for it, never gates on it, and reports `null` rather than
* `false`. A wake target can still be fired for it, blind.
*/
export function isProbeable(remote: WakeableRemote): boolean {
if (remote.jumpHost || remote.socksProxy) return false;
return !(remote.extraSshOptions ?? []).some((option) => /^\s*proxy(command|jump)\s*=/i.test(option));
}
/**
@@ -270,12 +293,14 @@ export function wakeConfigured(remote: WakeableRemote | undefined): WakeConfigur
/**
* Outcome of waking a host for a caller that has NO session yet (the create/attach
* routes). A union rather than a boolean because the three cases need different
* handling: `'no-target'` must leave the caller's behavior byte-identical (no probe,
* no extra latency for a host without WoL), and only `'failed'` is an error that
* deserves its own message instead of the caller's usual one.
* routes). A union rather than a boolean because the cases need different handling:
* `'no-target'` must leave the caller's behavior byte-identical (no probe, no extra
* latency for a host without WoL), `'unprobeable'` likewise (a proxied host, see
* {@link isProbeable} — the probe cannot tell asleep from awake, so nothing is gated on
* it), and only `'failed'` is an error that deserves its own message instead of the
* caller's usual one.
*/
export type HostWakeOutcome = 'no-target' | 'ready' | 'failed';
export type HostWakeOutcome = 'no-target' | 'unprobeable' | 'ready' | 'failed';
/**
* State key for a host-scoped wake. Prefixed so it can never collide with a session
@@ -368,15 +393,21 @@ export class RemoteWakeRegistry {
}
/**
* Reachability for the UI: probe unless a recent result is still fresh.
* Reachability for the UI: probe unless a recent result is still fresh. `null` for a
* host the probe cannot reach (see {@link isProbeable}): unknown is not unreachable.
*
* Shares the per-session probe state with the input path on purpose — a fresh
* answer is exactly what the input ladder wants, and an `unreachable` verdict here
* makes the next keystroke buffer + wake instead of vanishing into a stalled pane.
*/
async checkReachable(session: WakeableSession, opts: { force?: boolean; ttlMs?: number } = {}): Promise<boolean> {
async checkReachable(
session: WakeableSession,
opts: { force?: boolean; ttlMs?: number } = {}
): Promise<boolean | null> {
const remote = await this._effectiveRemote(session);
if (!remote) return true;
// `null`, never `false`: the UI keys the banner on a PROVEN unreachable host.
if (!isProbeable(remote)) return null;
const state = this._state(session.id);
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
if (Date.now() - state.probedAt >= ttl) {
@@ -396,6 +427,10 @@ export class RemoteWakeRegistry {
*/
async handleInput(session: WakeableSession, data: string): Promise<RemoteInputOutcome> {
const remote = await this._effectiveRemote(session);
// A proxied host can never pass the readiness poll, so buffering for it would hold
// the bytes for the life of the session (reproduced upstream: three inputs, nothing
// written, no reattach). Deliver, as if the feature were off.
if (remote && !isProbeable(remote)) return 'deliver';
const state = this._state(session.id);
const target = resolveWakeTarget(remote);
const action = decideRemoteInputAction({
@@ -433,7 +468,11 @@ export class RemoteWakeRegistry {
async ensureAwake(session: WakeableSession, opts: { force?: boolean; timeoutMs?: number } = {}): Promise<boolean> {
if (this.stopped) return false;
const remote = await this._effectiveRemote(session);
if (!remote || !resolveWakeTarget(remote)) return true;
const target = resolveWakeTarget(remote);
if (!remote || !target) return true;
// A proxied host: the send-and-wait path has nothing to gate on (unknown is not
// asleep), so it delivers; the manual button still wakes, blind (see `wake`).
if (!isProbeable(remote)) return opts.force ? this.wake(session, opts) : true;
const state = this._state(session.id);
// `force` is the manual path (a user pressed "wake"): a cached "reachable" from
// seconds ago must not talk the button out of waking a host that just slept.
@@ -452,9 +491,16 @@ export class RemoteWakeRegistry {
* Host-scoped reachability, for a caller that has no session yet (create/attach).
* Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the
* wake flow just paid for also answers "was that ssh failure really a sleeping
* machine?". Never wakes anything — it is a question, not an action.
* machine?". Never wakes anything — it is a question, not an action. `null` when the
* question cannot be answered (see {@link isProbeable}).
*/
async checkHostReachable(remote: WakeableRemote, opts: { force?: boolean; ttlMs?: number } = {}): Promise<boolean> {
async checkHostReachable(
remote: WakeableRemote,
opts: { force?: boolean; ttlMs?: number } = {}
): Promise<boolean | null> {
// `null` for a proxied host: callers gate on `=== false` (proven unreachable), so an
// unknown verdict leaves their ordinary error path — "needs tmux" — intact.
if (!isProbeable(remote)) return null;
const state = this._state(hostWakeKey(remote.hostId));
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
if (Date.now() - state.probedAt >= ttl) {
@@ -472,8 +518,14 @@ export class RemoteWakeRegistry {
* nothing and behaves exactly as before. Single-flight per host, so a double click
* (or two cases on the same host) sends one packet and shares one readiness poll.
*/
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
async ensureHostAwake(
remote: WakeableRemote,
opts: { timeoutMs?: number; requestedBy?: string } = {}
): Promise<HostWakeOutcome> {
if (!resolveWakeTarget(remote)) return 'no-target';
// The probe cannot tell a proxied host asleep from awake, and a wake that cannot
// verify readiness would only delay the request by its whole budget. Not gated.
if (!isProbeable(remote)) return 'unprobeable';
if (this.stopped) return 'failed';
const state = this._state(hostWakeKey(remote.hostId));
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
@@ -493,12 +545,16 @@ export class RemoteWakeRegistry {
* user-initiated and the host only wakes once), and keying them together would mean a
* create request joining an unrelated session's wake and inheriting its budget.
*/
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number; requestedBy?: string }): Promise<boolean> {
const state = this._state(hostWakeKey(remote.hostId));
if (state.waking) return state.waking;
state.waking = (async (): Promise<boolean> => {
try {
return await this._wakeAndWait(remote, state, { timeoutMs: opts.timeoutMs, forNewSession: true });
return await this._wakeAndWait(remote, state, {
timeoutMs: opts.timeoutMs,
forNewSession: true,
requestedBy: opts.requestedBy,
});
} catch (err) {
// Injected IO is documented not to throw, but a rejected promise here would
// surface as an unhandled rejection AND take the route down with it (the
@@ -522,6 +578,7 @@ export class RemoteWakeRegistry {
const remote = await this._effectiveRemote(session);
const target = resolveWakeTarget(remote);
if (!remote || !target) return true;
if (!isProbeable(remote)) return this._wakeBlind(remote, target);
const state = this._state(session.id);
if (state.waking) return state.waking;
@@ -556,6 +613,24 @@ export class RemoteWakeRegistry {
return state.waking;
}
/**
* Fire the wake target for a host whose readiness cannot be verified (see
* {@link isProbeable}): no readiness poll (it could never succeed), no reattach (the
* COD-108 watcher owns the pane once ssh works again), no `hostWaking` broadcast (its
* toast promises a wait that does not happen). The caller learns only whether the
* packet/command went out — and a wake IO that throws is a failed wake, never a
* rejected route.
*/
private async _wakeBlind(remote: WakeableRemote, target: NonNullable<WakeTarget>): Promise<boolean> {
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind}, blind: proxied host`);
try {
return await this.deps.wake(target);
} catch (err) {
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
/**
* Broadcast + run the wake target + wait for SSH. Shared by the session flow (which
* then reattaches and flushes the buffer) and the create/attach flow (which has no
@@ -565,11 +640,18 @@ export class RemoteWakeRegistry {
private async _wakeAndWait(
remote: WakeableRemote,
state: WakeState,
opts: { sessionId?: string; timeoutMs?: number; forNewSession?: boolean } = {}
opts: { sessionId?: string; timeoutMs?: number; forNewSession?: boolean; requestedBy?: string } = {}
): Promise<boolean> {
const target = resolveWakeTarget(remote);
if (!target) return true;
const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session';
// Routing for multi-user mode (server.ts `deriveSseHint`): a session-scoped event
// reaches its owner, and the create/attach wake has no session yet — so it names
// the requesting user instead, or it would reach admins only. The payload carries
// `hostId`/`label`, which non-admins are not shown elsewhere, so it must not go global.
const scope = opts.sessionId
? { sessionId: opts.sessionId }
: { forNewSession: true, ...(opts.requestedBy ? { username: opts.requestedBy } : {}) };
// No `sessionId` for a create-path wake: the toast handler is then the only one
// that acts (a banner for a session that does not exist yet would have no target),
// which is exactly the `forNewSession` distinction the UI renders.
@@ -580,7 +662,7 @@ export class RemoteWakeRegistry {
// something the user can disprove by typing (browser keystrokes go over the
// WebSocket, which never passes through this registry).
this.deps.broadcast?.('remote:hostWaking', {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
...scope,
hostId: remote.hostId,
label: remote.label,
queuedInput: state.pending.length > 0,
@@ -603,7 +685,7 @@ export class RemoteWakeRegistry {
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
);
this.deps.broadcast?.('remote:hostWakeFailed', {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
...scope,
hostId: remote.hostId,
label: remote.label,
queuedInput: state.pending.length > 0,
@@ -699,10 +781,15 @@ export class RemoteWakeRegistry {
state.pending = state.pending.slice(1);
const ok = await session.writeViaMux(chunk).catch(() => false);
if (!ok) {
// Retain it, IN ORDER: a failed write must not reorder the queue behind it.
state.pending = [chunk, ...state.pending];
// Drop the rest, and say so. Retaining it looked safer but was worse: the wake
// still resolves and marks the host reachable, so the NEXT input takes the
// deliver path while the old chunks sit here — to be replayed by the next wake,
// possibly hours later, after everything typed since, and maybe ending in a
// carriage return. Same policy as the oversized paste: gone, with a log line.
const dropped = state.pending.length + 1;
state.pending = [];
this.deps.log?.(
`[RemoteWake] flush failed for session ${session.id} — ${state.pending.length} chunk(s) retained`
`[RemoteWake] flush failed for session ${session.id} — ${dropped} buffered chunk(s) dropped rather than replayed on a later wake`
);
return;
}
@@ -713,7 +800,21 @@ export class RemoteWakeRegistry {
// ========== Default IO ==========
/**
* Cheap reachability probe: a bare TCP connect to the SSH port.
* Under vitest none of this may do real IO (a TCP connect, a child process, a UDP
* broadcast) — mirrors `remote-files.ts`. Every consumer injects its deps
* (`RemoteWakeDeps`, the socket factory); this is what makes that seam non-optional
* instead of a convention the next test can forget.
*/
function assertNotUnderTest(what: string): void {
if (process.env.VITEST) {
throw new Error(`remote-wake: ${what} is disabled under test — inject a fake (RemoteWakeDeps / WakeSocketFactory)`);
}
}
/**
* Cheap reachability probe: a bare TCP connect to the SSH port. Only meaningful for a
* host the registry deems probeable (see {@link isProbeable}); the registry never asks
* it about a proxied host.
*
* Deliberately NOT an `ssh … true` probe: that opens a full session (auth,
* remote log, process) every throttle window for a question a SYN already
@@ -725,6 +826,7 @@ export function probeRemoteHostReachable(
remote: WakeableRemote,
timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS
): Promise<boolean> {
assertNotUnderTest('the TCP probe');
const port = remote.port ?? DEFAULT_SSH_PORT;
return new Promise((resolve) => {
let settled = false;
@@ -748,6 +850,7 @@ export function probeRemoteHostReachable(
* than throwing: a broken wake command must not break the input route.
*/
export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS): Promise<boolean> {
assertNotUnderTest('the wake command');
return new Promise((resolve) => {
let settled = false;
const finish = (value: boolean) => {
@@ -790,7 +893,10 @@ export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_CO
export function sendWakePackets(
addresses: number[][],
port = 9,
createSocket: WakeSocketFactory = () => dgram.createSocket('udp4')
createSocket: WakeSocketFactory = () => {
assertNotUnderTest('the UDP broadcast');
return dgram.createSocket('udp4');
}
): Promise<boolean> {
if (addresses.length === 0) return Promise.resolve(false);
return new Promise((resolve) => {
@@ -884,12 +990,20 @@ function delayOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** Production wiring: all IO defaults, overridable for tests. */
/**
* Production wiring: all IO defaults, overridable for tests.
*
* The readiness poll uses the SAME probe as the rest of the deps, overridden or not.
* Wiring it to the module default instead let a caller that injected `probe` still
* poll the real host during the wait — under vitest, a TCP connect to a production
* address on every shutdown test (which the vitest guard is what finally caught).
*/
export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> = {}): RemoteWakeDeps {
const probe = overrides.probe ?? probeRemoteHostReachable;
return {
probe: probeRemoteHostReachable,
probe,
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, opts),
waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, { ...opts, probe }),
delay,
...overrides,
};
+35 -7
View File
@@ -9,10 +9,17 @@
* nothing happening" into one click.
*
* Behavior:
* - Polls `GET /api/sessions/:id/reachability` for the ACTIVE remote session only
* (on tab activation and every `POLL_MS` while the tab is visible). The endpoint
* shares the server's probe cache with the input path, so opening the tab also
* primes the wake path.
* - Asks `GET /api/sessions/:id/reachability` for the ACTIVE remote session only:
* once when the tab is activated (a user action), and every `POLL_MS` while the tab
* is visible ONLY for a host with a wake target. The timer is the one thing here that
* is not user-driven, and each poll is a TCP connect to the host — the same
* timer-driven traffic invariant #2 rejects keepalives for: it cannot wake a host,
* but it can keep an activity-based suspend timer from firing. So a host Codeman
* could not wake anyway is never polled on a timer. A host behind a jump host or
* SOCKS proxy (`probeable: false`) is never polled at all: the probe cannot reach
* it, so its answer would only ever be a false "asleep". The endpoint shares the
* server's probe cache with the input path, so opening the tab also primes the
* wake path.
* - Unreachable + a configured wake target → "Wake" button → `POST /api/sessions/:id/wake`
* (which wakes, waits, reattaches the pane and flushes buffered input).
* - Unreachable + NO wake target → "Configure WoL" → `#wakeConfigModal`, a small form
@@ -46,6 +53,11 @@ Object.assign(CodemanApp.prototype, {
wakeConfigured: 'none',
host: '',
label: '',
/**
* False for a host the server's probe cannot reach (behind a jump host or SOCKS
* proxy): its reachability is unknown, so there is no banner and no polling.
*/
probeable: true,
/** True between clicking Wake and the answer coming back. */
waking: false,
/**
@@ -79,17 +91,22 @@ Object.assign(CodemanApp.prototype, {
/** Create the page-wide poller once (interval + a visibility wake-up). */
_ensureHostWakePoller() {
if (this._hostWakeTimer) return;
this._hostWakeTimer = setInterval(() => this._hostWakeTick(), HOST_WAKE_POLL_MS);
this._hostWakeTimer = setInterval(() => this._hostWakeTick({ periodic: true }), HOST_WAKE_POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this._hostWakeTick();
if (document.visibilityState === 'visible') this._hostWakeTick({ periodic: true });
});
},
/**
* One poller tick: resolve the ACTIVE session, reset the banner when it changed, and
* ask the server. No-op while the page is hidden (a background tab must not poll).
*
* `periodic` marks the timer (and the visibility wake-up) as opposed to a tab
* activation: a periodic tick polls only a host with a wake target, see the module
* comment. The activation poll is what still offers "Configure WoL" for a sleeping
* host that has none — one connect, on a user action.
*/
_hostWakeTick() {
_hostWakeTick({ periodic = false } = {}) {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
const sessionId = this.activeSessionId;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
@@ -103,7 +120,9 @@ Object.assign(CodemanApp.prototype, {
return;
}
let state = this._hostWake;
let fresh = false;
if (!state || state.sessionId !== sessionId) {
fresh = true;
state = this._hostWake = this._hostWakeState();
state.sessionId = sessionId;
state.host = session.remote.host || '';
@@ -114,8 +133,14 @@ Object.assign(CodemanApp.prototype, {
// path is configured, so a command-only host is not mislabelled 'mac' until the
// first poll lands.
state.wakeConfigured = session.remote.wakeMac ? 'mac' : session.remote.wakeCommand ? 'command' : 'none';
// Known from the payload already: a proxied host is not probeable (the server
// says so too, on every answer), so not even the activation poll is worth a
// round trip whose verdict could only be a wrong "asleep".
state.probeable = !(session.remote.jumpHost || session.remote.socksProxy);
this._renderHostWakeBanner();
}
if (!state.probeable) return;
if (periodic && !fresh && state.wakeConfigured === 'none') return;
this._pollHostReachability();
},
@@ -130,7 +155,10 @@ Object.assign(CodemanApp.prototype, {
if (!data.success) return;
// The tab may have changed while this was in flight.
if (this._hostWake !== state || state.sessionId !== sessionId) return;
// `reachable` is `null` (unknown, not unreachable) for a host the probe cannot
// reach — only a PROVEN `false` may raise the banner.
state.reachable = data.data.reachable !== false;
if (data.data.probeable === false) state.probeable = false;
state.wakeConfigured = data.data.wakeConfigured || 'none';
if (data.data.host) state.host = data.data.host;
if (data.data.label) state.label = data.data.label;
+20 -3
View File
@@ -72,6 +72,7 @@ import {
RemoteWakeRegistry,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
createDefaultRemoteWakeDeps,
isProbeable,
type WakeableRemote,
} from '../../remote-wake.js';
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
@@ -760,7 +761,8 @@ export function resolveOmpConfigForCreate(
/**
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
* bearing: the registry keys its per-host wake state on `hostId`.
* bearing: the registry keys its per-host wake state on `hostId`. The proxy fields
* travel too: they are what tells the registry its probe cannot reach this host.
*/
function wakeableHost(host: RemoteHost): WakeableRemote {
return {
@@ -770,6 +772,9 @@ function wakeableHost(host: RemoteHost): WakeableRemote {
port: host.port,
wakeMac: host.wakeMac,
wakeCommand: host.wakeCommand,
jumpHost: host.jumpHost,
socksProxy: host.socksProxy,
extraSshOptions: host.extraSshOptions,
};
}
@@ -892,6 +897,8 @@ export function registerSessionRoutes(
// answers with an ssh failure that blames anything but the machine being asleep.
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
// No session yet, so the wake events name their requester (multi-user routing).
requestedBy: ownerFor(req),
});
if (hostWake === 'failed') {
return createErrorResponse(
@@ -1535,13 +1542,18 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const remote = session.remote;
if (!remote) return { success: true, data: { reachable: true, wakeConfigured: 'none' as const } };
if (!remote) {
return { success: true, data: { reachable: true, probeable: true, wakeConfigured: 'none' as const } };
}
const force = (req.query as { force?: string })?.force === '1';
// `reachable: null` + `probeable: false` for a host behind a jump host / SOCKS proxy:
// the probe cannot reach it, so the UI shows no banner and stops polling.
const reachable = await remoteWake.checkReachable(session, { force });
return {
success: true,
data: {
reachable,
probeable: isProbeable(remote),
wakeConfigured: await remoteWake.wakeConfigured(session),
host: remote.host,
label: remote.label,
@@ -3264,6 +3276,8 @@ export function registerSessionRoutes(
// host on every schedule (the failure invariant #1 exists to prevent).
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
// No session yet, so the wake events name their requester (multi-user routing).
requestedBy: ownerFor(req),
});
if (hostWake === 'failed') {
return createErrorResponse(
@@ -3280,7 +3294,10 @@ export function registerSessionRoutes(
// An unreachable host and a host without tmux fail the same way over ssh, so the
// probe's own message would send the user hunting for a tmux install. Ask the
// registry (which just probed, when it woke the host) which of the two it is.
if (!(await remoteWake.checkHostReachable(wakeableHost(host)))) {
// `=== false` on purpose: a proxied host answers `null` (the probe cannot reach
// it), and an unknown verdict must not replace the real ssh error with
// "not reachable" over a host that is fine.
if ((await remoteWake.checkHostReachable(wakeableHost(host))) === false) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
hostWake === 'no-target'
+9 -1
View File
@@ -2349,11 +2349,19 @@ export class WebServer extends EventEmitter {
'scheduled:',
'team:',
'case:',
'remote:',
];
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } };
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string }; username?: string };
const sessionId = d.sessionId ?? d.id ?? d.session?.id;
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// `remote:hostWaking` / `remote:hostWakeFailed` for a create/attach wake have no
// session yet (nothing exists until the host is up), so the registry names the
// requesting user instead; the payload carries `hostId`/`label`, which non-admins
// are not shown elsewhere. No session and no requester: admins only (fail closed).
if (!sessionId && event.startsWith('remote:') && d.username) {
return { username: d.username, sessionScoped: true };
}
return { owner, sessionScoped: true };
}
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
+4
View File
@@ -177,6 +177,8 @@ export const MuxDied = 'mux:died' as const;
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
// ─── Remote auto-reconnect (COD-108) + wake-on-LAN ───────────────────────────
// Session-scoped in multi-user mode (`deriveSseHint`): routed to the session's owner,
// or — for a wake with no session yet — to the requesting `username` in the payload.
/** A remote session's local ssh pane died; an auto-reconnect attempt is starting. */
export const RemoteSessionDropped = 'remote:sessionDropped' as const;
@@ -187,6 +189,8 @@ export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
/**
* User input arrived for a session whose host is unreachable, so a Wake-on-LAN
* command was started (see `remote-wake.ts`). Input sent meanwhile is buffered.
* Payload: `sessionId` (session wake) or `forNewSession: true` + `username`
* (create/attach wake), `hostId`, `label`, `queuedInput`.
*/
export const RemoteHostWaking = 'remote:hostWaking' as const;
/** The host did not come back within the wake timeout — buffered input is still held. */