@bitkyc08/opencodex 2.58.0 → 2.59.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/README.md +28 -10
- package/gui/dist/assets/index-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +1 -1
- package/src/adapters/base.ts +11 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor.ts +42 -1
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +8 -1
- package/src/adapters/google.ts +23 -4
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +10 -1
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +165 -24
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +54 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +88 -20
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/history-provider.ts +4 -1
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-entitlements.ts +60 -1
- package/src/codex/observed-model-denials.ts +137 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +282 -12
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +23 -5
- package/src/config/schema/config-schema.ts +21 -0
- package/src/config/schema/leaf-validators.ts +64 -17
- package/src/generated/compatibility-version.json +235 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-extended.ts +96 -0
- package/src/providers/registry/model-seeds.ts +78 -21
- package/src/responses/apply-patch-envelope.ts +44 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +31 -6
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +10 -1
- package/src/server/responses/passthrough-dispatch.ts +113 -48
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +29 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +100 -3
- package/src/types/provider.ts +19 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
package/src/cli/doctor.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { getConfigDir, getConfigPath, readConfigDiagnostics } from "../config";
|
|
14
14
|
import { readPid } from "../config/process-state";
|
|
15
15
|
import { probeUncleanExitState } from "./status";
|
|
16
|
-
import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
|
|
16
|
+
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
17
17
|
import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime";
|
|
18
18
|
import type { BunRuntimeSource } from "../lib/bun-runtime";
|
|
19
19
|
import { maskAccountId } from "../lib/privacy";
|
|
@@ -26,7 +26,11 @@ import { withNativeMainSharedClaim } from "../codex/native-main-claim";
|
|
|
26
26
|
import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store";
|
|
27
27
|
import { NativeProfileError } from "../codex/native-profile-types";
|
|
28
28
|
import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
scanCodexAgentRolesWithTomlModelFallback,
|
|
31
|
+
scanOpencodexDerivedCodexAgentRolesWithoutModelPin,
|
|
32
|
+
} from "../codex/subagent-model-fallback";
|
|
33
|
+
import { readCatalog, readCodexCatalogPath, readConfiguredDefaultModel } from "../codex/catalog/parsing";
|
|
30
34
|
import { diagnoseCodexShim, findCodexOnPath, isWindowsInteropDir, type CodexShimDiagnostic } from "../codex/shim";
|
|
31
35
|
import { providerTableString, rootTomlString } from "../codex/injected-marker";
|
|
32
36
|
import { countPendingOpencodexHistory } from "../codex/history-provider";
|
|
@@ -1040,6 +1044,161 @@ export function chatgptPublicEndpointHint(
|
|
|
1040
1044
|
return "ChatGPT-family requests use the public ChatGPT endpoint through this proxy, in both Pool and Direct modes. Eligible streaming turns dial the ChatGPT websocket transport (the same responses_websockets lane Codex CLI defaults to) and fall back to SSE over HTTP when a turn is not eligible - an unsupported Bun runtime, an oversized create frame, or a proxy route that cannot carry the socket - and local provider pacing can hold a request before it is dispatched at all. This hint classifies configuration only and measures nothing, so upstream queueing is one possible contributor to a slow first output: compare actual transport, pacing, network, and provider observations before concluding. service_tier=priority is a request preference: this backend can echo service_tier \"default\" even on turns it scheduled as priority (#2558), so the echoed response tier in request logs stays an observation with confirmation \"assumed\" and cannot confirm or deny the granted tier.";
|
|
1041
1045
|
}
|
|
1042
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Bound for the doctor-side `/v1/models` read (#4646). A diagnostic must not hang on a proxy
|
|
1049
|
+
* that is listening but wedged mid-gather; when the read does not land in time the on-disk
|
|
1050
|
+
* catalog answers instead, and if that is unreadable too the verdict is "could not determine"
|
|
1051
|
+
* rather than a guess.
|
|
1052
|
+
*/
|
|
1053
|
+
const EXPOSED_MODELS_TIMEOUT_MS = 8000;
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Whether Codex's pinned default model is one this proxy exposes (#4646).
|
|
1057
|
+
*
|
|
1058
|
+
* Three states, not two. Reporting "not exposed" when the exposed set could not be read would
|
|
1059
|
+
* be a fabricated failure on exactly the installs least able to check it (proxy down, catalog
|
|
1060
|
+
* never synced), so an unreadable set is its own verdict.
|
|
1061
|
+
*/
|
|
1062
|
+
export type DefaultModelExposureStatus = "not_configured" | "exposed" | "not_exposed" | "undeterminable";
|
|
1063
|
+
|
|
1064
|
+
export interface DefaultModelExposure {
|
|
1065
|
+
status: DefaultModelExposureStatus;
|
|
1066
|
+
/** The configured pin, or null when Codex's config.toml has no root `model`. */
|
|
1067
|
+
model: string | null;
|
|
1068
|
+
/** Which surface answered; null when neither could be read. */
|
|
1069
|
+
source: "proxy" | "catalog" | null;
|
|
1070
|
+
detail: string;
|
|
1071
|
+
action?: string;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Exactly the catalog's own `RawEntry` shape, so an on-disk row needs no conversion. */
|
|
1075
|
+
type CatalogVisibilityRow = Record<string, unknown>;
|
|
1076
|
+
|
|
1077
|
+
export interface DefaultModelExposureDeps {
|
|
1078
|
+
readConfiguredModelFn?: () => string | null;
|
|
1079
|
+
/** The live proxy doctor already resolved, or null/absent when none is running. */
|
|
1080
|
+
live?: LiveProxy | null;
|
|
1081
|
+
fetchFn?: typeof fetch;
|
|
1082
|
+
readCatalogModelsFn?: () => readonly CatalogVisibilityRow[] | null;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Ids the running proxy advertises, or null when the read did not produce a usable answer.
|
|
1087
|
+
*
|
|
1088
|
+
* Null is deliberately indistinguishable across transport failure, a non-200, and a malformed
|
|
1089
|
+
* body, because every one of them means the same thing to the caller: this surface did not
|
|
1090
|
+
* answer, ask the next one. The 401 case is real rather than theoretical — `/v1/models` requires
|
|
1091
|
+
* data-plane admission on a non-loopback bind (`isApiAuthRequired`), and doctor deliberately
|
|
1092
|
+
* holds no data-plane key, so a remote-bound proxy always falls through to the catalog.
|
|
1093
|
+
*/
|
|
1094
|
+
async function fetchExposedModelIds(live: LiveProxy, fetchFn: typeof fetch): Promise<Set<string> | null> {
|
|
1095
|
+
try {
|
|
1096
|
+
const res = await fetchFn(`http://${probeHostname(live.hostname)}:${live.port}/v1/models`, {
|
|
1097
|
+
signal: AbortSignal.timeout(EXPOSED_MODELS_TIMEOUT_MS),
|
|
1098
|
+
});
|
|
1099
|
+
if (!res.ok) return null;
|
|
1100
|
+
const body = await res.json() as { data?: unknown };
|
|
1101
|
+
if (!Array.isArray(body?.data)) return null;
|
|
1102
|
+
const ids = new Set<string>();
|
|
1103
|
+
for (const row of body.data) {
|
|
1104
|
+
const id = (row as { id?: unknown } | null)?.id;
|
|
1105
|
+
if (typeof id === "string" && id.length > 0) ids.add(id);
|
|
1106
|
+
}
|
|
1107
|
+
return ids;
|
|
1108
|
+
} catch {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Picker-visible catalog slugs, or null when the catalog is absent or unparseable. */
|
|
1114
|
+
function catalogExposedModelIds(rows: readonly CatalogVisibilityRow[] | null): Set<string> | null {
|
|
1115
|
+
if (rows === null) return null;
|
|
1116
|
+
const ids = new Set<string>();
|
|
1117
|
+
for (const row of rows) {
|
|
1118
|
+
// `visibility: "hide"` rows are retained on purpose (see the native-toggle contract in
|
|
1119
|
+
// structure/catalog.md); they are exactly the rows a pin must not resolve to.
|
|
1120
|
+
if (!row || row.visibility !== "list") continue;
|
|
1121
|
+
const slug = row.slug;
|
|
1122
|
+
if (typeof slug === "string" && slug.length > 0) ids.add(slug);
|
|
1123
|
+
}
|
|
1124
|
+
return ids;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function defaultCatalogModels(): readonly CatalogVisibilityRow[] | null {
|
|
1128
|
+
const models = readCatalog(readCodexCatalogPath())?.models;
|
|
1129
|
+
return Array.isArray(models) ? models : null;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Compare Codex's root `model` pin against the models this install actually exposes (#4646).
|
|
1134
|
+
*
|
|
1135
|
+
* The exposed set is read, never recomputed. Reproducing the live assembly in the CLI would mean
|
|
1136
|
+
* duplicating an entitlements snapshot, a provider gather and account-selector expansion, and the
|
|
1137
|
+
* duplicate would drift — the same failure `formatStartupRoutingDetail` and `computeVersionSkew`
|
|
1138
|
+
* were extracted to prevent. So the running proxy answers when there is one, the on-disk catalog
|
|
1139
|
+
* answers otherwise, and neither is reconstructed here.
|
|
1140
|
+
*
|
|
1141
|
+
* Both surfaces are consulted before any negative verdict. They name a routed row through the
|
|
1142
|
+
* same `<provider>/<id>` slug space, but they are built by different code at different times, so
|
|
1143
|
+
* requiring both to disagree is what keeps an encoding or staleness difference from being
|
|
1144
|
+
* reported to the operator as a broken pin.
|
|
1145
|
+
*/
|
|
1146
|
+
export async function collectDefaultModelExposure(
|
|
1147
|
+
deps: DefaultModelExposureDeps = {},
|
|
1148
|
+
): Promise<DefaultModelExposure> {
|
|
1149
|
+
const configured = (deps.readConfiguredModelFn ?? readConfiguredDefaultModel)();
|
|
1150
|
+
const model = typeof configured === "string" ? configured.trim() : "";
|
|
1151
|
+
if (!model) {
|
|
1152
|
+
return {
|
|
1153
|
+
status: "not_configured",
|
|
1154
|
+
model: null,
|
|
1155
|
+
source: null,
|
|
1156
|
+
detail: "Codex config.toml pins no root `model`, so Codex picks from the exposed catalog",
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const live = deps.live ?? null;
|
|
1161
|
+
const proxyIds = live ? await fetchExposedModelIds(live, deps.fetchFn ?? fetch) : null;
|
|
1162
|
+
const catalogIds = catalogExposedModelIds((deps.readCatalogModelsFn ?? defaultCatalogModels)());
|
|
1163
|
+
if (proxyIds === null && catalogIds === null) {
|
|
1164
|
+
return {
|
|
1165
|
+
status: "undeterminable",
|
|
1166
|
+
model,
|
|
1167
|
+
source: null,
|
|
1168
|
+
detail: `could not read the exposed model set, so Codex \`model = "${model}"\` was not checked`,
|
|
1169
|
+
action: "Start the proxy with 'ocx start', or run 'ocx sync' to write the Codex catalog, then re-run 'ocx doctor'",
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const source = proxyIds !== null ? "proxy" as const : "catalog" as const;
|
|
1174
|
+
// `source` reports which surface produced the verdict, so a match names the surface that
|
|
1175
|
+
// matched rather than the one we happened to read first.
|
|
1176
|
+
const matched = proxyIds?.has(model) === true
|
|
1177
|
+
? "proxy" as const
|
|
1178
|
+
: catalogIds?.has(model) === true ? "catalog" as const : null;
|
|
1179
|
+
if (matched !== null) {
|
|
1180
|
+
return {
|
|
1181
|
+
status: "exposed",
|
|
1182
|
+
model,
|
|
1183
|
+
source: matched,
|
|
1184
|
+
detail: `Codex \`model = "${model}"\` is exposed by this install`,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
// Name only the surfaces that actually answered: claiming a check that did not happen is the
|
|
1188
|
+
// same defect as claiming an exposure verdict we could not reach.
|
|
1189
|
+
const checked = [
|
|
1190
|
+
...(proxyIds !== null ? ["the running proxy's /v1/models"] : []),
|
|
1191
|
+
...(catalogIds !== null ? ["the on-disk Codex catalog"] : []),
|
|
1192
|
+
].join(" and ");
|
|
1193
|
+
return {
|
|
1194
|
+
status: "not_exposed",
|
|
1195
|
+
model,
|
|
1196
|
+
source,
|
|
1197
|
+
detail: `Codex \`model = "${model}"\` is NOT exposed by this install (checked ${checked}), so every new Codex session starts on a model this proxy does not serve`,
|
|
1198
|
+
action: "Expose that model (enable it in the dashboard or drop it from 'disabledModels') and run 'ocx sync', or pin an exposed id as 'model' in CODEX_HOME/config.toml",
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1043
1202
|
export async function runDoctor(args: string[] = []): Promise<void> {
|
|
1044
1203
|
if (args.includes("--fix-codex-runtime")) {
|
|
1045
1204
|
const resolved = resolveCodexRuntime();
|
|
@@ -1325,6 +1484,26 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
1325
1484
|
console.log(line);
|
|
1326
1485
|
}
|
|
1327
1486
|
|
|
1487
|
+
// Adjacent to the section above because both read Codex's config.toml, and an operator
|
|
1488
|
+
// debugging "Codex config" wants the pinned model checked in the same place.
|
|
1489
|
+
console.log("\nCodex default model exposure");
|
|
1490
|
+
const defaultModelExposure = await collectDefaultModelExposure({ live });
|
|
1491
|
+
if (defaultModelExposure.status === "not_exposed") {
|
|
1492
|
+
console.log(` !! ${defaultModelExposure.detail}`);
|
|
1493
|
+
console.log(` Action: ${defaultModelExposure.action}`);
|
|
1494
|
+
} else if (defaultModelExposure.status === "undeterminable") {
|
|
1495
|
+
// Not `!!`: nothing is known to be wrong. The one thing this must never do is report an
|
|
1496
|
+
// unread set as a broken pin.
|
|
1497
|
+
console.log(` -- ${defaultModelExposure.detail}`);
|
|
1498
|
+
console.log(` Action: ${defaultModelExposure.action}`);
|
|
1499
|
+
} else {
|
|
1500
|
+
console.log(` ok ${defaultModelExposure.detail}`);
|
|
1501
|
+
}
|
|
1502
|
+
// Deliberately no `recordDoctorFailure()` and no `process.exitCode` write. A pin that is not
|
|
1503
|
+
// exposed is a degraded install, not an unusable one — the operator can still pick another
|
|
1504
|
+
// model in the session — and the rule above reserves FAIL for an unusable surface so a warning
|
|
1505
|
+
// cannot break a legitimately green pipeline.
|
|
1506
|
+
|
|
1328
1507
|
console.log("\nCodex agent role files");
|
|
1329
1508
|
const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl());
|
|
1330
1509
|
if (tomlFallbackRoles.length === 0) {
|
|
@@ -1333,6 +1512,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
1333
1512
|
console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`);
|
|
1334
1513
|
console.log(" Codex >= 0.146 rejects that field as unknown and skips the whole role. Move the chains to opencodex config `subagentModelFallbackByModel` (keyed by primary model) and remove the field from the TOML files.");
|
|
1335
1514
|
}
|
|
1515
|
+
// opencodex does not write these files; the Codex desktop external-agent import does, and it
|
|
1516
|
+
// drops the model pin on the way in. Observe-only: doctor never repairs or removes them.
|
|
1517
|
+
const unpinnedDerivedRoles = scanOpencodexDerivedCodexAgentRolesWithoutModelPin(resolveCodexHomeDirImpl());
|
|
1518
|
+
if (unpinnedDerivedRoles.length === 0) {
|
|
1519
|
+
console.log(" ok every opencodex-derived role file in $CODEX_HOME/agents/*.toml pins a model");
|
|
1520
|
+
} else {
|
|
1521
|
+
console.log(` [WARN] ${unpinnedDerivedRoles.length} opencodex-derived role file${unpinnedDerivedRoles.length === 1 ? "" : "s"} without a \`model\` pin: ${unpinnedDerivedRoles.map(role => `${role}.toml`).join(", ")}`);
|
|
1522
|
+
console.log(" Codex runs these roles on the parent model, so a spawn records one role and another model. The `ocx-route` directive in the file cannot pin them: it is honoured only on the Claude Code `/v1/messages` path and is inert on `/v1/responses`.");
|
|
1523
|
+
console.log(" Add `model = \"<id>\"` to each file, or remove them. They usually come from the Codex desktop external-agent import of ~/.claude/agents/ocx-*.md; set `[desktop] external-agent-import-sync-item-types` with `SUBAGENTS = false` to stop it recreating them.");
|
|
1524
|
+
}
|
|
1336
1525
|
|
|
1337
1526
|
const dual = collectWslDualInstall();
|
|
1338
1527
|
if (dual.wsl) {
|
|
@@ -1397,6 +1586,12 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
1397
1586
|
hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
|
|
1398
1587
|
}
|
|
1399
1588
|
if (codexEnvKeyReadiness) hints.push(`${codexEnvKeyReadiness.detail}. ${codexEnvKeyReadiness.action}.`);
|
|
1589
|
+
// Only the negative verdict becomes a hint. "Could not determine" is usually just a proxy that
|
|
1590
|
+
// is not running, which `proxyDownRestartHint` already reports; repeating it here would put a
|
|
1591
|
+
// second line in the hint list for one fact.
|
|
1592
|
+
if (defaultModelExposure.status === "not_exposed") {
|
|
1593
|
+
hints.push(`${defaultModelExposure.detail}. ${defaultModelExposure.action}.`);
|
|
1594
|
+
}
|
|
1400
1595
|
const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
|
|
1401
1596
|
const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
|
|
1402
1597
|
if (!startup.rebootSafe) {
|
package/src/cli/help.ts
CHANGED
|
@@ -27,7 +27,8 @@ export function printUsage(): void {
|
|
|
27
27
|
|
|
28
28
|
Usage:
|
|
29
29
|
ocx setup Interactive setup (alias: init)
|
|
30
|
-
ocx start [--port <port>]
|
|
30
|
+
ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]
|
|
31
|
+
Start the proxy; SOCKS5 defaults to 127.0.0.1:10808
|
|
31
32
|
ocx stop Stop the proxy AND restore native Codex (plain codex works again)
|
|
32
33
|
ocx restore Restore native Codex without stopping (alias: eject)
|
|
33
34
|
ocx restore back Re-point codex at the running proxy (undo restore)
|
|
@@ -102,6 +103,8 @@ Examples:
|
|
|
102
103
|
ocx init Set up provider and inject into Codex
|
|
103
104
|
ocx start Start on default port (10100)
|
|
104
105
|
ocx start --port 8080 Start on custom port
|
|
106
|
+
ocx start --socks5 Outbound via SOCKS5 at 127.0.0.1:10808 (saved)
|
|
107
|
+
ocx start --socks5-off Clear a saved SOCKS5 outbound proxy
|
|
105
108
|
ocx help service Show service command help
|
|
106
109
|
ocx help hub Explain the hub topology, token file, and invites
|
|
107
110
|
ocx sync Sync available models to Codex`);
|
package/src/cli/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
saveConfig,
|
|
44
44
|
} from "../config";
|
|
45
45
|
import {
|
|
46
|
+
isLikelyOcxProcess,
|
|
46
47
|
readPid,
|
|
47
48
|
readPidFileValue,
|
|
48
49
|
readRuntimePort,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
|
|
66
67
|
import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
|
|
67
68
|
import { takeFlag } from "./runtime-api";
|
|
69
|
+
import { parseStartOptions, StartArgsError } from "./start-args";
|
|
68
70
|
|
|
69
71
|
import {
|
|
70
72
|
discoverStableProxyForRestart,
|
|
@@ -76,9 +78,10 @@ import {
|
|
|
76
78
|
} from "./tray-proxy";
|
|
77
79
|
import { requestBoundSystemRestart } from "./system-restart-client";
|
|
78
80
|
import { installCrashGuards } from "../lib/crash-guard";
|
|
79
|
-
import {
|
|
81
|
+
import { redactUrlForLog } from "../lib/redact";
|
|
82
|
+
import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
|
|
80
83
|
import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
|
|
81
|
-
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
84
|
+
import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness";
|
|
82
85
|
import { createReadinessGate } from "../server/readiness";
|
|
83
86
|
import { isApiAuthRequired } from "../server/auth-cors";
|
|
84
87
|
import { runReady, type ReadyArgs } from "./ready";
|
|
@@ -169,21 +172,13 @@ const head = await runCli(process.argv.slice(2));
|
|
|
169
172
|
const args = head.args;
|
|
170
173
|
const command = head.command;
|
|
171
174
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
const portIdx = args.indexOf("--port");
|
|
179
|
-
if (portIdx === -1) return undefined;
|
|
180
|
-
const value = args[portIdx + 1];
|
|
181
|
-
const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
|
|
182
|
-
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
183
|
-
console.error("Invalid port number");
|
|
175
|
+
function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
|
|
176
|
+
try {
|
|
177
|
+
return parseStartOptions(args.slice(1));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(error instanceof StartArgsError ? error.message : String(error));
|
|
184
180
|
process.exit(1);
|
|
185
181
|
}
|
|
186
|
-
return port;
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
|
|
@@ -272,8 +267,41 @@ async function chooseListenPort(
|
|
|
272
267
|
// ever a config collision.
|
|
273
268
|
...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
|
|
274
269
|
});
|
|
275
|
-
if (
|
|
276
|
-
|
|
270
|
+
if (selected !== preferred) {
|
|
271
|
+
// The hop used to be automatic, and that is how a bare `start` beside a healthy
|
|
272
|
+
// proxy produced a second one (#5004): nothing on this path ever asked who held the
|
|
273
|
+
// preferred port. Ask the holder itself — not this home's pid/runtime bookkeeping,
|
|
274
|
+
// which is exactly what was wrong when the duplicate happened — and give the
|
|
275
|
+
// question a budget that cannot mistake one lost probe for an empty port.
|
|
276
|
+
const holder = preferred > 0 && !hardPin
|
|
277
|
+
? await probePortOwner(preferred, { hostname: config.hostname }, START_OWNERSHIP_LIVENESS)
|
|
278
|
+
: null;
|
|
279
|
+
const decision = decideBusyPreferredPort({
|
|
280
|
+
preferredPort: preferred,
|
|
281
|
+
selectedPort: selected,
|
|
282
|
+
hardPin,
|
|
283
|
+
holderIsOpencodex: holder !== null,
|
|
284
|
+
ocxService: process.env.OCX_SERVICE,
|
|
285
|
+
});
|
|
286
|
+
if (decision === "service-stay-out") {
|
|
287
|
+
// Same contract as the pre-bind owner check: the wrapper's retry loop terminates
|
|
288
|
+
// on a zero exit, and the port it was asked to serve is already served.
|
|
289
|
+
console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`);
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
if (decision === "refuse-live-proxy") {
|
|
293
|
+
console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
if (decision === "refuse-unidentified-holder") {
|
|
297
|
+
console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`);
|
|
298
|
+
console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for.");
|
|
299
|
+
console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port <port>'.");
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
if (preferred > 0) {
|
|
303
|
+
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
304
|
+
}
|
|
277
305
|
}
|
|
278
306
|
if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
|
|
279
307
|
config.port = selected;
|
|
@@ -296,7 +324,12 @@ async function findProxyOwnerBeforeJournalRecovery(
|
|
|
296
324
|
const pidSnapshot = readPidFileValue();
|
|
297
325
|
const hasRuntimeOwner = readRuntimePort() !== null;
|
|
298
326
|
const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
|
|
299
|
-
|
|
327
|
+
// A negative answer here is acted on twice over: the caller walks past a proxy it was
|
|
328
|
+
// supposed to find, and the lines below delete this home's pid record and reconcile the
|
|
329
|
+
// journal. One 750ms probe is not enough evidence for either (#5004) — a transport
|
|
330
|
+
// failure is indistinguishable from an empty port, and the reported Windows duplicate
|
|
331
|
+
// came from exactly that answer on a proxy the previous command had just found healthy.
|
|
332
|
+
const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null;
|
|
300
333
|
if (live) return { live, pidSnapshot };
|
|
301
334
|
|
|
302
335
|
// The probe established that the snapshotted owner is stale. Compare before
|
|
@@ -328,7 +361,26 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
328
361
|
// already-broken file cannot fence /api/* closed at boot (#2696).
|
|
329
362
|
const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
330
363
|
if (present) assertNotAdminToken(present);
|
|
331
|
-
const
|
|
364
|
+
const startOpts = parseStartCliOptions();
|
|
365
|
+
if (startOpts.socks5 !== undefined || startOpts.socks5Off) {
|
|
366
|
+
const proxyConfig = loadConfig();
|
|
367
|
+
if (startOpts.socks5Off) {
|
|
368
|
+
if (proxyConfig.proxy && !/^socks5h?:\/\//i.test(proxyConfig.proxy.trim())) {
|
|
369
|
+
console.error("Cannot use --socks5-off: config.proxy is not a SOCKS5 URL; it was left unchanged.");
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
if (proxyConfig.proxy) {
|
|
373
|
+
delete proxyConfig.proxy;
|
|
374
|
+
saveConfig(proxyConfig);
|
|
375
|
+
console.log("Cleared config.proxy (outbound SOCKS5 proxy off).");
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
proxyConfig.proxy = startOpts.socks5!;
|
|
379
|
+
saveConfig(proxyConfig);
|
|
380
|
+
console.log(`Outbound SOCKS5: ${redactUrlForLog(startOpts.socks5!)} (saved to config.proxy)`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const requestedPort = startOpts.port;
|
|
332
384
|
// Always probe the configured port, even when both state files are absent. A
|
|
333
385
|
// fallback-port sibling overwrites the pid/runtime records when it starts and
|
|
334
386
|
// removes them on its own shutdown, so their absence proves nothing about the
|
|
@@ -927,8 +979,24 @@ async function handleStop() {
|
|
|
927
979
|
// `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations
|
|
928
980
|
// unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt
|
|
929
981
|
// is never mistaken for one it inherited.
|
|
982
|
+
//
|
|
983
|
+
// Ownership is decided by IDENTITY, not by bare liveness. A receipt records a number, and
|
|
984
|
+
// the OS reuses numbers: once the owner exits, an unrelated process can be handed its PID,
|
|
985
|
+
// and `isProcessAlive` alone then answers "that stop is still running" for as long as the
|
|
986
|
+
// new process lives. The receipt is filtered out, so no run ever recovers it, quarantines
|
|
987
|
+
// it or even mentions it — while both updater gates keep seeing an outstanding obligation
|
|
988
|
+
// and refuse. That is the permanent fail-closed reported in #4897: no proxy running, a
|
|
989
|
+
// dead owner, and `ocx update` aborting on `teardown-outstanding` every time.
|
|
990
|
+
//
|
|
991
|
+
// Requiring the live PID to be an opencodex process is the narrowing that costs the safety
|
|
992
|
+
// intent nothing: a stop that really is in flight is still left strictly alone, because its
|
|
993
|
+
// process is one of ours. Recognizing the receipt as abandoned only admits it to the
|
|
994
|
+
// recovery loop below, which still has to prove the recorded endpoint is down before
|
|
995
|
+
// anything is restored.
|
|
996
|
+
const teardownOwnerStillRunning = (ownerPid: number): boolean =>
|
|
997
|
+
isProcessAlive(ownerPid) && isLikelyOcxProcess(ownerPid);
|
|
930
998
|
const inheritedTeardowns = listPendingTeardowns()
|
|
931
|
-
.filter(read => isPendingTeardownAbandoned(read,
|
|
999
|
+
.filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning));
|
|
932
1000
|
let teardownNonce: string | undefined;
|
|
933
1001
|
const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
|
|
934
1002
|
if (teardownNonce) return;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
printData,
|
|
5
5
|
rejectArgs,
|
|
6
6
|
runCliAction,
|
|
7
|
+
RuntimeApiError,
|
|
7
8
|
runtimeRequest,
|
|
8
9
|
summaryLines,
|
|
9
10
|
takeBooleanOption,
|
|
@@ -165,6 +166,22 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps
|
|
|
165
166
|
[auto ? `${selector}: automatic pricing restored.` : `${selector}: manual pricing saved.`]);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* True for the management handler's own unknown-id 404, and only that.
|
|
171
|
+
*
|
|
172
|
+
* Two different listeners answer 404 on this route. `src/server/management/model-routes.ts`
|
|
173
|
+
* means "no custom model with that id"; a listener that does not route the request at all
|
|
174
|
+
* reports `{error, method, path}` (src/client/machine-listener.ts), and runtime-api.ts already
|
|
175
|
+
* renders that shape as a routing statement. Narrowing on the absence of `method`/`path` keeps
|
|
176
|
+
* this rewrite from relabelling a not-served-here 404 as a missing record — the exact confusion
|
|
177
|
+
* #4662 was reported as.
|
|
178
|
+
*/
|
|
179
|
+
function unknownCustomModelId(body: unknown): boolean {
|
|
180
|
+
if (!body || typeof body !== "object") return false;
|
|
181
|
+
const record = body as Record<string, unknown>;
|
|
182
|
+
return record.method === undefined && record.path === undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
169
186
|
const args = [...argv];
|
|
170
187
|
const id = args.shift()?.trim();
|
|
@@ -204,10 +221,22 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
204
221
|
}
|
|
205
222
|
if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw === "-" ? null : defaultEffortRaw;
|
|
206
223
|
if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
224
|
+
let result: unknown;
|
|
225
|
+
try {
|
|
226
|
+
result = await runtimeRequest(`/api/custom-models/${encodeURIComponent(id)}`, {
|
|
227
|
+
method: "PUT",
|
|
228
|
+
body: JSON.stringify(patch),
|
|
229
|
+
}, deps);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof RuntimeApiError && error.status === 404 && unknownCustomModelId(error.body)) {
|
|
232
|
+
throw new RuntimeApiError(
|
|
233
|
+
`No custom model has id ${id}. Edits address the custom-model id, not the provider/model slug; list the ids with: ocx models list-custom`,
|
|
234
|
+
404,
|
|
235
|
+
error.body,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
211
240
|
printData(result, wantsJson, [`Updated custom model ${id}.`]);
|
|
212
241
|
}
|
|
213
242
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -20,7 +20,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
20
20
|
usage: "ocx setup",
|
|
21
21
|
summary: "Interactive setup for providers and Codex config injection (alias of init).",
|
|
22
22
|
},
|
|
23
|
-
{
|
|
23
|
+
{
|
|
24
|
+
name: "start",
|
|
25
|
+
usage: "ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]",
|
|
26
|
+
summary: "Start the proxy server and sync models to Codex.",
|
|
27
|
+
details: [
|
|
28
|
+
"--socks5 [host:port] Route outbound provider traffic through SOCKS5 (default 127.0.0.1:10808). Saved as config.proxy.",
|
|
29
|
+
"--socks5-off Clear a saved SOCKS5 outbound proxy from config.proxy.",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
24
32
|
{ name: "stop", usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." },
|
|
25
33
|
{
|
|
26
34
|
name: "restore",
|
|
@@ -390,6 +398,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
390
398
|
details: [
|
|
391
399
|
"system update manages OpenCodex itself.",
|
|
392
400
|
"ocx system codex-cli-update check [--json]",
|
|
401
|
+
"ocx system codex-cli-update attest [--json]",
|
|
402
|
+
"ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]",
|
|
393
403
|
"The Codex CLI inspection command makes no package-registry request, does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.",
|
|
394
404
|
],
|
|
395
405
|
},
|
package/src/cli/runtime-api.ts
CHANGED
|
@@ -42,10 +42,39 @@ export class RuntimeApiError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Refusal for a management request that resolved to a connected-client machine listener.
|
|
47
|
+
*
|
|
48
|
+
* A connected client runs `src/client/machine-listener.ts`, which binds the SAME address the
|
|
49
|
+
* standalone proxy would (`port ?? config.port ?? 10100`) and answers `/healthz` as opencodex.
|
|
50
|
+
* Liveness therefore finds it — correctly, it is our process — but it serves only
|
|
51
|
+
* `/api/machine/*` and returns a JSON 404 for every other `/api/*` path. Before this refusal
|
|
52
|
+
* every management-backed subcommand on such a machine died on that opaque 404:
|
|
53
|
+
* `{"error":"not_found","method":"PUT","path":"/api/custom-models/<id>"}`, one character away
|
|
54
|
+
* from the real handler's unknown-id `{"error":"not found"}` and indistinguishable from it
|
|
55
|
+
* (#4662). The role was already on the wire; only the parser was throwing it away.
|
|
56
|
+
*
|
|
57
|
+
* 503 rather than 404: the management plane is unavailable here, the resource is not missing,
|
|
58
|
+
* and `runCliAction` maps 404 to exit 4 ("no such thing") — the wrong answer to give a script.
|
|
59
|
+
*/
|
|
60
|
+
function clientRoleManagementRefusal(port: number): string {
|
|
61
|
+
return [
|
|
62
|
+
`The opencodex listener on port ${port} is running in the client role. It serves only the machine routes (/api/machine/*), so this machine has no management API to call.`,
|
|
63
|
+
"Custom-model edits and other management changes are made on the hub this machine is connected to: run the command there, or use the hub dashboard (the dashboard on this machine relays to it when the connection uses the relay transport).",
|
|
64
|
+
"To change this machine's own configuration instead: edit customModels in config.json, then run: ocx sync",
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise<string> {
|
|
46
69
|
if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, "");
|
|
47
70
|
const live = await (deps.findLiveProxy ?? findLiveProxy)();
|
|
48
71
|
if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null);
|
|
72
|
+
// The role comes from the same identity-checked /healthz body liveness already parsed, so
|
|
73
|
+
// this costs no extra request. Only the client role is refused: an absent role is a
|
|
74
|
+
// standalone or hub proxy (or a legacy body that predates the field), and both serve /api/*.
|
|
75
|
+
if (live.role === "client") {
|
|
76
|
+
throw new RuntimeApiError(clientRoleManagementRefusal(live.port), 503, null);
|
|
77
|
+
}
|
|
49
78
|
return `http://${probeHostname(live.hostname)}:${live.port}`;
|
|
50
79
|
}
|
|
51
80
|
|
|
@@ -62,11 +91,26 @@ function stringField(record: Record<string, unknown>, key: string): string | und
|
|
|
62
91
|
* is unavailable). Both were dropped here, so a fenced management plane was
|
|
63
92
|
* indistinguishable from a generic failure and an operator had no way to tell a port
|
|
64
93
|
* collision from an ACL refusal from a stopped proxy (#2698).
|
|
94
|
+
*
|
|
95
|
+
* A 404 body that carries both `method` and `path` is a different statement again: some
|
|
96
|
+
* opencodex listener answered, and it does not route that request at all. Only the
|
|
97
|
+
* connected-client machine listener emits that shape today (`json404` in
|
|
98
|
+
* src/client/machine-listener.ts), and printing its bare `not_found` token read as though the
|
|
99
|
+
* resource were missing — a real handler's unknown-id 404 says `not found`, one space apart
|
|
100
|
+
* (#4662). Name the route instead, so any listener that does not serve a path stays legible
|
|
101
|
+
* even if another one starts answering this way.
|
|
65
102
|
*/
|
|
66
103
|
function responseMessage(body: unknown, status: number): string {
|
|
67
104
|
if (typeof body === "string" && body.trim()) return body.trim().slice(0, 400);
|
|
68
105
|
if (!body || typeof body !== "object") return `Management request failed (${status})`;
|
|
69
106
|
const record = body as Record<string, unknown>;
|
|
107
|
+
if (status === 404) {
|
|
108
|
+
const method = stringField(record, "method");
|
|
109
|
+
const path = stringField(record, "path");
|
|
110
|
+
if (method && path) {
|
|
111
|
+
return `This opencodex listener does not serve ${method.slice(0, 16)} ${path.slice(0, 200)}, so the request was refused before any handler ran (it is not a missing record). Check that the command is pointed at a proxy that serves the management API.`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
70
114
|
let primary: string | undefined;
|
|
71
115
|
for (const key of ["error", "message", "detail"]) {
|
|
72
116
|
primary = stringField(record, key);
|