mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4243575fa0 | |||
| 6b27a0d11d | |||
| 5743a48f9b | |||
| 69348510e9 | |||
| 17a72938be | |||
| 4511644d0d | |||
| 86063e0ea0 | |||
| d1ce15a4de | |||
| 5b24dd4d2e | |||
| 0d8f7f8668 | |||
| fff32f3028 | |||
| 1bb065e655 | |||
| ea7561a978 | |||
| cfc4b6c28e | |||
| dad98b0a8f | |||
| 3e41e54e10 | |||
| 4d9f0288ee | |||
| 972edd14f6 | |||
| fd59ff0ec9 | |||
| e2e32219c9 | |||
| c601aaa947 | |||
| d43d53244f | |||
| e8326bae62 | |||
| d71ffaf7ef | |||
| 5b5ee91aa7 | |||
| 2007471f4f | |||
| 9e90c0712e | |||
| 2317302745 | |||
| b247357e0d | |||
| 4dd27adb84 | |||
| cc4f03a203 | |||
| 4bc232e513 | |||
| c9d1569702 | |||
| 5b20e2908a | |||
| 089fcea474 | |||
| bd64fd667d | |||
| f0ac7fbb6d | |||
| 207addfa19 | |||
| 4afb5bd9f1 | |||
| dfcdfcac11 | |||
| d33e514d04 | |||
| 4cb13b2b60 |
@@ -0,0 +1 @@
|
||||
OLLAMA_HOST=
|
||||
|
||||
@@ -409,7 +409,8 @@ async function runQuery(
|
||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||
'TodoWrite', 'ToolSearch', 'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*'
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__ollama__*'
|
||||
],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
@@ -425,6 +426,10 @@ async function runQuery(
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
ollama: {
|
||||
command: 'node',
|
||||
args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')],
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Ollama MCP Server for NanoClaw
|
||||
* Exposes local Ollama models as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Ollama instance from Docker.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
|
||||
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
|
||||
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[OLLAMA] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
async function ollamaFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const url = `${OLLAMA_HOST}${path}`;
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (OLLAMA_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, options);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'ollama',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'ollama_list_models',
|
||||
'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await ollamaFetch('/api/tags');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> };
|
||||
const models = data.models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull <model>` on the host to install one.' }] };
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_generate',
|
||||
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
|
||||
{
|
||||
model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z.string().optional().describe('Optional system prompt to set model behavior'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
prompt: args.prompt,
|
||||
stream: false,
|
||||
};
|
||||
if (args.system) {
|
||||
body.system = args.system;
|
||||
}
|
||||
|
||||
const res = await ollamaFetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json() as { response: string; total_duration?: number; eval_count?: number };
|
||||
|
||||
let meta = '';
|
||||
if (data.total_duration) {
|
||||
const secs = (data.total_duration / 1e9).toFixed(1);
|
||||
meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`;
|
||||
log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`);
|
||||
writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`);
|
||||
} else {
|
||||
log(`<<< Done: ${args.model} | ${data.response.length} chars`);
|
||||
writeStatus('done', `${args.model} | ${data.response.length} chars`);
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text' as const, text: data.response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Management tools — only registered when OLLAMA_ADMIN_TOOLS=true
|
||||
if (OLLAMA_ADMIN_TOOLS) {
|
||||
server.tool(
|
||||
'ollama_pull_model',
|
||||
'Pull (download) a model from the Ollama registry by name. Returns the final status once the pull is complete. Use model names like "llama3.2", "mistral", "gemma2:9b".',
|
||||
{
|
||||
model: z.string().describe('Model name to pull, e.g. "llama3.2", "mistral", "gemma2:9b"'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Pulling model: ${args.model}...`);
|
||||
writeStatus('pulling', `Pulling ${args.model}`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model, stream: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const data = await res.json() as { status: string };
|
||||
log(`Pull complete: ${args.model} — ${data.status}`);
|
||||
writeStatus('done', `Pulled ${args.model}`);
|
||||
return { content: [{ type: 'text' as const, text: `Pull complete: ${args.model} — ${data.status}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to pull model: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_delete_model',
|
||||
'Delete a locally installed Ollama model to free up disk space.',
|
||||
{
|
||||
model: z.string().describe('Model name to delete, e.g. "llama3.2", "mistral:latest"'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Deleting model: ${args.model}...`);
|
||||
writeStatus('deleting', `Deleting ${args.model}`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
log(`Deleted: ${args.model}`);
|
||||
writeStatus('done', `Deleted ${args.model}`);
|
||||
return { content: [{ type: 'text' as const, text: `Deleted model: ${args.model}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to delete model: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_show_model',
|
||||
'Show details for a locally installed Ollama model: modelfile, parameters, template, system prompt, and architecture info.',
|
||||
{
|
||||
model: z.string().describe('Model name to inspect, e.g. "llama3.2", "mistral:latest"'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Showing model info: ${args.model}...`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/show', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to show model info: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_list_running',
|
||||
'List Ollama models currently loaded in memory with their memory usage, processor type (CPU/GPU), and time until they are unloaded.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing running models...');
|
||||
try {
|
||||
const res = await ollamaFetch('/api/ps');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const data = await res.json() as { models?: Array<{ name: string; size: number; size_vram: number; processor: string; expires_at: string }> };
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No models currently loaded in memory.' }] };
|
||||
}
|
||||
const list = models
|
||||
.map(m => {
|
||||
const size = m.size_vram > 0 ? m.size_vram : m.size;
|
||||
return `- ${m.name} (${(size / 1e9).toFixed(1)}GB ${m.processor}, unloads at ${m.expires_at})`;
|
||||
})
|
||||
.join('\n');
|
||||
log(`${models.length} model(s) running`);
|
||||
return { content: [{ type: 'text' as const, text: `Models loaded in memory:\n${list}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to list running models: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
log('Admin tools enabled (pull, delete, show, list-running)');
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Watch NanoClaw IPC for Ollama activity and show macOS notifications
|
||||
# Usage: ./scripts/ollama-watch.sh
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
echo "Watching for Ollama activity..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
LAST_TIMESTAMP=""
|
||||
|
||||
while true; do
|
||||
# Check all group IPC dirs for ollama_status.json
|
||||
for status_file in data/ipc/*/ollama_status.json; do
|
||||
[ -f "$status_file" ] || continue
|
||||
|
||||
TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null)
|
||||
[ -z "$TIMESTAMP" ] && continue
|
||||
[ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue
|
||||
|
||||
LAST_TIMESTAMP="$TIMESTAMP"
|
||||
STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null)
|
||||
DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null)
|
||||
|
||||
case "$STATUS" in
|
||||
generating)
|
||||
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null
|
||||
echo "$(date +%H:%M:%S) 🔄 $DETAIL"
|
||||
;;
|
||||
done)
|
||||
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null
|
||||
echo "$(date +%H:%M:%S) ✅ $DETAIL"
|
||||
;;
|
||||
listing)
|
||||
echo "$(date +%H:%M:%S) 📋 Listing models..."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 0.5
|
||||
done
|
||||
+1
-1
@@ -101,7 +101,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
const envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
|
||||
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) {
|
||||
credentials = 'configured';
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -5,15 +5,21 @@ import { readEnvFile } from './env.js';
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
// Secrets (API keys, tokens) are NOT read here — they are loaded only
|
||||
// by the credential proxy (credential-proxy.ts), never exposed to containers.
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'TZ']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'OLLAMA_ADMIN_TOOLS',
|
||||
'ONECLI_URL',
|
||||
'TZ',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
|
||||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
export const OLLAMA_ADMIN_TOOLS =
|
||||
(process.env.OLLAMA_ADMIN_TOOLS || envConfig.OLLAMA_ADMIN_TOOLS) === 'true';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
@@ -48,10 +54,8 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
export const CREDENTIAL_PROXY_PORT = parseInt(
|
||||
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
||||
10,
|
||||
);
|
||||
export const ONECLI_URL =
|
||||
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
|
||||
|
||||
@@ -11,10 +11,10 @@ vi.mock('./config.js', () => ({
|
||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||
CREDENTIAL_PROXY_PORT: 3001,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
ONECLI_URL: 'http://localhost:10254',
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
@@ -54,15 +54,20 @@ vi.mock('./mount-security.js', () => ({
|
||||
// Mock container-runtime
|
||||
vi.mock('./container-runtime.js', () => ({
|
||||
CONTAINER_RUNTIME_BIN: 'docker',
|
||||
CONTAINER_HOST_GATEWAY: 'host.docker.internal',
|
||||
hostGatewayArgs: () => [],
|
||||
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
|
||||
stopContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock credential-proxy
|
||||
vi.mock('./credential-proxy.js', () => ({
|
||||
detectAuthMode: vi.fn(() => 'api-key'),
|
||||
// Mock OneCLI SDK
|
||||
vi.mock('@onecli-sh/sdk', () => ({
|
||||
OneCLI: class {
|
||||
applyContainerConfig = vi.fn().mockResolvedValue(true);
|
||||
createAgent = vi.fn().mockResolvedValue({ id: 'test' });
|
||||
ensureAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', identifier: 'test', created: true });
|
||||
},
|
||||
}));
|
||||
|
||||
// Create a controllable fake ChildProcess
|
||||
|
||||
+40
-21
@@ -10,25 +10,27 @@ import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
OLLAMA_ADMIN_TOOLS,
|
||||
ONECLI_URL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
CONTAINER_HOST_GATEWAY,
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
hostGatewayArgs,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
} from './container-runtime.js';
|
||||
import { detectAuthMode } from './credential-proxy.js';
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
@@ -78,7 +80,7 @@ function buildVolumeMounts(
|
||||
});
|
||||
|
||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
||||
// Credentials are injected by the credential proxy, never exposed to containers.
|
||||
// Credentials are injected by the OneCLI gateway, never exposed to containers.
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
mounts.push({
|
||||
@@ -222,30 +224,34 @@ function buildVolumeMounts(
|
||||
return mounts;
|
||||
}
|
||||
|
||||
function buildContainerArgs(
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
): string[] {
|
||||
agentIdentifier?: string,
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
||||
args.push(
|
||||
'-e',
|
||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
||||
);
|
||||
// Forward Ollama admin tools flag if enabled
|
||||
if (OLLAMA_ADMIN_TOOLS) {
|
||||
args.push('-e', 'OLLAMA_ADMIN_TOOLS=true');
|
||||
}
|
||||
|
||||
// Mirror the host's auth method with a placeholder value.
|
||||
// API key mode: SDK sends x-api-key, proxy replaces with real key.
|
||||
// OAuth mode: SDK exchanges placeholder token for temp API key,
|
||||
// proxy injects real OAuth token on that exchange request.
|
||||
const authMode = detectAuthMode();
|
||||
if (authMode === 'api-key') {
|
||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
||||
// OneCLI gateway handles credential injection — containers never see real secrets.
|
||||
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, {
|
||||
addHostMapping: false, // Nanoclaw already handles host gateway
|
||||
agent: agentIdentifier,
|
||||
});
|
||||
if (onecliApplied) {
|
||||
logger.info({ containerName }, 'OneCLI gateway config applied');
|
||||
} else {
|
||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
||||
logger.warn(
|
||||
{ containerName },
|
||||
'OneCLI gateway not reachable — container will have no credentials',
|
||||
);
|
||||
}
|
||||
|
||||
// Runtime-specific args for host gateway resolution
|
||||
@@ -288,7 +294,15 @@ export async function runContainerAgent(
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||
const containerArgs = buildContainerArgs(mounts, containerName);
|
||||
// Main group uses the default OneCLI agent; others use their own agent.
|
||||
const agentIdentifier = input.isMain
|
||||
? undefined
|
||||
: group.folder.toLowerCase().replace(/_/g, '-');
|
||||
const containerArgs = await buildContainerArgs(
|
||||
mounts,
|
||||
containerName,
|
||||
agentIdentifier,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
@@ -392,7 +406,12 @@ export async function runContainerAgent(
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ container: group.folder }, line);
|
||||
if (!line) continue;
|
||||
if (line.includes('[OLLAMA]')) {
|
||||
logger.info({ container: group.folder }, line);
|
||||
} else {
|
||||
logger.debug({ container: group.folder }, line);
|
||||
}
|
||||
}
|
||||
// Don't reset timeout on stderr — SDK writes debug logs continuously.
|
||||
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
@@ -11,35 +10,6 @@ import { logger } from './logger.js';
|
||||
/** The container runtime binary name. */
|
||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
||||
|
||||
/** Hostname containers use to reach the host machine. */
|
||||
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
|
||||
|
||||
/**
|
||||
* Address the credential proxy binds to.
|
||||
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
|
||||
* Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
|
||||
* falling back to 0.0.0.0 if the interface isn't found.
|
||||
*/
|
||||
export const PROXY_BIND_HOST =
|
||||
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
|
||||
|
||||
function detectProxyBindHost(): string {
|
||||
if (os.platform() === 'darwin') return '127.0.0.1';
|
||||
|
||||
// WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
|
||||
// Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
|
||||
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
|
||||
|
||||
// Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
|
||||
const ifaces = os.networkInterfaces();
|
||||
const docker0 = ifaces['docker0'];
|
||||
if (docker0) {
|
||||
const ipv4 = docker0.find((a) => a.family === 'IPv4');
|
||||
if (ipv4) return ipv4.address;
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/** CLI args needed for the container to resolve the host gateway. */
|
||||
export function hostGatewayArgs(): string[] {
|
||||
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
const mockEnv: Record<string, string> = {};
|
||||
vi.mock('./env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({ ...mockEnv })),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
|
||||
function makeRequest(
|
||||
port: number,
|
||||
options: http.RequestOptions,
|
||||
body = '',
|
||||
): Promise<{
|
||||
statusCode: number;
|
||||
body: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ ...options, hostname: '127.0.0.1', port },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode!,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('credential-proxy', () => {
|
||||
let proxyServer: http.Server;
|
||||
let upstreamServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let upstreamPort: number;
|
||||
let lastUpstreamHeaders: http.IncomingHttpHeaders;
|
||||
|
||||
beforeEach(async () => {
|
||||
lastUpstreamHeaders = {};
|
||||
|
||||
upstreamServer = http.createServer((req, res) => {
|
||||
lastUpstreamHeaders = { ...req.headers };
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
upstreamPort = (upstreamServer.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => proxyServer?.close(() => r()));
|
||||
await new Promise<void>((r) => upstreamServer?.close(() => r()));
|
||||
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
|
||||
});
|
||||
|
||||
async function startProxy(env: Record<string, string>): Promise<number> {
|
||||
Object.assign(mockEnv, env, {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
return (proxyServer.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
it('API-key mode injects x-api-key and strips placeholder', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
|
||||
});
|
||||
|
||||
it('OAuth mode replaces Authorization when container sends one', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/oauth/claude_cli/create_api_key',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['authorization']).toBe(
|
||||
'Bearer real-oauth-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('OAuth mode does not inject Authorization when container omits it', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
// Post-exchange: container uses x-api-key only, no Authorization header
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'temp-key-from-exchange',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
|
||||
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
connection: 'keep-alive',
|
||||
'keep-alive': 'timeout=5',
|
||||
'transfer-encoding': 'chunked',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
// Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
|
||||
// its own Connection header (standard HTTP/1.1 behavior), but the client's
|
||||
// custom keep-alive and transfer-encoding must not be forwarded.
|
||||
expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
|
||||
expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 502 when upstream is unreachable', async () => {
|
||||
Object.assign(mockEnv, {
|
||||
ANTHROPIC_API_KEY: 'sk-ant-real-key',
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
proxyPort = (proxyServer.address() as AddressInfo).port;
|
||||
|
||||
const res = await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.body).toBe('Bad Gateway');
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Credential proxy for container isolation.
|
||||
* Containers connect here instead of directly to the Anthropic API.
|
||||
* The proxy injects real credentials so containers never see them.
|
||||
*
|
||||
* Two auth modes:
|
||||
* API key: Proxy injects x-api-key on every request.
|
||||
* OAuth: Container CLI exchanges its placeholder token for a temp
|
||||
* API key via /api/oauth/claude_cli/create_api_key.
|
||||
* Proxy injects real OAuth token on that exchange request;
|
||||
* subsequent requests carry the temp key which is valid as-is.
|
||||
*/
|
||||
import { createServer, Server } from 'http';
|
||||
import { request as httpsRequest } from 'https';
|
||||
import { request as httpRequest, RequestOptions } from 'http';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export type AuthMode = 'api-key' | 'oauth';
|
||||
|
||||
export interface ProxyConfig {
|
||||
authMode: AuthMode;
|
||||
}
|
||||
|
||||
export function startCredentialProxy(
|
||||
port: number,
|
||||
host = '127.0.0.1',
|
||||
): Promise<Server> {
|
||||
const secrets = readEnvFile([
|
||||
'ANTHROPIC_API_KEY',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
]);
|
||||
|
||||
const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
|
||||
const oauthToken =
|
||||
secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const upstreamUrl = new URL(
|
||||
secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
|
||||
);
|
||||
const isHttps = upstreamUrl.protocol === 'https:';
|
||||
const makeRequest = isHttps ? httpsRequest : httpRequest;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const headers: Record<string, string | number | string[] | undefined> =
|
||||
{
|
||||
...(req.headers as Record<string, string>),
|
||||
host: upstreamUrl.host,
|
||||
'content-length': body.length,
|
||||
};
|
||||
|
||||
// Strip hop-by-hop headers that must not be forwarded by proxies
|
||||
delete headers['connection'];
|
||||
delete headers['keep-alive'];
|
||||
delete headers['transfer-encoding'];
|
||||
|
||||
if (authMode === 'api-key') {
|
||||
// API key mode: inject x-api-key on every request
|
||||
delete headers['x-api-key'];
|
||||
headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
// OAuth mode: replace placeholder Bearer token with the real one
|
||||
// only when the container actually sends an Authorization header
|
||||
// (exchange request + auth probes). Post-exchange requests use
|
||||
// x-api-key only, so they pass through without token injection.
|
||||
if (headers['authorization']) {
|
||||
delete headers['authorization'];
|
||||
if (oauthToken) {
|
||||
headers['authorization'] = `Bearer ${oauthToken}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = makeRequest(
|
||||
{
|
||||
hostname: upstreamUrl.hostname,
|
||||
port: upstreamUrl.port || (isHttps ? 443 : 80),
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers,
|
||||
} as RequestOptions,
|
||||
(upRes) => {
|
||||
res.writeHead(upRes.statusCode!, upRes.headers);
|
||||
upRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
upstream.on('error', (err) => {
|
||||
logger.error(
|
||||
{ err, url: req.url },
|
||||
'Credential proxy upstream error',
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end('Bad Gateway');
|
||||
}
|
||||
});
|
||||
|
||||
upstream.write(body);
|
||||
upstream.end();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
logger.info({ port, host, authMode }, 'Credential proxy started');
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect which auth mode the host is configured for. */
|
||||
export function detectAuthMode(): AuthMode {
|
||||
const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
|
||||
return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
|
||||
}
|
||||
+34
-10
@@ -1,18 +1,19 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DEFAULT_TRIGGER,
|
||||
getTriggerPattern,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
ONECLI_URL,
|
||||
POLL_INTERVAL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
import './channels/index.js';
|
||||
import {
|
||||
getChannelFactory,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
import {
|
||||
cleanupOrphans,
|
||||
ensureContainerRuntimeRunning,
|
||||
PROXY_BIND_HOST,
|
||||
} from './container-runtime.js';
|
||||
import {
|
||||
getAllChats,
|
||||
@@ -76,6 +76,27 @@ let messageLoopRunning = false;
|
||||
const channels: Channel[] = [];
|
||||
const queue = new GroupQueue();
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
|
||||
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
|
||||
if (group.isMain) return;
|
||||
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
|
||||
onecli.ensureAgent({ name: group.name, identifier }).then(
|
||||
(res) => {
|
||||
logger.info(
|
||||
{ jid, identifier, created: res.created },
|
||||
'OneCLI agent ensured',
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
logger.debug(
|
||||
{ jid, identifier, err: String(err) },
|
||||
'OneCLI agent ensure skipped',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadState(): void {
|
||||
lastTimestamp = getRouterState('last_timestamp') || '';
|
||||
const agentTs = getRouterState('last_agent_timestamp');
|
||||
@@ -157,6 +178,9 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
|
||||
ensureOneCLIAgent(jid, group);
|
||||
|
||||
logger.info(
|
||||
{ jid, name: group.name, folder: group.folder },
|
||||
'Group registered',
|
||||
@@ -527,18 +551,18 @@ 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(
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
PROXY_BIND_HOST,
|
||||
);
|
||||
// Ensure OneCLI agents exist for all registered groups.
|
||||
// Recovers from missed creates (e.g. OneCLI was down at registration time).
|
||||
for (const [jid, group] of Object.entries(registeredGroups)) {
|
||||
ensureOneCLIAgent(jid, group);
|
||||
}
|
||||
|
||||
restoreRemoteControl();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
proxyServer.close();
|
||||
await queue.shutdown(10000);
|
||||
for (const ch of channels) await ch.disconnect();
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user