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) <noreply@anthropic.com>
This commit is contained in:
157
.claude/skills/use-native-credential-proxy/SKILL.md
Normal file
157
.claude/skills/use-native-credential-proxy/SKILL.md
Normal file
@@ -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=<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=<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=<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=<other 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 <merge-commit> -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`
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user