Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
386 changes: 386 additions & 0 deletions devlog/_plan/260911_hub_single_port/060_client_hub_state.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ the hub. `ocx connect revoke --admin-token-stdin` is available only while still
the persisted `apiKeyId`; it accepts no id override. Browser session logout/expiry is separate from
data-key rotation, revocation, and disconnect.

### What a connected client shows

A client stores no provider credentials and no catalog of its own, so its local config and
credential store are empty by design — and reading them as the truth produces a confident, wrong
answer about what the hub can serve. On a connected client `ocx status` therefore leads with
`State from hub <origin>` and sources the OAuth-logins, providers and delegable-models lines from
the hub over the data plane, tagging the lines that really describe this machine `(local)`: the
proxy, the service, the Codex binary and shim, and the local ports. `ocx status --json` carries the
same answer as `runtimeRole` plus a `remoteHub` block whose `stateSource` is `hub`, `cache`, or
`unavailable` — never the client's own state. An older hub that does not serve `/v1/hub-state`
reports `unavailable` with an instruction to upgrade the hub rather than silently falling back to
local login state, and `ocx config show` on a client prints a `_remoteHub` note saying the
credentials and model availability live on the hub. The hub read uses the per-client data key
only; no admin token and no provider secret ever reaches a client.

## Linux systemd or macOS launchd

Choose the hub's Tailscale address for the data listener and the exact browser-visible HTTPS origin
Expand Down
7 changes: 6 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@
"cl01-review-regressions.test.ts": "routing",
"claude-529-mapping.test.ts": "claude-integration",
"claude-agent-startup-sync.test.ts": "claude-integration",
"claude-agents-inject-client.test.ts": "claude-integration",
"claude-agents-inject.test.ts": "claude-integration",
"claude-alias.test.ts": "claude-integration",
"claude-auth-detect.test.ts": "claude-integration",
Expand Down Expand Up @@ -332,6 +333,7 @@
"cli-codex-log-guard-protection.test.ts": "cli",
"cli-codex-log-guard.test.ts": "cli",
"cli-config-command.test.ts": "cli",
"cli-config-show-client.test.ts": "cli",
"cli-dispatch.test.ts": "cli",
"cli-dto-fidelity.test.ts": "cli",
"cli-export-command.test.ts": "cli",
Expand All @@ -354,6 +356,7 @@
"cli-restart-health.test.ts": "cli",
"cli-restore-back.test.ts": "cli",
"cli-start-journal-order.test.ts": "cli",
"cli-status-hub-state.test.ts": "cli",
"cli-status-json.test.ts": "cli",
"cli-status-oauth-health.test.ts": "cli",
"cli-storage-inspect.test.ts": "cli",
Expand All @@ -368,6 +371,7 @@
"client-export-modality-enum.test.ts": "clients",
"client-fingerprint.test.ts": "clients",
"client-hub-relay.test.ts": "clients",
"client-hub-state.test.ts": "clients",
"client-injection-guard.test.ts": "codex-integration",
"client-lifecycle-lock.test.ts": "clients",
"client-machine-listener.test.ts": "clients",
Expand Down Expand Up @@ -702,9 +706,9 @@
"health-scoring.test.ts": "server",
"history-migration-guardian.test.ts": "codex-integration",
"history-ocx-compaction-recovery.test.ts": "codex-integration",
"hyperbolic-provider.test.ts": "providers",
"hub-gated-local-clients.test.ts": "cli",
"hub-invite.test.ts": "cli",
"hyperbolic-provider.test.ts": "providers",
"identity-neutralize.test.ts": "adapters",
"init-backup-cleanup.test.ts": "service",
"init-eof.test.ts": "service",
Expand Down Expand Up @@ -1270,6 +1274,7 @@
"user-cost-overlay-coderabbit-regressions.test.ts": "usage",
"user-cost-overlay-live-reconcile.test.ts": "usage",
"user-cost-overlay-provider-delete.test.ts": "usage",
"v1-hub-state.test.ts": "server",
"v2-agent-message-failfast.test.ts": "server",
"vercel-gateway-provider-routing.test.ts": "providers",
"version-line.test.ts": "ci-workflows",
Expand Down
34 changes: 29 additions & 5 deletions src/claude/agents-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,23 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri
return { alias: claudeCodeNativeAlias(entry), id: entry, provider: "native" };
}

