mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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
185 lines
6.6 KiB
TypeScript
185 lines
6.6 KiB
TypeScript
// Port: none (pure frontend module in a node VM with a fake DOM — no browser, no server).
|
|
//
|
|
// The remote-host wake banner (src/web/public/host-wake-ui.js) is a SINGLE global
|
|
// element that is shown only for the active remote session. The regression this
|
|
// guards: `refreshHostWakeBanner` clears `_hostWake` when the tab switches, but
|
|
// `_hostWakeTick`'s clear branch only re-rendered when IT was the one clearing —
|
|
// so switching from an unreachable remote session to a LOCAL one left the banner
|
|
// visible ("Hufflepuff is not reachable") on every chat until a full reload.
|
|
//
|
|
// The bug is a pure ordering problem between two methods, so it can be reproduced
|
|
// here without a browser: render the remote state, switch to a local session, and
|
|
// assert the banner is hidden again.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
const REMOTE_ID = 'remote-session-0001';
|
|
const LOCAL_ID = 'local-session-0001';
|
|
|
|
type El = { hidden: boolean; textContent: string; disabled: boolean; classList: { add(): void; remove(): void } };
|
|
|
|
function fakeElement(): El {
|
|
return { hidden: false, textContent: '', disabled: false, classList: { add() {}, remove() {} } };
|
|
}
|
|
|
|
const PROXIED_ID = 'remote-session-proxied';
|
|
const NOWOL_ID = 'remote-session-nowol';
|
|
|
|
/** Load `host-wake-ui.js` with the minimal DOM it touches, and return a wired app. */
|
|
function loadWakeApp() {
|
|
const fetches: string[] = [];
|
|
const elements = new Map<string, El>([
|
|
['hostWakeBanner', fakeElement()],
|
|
['hostWakeBannerText', fakeElement()],
|
|
['hostWakeBannerDetail', fakeElement()],
|
|
['hostWakeBannerAction', fakeElement()],
|
|
]);
|
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
console,
|
|
setInterval: () => 1,
|
|
clearInterval: () => {},
|
|
fetch: (url: string) => {
|
|
fetches.push(url);
|
|
return Promise.resolve({ json: () => Promise.resolve({ success: false }) });
|
|
},
|
|
document: {
|
|
visibilityState: 'visible',
|
|
getElementById: (id: string) => elements.get(id) ?? null,
|
|
addEventListener: () => {},
|
|
},
|
|
window: {},
|
|
});
|
|
vm.runInContext(readFileSync(resolve(PUBLIC, 'host-wake-ui.js'), 'utf8'), context, { filename: 'host-wake-ui.js' });
|
|
|
|
const app = new (CodemanApp as new () => Record<string, unknown>)();
|
|
app.$ = (id: string) => elements.get(id) ?? null;
|
|
app.activeSessionId = REMOTE_ID;
|
|
app.sessions = new Map<string, { remote?: Record<string, unknown> }>([
|
|
[
|
|
REMOTE_ID,
|
|
{ remote: { hostId: 'hufflepuff', host: '192.168.50.137', label: 'Hufflepuff', wakeMac: '04:d9:f5:80:c6:58' } },
|
|
],
|
|
[
|
|
PROXIED_ID,
|
|
{
|
|
remote: {
|
|
hostId: 'bastioned',
|
|
host: '10.20.0.5',
|
|
label: 'Behind bastion',
|
|
jumpHost: 'bastion',
|
|
wakeMac: '04:d9:f5:80:c6:58',
|
|
},
|
|
},
|
|
],
|
|
[NOWOL_ID, { remote: { hostId: 'plain', host: '10.0.0.9', label: 'Plain' } }],
|
|
[LOCAL_ID, {}],
|
|
]);
|
|
return {
|
|
app,
|
|
fetches,
|
|
banner: elements.get('hostWakeBanner') as El,
|
|
text: elements.get('hostWakeBannerText') as El,
|
|
};
|
|
}
|
|
|
|
describe('host wake banner visibility', () => {
|
|
it('hides the banner when switching from an unreachable remote session to a local one', () => {
|
|
const { app, banner, text } = loadWakeApp();
|
|
|
|
// The banner is up for the active, unreachable remote session.
|
|
app._hostWake = {
|
|
sessionId: REMOTE_ID,
|
|
reachable: false,
|
|
wakeConfigured: 'mac',
|
|
host: '192.168.50.137',
|
|
label: 'Hufflepuff',
|
|
waking: false,
|
|
error: '',
|
|
};
|
|
(app._renderHostWakeBanner as () => void)();
|
|
expect(banner.hidden).toBe(false);
|
|
expect(text.textContent).toBe('Hufflepuff is not reachable');
|
|
|
|
// Switch to a LOCAL session. `refreshHostWakeBanner` clears the state, and the
|
|
// tick that follows must still repaint the (now empty) banner as hidden.
|
|
app.activeSessionId = LOCAL_ID;
|
|
(app.refreshHostWakeBanner as (id: string) => void)(LOCAL_ID);
|
|
|
|
expect(app._hostWake).toBeNull();
|
|
expect(banner.hidden).toBe(true);
|
|
});
|
|
|
|
it('keeps the banner hidden on a later poller tick once the state is cleared', () => {
|
|
const { app, banner } = loadWakeApp();
|
|
app.activeSessionId = LOCAL_ID;
|
|
app._hostWake = null;
|
|
// A page-wide tick on a local session must be idempotent and leave it hidden.
|
|
(app._hostWakeTick as () => void)();
|
|
expect(banner.hidden).toBe(true);
|
|
});
|
|
|
|
it('shows the banner only while the active session is remote and unreachable', () => {
|
|
const { app, banner } = loadWakeApp();
|
|
app._hostWake = {
|
|
sessionId: REMOTE_ID,
|
|
reachable: false,
|
|
wakeConfigured: 'mac',
|
|
host: '192.168.50.137',
|
|
label: 'Hufflepuff',
|
|
waking: false,
|
|
error: '',
|
|
};
|
|
(app._renderHostWakeBanner as () => void)();
|
|
expect(banner.hidden).toBe(false);
|
|
|
|
// Reachable again → hidden, state intact (the banner must not leak across the
|
|
// reachable/unreachable transition either).
|
|
app._hostWake.reachable = true;
|
|
(app._renderHostWakeBanner as () => void)();
|
|
expect(banner.hidden).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('host wake banner polling', () => {
|
|
// Each poll is a TCP connect to the host from the server. The timer is the one
|
|
// trigger that is not a user action, so it must not fire for a host Codeman could
|
|
// not wake anyway (it cannot wake it, but it can keep an activity-based suspend timer
|
|
// from firing), and a proxied host is never polled: the probe cannot reach it.
|
|
const tick = (app: Record<string, unknown>, periodic: boolean) =>
|
|
(app._hostWakeTick as (o: { periodic: boolean }) => void)({ periodic });
|
|
|
|
it('polls a wake-configured host on activation and on the timer', () => {
|
|
const { app, fetches } = loadWakeApp();
|
|
app.activeSessionId = REMOTE_ID;
|
|
tick(app, false);
|
|
tick(app, true);
|
|
tick(app, true);
|
|
expect(fetches).toHaveLength(3);
|
|
expect(fetches[0]).toContain(`/api/sessions/${REMOTE_ID}/reachability`);
|
|
});
|
|
|
|
it('polls a host without a wake target once on activation, never on the timer', () => {
|
|
const { app, fetches } = loadWakeApp();
|
|
app.activeSessionId = NOWOL_ID;
|
|
tick(app, false);
|
|
tick(app, true);
|
|
tick(app, true);
|
|
expect(fetches).toHaveLength(1);
|
|
});
|
|
|
|
it('never polls a host behind a jump host or SOCKS proxy', () => {
|
|
const { app, fetches } = loadWakeApp();
|
|
app.activeSessionId = PROXIED_ID;
|
|
tick(app, false);
|
|
tick(app, true);
|
|
expect(fetches).toHaveLength(0);
|
|
expect((app._hostWake as { probeable: boolean }).probeable).toBe(false);
|
|
});
|
|
});
|