@bitkyc08/opencodex 2.50.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/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
- 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/cli/capabilities.ts +28 -0
- 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 +94 -30
- package/src/cli/launcher-context.ts +1 -1
- 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/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -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 +26 -10
- package/src/codex/loopback-target.ts +45 -0
- 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/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- 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/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/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- 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/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- 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 +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- 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/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;
|
package/src/cli/connect.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
|
|
3
|
+
import { codexSupportedReasoningEfforts } from "../codex/catalog/effort";
|
|
4
|
+
import { resolveCodexRuntime } from "../codex/runtime";
|
|
5
|
+
import {
|
|
6
|
+
inspectClientCatalogReadiness,
|
|
7
|
+
type CatalogCompatibilityDeps,
|
|
8
|
+
type ClientCatalogFileState,
|
|
9
|
+
type ClientCatalogReadiness,
|
|
10
|
+
} from "../client/catalog-compatibility";
|
|
3
11
|
import {
|
|
4
12
|
disconnectClient,
|
|
5
13
|
revokeConnectedClientKey,
|
|
@@ -26,6 +34,12 @@ import {
|
|
|
26
34
|
|
|
27
35
|
export interface ClientCommandDeps extends RuntimeApiDeps {
|
|
28
36
|
lifecycleLockDeps?: ClientLifecycleLockDeps;
|
|
37
|
+
catalogProbeDeps?: ClientCatalogProbeDeps;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
|
|
41
|
+
/** Injected in tests; defaults to reading the materialized client catalog off disk. */
|
|
42
|
+
readCatalogBody?: () => string | null;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
export const CONNECT_USAGE = `Usage:
|
|
@@ -56,21 +70,96 @@ export type ClientConnectionStatus = {
|
|
|
56
70
|
catalog: "present" | "missing" | "unsafe";
|
|
57
71
|
token: "owned" | "missing" | "changed" | "unsafe";
|
|
58
72
|
rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe";
|
|
73
|
+
/**
|
|
74
|
+
* Whether the selected local Codex CLI can actually launch against this connection (#4207).
|
|
75
|
+
*
|
|
76
|
+
* `state: "connected"` proves the hub answered and the credential works. It never proved the
|
|
77
|
+
* local runtime could consume what was downloaded, which is how a connection kept reporting
|
|
78
|
+
* itself healthy while `codex exec` exited on `unknown variant` before its first request.
|
|
79
|
+
*
|
|
80
|
+
* Reported only while connected, and reported as its own field rather than as a fourth
|
|
81
|
+
* `catalog` value: the status JSON is documented additive-only, so widening an existing
|
|
82
|
+
* field's value domain would change what `catalog: "present"` means for every consumer that
|
|
83
|
+
* already reads it.
|
|
84
|
+
*/
|
|
85
|
+
readiness?: ClientCatalogReadiness["kind"];
|
|
86
|
+
/** Present whenever readiness is not `ready`; names the fault and the way out. */
|
|
87
|
+
readinessReason?: string;
|
|
59
88
|
};
|
|
60
89
|
|
|
61
|
-
|
|
90
|
+
function readInstalledCatalogBody(): string | null {
|
|
91
|
+
try {
|
|
92
|
+
return readFileSync(DEFAULT_CATALOG_PATH, "utf8");
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The ladder the selected Codex CLI accepts, observed without persisting anything.
|
|
100
|
+
*
|
|
101
|
+
* `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which
|
|
102
|
+
* writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a
|
|
103
|
+
* read-only diagnostics command should not start writing runtime selection state because a
|
|
104
|
+
* readiness check was added to it. Handing the already-resolved command in as the only candidate
|
|
105
|
+
* skips that path and reuses the resolve cache `ocx status` has usually already filled.
|
|
106
|
+
*/
|
|
107
|
+
function observeLocalCodexEffortLadder(): ReadonlySet<string> | null {
|
|
108
|
+
const command = resolveCodexRuntime().runtime.command;
|
|
109
|
+
return codexSupportedReasoningEfforts({ commandCandidates: () => [command] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One observer per command, for the write-time gate and the readiness check alike. Built even
|
|
114
|
+
* when nothing was injected, so production does not silently fall back to the default inside
|
|
115
|
+
* {@link assertClientCatalogCompatible} — that default persists runtime selection state and
|
|
116
|
+
* would run its own probe, which is how the two checks could disagree about the ladder within
|
|
117
|
+
* a single `ocx connect`.
|
|
118
|
+
*/
|
|
119
|
+
function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps {
|
|
120
|
+
return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */
|
|
124
|
+
function installedCatalogFileState(): ClientCatalogFileState {
|
|
125
|
+
if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing";
|
|
126
|
+
try {
|
|
127
|
+
const stat = lstatSync(DEFAULT_CATALOG_PATH);
|
|
128
|
+
return !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
|
|
129
|
+
} catch {
|
|
130
|
+
return "unsafe";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Observing the runtime spawns `codex debug models`, so this runs only for a connected client —
|
|
136
|
+
* the one configuration that installs hub bytes the local clamp never touched. A standalone or
|
|
137
|
+
* hub install pays nothing for it.
|
|
138
|
+
*
|
|
139
|
+
* Never throws. A status command that dies because a Codex probe failed would replace one wrong
|
|
140
|
+
* answer with a worse one.
|
|
141
|
+
*/
|
|
142
|
+
function inspectInstalledCatalogReadiness(
|
|
143
|
+
file: ClientCatalogFileState,
|
|
144
|
+
deps: ClientCatalogProbeDeps,
|
|
145
|
+
): ClientCatalogReadiness {
|
|
146
|
+
try {
|
|
147
|
+
const read = deps.readCatalogBody ?? readInstalledCatalogBody;
|
|
148
|
+
return inspectClientCatalogReadiness(file, file === "present" ? read() : null, catalogObserver(deps));
|
|
149
|
+
} catch {
|
|
150
|
+
return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function collectClientConnectionStatus(
|
|
155
|
+
now = Date.now(),
|
|
156
|
+
lifecycleLockDeps?: ClientLifecycleLockDeps,
|
|
157
|
+
catalogProbeDeps: ClientCatalogProbeDeps = {},
|
|
158
|
+
): ClientConnectionStatus {
|
|
62
159
|
const state = readClientConnectionState();
|
|
63
160
|
const tokenState = readServiceApiTokenState();
|
|
64
161
|
const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind;
|
|
65
|
-
|
|
66
|
-
if (existsSync(DEFAULT_CATALOG_PATH)) {
|
|
67
|
-
try {
|
|
68
|
-
const stat = lstatSync(DEFAULT_CATALOG_PATH);
|
|
69
|
-
catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
|
|
70
|
-
} catch {
|
|
71
|
-
catalog = "unsafe";
|
|
72
|
-
}
|
|
73
|
-
}
|
|
162
|
+
const catalog = installedCatalogFileState();
|
|
74
163
|
if (state.kind !== "connected") {
|
|
75
164
|
return {
|
|
76
165
|
state: state.kind,
|
|
@@ -88,6 +177,7 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
|
|
|
88
177
|
: tokenState.kind === "unsafe"
|
|
89
178
|
? "unsafe"
|
|
90
179
|
: tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed";
|
|
180
|
+
const readiness = inspectInstalledCatalogReadiness(catalog, catalogProbeDeps);
|
|
91
181
|
return {
|
|
92
182
|
state: "connected",
|
|
93
183
|
serverUrl: state.value.serverUrl,
|
|
@@ -102,6 +192,8 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
|
|
|
102
192
|
catalog,
|
|
103
193
|
token,
|
|
104
194
|
rotation,
|
|
195
|
+
readiness: readiness.kind,
|
|
196
|
+
...(readiness.kind === "ready" ? {} : { readinessReason: readiness.reason }),
|
|
105
197
|
};
|
|
106
198
|
}
|
|
107
199
|
|
|
@@ -113,12 +205,74 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] {
|
|
|
113
205
|
return values as OcxConnectedClientId[];
|
|
114
206
|
}
|
|
115
207
|
|
|
208
|
+
/** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */
|
|
209
|
+
function readinessLine(status: ClientConnectionStatus): string {
|
|
210
|
+
const label = status.readiness === "ready"
|
|
211
|
+
? "ready"
|
|
212
|
+
: status.readiness === "incompatible"
|
|
213
|
+
? "not ready"
|
|
214
|
+
: "unverified";
|
|
215
|
+
return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ConnectCompletionReport = {
|
|
219
|
+
readonly lines: readonly string[];
|
|
220
|
+
/** Non-null when `ocx connect` must exit non-zero rather than report success. */
|
|
221
|
+
readonly failure: string | null;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* What `ocx connect` says once the hub and the credential are settled, and whether the command
|
|
226
|
+
* still fails (#4207).
|
|
227
|
+
*
|
|
228
|
+
* Pure so the fail-closed decision can be exercised without a hub. Two rules it encodes:
|
|
229
|
+
*
|
|
230
|
+
* On a proven incompatibility the verdict is printed FIRST and the `Connected to …` line is
|
|
231
|
+
* withheld, because a caller grepping for that phrase would otherwise read a catalog the local
|
|
232
|
+
* CLI cannot parse as a success. The connection really was saved, so the replacement line says
|
|
233
|
+
* where to see it.
|
|
234
|
+
*
|
|
235
|
+
* And that failure applies only when this connection selected Codex. A Claude-only connection
|
|
236
|
+
* never launches the Codex CLI, so an old binary somewhere on PATH is not a reason to fail the
|
|
237
|
+
* operator's Claude Desktop setup — it is still worth saying, which is why the line survives
|
|
238
|
+
* without the exit code.
|
|
239
|
+
*/
|
|
240
|
+
export function connectCompletionReport(
|
|
241
|
+
connection: { serverUrl: string; apiKeyId: string },
|
|
242
|
+
selectedClients: readonly OcxConnectedClientId[],
|
|
243
|
+
readiness: ClientCatalogReadiness,
|
|
244
|
+
): ConnectCompletionReport {
|
|
245
|
+
const connected = `Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`;
|
|
246
|
+
if (readiness.kind === "ready") {
|
|
247
|
+
return { lines: [connected, "Local Codex CLI: ready (it accepts every reasoning level in the installed catalog)."], failure: null };
|
|
248
|
+
}
|
|
249
|
+
if (readiness.kind === "unverified") {
|
|
250
|
+
// Not a failure. A client with no observable Codex CLI is a working configuration, and the
|
|
251
|
+
// write-time gate deliberately lets it through; saying so is the honest middle report.
|
|
252
|
+
return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null };
|
|
253
|
+
}
|
|
254
|
+
const verdict = `Local Codex CLI: not ready (${readiness.reason})`;
|
|
255
|
+
if (!selectedClients.includes("codex")) {
|
|
256
|
+
return {
|
|
257
|
+
lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`],
|
|
258
|
+
failure: null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`],
|
|
263
|
+
failure: `client_not_ready: ${readiness.reason}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
116
267
|
function statusLines(status: ClientConnectionStatus): string[] {
|
|
117
268
|
if (status.state !== "connected") {
|
|
118
269
|
return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`];
|
|
119
270
|
}
|
|
120
271
|
return [
|
|
121
272
|
"Connection: connected",
|
|
273
|
+
// Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and
|
|
274
|
+
// believed the client was usable, so the local verdict has to arrive before the hub detail.
|
|
275
|
+
readinessLine(status),
|
|
122
276
|
`Hub: ${status.serverUrl}`,
|
|
123
277
|
`Management: ${status.managementUrl} (${status.managementTransport})`,
|
|
124
278
|
`Protocol: ${status.protocolVersion}`,
|
|
@@ -183,8 +337,21 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void
|
|
|
183
337
|
managementTransport,
|
|
184
338
|
noSync,
|
|
185
339
|
...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
|
|
186
|
-
}, {
|
|
187
|
-
|
|
340
|
+
}, {
|
|
341
|
+
fetchImpl: deps.fetchImpl,
|
|
342
|
+
lifecycleLockDeps: deps.lifecycleLockDeps,
|
|
343
|
+
// Same observer the readiness check below uses. Passed unconditionally: leaving it out in
|
|
344
|
+
// production would let the gate fall back to its own probing, persisting default, so one
|
|
345
|
+
// command could run two probes and act on two different ladders.
|
|
346
|
+
catalogCompatibility: catalogObserver(deps.catalogProbeDeps),
|
|
347
|
+
});
|
|
348
|
+
// The hub and the credential are proven at this point; the local runtime is not. Reporting
|
|
349
|
+
// only the first half is what #4207 was filed for, so the catalog now on disk is checked
|
|
350
|
+
// against the Codex CLI that will read it.
|
|
351
|
+
const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
|
|
352
|
+
const report = connectCompletionReport(connection, clients, readiness);
|
|
353
|
+
for (const line of report.lines) console.log(line);
|
|
354
|
+
if (report.failure) throw new Error(report.failure);
|
|
188
355
|
}
|
|
189
356
|
|
|
190
357
|
async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> {
|
|
@@ -204,7 +371,7 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe
|
|
|
204
371
|
const args = argv.slice(1);
|
|
205
372
|
const wantsJson = takeFlag(args, "--json");
|
|
206
373
|
rejectArgs(args, CONNECT_USAGE, { redactValues: true });
|
|
207
|
-
const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps);
|
|
374
|
+
const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps, deps.catalogProbeDeps ?? {});
|
|
208
375
|
printData(status, wantsJson, statusLines(status));
|
|
209
376
|
return;
|
|
210
377
|
}
|