feat: add /remote-control command for host-level Claude Code access

Users can send /remote-control from the main group in any channel to
spawn a detached `claude remote-control` process on the host. The
session URL is sent back through the channel. /remote-control-end
kills the session.

Key design decisions:
- One global session at a time, restricted to main group only
- Process is fully detached (stdout/stderr to files, not pipes) so it
  survives NanoClaw restarts
- PID + URL persisted to data/remote-control.json; restored on startup
- Commands intercepted in onMessage before DB storage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-03-14 16:59:52 +02:00
parent 2640973b41
commit e2b0d2d0aa
3 changed files with 650 additions and 0 deletions

377
src/remote-control.test.ts Normal file
View File

@@ -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<typeof vi.spyOn>;
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
let mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
let openSyncSpy: ReturnType<typeof vi.spyOn>;
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
// 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();
});
});
});
});