export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir = claudeConfigDir()): ClaudeAgentDef[] {
export function buildClaudeAgentDefs(
config: OcxConfig,
windows: Record<string, number>,
configDir = claudeConfigDir(),
/**
* The roster to generate defs from, overriding local `config.subagentModels` (#4236).
*
* A connected client's local roster is whatever it had before it joined the hub — on a fresh
* client, the five native defaults — while the hub's featured roster is the list that
* actually routes. Passing it in keeps this function pure and keeps the override visible at
* the call site instead of hidden behind a config read.
*
* Undefined preserves today's behaviour exactly, including "unset means the defaults, an
* explicit `[]` means none".
*/
rosterOverride?: readonly string[],
): ClaudeAgentDef[] {
const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
const blockedSkillsFor = (model: string): readonly string[] => {
const unmarked = stripOneMillionMarker(model);
Expand Down Expand Up @@ -137,7 +153,8 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,

// Default roster applies only when the field is UNSET — an explicit [] is
// respected (audit 071 #6: an upgraded config must not lose the default five).
const roster = config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels;
const roster = rosterOverride
?? (config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels);
for (const entry of roster.slice(0, 5)) {
if (typeof entry !== "string" || entry.trim() === "") continue;
const { alias, id, provider } = entryParts(entry.trim(), config);
Expand Down Expand Up @@ -260,13 +277,20 @@ export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir =
}

/** Launch-time hook: gate + build + sync in one call (used by ocx claude and systemEnv). */
export function injectClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir?: string): string[] | null {
export function injectClaudeAgentDefs(
config: OcxConfig,
windows: Record<string, number>,
configDir?: string,
/** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */
rosterOverride?: readonly string[],
): string[] | null {
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
// Disabled: prune verified-owned files so stale definitions stop loading
// in future sessions (audit 071 #3).
// in future sessions (audit 071 #3). The roster override is irrelevant here by
// construction: there is nothing to build.
return syncClaudeAgentDefs([], configDir);
}
return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir), configDir);
return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir, rosterOverride), configDir);
}
/**
* Dispatcher directive appended to every ocx-* description. The ocx-route body
Expand Down
27 changes: 26 additions & 1 deletion src/cli/claude-agent-startup-sync.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import type { OcxConfig } from "../types";
import { injectClaudeAgentDefs } from "../claude/agents-inject";
import { readCachedHubState } from "../client/hub-state";
import { fetchClaudeContextWindows } from "./claude";
import type { ReadinessGate } from "../server/readiness";

export interface ClaudeAgentStartupSyncDeps {
fetchContextWindows?: typeof fetchClaudeContextWindows;
injectAgentDefs?: typeof injectClaudeAgentDefs;
warn?: (message: string) => void;
/** Seam for the hub roster lookup; the default reads only the on-disk cache. */
readHubRoster?: (config: OcxConfig) => readonly string[] | undefined;
}

/**
* The hub's roster for a connected client, from the CACHE only (#4236).
*
* Startup deliberately makes no network call for this. The roster is a convenience here — `ocx
* claude` does the live read on the path where it matters — and a hub round trip on every proxy
* start would put an offline hub in the way of a local launch. Undefined falls back to local
* `subagentModels`, which is what this path has always used.
*/
function cachedHubRoster(config: OcxConfig): readonly string[] | undefined {
if (config.runtimeRole !== "client" || !config.client) return undefined;
try {
const cached = readCachedHubState({
serverUrl: config.client.serverUrl,
apiKeyId: config.client.apiKeyId,
connectedAt: config.client.connectedAt,
});
return cached?.state.subagentModels;
} catch {
return undefined;
}
}

