diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 2041f80..86726ff 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -50,7 +50,7 @@ Already configured. Continue. **Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`. -## 1. Bootstrap (Node.js + Dependencies) +## 1. Bootstrap (Node.js + Dependencies + OneCLI) Run `bash setup.sh` and parse the status block. @@ -62,6 +62,34 @@ Run `bash setup.sh` and parse the status block. - If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. - Record PLATFORM and IS_WSL for later steps. +After bootstrap succeeds, install OneCLI and its CLI tool: + +```bash +curl -fsSL onecli.sh/install | sh +curl -fsSL onecli.sh/cli/install | sh +``` + +Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it: + +```bash +export PATH="$HOME/.local/bin:$PATH" +# Persist for future sessions (append to shell profile if not already present) +grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +``` + +Then re-verify with `onecli version`. + +Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): +```bash +onecli config set api-host http://127.0.0.1:10254 +``` + +Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): +```bash +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env +``` + ## 2. Check Environment Run `npx tsx setup/index.ts --step environment` and parse the status block. @@ -112,15 +140,47 @@ Run `npx tsx setup/index.ts --step container -- --runtime ` and parse th **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. -## 4. Claude Authentication (No Script) +## 4. Anthropic Credentials via OneCLI -If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure? +NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time. -AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? +Check if a secret already exists: +```bash +onecli secrets list +``` -**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=` to `.env`. Do NOT collect the token in chat. +If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5. -**API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`. +AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? + +1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." +2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." + +### Subscription path + +Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat. + +Once they have the token, they register it with OneCLI. AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" + +### API key path + +Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. + +Then AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" + +### After either path + +Ask them to let you know when done. + +**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf. + +**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. ## 5. Set Up Channels @@ -198,7 +258,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block. **If STATUS=failed, fix each:** - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) - SERVICE=not_found → re-run step 7 -- CREDENTIALS=missing → re-run step 4 +- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret) - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) - REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` @@ -207,7 +267,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## Troubleshooting -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). +**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), missing channel credentials (re-invoke channel skill). **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..2fe1b22 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,7 +1,7 @@ # NanoClaw Agent Container # Runs Claude Agent SDK in isolated Linux VM with browser automation -FROM node:22-slim +FROM node:24-slim # Install system dependencies for Chromium RUN apt-get update && apt-get install -y \ diff --git a/package-lock.json b/package-lock.json index fae72c7..afca823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "nanoclaw", "version": "1.2.21", "dependencies": { + "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", "pino": "^9.6.0", @@ -786,6 +787,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@onecli-sh/sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz", + "integrity": "sha512-u7PqWROEvTV9f0ADVkjigTrd2AZn3klbPrv7GGpeRHIJpjAxJUdlWqxr5kiGt6qTDKL8t3nq76xr4X2pxTiyBg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", diff --git a/package.json b/package.json index b30dd39..54185a0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:watch": "vitest" }, "dependencies": { + "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", "pino": "^9.6.0", diff --git a/setup/verify.ts b/setup/verify.ts index f64e4d0..e039e52 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -101,7 +101,7 @@ export async function run(_args: string[]): Promise { 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'; } } diff --git a/src/config.ts b/src/config.ts index 43db54f..63d1207 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,9 +4,7 @@ import path from 'path'; import { readEnvFile } from './env.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']); +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL']); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -47,10 +45,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 IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max( diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index c830176..2de45c5 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -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', })); @@ -51,6 +51,15 @@ vi.mock('./mount-security.js', () => ({ validateAdditionalMounts: vi.fn(() => []), })); +// 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 function createFakeProcess() { const proc = new EventEmitter() as EventEmitter & { diff --git a/src/container-runner.ts b/src/container-runner.ts index a6b58d7..1dc607f 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -10,25 +10,26 @@ import { CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, - CREDENTIAL_PROXY_PORT, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, + 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---'; @@ -77,7 +78,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({ @@ -212,30 +213,26 @@ function buildVolumeMounts( return mounts; } -function buildContainerArgs( +async function buildContainerArgs( mounts: VolumeMount[], containerName: string, -): string[] { + agentIdentifier?: string, +): Promise { 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}`, - ); - - // 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 @@ -278,7 +275,11 @@ 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( { diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 9f32d10..6326fde 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index db274f0..3f5e710 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ import fs from 'fs'; import path from 'path'; +import { OneCLI } from '@onecli-sh/sdk'; + import { ASSISTANT_NAME, - CREDENTIAL_PROXY_PORT, IDLE_TIMEOUT, + ONECLI_URL, POLL_INTERVAL, TIMEZONE, TRIGGER_PATTERN, } from './config.js'; -import { startCredentialProxy } from './credential-proxy.js'; import './channels/index.js'; import { getChannelFactory, @@ -24,7 +25,6 @@ import { import { cleanupOrphans, ensureContainerRuntimeRunning, - PROXY_BIND_HOST, } from './container-runtime.js'; import { getAllChats, @@ -72,6 +72,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'); @@ -112,6 +133,9 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) + ensureOneCLIAgent(jid, group); + logger.info( { jid, name: group.name, folder: group.folder }, 'Group registered', @@ -474,18 +498,18 @@ async function main(): Promise { 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);