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

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