/**
Expand Down Expand Up @@ -70,7 +95,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
// Startup remains best-effort. The next management mutation or `ocx claude` launch can
// restore context markers after a transient catalog/Management API failure.
}
return inject(config, windows);
return inject(config, windows, undefined, (deps.readHubRoster ?? cachedHubRoster)(config));
} catch (error) {
warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
return null;
Expand Down
67 changes: 58 additions & 9 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { selfLaunchArgv } from "../lib/self-launch-argv";
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
import { readClientConnectionState, type ClientConnectionState } from "../client/state";
import { resolveHubState } from "../client/hub-state";
import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets";
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
import { readFileSync } from "node:fs";
Expand Down Expand Up @@ -663,6 +664,43 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string {
return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`;
}

/**
* The hub's featured subagent roster, or undefined to fall back to local `subagentModels`.
*
* Best-effort by design: a launch must not fail because the hub is slow or old. But the
* fallback is ANNOUNCED (#4236) — a silently local roster is exactly how an operator came to
* believe a hub that serves grok could only delegate to five native models.
*
* An empty hub roster is honoured as empty, not treated as "no answer": an operator who cleared
* the hub's featured list meant it.
*/
export async function resolveHubRosterForClaude(
connection: { serverUrl: string; apiKeyId: string; connectedAt: string },
token: string,
deps: { resolve?: typeof resolveHubState; warn?: (message: string) => void } = {},
): Promise<readonly string[] | undefined> {
const warn = deps.warn ?? (message => console.error(message));
const resolve = deps.resolve ?? resolveHubState;
try {
const resolved = await resolve({
owner: { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt },
token,
});
if (!resolved.state) {
warn(`⚠ Hub roster unavailable (${resolved.reason ?? "unknown reason"}); using this machine's local subagentModels instead. The delegable agents below may not be what the hub can route.`);
return undefined;
}
if (resolved.stateSource === "cache") {
warn(`⚠ Hub roster came from a cached read ${resolved.ageSeconds ?? "?"}s old (${resolved.reason ?? "live read failed"}).`);
}
return resolved.state.subagentModels;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warn(`⚠ Hub roster could not be read (${message}); using this machine's local subagentModels instead.`);
return undefined;
}
}

