the anthropic proxy binds PORT + 1

ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts,
which binds it, and three others that guessed the same constant to find it.

It is the only sidecar that binds a fixed port, and that part is a real
constraint rather than an oversight. Every other one binds `port: 0`, lets the
kernel choose and reports back over the registration socket — which works because
their consumer is the platform. The proxy's consumer is `claude`, spawned by a
different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no
channel to ask what port the proxy landed on. Two processes with nothing between
them have to agree in advance.

So the number must be predictable, but it need not be 5051 — a value chosen
against nothing, in the registered range, free to collide with anything the owner
installs later. The symptom of that collision would have been chat failing while
the rest of the platform looked healthy.

PORT + 1 keeps the predictability and drops both the constant and the variable.
Nothing to set, no second number to keep in agreement with the first, and the
pair moves together when the install moves.

Also corrects .env.example, which said the proxy "holds the API credential, which
lives in the host env". It does not. The upstream credential is the OAuth token
claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent
presents is the proxy's own generated secret.

Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import
it; every edited file parses. Still not typechecked — empty node_modules, frozen
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:51:03 +00:00
co-authored by Claude Opus 5
parent 3c7f52ab77
commit c5adb4aa08
6 changed files with 32 additions and 11 deletions
+2 -2
View File
@@ -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<void> {
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)
}
+21
View File
@@ -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}`;
+1 -1
View File
@@ -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';
+2 -3
View File
@@ -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.
//
@@ -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({