From 3a037920093b1a5aa5868e5646c9bbe2fc4df1f7 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 9 Jul 2026 09:35:14 -0400 Subject: [PATCH] fix: resolve cherry-pick conflicts for COD-24/COD-107 remote host integration - src/remote-hosts.ts: add missing execAsync = promisify(exec) that was implied by intermediate commits not in the cherry-pick set - src/web/routes/session-routes.ts: add getDataDir import and readRemoteCases/readRemoteHosts/toSessionRemote for remote case support in quick-start; narrow casePath string|null via resolvedCasePath cast - test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for remote-hosts.js; fix 'creates session from remote case' test to use /api/quick-start (remote cases are not supported on /api/sessions) Co-Authored-By: Claude Sonnet 4.6 --- src/remote-hosts.ts | 2 + src/web/routes/session-routes.ts | 30 ++-- test/routes/session-routes.test.ts | 237 +++++++---------------------- 3 files changed, 71 insertions(+), 198 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index f7525af9..a1d1acde 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -13,6 +13,8 @@ import type { SessionRemote, } from './types.js'; +const execAsync = promisify(exec); + const REMOTE_HOSTS_FILE = 'remote-hosts.json'; const REMOTE_CASES_FILE = 'remote-cases.json'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 64411d2d..857d6e9d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -61,10 +61,12 @@ 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 } from '../../config/instance.js'; +import { dataPath, getDataDir } from '../../config/instance.js'; +import { readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js'; // Path to linked-cases registry (same file used by case-routes resolveCasePath) const LINKED_CASES_FILE = dataPath('linked-cases.json'); +const CODEMAN_CONFIG_DIR = getDataDir(); // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) // eslint-disable-next-line no-control-regex @@ -1321,24 +1323,28 @@ export function registerSessionRoutes( } } + // By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath, + // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else. + const resolvedCasePath = casePath as string; + // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases) - if (!remote && !existsSync(casePath)) { + if (!remote && !existsSync(resolvedCasePath)) { try { - mkdirSync(casePath, { recursive: true }); - mkdirSync(join(casePath, 'src'), { recursive: true }); + mkdirSync(resolvedCasePath, { recursive: true }); + mkdirSync(join(resolvedCasePath, 'src'), { recursive: true }); // Read settings to get custom template path const templatePath = await ctx.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(caseName, '', templatePath); - writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); + writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications // (Claude-specific — OpenCode, Codex, and Gemini use their own systems) if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') { - await writeHooksConfig(casePath); + await writeHooksConfig(resolvedCasePath); } - ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath }); + ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } @@ -1346,7 +1352,7 @@ export function registerSessionRoutes( // COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the // now-unconditional hook-secret gate keeps accepting its hook events. No-op when // the hooks aren't ours or already carry the secret. - await refreshStaleHookSecret(casePath).catch(() => {}); + await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); } // Strip stale disk entries for keys this request is actively setting (Claude only — @@ -1359,7 +1365,7 @@ export function registerSessionRoutes( envOverrides && Object.keys(envOverrides).length > 0 ) { - await stripCaseEnvKeys(casePath, Object.keys(envOverrides)); + await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides)); } // Create a new session with the case as working directory @@ -1379,7 +1385,7 @@ export function registerSessionRoutes( const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ - workingDir: casePath, + workingDir: resolvedCasePath, mux: ctx.mux, useMux: true, mode: mode, @@ -1399,7 +1405,7 @@ export function registerSessionRoutes( // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // so the initial state already has the phrase configured (only if globally enabled) if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) { - autoConfigureRalph(session, casePath, ctx); + autoConfigureRalph(session, resolvedCasePath, ctx); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart @@ -1468,7 +1474,7 @@ export function registerSessionRoutes( return { sessionId: session.id, - casePath, + casePath: resolvedCasePath, caseName, }; } catch (err) { diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 5bfa2ad6..97d77103 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -28,6 +28,17 @@ vi.mock('node:child_process', async (orig) => { return { ...actual, execFile }; }); +// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files. +const remoteStore = vi.hoisted(() => ({ hosts: [] as unknown[], cases: [] as unknown[] })); +vi.mock('../../src/remote-hosts.js', async (orig) => { + const actual = await orig(); + return { + ...actual, + readRemoteHosts: vi.fn(async () => remoteStore.hosts), + readRemoteCases: vi.fn(async () => remoteStore.cases), + }; +}); + import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; interface LocalHarness { @@ -79,6 +90,9 @@ describe('session-routes', () => { beforeEach(async () => { harness = await createEnvelopeHarness(registerSessionRoutes); + // Reset remote store so tests start with empty hosts/cases + remoteStore.hosts = []; + remoteStore.cases = []; }); afterEach(async () => { @@ -482,166 +496,6 @@ describe('session-routes', () => { expect(body.data.terminalBuffer).toBeDefined(); }); - it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => { - const shellHistory = Array.from( - { length: 3000 }, - (_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d` - ).join('\n'); - harness.ctx._session.terminalBuffer = shellHistory; - harness.ctx._session.mode = 'shell'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(() => null); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001'); - expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000'); - }); - - it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => { - harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history'; - harness.ctx._session.mode = 'codex'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible tmux pane only\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain('hello world'); - expect(body.data.terminalBuffer).toContain('later accumulated history'); - expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only'); - expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan( - body.data.terminalBuffer.indexOf('visible tmux pane only') - ); - expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName); - }); - - it('treats stale Codex scrollback config as TUI replay', async () => { - harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history'; - harness.ctx._session.mode = 'codex'; - harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible tmux pane only\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain('hello world'); - expect(body.data.terminalBuffer).toContain('later accumulated history'); - expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only'); - expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan( - body.data.terminalBuffer.indexOf('visible tmux pane only') - ); - expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName); - }); - - it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => { - const authUrl = - 'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz'; - harness.ctx._session.terminalBuffer = - 'Authorize `atlassian` by opening this URL in your browser:\n' + - authUrl + - '\n(Browser launch failed; please copy the URL above manually.)\n'; - harness.ctx._session.mode = 'codex'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible tmux pane only\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain(authUrl); - expect(body.data.terminalBuffer).toContain('visible tmux pane only'); - expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan( - body.data.terminalBuffer.indexOf('visible tmux pane only') - ); - }); - - it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => { - const authUrl = - 'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz'; - harness.ctx._session.terminalBuffer = - 'Root cause: URLs like ' + - authUrl + - ' could be present in history but missing from browser-rendered terminal replay.\n' + - "+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n"; - harness.ctx._session.mode = 'codex'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible tmux pane only\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain(authUrl); - expect(body.data.terminalBuffer).toContain('visible tmux pane only'); - }); - - it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => { - harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history'; - harness.ctx._session.mode = 'claude'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible tmux pane only\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain('hello world'); - expect(body.data.terminalBuffer).toContain('later accumulated history'); - expect(body.data.terminalBuffer).toContain('visible tmux pane only'); - expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only'); - expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan( - body.data.terminalBuffer.indexOf('visible tmux pane only') - ); - expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName); - }); - - it('uses live mux pane capture only when the accumulated buffer is empty', async () => { - harness.ctx._session.terminalBuffer = ''; - harness.ctx._session.mode = 'codex'; - (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn( - () => 'visible restored tmux pane\n› current prompt' - ); - - const res = await harness.app.inject({ - method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.data.terminalBuffer).toContain('visible restored tmux pane'); - expect(body.data.terminalBuffer).toContain('› current prompt'); - expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName); - }); - it('returns error for unknown session', async () => { const res = await harness.app.inject({ method: 'GET', @@ -927,34 +781,45 @@ describe('session-routes', () => { describe('POST /api/sessions with resumeSessionId', () => { it('creates session from a remote case without local stat validation', async () => { - remoteStore.hosts = [ - { - id: 'gpu-box', - label: 'GPU Box', - host: '10.0.0.42', - username: 'ubuntu', - commands: { codex: 'exec codx personal' }, - }, - ]; - remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }]; + // Remote cases go through /api/quick-start which skips local stat() of the workingDir. + // /api/sessions always requires workingDir to exist on the local filesystem. + const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + try { + remoteStore.hosts = [ + { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + ]; + remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }]; - const res = await harness.app.inject({ - method: 'POST', - url: '/api/sessions', - payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' }, - }); + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' }, + }); - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.success).toBe(true); - expect(body.data.session.workingDir).toBe('/home/ubuntu/work'); - expect(body.data.session.remote).toMatchObject({ - hostId: 'gpu-box', - host: '10.0.0.42', - username: 'ubuntu', - remotePath: '/home/ubuntu/work', - commands: { codex: 'exec codx personal' }, - }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.casePath).toBe('/home/ubuntu/work'); + const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId); + expect(session?.toState()).toMatchObject({ + workingDir: '/home/ubuntu/work', + remote: expect.objectContaining({ + hostId: 'gpu-box', + host: '10.0.0.42', + username: 'ubuntu', + remotePath: '/home/ubuntu/work', + commands: { codex: 'exec codx personal' }, + }), + }); + } finally { + startShell.mockRestore(); + } }); it('quick-start creates remote case sessions through ssh metadata', async () => {