diff --git a/.env.example b/.env.example index df91415d..0eec5b6a 100644 --- a/.env.example +++ b/.env.example @@ -61,9 +61,10 @@ ALLOW_ANY_ORIGIN=true # key, not Vaultwarden's, however much the name and its old position here suggested otherwise. VAULTWARDEN_URL=http://127.0.0.1:8222 -# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which -# lives in the host env rather than here. -# ANTHROPIC_PROXY_PORT=5051 +# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see +# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either: +# the upstream token is the OAuth one `claude` writes to ~/.claude/.credentials.json, and the +# ANTHROPIC_API_KEY the agent presents to it is the proxy's own generated secret. # ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to # http://localhost:8899. diff --git a/src/servers/api/tasks/pipeline-executor.ts b/src/servers/api/tasks/pipeline-executor.ts index 8f25ec09..f8e220a6 100644 --- a/src/servers/api/tasks/pipeline-executor.ts +++ b/src/servers/api/tasks/pipeline-executor.ts @@ -10,6 +10,7 @@ import { resolveBaseCwd } from '../chat/websocket'; import { sendClaudeCodeStreaming } from '../../channels/send-claude-code'; import type { TurnMessage, MessageCost } from '../chat/types'; import * as jobManager from './pipeline-job-manager'; +import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; const DEFAULT_MODEL = 'claude-code'; @@ -96,9 +97,8 @@ type RunStepParams = { }; async function refreshProxyToken(): Promise { - const port = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; try { - await fetch(`http://127.0.0.1:${port}/refresh`, { method: 'POST' }); + await fetch(`${ANTHROPIC_PROXY_URL}/refresh`, { method: 'POST' }); } catch { // Best effort — proxy may not be running (e.g. using API key directly) } diff --git a/src/servers/officer-url.mjs b/src/servers/officer-url.mjs index 4fe93426..0ff14fc1 100644 --- a/src/servers/officer-url.mjs +++ b/src/servers/officer-url.mjs @@ -52,3 +52,24 @@ export const PORT = parsed; // sets either today, and a value that is present is a deliberate act rather than a guess. export const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${PORT}`; export const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${PORT}`; + +// ── The Anthropic proxy: PORT + 1, derived, not configured ── +// +// This is the ONE sidecar that binds a fixed port. Every other one binds `port: 0`, lets the kernel +// choose, and reports what it got back over the registration socket — which works because their +// consumer is the platform. +// +// The proxy cannot do that. Its consumer is `claude`, spawned by a DIFFERENT pm2 process +// (officer-agent), which needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask the proxy +// what port it landed on. Two independent processes with nothing between them have to agree in +// advance, so the number has to be predictable rather than discovered. +// +// It was ANTHROPIC_PROXY_PORT, defaulting to 5051 in four separate files: the one that binds it and +// three that guessed the same constant to find it. 5051 was chosen against nothing and could collide +// with anything the owner installs later, with the symptom being chat failing while the rest of the +// platform looked healthy. +// +// PORT + 1 keeps the predictability and removes both problems. There is no variable to set, no second +// number to keep in agreement with the first, and the pair moves together when the install moves. +export const ANTHROPIC_PROXY_PORT = PORT + 1; +export const ANTHROPIC_PROXY_URL = `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`; diff --git a/src/servers/sidecar/claude/proxy.ts b/src/servers/sidecar/claude/proxy.ts index b4618fe2..c013d0c7 100644 --- a/src/servers/sidecar/claude/proxy.ts +++ b/src/servers/sidecar/claude/proxy.ts @@ -1,8 +1,8 @@ import { join } from 'node:path'; import { homedir, userInfo } from 'node:os'; import { getState, updateState } from './state'; +import { ANTHROPIC_PROXY_PORT as PROXY_PORT } from '../../officer-url.mjs'; -const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051'); const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json'); const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index aa91dbc9..6daeec9f 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -18,7 +18,7 @@ import { createSidecarConnector } from '../connect'; import { sign } from '../../jwt'; import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb'; import { DATA_PATH } from '../../data-path'; -import { API_URL, OFFICER_API_URL } from '../../officer-url.mjs'; +import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; // PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than // being told who to run as by the main server — one less thing that has to come from `officer` before @@ -191,10 +191,9 @@ function generateMcpConfig(): string { // Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and // `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly // absent. Re-checked before every spawn until it lands. -const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; function ensureAnthropicEnv(): void { - process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`; + process.env.ANTHROPIC_BASE_URL ??= ANTHROPIC_PROXY_URL; // Without this, every platform chat session is capped at 200K context while the same `claude` in a // terminal gets Opus 5's full 1M — for no reason other than the proxy hop above. // diff --git a/src/servers/sidecar/headscale/claude-proxy.ts b/src/servers/sidecar/headscale/claude-proxy.ts index 3013a9d6..3476f955 100644 --- a/src/servers/sidecar/headscale/claude-proxy.ts +++ b/src/servers/sidecar/headscale/claude-proxy.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; +import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; // One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. // @@ -16,7 +17,6 @@ import { DATA_PATH } from '../../data-path'; // This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat // surface, which already exists and already persists. -const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; const DEFAULT_TIMEOUT_MS = 120_000; /** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */ @@ -55,7 +55,7 @@ export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: let res: Response; try { - res = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/messages`, { + res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, { method: 'POST', headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({