export async function cmdClaude(args: string[]): Promise<number> {
const config = loadConfig();
const clientState = readClientConnectionState();
Expand All @@ -675,10 +713,13 @@ export async function cmdClaude(args: string[]): Promise<number> {
if (preflight.kind === "native") return launchNativeClaude(config, args, preflight.notice);
let route: number | ClaudeRoutingTarget;
let contextWindows: Record<string, number>;
/** The hub's featured roster on a connected client; undefined means "use local config". */
let hubRoster: readonly string[] | undefined;
if (clientState.kind === "connected") {
if (tokenState?.kind !== "present") return 1;
route = { baseUrl: clientState.value.serverUrl, admissionToken: tokenState.token };
contextWindows = readConnectedClaudeContextWindows();
hubRoster = await resolveHubRosterForClaude(clientState.value, tokenState.token);
} else {
const port = await ensureProxyForClaude();
if (!port) {
Expand Down Expand Up @@ -710,16 +751,24 @@ export async function cmdClaude(args: string[]): Promise<number> {
console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
}
// Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
if (typeof route === "number") {
try {
const written = injectClaudeAgentDefs(config, contextWindows);
if (written === null) {
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
//
// This used to run only when `route` was a number — i.e. never on a connected client, where
// `route` is a ClaudeRoutingTarget (#4236). So `~/.claude/agents/ocx-*.md` on a client stayed
// whatever a previous standalone run had left, and the five delegable agents an operator saw
// were a frozen snapshot of a machine that no longer does the routing. Nothing in the output
// said so; the roster simply looked like the answer.
//
// On a client the roster comes from the hub, because the local `subagentModels` list is the
// one this machine had before it joined. The five-row cap stays: it is a Claude Code picker
// constraint, not the defect — sourcing the five from the wrong machine was.
try {
const written = injectClaudeAgentDefs(config, contextWindows, undefined, hubRoster);
if (written === null) {
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
}
return spawnClaude(args, env);
}
Expand Down
59 changes: 58 additions & 1 deletion src/cli/config-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeMo
import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
import type { OcxConfig } from "../types";
import { normalizeVisionReasoningForModel } from "../vision/reasoning";
import type { ClientConnectionStatus } from "./connect";
import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";

const USAGE = `Usage:
Expand All @@ -26,8 +27,55 @@ const USAGE = `Usage:
const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i;
const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);

/**
* The synthetic `_remoteHub` note printed by `ocx config show` on a client (#4236).
*
* `runtimeRole: "client"` and the `client` block were already printed, and were already ignored:
* an agent read a client's `config.json`, saw an empty `providers` map and no grok, and concluded
* the hub could not serve grok. Naming the situation in the config output costs one key.
*
* `connected` is OBSERVED, never assumed. It was briefly hardcoded `true` for any config with a
* `client` block, which is the same defect in miniature: the presence of configuration is not
* evidence that the connection works, and a machine whose data-plane token was revoked, rotated
* away or deleted would have been labelled `connected: true` while it could not reach the hub at
* all. `collectClientConnectionStatus` is the one reader that knows — it compares the token file's
* fingerprint against the connection record — so the caller passes its answer in and this stays
* pure and testable.
*
* Synthetic and NOT persisted, for two reasons. `clientConnectionSchema` is `.strict()`, so a
* `client.note` field would not validate; and persisted prose drifts from the behaviour it
* describes. The leading underscore marks it as an annotation rather than a setting, and
* `config export` emits the real config untouched so round-trips still validate.
*/
export function remoteHubConfigNote(
config: OcxConfig,
readConnection: () => Pick<ClientConnectionStatus, "state" | "reason" | "token">,
): { connected: boolean; origin: string; note: string } | null {
if (config.runtimeRole !== "client" || !config.client) return null;
// A thunk, so a standalone or hub install pays nothing: the guard above returns first and the
// connection probe (three file reads) never runs.
const connection = readConnection();
// Both halves are required: a settled connection record AND the token it recorded. Either one
// alone describes a machine that cannot read its hub, and `ocx status` is still the command
// that has the facts — so the note points there in every case, connected or not.
const connected = connection.state === "connected" && connection.token === "owned";
const note = connection.state !== "connected"
? `this machine is configured as a client but its connection is ${connection.state}${connection.reason ? ` (${connection.reason})` : ""}; run ocx connect status`
: connection.token !== "owned"
? `this machine is configured as a client but its hub data-plane token is ${connection.token}; run ocx connect status`
: "provider credentials and model availability live on the hub; run ocx status";
return { connected, origin: config.client.serverUrl, note };
}

function redact(value: unknown, key = ""): unknown {
if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
// `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
// local one — up to 64 MB of it (src/config.ts). Printed in full it buried `runtimeRole` and
// the `client` block under a wall of base64, which is how a reader came to miss that this
// machine is a client at all. Size only, mirroring sanitizeModelCostsForDisplay.
if (key === "priorCatalog" && typeof value === "string") {
return value ? `<omitted: ${Buffer.byteLength(value)} bytes>` : value;
}
// modelCosts rows are keyed by model id; a pasted API key in a key position
// must not be echoed back by config show/get (values are already redacted).
if (key === "modelCosts") return sanitizeModelCostsForDisplay(value);
Expand Down Expand Up @@ -119,7 +167,16 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
const source = takeFlag(args, "--source");
rejectArgs(args, USAGE);
const diagnostics = readConfigDiagnostics();
const config = redact(diagnostics.config);
const redacted = redact(diagnostics.config);
// Imported here rather than at module scope: `./connect` pulls the whole client lifecycle
// in, and `ocx config get/set` has no use for it.
const { collectClientConnectionStatus } = await import("./connect");
const note = remoteHubConfigNote(diagnostics.config, () => collectClientConnectionStatus());
// First key, not last: it has to be read before the empty `providers` map that misled a
// reader into concluding nothing was configured anywhere.
const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted)
? { _remoteHub: note, ...redacted as Record<string, unknown> }
: redacted;
const result = source ? { config, source: diagnostics.source, error: diagnostics.error, warnings: diagnostics.warnings ?? [] } : config;
printData(result, true);
return;
Expand Down
Loading
Loading