mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
COD-105 discover + attach existing remote tmux sessions (detach-not-kill)
Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already
running on a remote host (created by the remote's own Codeman or another
instance) and attach to one this Codeman didn't launch, with detach-not-kill
ownership for non-owned sessions.
- remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws)
+ pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits
on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a
real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote
now marks the COD-104 launch path owned:true.
- tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand);
buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains
a detach-not-kill early return for non-owned remote sessions: tears down only the
LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote
kill-session.
- types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName.
- schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName};
fixed a pre-existing no-useless-escape lint error in the jumpHost regex.
- case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery).
- session-routes.ts: attachRemoteSession create path -> non-owned session.
- UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions"
button + Attach action (owned:false). No auto-discover.
Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared
view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests:
parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSessionInfo,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
@@ -210,6 +211,105 @@ export async function checkRemoteTmuxAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
|
||||
* remote host's canonical `-L codeman` socket.
|
||||
*
|
||||
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
|
||||
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
|
||||
* running" stderr; the caller treats a non-zero exit / empty output as "no
|
||||
* sessions" rather than an error.
|
||||
*
|
||||
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
|
||||
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
|
||||
* and the tmux prereq probe.
|
||||
*/
|
||||
export function buildRemoteListSessionsCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
|
||||
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
|
||||
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
|
||||
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
|
||||
const remoteCmd =
|
||||
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
|
||||
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
|
||||
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
|
||||
* without opening a real ssh connection.
|
||||
*
|
||||
* - Splits each non-empty line into [name, attached, created, windows] on the
|
||||
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
|
||||
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
|
||||
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
|
||||
* backslash-t sequence; we also tolerate a real tab in case a tmux build
|
||||
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
|
||||
* regex `\\t`.)
|
||||
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
|
||||
* sessions that happen to share the socket).
|
||||
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
|
||||
* - Skips malformed lines (wrong column count or non-numeric created/windows)
|
||||
* rather than emitting garbage.
|
||||
*/
|
||||
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
|
||||
const out: RemoteSessionInfo[] = [];
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
|
||||
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
|
||||
const cols = line.split(/\\t|\t/);
|
||||
if (cols.length !== 4) continue;
|
||||
const [name, attachedStr, createdStr, windowsStr] = cols;
|
||||
if (!name.startsWith('codeman-')) continue;
|
||||
const created = Number(createdStr);
|
||||
const windows = Number(windowsStr);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
|
||||
out.push({
|
||||
name,
|
||||
attached: attachedStr.trim() === '1',
|
||||
created: Math.trunc(created),
|
||||
windows: Math.trunc(windows),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
|
||||
* (created by the remote's own Codeman, another instance, or this one), so the
|
||||
* operator can attach to one this Codeman didn't launch.
|
||||
*
|
||||
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
|
||||
* (`list-sessions` exits non-zero with empty output when there are none).
|
||||
*
|
||||
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
|
||||
* real ssh never runs in a request path (which would make route tests hit a
|
||||
* ~10s timeout). The command construction is covered by
|
||||
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
|
||||
*/
|
||||
export async function listRemoteCodemanSessions(
|
||||
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteSessionInfo[]> {
|
||||
if (process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const command = buildRemoteListSessionsCommand(remote);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
return parseRemoteSessionList(stdout);
|
||||
} catch {
|
||||
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
|
||||
// to "nothing to attach to" — never surface as an error to the caller.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteDisplayPath(
|
||||
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
|
||||
): string {
|
||||
@@ -226,6 +326,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
owned: true,
|
||||
// COD-107 — carry the advanced SSH options from host config into the session
|
||||
// so the launch/prereq commands connect the same way the operator configured.
|
||||
identityFile: host.identityFile,
|
||||
@@ -234,3 +338,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
|
||||
* session already running on a remote host (discovered via
|
||||
* `listRemoteCodemanSessions`). The resulting session's pane runs
|
||||
* `tmux -L codeman attach -t <remoteSessionName>` (see
|
||||
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
|
||||
* `owned: false` means closing the tab DETACHES rather than killing it.
|
||||
*
|
||||
* `remotePath` is informational here (the attached remote session keeps its own
|
||||
* cwd); we record the host's nominal path so display helpers still show
|
||||
* `user@host:path`.
|
||||
*/
|
||||
export function toAttachedSessionRemote(
|
||||
host: RemoteHost,
|
||||
remoteSessionName: string,
|
||||
remotePath: string
|
||||
): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
+78
-2
@@ -812,6 +812,49 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
|
||||
* session on the remote host (one this Codeman didn't create — discovered via
|
||||
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
|
||||
*
|
||||
* Emits:
|
||||
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman attach -t <session>'
|
||||
*
|
||||
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
|
||||
* remote session keeps running independent of us, which is exactly why the
|
||||
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
|
||||
* the local tab must detach, never `kill-session` the remote.
|
||||
* - The remote session name is shell-escaped so a value with metachars stays a
|
||||
* single token inside the quoted tmux invocation.
|
||||
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
||||
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
|
||||
* connects identically to launch / discovery / the prereq probe. `-t` sits
|
||||
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
|
||||
*/
|
||||
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
|
||||
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
|
||||
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
|
||||
return sshParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — choose the right remote ssh command for a session's ownership:
|
||||
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
|
||||
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
|
||||
* this session's deterministic name). We only join — never create.
|
||||
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
|
||||
* (COD-104), which we then own and may explicitly kill.
|
||||
*/
|
||||
function buildRemoteSessionCommand(mode: SessionMode, remote: SessionRemote, sessionId: string): string {
|
||||
if (remote.owned === false) {
|
||||
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
|
||||
return buildRemoteAttachCommand(remote, target);
|
||||
}
|
||||
return buildRemoteLaunchCommand({ mode, remote, sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -1273,7 +1316,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
// and avoid the race where the command exits before remain-on-exit is set:
|
||||
@@ -1521,7 +1564,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -1650,6 +1693,39 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
|
||||
//
|
||||
// When this session was created by ATTACHING a remote tmux session another
|
||||
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
|
||||
// a remote `tmux kill-session` — that would nuke work the remote's own
|
||||
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
|
||||
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
|
||||
// remote `tmux attach`, which DETACHES (the durable remote session survives).
|
||||
//
|
||||
// This early return is the structural guarantee: no code below this point
|
||||
// (now or in future for owned sessions) can ever issue a remote kill-session
|
||||
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
|
||||
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
|
||||
// local pane — it does NOT reach the REMOTE socket.
|
||||
if (session.remote && session.remote.owned === false) {
|
||||
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Local pane may already be gone.
|
||||
}
|
||||
}
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
|
||||
@@ -96,6 +96,43 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
port?: number;
|
||||
remotePath: string;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* COD-105 — whether THIS Codeman created the remote tmux session.
|
||||
*
|
||||
* - `true` (default for COD-104 launched sessions): we own the remote session;
|
||||
* an explicit "kill" may propagate a remote `tmux kill-session`.
|
||||
* - `false` (discovered + attached an existing remote session another Codeman
|
||||
* created): closing the local tab must DETACH only — we must NEVER issue a
|
||||
* remote `kill-session`, or we'd nuke work the remote's own Codeman (or
|
||||
* another instance) still relies on. See `killSession()` gate.
|
||||
*
|
||||
* Absent is treated as owned (legacy/COD-104 sessions persisted before this
|
||||
* field existed were all launched by us).
|
||||
*/
|
||||
owned?: boolean;
|
||||
/**
|
||||
* COD-105 — for a NON-owned (discovered + attached) session, the EXISTING
|
||||
* remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs
|
||||
* from this Codeman's deterministic `codeman-<id>` name because the remote
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — a `codeman-*` tmux session discovered on a remote host's
|
||||
* `tmux -L codeman` socket (may have been created by the remote's own Codeman,
|
||||
* another instance, or this one). Returned by `listRemoteCodemanSessions`.
|
||||
*/
|
||||
export interface RemoteSessionInfo {
|
||||
/** tmux session name (always starts `codeman-`). */
|
||||
name: string;
|
||||
/** Whether a client is currently attached to the remote session. */
|
||||
attached: boolean;
|
||||
/** tmux `session_created` epoch seconds. */
|
||||
created: number;
|
||||
/** Number of windows in the remote session. */
|
||||
windows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1915,6 +1915,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- COD-105 — discover + attach existing remote tmux sessions this Codeman didn't create. -->
|
||||
<details class="advanced-options" id="remoteDiscoverSection">
|
||||
<summary>Discover existing sessions</summary>
|
||||
<div class="advanced-options-content">
|
||||
<span class="form-hint">Find <code>codeman-*</code> tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed.</span>
|
||||
<div class="form-row" style="margin-top: 8px;">
|
||||
<button type="button" class="btn-toolbar" id="remoteDiscoverBtn" onclick="app.discoverRemoteSessions()">Discover existing sessions</button>
|
||||
</div>
|
||||
<div id="remoteDiscoverResults" class="remote-discover-results"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
|
||||
@@ -1767,6 +1767,150 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COD-105 — Discover + attach existing remote tmux sessions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Read the remote-host fields from the remote-case form into a host payload. */
|
||||
_readRemoteHostFromForm() {
|
||||
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
||||
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
||||
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
||||
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
||||
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
let port;
|
||||
if (portRaw) {
|
||||
const n = Number(portRaw);
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
|
||||
}
|
||||
return {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
host,
|
||||
username,
|
||||
...(port ? { port } : {}),
|
||||
...(identityFile ? { identityFile } : {}),
|
||||
...(socksProxy ? { socksProxy } : {}),
|
||||
...(jumpHost ? { jumpHost } : {}),
|
||||
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
||||
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicit Discover action (Decision A — never auto-runs on host select).
|
||||
* Saves the host config (idempotent), then queries the host for `codeman-*`
|
||||
* tmux sessions it didn't create and renders an Attach action per session.
|
||||
*/
|
||||
async discoverRemoteSessions() {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
const btn = document.getElementById('remoteDiscoverBtn');
|
||||
const hostPayload = this._readRemoteHostFromForm();
|
||||
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
|
||||
this.showToast('Fill in Host ID, address, and username first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
|
||||
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
|
||||
try {
|
||||
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
|
||||
const hostRes = await fetch('/api/remote-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload)
|
||||
});
|
||||
const hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||
throw new Error(hostData.error || 'Failed to save remote host');
|
||||
}
|
||||
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Discovery failed');
|
||||
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
|
||||
} catch (err) {
|
||||
console.error('Discover remote sessions failed:', err);
|
||||
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Render the discovered remote sessions with an Attach action each. */
|
||||
_renderDiscoveredSessions(hostId, sessions) {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
if (!results) return;
|
||||
if (!sessions.length) {
|
||||
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = sessions.map(s => {
|
||||
const ageSecs = Math.max(0, now - (s.created || 0));
|
||||
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
|
||||
const attachedBadge = s.attached
|
||||
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="remote-discover-item">
|
||||
<div class="remote-discover-info">
|
||||
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
|
||||
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
|
||||
</div>
|
||||
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
results.innerHTML = rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a NON-owned session that attaches to a discovered remote tmux session.
|
||||
* Closing this tab detaches — it never kills the remote session.
|
||||
*/
|
||||
async attachDiscoveredSession(hostId, remoteSessionName) {
|
||||
try {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: 'shell',
|
||||
name: remoteSessionName,
|
||||
attachRemoteSession: { hostId, remoteSessionName },
|
||||
})
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
|
||||
const id = createData.data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (dims) {
|
||||
await fetch(`/api/sessions/${id}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
});
|
||||
}
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
|
||||
this.activeSessionId = id;
|
||||
await this.selectSession(id);
|
||||
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
|
||||
} catch (err) {
|
||||
console.error('Attach discovered session failed:', err);
|
||||
this.showToast('Failed to attach: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -3933,6 +3933,37 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transition: background var(--transition-smooth);
|
||||
}
|
||||
|
||||
/* COD-105 — discovered remote tmux sessions list (remote-case flow). */
|
||||
.remote-discover-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.remote-discover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.remote-discover-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-discover-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.case-manage-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, RemoteSessionInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -26,6 +26,7 @@ import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
listRemoteCodemanSessions,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
@@ -167,6 +168,23 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
|
||||
// COD-105 — discover `codeman-*` tmux sessions already running on a remote
|
||||
// host (created by the remote's own Codeman, another instance, or this one)
|
||||
// so the operator can attach to one this Codeman didn't launch. Explicit
|
||||
// trigger only (Decision A): the frontend calls this on a "Discover" click,
|
||||
// never automatically on host select. listRemoteCodemanSessions never throws
|
||||
// (returns [] on unreachable/no-tmux/no-sessions) and is ssh-guarded under test.
|
||||
app.get(
|
||||
'/api/remote-hosts/:hostId/sessions',
|
||||
async (req): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
const sessions = await listRemoteCodemanSessions(host);
|
||||
return { success: true, data: { sessions } };
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
|
||||
@@ -73,7 +73,13 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
toAttachedSessionRemote,
|
||||
toSessionRemote,
|
||||
} from '../../remote-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
@@ -292,7 +298,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
let remote = undefined;
|
||||
|
||||
// COD-105 — attach to a discovered (non-owned) remote tmux session. The
|
||||
// remote session is already running, so we skip the tmux-prereq probe and
|
||||
// build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
|
||||
// creation (owned durable sessions) is handled by the dedicated case-create
|
||||
// endpoint below, which #145 consolidated remote-host resolution into.
|
||||
if (body.attachRemoteSession) {
|
||||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (body.workingDir) {
|
||||
@@ -447,6 +467,7 @@ export function registerSessionRoutes(
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
|
||||
@@ -192,6 +192,22 @@ export const CreateSessionSchema = z.object({
|
||||
.max(100)
|
||||
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
|
||||
.optional(),
|
||||
/**
|
||||
* COD-105 — attach to an EXISTING remote tmux session discovered via
|
||||
* `GET /api/remote-hosts/:hostId/sessions` (one this Codeman didn't create).
|
||||
* The resulting session is NON-owned (closing it detaches, never kills the
|
||||
* remote). `remoteSessionName` is a discovered `codeman-*` tmux session name.
|
||||
*/
|
||||
attachRemoteSession: z
|
||||
.object({
|
||||
hostId: z.string().min(1).max(200),
|
||||
remoteSessionName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(/^codeman-[a-zA-Z0-9._-]+$/, 'remoteSessionName must be a codeman-* tmux session name'),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — discover & attach existing remote tmux sessions.
|
||||
*
|
||||
* Phase 2 of the remote-tmux arc (builds on COD-104 durable remote sessions +
|
||||
* COD-107 connection args). These tests are tmux-safe / ssh-safe: they exercise
|
||||
* the PURE parse helper, the pure attach-command builder, and the killSession
|
||||
* ownership gate — none open a real ssh connection or a real tmux server.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
describe('COD-105 parseRemoteSessionList', () => {
|
||||
it('parses tab-delimited -F output and coerces fields', () => {
|
||||
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 },
|
||||
{ name: 'codeman-abcd1234', attached: true, created: 1700000123, windows: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses the LITERAL backslash-t separator the remote tmux actually emits', () => {
|
||||
// tmux next-3.7's `-F "…\t…"` does NOT expand \t — it prints a literal
|
||||
// backslash-t (verified on aa-desktop). The parser must split on that.
|
||||
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, created: 1781362858, windows: 1 },
|
||||
{ name: 'codeman-real', attached: true, created: 1781329905, windows: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only codeman-* sessions, dropping foreign tmux sessions', () => {
|
||||
const stdout = 'work\t1\t1700000000\t2\n' + 'codeman-keep\t0\t1700000001\t1\n' + 'scratch\t0\t1700000002\t1\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list.map((s) => s.name)).toEqual(['codeman-keep']);
|
||||
});
|
||||
|
||||
it('returns [] for empty / whitespace output (the no-sessions case)', () => {
|
||||
expect(parseRemoteSessionList('')).toEqual([]);
|
||||
expect(parseRemoteSessionList(' \n \n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates malformed lines (missing columns) by skipping them', () => {
|
||||
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([{ name: 'codeman-ok', attached: false, created: 1700000000, windows: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 buildRemoteAttachCommand', () => {
|
||||
it('emits ssh -t <target> tmux -L codeman attach -t <session>', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
// The tmux invocation is nested-quoted (inner session name escaped, whole
|
||||
// invocation re-escaped as one ssh arg). Assert the stable prefix here; the
|
||||
// exact re-parsed token is verified by the argv-reparse test below.
|
||||
expect(command).toContain('tmux -L codeman attach -t ');
|
||||
expect(command).toContain('codeman-disco1');
|
||||
});
|
||||
|
||||
it('threads the COD-107 connection args (port / identity / proxy) into the ssh invocation', () => {
|
||||
const command = buildRemoteAttachCommand(
|
||||
{ ...baseRemote, port: 2222, identityFile: '/keys/id_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
'codeman-disco1'
|
||||
);
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain("-i '/keys/id_ed25519'");
|
||||
expect(command).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
|
||||
// Port/identity/proxy belong to ssh, ahead of the target.
|
||||
expect(command).toMatch(/ssh[\s\S]*-p 2222[\s\S]*ubuntu@10\.0\.0\.42/);
|
||||
});
|
||||
|
||||
it('shell-escapes the session name so it stays a single token', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
// Re-parse: stub ssh to dump argv, confirm the trailing tmux invocation is one arg.
|
||||
const dumpArgs = (name: string, prefix: string) =>
|
||||
`${name}() { for a in "$@"; do printf '${prefix}:%s\\n' "$a"; done; }`;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${dumpArgs('ssh', 'A')}\n${command}`], { encoding: 'utf8' });
|
||||
const sshArgs = out
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('A:'))
|
||||
.map((l) => l.slice(2));
|
||||
expect(sshArgs).toContain('ubuntu@10.0.0.42');
|
||||
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
|
||||
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 killSession ownership gate (detach-not-kill)', () => {
|
||||
it('never issues a remote tmux kill-session for a non-owned remote session', async () => {
|
||||
const mgr = new TmuxManager();
|
||||
// Register a discovered+attached (non-owned) remote session.
|
||||
mgr.registerSession({
|
||||
sessionId: 'disco-1',
|
||||
muxName: 'codeman-disco-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: false },
|
||||
});
|
||||
|
||||
// killSession under VITEST is in-memory only (IS_TEST_MODE), so it physically
|
||||
// cannot run a remote kill-session. We assert the contract: the session's
|
||||
// ownership flag is the gate, and tearing it down removes only local state.
|
||||
const session = mgr.getSession('disco-1');
|
||||
expect(session?.remote?.owned).toBe(false);
|
||||
|
||||
const ok = await mgr.killSession('disco-1');
|
||||
expect(ok).toBe(true);
|
||||
// Local tracking removed; no remote kill was (or could be) issued.
|
||||
expect(mgr.getSession('disco-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats COD-104 launched remote sessions as owned by default', () => {
|
||||
const mgr = new TmuxManager();
|
||||
mgr.registerSession({
|
||||
sessionId: 'owned-1',
|
||||
muxName: 'codeman-owned-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: true },
|
||||
});
|
||||
expect(mgr.getSession('owned-1')?.remote?.owned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — GET /api/remote-hosts/:hostId/sessions discovery endpoint.
|
||||
*
|
||||
* The endpoint reads the saved host config by id, runs listRemoteCodemanSessions
|
||||
* (ssh-guarded under VITEST), and returns the discovered sessions in the
|
||||
* ApiResponse envelope. We mock the remote-hosts module so the test controls the
|
||||
* host record and the session list WITHOUT any real ssh / filesystem.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import type { RemoteHost, RemoteSessionInfo } from '../../src/types.js';
|
||||
|
||||
// Mock the remote-hosts module: control readRemoteHosts + listRemoteCodemanSessions.
|
||||
const mockHosts: RemoteHost[] = [];
|
||||
let mockSessions: RemoteSessionInfo[] = [];
|
||||
let lastListArg: unknown = undefined;
|
||||
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => mockHosts),
|
||||
readRemoteCases: vi.fn(async () => []),
|
||||
listRemoteCodemanSessions: vi.fn(async (remote: unknown) => {
|
||||
lastListArg = remote;
|
||||
return mockSessions;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/templates/claude-md.js', () => ({
|
||||
generateClaudeMd: vi.fn(() => '# CLAUDE.md'),
|
||||
}));
|
||||
vi.mock('../../src/hooks-config.js', () => ({ writeHooksConfig: vi.fn(async () => {}) }));
|
||||
|
||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
|
||||
async function createHarness(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
registerCaseRoutes(app, ctx as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('COD-105 GET /api/remote-hosts/:hostId/sessions', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createHarness();
|
||||
mockHosts.length = 0;
|
||||
mockSessions = [];
|
||||
lastListArg = undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns discovered sessions for a known host in the envelope', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht', port: 2222 });
|
||||
mockSessions = [{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }];
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }]);
|
||||
// The host config (incl. port) was threaded to the discovery call.
|
||||
expect((lastListArg as { host?: string; port?: number }).host).toBe('1.2.3.4');
|
||||
expect((lastListArg as { port?: number }).port).toBe(2222);
|
||||
});
|
||||
|
||||
it('404s when the host id is unknown', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/nope/sessions' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('returns an empty list (not an error) when no sessions are discovered', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht' });
|
||||
mockSessions = [];
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user