@bitkyc08/opencodex 2.49.0 → 2.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/index-D7BdZpZm.js +115 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +43 -1
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +99 -31
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +50 -13
- package/src/codex/auth-api.ts +156 -28
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +99 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +29 -12
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +184 -12
- package/src/generated/compatibility-version.json +188 -116
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/errors.ts +8 -0
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +20 -1
- package/src/providers/registry.ts +35 -6
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +10 -7
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +12 -6
- package/src/server/hub-state.ts +98 -0
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +149 -8
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/request-log.ts +10 -0
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +76 -5
- package/src/server/responses/compact.ts +28 -11
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +201 -48
- package/src/server/responses/policy-fallback.ts +13 -3
- package/src/server/search.ts +2 -2
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +965 -68
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +93 -4
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/gui/dist/assets/index-BtyONQrZ.js +0 -115
|
@@ -264,6 +264,7 @@ function refreshLine(row: FamilyRows["rows"][number]): string {
|
|
|
264
264
|
const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
|
|
265
265
|
parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
|
|
266
266
|
if (row.needsReauth) parts.push("needs-reauth");
|
|
267
|
+
if (row.validationPending) parts.push("validation-pending (routing disabled; open 'ocx gui' and click Refresh quotas after recovery)");
|
|
267
268
|
return parts.filter(Boolean).join(" ");
|
|
268
269
|
}
|
|
269
270
|
|
|
@@ -339,7 +340,7 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
|
|
|
339
340
|
} else console.log(`no quota report available for ${name}`);
|
|
340
341
|
return 0;
|
|
341
342
|
}
|
|
342
|
-
const result = await fetchCodexRows(deps, baseUrl, true);
|
|
343
|
+
const result = await fetchCodexRows(deps, baseUrl, true, true, { refreshAction: true });
|
|
343
344
|
const failed = familyFailure(result, `failed to refresh ${name}`);
|
|
344
345
|
if (failed !== null) return failed;
|
|
345
346
|
if (wantsJson) console.log(JSON.stringify({ accounts: result.rows }, null, 2));
|
package/src/cli/account.ts
CHANGED
|
@@ -99,6 +99,7 @@ function statusText(row: AccountRow): string {
|
|
|
99
99
|
if (row.paused) parts.push("paused");
|
|
100
100
|
if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
|
|
101
101
|
if (row.needsReauth) parts.push("needs-reauth");
|
|
102
|
+
if (row.validationPending) parts.push("validation-pending");
|
|
102
103
|
return parts.join(" ");
|
|
103
104
|
}
|
|
104
105
|
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -132,6 +132,34 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
132
132
|
json: "envelope",
|
|
133
133
|
details: ["Reads /healthz plus local config; drives no management API route."],
|
|
134
134
|
},
|
|
135
|
+
{
|
|
136
|
+
command: ["hub", "invite"],
|
|
137
|
+
summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
|
|
138
|
+
// Deliberately empty. The command DOES drive `POST /api/gui/pairing-grants` -- the attested
|
|
139
|
+
// local mint route `ocx gui pair` uses, authorized by a capability HMAC'd with the running
|
|
140
|
+
// proxy's own attestation secret rather than by the admin token, which is why it needs
|
|
141
|
+
// nothing exported in the shell. That route is answered in the composition root, ahead of
|
|
142
|
+
// `handleManagementAPI`, so it is not in MANAGEMENT_ROUTES; declaring it here would fail the
|
|
143
|
+
// capability/registry reconciliation rather than inform anyone. Widening the registry's scope
|
|
144
|
+
// to `src/server/index.ts` is its own change.
|
|
145
|
+
routes: [],
|
|
146
|
+
flags: [
|
|
147
|
+
{ name: "--json", value: "boolean", summary: "Emit code, expiresAt, dataUrl, managementUrl, and command." },
|
|
148
|
+
{ name: "--data-url", value: "string", summary: "Advertise this data origin instead of hub.dataPublicOrigin or the bind address." },
|
|
149
|
+
{ name: "--management-url", value: "string", summary: "Confirm the management origin; it must equal hub.managementPublicOrigin." },
|
|
150
|
+
{ name: "--clients", value: "string", summary: "Pre-select codex and/or claude in the printed connect command." },
|
|
151
|
+
],
|
|
152
|
+
mutates: true,
|
|
153
|
+
json: "envelope",
|
|
154
|
+
details: [
|
|
155
|
+
"Hub only: refuses when runtimeRole is not hub, and requires a running attested proxy.",
|
|
156
|
+
"The code is secret, single-use and short-lived; it is bound to hub.managementPublicOrigin and to the connecting machine's loopback browser origin.",
|
|
157
|
+
"The bound browser origin is always printed; when it is not http://localhost:10100 the warning names the port the connecting machine must use.",
|
|
158
|
+
"Refuses when the advertised data origin would be loopback (a loopback or wildcard bind with no hub.dataPublicOrigin and no --data-url) rather than printing a line that dials the other machine itself.",
|
|
159
|
+
"Prints no data-plane token. Remote machines receive their own revocable per-client key from the exchange.",
|
|
160
|
+
"Mints through the attested local pairing-grant route, the same one ocx gui pair uses; no admin token is read.",
|
|
161
|
+
],
|
|
162
|
+
},
|
|
135
163
|
{
|
|
136
164
|
command: ["connect", "rotate"],
|
|
137
165
|
summary: "Rotate the connected client's data key against the hub, with commit and abort.",
|
|
@@ -219,6 +247,18 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
219
247
|
"`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
|
|
220
248
|
],
|
|
221
249
|
},
|
|
250
|
+
{
|
|
251
|
+
command: ["account", "refresh"],
|
|
252
|
+
summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
|
|
253
|
+
routes: [
|
|
254
|
+
{ method: "POST", path: "/api/codex-auth/accounts/refresh" },
|
|
255
|
+
{ method: "GET", path: "/api/provider-quotas" },
|
|
256
|
+
],
|
|
257
|
+
flags: [{ name: "--json", value: "boolean", summary: "Emit the refresh result as JSON." }],
|
|
258
|
+
mutates: true,
|
|
259
|
+
json: "payload",
|
|
260
|
+
details: ["CLI/admin-token refreshes only observe usage. After quota recovery, a human must click Refresh quotas in the dashboard to authorize model validation. Do not mint a GUI session to work around this consent boundary."],
|
|
261
|
+
},
|
|
222
262
|
{
|
|
223
263
|
command: ["usage"],
|
|
224
264
|
summary: "Token and estimated-cost report over a time range.",
|
|
@@ -307,12 +347,13 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
307
347
|
},
|
|
308
348
|
{
|
|
309
349
|
command: ["logs"],
|
|
310
|
-
summary: "Recent request log rows, filterable by provider, model, conversation, and status.",
|
|
350
|
+
summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.",
|
|
311
351
|
routes: [{ method: "GET", path: "/api/logs" }],
|
|
312
352
|
flags: [
|
|
313
353
|
{ name: "--provider", value: "string", summary: "Restrict to one provider, matching failover attempts too." },
|
|
314
354
|
{ name: "--model", value: "string", summary: "Restrict to one model id, matching failover attempts too." },
|
|
315
355
|
{ name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
|
|
356
|
+
{ name: "--account", value: "string", summary: "Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too." },
|
|
316
357
|
{ name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
|
|
317
358
|
{ name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
|
|
318
359
|
{ name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
|
|
@@ -324,6 +365,7 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
324
365
|
details: [
|
|
325
366
|
"`--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.",
|
|
326
367
|
"Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.",
|
|
368
|
+
"Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.",
|
|
327
369
|
"`--follow` deduplicates by row id and cannot be combined with `--json`.",
|
|
328
370
|
],
|
|
329
371
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig } from "../types";
|
|
2
2
|
import { injectClaudeAgentDefs } from "../claude/agents-inject";
|
|
3
|
+
import { readCachedHubState } from "../client/hub-state";
|
|
3
4
|
import { fetchClaudeContextWindows } from "./claude";
|
|
4
5
|
import type { ReadinessGate } from "../server/readiness";
|
|
5
6
|
|
|
@@ -7,6 +8,30 @@ export interface ClaudeAgentStartupSyncDeps {
|
|
|
7
8
|
fetchContextWindows?: typeof fetchClaudeContextWindows;
|
|
8
9
|
injectAgentDefs?: typeof injectClaudeAgentDefs;
|
|
9
10
|
warn?: (message: string) => void;
|
|
11
|
+
/** Seam for the hub roster lookup; the default reads only the on-disk cache. */
|
|
12
|
+
readHubRoster?: (config: OcxConfig) => readonly string[] | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The hub's roster for a connected client, from the CACHE only (#4236).
|
|
17
|
+
*
|
|
18
|
+
* Startup deliberately makes no network call for this. The roster is a convenience here — `ocx
|
|
19
|
+
* claude` does the live read on the path where it matters — and a hub round trip on every proxy
|
|
20
|
+
* start would put an offline hub in the way of a local launch. Undefined falls back to local
|
|
21
|
+
* `subagentModels`, which is what this path has always used.
|
|
22
|
+
*/
|
|
23
|
+
function cachedHubRoster(config: OcxConfig): readonly string[] | undefined {
|
|
24
|
+
if (config.runtimeRole !== "client" || !config.client) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const cached = readCachedHubState({
|
|
27
|
+
serverUrl: config.client.serverUrl,
|
|
28
|
+
apiKeyId: config.client.apiKeyId,
|
|
29
|
+
connectedAt: config.client.connectedAt,
|
|
30
|
+
});
|
|
31
|
+
return cached?.state.subagentModels;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
10
35
|
}
|
|
11
36
|
|
|
12
37
|
/**
|
|
@@ -70,7 +95,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
|
|
|
70
95
|
// Startup remains best-effort. The next management mutation or `ocx claude` launch can
|
|
71
96
|
// restore context markers after a transient catalog/Management API failure.
|
|
72
97
|
}
|
|
73
|
-
return inject(config, windows);
|
|
98
|
+
return inject(config, windows, undefined, (deps.readHubRoster ?? cachedHubRoster)(config));
|
|
74
99
|
} catch (error) {
|
|
75
100
|
warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
101
|
return null;
|
package/src/cli/claude.ts
CHANGED
|
@@ -18,12 +18,14 @@ import { isProxyAdmissionSecret } from "../server/auth-cors";
|
|
|
18
18
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
19
19
|
import type { OcxConfig } from "../types";
|
|
20
20
|
import { configuredAdminToken } from "../lib/admin-secrets";
|
|
21
|
+
import { localAdmissionToken, localInferenceDestination, localLoopbackInferencePorts, localManagementOrigin } from "../lib/local-destinations";
|
|
21
22
|
import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
|
|
22
23
|
import { resolveClaudeAuthMode } from "../claude/auth-mode";
|
|
23
24
|
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
24
25
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
25
26
|
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
|
|
26
27
|
import { readClientConnectionState, type ClientConnectionState } from "../client/state";
|
|
28
|
+
import { resolveHubState } from "../client/hub-state";
|
|
27
29
|
import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets";
|
|
28
30
|
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
|
|
29
31
|
import { readFileSync } from "node:fs";
|
|
@@ -102,16 +104,34 @@ function isClaudeLoopbackHostname(hostname: string): boolean {
|
|
|
102
104
|
|| normalized === "[::1]";
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Is this base URL one of OURS?
|
|
109
|
+
*
|
|
110
|
+
* Two ways to be ours (#4236), because a hub has two shapes of local destination:
|
|
111
|
+
*
|
|
112
|
+
* - a SET of loopback ports, not one port: with an unauthenticated loopback listener the
|
|
113
|
+
* public port and the listener's port are both addresses this proxy answers on at
|
|
114
|
+
* 127.0.0.1, so a URL naming either of them was written by us. Treating the one this launch
|
|
115
|
+
* did not pick as a foreign proxy would strip our own admission token out of the
|
|
116
|
+
* environment. On a tailnet bind with no listener that set is EMPTY, so a leftover
|
|
117
|
+
* `http://127.0.0.1:<port>` is correctly seen as stale rather than as ours.
|
|
118
|
+
* - the resolved destination origin itself, which on such a bind is the bind address. Without
|
|
119
|
+
* this arm the launch would write a base URL and then refuse to recognize it one line later.
|
|
120
|
+
*/
|
|
121
|
+
function targetsLocalClaudeProxy(
|
|
122
|
+
value: string | undefined,
|
|
123
|
+
ports: readonly number[],
|
|
124
|
+
ownOrigin?: string,
|
|
125
|
+
): boolean {
|
|
106
126
|
if (!value) return false;
|
|
107
127
|
try {
|
|
108
128
|
const parsed = new URL(value);
|
|
129
|
+
if (parsed.username !== "" || parsed.password !== "") return false;
|
|
130
|
+
if (ownOrigin !== undefined && parsed.origin === ownOrigin) return true;
|
|
109
131
|
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
|
|
110
132
|
return parsed.protocol === "http:"
|
|
111
133
|
&& isClaudeLoopbackHostname(parsed.hostname)
|
|
112
|
-
&& effectivePort
|
|
113
|
-
&& parsed.username === ""
|
|
114
|
-
&& parsed.password === "";
|
|
134
|
+
&& ports.includes(effectivePort);
|
|
115
135
|
} catch {
|
|
116
136
|
return false;
|
|
117
137
|
}
|
|
@@ -147,7 +167,16 @@ export function buildClaudeEnv(
|
|
|
147
167
|
): ClaudeLaunchEnv {
|
|
148
168
|
const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
|
|
149
169
|
const port = typeof portOrTarget === "number" ? portOrTarget : null;
|
|
150
|
-
|
|
170
|
+
// A local launch dials the unauthenticated loopback listener whenever one is enabled — the
|
|
171
|
+
// only credential-free local socket a tailnet-bound hub has (#4236). With the listener OFF
|
|
172
|
+
// the destination is the BIND address, which is reachable but demands data-plane admission;
|
|
173
|
+
// the resolver says which of the two this is instead of every caller guessing.
|
|
174
|
+
const destination = port === null ? null : localInferenceDestination(config, port);
|
|
175
|
+
const managedBaseUrl = explicitTarget
|
|
176
|
+
? new URL(explicitTarget.baseUrl).origin
|
|
177
|
+
: destination!.origin;
|
|
178
|
+
// Every port this proxy answers on at 127.0.0.1, so a base URL naming any of them is ours.
|
|
179
|
+
const ownLocalPorts = port === null ? [] : localLoopbackInferencePorts(config, port);
|
|
151
180
|
const env: ClaudeLaunchEnv = { ...base };
|
|
152
181
|
// Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
|
|
153
182
|
// or writes the token slot. setDefault below preserves any non-empty value, so a
|
|
@@ -188,10 +217,14 @@ export function buildClaudeEnv(
|
|
|
188
217
|
try {
|
|
189
218
|
const parsed = new URL(existingBaseUrl);
|
|
190
219
|
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
|
|
220
|
+
// Stale means "a port no live local listener of ours owns". With a loopback listener
|
|
221
|
+
// enabled that is two ports, and rewriting one of them into the other would reject a
|
|
222
|
+
// destination we wrote ourselves.
|
|
191
223
|
if (parsed.protocol === "http:"
|
|
192
224
|
&& isClaudeLoopbackHostname(parsed.hostname)
|
|
193
|
-
&& effectivePort
|
|
194
|
-
|
|
225
|
+
&& !ownLocalPorts.includes(effectivePort)
|
|
226
|
+
&& parsed.origin !== managedBaseUrl) {
|
|
227
|
+
const replacement = managedBaseUrl;
|
|
195
228
|
console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`);
|
|
196
229
|
env.ANTHROPIC_BASE_URL = replacement;
|
|
197
230
|
// The credentials in this environment were paired with the destination we just
|
|
@@ -216,10 +249,19 @@ export function buildClaudeEnv(
|
|
|
216
249
|
// the user's Claude login. Resolve the mode before adding any proxy-owned credential:
|
|
217
250
|
// subscription launches must keep their OAuth, while proxy launches may use the
|
|
218
251
|
// admission key or dummy marker (see server/claude-messages.ts).
|
|
219
|
-
|
|
252
|
+
// A bind that demands admission needs a credential the machine can actually present, which
|
|
253
|
+
// is wider than `config.apiKeys`: the service installs its data-plane secret as
|
|
254
|
+
// `OPENCODEX_API_AUTH_TOKEN` / the hardened token file, and that is the ladder the Codex
|
|
255
|
+
// provider table already uses. Never the admin token (reviewer constraint on #4236).
|
|
256
|
+
const hostAdmissionToken = destination?.requiresAdmissionToken === true
|
|
257
|
+
? localAdmissionToken(config)
|
|
258
|
+
: undefined;
|
|
259
|
+
const ownTokens = explicitTarget
|
|
260
|
+
? [explicitTarget.admissionToken]
|
|
261
|
+
: [...new Set([...(hostAdmissionToken ? [hostAdmissionToken] : []), ...ownAdmissionTokens(config)])];
|
|
220
262
|
const targetsLocalProxy = explicitTarget
|
|
221
263
|
? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
|
|
222
|
-
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL,
|
|
264
|
+
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, ownLocalPorts, managedBaseUrl);
|
|
223
265
|
const isOwnAdmissionToken = (value: string): boolean =>
|
|
224
266
|
ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
|
|
225
267
|
const inheritedApiKey = env.ANTHROPIC_API_KEY;
|
|
@@ -265,6 +307,19 @@ export function buildClaudeEnv(
|
|
|
265
307
|
if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
|
|
266
308
|
env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
|
|
267
309
|
}
|
|
310
|
+
// Degrade out loud rather than hand Claude Code a destination that 401s (#4236). A
|
|
311
|
+
// subscription launch deliberately carries no host token — asserting one logs a claude.ai
|
|
312
|
+
// subscriber out (#253) — so on a bind that demands admission the honest outcome is a
|
|
313
|
+
// warning naming the two fixes, not a silent refusal at the first request.
|
|
314
|
+
if (destination?.requiresAdmissionToken === true && targetsLocalProxy) {
|
|
315
|
+
const carried = env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
316
|
+
if (!hasUserApiKey && (!carried || carried === PROXY_MARKER)) {
|
|
317
|
+
console.error(
|
|
318
|
+
`⚠ ${managedBaseUrl} requires an opencodex data-plane credential and this launch carries none — `
|
|
319
|
+
+ "requests will be refused. Enable `unauthenticatedLoopbackListener` or bind the proxy to loopback.",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
268
323
|
const finalAuthToken = env.ANTHROPIC_AUTH_TOKEN;
|
|
269
324
|
const hostOwnsAuthentication = targetsLocalProxy
|
|
270
325
|
&& !hasUserApiKey
|
|
@@ -335,6 +390,12 @@ export function buildClaudeEnv(
|
|
|
335
390
|
* Context-window map from the RUNNING proxy's management API (warm TTL cache; the
|
|
336
391
|
* daemon registers every selector form — audit R3#1). 3s bound + management auth header.
|
|
337
392
|
* (no [1m] marking, conservative).
|
|
393
|
+
*
|
|
394
|
+
* This is the MANAGEMENT destination, not the inference one (#4236): `/api/claude-code` is
|
|
395
|
+
* never served by the unauthenticated loopback listener, so it resolves through
|
|
396
|
+
* `localManagementOrigin` — a hub's loopback management ingress when it has one, otherwise the
|
|
397
|
+
* public bind — and keeps sending the local admin token. `enabled: false` is how `ocx claude`
|
|
398
|
+
* decides to launch natively, so a wrong destination here silently downgrades every launch.
|
|
338
399
|
*/
|
|
339
400
|
export interface ClaudeCodeLiveState {
|
|
340
401
|
contextWindows: Record<string, number>;
|
|
@@ -346,7 +407,7 @@ export async function fetchClaudeCodeState(config: OcxConfig, port: number, time
|
|
|
346
407
|
const headers = new Headers();
|
|
347
408
|
const token = configuredAdminToken();
|
|
348
409
|
if (token) headers.set("x-opencodex-api-key", token);
|
|
349
|
-
const res = await fetch(
|
|
410
|
+
const res = await fetch(`${localManagementOrigin(config, port)}/api/claude-code`, {
|
|
350
411
|
headers,
|
|
351
412
|
signal: AbortSignal.timeout(timeoutMs),
|
|
352
413
|
});
|
|
@@ -525,7 +586,16 @@ export function buildNativeClaudeEnv(
|
|
|
525
586
|
return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config)));
|
|
526
587
|
});
|
|
527
588
|
const baseUrl = env.ANTHROPIC_BASE_URL;
|
|
528
|
-
|
|
589
|
+
// Shedding asks a DIFFERENT question than the stale-replacement branch above, so it uses a
|
|
590
|
+
// wider set (#4236). There the question is "is this inherited URL a live destination of
|
|
591
|
+
// ours?" and a port nothing answers on must be rewritten. Here it is "could we have written
|
|
592
|
+
// this?" — and the answer is yes for the public port on any topology, because an earlier
|
|
593
|
+
// config on this machine may have been loopback-bound. Leaving such a URL in place with its
|
|
594
|
+
// admission token stripped (the loop below always strips it) would point a native launch at a
|
|
595
|
+
// dead socket with no credential, which is strictly worse than shedding one port too many.
|
|
596
|
+
const nativeLocalPorts = [...new Set([config.port, ...localLoopbackInferencePorts(config, config.port)])];
|
|
597
|
+
const nativeOwnOrigin = localInferenceDestination(config, config.port).origin;
|
|
598
|
+
if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, nativeLocalPorts, nativeOwnOrigin)) {
|
|
529
599
|
delete env.ANTHROPIC_BASE_URL;
|
|
530
600
|
}
|
|
531
601
|
for (const name of admissionSlots) {
|
|
@@ -594,6 +664,43 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string {
|
|
|
594
664
|
return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`;
|
|
595
665
|
}
|
|
596
666
|
|
|
667
|
+
/**
|
|
668
|
+
* The hub's featured subagent roster, or undefined to fall back to local `subagentModels`.
|
|
669
|
+
*
|
|
670
|
+
* Best-effort by design: a launch must not fail because the hub is slow or old. But the
|
|
671
|
+
* fallback is ANNOUNCED (#4236) — a silently local roster is exactly how an operator came to
|
|
672
|
+
* believe a hub that serves grok could only delegate to five native models.
|
|
673
|
+
*
|
|
674
|
+
* An empty hub roster is honoured as empty, not treated as "no answer": an operator who cleared
|
|
675
|
+
* the hub's featured list meant it.
|
|
676
|
+
*/
|
|
677
|
+
export async function resolveHubRosterForClaude(
|
|
678
|
+
connection: { serverUrl: string; apiKeyId: string; connectedAt: string },
|
|
679
|
+
token: string,
|
|
680
|
+
deps: { resolve?: typeof resolveHubState; warn?: (message: string) => void } = {},
|
|
681
|
+
): Promise<readonly string[] | undefined> {
|
|
682
|
+
const warn = deps.warn ?? (message => console.error(message));
|
|
683
|
+
const resolve = deps.resolve ?? resolveHubState;
|
|
684
|
+
try {
|
|
685
|
+
const resolved = await resolve({
|
|
686
|
+
owner: { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt },
|
|
687
|
+
token,
|
|
688
|
+
});
|
|
689
|
+
if (!resolved.state) {
|
|
690
|
+
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.`);
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
if (resolved.stateSource === "cache") {
|
|
694
|
+
warn(`⚠ Hub roster came from a cached read ${resolved.ageSeconds ?? "?"}s old (${resolved.reason ?? "live read failed"}).`);
|
|
695
|
+
}
|
|
696
|
+
return resolved.state.subagentModels;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
699
|
+
warn(`⚠ Hub roster could not be read (${message}); using this machine's local subagentModels instead.`);
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
597
704
|
export async function cmdClaude(args: string[]): Promise<number> {
|
|
598
705
|
const config = loadConfig();
|
|
599
706
|
const clientState = readClientConnectionState();
|
|
@@ -606,10 +713,13 @@ export async function cmdClaude(args: string[]): Promise<number> {
|
|
|
606
713
|
if (preflight.kind === "native") return launchNativeClaude(config, args, preflight.notice);
|
|
607
714
|
let route: number | ClaudeRoutingTarget;
|
|
608
715
|
let contextWindows: Record<string, number>;
|
|
716
|
+
/** The hub's featured roster on a connected client; undefined means "use local config". */
|
|
717
|
+
let hubRoster: readonly string[] | undefined;
|
|
609
718
|
if (clientState.kind === "connected") {
|
|
610
719
|
if (tokenState?.kind !== "present") return 1;
|
|
611
720
|
route = { baseUrl: clientState.value.serverUrl, admissionToken: tokenState.token };
|
|
612
721
|
contextWindows = readConnectedClaudeContextWindows();
|
|
722
|
+
hubRoster = await resolveHubRosterForClaude(clientState.value, tokenState.token);
|
|
613
723
|
} else {
|
|
614
724
|
const port = await ensureProxyForClaude();
|
|
615
725
|
if (!port) {
|
|
@@ -641,16 +751,24 @@ export async function cmdClaude(args: string[]): Promise<number> {
|
|
|
641
751
|
console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
|
|
642
752
|
}
|
|
643
753
|
// Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
754
|
+
//
|
|
755
|
+
// This used to run only when `route` was a number — i.e. never on a connected client, where
|
|
756
|
+
// `route` is a ClaudeRoutingTarget (#4236). So `~/.claude/agents/ocx-*.md` on a client stayed
|
|
757
|
+
// whatever a previous standalone run had left, and the five delegable agents an operator saw
|
|
758
|
+
// were a frozen snapshot of a machine that no longer does the routing. Nothing in the output
|
|
759
|
+
// said so; the roster simply looked like the answer.
|
|
760
|
+
//
|
|
761
|
+
// On a client the roster comes from the hub, because the local `subagentModels` list is the
|
|
762
|
+
// one this machine had before it joined. The five-row cap stays: it is a Claude Code picker
|
|
763
|
+
// constraint, not the defect — sourcing the five from the wrong machine was.
|
|
764
|
+
try {
|
|
765
|
+
const written = injectClaudeAgentDefs(config, contextWindows, undefined, hubRoster);
|
|
766
|
+
if (written === null) {
|
|
767
|
+
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
|
|
653
768
|
}
|
|
769
|
+
} catch (error) {
|
|
770
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
771
|
+
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
|
|
654
772
|
}
|
|
655
773
|
return spawnClaude(args, env);
|
|
656
774
|
}
|
|
@@ -4,6 +4,7 @@ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeMo
|
|
|
4
4
|
import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
|
|
5
5
|
import type { OcxConfig } from "../types";
|
|
6
6
|
import { normalizeVisionReasoningForModel } from "../vision/reasoning";
|
|
7
|
+
import type { ClientConnectionStatus } from "./connect";
|
|
7
8
|
import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
|
|
8
9
|
|
|
9
10
|
const USAGE = `Usage:
|
|
@@ -26,8 +27,55 @@ const USAGE = `Usage:
|
|
|
26
27
|
const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i;
|
|
27
28
|
const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The synthetic `_remoteHub` note printed by `ocx config show` on a client (#4236).
|
|
32
|
+
*
|
|
33
|
+
* `runtimeRole: "client"` and the `client` block were already printed, and were already ignored:
|
|
34
|
+
* an agent read a client's `config.json`, saw an empty `providers` map and no grok, and concluded
|
|
35
|
+
* the hub could not serve grok. Naming the situation in the config output costs one key.
|
|
36
|
+
*
|
|
37
|
+
* `connected` is OBSERVED, never assumed. It was briefly hardcoded `true` for any config with a
|
|
38
|
+
* `client` block, which is the same defect in miniature: the presence of configuration is not
|
|
39
|
+
* evidence that the connection works, and a machine whose data-plane token was revoked, rotated
|
|
40
|
+
* away or deleted would have been labelled `connected: true` while it could not reach the hub at
|
|
41
|
+
* all. `collectClientConnectionStatus` is the one reader that knows — it compares the token file's
|
|
42
|
+
* fingerprint against the connection record — so the caller passes its answer in and this stays
|
|
43
|
+
* pure and testable.
|
|
44
|
+
*
|
|
45
|
+
* Synthetic and NOT persisted, for two reasons. `clientConnectionSchema` is `.strict()`, so a
|
|
46
|
+
* `client.note` field would not validate; and persisted prose drifts from the behaviour it
|
|
47
|
+
* describes. The leading underscore marks it as an annotation rather than a setting, and
|
|
48
|
+
* `config export` emits the real config untouched so round-trips still validate.
|
|
49
|
+
*/
|
|
50
|
+
export function remoteHubConfigNote(
|
|
51
|
+
config: OcxConfig,
|
|
52
|
+
readConnection: () => Pick<ClientConnectionStatus, "state" | "reason" | "token">,
|
|
53
|
+
): { connected: boolean; origin: string; note: string } | null {
|
|
54
|
+
if (config.runtimeRole !== "client" || !config.client) return null;
|
|
55
|
+
// A thunk, so a standalone or hub install pays nothing: the guard above returns first and the
|
|
56
|
+
// connection probe (three file reads) never runs.
|
|
57
|
+
const connection = readConnection();
|
|
58
|
+
// Both halves are required: a settled connection record AND the token it recorded. Either one
|
|
59
|
+
// alone describes a machine that cannot read its hub, and `ocx status` is still the command
|
|
60
|
+
// that has the facts — so the note points there in every case, connected or not.
|
|
61
|
+
const connected = connection.state === "connected" && connection.token === "owned";
|
|
62
|
+
const note = connection.state !== "connected"
|
|
63
|
+
? `this machine is configured as a client but its connection is ${connection.state}${connection.reason ? ` (${connection.reason})` : ""}; run ocx connect status`
|
|
64
|
+
: connection.token !== "owned"
|
|
65
|
+
? `this machine is configured as a client but its hub data-plane token is ${connection.token}; run ocx connect status`
|
|
66
|
+
: "provider credentials and model availability live on the hub; run ocx status";
|
|
67
|
+
return { connected, origin: config.client.serverUrl, note };
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
function redact(value: unknown, key = ""): unknown {
|
|
30
71
|
if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
|
|
72
|
+
// `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
|
|
73
|
+
// local one — up to 64 MB of it (src/config.ts). Printed in full it buried `runtimeRole` and
|
|
74
|
+
// the `client` block under a wall of base64, which is how a reader came to miss that this
|
|
75
|
+
// machine is a client at all. Size only, mirroring sanitizeModelCostsForDisplay.
|
|
76
|
+
if (key === "priorCatalog" && typeof value === "string") {
|
|
77
|
+
return value ? `<omitted: ${Buffer.byteLength(value)} bytes>` : value;
|
|
78
|
+
}
|
|
31
79
|
// modelCosts rows are keyed by model id; a pasted API key in a key position
|
|
32
80
|
// must not be echoed back by config show/get (values are already redacted).
|
|
33
81
|
if (key === "modelCosts") return sanitizeModelCostsForDisplay(value);
|
|
@@ -119,7 +167,25 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
|
|
|
119
167
|
const source = takeFlag(args, "--source");
|
|
120
168
|
rejectArgs(args, USAGE);
|
|
121
169
|
const diagnostics = readConfigDiagnostics();
|
|
122
|
-
const
|
|
170
|
+
const redacted = redact(diagnostics.config);
|
|
171
|
+
// Imported here rather than at module scope: `./connect` pulls the whole client lifecycle
|
|
172
|
+
// in, and `ocx config get/set` has no use for it.
|
|
173
|
+
const { collectClientConnectionStatus } = await import("./connect");
|
|
174
|
+
// The readiness probe is declined explicitly. `collectClientConnectionStatus` observes the
|
|
175
|
+
// local Codex ladder for a connected client, and observing it spawns `codex debug models`
|
|
176
|
+
// under a 45s budget. `ocx config show` reads only `state`, `reason` and `token` from the
|
|
177
|
+
// result, so paying for a subprocess here would buy nothing and would quietly turn a
|
|
178
|
+
// read-only config dump into a runtime probe. Returning no ladder resolves readiness to
|
|
179
|
+
// `unverified`, which is the honest answer for a caller that never asked.
|
|
180
|
+
const note = remoteHubConfigNote(
|
|
181
|
+
diagnostics.config,
|
|
182
|
+
() => collectClientConnectionStatus(undefined, undefined, { supportedEfforts: () => null }),
|
|
183
|
+
);
|
|
184
|
+
// First key, not last: it has to be read before the empty `providers` map that misled a
|
|
185
|
+
// reader into concluding nothing was configured anywhere.
|
|
186
|
+
const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted)
|
|
187
|
+
? { _remoteHub: note, ...redacted as Record<string, unknown> }
|
|
188
|
+
: redacted;
|
|
123
189
|
const result = source ? { config, source: diagnostics.source, error: diagnostics.error, warnings: diagnostics.warnings ?? [] } : config;
|
|
124
190
|
printData(result, true);
|
|
125
191
|
return;
|