@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/grok/status.ts
CHANGED
|
@@ -111,11 +111,18 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
|
|
|
111
111
|
* `ocx status` is for.
|
|
112
112
|
*
|
|
113
113
|
* Returns null when there is nothing to say: no fence, an unparsable endpoint, or a
|
|
114
|
-
* fence that already agrees with
|
|
114
|
+
* fence that already agrees with a port we are actually listening on.
|
|
115
|
+
*
|
|
116
|
+
* "Listening on" is a SET, not one number (#4236). A hub with an unauthenticated loopback
|
|
117
|
+
* listener answers on the public port and on the listener's port; a fence pointing at the
|
|
118
|
+
* latter is exactly what `ocx sync` wrote, so reporting it as drift told the operator their
|
|
119
|
+
* working config was broken. `loopbackPort` is that second reachable port, already resolved
|
|
120
|
+
* through `effectiveLoopbackListenerPort`, or null when no such listener is configured.
|
|
115
121
|
*/
|
|
116
122
|
export function grokFenceEndpointDrift(
|
|
117
123
|
status: Pick<GrokStatus, "present" | "baseUrl">,
|
|
118
124
|
livePort: number | undefined,
|
|
125
|
+
loopbackPort?: number | null,
|
|
119
126
|
): { fencePort: number; livePort: number } | null {
|
|
120
127
|
if (!status.present || !status.baseUrl) return null;
|
|
121
128
|
if (typeof livePort !== "number" || !Number.isFinite(livePort) || livePort <= 0) return null;
|
|
@@ -130,5 +137,6 @@ export function grokFenceEndpointDrift(
|
|
|
130
137
|
return null;
|
|
131
138
|
}
|
|
132
139
|
if (!Number.isFinite(fencePort) || fencePort === livePort) return null;
|
|
140
|
+
if (typeof loopbackPort === "number" && fencePort === loopbackPort) return null;
|
|
133
141
|
return { fencePort, livePort };
|
|
134
142
|
}
|
|
@@ -256,7 +256,10 @@ export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "d
|
|
|
256
256
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
|
|
257
257
|
}
|
|
258
258
|
},
|
|
259
|
-
writeText: (path, text) =>
|
|
259
|
+
writeText: (path, text) => {
|
|
260
|
+
assertIntegrationWriteOwnership(path);
|
|
261
|
+
atomicWriteFile(path, text);
|
|
262
|
+
},
|
|
260
263
|
removeFile: path => rmSync(path, { force: true }),
|
|
261
264
|
mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }),
|
|
262
265
|
now: () => Date.now(),
|
|
@@ -282,3 +285,53 @@ export function defaultIntegrationIO(store: {
|
|
|
282
285
|
dropRecord: clientId => store.dropRecord(clientId),
|
|
283
286
|
};
|
|
284
287
|
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Refuse to replace an integration file this process does not own.
|
|
291
|
+
*
|
|
292
|
+
* `atomicWriteFile` writes a private temp file and renames it over the target. That is the right
|
|
293
|
+
* shape for a secret — the replacement is atomic and the result is owner-only `0600` — but it also
|
|
294
|
+
* means the surviving inode belongs to whoever runs opencodex. When the target is another product's
|
|
295
|
+
* configuration on a shared mount, the replace quietly takes the file away from its owner. #4197 is
|
|
296
|
+
* that case: opencodex at uid 1000 replaces a DSH `settings.yaml` owned by uid 987, and DSH dies with
|
|
297
|
+
* `EACCES` on its next read while the restore call reports success.
|
|
298
|
+
*
|
|
299
|
+
* Preserving the previous uid would need a `chown` capability we usually do not have, and relaxing
|
|
300
|
+
* `0600` would weaken every integration to fix one. So refuse before writing, and say what is wrong,
|
|
301
|
+
* rather than succeeding into a broken state.
|
|
302
|
+
*
|
|
303
|
+
* Windows has no uid model here; `hardenSecretPath` owns that platform, so the check is skipped when
|
|
304
|
+
* the runtime exposes no effective uid.
|
|
305
|
+
*/
|
|
306
|
+
export function assertIntegrationWriteOwnership(
|
|
307
|
+
path: string,
|
|
308
|
+
deps: {
|
|
309
|
+
effectiveUid?: () => number | undefined;
|
|
310
|
+
ownerUid?: (target: string) => number | undefined;
|
|
311
|
+
} = {},
|
|
312
|
+
): void {
|
|
313
|
+
const effectiveUid = deps.effectiveUid ?? (() =>
|
|
314
|
+
typeof process.geteuid === "function" ? process.geteuid() : undefined);
|
|
315
|
+
const euid = effectiveUid();
|
|
316
|
+
if (euid === undefined) return;
|
|
317
|
+
|
|
318
|
+
const ownerUid = deps.ownerUid ?? ((target: string) => {
|
|
319
|
+
try {
|
|
320
|
+
return statSync(target).uid;
|
|
321
|
+
} catch {
|
|
322
|
+
// An absent or unreadable target has no owner to dispossess; the write itself will report
|
|
323
|
+
// any real failure.
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const owner = ownerUid(path);
|
|
328
|
+
if (owner === undefined || owner === euid) return;
|
|
329
|
+
|
|
330
|
+
throw new Error(
|
|
331
|
+
`refusing to replace ${path}: it belongs to uid ${owner} while opencodex runs as uid ${euid}. `
|
|
332
|
+
+ "An atomic replace would transfer ownership of that file and leave its owner unable to read "
|
|
333
|
+
+ "its own configuration. Run both under the same user, or give each one its own copy instead "
|
|
334
|
+
+ "of sharing the mount.",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
package/src/lib/bun-runtime.ts
CHANGED
|
@@ -176,7 +176,7 @@ export function durableBunRuntime(): DurableBunRuntime {
|
|
|
176
176
|
/**
|
|
177
177
|
* Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
|
|
178
178
|
* the Codex auto-start shim). Prefer the bundled binary — it lives under the
|
|
179
|
-
*
|
|
179
|
+
* manager-owned global package directory and survives across `ocx update` — and fall back to the
|
|
180
180
|
* current runtime, which is Bun when launched normally.
|
|
181
181
|
*/
|
|
182
182
|
export function durableBunPath(): string {
|
|
@@ -38,6 +38,33 @@ export function canonicalGuiBrowserOrigin(value: unknown): string | null {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A bare http(s) origin, or null.
|
|
43
|
+
*
|
|
44
|
+
* Stricter than {@link canonicalGuiBrowserOrigin}: that one also accepts non-HTTP schemes
|
|
45
|
+
* (a packaged app's custom scheme can be a browser origin), while this is the rule for an
|
|
46
|
+
* origin that will be DIALLED — the hub's management and data origins, and the `serverOrigin`
|
|
47
|
+
* a pairing grant is bound to. Credentials, a path, a query or a fragment are all rejected
|
|
48
|
+
* rather than silently dropped, because every caller goes on to print or compare the result.
|
|
49
|
+
*
|
|
50
|
+
* Exported here, beside the browser-origin canonicaliser, because this file is the only
|
|
51
|
+
* module the CLI pairing path and the hub command already share. `src/config.ts` and
|
|
52
|
+
* `src/server/gui-session.ts` still hold byte-identical private copies; folding those in
|
|
53
|
+
* would pull a heavy config import into a server security-boundary file, so it is its own
|
|
54
|
+
* change rather than a drive-by in a token-UX PR.
|
|
55
|
+
*/
|
|
56
|
+
export function canonicalHttpOrigin(value: unknown): string | null {
|
|
57
|
+
if (typeof value !== "string") return null;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = new URL(value);
|
|
60
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
61
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
62
|
+
return parsed.origin;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
function capabilityPayload(
|
|
42
69
|
nonce: string,
|
|
43
70
|
method: string,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a process running ON THE HUB ITSELF dials the hub (#4236).
|
|
3
|
+
*
|
|
4
|
+
* There are TWO destinations here, not one base URL substituted everywhere, and conflating
|
|
5
|
+
* them is what broke every local integration on a tailnet-bound hub:
|
|
6
|
+
*
|
|
7
|
+
* 1. `localManagementOrigin` — authenticated management discovery/state (`/api/*`). It is
|
|
8
|
+
* served by the public listener and, on a hub, additionally by the loopback-only
|
|
9
|
+
* `hub.managementIngress`. Callers must still send a management credential: management
|
|
10
|
+
* authentication has no loopback bypass (structure/05), and the unauthenticated loopback
|
|
11
|
+
* listener deliberately does not serve `/api/*` at all.
|
|
12
|
+
* 2. `localInferenceDestination` — the data plane a client wire actually speaks.
|
|
13
|
+
*
|
|
14
|
+
* Both resolvers have the SAME three-branch shape, because the bind address is the thing that
|
|
15
|
+
* decides. The first review of this module got that wrong for inference: it returned
|
|
16
|
+
* `http://127.0.0.1:<public port>` unconditionally whenever the loopback listener was off, so a
|
|
17
|
+
* hub with `hostname: <tailnet IP>` and no listener handed all eight call sites a socket that
|
|
18
|
+
* does not exist. The bind address has to be the fallback, exactly as it already was for
|
|
19
|
+
* management:
|
|
20
|
+
*
|
|
21
|
+
* loopback listener enabled → `127.0.0.1:<effective listener port>`, no credential
|
|
22
|
+
* loopback/absent `hostname` → `127.0.0.1:<public port>`, no credential
|
|
23
|
+
* wildcard `hostname` → `127.0.0.1:<public port>`, ADMISSION CREDENTIAL REQUIRED
|
|
24
|
+
* anything else → `<probeHostname(hostname)>:<public port>`, credential REQUIRED
|
|
25
|
+
*
|
|
26
|
+
* A wildcard bind does answer on 127.0.0.1, which is why its origin stays loopback, but the
|
|
27
|
+
* public listener demands data-plane admission regardless of which address received the
|
|
28
|
+
* request — so it lands in the same credential bucket as a tailnet bind. That is why the
|
|
29
|
+
* resolver returns a STRUCT rather than a string: a caller that cannot see
|
|
30
|
+
* `requiresAdmissionToken` cannot tell a free socket from one that will 401, and the only
|
|
31
|
+
* honest answers are "attach the data-plane credential" or "say so in a log line".
|
|
32
|
+
*
|
|
33
|
+
* The credential in question is the DATA-PLANE one — `OPENCODEX_API_AUTH_TOKEN`, the hardened
|
|
34
|
+
* service token file, or a configured `apiKeys` entry, the same ladder
|
|
35
|
+
* `standaloneCodexRoutingTarget` / the Codex provider table already uses. Never the admin
|
|
36
|
+
* token: no exported client configuration may carry management authority (reviewer constraint
|
|
37
|
+
* on #4236).
|
|
38
|
+
*/
|
|
39
|
+
import { effectiveLoopbackListenerPort, isLoopbackHostname, isWildcardHostname, shouldInjectApiAuthHeader } from "../codex/loopback-target";
|
|
40
|
+
import { probeHostname } from "../server/proxy-liveness";
|
|
41
|
+
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "./service-secrets";
|
|
42
|
+
import type { OcxConfig } from "../types";
|
|
43
|
+
|
|
44
|
+
export type LocalInferenceConfig = Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener">;
|
|
45
|
+
export type LocalManagementConfig = Pick<OcxConfig, "hostname" | "runtimeRole" | "hub">;
|
|
46
|
+
|
|
47
|
+
export interface LocalInferenceDestination {
|
|
48
|
+
/** Origin a local client wire dials, e.g. `http://127.0.0.1:10104`. */
|
|
49
|
+
origin: string;
|
|
50
|
+
/** Port component of `origin`. */
|
|
51
|
+
port: number;
|
|
52
|
+
/**
|
|
53
|
+
* Does the listener at `origin` demand `x-opencodex-api-key`?
|
|
54
|
+
*
|
|
55
|
+
* False only for the unauthenticated loopback listener and a genuinely loopback public bind.
|
|
56
|
+
* A caller that cannot attach a credential must log that it is degrading rather than write a
|
|
57
|
+
* destination that answers 401.
|
|
58
|
+
*/
|
|
59
|
+
requiresAdmissionToken: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The one answer for "where does a local client send inference, and does it need a key?".
|
|
64
|
+
*
|
|
65
|
+
* Kept in one place so the day the resolution changes a forgotten site cannot point a client
|
|
66
|
+
* config at a closed socket — which is precisely the defect this module exists to close.
|
|
67
|
+
*/
|
|
68
|
+
export function localInferenceDestination(
|
|
69
|
+
config: LocalInferenceConfig | undefined,
|
|
70
|
+
publicPort: number,
|
|
71
|
+
): LocalInferenceDestination {
|
|
72
|
+
const listenerPort = effectiveLoopbackListenerPort(config, publicPort);
|
|
73
|
+
if (listenerPort !== null) {
|
|
74
|
+
// Always bound to 127.0.0.1 and admits local callers with no credential, which is what
|
|
75
|
+
// lets an exported client configuration stay credential-free.
|
|
76
|
+
return { origin: `http://127.0.0.1:${listenerPort}`, port: listenerPort, requiresAdmissionToken: false };
|
|
77
|
+
}
|
|
78
|
+
const hostname = config?.hostname;
|
|
79
|
+
// A loopback bind keeps the byte-identical string every call site wrote before this module
|
|
80
|
+
// existed, including the `localhost`/`::1` spellings that `probeHostname` would preserve.
|
|
81
|
+
if (isLoopbackHostname(hostname)) {
|
|
82
|
+
return { origin: `http://127.0.0.1:${publicPort}`, port: publicPort, requiresAdmissionToken: false };
|
|
83
|
+
}
|
|
84
|
+
// `probeHostname` turns every all-zero spelling into 127.0.0.1 and brackets a bare IPv6
|
|
85
|
+
// literal; `shouldInjectApiAuthHeader` is the existing encoding of "this bind demands a
|
|
86
|
+
// data-plane credential", so the two stay in agreement by construction.
|
|
87
|
+
return {
|
|
88
|
+
origin: `http://${probeHostname(hostname)}:${publicPort}`,
|
|
89
|
+
port: publicPort,
|
|
90
|
+
requiresAdmissionToken: shouldInjectApiAuthHeader(config),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every port this proxy's data plane answers on at 127.0.0.1 — the set an inherited
|
|
96
|
+
* `http://127.0.0.1:<port>` base URL must hit to count as one of OURS.
|
|
97
|
+
*
|
|
98
|
+
* On a tailnet bind with no loopback listener the set is EMPTY, and that is the point: a
|
|
99
|
+
* leftover `http://127.0.0.1:10100` from a previous loopback-bound install is a dead socket
|
|
100
|
+
* there, so `ocx claude` must replace it rather than preserve it as its own destination.
|
|
101
|
+
*/
|
|
102
|
+
export function localLoopbackInferencePorts(
|
|
103
|
+
config: LocalInferenceConfig | undefined,
|
|
104
|
+
publicPort: number,
|
|
105
|
+
): number[] {
|
|
106
|
+
const ports: number[] = [];
|
|
107
|
+
// A wildcard bind owns loopback on the public port too, so a URL naming it is still ours.
|
|
108
|
+
if (isLoopbackHostname(config?.hostname) || isWildcardHostname(config?.hostname)) ports.push(publicPort);
|
|
109
|
+
const listenerPort = effectiveLoopbackListenerPort(config, publicPort);
|
|
110
|
+
if (listenerPort !== null && !ports.includes(listenerPort)) ports.push(listenerPort);
|
|
111
|
+
return ports;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The DATA-PLANE admission credential this host can present to its own public listener.
|
|
116
|
+
*
|
|
117
|
+
* Same ladder the Codex provider table and `ocx opencode` already use — environment token,
|
|
118
|
+
* hardened service token file, first configured `apiKeys` entry — and deliberately NOT the
|
|
119
|
+
* admin token, which must never leave the management surface. `undefined` means the caller has
|
|
120
|
+
* nothing to attach and has to degrade loudly.
|
|
121
|
+
*/
|
|
122
|
+
export function localAdmissionToken(
|
|
123
|
+
config: Pick<OcxConfig, "apiKeys"> | undefined,
|
|
124
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
125
|
+
): string | undefined {
|
|
126
|
+
const envToken = env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
127
|
+
if (envToken) return envToken;
|
|
128
|
+
const lookup = env.OCX_API_TOKEN_FILE?.trim()
|
|
129
|
+
? env
|
|
130
|
+
: { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
|
|
131
|
+
const fileToken = loadServiceTokenFromFile(lookup as Record<string, string | undefined>)?.trim();
|
|
132
|
+
// Shape-check the FILE candidate only. The env var and `apiKeys` are values an operator set
|
|
133
|
+
// deliberately and pass through verbatim; a path, by contrast, can be pointed at or replaced
|
|
134
|
+
// by something that is not a credential at all, and sending that as one leaks file contents
|
|
135
|
+
// into a request header. Configured keys are never checked, so no existing key can be broken.
|
|
136
|
+
if (fileToken && ADMISSION_TOKEN_SHAPE.test(fileToken)) return fileToken;
|
|
137
|
+
const configured = config?.apiKeys?.find(entry => entry.key.trim().length > 0)?.key.trim();
|
|
138
|
+
return configured || undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** An admission credential is an opaque printable token — never JSON, a path, or multi-line. */
|
|
142
|
+
const ADMISSION_TOKEN_SHAPE = /^[A-Za-z0-9._~+/=-]{8,4096}$/;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The origin a local CLI dials for `/api/*`.
|
|
146
|
+
*
|
|
147
|
+
* A hub's management ingress is loopback-only and exists precisely so the operator's own
|
|
148
|
+
* machine has a management address when the proxy listener is bound elsewhere. Everything else
|
|
149
|
+
* keeps dialing the public listener on the bind address it can actually reach — `probeHostname`
|
|
150
|
+
* turns a wildcard bind into 127.0.0.1 and brackets a bare IPv6 literal.
|
|
151
|
+
*
|
|
152
|
+
* The caller still supplies the management credential. Never write that credential into an
|
|
153
|
+
* exported client configuration.
|
|
154
|
+
*/
|
|
155
|
+
export function localManagementOrigin(
|
|
156
|
+
config: LocalManagementConfig | undefined,
|
|
157
|
+
publicPort: number,
|
|
158
|
+
): string {
|
|
159
|
+
const ingress = config?.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
|
|
160
|
+
if (ingress?.enabled) return `http://127.0.0.1:${ingress.port}`;
|
|
161
|
+
return `http://${probeHostname(config?.hostname)}:${publicPort}`;
|
|
162
|
+
}
|
|
@@ -16,7 +16,7 @@ export interface PackageTreeIntegrityGuard {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
type ObservePackageTree = () => PackageTreeObservation | null;
|
|
19
|
-
type PackageTreeRuntimeInstall = "bun" | "npm" | "source";
|
|
19
|
+
type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
|
|
20
20
|
|
|
21
21
|
const packageManifestUrl = new URL("../../package.json", import.meta.url);
|
|
22
22
|
|
|
@@ -80,17 +80,96 @@ export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";
|
|
|
80
80
|
*/
|
|
81
81
|
let lastRefusalMessage: string | null = null;
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The server's machine-readable reason for the most recent 409, captured alongside the
|
|
85
|
+
* message so a refusal that arrives without a body still names the right cause. Without it
|
|
86
|
+
* the fallback has to guess, and guessing "ownership" sent operators to re-check
|
|
87
|
+
* CODEX_HOME for a refusal the scheduler wrapper had issued (#4169).
|
|
88
|
+
*/
|
|
89
|
+
let lastRefusalCode: string | null = null;
|
|
90
|
+
|
|
83
91
|
/** The server's explanation for the most recent 409, or `null` when it sent none. */
|
|
84
92
|
export function lastStopRefusalMessage(): string | null {
|
|
85
93
|
return lastRefusalMessage;
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/** The server's `code` for the most recent 409, or `null` when it sent none. */
|
|
97
|
+
export function lastStopRefusalCode(): string | null {
|
|
98
|
+
return lastRefusalCode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Wording for a refusal whose body carried no message. Each branch mirrors a refusal the
|
|
103
|
+
* management API can return from `POST /api/stop`; the default stays cause-neutral because
|
|
104
|
+
* naming the wrong cause is worse than naming none — it costs the operator the time they
|
|
105
|
+
* spend acting on it.
|
|
106
|
+
*
|
|
107
|
+
* These name the cause only. The command belongs to {@link refusalNextStep}, because the
|
|
108
|
+
* only callers of `stopProxy` are `ocx stop` and the service manager's own cleanup, and a
|
|
109
|
+
* message that told either of them to run `ocx stop` would be the #4169 loop again.
|
|
110
|
+
*/
|
|
111
|
+
function refusalFallbackMessage(code: string | null): string {
|
|
112
|
+
switch (code) {
|
|
113
|
+
case "respawnable_service":
|
|
114
|
+
return "The running proxy refused to stop: a service manager that can respawn it owns "
|
|
115
|
+
+ "the process.";
|
|
116
|
+
case "self_unload_service":
|
|
117
|
+
return "The running proxy refused to stop: it is the installed service itself, so "
|
|
118
|
+
+ "stopping the manager from inside it would end the process before native Codex is "
|
|
119
|
+
+ "restored.";
|
|
120
|
+
case "service_state_unknown":
|
|
121
|
+
return "The running proxy refused to stop: the service manager state could not be read, "
|
|
122
|
+
+ "so it cannot tell whether a wrapper would respawn it.";
|
|
123
|
+
default:
|
|
124
|
+
return "The running proxy refused to stop and sent no reason.";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What is actually left to do when `ocx stop` is the command that received the refusal.
|
|
130
|
+
*
|
|
131
|
+
* Every refusal `POST /api/stop` produces is written for an API client, so it recommends
|
|
132
|
+
* `ocx stop` — which is the command already running when the CLI prints it. That is the
|
|
133
|
+
* loop #4169 reports: the endpoint points at `ocx stop`, `ocx stop` repeats the endpoint,
|
|
134
|
+
* and neither names the wrapper that is refusing. `ocx stop` has already asked the service
|
|
135
|
+
* manager to stop by the time this is reached, so the remaining question is always what the
|
|
136
|
+
* service manager is doing, and no branch may answer with the command that just failed.
|
|
137
|
+
*/
|
|
138
|
+
export function refusalNextStep(code: string | null): string {
|
|
139
|
+
switch (code) {
|
|
140
|
+
case "respawnable_service":
|
|
141
|
+
return "This stop already asked the service manager to stop, so running `ocx stop` "
|
|
142
|
+
+ "again is not the missing step. Run `ocx service status` to see whether a wrapper "
|
|
143
|
+
+ "is still installed and able to respawn the proxy.";
|
|
144
|
+
case "self_unload_service":
|
|
145
|
+
return "This stop already asked the service manager to stop, so running `ocx stop` "
|
|
146
|
+
+ "again is not the missing step. Run `ocx service status` to see whether the service "
|
|
147
|
+
+ "is still registered.";
|
|
148
|
+
case "service_state_unknown":
|
|
149
|
+
return "Run `ocx service status` to see the query error, repair the service manager "
|
|
150
|
+
+ "access, then retry.";
|
|
151
|
+
default:
|
|
152
|
+
return "Run `ocx service status` to inspect the service state.";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
88
156
|
/**
|
|
89
157
|
* A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a
|
|
90
158
|
* scheduler wrapper under another home, or the proxy being the installed service itself
|
|
91
159
|
* (#4023) — so the server's own message is carried through rather than guessed at.
|
|
160
|
+
*
|
|
161
|
+
* The refusal's `code` travels on the error because the reporting caller has to act on the
|
|
162
|
+
* cause, not re-parse prose: the message is the server's, and it recommends a command the
|
|
163
|
+
* CLI has already run.
|
|
92
164
|
*/
|
|
93
|
-
export class ProxyOwnershipRefusedError extends Error {
|
|
165
|
+
export class ProxyOwnershipRefusedError extends Error {
|
|
166
|
+
readonly code: string | null;
|
|
167
|
+
|
|
168
|
+
constructor(message: string, code: string | null = null) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.code = code;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
94
173
|
|
|
95
174
|
/**
|
|
96
175
|
* Ask a running proxy to stop itself via the management API (`POST /api/stop`), which
|
|
@@ -104,9 +183,28 @@ export class ProxyOwnershipRefusedError extends Error {}
|
|
|
104
183
|
* attest the process exit code or completion of every drain/shutdown hook.
|
|
105
184
|
*/
|
|
106
185
|
export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
|
|
186
|
+
return (await stopProxyGracefullyDetailed(pid, io)).result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The refusal a single stop attempt received, carried back to that attempt's caller.
|
|
191
|
+
*
|
|
192
|
+
* Module-scoped state cannot do this job: two overlapping stops race, and the first would
|
|
193
|
+
* report the second's cause. The exported accessors stay as observational state for callers
|
|
194
|
+
* that only want the last refusal, but the error text is built from this per-call value.
|
|
195
|
+
*/
|
|
196
|
+
type StopRefusal = { message: string | null; code: string | null };
|
|
197
|
+
|
|
198
|
+
async function stopProxyGracefullyDetailed(
|
|
199
|
+
pid: number,
|
|
200
|
+
io: GracefulStopIo = {},
|
|
201
|
+
): Promise<{ result: GracefulStopResult; refusal: StopRefusal }> {
|
|
202
|
+
const refusal: StopRefusal = { message: null, code: null };
|
|
203
|
+
const done = (result: GracefulStopResult): { result: GracefulStopResult; refusal: StopRefusal } =>
|
|
204
|
+
({ result, refusal });
|
|
107
205
|
const readRuntime = io.readRuntime ?? readRuntimePort;
|
|
108
206
|
const runtime = io.runtimeEndpoint ?? readRuntime(pid);
|
|
109
|
-
if (!runtime?.port) return false;
|
|
207
|
+
if (!runtime?.port) return done(false);
|
|
110
208
|
const env = io.env ?? process.env;
|
|
111
209
|
const headers: Record<string, string> = {};
|
|
112
210
|
const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
|
|
@@ -128,20 +226,31 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
128
226
|
// longer than a health poll so we prefer drain over taskkill /F.
|
|
129
227
|
signal: AbortSignal.timeout(io.exitTimeoutMs ? Math.min(io.exitTimeoutMs, 10_000) : 10_000),
|
|
130
228
|
});
|
|
131
|
-
// 409 is the proxy REFUSING to stop
|
|
132
|
-
//
|
|
133
|
-
//
|
|
229
|
+
// 409 is the proxy REFUSING to stop. There is more than one reason it can say no — a
|
|
230
|
+
// respawning service manager, the proxy being the installed service itself, or an
|
|
231
|
+
// unreadable scheduler state — so both the message and the code are captured rather
|
|
232
|
+
// than assumed. That is a policy answer, not a dead endpoint — escalating to SIGTERM
|
|
233
|
+
// here would run the daemon's cleanup and strip shared config out from under the
|
|
134
234
|
// still-running service. Report the refusal instead of forcing.
|
|
135
235
|
if (res.status === 409) {
|
|
136
|
-
|
|
236
|
+
const parsed = await res.json()
|
|
137
237
|
.then(body => {
|
|
138
|
-
const
|
|
139
|
-
|
|
238
|
+
const record = body as { message?: unknown; code?: unknown } | null;
|
|
239
|
+
const message = record?.message;
|
|
240
|
+
const code = record?.code;
|
|
241
|
+
return {
|
|
242
|
+
message: typeof message === "string" && message.trim() ? message.trim() : null,
|
|
243
|
+
code: typeof code === "string" && code.trim() ? code.trim() : null,
|
|
244
|
+
};
|
|
140
245
|
})
|
|
141
|
-
.catch(() => null);
|
|
142
|
-
|
|
246
|
+
.catch(() => ({ message: null, code: null }));
|
|
247
|
+
refusal.message = parsed.message;
|
|
248
|
+
refusal.code = parsed.code;
|
|
249
|
+
lastRefusalMessage = parsed.message;
|
|
250
|
+
lastRefusalCode = parsed.code;
|
|
251
|
+
return done("refused");
|
|
143
252
|
}
|
|
144
|
-
if (!res.ok) return false;
|
|
253
|
+
if (!res.ok) return done(false);
|
|
145
254
|
const body: unknown = await res.json().catch(() => null);
|
|
146
255
|
const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
|
|
147
256
|
sharedTeardownConfirmed = body !== null
|
|
@@ -150,14 +259,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
150
259
|
&& "success" in body && body.success === true
|
|
151
260
|
&& "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
|
|
152
261
|
} catch {
|
|
153
|
-
return false;
|
|
262
|
+
return done(false);
|
|
154
263
|
}
|
|
155
264
|
const waitExit = io.waitExit ?? waitForExit;
|
|
156
265
|
// Honor the server's own drain window: /api/stop answers 200 first, then drains for
|
|
157
266
|
// config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
|
|
158
267
|
const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
|
|
159
|
-
if (!waitExit(pid, exitTimeoutMs)) return false;
|
|
160
|
-
return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
|
|
268
|
+
if (!waitExit(pid, exitTimeoutMs)) return done(false);
|
|
269
|
+
return done(sharedTeardownConfirmed ? true : "teardown-unconfirmed");
|
|
161
270
|
}
|
|
162
271
|
|
|
163
272
|
function drainDeadlineMs(): number {
|
|
@@ -172,14 +281,15 @@ function drainDeadlineMs(): number {
|
|
|
172
281
|
export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
|
|
173
282
|
if (!isProcessAlive(pid)) return false;
|
|
174
283
|
const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
|
|
175
|
-
const graceful = await
|
|
284
|
+
const { result: graceful, refusal } = await stopProxyGracefullyDetailed(pid, io);
|
|
176
285
|
if (graceful === "refused") {
|
|
177
|
-
// The proxy refused on purpose
|
|
178
|
-
//
|
|
286
|
+
// The proxy refused on purpose. Forcing would strip shared config while whatever owns
|
|
287
|
+
// the process keeps it alive. The server's own message is preferred; the fallback is
|
|
288
|
+
// selected from its code so an empty body still names the right cause. Both come from
|
|
289
|
+
// THIS attempt, so an overlapping stop cannot lend it the wrong reason.
|
|
179
290
|
throw new ProxyOwnershipRefusedError(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
+ "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
|
|
291
|
+
refusal.message ?? refusalFallbackMessage(refusal.code),
|
|
292
|
+
refusal.code,
|
|
183
293
|
);
|
|
184
294
|
}
|
|
185
295
|
if (graceful === "teardown-unconfirmed") {
|
|
@@ -184,6 +184,34 @@ export function loadServiceTokenFromFile(env: Record<string, string | undefined>
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The data-plane token a boot should export, or null when the environment already has one
|
|
189
|
+
* (or there is nothing to export).
|
|
190
|
+
*
|
|
191
|
+
* The launchd plist and the systemd unit `cat` the token file into the environment before
|
|
192
|
+
* exec'ing the proxy, and WinSW native mode names it through `OCX_API_TOKEN_FILE` — so under
|
|
193
|
+
* a service the server has always seen `OPENCODEX_API_AUTH_TOKEN` regardless of the calling
|
|
194
|
+
* shell. A FOREGROUND `ocx start` on the same machine had neither, so `assertServerAuthConfig`
|
|
195
|
+
* refused to bind a non-loopback hostname that the installed service was serving happily.
|
|
196
|
+
* This closes that gap with the same precedence the wrappers use, in one place.
|
|
197
|
+
*
|
|
198
|
+
* `authRequired` is the caller's admission decision (`isApiAuthRequired`), passed in rather
|
|
199
|
+
* than recomputed: this module must not load config, and the installed file is deliberately
|
|
200
|
+
* NOT consulted on a loopback bind — on a machine connected to a hub it holds that hub's
|
|
201
|
+
* issued client key, which is not this proxy's admission secret.
|
|
202
|
+
*/
|
|
203
|
+
export function startupDataPlaneToken(
|
|
204
|
+
env: Record<string, string | undefined>,
|
|
205
|
+
options: { authRequired: boolean },
|
|
206
|
+
): string | null {
|
|
207
|
+
if (env.OPENCODEX_API_AUTH_TOKEN?.trim()) return null;
|
|
208
|
+
const named = loadServiceTokenFromFile(env);
|
|
209
|
+
if (named) return named;
|
|
210
|
+
if (!options.authRequired) return null;
|
|
211
|
+
const state = readServiceApiTokenState();
|
|
212
|
+
return state.kind === "present" ? state.token : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
187
215
|
/**
|
|
188
216
|
* Contents of the installed service token file. The launch wrapper always re-exports
|
|
189
217
|
* this file as OPENCODEX_API_AUTH_TOKEN, so doctor and start must inspect it even
|
|
@@ -61,6 +61,16 @@ function canonicalize(path: string): string {
|
|
|
61
61
|
const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
|
|
62
62
|
const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
|
|
63
63
|
const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
|
|
64
|
+
/**
|
|
65
|
+
* `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
|
|
66
|
+
* `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
|
|
67
|
+
* HOME still resolves `plistPath()` to the developer's real LaunchAgents directory. The
|
|
68
|
+
* launchd install tests were doing exactly that — replacing the live
|
|
69
|
+
* `com.opencodex.proxy.plist` with one whose token file, log path and Bun paths all point
|
|
70
|
+
* into a temp sandbox, for as long as the case ran. launchd holds its own parsed copy, so
|
|
71
|
+
* nothing broke until the job next restarted.
|
|
72
|
+
*/
|
|
73
|
+
const PROTECTED_LAUNCH_AGENTS = canonicalize(join(REAL_HOME, "Library", "LaunchAgents"));
|
|
64
74
|
|
|
65
75
|
/** The production home this process protects. Exported for the guard's own tests. */
|
|
66
76
|
export function protectedHomeForTests(): string {
|
|
@@ -76,6 +86,23 @@ export function isTestHomeGuardArmed(): boolean {
|
|
|
76
86
|
return process.env[GUARD_ENV] === "1";
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Whether `dir` IS the protected production home, decided with the SAME canonicalization as
|
|
91
|
+
* {@link assertNotRealHomeUnderTest}.
|
|
92
|
+
*
|
|
93
|
+
* For the caller that must FILTER the real home out of a candidate list instead of refusing
|
|
94
|
+
* one write: `serviceStatePaths()` in `src/service.ts` keeps a legacy
|
|
95
|
+
* `~/.opencodex/service-state.json` entry so an install made before OPENCODEX_HOME existed
|
|
96
|
+
* can still be found, and under an armed test process that entry is the developer's live
|
|
97
|
+
* record. Exported so that filter cannot drift onto a weaker comparison — `resolve()` alone
|
|
98
|
+
* calls `/var/folders/...` and `/private/var/folders/...` different paths, which is exactly
|
|
99
|
+
* how a macOS sandbox path slips past a string compare.
|
|
100
|
+
*/
|
|
101
|
+
export function isProtectedHomeUnderTest(dir: string): boolean {
|
|
102
|
+
if (!isTestHomeGuardArmed()) return false;
|
|
103
|
+
return canonicalize(dir) === PROTECTED_HOME;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
/**
|
|
80
107
|
* Throw when an armed test process is about to write the real OpenCodex home.
|
|
81
108
|
*
|
|
@@ -94,6 +121,28 @@ export function assertNotRealHomeUnderTest(dir: string): void {
|
|
|
94
121
|
);
|
|
95
122
|
}
|
|
96
123
|
|
|
124
|
+
/** The production LaunchAgents directory this process protects. Exported for its tests. */
|
|
125
|
+
export function protectedLaunchAgentsDirForTests(): string {
|
|
126
|
+
return PROTECTED_LAUNCH_AGENTS;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Throw when an armed test process is about to write the real `~/Library/LaunchAgents`.
|
|
131
|
+
*
|
|
132
|
+
* Same contract as {@link assertNotRealHomeUnderTest}: call before any mkdir/write, and
|
|
133
|
+
* pass a DIRECTORY. A launchd test gives `installLaunchd` an explicit plist path inside its
|
|
134
|
+
* own fixture directory instead.
|
|
135
|
+
*/
|
|
136
|
+
export function assertNotRealLaunchAgentsUnderTest(dir: string): void {
|
|
137
|
+
if (!isTestHomeGuardArmed()) return;
|
|
138
|
+
if (canonicalize(dir) !== PROTECTED_LAUNCH_AGENTS) return;
|
|
139
|
+
throw new Error(
|
|
140
|
+
`refusing to write the real LaunchAgents directory (${PROTECTED_LAUNCH_AGENTS}) from a test `
|
|
141
|
+
+ "process: os.homedir() ignores HOME, so rewriting HOME does not move this path. Pass an "
|
|
142
|
+
+ "explicit plist path inside the test's own fixture directory instead.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
/** Throw when an armed test process is about to write the real native Codex home. */
|
|
98
147
|
export function assertNotRealCodexHomeUnderTest(dir: string): void {
|
|
99
148
|
if (!isTestHomeGuardArmed()) return;
|
|
@@ -22,7 +22,15 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
|
|
|
22
22
|
return `ocx_${digest}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Add Go affinity only to the canonical fixed-key destination.
|
|
27
|
+
*
|
|
28
|
+
* Callers on the request path resolve the lane with `getOrAllocateRequestSessionLane`, which returns
|
|
29
|
+
* real conversation identity when the client supplied it and a per-request value otherwise, so a
|
|
30
|
+
* request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays
|
|
31
|
+
* for direct callers that have no request context; it is not a per-request identity of its own, and
|
|
32
|
+
* minting one here would hand each retry a different value.
|
|
33
|
+
*/
|
|
26
34
|
export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
|
|
27
35
|
provider: T,
|
|
28
36
|
sessionLane: string | undefined,
|
package/src/providers/quota.ts
CHANGED
|
@@ -2906,7 +2906,11 @@ function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): K
|
|
|
2906
2906
|
if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota;
|
|
2907
2907
|
if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota;
|
|
2908
2908
|
if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota;
|
|
2909
|
-
|
|
2909
|
+
// #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI
|
|
2910
|
+
// Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the
|
|
2911
|
+
// canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a
|
|
2912
|
+
// lookalike host, so a same-named custom destination still dispatches nothing.
|
|
2913
|
+
if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
|
|
2910
2914
|
if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota;
|
|
2911
2915
|
if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota;
|
|
2912
2916
|
if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota;
|