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_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,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { readEnvFile } from './env.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,
|
||||||
readonlyMountArgs,
|
readonlyMountArgs,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
|
import { detectAuthMode } from './credential-proxy.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -38,7 +41,6 @@ export interface ContainerInput {
|
|||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
secrets?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerOutput {
|
export interface ContainerOutput {
|
||||||
@@ -199,14 +201,6 @@ function buildVolumeMounts(
|
|||||||
return mounts;
|
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(
|
function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -217,6 +211,23 @@ function buildContainerArgs(
|
|||||||
// 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)
|
||||||
|
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.
|
// 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),
|
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
// or when getuid is unavailable (native Windows without WSL).
|
||||||
@@ -301,12 +312,8 @@ export async function runContainerAgent(
|
|||||||
let stdoutTruncated = false;
|
let stdoutTruncated = false;
|
||||||
let stderrTruncated = 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.write(JSON.stringify(input));
|
||||||
container.stdin.end();
|
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
|
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||||
let parseBuffer = '';
|
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`
|
- All exported interfaces unchanged: `ContainerInput`, `ContainerOutput`, `runContainerAgent`, `writeTasksSnapshot`, `writeGroupsSnapshot`, `AvailableGroup`
|
||||||
- Non-main containers behave identically (still get `--user` flag)
|
- Non-main containers behave identically (still get `--user` flag)
|
||||||
- Mount list for non-main containers is unchanged
|
- 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
|
- Output parsing (streaming + legacy) unchanged
|
||||||
|
|
||||||
## Must-keep
|
## Must-keep
|
||||||
- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`)
|
- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`)
|
||||||
- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh)
|
- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh)
|
||||||
- The `--user` flag for non-main containers (file permission compatibility)
|
- 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. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'container';
|
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. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
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
|
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'` → `container ls --format json` with JSON parsing
|
||||||
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
|
- 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
|
## 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>`)
|
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
|
||||||
- Logger usage pattern is unchanged
|
- Logger usage pattern is unchanged
|
||||||
- Error handling 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 exported function signatures (consumed by container-runner.ts and index.ts)
|
||||||
- The error box-drawing output format
|
- The error box-drawing output format
|
||||||
- The orphan cleanup logic (find + stop pattern)
|
- 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
|
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||||
|
|
||||||
# Create entrypoint script
|
# 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/
|
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||||
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
|
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
|
||||||
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
|
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
@@ -27,7 +27,6 @@ interface ContainerInput {
|
|||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
secrets?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerOutput {
|
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 {
|
function sanitizeFilename(summary: string): string {
|
||||||
return summary
|
return summary
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -451,7 +426,6 @@ async function runQuery(
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})) {
|
})) {
|
||||||
@@ -496,7 +470,6 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const stdinData = await readStdin();
|
const stdinData = await readStdin();
|
||||||
containerInput = JSON.parse(stdinData);
|
containerInput = JSON.parse(stdinData);
|
||||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
|
||||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -508,12 +481,9 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SDK env: merge secrets into process.env for the SDK only.
|
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||||
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
// No real secrets exist in the container environment.
|
||||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
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 |
|
| View all tasks | ✓ | Own only |
|
||||||
| Manage other groups | ✓ | ✗ |
|
| Manage other groups | ✓ | ✗ |
|
||||||
|
|
||||||
### 5. Credential Handling
|
### 5. Credential Isolation (Credential Proxy)
|
||||||
|
|
||||||
**Mounted Credentials:**
|
Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently.
|
||||||
- Claude auth tokens (filtered from `.env`, read-only)
|
|
||||||
|
**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:**
|
**NOT Mounted:**
|
||||||
- WhatsApp session (`store/auth/`) - host only
|
- WhatsApp session (`store/auth/`) - host only
|
||||||
- Mount allowlist - external, never mounted
|
- Mount allowlist - external, never mounted
|
||||||
- Any credentials matching blocked patterns
|
- Any credentials matching blocked patterns
|
||||||
|
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||||
**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.
|
|
||||||
|
|
||||||
## Privilege Comparison
|
## Privilege Comparison
|
||||||
|
|
||||||
@@ -108,16 +107,16 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|||||||
│ • IPC authorization │
|
│ • IPC authorization │
|
||||||
│ • Mount validation (external allowlist) │
|
│ • Mount validation (external allowlist) │
|
||||||
│ • Container lifecycle │
|
│ • Container lifecycle │
|
||||||
│ • Credential filtering │
|
│ • Credential proxy (injects auth headers) │
|
||||||
└────────────────────────────────┬─────────────────────────────────┘
|
└────────────────────────────────┬─────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼ Explicit mounts only
|
▼ Explicit mounts only, no secrets
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ CONTAINER (ISOLATED/SANDBOXED) │
|
│ CONTAINER (ISOLATED/SANDBOXED) │
|
||||||
│ • Agent execution │
|
│ • Agent execution │
|
||||||
│ • Bash commands (sandboxed) │
|
│ • Bash commands (sandboxed) │
|
||||||
│ • File operations (limited to mounts) │
|
│ • File operations (limited to mounts) │
|
||||||
│ • Network access (unrestricted) │
|
│ • API calls routed through credential proxy │
|
||||||
│ • Cannot modify security config │
|
│ • No real credentials in environment or filesystem │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.11",
|
"version": "1.2.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.11",
|
"version": "1.2.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.11",
|
"version": "1.2.12",
|
||||||
"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",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ 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 are NOT read here — they stay on disk and are loaded only
|
// Secrets (API keys, tokens) are NOT read here — they are loaded only
|
||||||
// where needed (container-runner.ts) to avoid leaking to child processes.
|
// by the credential proxy (credential-proxy.ts), never exposed to containers.
|
||||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
@@ -47,6 +47,10 @@ 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(
|
||||||
|
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
||||||
|
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,6 +11,7 @@ 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
|
||||||
|
|||||||
@@ -10,19 +10,22 @@ 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,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { readEnvFile } from './env.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,
|
||||||
readonlyMountArgs,
|
readonlyMountArgs,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
|
import { detectAuthMode } from './credential-proxy.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -38,7 +41,6 @@ export interface ContainerInput {
|
|||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
secrets?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerOutput {
|
export interface ContainerOutput {
|
||||||
@@ -74,6 +76,17 @@ function buildVolumeMounts(
|
|||||||
readonly: true,
|
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
|
// Main also gets its group folder as the working directory
|
||||||
mounts.push({
|
mounts.push({
|
||||||
hostPath: groupDir,
|
hostPath: groupDir,
|
||||||
@@ -199,14 +212,6 @@ function buildVolumeMounts(
|
|||||||
return mounts;
|
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(
|
function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -217,6 +222,26 @@ function buildContainerArgs(
|
|||||||
// 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)
|
||||||
|
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.
|
// 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),
|
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
// or when getuid is unavailable (native Windows without WSL).
|
||||||
@@ -301,12 +326,8 @@ export async function runContainerAgent(
|
|||||||
let stdoutTruncated = false;
|
let stdoutTruncated = false;
|
||||||
let stderrTruncated = 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.write(JSON.stringify(input));
|
||||||
container.stdin.end();
|
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
|
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||||
let parseBuffer = '';
|
let parseBuffer = '';
|
||||||
|
|||||||
@@ -3,12 +3,52 @@
|
|||||||
* 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 { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
/** The container runtime binary name. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'container';
|
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. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
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 {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
|
CREDENTIAL_PROXY_PORT,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
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,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cleanupOrphans,
|
cleanupOrphans,
|
||||||
ensureContainerRuntimeRunning,
|
ensureContainerRuntimeRunning,
|
||||||
|
PROXY_BIND_HOST,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
getAllChats,
|
||||||
@@ -468,9 +471,16 @@ async function main(): Promise<void> {
|
|||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
|
// Start credential proxy (containers route API calls through this)
|
||||||
|
const proxyServer = await startCredentialProxy(
|
||||||
|
CREDENTIAL_PROXY_PORT,
|
||||||
|
PROXY_BIND_HOST,
|
||||||
|
);
|
||||||
|
|
||||||
// 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