diff --git a/src/index.ts b/src/index.ts index c6295c5..504400d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,11 @@ import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + restoreRemoteControl, + startRemoteControl, + stopRemoteControl, +} from './remote-control.js'; import { isSenderAllowed, isTriggerAllowed, @@ -470,6 +475,7 @@ async function main(): Promise { initDatabase(); logger.info('Database initialized'); loadState(); + restoreRemoteControl(); // Start credential proxy (containers route API calls through this) const proxyServer = await startCredentialProxy( @@ -488,9 +494,60 @@ async function main(): Promise { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + // Handle /remote-control and /remote-control-end commands + async function handleRemoteControl( + command: string, + chatJid: string, + msg: NewMessage, + ): Promise { + const group = registeredGroups[chatJid]; + if (!group?.isMain) { + logger.warn( + { chatJid, sender: msg.sender }, + 'Remote control rejected: not main group', + ); + return; + } + + const channel = findChannel(channels, chatJid); + if (!channel) return; + + if (command === '/remote-control') { + const result = await startRemoteControl( + msg.sender, + chatJid, + process.cwd(), + ); + if (result.ok) { + await channel.sendMessage(chatJid, result.url); + } else { + await channel.sendMessage( + chatJid, + `Remote Control failed: ${result.error}`, + ); + } + } else { + const result = stopRemoteControl(); + if (result.ok) { + await channel.sendMessage(chatJid, 'Remote Control session ended.'); + } else { + await channel.sendMessage(chatJid, result.error); + } + } + } + // Channel callbacks (shared by all channels) const channelOpts = { onMessage: (chatJid: string, msg: NewMessage) => { + // Remote control commands — intercept before storage + const trimmed = msg.content.trim(); + if (trimmed === '/remote-control' || trimmed === '/remote-control-end') { + handleRemoteControl(trimmed, chatJid, msg).catch((err) => + logger.error({ err, chatJid }, 'Remote control command error'), + ); + return; + } + // Sender allowlist drop mode: discard messages from denied senders before storing if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { const cfg = loadSenderAllowlist(); diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts new file mode 100644 index 0000000..4b5ab2f --- /dev/null +++ b/src/remote-control.test.ts @@ -0,0 +1,377 @@ +import fs from 'fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock config before importing the module under test +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/nanoclaw-rc-test', +})); + +// Mock child_process +const spawnMock = vi.fn(); +vi.mock('child_process', () => ({ + spawn: (...args: any[]) => spawnMock(...args), +})); + +import { + startRemoteControl, + stopRemoteControl, + restoreRemoteControl, + getActiveSession, + _resetForTesting, + _getStateFilePath, +} from './remote-control.js'; + +// --- Helpers --- + +function createMockProcess(pid = 12345) { + return { pid, unref: vi.fn(), kill: vi.fn() }; +} + +describe('remote-control', () => { + const STATE_FILE = _getStateFilePath(); + let readFileSyncSpy: ReturnType; + let writeFileSyncSpy: ReturnType; + let unlinkSyncSpy: ReturnType; + let mkdirSyncSpy: ReturnType; + let openSyncSpy: ReturnType; + let closeSyncSpy: ReturnType; + + // Track what readFileSync should return for the stdout file + let stdoutFileContent: string; + + beforeEach(() => { + _resetForTesting(); + spawnMock.mockReset(); + stdoutFileContent = ''; + + // Default fs mocks + mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any); + writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); + closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); + + // readFileSync: return stdoutFileContent for the stdout file, state file, etc. + readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { + if (p.endsWith('remote-control.stdout')) return stdoutFileContent; + if (p.endsWith('remote-control.json')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + return ''; + }) as any); + }); + + afterEach(() => { + _resetForTesting(); + vi.restoreAllMocks(); + }); + + // --- startRemoteControl --- + + describe('startRemoteControl', () => { + it('spawns claude remote-control and returns the URL', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + + // Simulate URL appearing in stdout file on first poll + stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_abc123', + }); + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + ['remote-control', '--name', 'NanoClaw Remote'], + expect.objectContaining({ cwd: '/project', detached: true }), + ); + expect(proc.unref).toHaveBeenCalled(); + }); + + it('uses file descriptors for stdout/stderr (not pipes)', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + const spawnCall = spawnMock.mock.calls[0]; + const options = spawnCall[2]; + // stdio should use file descriptors (numbers), not 'pipe' + expect(options.stdio[0]).toBe('ignore'); + expect(typeof options.stdio[1]).toBe('number'); + expect(typeof options.stdio[2]).toBe('number'); + }); + + it('closes file descriptors in parent after spawn', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + // Two openSync calls (stdout + stderr), two closeSync calls + expect(openSyncSpy).toHaveBeenCalledTimes(2); + expect(closeSyncSpy).toHaveBeenCalledTimes(2); + }); + + it('saves state to disk after capturing URL', async () => { + const proc = createMockProcess(99999); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + expect(writeFileSyncSpy).toHaveBeenCalledWith( + STATE_FILE, + expect.stringContaining('"pid":99999'), + ); + }); + + it('returns existing URL if session is already active', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + // Second call should return existing URL without spawning + const result = await startRemoteControl('user2', 'tg:456', '/project'); + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_existing', + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('starts new session if existing process is dead', async () => { + const proc1 = createMockProcess(11111); + const proc2 = createMockProcess(22222); + spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); + + // First start: process alive, URL found + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; + await startRemoteControl('user1', 'tg:123', '/project'); + + // Old process (11111) is dead, new process (22222) is alive + killSpy.mockImplementation(((pid: number, sig: any) => { + if (pid === 11111 && (sig === 0 || sig === undefined)) { + throw new Error('ESRCH'); + } + return true; + }) as any); + + stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n'; + const result = await startRemoteControl('user1', 'tg:123', '/project'); + + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_second', + }); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + it('returns error if process exits before URL', async () => { + const proc = createMockProcess(33333); + spawnMock.mockReturnValue(proc); + stdoutFileContent = ''; + + // Process is dead (poll will detect this) + vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as any); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + expect(result).toEqual({ + ok: false, + error: 'Process exited before producing URL', + }); + }); + + it('times out if URL never appears', async () => { + vi.useFakeTimers(); + const proc = createMockProcess(44444); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'no url here'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + const promise = startRemoteControl('user1', 'tg:123', '/project'); + + // Advance past URL_TIMEOUT_MS (30s), with enough steps for polls + for (let i = 0; i < 160; i++) { + await vi.advanceTimersByTimeAsync(200); + } + + const result = await promise; + expect(result).toEqual({ + ok: false, + error: 'Timed out waiting for Remote Control URL', + }); + + vi.useRealTimers(); + }); + + it('returns error if spawn throws', async () => { + spawnMock.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + expect(result).toEqual({ + ok: false, + error: 'Failed to start: ENOENT', + }); + }); + }); + + // --- stopRemoteControl --- + + describe('stopRemoteControl', () => { + it('kills the process and clears state', async () => { + const proc = createMockProcess(55555); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + const result = stopRemoteControl(); + expect(result).toEqual({ ok: true }); + expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM'); + expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE); + expect(getActiveSession()).toBeNull(); + }); + + it('returns error when no session is active', () => { + const result = stopRemoteControl(); + expect(result).toEqual({ + ok: false, + error: 'No active Remote Control session', + }); + }); + }); + + // --- restoreRemoteControl --- + + describe('restoreRemoteControl', () => { + it('restores session if state file exists and process is alive', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + restoreRemoteControl(); + + const active = getActiveSession(); + expect(active).not.toBeNull(); + expect(active!.pid).toBe(77777); + expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored'); + }); + + it('clears state if process is dead', () => { + const session = { + pid: 88888, + url: 'https://claude.ai/code?bridge=env_dead', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as any); + + restoreRemoteControl(); + + expect(getActiveSession()).toBeNull(); + expect(unlinkSyncSpy).toHaveBeenCalled(); + }); + + it('does nothing if no state file exists', () => { + // readFileSyncSpy default throws ENOENT for .json + restoreRemoteControl(); + expect(getActiveSession()).toBeNull(); + }); + + it('clears state on corrupted JSON', () => { + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return 'not json{{{'; + return ''; + }) as any); + + restoreRemoteControl(); + + expect(getActiveSession()).toBeNull(); + expect(unlinkSyncSpy).toHaveBeenCalled(); + }); + + // ** This is the key integration test: restore → stop must work ** + it('stopRemoteControl works after restoreRemoteControl', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + restoreRemoteControl(); + expect(getActiveSession()).not.toBeNull(); + + const result = stopRemoteControl(); + expect(result).toEqual({ ok: true }); + expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM'); + expect(unlinkSyncSpy).toHaveBeenCalled(); + expect(getActiveSession()).toBeNull(); + }); + + it('startRemoteControl returns restored URL without spawning', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + restoreRemoteControl(); + + return startRemoteControl('user2', 'tg:456', '/project').then((result) => { + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_restored', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/remote-control.ts b/src/remote-control.ts new file mode 100644 index 0000000..df8f646 --- /dev/null +++ b/src/remote-control.ts @@ -0,0 +1,216 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { logger } from './logger.js'; + +interface RemoteControlSession { + pid: number; + url: string; + startedBy: string; + startedInChat: string; + startedAt: string; +} + +let activeSession: RemoteControlSession | null = null; + +const URL_REGEX = /https:\/\/claude\.ai\/code\S+/; +const URL_TIMEOUT_MS = 30_000; +const URL_POLL_MS = 200; +const STATE_FILE = path.join(DATA_DIR, 'remote-control.json'); +const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout'); +const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr'); + +function saveState(session: RemoteControlSession): void { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(session)); +} + +function clearState(): void { + try { + fs.unlinkSync(STATE_FILE); + } catch { + // ignore + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Restore session from disk on startup. + * If the process is still alive, adopt it. Otherwise, clean up. + */ +export function restoreRemoteControl(): void { + let data: string; + try { + data = fs.readFileSync(STATE_FILE, 'utf-8'); + } catch { + return; + } + + try { + const session: RemoteControlSession = JSON.parse(data); + if (session.pid && isProcessAlive(session.pid)) { + activeSession = session; + logger.info( + { pid: session.pid, url: session.url }, + 'Restored Remote Control session from previous run', + ); + } else { + clearState(); + } + } catch { + clearState(); + } +} + +export function getActiveSession(): RemoteControlSession | null { + return activeSession; +} + +/** @internal — exported for testing only */ +export function _resetForTesting(): void { + activeSession = null; +} + +/** @internal — exported for testing only */ +export function _getStateFilePath(): string { + return STATE_FILE; +} + +export async function startRemoteControl( + sender: string, + chatJid: string, + cwd: string, +): Promise<{ ok: true; url: string } | { ok: false; error: string }> { + if (activeSession) { + // Verify the process is still alive + if (isProcessAlive(activeSession.pid)) { + return { ok: true, url: activeSession.url }; + } + // Process died — clean up and start a new one + activeSession = null; + clearState(); + } + + // Redirect stdout/stderr to files so the process has no pipes to the parent. + // This prevents SIGPIPE when NanoClaw restarts. + fs.mkdirSync(DATA_DIR, { recursive: true }); + const stdoutFd = fs.openSync(STDOUT_FILE, 'w'); + const stderrFd = fs.openSync(STDERR_FILE, 'w'); + + let proc; + try { + proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], { + cwd, + stdio: ['ignore', stdoutFd, stderrFd], + detached: true, + }); + } catch (err: any) { + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + return { ok: false, error: `Failed to start: ${err.message}` }; + } + + // Close FDs in the parent — the child inherited copies + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + + // Fully detach from parent + proc.unref(); + + const pid = proc.pid; + if (!pid) { + return { ok: false, error: 'Failed to get process PID' }; + } + + // Poll the stdout file for the URL + return new Promise((resolve) => { + const startTime = Date.now(); + + const poll = () => { + // Check if process died + if (!isProcessAlive(pid)) { + resolve({ ok: false, error: 'Process exited before producing URL' }); + return; + } + + // Check for URL in stdout file + let content = ''; + try { + content = fs.readFileSync(STDOUT_FILE, 'utf-8'); + } catch { + // File might not have content yet + } + + const match = content.match(URL_REGEX); + if (match) { + const session: RemoteControlSession = { + pid, + url: match[0], + startedBy: sender, + startedInChat: chatJid, + startedAt: new Date().toISOString(), + }; + activeSession = session; + saveState(session); + + logger.info( + { url: match[0], pid, sender, chatJid }, + 'Remote Control session started', + ); + resolve({ ok: true, url: match[0] }); + return; + } + + // Timeout check + if (Date.now() - startTime >= URL_TIMEOUT_MS) { + try { + process.kill(-pid, 'SIGTERM'); + } catch { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already dead + } + } + resolve({ + ok: false, + error: 'Timed out waiting for Remote Control URL', + }); + return; + } + + setTimeout(poll, URL_POLL_MS); + }; + + poll(); + }); +} + +export function stopRemoteControl(): { + ok: true; +} | { ok: false; error: string } { + if (!activeSession) { + return { ok: false, error: 'No active Remote Control session' }; + } + + const { pid } = activeSession; + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already dead + } + activeSession = null; + clearState(); + logger.info({ pid }, 'Remote Control session stopped'); + return { ok: true }; +}