@bitkyc08/opencodex 2.39.0 → 2.40.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 +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
import { atomicWriteFile, loadConfig } from "../config";
|
|
10
|
+
import { invalidateCodexModelsCache } from "../codex/catalog/sync";
|
|
11
|
+
import {
|
|
12
|
+
injectCodexConfig,
|
|
13
|
+
currentExternalCodexModelProvider,
|
|
14
|
+
isCodexRoutingInjected,
|
|
15
|
+
type CodexRoutingTarget,
|
|
16
|
+
} from "../codex/inject";
|
|
17
|
+
import {
|
|
18
|
+
journalOwner,
|
|
19
|
+
restoreJournalState,
|
|
20
|
+
} from "../codex/journal";
|
|
21
|
+
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
|
|
22
|
+
import {
|
|
23
|
+
readServiceApiTokenState,
|
|
24
|
+
readTokenBackupState,
|
|
25
|
+
removeServiceApiTokenFileIfOwned,
|
|
26
|
+
removeOrphanTokenBackup,
|
|
27
|
+
replaceServiceApiTokenFile,
|
|
28
|
+
restoreTokenBackup,
|
|
29
|
+
serviceApiTokenBackupPath,
|
|
30
|
+
writeTokenBackup,
|
|
31
|
+
writeServiceApiTokenFile,
|
|
32
|
+
} from "../lib/service-secrets";
|
|
33
|
+
import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
|
|
34
|
+
import type {
|
|
35
|
+
OcxClientConnectionConfig,
|
|
36
|
+
OcxConnectedClientId,
|
|
37
|
+
} from "../types";
|
|
38
|
+
import {
|
|
39
|
+
downloadClientCatalog,
|
|
40
|
+
abortClientKeyRotation,
|
|
41
|
+
commitClientKeyRotation,
|
|
42
|
+
exchangeConnectPairingGrant,
|
|
43
|
+
fetchHubReady,
|
|
44
|
+
HubClientError,
|
|
45
|
+
issueClientKey,
|
|
46
|
+
normalizeHubOrigin,
|
|
47
|
+
probeClientKeyId,
|
|
48
|
+
revokeClientKey,
|
|
49
|
+
startClientKeyRotation,
|
|
50
|
+
type ConnectGuiSession,
|
|
51
|
+
type IssuedClientKey,
|
|
52
|
+
type OneTimeConnectCredential,
|
|
53
|
+
} from "./hub-client";
|
|
54
|
+
import {
|
|
55
|
+
clearClientConnection,
|
|
56
|
+
commitClientConnection,
|
|
57
|
+
readClientConnectionState,
|
|
58
|
+
} from "./state";
|
|
59
|
+
|
|
60
|
+
class RotationRecoveryRequiredError extends Error {
|
|
61
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
62
|
+
super(message, options);
|
|
63
|
+
this.name = "RotationRecoveryRequiredError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConnectOptions {
|
|
68
|
+
serverUrl: string;
|
|
69
|
+
managementUrl?: string;
|
|
70
|
+
credential: OneTimeConnectCredential;
|
|
71
|
+
selectedClients: OcxConnectedClientId[];
|
|
72
|
+
managementTransport: "direct" | "relay";
|
|
73
|
+
noSync?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ClientConnectDeps {
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
now?: () => Date;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RotateClientOptions {
|
|
82
|
+
credential: OneTimeConnectCredential;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type CatalogSnapshot =
|
|
86
|
+
| { kind: "absent" }
|
|
87
|
+
| { kind: "file"; body: string; fingerprint: string };
|
|
88
|
+
|
|
89
|
+
function sha256(value: string): string {
|
|
90
|
+
return createHash("sha256").update(value).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function catalogSnapshot(): CatalogSnapshot {
|
|
94
|
+
if (!existsSync(DEFAULT_CATALOG_PATH)) return { kind: "absent" };
|
|
95
|
+
const stat = lstatSync(DEFAULT_CATALOG_PATH);
|
|
96
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_REMOTE_CATALOG_BYTES) {
|
|
97
|
+
throw new Error("existing OpenCodex catalog is not a bounded regular file");
|
|
98
|
+
}
|
|
99
|
+
const body = readFileSync(DEFAULT_CATALOG_PATH, "utf8");
|
|
100
|
+
return { kind: "file", body, fingerprint: sha256(body) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function restoreCatalogSnapshot(snapshot: CatalogSnapshot, writtenFingerprint: string): boolean {
|
|
104
|
+
try {
|
|
105
|
+
if (!existsSync(DEFAULT_CATALOG_PATH)) return snapshot.kind === "absent";
|
|
106
|
+
const stat = lstatSync(DEFAULT_CATALOG_PATH);
|
|
107
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_REMOTE_CATALOG_BYTES) return false;
|
|
108
|
+
const current = readFileSync(DEFAULT_CATALOG_PATH, "utf8");
|
|
109
|
+
if (sha256(current) !== writtenFingerprint) return false;
|
|
110
|
+
if (snapshot.kind === "absent") unlinkSync(DEFAULT_CATALOG_PATH);
|
|
111
|
+
else atomicWriteFile(DEFAULT_CATALOG_PATH, snapshot.body);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validLocalCatalog(): string {
|
|
119
|
+
const snapshot = catalogSnapshot();
|
|
120
|
+
if (snapshot.kind !== "file") throw new Error("connected catalog is missing");
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(snapshot.body) as unknown;
|
|
123
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error("connected catalog is malformed");
|
|
126
|
+
}
|
|
127
|
+
return snapshot.body;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Is the on-disk catalog still the one this connection wrote?
|
|
132
|
+
*
|
|
133
|
+
* Recorded as our own hash rather than the hub's ETag: /v1/catalog emits no validator
|
|
134
|
+
* (Phase 1, D2), so there is no server-supplied tag to keep. This is an ownership check on
|
|
135
|
+
* local bytes, which never needed the hub's participation — the previous spelling only
|
|
136
|
+
* looked like a cache concern because it reused the ETag string.
|
|
137
|
+
*/
|
|
138
|
+
function catalogMatchesFingerprint(body: string, fingerprint: string | undefined): boolean {
|
|
139
|
+
if (!fingerprint) return false;
|
|
140
|
+
return createHash("sha256").update(body).digest("base64url") === fingerprint;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function routingTarget(serverUrl: string): CodexRoutingTarget {
|
|
144
|
+
return {
|
|
145
|
+
baseUrl: `${serverUrl}/v1`,
|
|
146
|
+
requiresAdmissionToken: true,
|
|
147
|
+
tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function localGuiOrigin(): string {
|
|
152
|
+
const port = loadConfig().port;
|
|
153
|
+
return `http://localhost:${Number.isInteger(port) && port > 0 ? port : 10100}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clientKeyName(): string {
|
|
157
|
+
const raw = `ocx connect ${hostname() || "client"}`;
|
|
158
|
+
return raw.slice(0, 80);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function releaseCredential(credential: OneTimeConnectCredential): void {
|
|
162
|
+
credential.value.fill(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function rotationAuthority(
|
|
166
|
+
connection: OcxClientConnectionConfig,
|
|
167
|
+
options: RotateClientOptions,
|
|
168
|
+
deps: ClientConnectDeps,
|
|
169
|
+
): Promise<{ kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession }> {
|
|
170
|
+
if (options.credential.kind === "admin") return { kind: "admin", value: options.credential.value };
|
|
171
|
+
const session = await exchangeConnectPairingGrant(
|
|
172
|
+
connection.managementUrl,
|
|
173
|
+
localGuiOrigin(),
|
|
174
|
+
options.credential.value,
|
|
175
|
+
{ fetchImpl: deps.fetchImpl },
|
|
176
|
+
);
|
|
177
|
+
return { kind: "gui-session", value: session };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function clearRotationState(
|
|
181
|
+
connection: OcxClientConnectionConfig,
|
|
182
|
+
tokenFingerprint: string,
|
|
183
|
+
): OcxClientConnectionConfig {
|
|
184
|
+
const next = { ...connection, tokenFingerprint };
|
|
185
|
+
delete next.pendingOperation;
|
|
186
|
+
commitClientConnection(next);
|
|
187
|
+
return next;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function recoverRotationWithAuthority(
|
|
191
|
+
connection: OcxClientConnectionConfig,
|
|
192
|
+
authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
|
|
193
|
+
deps: ClientConnectDeps,
|
|
194
|
+
): Promise<OcxClientConnectionConfig> {
|
|
195
|
+
const pending = connection.pendingOperation;
|
|
196
|
+
if (!pending || pending.oldKeyBackupPath !== serviceApiTokenBackupPath()) {
|
|
197
|
+
throw new RotationRecoveryRequiredError("rotation recovery state is missing or invalid");
|
|
198
|
+
}
|
|
199
|
+
const current = readServiceApiTokenState();
|
|
200
|
+
const backup = readTokenBackupState();
|
|
201
|
+
if (current.kind !== "present" || backup.kind !== "present") {
|
|
202
|
+
throw new RotationRecoveryRequiredError(
|
|
203
|
+
"rotation recovery requires owner-only current and .prev token files; preserve both and rerun ocx connect rotate with transient authority",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
let currentAccepted: boolean;
|
|
207
|
+
let backupAccepted: boolean;
|
|
208
|
+
try {
|
|
209
|
+
[currentAccepted, backupAccepted] = await Promise.all([
|
|
210
|
+
probeClientKeyId(connection.serverUrl, current.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }),
|
|
211
|
+
probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }),
|
|
212
|
+
]);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new RotationRecoveryRequiredError(
|
|
215
|
+
"rotation recovery could not establish both key admissions; preserve service-api-token and .prev",
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (currentAccepted && backupAccepted) {
|
|
220
|
+
await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
|
|
221
|
+
const next = clearRotationState(connection, current.fingerprint);
|
|
222
|
+
removeOrphanTokenBackup();
|
|
223
|
+
return next;
|
|
224
|
+
}
|
|
225
|
+
if (currentAccepted && !backupAccepted) {
|
|
226
|
+
const next = clearRotationState(connection, current.fingerprint);
|
|
227
|
+
removeOrphanTokenBackup();
|
|
228
|
+
return next;
|
|
229
|
+
}
|
|
230
|
+
if (!currentAccepted && backupAccepted) {
|
|
231
|
+
const restored = restoreTokenBackup(pending.oldKeyBackupPath);
|
|
232
|
+
await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
|
|
233
|
+
const next = clearRotationState(connection, restored.fingerprint);
|
|
234
|
+
removeOrphanTokenBackup();
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
throw new RotationRecoveryRequiredError(
|
|
238
|
+
"both rotation candidates were rejected; preserve service-api-token and .prev and repair admission from the hub",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function recoverPendingClientRotation(
|
|
243
|
+
options: RotateClientOptions,
|
|
244
|
+
deps: ClientConnectDeps = {},
|
|
245
|
+
): Promise<OcxClientConnectionConfig> {
|
|
246
|
+
try {
|
|
247
|
+
const state = readClientConnectionState();
|
|
248
|
+
if (state.kind !== "connected" || !state.value.pendingOperation) {
|
|
249
|
+
throw new Error("no pending client key rotation to recover");
|
|
250
|
+
}
|
|
251
|
+
const authority = await rotationAuthority(state.value, options, deps);
|
|
252
|
+
return await recoverRotationWithAuthority(state.value, authority, deps);
|
|
253
|
+
} finally {
|
|
254
|
+
releaseCredential(options.credential);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function rotateConnectedClientKey(
|
|
259
|
+
options: RotateClientOptions,
|
|
260
|
+
deps: ClientConnectDeps = {},
|
|
261
|
+
): Promise<OcxClientConnectionConfig> {
|
|
262
|
+
let connection: OcxClientConnectionConfig | null = null;
|
|
263
|
+
let authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null;
|
|
264
|
+
let started: { rotationId: string; key: string; createdAt: string } | null = null;
|
|
265
|
+
let markerPersisted = false;
|
|
266
|
+
try {
|
|
267
|
+
const state = readClientConnectionState();
|
|
268
|
+
if (state.kind !== "connected") throw new Error(`connect rotate is available only while connected (${state.kind})`);
|
|
269
|
+
connection = state.value;
|
|
270
|
+
authority = await rotationAuthority(connection, options, deps);
|
|
271
|
+
if (connection.pendingOperation) return await recoverRotationWithAuthority(connection, authority, deps);
|
|
272
|
+
const current = readServiceApiTokenState();
|
|
273
|
+
if (current.kind !== "present" || current.fingerprint !== connection.tokenFingerprint) {
|
|
274
|
+
throw new Error(current.kind === "unsafe" ? current.reason : "connected service token ownership changed");
|
|
275
|
+
}
|
|
276
|
+
writeTokenBackup(current.fingerprint);
|
|
277
|
+
const rotation = await startClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, { fetchImpl: deps.fetchImpl });
|
|
278
|
+
started = { rotationId: rotation.rotationId, key: rotation.key, createdAt: rotation.createdAt };
|
|
279
|
+
const marked: OcxClientConnectionConfig = {
|
|
280
|
+
...connection,
|
|
281
|
+
pendingOperation: {
|
|
282
|
+
kind: "rotate",
|
|
283
|
+
rotationId: rotation.rotationId,
|
|
284
|
+
newKeyIssuedAt: rotation.createdAt,
|
|
285
|
+
oldKeyBackupPath: serviceApiTokenBackupPath(),
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
commitClientConnection(marked);
|
|
289
|
+
connection = marked;
|
|
290
|
+
markerPersisted = true;
|
|
291
|
+
const replacement = replaceServiceApiTokenFile(rotation.key);
|
|
292
|
+
if (!await probeClientKeyId(connection.serverUrl, rotation.key, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) {
|
|
293
|
+
throw new Error("new client key admission probe was refused");
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, rotation.rotationId, { fetchImpl: deps.fetchImpl });
|
|
297
|
+
} catch {
|
|
298
|
+
return await recoverRotationWithAuthority(connection, authority, deps);
|
|
299
|
+
}
|
|
300
|
+
const next = clearRotationState(connection, replacement.fingerprint);
|
|
301
|
+
removeOrphanTokenBackup();
|
|
302
|
+
return next;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof RotationRecoveryRequiredError) throw error;
|
|
305
|
+
if (connection && authority && started) {
|
|
306
|
+
if (markerPersisted && connection.pendingOperation) {
|
|
307
|
+
try {
|
|
308
|
+
// Abort FIRST, restore second.
|
|
309
|
+
//
|
|
310
|
+
// The old order restored the local token and then asked the hub to abort. If that
|
|
311
|
+
// abort failed transiently the process was left holding the old key locally while
|
|
312
|
+
// the hub still had a pending rotation for the new one — two sides disagreeing
|
|
313
|
+
// about which generation is current, with the failure surfaced only as "rollback
|
|
314
|
+
// was incomplete". Confirming the hub's state first means the local file is only
|
|
315
|
+
// rewound once the authority that decides it has agreed.
|
|
316
|
+
await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl });
|
|
317
|
+
const restored = restoreTokenBackup(connection.pendingOperation.oldKeyBackupPath);
|
|
318
|
+
clearRotationState(connection, restored.fingerprint);
|
|
319
|
+
removeOrphanTokenBackup();
|
|
320
|
+
} catch (recoveryError) {
|
|
321
|
+
// Both candidates and the pending marker stay on disk. Recovery cannot tell which
|
|
322
|
+
// generation is authoritative without the hub, so it preserves the evidence and
|
|
323
|
+
// names the command that carries the authority to ask.
|
|
324
|
+
throw new RotationRecoveryRequiredError(
|
|
325
|
+
"rotation rollback was incomplete; preserve service-api-token and .prev and rerun ocx connect rotate with transient authority",
|
|
326
|
+
{ cause: recoveryError },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
try { await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl }); }
|
|
331
|
+
finally { removeOrphanTokenBackup(); }
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
const backup = readTokenBackupState();
|
|
335
|
+
if (backup.kind === "present") removeOrphanTokenBackup();
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
} finally {
|
|
339
|
+
if (started) started.key = "";
|
|
340
|
+
authority = null;
|
|
341
|
+
releaseCredential(options.credential);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function cleanupIssuedKey(
|
|
346
|
+
managementUrl: string,
|
|
347
|
+
credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
|
|
348
|
+
issuedId: string,
|
|
349
|
+
deps: ClientConnectDeps,
|
|
350
|
+
): Promise<string | null> {
|
|
351
|
+
try {
|
|
352
|
+
await revokeClientKey(managementUrl, credential, issuedId, { fetchImpl: deps.fetchImpl });
|
|
353
|
+
return null;
|
|
354
|
+
} catch {
|
|
355
|
+
return `Hub cleanup could not revoke client key ${issuedId}; revoke it from Integrations → API Keys.`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function connectClient(
|
|
360
|
+
options: ConnectOptions,
|
|
361
|
+
deps: ClientConnectDeps = {},
|
|
362
|
+
): Promise<OcxClientConnectionConfig> {
|
|
363
|
+
let serverUrl = "";
|
|
364
|
+
let managementUrl = "";
|
|
365
|
+
let issued: IssuedClientKey | null = null;
|
|
366
|
+
let cleanupCredential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null;
|
|
367
|
+
let tokenFingerprint: string | null = null;
|
|
368
|
+
let priorCatalog: CatalogSnapshot | null = null;
|
|
369
|
+
let writtenCatalogFingerprint: string | null = null;
|
|
370
|
+
let injectionCommitted = false;
|
|
371
|
+
let committed = false;
|
|
372
|
+
try {
|
|
373
|
+
serverUrl = normalizeHubOrigin(options.serverUrl);
|
|
374
|
+
if (options.managementUrl) managementUrl = normalizeHubOrigin(options.managementUrl);
|
|
375
|
+
if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) {
|
|
376
|
+
throw new Error("at least one unique connected client is required");
|
|
377
|
+
}
|
|
378
|
+
const state = readClientConnectionState();
|
|
379
|
+
if (state.kind !== "disconnected") {
|
|
380
|
+
const detail = state.kind === "connected" ? "already connected" : state.reason;
|
|
381
|
+
throw new Error(`connect refused: client state is ${state.kind} (${detail})`);
|
|
382
|
+
}
|
|
383
|
+
const externalProvider = currentExternalCodexModelProvider();
|
|
384
|
+
if (externalProvider) throw new Error(`connect refused: external Codex provider ${externalProvider} owns config.toml`);
|
|
385
|
+
const tokenState = readServiceApiTokenState();
|
|
386
|
+
if (tokenState.kind !== "absent") {
|
|
387
|
+
throw new Error(tokenState.kind === "unsafe" ? tokenState.reason : "connect refused: service token file already exists");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl });
|
|
391
|
+
if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`);
|
|
392
|
+
managementUrl = managementUrl || ready.metadata.managementUrl;
|
|
393
|
+
|
|
394
|
+
if (options.credential.kind === "pairing-grant") {
|
|
395
|
+
const session = await exchangeConnectPairingGrant(
|
|
396
|
+
managementUrl,
|
|
397
|
+
localGuiOrigin(),
|
|
398
|
+
options.credential.value,
|
|
399
|
+
{ fetchImpl: deps.fetchImpl },
|
|
400
|
+
);
|
|
401
|
+
cleanupCredential = { kind: "gui-session", value: session };
|
|
402
|
+
} else {
|
|
403
|
+
cleanupCredential = { kind: "admin", value: options.credential.value };
|
|
404
|
+
}
|
|
405
|
+
issued = await issueClientKey(managementUrl, cleanupCredential, clientKeyName(), { fetchImpl: deps.fetchImpl });
|
|
406
|
+
|
|
407
|
+
priorCatalog = catalogSnapshot();
|
|
408
|
+
const persisted = writeServiceApiTokenFile(issued.key);
|
|
409
|
+
tokenFingerprint = persisted.fingerprint;
|
|
410
|
+
|
|
411
|
+
const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl });
|
|
412
|
+
atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
|
|
413
|
+
writtenCatalogFingerprint = sha256(catalog.body);
|
|
414
|
+
|
|
415
|
+
const config = loadConfig();
|
|
416
|
+
const target = routingTarget(serverUrl);
|
|
417
|
+
const injectConfig = { ...config, syncResumeHistory: false };
|
|
418
|
+
const preflight = await injectCodexConfig(config.port, injectConfig, {
|
|
419
|
+
validateOnly: true,
|
|
420
|
+
routingTarget: target,
|
|
421
|
+
catalogPath: DEFAULT_CATALOG_PATH,
|
|
422
|
+
journalOwner: { kind: "client", apiKeyId: issued.id },
|
|
423
|
+
});
|
|
424
|
+
if (!preflight.success) throw new Error(preflight.message);
|
|
425
|
+
|
|
426
|
+
if (!options.noSync && options.selectedClients.includes("codex")) {
|
|
427
|
+
const injected = await injectCodexConfig(config.port, injectConfig, {
|
|
428
|
+
routingTarget: target,
|
|
429
|
+
catalogPath: DEFAULT_CATALOG_PATH,
|
|
430
|
+
journalOwner: { kind: "client", apiKeyId: issued.id },
|
|
431
|
+
});
|
|
432
|
+
if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
|
|
433
|
+
injectionCommitted = true;
|
|
434
|
+
if (!isCodexRoutingInjected()) throw new Error("Codex routing target was not committed");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const now = (deps.now ?? (() => new Date()))().toISOString();
|
|
438
|
+
const connection: OcxClientConnectionConfig = {
|
|
439
|
+
serverUrl,
|
|
440
|
+
managementUrl,
|
|
441
|
+
managementTransport: options.managementTransport,
|
|
442
|
+
selectedClients: [...options.selectedClients],
|
|
443
|
+
tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
|
|
444
|
+
apiKeyId: issued.id,
|
|
445
|
+
tokenFingerprint: persisted.fingerprint,
|
|
446
|
+
protocolVersion: 1,
|
|
447
|
+
connectedAt: now,
|
|
448
|
+
catalogFingerprint: createHash("sha256").update(catalog.body).digest("base64url"),
|
|
449
|
+
// Durable so disconnect — a different process — can put back whatever was here
|
|
450
|
+
// before. The in-memory `priorCatalog` only covers a connect that fails and rolls
|
|
451
|
+
// back in the same run.
|
|
452
|
+
priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "",
|
|
453
|
+
catalogSyncedAt: now,
|
|
454
|
+
};
|
|
455
|
+
commitClientConnection(connection);
|
|
456
|
+
committed = true;
|
|
457
|
+
return connection;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const rollbackFailures: string[] = [];
|
|
460
|
+
if (injectionCommitted) {
|
|
461
|
+
const restored = restoreJournalState();
|
|
462
|
+
if (!restored.complete) rollbackFailures.push("Codex journal restore was partial");
|
|
463
|
+
}
|
|
464
|
+
if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) {
|
|
465
|
+
rollbackFailures.push("catalog rollback did not match the written artifact");
|
|
466
|
+
}
|
|
467
|
+
if (tokenFingerprint) {
|
|
468
|
+
const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint);
|
|
469
|
+
if (removed === "changed") rollbackFailures.push("service token changed during rollback");
|
|
470
|
+
}
|
|
471
|
+
let remoteCleanup: string | null = null;
|
|
472
|
+
if (issued && cleanupCredential && managementUrl) {
|
|
473
|
+
remoteCleanup = await cleanupIssuedKey(managementUrl, cleanupCredential, issued.id, deps);
|
|
474
|
+
}
|
|
475
|
+
const base = error instanceof Error ? error.message : String(error);
|
|
476
|
+
const details = [
|
|
477
|
+
...rollbackFailures,
|
|
478
|
+
...(remoteCleanup ? [remoteCleanup] : []),
|
|
479
|
+
];
|
|
480
|
+
throw new Error(details.length > 0 ? `${base}. ${details.join(" ")}` : base, { cause: error });
|
|
481
|
+
} finally {
|
|
482
|
+
releaseCredential(options.credential);
|
|
483
|
+
cleanupCredential = null;
|
|
484
|
+
issued = null;
|
|
485
|
+
if (!committed) {
|
|
486
|
+
tokenFingerprint = null;
|
|
487
|
+
priorCatalog = null;
|
|
488
|
+
writtenCatalogFingerprint = null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function syncConnectedClient(
|
|
494
|
+
_options: { restartCodex?: boolean } = {},
|
|
495
|
+
deps: ClientConnectDeps = {},
|
|
496
|
+
): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> {
|
|
497
|
+
const state = readClientConnectionState();
|
|
498
|
+
if (state.kind !== "connected") throw new Error(`connected sync refused: client state is ${state.kind}`);
|
|
499
|
+
const token = readServiceApiTokenState();
|
|
500
|
+
if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) {
|
|
501
|
+
throw new Error(token.kind === "absent" ? "connected service token is missing" : "connected service token ownership changed");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let catalogWritten = false;
|
|
505
|
+
let stale = false;
|
|
506
|
+
let next = state.value;
|
|
507
|
+
try {
|
|
508
|
+
const downloaded = await downloadClientCatalog(state.value.serverUrl, token.token, {
|
|
509
|
+
fetchImpl: deps.fetchImpl,
|
|
510
|
+
});
|
|
511
|
+
atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body);
|
|
512
|
+
catalogWritten = true;
|
|
513
|
+
const now = (deps.now ?? (() => new Date()))().toISOString();
|
|
514
|
+
next = {
|
|
515
|
+
...state.value,
|
|
516
|
+
catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"),
|
|
517
|
+
catalogSyncedAt: now,
|
|
518
|
+
};
|
|
519
|
+
commitClientConnection(next);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
const transient = error instanceof HubClientError
|
|
522
|
+
&& (error.code === "unreachable" || (error.status !== undefined && error.status >= 500));
|
|
523
|
+
if (!transient) throw error;
|
|
524
|
+
validLocalCatalog();
|
|
525
|
+
stale = true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let injected = false;
|
|
529
|
+
if (next.selectedClients.includes("codex")) {
|
|
530
|
+
const config = loadConfig();
|
|
531
|
+
const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
|
|
532
|
+
routingTarget: routingTarget(next.serverUrl),
|
|
533
|
+
catalogPath: DEFAULT_CATALOG_PATH,
|
|
534
|
+
journalOwner: { kind: "client", apiKeyId: next.apiKeyId },
|
|
535
|
+
});
|
|
536
|
+
if (!result.success || result.status === "skipped") throw new Error(result.message);
|
|
537
|
+
injected = true;
|
|
538
|
+
}
|
|
539
|
+
const cacheSynced = invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
|
|
540
|
+
return { catalogWritten, cacheSynced, injected, stale };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Put the catalog back the way connect found it.
|
|
545
|
+
*
|
|
546
|
+
* Not a delete. Connect overwrites whatever catalog was already there, so removing the
|
|
547
|
+
* remote one leaves the user with nothing — and disconnect still reports that native Codex
|
|
548
|
+
* state was restored. If the connection recorded a prior catalog, it is rewritten;
|
|
549
|
+
* `priorCatalog: ""` means there genuinely was none and removal is the restoration.
|
|
550
|
+
*
|
|
551
|
+
* Still ownership-checked first: a catalog the user edited or replaced since connect is
|
|
552
|
+
* theirs, and `changed` refuses rather than overwriting it.
|
|
553
|
+
*/
|
|
554
|
+
function restorePriorCatalog(connection: OcxClientConnectionConfig): "removed" | "restored" | "absent" | "changed" {
|
|
555
|
+
if (!existsSync(DEFAULT_CATALOG_PATH)) return "absent";
|
|
556
|
+
try {
|
|
557
|
+
const body = validLocalCatalog();
|
|
558
|
+
if (!catalogMatchesFingerprint(body, connection.catalogFingerprint)) return "changed";
|
|
559
|
+
if (connection.priorCatalog) {
|
|
560
|
+
atomicWriteFile(DEFAULT_CATALOG_PATH, Buffer.from(connection.priorCatalog, "base64").toString("utf8"));
|
|
561
|
+
return "restored";
|
|
562
|
+
}
|
|
563
|
+
// Undefined means the connection predates this field: the pre-connect catalog was
|
|
564
|
+
// never recorded, so removal is the only honest option and matches the old behavior.
|
|
565
|
+
unlinkSync(DEFAULT_CATALOG_PATH);
|
|
566
|
+
return "removed";
|
|
567
|
+
} catch {
|
|
568
|
+
return "changed";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function disconnectClient(
|
|
573
|
+
options: { keepCatalog?: boolean } = {},
|
|
574
|
+
): Promise<{
|
|
575
|
+
restored: boolean;
|
|
576
|
+
tokenRemoved: boolean;
|
|
577
|
+
/** True when the catalog no longer holds remote bytes: removed outright or overwritten. */
|
|
578
|
+
catalogRemoved: boolean;
|
|
579
|
+
/** True only when a recorded pre-connect catalog was written back. */
|
|
580
|
+
catalogRestored: boolean;
|
|
581
|
+
apiKeyId: string;
|
|
582
|
+
}> {
|
|
583
|
+
const state = readClientConnectionState();
|
|
584
|
+
if (state.kind !== "connected") throw new Error(`disconnect refused: client state is ${state.kind}`);
|
|
585
|
+
const token = readServiceApiTokenState();
|
|
586
|
+
if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) {
|
|
587
|
+
throw new Error(token.kind === "absent" ? "disconnect refused: service token is missing" : "disconnect refused: service token ownership changed");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let restored = true;
|
|
591
|
+
if (state.value.selectedClients.includes("codex")) {
|
|
592
|
+
const owner = journalOwner();
|
|
593
|
+
// A journal owned by this client key is ours, obviously. A journal owned by a PROCESS is
|
|
594
|
+
// also ours to unwind: it is what `ocx start` leaves behind, and connecting on top of it
|
|
595
|
+
// never transfers ownership — writeJournal() declines to overwrite a journal whose
|
|
596
|
+
// config is already injected, so the process owner survives into the connected state.
|
|
597
|
+
//
|
|
598
|
+
// Treating that as a conflict stranded the normal "start, then connect" path: disconnect
|
|
599
|
+
// refused, and nothing the operator could do would satisfy the check. The genuine
|
|
600
|
+
// conflict is a journal owned by a DIFFERENT client key, which is the one case where
|
|
601
|
+
// restoring would unwind somebody else's routing.
|
|
602
|
+
if (
|
|
603
|
+
owner === null
|
|
604
|
+
|| owner.kind === "process"
|
|
605
|
+
|| owner.apiKeyId === state.value.apiKeyId
|
|
606
|
+
) {
|
|
607
|
+
if (owner !== null) restored = restoreJournalState().complete;
|
|
608
|
+
else if (isCodexRoutingInjected()) {
|
|
609
|
+
// Injected routing with no journal at all: there is no recorded baseline to restore,
|
|
610
|
+
// so unwinding would be a guess about what the config looked like before.
|
|
611
|
+
throw new Error("disconnect refused: Codex routing is injected but no journal records the original state");
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key");
|
|
615
|
+
}
|
|
616
|
+
if (!restored) throw new Error("disconnect refused: Codex journal restore was partial");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const tokenRemoval = removeServiceApiTokenFileIfOwned(state.value.tokenFingerprint);
|
|
620
|
+
if (tokenRemoval === "changed") throw new Error("disconnect refused: service token changed before removal");
|
|
621
|
+
let catalogRemoval: "removed" | "restored" | "absent" | "changed" = "absent";
|
|
622
|
+
if (!options.keepCatalog) {
|
|
623
|
+
catalogRemoval = restorePriorCatalog(state.value);
|
|
624
|
+
if (catalogRemoval === "changed") throw new Error("disconnect refused: catalog ownership changed");
|
|
625
|
+
}
|
|
626
|
+
if (clearClientConnection(state.value.apiKeyId) !== "committed") {
|
|
627
|
+
throw new Error("disconnect refused: client state changed before final commit");
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
restored,
|
|
631
|
+
tokenRemoved: tokenRemoval === "removed",
|
|
632
|
+
catalogRemoved: catalogRemoval === "removed" || catalogRemoval === "restored",
|
|
633
|
+
catalogRestored: catalogRemoval === "restored",
|
|
634
|
+
apiKeyId: state.value.apiKeyId,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function revokeConnectedClientKey(
|
|
639
|
+
credential: { kind: "admin"; value: Uint8Array },
|
|
640
|
+
deps: ClientConnectDeps = {},
|
|
641
|
+
): Promise<{ apiKeyId: string }> {
|
|
642
|
+
try {
|
|
643
|
+
const state = readClientConnectionState();
|
|
644
|
+
if (state.kind !== "connected") throw new Error("connect revoke is available only while connected");
|
|
645
|
+
await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl });
|
|
646
|
+
return { apiKeyId: state.value.apiKeyId };
|
|
647
|
+
} finally {
|
|
648
|
+
credential.value.fill(0);
|
|
649
|
+
}
|
|
650
|
+
}
|