Merge pull request #1133 from gabi-simons/fix/remote-control-stdin-clean

fix: auto-accept remote-control prompt to prevent immediate exit
This commit is contained in:
gavrielc
2026-03-16 19:37:00 +02:00
committed by GitHub
4 changed files with 61 additions and 21 deletions

View File

@@ -62,6 +62,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot} WorkingDirectory=${projectRoot}
Restart=always Restart=always
RestartSec=5 RestartSec=5
KillMode=process
Environment=HOME=${homeDir} Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log StandardOutput=append:${projectRoot}/logs/nanoclaw.log
@@ -142,6 +143,16 @@ describe('systemd unit generation', () => {
expect(unit).toContain('RestartSec=5'); expect(unit).toContain('RestartSec=5');
}); });
it('uses KillMode=process to preserve detached children', () => {
const unit = generateSystemdUnit(
'/usr/bin/node',
'/home/user/nanoclaw',
'/home/user',
false,
);
expect(unit).toContain('KillMode=process');
});
it('sets correct ExecStart', () => { it('sets correct ExecStart', () => {
const unit = generateSystemdUnit( const unit = generateSystemdUnit(
'/usr/bin/node', '/usr/bin/node',

View File

@@ -243,6 +243,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot} WorkingDirectory=${projectRoot}
Restart=always Restart=always
RestartSec=5 RestartSec=5
KillMode=process
Environment=HOME=${homeDir} Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log StandardOutput=append:${projectRoot}/logs/nanoclaw.log

View File

@@ -24,7 +24,12 @@ import {
// --- Helpers --- // --- Helpers ---
function createMockProcess(pid = 12345) { function createMockProcess(pid = 12345) {
return { pid, unref: vi.fn(), kill: vi.fn() }; return {
pid,
unref: vi.fn(),
kill: vi.fn(),
stdin: { write: vi.fn(), end: vi.fn() },
};
} }
describe('remote-control', () => { describe('remote-control', () => {
@@ -45,14 +50,20 @@ describe('remote-control', () => {
stdoutFileContent = ''; stdoutFileContent = '';
// Default fs mocks // Default fs mocks
mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any); mkdirSyncSpy = vi
writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); .spyOn(fs, 'mkdirSync')
.mockImplementation(() => undefined as any);
writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => {});
unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
// readFileSync: return stdoutFileContent for the stdout file, state file, etc. // readFileSync: return stdoutFileContent for the stdout file, state file, etc.
readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
p: string,
) => {
if (p.endsWith('remote-control.stdout')) return stdoutFileContent; if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
if (p.endsWith('remote-control.json')) { if (p.endsWith('remote-control.json')) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
@@ -74,7 +85,8 @@ describe('remote-control', () => {
spawnMock.mockReturnValue(proc); spawnMock.mockReturnValue(proc);
// Simulate URL appearing in stdout file on first poll // Simulate URL appearing in stdout file on first poll
stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; stdoutFileContent =
'Session URL: https://claude.ai/code?bridge=env_abc123\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any); vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
const result = await startRemoteControl('user1', 'tg:123', '/project'); const result = await startRemoteControl('user1', 'tg:123', '/project');
@@ -101,8 +113,8 @@ describe('remote-control', () => {
const spawnCall = spawnMock.mock.calls[0]; const spawnCall = spawnMock.mock.calls[0];
const options = spawnCall[2]; const options = spawnCall[2];
// stdio should use file descriptors (numbers), not 'pipe' // stdio[0] is 'pipe' so we can write 'y' to accept the prompt
expect(options.stdio[0]).toBe('ignore'); expect(options.stdio[0]).toBe('pipe');
expect(typeof options.stdio[1]).toBe('number'); expect(typeof options.stdio[1]).toBe('number');
expect(typeof options.stdio[2]).toBe('number'); expect(typeof options.stdio[2]).toBe('number');
}); });
@@ -157,7 +169,9 @@ describe('remote-control', () => {
spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
// First start: process alive, URL found // First start: process alive, URL found
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const killSpy = vi
.spyOn(process, 'kill')
.mockImplementation((() => true) as any);
stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
await startRemoteControl('user1', 'tg:123', '/project'); await startRemoteControl('user1', 'tg:123', '/project');
@@ -239,7 +253,9 @@ describe('remote-control', () => {
const proc = createMockProcess(55555); const proc = createMockProcess(55555);
spawnMock.mockReturnValue(proc); spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const killSpy = vi
.spyOn(process, 'kill')
.mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project'); await startRemoteControl('user1', 'tg:123', '/project');
@@ -337,7 +353,9 @@ describe('remote-control', () => {
if (p.endsWith('remote-control.json')) return JSON.stringify(session); if (p.endsWith('remote-control.json')) return JSON.stringify(session);
return ''; return '';
}) as any); }) as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const killSpy = vi
.spyOn(process, 'kill')
.mockImplementation((() => true) as any);
restoreRemoteControl(); restoreRemoteControl();
expect(getActiveSession()).not.toBeNull(); expect(getActiveSession()).not.toBeNull();
@@ -365,13 +383,15 @@ describe('remote-control', () => {
restoreRemoteControl(); restoreRemoteControl();
return startRemoteControl('user2', 'tg:456', '/project').then((result) => { return startRemoteControl('user2', 'tg:456', '/project').then(
expect(result).toEqual({ (result) => {
ok: true, expect(result).toEqual({
url: 'https://claude.ai/code?bridge=env_restored', ok: true,
}); url: 'https://claude.ai/code?bridge=env_restored',
expect(spawnMock).not.toHaveBeenCalled(); });
}); expect(spawnMock).not.toHaveBeenCalled();
},
);
}); });
}); });
}); });

View File

@@ -111,7 +111,7 @@ export async function startRemoteControl(
try { try {
proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], { proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
cwd, cwd,
stdio: ['ignore', stdoutFd, stderrFd], stdio: ['pipe', stdoutFd, stderrFd],
detached: true, detached: true,
}); });
} catch (err: any) { } catch (err: any) {
@@ -120,6 +120,12 @@ export async function startRemoteControl(
return { ok: false, error: `Failed to start: ${err.message}` }; return { ok: false, error: `Failed to start: ${err.message}` };
} }
// Auto-accept the "Enable Remote Control?" prompt
if (proc.stdin) {
proc.stdin.write('y\n');
proc.stdin.end();
}
// Close FDs in the parent — the child inherited copies // Close FDs in the parent — the child inherited copies
fs.closeSync(stdoutFd); fs.closeSync(stdoutFd);
fs.closeSync(stderrFd); fs.closeSync(stderrFd);
@@ -196,9 +202,11 @@ export async function startRemoteControl(
}); });
} }
export function stopRemoteControl(): { export function stopRemoteControl():
ok: true; | {
} | { ok: false; error: string } { ok: true;
}
| { ok: false; error: string } {
if (!activeSession) { if (!activeSession) {
return { ok: false, error: 'No active Remote Control session' }; return { ok: false, error: 'No active Remote Control session' };
} }