Merge branch 'main' into skill/apple-container

This commit is contained in:
github-actions[bot]
2026-03-14 15:24:09 +00:00
3 changed files with 650 additions and 0 deletions

View File

@@ -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<void> {
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<void> {
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<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)
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();

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();
});
});
});
});

216
src/remote-control.ts Normal file
View 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 };
}