@bitkyc08/opencodex 2.7.38-preview.20260724 → 2.7.39
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.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-B-cheu55.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/index.ts +6 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +17 -1
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +11 -0
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +1 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +182 -0
- package/src/server/live.ts +491 -0
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/responses/core.ts +184 -20
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +14 -0
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-CprFnVjr.js +0 -52
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
headersForCodexAuthContext,
|
|
50
50
|
isCodexAuthContextUsable,
|
|
51
51
|
resolveCodexAuthContext,
|
|
52
|
+
stripCodexRuntimeProviderFields,
|
|
52
53
|
type CodexAuthContext,
|
|
53
54
|
} from "../../codex/auth-context";
|
|
54
55
|
import {
|
|
@@ -97,6 +98,7 @@ import {
|
|
|
97
98
|
} from "../relay";
|
|
98
99
|
import { relaySseEagerBounded } from "../relay-eager";
|
|
99
100
|
import { decideEagerRelay } from "../../lib/bun-stream-caps";
|
|
101
|
+
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
100
102
|
import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
|
|
101
103
|
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
102
104
|
|
|
@@ -150,6 +152,45 @@ export function usesCodexForwardPoolAuth(
|
|
|
150
152
|
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
function normalizeCodexUnsupportedModelDetail(value: string): string {
|
|
156
|
+
return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAllowListedCodexAccountModel400(
|
|
160
|
+
status: number,
|
|
161
|
+
bodyText: string,
|
|
162
|
+
modelId: string,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (status !== 400) return false;
|
|
165
|
+
try {
|
|
166
|
+
const payload = JSON.parse(bodyText) as unknown;
|
|
167
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
168
|
+
const detail = (payload as { detail?: unknown }).detail;
|
|
169
|
+
if (typeof detail !== "string") return false;
|
|
170
|
+
const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
|
|
171
|
+
return normalizeCodexUnsupportedModelDetail(detail)
|
|
172
|
+
=== normalizeCodexUnsupportedModelDetail(expected);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function shouldRetryCodexPoolAccountModel400(
|
|
179
|
+
response: Response,
|
|
180
|
+
modelId: string,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
if (response.status !== 400) return false;
|
|
184
|
+
try {
|
|
185
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
186
|
+
return body.displaySafe
|
|
187
|
+
&& !body.truncated
|
|
188
|
+
&& isAllowListedCodexAccountModel400(response.status, body.text, modelId);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
153
194
|
|
|
154
195
|
|
|
155
196
|
export function codexForwardTerminalOutcomeRecorder(
|
|
@@ -345,6 +386,22 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
|
345
386
|
return childHeaders;
|
|
346
387
|
}
|
|
347
388
|
|
|
389
|
+
const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
390
|
+
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
|
|
391
|
+
|
|
392
|
+
function unreadableEncryptedAgentTaskResponse(): Response {
|
|
393
|
+
return new Response(
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
error: {
|
|
396
|
+
message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE,
|
|
397
|
+
type: "invalid_request_error",
|
|
398
|
+
code: "unreadable_encrypted_agent_task",
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
348
405
|
|
|
349
406
|
|
|
350
407
|
export async function handleComboResponses(
|
|
@@ -369,9 +426,30 @@ export async function handleComboResponses(
|
|
|
369
426
|
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
370
427
|
}
|
|
371
428
|
|
|
429
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
430
|
+
(rawBody as { input?: unknown } | undefined)?.input,
|
|
431
|
+
);
|
|
432
|
+
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
|
|
433
|
+
const provider = config.providers[target.provider];
|
|
434
|
+
if (!provider || provider.disabled === true) return false;
|
|
435
|
+
try {
|
|
436
|
+
const route = routeModel(config, `${target.provider}/${target.model}`);
|
|
437
|
+
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
|
|
443
|
+
!unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
|
|
444
|
+
|
|
445
|
+
if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) {
|
|
446
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
447
|
+
}
|
|
448
|
+
|
|
372
449
|
const initialNow = Date.now();
|
|
373
450
|
let pick = pickComboTarget(config, comboId, {
|
|
374
|
-
eligible: target =>
|
|
451
|
+
eligible: target => payloadEligible(target)
|
|
452
|
+
&& !isComboTargetInCooldown(comboId, target, initialNow),
|
|
375
453
|
});
|
|
376
454
|
if (!pick) {
|
|
377
455
|
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
@@ -532,6 +610,7 @@ export async function handleComboResponses(
|
|
|
532
610
|
pick = advanceComboAfterFailure(config, pick, {
|
|
533
611
|
retryAfter: failure.retryAfter,
|
|
534
612
|
now: Date.now(),
|
|
613
|
+
eligible: payloadEligible,
|
|
535
614
|
});
|
|
536
615
|
}
|
|
537
616
|
return lastFailure!;
|
|
@@ -555,12 +634,12 @@ export async function handleResponses(
|
|
|
555
634
|
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
556
635
|
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
557
636
|
}
|
|
558
|
-
const originalBody = body;
|
|
559
|
-
body = expandPreviousResponseInput(body);
|
|
560
|
-
const previousResponseInputExpanded = body !== originalBody;
|
|
561
637
|
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
562
638
|
(body as { input?: unknown } | undefined)?.input,
|
|
563
639
|
);
|
|
640
|
+
const originalBody = body;
|
|
641
|
+
body = expandPreviousResponseInput(body);
|
|
642
|
+
const previousResponseInputExpanded = body !== originalBody;
|
|
564
643
|
|
|
565
644
|
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
566
645
|
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
@@ -583,6 +662,8 @@ export async function handleResponses(
|
|
|
583
662
|
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
584
663
|
parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
|
|
585
664
|
parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
|
|
665
|
+
const clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim();
|
|
666
|
+
if (clientThreadId) parsed._clientThreadId = clientThreadId;
|
|
586
667
|
} catch (err) {
|
|
587
668
|
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
588
669
|
}
|
|
@@ -608,7 +689,10 @@ export async function handleResponses(
|
|
|
608
689
|
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
609
690
|
}
|
|
610
691
|
(logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
|
|
692
|
+
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
693
|
+
parsed._cursorIsolateConversation = true;
|
|
611
694
|
}
|
|
695
|
+
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
612
696
|
|
|
613
697
|
let route;
|
|
614
698
|
try {
|
|
@@ -624,11 +708,7 @@ export async function handleResponses(
|
|
|
624
708
|
// providers cannot. Reject the raw-input classification before adapter construction
|
|
625
709
|
// or provider dispatch so an unreadable worker task cannot trigger a cost storm.
|
|
626
710
|
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
|
|
627
|
-
return
|
|
628
|
-
400,
|
|
629
|
-
"invalid_request_error",
|
|
630
|
-
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.",
|
|
631
|
-
);
|
|
711
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
632
712
|
}
|
|
633
713
|
|
|
634
714
|
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace
|
|
@@ -775,6 +855,10 @@ export async function handleResponses(
|
|
|
775
855
|
}
|
|
776
856
|
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
777
857
|
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
858
|
+
// Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
|
|
859
|
+
// codexAccountMode still get a credential-derived scope inside the Cursor adapter.
|
|
860
|
+
const identityScope = codexLogAccountId(authCtx);
|
|
861
|
+
if (identityScope) parsed._cursorIdentityScope = identityScope;
|
|
778
862
|
|
|
779
863
|
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
780
864
|
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
@@ -920,7 +1004,7 @@ export async function handleResponses(
|
|
|
920
1004
|
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
921
1005
|
);
|
|
922
1006
|
}
|
|
923
|
-
|
|
1007
|
+
let request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
924
1008
|
const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
925
1009
|
? request.usageLog.inputTokens
|
|
926
1010
|
: undefined;
|
|
@@ -934,6 +1018,20 @@ export async function handleResponses(
|
|
|
934
1018
|
linkAbortSignal(upstream, options.abortSignal);
|
|
935
1019
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
936
1020
|
let upstreamResponse: Response;
|
|
1021
|
+
const transportFailureResponse = (err: unknown): Response => {
|
|
1022
|
+
upstream.abort();
|
|
1023
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1024
|
+
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
1025
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1026
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1027
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const msg = outcome === "timeout"
|
|
1031
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
1032
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
1033
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
1034
|
+
};
|
|
937
1035
|
try {
|
|
938
1036
|
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
939
1037
|
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
@@ -950,18 +1048,80 @@ export async function handleResponses(
|
|
|
950
1048
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
951
1049
|
);
|
|
952
1050
|
} catch (err) {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1051
|
+
return transportFailureResponse(err);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (
|
|
1055
|
+
usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
1056
|
+
&& await shouldRetryCodexPoolAccountModel400(
|
|
1057
|
+
upstreamResponse,
|
|
1058
|
+
route.modelId,
|
|
1059
|
+
options.abortSignal,
|
|
1060
|
+
)
|
|
1061
|
+
) {
|
|
1062
|
+
const firstAuthCtx = authCtx;
|
|
1063
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
1064
|
+
try {
|
|
1065
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
1066
|
+
req.headers,
|
|
1067
|
+
config,
|
|
1068
|
+
"pool",
|
|
1069
|
+
{ excludeAccountId: firstAuthCtx.accountId },
|
|
1070
|
+
);
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
if (
|
|
1073
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
1074
|
+
&& !(error instanceof CodexAuthContextError)
|
|
1075
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
1076
|
+
) throw error;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
|
|
1080
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
|
|
958
1081
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
959
1082
|
});
|
|
1083
|
+
|
|
1084
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
1085
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
1086
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
1087
|
+
retryAuthCtx,
|
|
1088
|
+
"pool",
|
|
1089
|
+
);
|
|
1090
|
+
const retryAdapter = resolveAdapter(
|
|
1091
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
|
|
1092
|
+
config.cacheRetention,
|
|
1093
|
+
);
|
|
1094
|
+
request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
|
|
1095
|
+
|
|
1096
|
+
await upstreamResponse.body?.cancel().catch(() => undefined);
|
|
1097
|
+
authCtx = retryAuthCtx;
|
|
1098
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
1099
|
+
selectedForwardHeaders = retryHeaders;
|
|
1100
|
+
route.provider = retryProvider;
|
|
1101
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
1102
|
+
route.providerName,
|
|
1103
|
+
retryAuthCtx.accountId,
|
|
1104
|
+
config,
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
1108
|
+
try {
|
|
1109
|
+
upstreamResponse = await fetchWithHeaderTimeout(
|
|
1110
|
+
request.url,
|
|
1111
|
+
{
|
|
1112
|
+
method: request.method,
|
|
1113
|
+
headers: request.headers,
|
|
1114
|
+
body: request.body,
|
|
1115
|
+
},
|
|
1116
|
+
upstream.signal,
|
|
1117
|
+
connectMs,
|
|
1118
|
+
parsed.stream,
|
|
1119
|
+
providerFetch(route.provider),
|
|
1120
|
+
);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
return transportFailureResponse(err);
|
|
1123
|
+
}
|
|
960
1124
|
}
|
|
961
|
-
const msg = outcome === "timeout"
|
|
962
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
963
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
964
|
-
return formatErrorResponse(502, "upstream_error", msg);
|
|
965
1125
|
}
|
|
966
1126
|
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
967
1127
|
const resolvedModel = headers.get("openai-model")?.trim();
|
|
@@ -1153,7 +1313,9 @@ export async function handleResponses(
|
|
|
1153
1313
|
if (adapter.runTurn) {
|
|
1154
1314
|
const runTurnAbort = new AbortController();
|
|
1155
1315
|
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
1156
|
-
const queue = createAdapterEventQueue(
|
|
1316
|
+
const queue = createAdapterEventQueue({
|
|
1317
|
+
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
1318
|
+
});
|
|
1157
1319
|
const runTurn = async (): Promise<void> => {
|
|
1158
1320
|
try {
|
|
1159
1321
|
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
@@ -1470,6 +1632,8 @@ export async function handleResponses(
|
|
|
1470
1632
|
}
|
|
1471
1633
|
}
|
|
1472
1634
|
|
|
1635
|
+
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
1636
|
+
|
|
1473
1637
|
if (parsed.stream) {
|
|
1474
1638
|
const eventStream = activeAdapter.parseStream(upstreamResponse);
|
|
1475
1639
|
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
@@ -104,52 +104,131 @@ export function looksLikeBackendCiphertext(payload: string): boolean {
|
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Backend-minted ciphertext runs are Fernet tokens (base64url, version byte 0x80).
|
|
109
|
+
* Used to carve embedded blobs out of MIXED slots: plugin hooks may prepend
|
|
110
|
+
* plaintext control metadata to a task body that is already backend-encrypted.
|
|
111
|
+
*/
|
|
112
|
+
const FERNET_TOKEN_CANDIDATE = /g[A-Za-z0-9_-]{97,}={0,2}/g;
|
|
113
|
+
const FERNET_TOKEN_BOUNDARY_CHAR = /[A-Za-z0-9_=-]/;
|
|
114
|
+
|
|
115
|
+
interface FernetTokenRun {
|
|
116
|
+
index: number;
|
|
117
|
+
token: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate only the key-independent Fernet wire structure. Authenticity cannot be
|
|
122
|
+
* checked without the backend key, but a real token must still be canonical base64url
|
|
123
|
+
* containing version(1) + timestamp(8) + IV(16) + AES-CBC ciphertext(16*n) + HMAC(32).
|
|
124
|
+
* Timestamp freshness is deliberately not enforced: old history can contain valid tokens.
|
|
125
|
+
*/
|
|
126
|
+
function isStructurallyValidFernetToken(token: string): boolean {
|
|
127
|
+
if (token.length < 100 || token.length % 4 !== 0) return false;
|
|
128
|
+
if (!/^[A-Za-z0-9_-]+={0,2}$/.test(token)) return false;
|
|
129
|
+
|
|
130
|
+
const unpadded = token.replace(/=+$/, "");
|
|
131
|
+
const paddingLength = token.length - unpadded.length;
|
|
132
|
+
const expectedPadding = (4 - (unpadded.length % 4)) % 4;
|
|
133
|
+
if (expectedPadding > 2 || paddingLength !== expectedPadding) return false;
|
|
134
|
+
|
|
135
|
+
let decoded: Buffer;
|
|
136
|
+
try {
|
|
137
|
+
decoded = Buffer.from(unpadded, "base64url");
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (decoded.toString("base64url") !== unpadded) return false;
|
|
142
|
+
if (decoded.length < 73 || decoded[0] !== 0x80) return false;
|
|
143
|
+
|
|
144
|
+
const ciphertextLength = decoded.length - 57;
|
|
145
|
+
return ciphertextLength >= 16 && ciphertextLength % 16 === 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Maximal, boundary-delimited and structurally valid Fernet runs embedded in a slot. */
|
|
149
|
+
function fernetTokenRuns(payload: string): FernetTokenRun[] {
|
|
150
|
+
const runs: FernetTokenRun[] = [];
|
|
151
|
+
for (const match of payload.matchAll(FERNET_TOKEN_CANDIDATE)) {
|
|
152
|
+
const index = match.index ?? 0;
|
|
153
|
+
const token = match[0];
|
|
154
|
+
const before = index > 0 ? payload[index - 1] : undefined;
|
|
155
|
+
const after = payload[index + token.length];
|
|
156
|
+
if (before && FERNET_TOKEN_BOUNDARY_CHAR.test(before)) continue;
|
|
157
|
+
if (after && FERNET_TOKEN_BOUNDARY_CHAR.test(after)) continue;
|
|
158
|
+
if (!isStructurallyValidFernetToken(token)) continue;
|
|
159
|
+
runs.push({ index, token });
|
|
160
|
+
}
|
|
161
|
+
return runs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]): string {
|
|
165
|
+
let last = 0;
|
|
166
|
+
let text = "";
|
|
167
|
+
for (const run of runs) {
|
|
168
|
+
text += `${payload.slice(last, run.index)}\n\n`;
|
|
169
|
+
last = run.index + run.token.length;
|
|
170
|
+
}
|
|
171
|
+
return `${text}${payload.slice(last)}`;
|
|
172
|
+
}
|
|
108
173
|
|
|
109
174
|
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
|
|
110
175
|
|
|
111
|
-
|
|
176
|
+
// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
|
|
177
|
+
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
|
|
178
|
+
// removed independently, and a following routing envelope remains available to the
|
|
179
|
+
// envelope stripper below.
|
|
180
|
+
export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi;
|
|
112
181
|
|
|
113
182
|
export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
114
183
|
if (!Array.isArray(input)) return false;
|
|
115
184
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
185
|
+
// codex-rs appends one NEW_TASK agent_message at the current input tail. Historical
|
|
186
|
+
// agent messages may be adjacent in full-history bodies; they must not poison the
|
|
187
|
+
// later task. compaction_trigger/additional_tools are trailing metadata rather than
|
|
188
|
+
// a newer user turn.
|
|
189
|
+
let index = input.length - 1;
|
|
190
|
+
while (index >= 0) {
|
|
191
|
+
const item = input[index];
|
|
192
|
+
const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
|
|
193
|
+
if (type !== "compaction_trigger" && type !== "additional_tools") break;
|
|
194
|
+
index -= 1;
|
|
195
|
+
}
|
|
196
|
+
const item = input[index];
|
|
197
|
+
if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
120
200
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let hasFernetTask = false;
|
|
125
|
-
const readableParts: string[] = [];
|
|
126
|
-
for (const part of content) {
|
|
127
|
-
if (!part || typeof part !== "object") continue;
|
|
128
|
-
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
|
|
129
|
-
if (
|
|
130
|
-
(record.type === "input_text" || record.type === "text" || record.type === "output_text")
|
|
131
|
-
&& typeof record.text === "string"
|
|
132
|
-
) {
|
|
133
|
-
readableParts.push(record.text);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
201
|
+
const content = (item as { content?: unknown }).content;
|
|
202
|
+
if (!Array.isArray(content)) return false;
|
|
139
203
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
204
|
+
let hasFernetTask = false;
|
|
205
|
+
const readableParts: string[] = [];
|
|
206
|
+
for (const part of content) {
|
|
207
|
+
if (!part || typeof part !== "object") continue;
|
|
208
|
+
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
|
|
209
|
+
if (
|
|
210
|
+
(record.type === "input_text" || record.type === "text")
|
|
211
|
+
&& typeof record.text === "string"
|
|
212
|
+
) {
|
|
213
|
+
readableParts.push(record.text);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
|
|
217
|
+
continue;
|
|
143
218
|
}
|
|
144
219
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
220
|
+
const runs = fernetTokenRuns(record.encrypted_content);
|
|
221
|
+
if (runs.length > 0) hasFernetTask = true;
|
|
222
|
+
readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!hasFernetTask) return false;
|
|
226
|
+
const readableTask = readableParts
|
|
227
|
+
.join("\n\n")
|
|
228
|
+
.replace(AGENT_MESSAGE_CONTROL_PREAMBLE, "\n")
|
|
229
|
+
.replace(AGENT_MESSAGE_ROUTING_ENVELOPE, "\n")
|
|
230
|
+
.trim();
|
|
231
|
+
return readableTask.length === 0;
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
|
|
@@ -157,12 +236,11 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
|
157
236
|
export function encryptedSlotParts(payload: string): Array<Record<string, string>> {
|
|
158
237
|
const parts: Array<Record<string, string>> = [];
|
|
159
238
|
let last = 0;
|
|
160
|
-
for (const
|
|
161
|
-
const
|
|
162
|
-
const before = payload.slice(last, index);
|
|
239
|
+
for (const run of fernetTokenRuns(payload)) {
|
|
240
|
+
const before = payload.slice(last, run.index);
|
|
163
241
|
if (before.trim().length > 0) parts.push({ type: "input_text", text: before });
|
|
164
|
-
parts.push({ type: "encrypted_content", encrypted_content:
|
|
165
|
-
last = index +
|
|
242
|
+
parts.push({ type: "encrypted_content", encrypted_content: run.token });
|
|
243
|
+
last = run.index + run.token.length;
|
|
166
244
|
}
|
|
167
245
|
const rest = payload.slice(last);
|
|
168
246
|
if (rest.trim().length > 0) parts.push({ type: "input_text", text: rest });
|
|
@@ -228,4 +306,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
|
228
306
|
return rewritten;
|
|
229
307
|
}
|
|
230
308
|
|
|
231
|
-
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface WsData {
|
|
|
22
22
|
authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup
|
|
23
23
|
cancel?: () => void; // cancels the in-flight stream reader/fetch
|
|
24
24
|
turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
|
|
25
|
+
/** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
|
|
26
|
+
kind?: "responses" | "live-sideband";
|
|
27
|
+
liveUpstream?: WebSocket;
|
|
28
|
+
liveUpstreamUrl?: string;
|
|
29
|
+
liveUpstreamHeaders?: Record<string, string>;
|
|
30
|
+
livePending?: Array<string | Buffer>;
|
|
31
|
+
liveOpened?: boolean;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export class WsSendDroppedError extends Error {
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,18 @@ export interface OcxParsedRequest {
|
|
|
11
11
|
_previousResponseInputExpanded?: boolean;
|
|
12
12
|
/** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
|
|
13
13
|
_cursorConversationId?: string;
|
|
14
|
+
/** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */
|
|
15
|
+
_clientThreadId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
|
|
18
|
+
* When absent (single-operator local proxy), derivation stays local-scoped.
|
|
19
|
+
*/
|
|
20
|
+
_cursorIdentityScope?: string;
|
|
21
|
+
/**
|
|
22
|
+
* True for helper/shadow/compaction turns that must not append into the main Cursor conversation
|
|
23
|
+
* derived from the parent thread id.
|
|
24
|
+
*/
|
|
25
|
+
_cursorIsolateConversation?: boolean;
|
|
14
26
|
/** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
|
|
15
27
|
_providerContinuation?: OcxProviderContinuationState;
|
|
16
28
|
/**
|
|
@@ -540,6 +552,8 @@ export interface OcxConfig {
|
|
|
540
552
|
apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
|
|
541
553
|
/** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
|
|
542
554
|
codexAutoStart?: boolean;
|
|
555
|
+
/** Restore an installed shim after a stable external Codex update replaces it. Default true. */
|
|
556
|
+
codexShimAutoRestore?: boolean;
|
|
543
557
|
/**
|
|
544
558
|
* Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active
|
|
545
559
|
* so Codex App can show old OpenAI chats and opencodex-created exec chats under its default
|