Merge branch 'main' into fix/diagnostics-prompt
This commit is contained in:
@@ -50,7 +50,7 @@ Already configured. Continue.
|
|||||||
|
|
||||||
**Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`.
|
**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.
|
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.
|
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
|
||||||
- Record PLATFORM and IS_WSL for later steps.
|
- 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
|
## 2. Check Environment
|
||||||
|
|
||||||
Run `npx tsx setup/index.ts --step environment` and parse the status block.
|
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 <chosen>` 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.
|
**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=<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=<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
|
## 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:**
|
**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=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
|
- 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`)
|
- 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
|
- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
|
||||||
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
|
- 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
|
## 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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
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`
|
||||||
@@ -21,6 +21,10 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
|
|||||||
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
||||||
| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
|
| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
|
||||||
|
|
||||||
|
## Secrets / Credentials / Proxy (OneCLI)
|
||||||
|
|
||||||
|
API keys, secret keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway — which handles secret injection into containers at request time, so no keys or tokens are ever passed to containers directly. Run `onecli --help`.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
|
Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# NanoClaw Agent Container
|
# NanoClaw Agent Container
|
||||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:24-slim
|
||||||
|
|
||||||
# Install system dependencies for Chromium
|
# Install system dependencies for Chromium
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.21",
|
"version": "1.2.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.21",
|
"version": "1.2.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@onecli-sh/sdk": "^0.2.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
@@ -786,6 +787,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.21",
|
"version": "1.2.23",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@onecli-sh/sdk": "^0.2.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="40.9k tokens, 20% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="39.9k tokens, 20% of context window">
|
||||||
<title>40.9k tokens, 20% of context window</title>
|
<title>39.9k tokens, 20% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">40.9k</text>
|
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">39.9k</text>
|
||||||
<text x="74" y="14">40.9k</text>
|
<text x="74" y="14">39.9k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -101,7 +101,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
const envFile = path.join(projectRoot, '.env');
|
const envFile = path.join(projectRoot, '.env');
|
||||||
if (fs.existsSync(envFile)) {
|
if (fs.existsSync(envFile)) {
|
||||||
const envContent = fs.readFileSync(envFile, 'utf-8');
|
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';
|
credentials = 'configured';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import path from 'path';
|
|||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
|
|
||||||
// Read config values from .env (falls back to process.env).
|
// Read config values from .env (falls back to process.env).
|
||||||
// Secrets (API keys, tokens) are NOT read here — they are loaded only
|
const envConfig = readEnvFile([
|
||||||
// by the credential proxy (credential-proxy.ts), never exposed to containers.
|
'ASSISTANT_NAME',
|
||||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
'ASSISTANT_HAS_OWN_NUMBER',
|
||||||
|
'ONECLI_URL',
|
||||||
|
]);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||||
@@ -47,10 +49,8 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
|||||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||||
10,
|
10,
|
||||||
); // 10MB default
|
); // 10MB default
|
||||||
export const CREDENTIAL_PROXY_PORT = parseInt(
|
export const ONECLI_URL =
|
||||||
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
|
||||||
10,
|
|
||||||
);
|
|
||||||
export const IPC_POLL_INTERVAL = 1000;
|
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 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(
|
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ vi.mock('./config.js', () => ({
|
|||||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||||
CREDENTIAL_PROXY_PORT: 3001,
|
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||||
IDLE_TIMEOUT: 1800000, // 30min
|
IDLE_TIMEOUT: 1800000, // 30min
|
||||||
|
ONECLI_URL: 'http://localhost:10254',
|
||||||
TIMEZONE: 'America/Los_Angeles',
|
TIMEZONE: 'America/Los_Angeles',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -51,6 +51,17 @@ vi.mock('./mount-security.js', () => ({
|
|||||||
validateAdditionalMounts: vi.fn(() => []),
|
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
|
// Create a controllable fake ChildProcess
|
||||||
function createFakeProcess() {
|
function createFakeProcess() {
|
||||||
const proc = new EventEmitter() as EventEmitter & {
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
|
|||||||
@@ -10,25 +10,26 @@ import {
|
|||||||
CONTAINER_IMAGE,
|
CONTAINER_IMAGE,
|
||||||
CONTAINER_MAX_OUTPUT_SIZE,
|
CONTAINER_MAX_OUTPUT_SIZE,
|
||||||
CONTAINER_TIMEOUT,
|
CONTAINER_TIMEOUT,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
GROUPS_DIR,
|
GROUPS_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
|
ONECLI_URL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
CONTAINER_HOST_GATEWAY,
|
|
||||||
CONTAINER_RUNTIME_BIN,
|
CONTAINER_RUNTIME_BIN,
|
||||||
hostGatewayArgs,
|
hostGatewayArgs,
|
||||||
readonlyMountArgs,
|
readonlyMountArgs,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import { detectAuthMode } from './credential-proxy.js';
|
import { OneCLI } from '@onecli-sh/sdk';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||||
|
|
||||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
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.
|
// 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');
|
const envFile = path.join(projectRoot, '.env');
|
||||||
if (fs.existsSync(envFile)) {
|
if (fs.existsSync(envFile)) {
|
||||||
mounts.push({
|
mounts.push({
|
||||||
@@ -212,30 +213,29 @@ function buildVolumeMounts(
|
|||||||
return mounts;
|
return mounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContainerArgs(
|
async function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
): string[] {
|
agentIdentifier?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||||
|
|
||||||
// Pass host timezone so container's local time matches the user's
|
// Pass host timezone so container's local time matches the user's
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
args.push('-e', `TZ=${TIMEZONE}`);
|
||||||
|
|
||||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
// OneCLI gateway handles credential injection — containers never see real secrets.
|
||||||
args.push(
|
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
|
||||||
'-e',
|
const onecliApplied = await onecli.applyContainerConfig(args, {
|
||||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
addHostMapping: false, // Nanoclaw already handles host gateway
|
||||||
);
|
agent: agentIdentifier,
|
||||||
|
});
|
||||||
// Mirror the host's auth method with a placeholder value.
|
if (onecliApplied) {
|
||||||
// API key mode: SDK sends x-api-key, proxy replaces with real key.
|
logger.info({ containerName }, 'OneCLI gateway config applied');
|
||||||
// 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');
|
|
||||||
} else {
|
} 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
|
// Runtime-specific args for host gateway resolution
|
||||||
@@ -278,7 +278,15 @@ export async function runContainerAgent(
|
|||||||
const mounts = buildVolumeMounts(group, input.isMain);
|
const mounts = buildVolumeMounts(group, input.isMain);
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
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(
|
logger.debug(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -11,35 +10,6 @@ import { logger } from './logger.js';
|
|||||||
/** The container runtime binary name. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
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. */
|
/** CLI args needed for the container to resolve the host gateway. */
|
||||||
export function hostGatewayArgs(): string[] {
|
export function hostGatewayArgs(): string[] {
|
||||||
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
// 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';
|
|
||||||
}
|
|
||||||
44
src/index.ts
44
src/index.ts
@@ -1,15 +1,16 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { OneCLI } from '@onecli-sh/sdk';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
|
ONECLI_URL,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { startCredentialProxy } from './credential-proxy.js';
|
|
||||||
import './channels/index.js';
|
import './channels/index.js';
|
||||||
import {
|
import {
|
||||||
getChannelFactory,
|
getChannelFactory,
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
cleanupOrphans,
|
cleanupOrphans,
|
||||||
ensureContainerRuntimeRunning,
|
ensureContainerRuntimeRunning,
|
||||||
PROXY_BIND_HOST,
|
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
getAllChats,
|
||||||
@@ -72,6 +72,27 @@ let messageLoopRunning = false;
|
|||||||
const channels: Channel[] = [];
|
const channels: Channel[] = [];
|
||||||
const queue = new GroupQueue();
|
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 {
|
function loadState(): void {
|
||||||
lastTimestamp = getRouterState('last_timestamp') || '';
|
lastTimestamp = getRouterState('last_timestamp') || '';
|
||||||
const agentTs = getRouterState('last_agent_timestamp');
|
const agentTs = getRouterState('last_agent_timestamp');
|
||||||
@@ -112,6 +133,9 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
// Create group folder
|
// Create group folder
|
||||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||||
|
|
||||||
|
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
|
||||||
|
ensureOneCLIAgent(jid, group);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jid, name: group.name, folder: group.folder },
|
{ jid, name: group.name, folder: group.folder },
|
||||||
'Group registered',
|
'Group registered',
|
||||||
@@ -474,18 +498,18 @@ async function main(): Promise<void> {
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
restoreRemoteControl();
|
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
// Ensure OneCLI agents exist for all registered groups.
|
||||||
const proxyServer = await startCredentialProxy(
|
// Recovers from missed creates (e.g. OneCLI was down at registration time).
|
||||||
CREDENTIAL_PROXY_PORT,
|
for (const [jid, group] of Object.entries(registeredGroups)) {
|
||||||
PROXY_BIND_HOST,
|
ensureOneCLIAgent(jid, group);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
restoreRemoteControl();
|
||||||
|
|
||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
proxyServer.close();
|
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user