Compare commits

..

42 Commits

Author SHA1 Message Date
gavrielc 4243575fa0 merge: catch up with upstream main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:29:19 +03:00
gavrielc 6b27a0d11d chore: remove direct pino/pino-pretty dependency
Pino was replaced with a built-in logger on main. For branches
with baileys (WhatsApp), pino resolves as a transitive dependency
of @whiskeysockets/baileys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:40:11 +03:00
gavrielc 5743a48f9b feat: add gated model management tools to Ollama MCP server
Adds four model management tools (pull, delete, show, list-running) behind
OLLAMA_ADMIN_TOOLS=true env var. Core inference tools (list + generate) are
always available. Management tools are opt-in during skill setup.

Based on contribution by @bitcryptic-gw in #1456 / #1331.

Co-Authored-By: BitCryptic <gary@bitcryptic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:29:34 +03:00
github-actions[bot] 69348510e9 Merge branch 'main' into skill/ollama-tool 2026-03-24 16:05:22 +00:00
github-actions[bot] 17a72938be Merge branch 'main' into skill/ollama-tool 2026-03-24 15:55:59 +00:00
github-actions[bot] 4511644d0d Merge branch 'main' into skill/ollama-tool 2026-03-24 15:46:04 +00:00
github-actions[bot] 86063e0ea0 Merge branch 'main' into skill/ollama-tool 2026-03-24 15:45:48 +00:00
github-actions[bot] d1ce15a4de Merge branch 'main' into skill/ollama-tool 2026-03-24 12:56:05 +00:00
github-actions[bot] 5b24dd4d2e Merge branch 'main' into skill/ollama-tool 2026-03-24 12:55:52 +00:00
github-actions[bot] 0d8f7f8668 Merge branch 'main' into skill/ollama-tool 2026-03-22 14:55:18 +00:00
github-actions[bot] fff32f3028 Merge branch 'main' into skill/ollama-tool 2026-03-21 11:11:06 +00:00
github-actions[bot] 1bb065e655 Merge branch 'main' into skill/ollama-tool 2026-03-21 11:09:15 +00:00
github-actions[bot] ea7561a978 Merge branch 'main' into skill/ollama-tool 2026-03-21 11:08:59 +00:00
github-actions[bot] cfc4b6c28e Merge branch 'main' into skill/ollama-tool 2026-03-21 10:21:22 +00:00
github-actions[bot] dad98b0a8f Merge branch 'main' into skill/ollama-tool 2026-03-21 09:57:40 +00:00
github-actions[bot] 3e41e54e10 Merge branch 'main' into skill/ollama-tool 2026-03-21 09:54:53 +00:00
github-actions[bot] 4d9f0288ee Merge branch 'main' into skill/ollama-tool 2026-03-19 19:05:57 +00:00
github-actions[bot] 972edd14f6 Merge branch 'main' into skill/ollama-tool 2026-03-19 19:05:42 +00:00
github-actions[bot] fd59ff0ec9 Merge branch 'main' into skill/ollama-tool 2026-03-19 19:03:46 +00:00
github-actions[bot] e2e32219c9 Merge branch 'main' into skill/ollama-tool 2026-03-19 19:03:28 +00:00
github-actions[bot] c601aaa947 Merge branch 'main' into skill/ollama-tool 2026-03-19 11:53:11 +00:00
github-actions[bot] d43d53244f Merge branch 'main' into skill/ollama-tool 2026-03-18 10:10:51 +00:00
github-actions[bot] e8326bae62 Merge branch 'main' into skill/ollama-tool 2026-03-18 09:52:36 +00:00
github-actions[bot] d71ffaf7ef Merge branch 'main' into skill/ollama-tool 2026-03-18 09:52:24 +00:00
github-actions[bot] 5b5ee91aa7 Merge branch 'main' into skill/ollama-tool 2026-03-16 17:37:28 +00:00
github-actions[bot] 2007471f4f Merge branch 'main' into skill/ollama-tool 2026-03-16 17:37:12 +00:00
github-actions[bot] 9e90c0712e Merge branch 'main' into skill/ollama-tool 2026-03-14 15:24:11 +00:00
github-actions[bot] 2317302745 Merge branch 'main' into skill/ollama-tool 2026-03-14 15:23:56 +00:00
github-actions[bot] b247357e0d Merge branch 'main' into skill/ollama-tool 2026-03-14 13:26:24 +00:00
github-actions[bot] 4dd27adb84 Merge branch 'main' into skill/ollama-tool 2026-03-13 11:59:53 +00:00
github-actions[bot] cc4f03a203 Merge branch 'main' into skill/ollama-tool 2026-03-13 11:59:19 +00:00
github-actions[bot] 4bc232e513 Merge branch 'main' into skill/ollama-tool 2026-03-11 10:30:55 +00:00
github-actions[bot] c9d1569702 Merge branch 'main' into skill/ollama-tool 2026-03-11 10:25:50 +00:00
github-actions[bot] 5b20e2908a Merge branch 'main' into skill/ollama-tool 2026-03-10 20:59:53 +00:00
github-actions[bot] 089fcea474 Merge branch 'main' into skill/ollama-tool 2026-03-10 20:52:17 +00:00
github-actions[bot] bd64fd667d Merge branch 'main' into skill/ollama-tool 2026-03-10 20:40:11 +00:00
github-actions[bot] f0ac7fbb6d Merge branch 'main' into skill/ollama-tool 2026-03-10 00:25:51 +00:00
gavrielc 207addfa19 Merge commit '4afb5bd' into rebuild-fork 2026-03-10 01:15:07 +02:00
gavrielc 4afb5bd9f1 Merge remote-tracking branch 'origin/main' into skill/ollama-tool 2026-03-09 23:21:01 +02:00
gavrielc dfcdfcac11 Merge remote-tracking branch 'origin/main' into skill/ollama-tool 2026-03-09 00:07:58 +02:00
gavrielc d33e514d04 Merge remote-tracking branch 'origin/main' into skill/ollama-tool 2026-03-08 23:24:40 +02:00
gavrielc 4cb13b2b60 skill/ollama-tool: local Ollama model inference via MCP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:15:05 +02:00
12 changed files with 434 additions and 392 deletions
+1
View File
@@ -0,0 +1 @@
OLLAMA_HOST=
+6 -1
View File
@@ -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);
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+10 -5
View File
@@ -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
View File
@@ -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).
-30
View File
@@ -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
-192
View File
@@ -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');
});
});
-125
View File
@@ -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
View File
@@ -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);