From 5e2bf1cb54bfecbbd5b9a7643a32e0f173cafbdf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:07:37 +0300 Subject: [PATCH] feat(cli): replace MCP tool with standalone nc client in container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the nc MCP tool in favor of a standalone Bun CLI script at container/agent-runner/src/cli/nc.ts. Same interface as host-side bin/nc — all three callers (operator, Claude on host, agent in container) now use the same nc CLI. Container transport: writes cli_request to outbound.db (BEGIN IMMEDIATE for seq safety), polls inbound.db for response, acks via processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that execs the mounted source. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 5 + container/agent-runner/src/cli/nc.ts | 253 ++++++++++++++++++ container/agent-runner/src/db/messages-in.ts | 24 -- container/agent-runner/src/mcp-tools/cli.ts | 97 ------- container/agent-runner/src/mcp-tools/index.ts | 1 - 5 files changed, 258 insertions(+), 122 deletions(-) create mode 100644 container/agent-runner/src/cli/nc.ts delete mode 100644 container/agent-runner/src/mcp-tools/cli.ts diff --git a/container/Dockerfile b/container/Dockerfile index efa58b6..d6b654a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -104,6 +104,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +# ---- nc CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/nc.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/nc.ts "$@"\n' > /usr/local/bin/nc && \ + chmod +x /usr/local/bin/nc + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts new file mode 100644 index 0000000..319c63a --- /dev/null +++ b/container/agent-runner/src/cli/nc.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * nc — NanoClaw CLI client (container edition). + * + * Same interface as the host-side `bin/nc`. Detects that it's inside a + * container (the session DBs exist at /workspace/) and uses a DB transport + * instead of the Unix socket transport. + * + * Writes a cli_request system message to outbound.db, polls inbound.db + * for the response. Self-contained — no imports from agent-runner. + */ +import { Database } from 'bun:sqlite'; + +// --------------------------------------------------------------------------- +// Frame types (mirrors src/cli/frame.ts on the host) +// --------------------------------------------------------------------------- + +type RequestFrame = { + id: string; + command: string; + args: Record; +}; + +type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: string; message: string } }; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const INBOUND_DB = '/workspace/inbound.db'; +const OUTBOUND_DB = '/workspace/outbound.db'; + +// --------------------------------------------------------------------------- +// DB transport +// --------------------------------------------------------------------------- + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Write a cli_request to outbound.db. + * + * Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq), + * preventing seq collisions with concurrent agent-runner writes. + */ +function writeRequest(req: RequestFrame): void { + const db = new Database(OUTBOUND_DB); + db.exec('PRAGMA journal_mode = DELETE'); + db.exec('PRAGMA busy_timeout = 5000'); + + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + + try { + db.exec('BEGIN IMMEDIATE'); + const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; + + db.prepare( + `INSERT INTO messages_out (id, seq, timestamp, kind, content) + VALUES ($id, $seq, datetime('now'), 'system', $content)`, + ).run({ + $id: req.id, + $seq: nextSeq, + $content: JSON.stringify({ + action: 'cli_request', + requestId: req.id, + command: req.command, + args: req.args, + }), + }); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } finally { + inDb.close(); + db.close(); + } +} + +/** + * Poll inbound.db for a cli_response matching our requestId. + * Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility. + */ +function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + inDb.exec('PRAGMA mmap_size = 0'); + + try { + const row = inDb + .prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; + + if (row) { + inDb.close(); + + // Mark as completed via processing_ack so agent-runner skips it + const outDb = new Database(OUTBOUND_DB); + outDb.exec('PRAGMA journal_mode = DELETE'); + outDb.exec('PRAGMA busy_timeout = 5000'); + outDb + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ) + .run(row.id); + outDb.close(); + + const parsed = JSON.parse(row.content); + return parsed.frame as ResponseFrame; + } + } finally { + try { inDb.close(); } catch {} + } + + Bun.sleepSync(500); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Arg parsing (mirrors host-side client.ts) +// --------------------------------------------------------------------------- + +function parseArgv(argv: string[]): { + command: string; + args: Record; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { + json = true; + continue; + } + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + continue; + } + positional.push(a); + } + + if (positional.length === 0) { + process.stderr.write('nc: missing command\n'); + printUsage(); + process.exit(2); + } + + const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + ['Usage: nc [--key value ...] [--json]', '', 'Run `nc help` to list available commands.', ''].join('\n'), + ); +} + +// --------------------------------------------------------------------------- +// Formatting (mirrors src/cli/format.ts on the host) +// --------------------------------------------------------------------------- + +function formatHuman(resp: ResponseFrame): string { + if (!resp.ok) { + return `error (${resp.error.code}): ${resp.error.message}\n`; + } + + const data = resp.data; + if (!Array.isArray(data) || data.length === 0) { + return JSON.stringify(data, null, 2) + '\n'; + } + + const isFlat = data.every( + (r) => + typeof r === 'object' && + r !== null && + !Array.isArray(r) && + Object.values(r as Record).every((v) => typeof v !== 'object' || v === null), + ); + + if (!isFlat) return JSON.stringify(data, null, 2) + '\n'; + + const keys = Object.keys(data[0] as Record); + const widths = keys.map((k) => + Math.max(k.length, ...data.map((r) => String((r as Record)[k] ?? '').length)), + ); + + const header = keys.map((k, i) => k.padEnd(widths[i])).join(' '); + const sep = widths.map((w) => '-'.repeat(w)).join(' '); + const rows = data.map((r) => + keys + .map((k, i) => String((r as Record)[k] ?? '').padEnd(widths[i])) + .join(' '), + ); + + return [header, sep, ...rows, ''].join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const argv = process.argv.slice(2); + +if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(0); +} + +const { command, args, json } = parseArgv(argv); +const requestId = generateId(); +const req: RequestFrame = { id: requestId, command, args }; + +writeRequest(req); + +const resp = pollResponse(requestId, 30_000); + +if (!resp) { + process.stderr.write('nc: command timed out after 30s\n'); + process.exit(2); +} + +if (json) { + process.stdout.write(JSON.stringify(resp, null, 2) + '\n'); +} else { + const output = formatHuman(resp); + if (!resp.ok) { + process.stderr.write(output); + process.exit(1); + } + process.stdout.write(output); +} diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 3fcb226..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -124,30 +124,6 @@ export function getMessageIn(id: string): MessageInRow | undefined { } } -/** - * Find a pending CLI response (by requestId in content). - * Reads from inbound.db, checks processing_ack to skip already-handled responses. - */ -export function findCliResponse(requestId: string): MessageInRow | undefined { - const inbound = openInboundDb(); - const outbound = getOutboundDb(); - - try { - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined; - - if (!response) return undefined; - - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; - - return response; - } finally { - inbound.close(); - } -} - /** * Find a pending response to a question (by questionId in content). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts deleted file mode 100644 index 868eee3..0000000 --- a/container/agent-runner/src/mcp-tools/cli.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * CLI MCP tool — lets the container agent invoke host CLI commands. - * - * Follows the ask_user_question blocking pattern: writes a system message - * to outbound.db, polls inbound.db for the response. - */ -import { findCliResponse, markCompleted } from '../db/messages-in.js'; -import { writeMessageOut } from '../db/messages-out.js'; -import { registerTools } from './server.js'; -import type { McpToolDefinition } from './types.js'; - -function log(msg: string): void { - console.error(`[mcp-tools] ${msg}`); -} - -function generateId(): string { - return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const ncCommand: McpToolDefinition = { - tool: { - name: 'nc', - description: - 'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.', - inputSchema: { - type: 'object' as const, - properties: { - command: { type: 'string', description: 'Command name (e.g. "list-groups")' }, - args: { - type: 'object', - description: 'Command arguments (command-specific)', - additionalProperties: true, - }, - timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' }, - }, - required: ['command'], - }, - }, - async handler(args) { - const command = args.command as string; - const commandArgs = (args.args as Record) ?? {}; - const timeout = ((args.timeout as number) || 30) * 1000; - - if (!command) { - return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true }; - } - - const requestId = generateId(); - - writeMessageOut({ - id: requestId, - kind: 'system', - content: JSON.stringify({ - action: 'cli_request', - requestId, - command, - args: commandArgs, - }), - }); - - log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`); - - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const response = findCliResponse(requestId); - if (response) { - markCompleted([response.id]); - const parsed = JSON.parse(response.content); - const frame = parsed.frame; - - if (frame.ok) { - log(`nc response: ${requestId} → ok`); - return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] }; - } else { - log(`nc response: ${requestId} → error: ${frame.error.message}`); - return { - content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }], - isError: true, - }; - } - } - await sleep(500); - } - - log(`nc timeout: ${requestId}`); - return { - content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }], - isError: true, - }; - }, -}; - -registerTools([ncCommand]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index 672a637..bdaef5c 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,7 +10,6 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; -import './cli.js'; import { startMcpServer } from './server.js'; function log(msg: string): void {