feat: remove container layer, run agents as direct host processes

- Rewrite container-runner.ts to spawn node processes directly instead
  of Docker/Apple Container
- Runner paths configurable via env vars (NANOCLAW_GROUP_DIR, etc.)
  with container-path defaults for backwards compat
- Pass real credentials directly (no credential proxy needed)
- Remove ensureContainerSystemRunning() and startCredentialProxy()
  from startup
- Add build:runners script for building agent-runner and codex-runner
- Fix TS strict mode errors in agent-runner ipc-mcp-stdio.ts
This commit is contained in:
Eyejoker
2026-03-10 21:05:21 +09:00
parent 4a34a9c802
commit 08dd468692
15 changed files with 449 additions and 498 deletions

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandAllowed,
} from './session-commands.js';
import type { NewMessage } from './types.js';
import type { SessionCommandDeps } from './session-commands.js';
@@ -23,7 +27,9 @@ describe('extractSessionCommand', () => {
});
it('rejects regular messages', () => {
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
expect(
extractSessionCommand('please compact the conversation', trigger),
).toBeNull();
});
it('handles whitespace', () => {
@@ -53,7 +59,10 @@ describe('isSessionCommandAllowed', () => {
});
});
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
function makeMsg(
content: string,
overrides: Partial<NewMessage> = {},
): NewMessage {
return {
id: 'msg-1',
chat_jid: 'group@test',
@@ -65,7 +74,9 @@ function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessa
};
}
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
function makeDeps(
overrides: Partial<SessionCommandDeps> = {},
): SessionCommandDeps {
return {
sendMessage: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
@@ -105,7 +116,10 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
@@ -120,13 +134,17 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
expect(deps.sendMessage).toHaveBeenCalledWith(
'Session commands require admin access.',
);
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('silently consumes denied command when sender cannot interact', async () => {
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
const deps = makeDeps({
canSenderInteract: vi.fn().mockReturnValue(false),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
@@ -158,8 +176,14 @@ describe('handleSessionCommand', () => {
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
// Two runAgent calls: pre-compact + /compact
expect(deps.runAgent).toHaveBeenCalledTimes(2);
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'<formatted>',
expect.any(Function),
);
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
});
it('allows is_from_me sender in non-main group', async () => {
@@ -173,15 +197,20 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
});
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
// runAgent resolves 'error' but callback never gets status: 'error'
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
await onOutput({ status: 'success', result: null });
return 'error';
})});
const deps = makeDeps({
runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
await onOutput({ status: 'success', result: null });
return 'error';
}),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
@@ -191,7 +220,9 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.stringContaining('failed'),
);
});
it('returns success:false on pre-compact failure with no output', async () => {
@@ -209,6 +240,8 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: false });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.stringContaining('Failed to process'),
);
});
});