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:
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
(result) => {
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
url: 'https://claude.ai/code?bridge=env_restored',
|
url: 'https://claude.ai/code?bridge=env_restored',
|
||||||
});
|
});
|
||||||
expect(spawnMock).not.toHaveBeenCalled();
|
expect(spawnMock).not.toHaveBeenCalled();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: true;
|
||||||
} | { ok: false; error: string } {
|
}
|
||||||
|
| { 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' };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user