Merge remote-tracking branch 'origin/main' into skill/apple-container
# Conflicts: # src/container-runner.ts
This commit is contained in:
@@ -10,19 +10,22 @@ import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.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 { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
@@ -38,7 +41,6 @@ export interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
@@ -199,14 +201,6 @@ function buildVolumeMounts(
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read allowed secrets from .env for passing to the container via stdin.
|
||||
* Secrets are never written to disk or mounted as files.
|
||||
*/
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
|
||||
}
|
||||
|
||||
function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
@@ -217,6 +211,23 @@ function buildContainerArgs(
|
||||
// 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.
|
||||
const authMode = detectAuthMode();
|
||||
if (authMode === 'api-key') {
|
||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
||||
} else {
|
||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
||||
}
|
||||
|
||||
// Runtime-specific args for host gateway resolution
|
||||
args.push(...hostGatewayArgs());
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
@@ -301,12 +312,8 @@ export async function runContainerAgent(
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||
input.secrets = readSecrets();
|
||||
container.stdin.write(JSON.stringify(input));
|
||||
container.stdin.end();
|
||||
// Remove secrets from input so they don't appear in logs
|
||||
delete input.secrets;
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||
let parseBuffer = '';
|
||||
|
||||
@@ -24,10 +24,14 @@ Apple Container (VirtioFS) only supports directory mounts, not file mounts. The
|
||||
- All exported interfaces unchanged: `ContainerInput`, `ContainerOutput`, `runContainerAgent`, `writeTasksSnapshot`, `writeGroupsSnapshot`, `AvailableGroup`
|
||||
- Non-main containers behave identically (still get `--user` flag)
|
||||
- Mount list for non-main containers is unchanged
|
||||
- Secrets still passed via stdin, never mounted as files
|
||||
- Credentials injected by host-side credential proxy, never in container env or stdin
|
||||
- Output parsing (streaming + legacy) unchanged
|
||||
|
||||
## Must-keep
|
||||
- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`)
|
||||
- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh)
|
||||
- The `--user` flag for non-main containers (file permission compatibility)
|
||||
- `CONTAINER_HOST_GATEWAY` and `hostGatewayArgs()` imports from `container-runtime.js`
|
||||
- `detectAuthMode()` import from `credential-proxy.js`
|
||||
- `CREDENTIAL_PROXY_PORT` import from `config.js`
|
||||
- Credential proxy env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`
|
||||
|
||||
@@ -9,6 +9,20 @@ import { logger } from './logger.js';
|
||||
/** The container runtime binary name. */
|
||||
export const CONTAINER_RUNTIME_BIN = 'container';
|
||||
|
||||
/**
|
||||
* Hostname containers use to reach the host machine.
|
||||
* Apple Container VMs access the host via the default gateway (192.168.64.1).
|
||||
*/
|
||||
export const CONTAINER_HOST_GATEWAY = '192.168.64.1';
|
||||
|
||||
/**
|
||||
* CLI args needed for the container to resolve the host gateway.
|
||||
* Apple Container provides host networking natively on macOS — no extra args needed.
|
||||
*/
|
||||
export function hostGatewayArgs(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns CLI args for a readonly bind mount. */
|
||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
||||
|
||||
@@ -20,8 +20,16 @@ Replaced Docker runtime with Apple Container runtime. This is a full file replac
|
||||
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'` → `container ls --format json` with JSON parsing
|
||||
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
|
||||
|
||||
### CONTAINER_HOST_GATEWAY
|
||||
- Set to `'192.168.64.1'` — the default gateway for Apple Container VMs to reach the host
|
||||
- Docker uses `'host.docker.internal'` which is resolved differently
|
||||
|
||||
### hostGatewayArgs
|
||||
- Returns `[]` — Apple Container provides host networking natively on macOS
|
||||
- Docker version returns `['--add-host=host.docker.internal:host-gateway']` on Linux
|
||||
|
||||
## Invariants
|
||||
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
|
||||
- All exports remain identical: `CONTAINER_RUNTIME_BIN`, `CONTAINER_HOST_GATEWAY`, `readonlyMountArgs`, `stopContainer`, `hostGatewayArgs`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
|
||||
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
|
||||
- Logger usage pattern is unchanged
|
||||
- Error handling pattern is unchanged
|
||||
@@ -30,3 +38,4 @@ Replaced Docker runtime with Apple Container runtime. This is a full file replac
|
||||
- The exported function signatures (consumed by container-runner.ts and index.ts)
|
||||
- The error box-drawing output format
|
||||
- The orphan cleanup logic (find + stop pattern)
|
||||
- `CONTAINER_HOST_GATEWAY` must match the address the credential proxy is reachable at from within the VM
|
||||
|
||||
@@ -51,7 +51,8 @@ RUN npm run build
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# Create entrypoint script
|
||||
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||
# Container input (prompt, group info) is passed via stdin JSON.
|
||||
# Credentials are injected by the host's credential proxy — never passed here.
|
||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
|
||||
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
@@ -27,7 +27,6 @@ interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
@@ -185,30 +184,6 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
};
|
||||
}
|
||||
|
||||
// Secrets to strip from Bash tool subprocess environments.
|
||||
// These are needed by claude-code for API auth but should never
|
||||
// be visible to commands Kit runs.
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
@@ -451,7 +426,6 @@ async function runQuery(
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
}
|
||||
})) {
|
||||
@@ -496,7 +470,6 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
@@ -508,12 +481,9 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build SDK env: merge secrets into process.env for the SDK only.
|
||||
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||
// No real secrets exist in the container environment.
|
||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
|
||||
@@ -64,23 +64,22 @@ Messages and task operations are verified against group identity:
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Handling
|
||||
### 5. Credential Isolation (Credential Proxy)
|
||||
|
||||
**Mounted Credentials:**
|
||||
- Claude auth tokens (filtered from `.env`, read-only)
|
||||
Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently.
|
||||
|
||||
**How it works:**
|
||||
1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001)
|
||||
2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:<port>` and `ANTHROPIC_API_KEY=placeholder`
|
||||
3. The SDK sends API requests to the proxy with the placeholder key
|
||||
4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com`
|
||||
5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc`
|
||||
|
||||
**NOT Mounted:**
|
||||
- WhatsApp session (`store/auth/`) - host only
|
||||
- Mount allowlist - external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
|
||||
**Credential Filtering:**
|
||||
Only these environment variables are exposed to containers:
|
||||
```typescript
|
||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
||||
```
|
||||
|
||||
> **Note:** Anthropic credentials are mounted so that Claude Code can authenticate when the agent runs. However, this means the agent itself can discover these credentials via Bash or file operations. Ideally, Claude Code would authenticate without exposing credentials to the agent's execution environment, but I couldn't figure this out. **PRs welcome** if you have ideas for credential isolation.
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
|
||||
## Privilege Comparison
|
||||
|
||||
@@ -108,16 +107,16 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
||||
│ • IPC authorization │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • Credential filtering │
|
||||
│ • Credential proxy (injects auth headers) │
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼ Explicit mounts only
|
||||
▼ Explicit mounts only, no secrets
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ CONTAINER (ISOLATED/SANDBOXED) │
|
||||
│ • Agent execution │
|
||||
│ • Bash commands (sandboxed) │
|
||||
│ • File operations (limited to mounts) │
|
||||
│ • Network access (unrestricted) │
|
||||
│ • Cannot modify security config │
|
||||
│ • API calls routed through credential proxy │
|
||||
│ • No real credentials in environment or filesystem │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.11",
|
||||
"version": "1.2.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.11",
|
||||
"version": "1.2.12",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.11",
|
||||
"version": "1.2.12",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -4,8 +4,8 @@ import path from 'path';
|
||||
import { readEnvFile } from './env.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
// Secrets are NOT read here — they stay on disk and are loaded only
|
||||
// where needed (container-runner.ts) to avoid leaking to child processes.
|
||||
// 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']);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
@@ -47,6 +47,10 @@ 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 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(
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
|
||||
@@ -10,19 +10,22 @@ import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.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 { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
@@ -38,7 +41,6 @@ export interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
@@ -74,6 +76,17 @@ function buildVolumeMounts(
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
||||
// Credentials are injected by the credential proxy, never exposed to containers.
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
mounts.push({
|
||||
hostPath: '/dev/null',
|
||||
containerPath: '/workspace/project/.env',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
@@ -199,14 +212,6 @@ function buildVolumeMounts(
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read allowed secrets from .env for passing to the container via stdin.
|
||||
* Secrets are never written to disk or mounted as files.
|
||||
*/
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
|
||||
}
|
||||
|
||||
function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
@@ -217,6 +222,26 @@ function buildContainerArgs(
|
||||
// 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');
|
||||
} else {
|
||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
||||
}
|
||||
|
||||
// Runtime-specific args for host gateway resolution
|
||||
args.push(...hostGatewayArgs());
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
@@ -301,12 +326,8 @@ export async function runContainerAgent(
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||
input.secrets = readSecrets();
|
||||
container.stdin.write(JSON.stringify(input));
|
||||
container.stdin.end();
|
||||
// Remove secrets from input so they don't appear in logs
|
||||
delete input.secrets;
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||
let parseBuffer = '';
|
||||
|
||||
@@ -3,12 +3,52 @@
|
||||
* 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';
|
||||
|
||||
/** The container runtime binary name. */
|
||||
export const CONTAINER_RUNTIME_BIN = 'container';
|
||||
|
||||
/** 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
|
||||
if (os.platform() === 'linux') {
|
||||
return ['--add-host=host.docker.internal:host-gateway'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns CLI args for a readonly bind mount. */
|
||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
||||
|
||||
192
src/credential-proxy.test.ts
Normal file
192
src/credential-proxy.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
const mockEnv: Record<string, string> = {};
|
||||
vi.mock('./env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({ ...mockEnv })),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
|
||||
function makeRequest(
|
||||
port: number,
|
||||
options: http.RequestOptions,
|
||||
body = '',
|
||||
): Promise<{
|
||||
statusCode: number;
|
||||
body: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ ...options, hostname: '127.0.0.1', port },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode!,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('credential-proxy', () => {
|
||||
let proxyServer: http.Server;
|
||||
let upstreamServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let upstreamPort: number;
|
||||
let lastUpstreamHeaders: http.IncomingHttpHeaders;
|
||||
|
||||
beforeEach(async () => {
|
||||
lastUpstreamHeaders = {};
|
||||
|
||||
upstreamServer = http.createServer((req, res) => {
|
||||
lastUpstreamHeaders = { ...req.headers };
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
upstreamPort = (upstreamServer.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => proxyServer?.close(() => r()));
|
||||
await new Promise<void>((r) => upstreamServer?.close(() => r()));
|
||||
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
|
||||
});
|
||||
|
||||
async function startProxy(env: Record<string, string>): Promise<number> {
|
||||
Object.assign(mockEnv, env, {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
return (proxyServer.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
it('API-key mode injects x-api-key and strips placeholder', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
|
||||
});
|
||||
|
||||
it('OAuth mode replaces Authorization when container sends one', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/oauth/claude_cli/create_api_key',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['authorization']).toBe(
|
||||
'Bearer real-oauth-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('OAuth mode does not inject Authorization when container omits it', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
// Post-exchange: container uses x-api-key only, no Authorization header
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'temp-key-from-exchange',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
|
||||
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
connection: 'keep-alive',
|
||||
'keep-alive': 'timeout=5',
|
||||
'transfer-encoding': 'chunked',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
// Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
|
||||
// its own Connection header (standard HTTP/1.1 behavior), but the client's
|
||||
// custom keep-alive and transfer-encoding must not be forwarded.
|
||||
expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
|
||||
expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 502 when upstream is unreachable', async () => {
|
||||
Object.assign(mockEnv, {
|
||||
ANTHROPIC_API_KEY: 'sk-ant-real-key',
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
proxyPort = (proxyServer.address() as AddressInfo).port;
|
||||
|
||||
const res = await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.body).toBe('Bad Gateway');
|
||||
});
|
||||
});
|
||||
125
src/credential-proxy.ts
Normal file
125
src/credential-proxy.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
10
src/index.ts
10
src/index.ts
@@ -3,11 +3,13 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
IDLE_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
} from './config.js';
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
import './channels/index.js';
|
||||
import {
|
||||
getChannelFactory,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import {
|
||||
cleanupOrphans,
|
||||
ensureContainerRuntimeRunning,
|
||||
PROXY_BIND_HOST,
|
||||
} from './container-runtime.js';
|
||||
import {
|
||||
getAllChats,
|
||||
@@ -468,9 +471,16 @@ async function main(): Promise<void> {
|
||||
logger.info('Database initialized');
|
||||
loadState();
|
||||
|
||||
// Start credential proxy (containers route API calls through this)
|
||||
const proxyServer = await startCredentialProxy(
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
PROXY_BIND_HOST,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user