mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +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:
@@ -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