mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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:
co-authored by
Claude Opus 5
parent
e271a65e79
commit
1040f6c489
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user