From 14247d0b577cf77a8d1f29aa3d3796b62bd1870c Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 24 Mar 2026 15:37:27 +0200 Subject: [PATCH] skill: add /use-native-credential-proxy, remove dead proxy code Add SKILL.md for the native credential proxy feature skill. Delete src/credential-proxy.ts and src/credential-proxy.test.ts which became dead code after PR #1237 (OneCLI integration). These files live on the skill/native-credential-proxy branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../use-native-credential-proxy/SKILL.md | 157 ++++++++++++++ src/credential-proxy.test.ts | 192 ------------------ src/credential-proxy.ts | 125 ------------ 3 files changed, 157 insertions(+), 317 deletions(-) create mode 100644 .claude/skills/use-native-credential-proxy/SKILL.md delete mode 100644 src/credential-proxy.test.ts delete mode 100644 src/credential-proxy.ts diff --git a/.claude/skills/use-native-credential-proxy/SKILL.md b/.claude/skills/use-native-credential-proxy/SKILL.md new file mode 100644 index 0000000..4cdda4c --- /dev/null +++ b/.claude/skills/use-native-credential-proxy/SKILL.md @@ -0,0 +1,157 @@ +--- +name: use-native-credential-proxy +description: Replace OneCLI gateway with the built-in credential proxy. For users who want simple .env-based credential management without installing OneCLI. Reads API key or OAuth token from .env and injects into container API requests. +--- + +# Use Native Credential Proxy + +This skill replaces the OneCLI gateway with NanoClaw's built-in credential proxy. Containers get credentials injected via a local HTTP proxy that reads from `.env` — no external services needed. + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `src/credential-proxy.ts` is imported in `src/index.ts`: + +```bash +grep "credential-proxy" src/index.ts +``` + +If it shows an import for `startCredentialProxy`, the native proxy is already active. Skip to Phase 3 (Setup). + +### Check if OneCLI is active + +```bash +grep "@onecli-sh/sdk" package.json +``` + +If `@onecli-sh/sdk` appears, OneCLI is the active credential provider. Proceed with Phase 2 to replace it. + +If neither check matches, you may be on an older version. Run `/update-nanoclaw` first, then retry. + +## Phase 2: Apply Code Changes + +### Ensure upstream remote + +```bash +git remote -v +``` + +If `upstream` is missing, add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/native-credential-proxy +git merge upstream/skill/native-credential-proxy || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} +``` + +This merges in: +- `src/credential-proxy.ts` and `src/credential-proxy.test.ts` (the proxy implementation) +- Restored credential proxy usage in `src/index.ts`, `src/container-runner.ts`, `src/container-runtime.ts`, `src/config.ts` +- Removed `@onecli-sh/sdk` dependency +- Restored `CREDENTIAL_PROXY_PORT` config (default 3001) +- Restored platform-aware proxy bind address detection +- Reverted setup skill to `.env`-based credential instructions + +If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides. + +### Validate code changes + +```bash +npm install +npm run build +npx vitest run src/credential-proxy.test.ts src/container-runner.test.ts +``` + +All tests must pass and build must be clean before proceeding. + +## Phase 3: Setup Credentials + +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, add it to `.env`: + +```bash +# Add to .env (create file if needed) +echo 'CLAUDE_CODE_OAUTH_TOKEN=' >> .env +``` + +Note: `ANTHROPIC_AUTH_TOKEN` is also supported as a fallback. + +### API key path + +Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. + +Add it to `.env`: + +```bash +echo 'ANTHROPIC_API_KEY=' >> .env +``` + +### After either path + +**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): write it to `.env` on their behalf using the appropriate variable name. + +**Optional:** If the user needs a custom API endpoint, they can add `ANTHROPIC_BASE_URL=` to `.env` (defaults to `https://api.anthropic.com`). + +## Phase 4: Verify + +1. Rebuild and restart: + +```bash +npm run build +``` + +Then restart the service: +- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` +- Linux: `systemctl --user restart nanoclaw` +- WSL/manual: stop and re-run `bash start-nanoclaw.sh` + +2. Check logs for successful proxy startup: + +```bash +tail -20 logs/nanoclaw.log | grep "Credential proxy" +``` + +Expected: `Credential proxy started` with port and auth mode. + +3. Send a test message in the registered chat to verify the agent responds. + +4. Note: after applying this skill, the OneCLI credential steps in `/setup` no longer apply. `.env` is now the credential source. + +## Troubleshooting + +**"Credential proxy upstream error" in logs:** Check that `.env` has a valid `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`. Verify the API is reachable: `curl -s https://api.anthropic.com/v1/messages -H "x-api-key: test" | head`. + +**Port 3001 already in use:** Set `CREDENTIAL_PROXY_PORT=` in `.env` or as an environment variable. + +**Container can't reach proxy (Linux):** The proxy binds to the `docker0` bridge IP by default. If that interface doesn't exist (e.g. rootless Docker), set `CREDENTIAL_PROXY_HOST=0.0.0.0` as an environment variable. + +**OAuth token expired (401 errors):** Re-run `claude setup-token` in a terminal and update the token in `.env`. + +## Removal + +To revert to OneCLI gateway: + +1. Find the merge commit: `git log --oneline --merges -5` +2. Revert it: `git revert -m 1` (undoes the skill branch merge, keeps your other changes) +3. `npm install` (re-adds `@onecli-sh/sdk`) +4. `npm run build` +5. Follow `/setup` step 4 to configure OneCLI credentials +6. Remove `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from `.env` diff --git a/src/credential-proxy.test.ts b/src/credential-proxy.test.ts deleted file mode 100644 index de76c89..0000000 --- a/src/credential-proxy.test.ts +++ /dev/null @@ -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 = {}; -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((resolve) => - upstreamServer.listen(0, '127.0.0.1', resolve), - ); - upstreamPort = (upstreamServer.address() as AddressInfo).port; - }); - - afterEach(async () => { - await new Promise((r) => proxyServer?.close(() => r())); - await new Promise((r) => upstreamServer?.close(() => r())); - for (const key of Object.keys(mockEnv)) delete mockEnv[key]; - }); - - async function startProxy(env: Record): Promise { - 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'); - }); -}); diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts deleted file mode 100644 index 8a893dd..0000000 --- a/src/credential-proxy.ts +++ /dev/null @@ -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 { - 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 = - { - ...(req.headers as Record), - 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'; -}