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:
57
src/index.ts
57
src/index.ts
@@ -46,6 +46,11 @@ import { GroupQueue } from './group-queue.js';
|
|||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { startIpcWatcher } from './ipc.js';
|
import { startIpcWatcher } from './ipc.js';
|
||||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||||
|
import {
|
||||||
|
restoreRemoteControl,
|
||||||
|
startRemoteControl,
|
||||||
|
stopRemoteControl,
|
||||||
|
} from './remote-control.js';
|
||||||
import {
|
import {
|
||||||
isSenderAllowed,
|
isSenderAllowed,
|
||||||
isTriggerAllowed,
|
isTriggerAllowed,
|
||||||
@@ -470,6 +475,7 @@ async function main(): Promise<void> {
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
restoreRemoteControl();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
// Start credential proxy (containers route API calls through this)
|
||||||
const proxyServer = await startCredentialProxy(
|
const proxyServer = await startCredentialProxy(
|
||||||
@@ -488,9 +494,60 @@ async function main(): Promise<void> {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Handle /remote-control and /remote-control-end commands
|
||||||
|
async function handleRemoteControl(
|
||||||
|
command: string,
|
||||||
|
chatJid: string,
|
||||||
|
msg: NewMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
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)
|
// Channel callbacks (shared by all channels)
|
||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
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
|
// Sender allowlist drop mode: discard messages from denied senders before storing
|
||||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
||||||
const cfg = loadSenderAllowlist();
|
const cfg = loadSenderAllowlist();
|
||||||
|
|||||||
377
src/remote-control.test.ts
Normal file
377
src/remote-control.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
src/remote-control.ts
Normal file
216
src/remote-control.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user