@bitkyc08/opencodex 2.7.42 → 2.7.43

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.
Files changed (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -25,10 +25,16 @@ import { signalWithTimeout } from "../lib/abort";
25
25
  import { sidecarEnter } from "../lib/sidecar-tracker";
26
26
  import type { OcxConfig } from "../types";
27
27
  import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
28
+ import { getProviderRegistryEntry } from "../providers/registry";
28
29
  import { readJsonRequestBody } from "./request-decompress";
29
30
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
30
31
  import type { RequestLogContext } from "./request-log";
31
32
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
33
+ import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index";
34
+ import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors";
35
+ import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error";
36
+ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
37
+ import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts";
32
38
 
33
39
  export type ImagesEndpoint = "generations" | "edits";
34
40
 
@@ -42,6 +48,276 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
42
48
  */
43
49
  const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
44
50
 
51
+ const CCA_IMAGE_MODEL = "gemini-3.1-flash-image";
52
+
53
+ /**
54
+ * Google Gemini finishReasons that indicate a permanent content/safety block.
55
+ * When any of these is present, the same prompt will always fail — retrying
56
+ * wastes paid quota. The response is surfaced as 400 (non-retryable) instead
57
+ * of 502 (which codex retries up to 5 times).
58
+ */
59
+ const CCA_BLOCKING_FINISH_REASONS: ReadonlySet<string> = new Set([
60
+ "SAFETY",
61
+ "BLOCKLIST",
62
+ "PROHIBITED_CONTENT",
63
+ "SPII",
64
+ "RECITATION",
65
+ ]);
66
+
67
+ /**
68
+ * Race a promise against an abort signal. If the signal aborts first, reject
69
+ * immediately — our code stops awaiting the underlying operation even though
70
+ * the HTTP request behind it may still complete. Used to make the non-cancellable
71
+ * OAuth refresh chain responsive to client cancellation and deadline expiry.
72
+ */
73
+ function abortableRace<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
74
+ if (signal.aborted) {
75
+ return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
76
+ }
77
+ return new Promise<T>((resolve, reject) => {
78
+ const onAbort = () => reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
79
+ signal.addEventListener("abort", onAbort, { once: true });
80
+ promise.then(
81
+ (val) => { signal.removeEventListener("abort", onAbort); resolve(val); },
82
+ (err) => { signal.removeEventListener("abort", onAbort); reject(err); },
83
+ );
84
+ });
85
+ }
86
+
87
+ async function tryCcaImageGeneration(
88
+ body: unknown,
89
+ config: OcxConfig,
90
+ logCtx: RequestLogContext,
91
+ signal: AbortSignal,
92
+ endpoint: ImagesEndpoint,
93
+ ): Promise<Response | undefined> {
94
+ if (endpoint !== "generations") return undefined;
95
+ const provider = config.providers?.["google-antigravity"];
96
+ if (!provider || provider.disabled) return undefined;
97
+
98
+ const prompt = (body as { prompt?: unknown })?.prompt;
99
+ if (typeof prompt !== "string" || !prompt.trim()) {
100
+ return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty");
101
+ }
102
+
103
+ const nRaw = (body as { n?: unknown })?.n;
104
+ if (nRaw !== undefined && nRaw !== null) {
105
+ const n = typeof nRaw === "number" ? nRaw : Number(nRaw);
106
+ if (!Number.isInteger(n) || n !== 1) {
107
+ return formatErrorResponse(400, "invalid_request_error", "CCA image generation supports n=1 only");
108
+ }
109
+ }
110
+
111
+ // Create the deadline before credential resolution so the timeout covers
112
+ // OAuth token refresh and project discovery, not just the upstream fetch.
113
+ const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
114
+ const linkedSignal = signalWithTimeout(timeoutMs, signal);
115
+ let token: string;
116
+ try {
117
+ // Race the OAuth refresh against the deadline signal. getValidAccessToken
118
+ // chains through 4 layers (resolveAccessSnapshotForAccount →
119
+ // refreshAndPersistAccessToken → refreshGenericAccountWithLock →
120
+ // def.refresh()) that do HTTP calls without accepting a signal. Rather than
121
+ // threading signal through the entire chain, race the whole call against
122
+ // linkedSignal: when the signal aborts we stop awaiting and surface the
123
+ // cancellation immediately instead of hanging on the refresh HTTP call.
124
+ token = await abortableRace(getValidAccessToken("google-antigravity"), linkedSignal.signal);
125
+ } catch (err) {
126
+ linkedSignal.cleanup();
127
+ // abortableRace rejects immediately when the signal fires, so client
128
+ // cancellation and deadline expiry surface here. Parent abort propagates
129
+ // into the linked signal, so check parent first (499) before the linked
130
+ // signal (504).
131
+ if (signal.aborted) {
132
+ return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client");
133
+ }
134
+ if (linkedSignal.signal.aborted) {
135
+ return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication");
136
+ }
137
+ // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502.
138
+ const errName = err instanceof Error ? err.name : "";
139
+ if (errName === "OAuthLoginRequiredError") {
140
+ return formatErrorResponse(401, "invalid_request_error", "Google Antigravity login required: run 'ocx login google-antigravity'");
141
+ }
142
+ return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed");
143
+ }
144
+ const project = getOAuthCredentialProjectId("google-antigravity");
145
+ if (!project) {
146
+ linkedSignal.cleanup();
147
+ return formatErrorResponse(
148
+ 400,
149
+ "invalid_request_error",
150
+ "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).",
151
+ );
152
+ }
153
+
154
+ logCtx.provider = "google-antigravity";
155
+ logCtx.model = CCA_IMAGE_MODEL;
156
+
157
+ // Pin to the registry endpoint — never use a config-level baseUrl override for OAuth token transmission.
158
+ const registryEntry = getProviderRegistryEntry("google-antigravity");
159
+ const baseUrl = registryEntry?.baseUrl ?? "https://daily-cloudcode-pa.googleapis.com";
160
+ const envelope = {
161
+ model: CCA_IMAGE_MODEL,
162
+ userAgent: "antigravity",
163
+ requestType: "agent",
164
+ project,
165
+ requestId: `agent-${crypto.randomUUID()}`,
166
+ request: {
167
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
168
+ generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
169
+ sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`,
170
+ },
171
+ };
172
+ let upstream: Response;
173
+ try {
174
+ try {
175
+ upstream = await fetch(`${baseUrl}/v1internal:generateContent`, {
176
+ method: "POST",
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ "Authorization": `Bearer ${token}`,
180
+ "User-Agent": ANTIGRAVITY_REQUEST_UA,
181
+ },
182
+ body: JSON.stringify(envelope),
183
+ signal: linkedSignal.signal,
184
+ });
185
+ } catch (err) {
186
+ if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client");
187
+ if (err instanceof Error && err.name === "TimeoutError") {
188
+ return formatErrorResponse(504, "upstream_error", "CCA image generation timed out");
189
+ }
190
+ // Network/DNS/runtime errors may embed the request URL or headers verbatim
191
+ // (e.g. "fetch failed: https://…/v1internal:generateContent"). The token
192
+ // lives in an Authorization header, not in the URL, but sanitize defensively
193
+ // so no upstream-rejected credential or query param can reach the client,
194
+ // and strip the internal base URL host from the surfaced message.
195
+ const rawMsg = err instanceof Error ? err.message : String(err);
196
+ const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(
197
+ /https?:\/\/[^\s"'<>]+/gi,
198
+ "[upstream-url]",
199
+ );
200
+ return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`);
201
+ }
202
+
203
+ // Stream the upstream body with a bounded reader so an oversized or malicious
204
+ // response is rejected mid-stream rather than after a full arrayBuffer() allocation.
205
+ let payload: Uint8Array;
206
+ try {
207
+ const reader = upstream.body?.getReader();
208
+ if (!reader) {
209
+ return formatErrorResponse(502, "upstream_error", "CCA image response had no body");
210
+ }
211
+ const chunks: Uint8Array[] = [];
212
+ let total = 0;
213
+ try {
214
+ for (;;) {
215
+ const { done, value } = await reader.read();
216
+ if (done) break;
217
+ total += value.byteLength;
218
+ if (total > IMAGES_RESPONSE_MAX_BYTES) {
219
+ await reader.cancel().catch(() => {});
220
+ return formatErrorResponse(502, "upstream_error", `CCA image response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`);
221
+ }
222
+ chunks.push(value);
223
+ }
224
+ } finally {
225
+ try { await reader.cancel(); } catch { /* ignore */ }
226
+ reader.releaseLock();
227
+ }
228
+ payload = new Uint8Array(total);
229
+ let offset = 0;
230
+ for (const chunk of chunks) {
231
+ payload.set(chunk, offset);
232
+ offset += chunk.byteLength;
233
+ }
234
+ } catch (err) {
235
+ // Body-read timeout/abort: when CCA returns headers then stalls, the linked
236
+ // signal's timeout aborts reader.read(), which rejects here. The rejection
237
+ // often surfaces as AbortError (not TimeoutError), so distinguish by signal
238
+ // state, not error name. Parent abort propagates into the linked signal, so
239
+ // check the parent first: parent abort → 499 (client cancelled); linked-only
240
+ // abort → 504 (upstream stall); anything else → 502 body-read failure.
241
+ if (signal.aborted) {
242
+ return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client");
243
+ }
244
+ if (linkedSignal.signal.aborted) {
245
+ return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read");
246
+ }
247
+ const rawMsg = err instanceof Error ? err.message : String(err);
248
+ const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]");
249
+ return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`);
250
+ }
251
+
252
+ if (!upstream.ok) {
253
+ // Preserve auth/rate-limit and other permanent 4xx signals so callers can
254
+ // distinguish retryable from permanent failures. Remaining 5xx collapse to 502.
255
+ const text = new TextDecoder().decode(payload);
256
+ const safeMsg = safeAntigravityHttpErrorMessage(upstream.status, text);
257
+ if (upstream.status >= 400 && upstream.status < 500) {
258
+ return formatErrorResponse(upstream.status, "upstream_error", safeMsg);
259
+ }
260
+ return formatErrorResponse(502, "upstream_error", safeMsg);
261
+ }
262
+
263
+ let json: Record<string, unknown>;
264
+ try {
265
+ json = JSON.parse(new TextDecoder().decode(payload)) as Record<string, unknown>;
266
+ } catch {
267
+ return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON");
268
+ }
269
+ const resp = (json.response ?? json) as {
270
+ candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] }; finishReason?: string }[];
271
+ promptFeedback?: { blockReason?: string };
272
+ };
273
+ // Safety blocks are permanent for the same prompt — return 400 (non-retryable)
274
+ // instead of 502 (which codex retries up to 5 times, wasting paid quota on a
275
+ // prompt that will never succeed). Two blocking signals exist in the Gemini
276
+ // API: promptFeedback.blockReason (prompt rejected before generation) and
277
+ // candidate.finishReason (generation cut off by a content filter).
278
+ const blockReason = resp.promptFeedback?.blockReason;
279
+ if (typeof blockReason === "string" && blockReason.trim()) {
280
+ return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (promptFeedback.blockReason: ${blockReason})`);
281
+ }
282
+ const candidate = resp.candidates?.[0];
283
+ const finishReason = candidate?.finishReason;
284
+ if (typeof finishReason === "string" && CCA_BLOCKING_FINISH_REASONS.has(finishReason)) {
285
+ return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (finishReason: ${finishReason})`);
286
+ }
287
+ const parts = candidate?.content?.parts;
288
+ if (!Array.isArray(parts)) {
289
+ return formatErrorResponse(502, "upstream_error", "CCA image response had no valid parts array");
290
+ }
291
+ const images: { b64_json: string }[] = [];
292
+ for (const part of parts) {
293
+ if (!part || typeof part !== "object") continue;
294
+ const data = part.inlineData?.data;
295
+ if (typeof data !== "string" || data.length === 0) continue;
296
+ if (data.length > MAX_ENCODED_BYTES_PER_IMAGE) {
297
+ return formatErrorResponse(502, "upstream_error", "CCA image payload exceeds per-image size cap");
298
+ }
299
+ try {
300
+ decodeValidatedImageBase64(data);
301
+ } catch {
302
+ return formatErrorResponse(502, "upstream_error", "CCA image payload failed base64/magic validation");
303
+ }
304
+ images.push({ b64_json: data });
305
+ }
306
+ if (images.length === 0) {
307
+ return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data");
308
+ }
309
+ // Only `{created, data:[{b64_json}]}` is returned — no token, projectId, or
310
+ // upstream metadata leak through. The Authorization header is consumed by the
311
+ // fetch above and never copied onto this Response.
312
+ return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), {
313
+ status: 200,
314
+ headers: { "content-type": "application/json" },
315
+ });
316
+ } finally {
317
+ linkedSignal.cleanup();
318
+ }
319
+ }
320
+
45
321
  export async function handleImages(
46
322
  req: Request,
47
323
  config: OcxConfig,
@@ -53,11 +329,18 @@ export async function handleImages(
53
329
  return formatErrorResponse(400, "invalid_request_error", candidates.error);
54
330
  }
55
331
  const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined;
332
+ // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be
333
+ // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward
334
+ // and allow CCA / keyed paths instead of rejecting the whole request.
335
+ let skipOpenAiForwardForAdmissionBearer = false;
56
336
  if (!explicitKeyedProvider) {
57
337
  try { validateForwardAdmissionCredential(req.headers, config); }
58
338
  catch (err) {
59
- if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
60
- throw err;
339
+ if (err instanceof ForwardAdmissionCredentialError) {
340
+ skipOpenAiForwardForAdmissionBearer = true;
341
+ } else {
342
+ throw err;
343
+ }
61
344
  }
62
345
  }
63
346
  let body: unknown;
@@ -69,15 +352,19 @@ export async function handleImages(
69
352
  const model = (body as { model?: unknown } | null)?.model;
70
353
  if (typeof model === "string" && model) logCtx.model = model;
71
354
 
72
- if (candidates.forwardCandidates.length === 0 && !candidates.keyed) {
355
+ const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0;
356
+
357
+ if (!canUseOpenAiForward && !candidates.keyed) {
358
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
359
+ if (ccaResponse) return ccaResponse;
73
360
  // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
74
361
  // configuration state that must surface on the first attempt.
75
362
  return formatErrorResponse(
76
363
  400,
77
364
  "invalid_request_error",
78
- "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), "
79
- + "but none is configured in opencodex. Routed providers cannot serve /v1/images/* "
80
- + "add an OpenAI provider or disable the tool with `codex features disable image_generation`.",
365
+ "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider) "
366
+ + "or a logged-in Google Antigravity (Cloud Code Assist) provider, "
367
+ + "but none is configured in opencodex. Add a provider or disable the tool with `codex features disable image_generation`.",
81
368
  );
82
369
  }
83
370
 
@@ -86,7 +373,7 @@ export async function handleImages(
86
373
  // 429 image_gen while api.openai.com sits idle).
87
374
  let forward: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
88
375
  let forwardAuthError: Response | undefined;
89
- if (candidates.forwardCandidates.length > 0) {
376
+ if (canUseOpenAiForward) {
90
377
  try {
91
378
  forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config);
92
379
  if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config);
@@ -116,8 +403,12 @@ export async function handleImages(
116
403
  // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`).
117
404
  url = `${provider.baseUrl}/images/${endpoint}`;
118
405
  } else if (forwardAuthError) {
119
- // A configured OpenAI pool mode owns its authentication failure. Do not hide a
120
- // broken/expired pool behind separately billed API-key image generation.
406
+ // Before surfacing the OpenAI auth failure, try CCA the user may have a
407
+ // valid Google Antigravity login even though their OpenAI pool is broken.
408
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
409
+ if (ccaResponse) return ccaResponse;
410
+ // No CCA either: a configured OpenAI pool mode owns its authentication failure.
411
+ // Do not hide a broken/expired pool behind separately billed API-key image generation.
121
412
  return forwardAuthError;
122
413
  } else if (candidates.keyed) {
123
414
  const { provider, apiKey, providerName } = candidates.keyed;
@@ -127,6 +418,9 @@ export async function handleImages(
127
418
  // Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts).
128
419
  url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
129
420
  } else {
421
+ // No usable OpenAI credential — try CCA before giving up.
422
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
423
+ if (ccaResponse) return ccaResponse;
130
424
  return formatErrorResponse(
131
425
  401,
132
426
  "authentication_error",
@@ -20,10 +20,14 @@ import {
20
20
  import { reconcileOAuthProviders } from "../oauth";
21
21
  import { invalidateCodexModelsCache } from "../codex/catalog";
22
22
  import { startMemoryWatchdog } from "./memory-watchdog";
23
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
24
+ import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job";
25
+ import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler";
23
26
  import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
24
27
  import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
25
28
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
26
29
  import { providerCodexAccountMode } from "../providers/registry";
30
+ import type { StorageCleanupPolicy } from "../types";
27
31
  import {
28
32
  CodexAccountCooldownError,
29
33
  cooldownErrorMessage,
@@ -52,6 +56,8 @@ export {
52
56
  drainAndShutdown,
53
57
  getActiveTurnCount,
54
58
  isDraining,
59
+ isRecyclingForExit,
60
+ markRecyclingForExit,
55
61
  registerTurn,
56
62
  trackStreamLifetime,
57
63
  unregisterTurn,
@@ -98,8 +104,10 @@ export {
98
104
  import {
99
105
  assertServerAuthConfig,
100
106
  corsHeaders,
107
+ managementCorsHeaders,
101
108
  hasValidApiAuth,
102
109
  isAllowedRequestOrigin,
110
+ isAllowedManagementOrigin,
103
111
  isApiAuthRequired,
104
112
  isLoopbackHostname,
105
113
  jsonResponse,
@@ -108,6 +116,7 @@ import {
108
116
  safeConfigDTO,
109
117
  setCorsOrigin,
110
118
  withCors,
119
+ withManagementCors,
111
120
  } from "./auth-cors";
112
121
  export {
113
122
  assertServerAuthConfig,
@@ -129,6 +138,7 @@ import { handleImages } from "./images";
129
138
  import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
130
139
  import { handleSearch } from "./search";
131
140
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
141
+ import { initializeManagementAuthState, issueGuiSession, requireManagementAuth } from "./management-auth";
132
142
 
133
143
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
134
144
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
@@ -220,14 +230,16 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
220
230
  // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
221
231
  // if (isEventStream && upstreamResponse.body) {
222
232
  // const repairConfig = route.provider.responsesItemIdRepair;
223
- // #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime):
233
+ // const needsClientRewrite = imageGenCallAliases.size > 0
234
+ // #314 gated shape (win32-no-client-rewrite only; default OFF on the bundled known-bad runtime):
224
235
  // decideEagerRelay(config.streamMode ?? "auto")
225
236
  // relaySseEagerBounded(upstreamResponse.body, turnAc,
237
+ // new Response(eagerBody,
226
238
  // Default shape (tee + background inspection):
227
239
  // upstreamResponse.body.tee()
228
240
  // const repairedBody = hasResponsesItemIdRepair(repairConfig)
229
241
  // process.platform === "win32"
230
- // && !hasResponsesItemIdRepair(repairConfig)
242
+ // && !needsClientRewrite
231
243
  // ? nativeBody
232
244
  // relaySseWithFailedTail(repairedBody, upstream)
233
245
  // new Response(clientBody
@@ -242,6 +254,7 @@ export function startServer(port?: number) {
242
254
  const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
243
255
  applyProxyEnv(config);
244
256
  assertServerAuthConfig(config);
257
+ const managementAuth = initializeManagementAuthState(config);
245
258
  // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
246
259
  // adding/dropping models reaches existing configs on start — not just fresh installs.
247
260
  reconcileOAuthProviders(config);
@@ -288,6 +301,16 @@ export function startServer(port?: number) {
288
301
  // #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
289
302
  // startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
290
303
  startMemoryWatchdog();
304
+ // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
305
+ // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
306
+ // Heavy work runs in a Worker via the single-flight job controller.
307
+ // Keep live config.policy in sync when background runs advance nextRun/lastRun.
308
+ const applyPolicy = (policy: StorageCleanupPolicy) => {
309
+ config.storageCleanupPolicy = policy;
310
+ };
311
+ setStorageCleanupPolicyLiveSink(applyPolicy);
312
+ setStorageCleanupPolicyJobLiveApply(applyPolicy);
313
+ startStorageCleanupScheduler();
291
314
 
292
315
  const listenPort = port ?? config.port ?? 10100;
293
316
  setCorsOrigin(listenPort);
@@ -296,7 +319,8 @@ export function startServer(port?: number) {
296
319
  // resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
297
320
  // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
298
321
  // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
299
- const bindHost = /^localhost$/i.test(config.hostname ?? "") ? "127.0.0.1" : (config.hostname ?? "127.0.0.1");
322
+ const configuredHost = config.hostname?.trim();
323
+ const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
300
324
 
301
325
  // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
302
326
  // the server_is_overloaded code so clients can back off, but always return a JSON envelope.
@@ -319,10 +343,17 @@ export function startServer(port?: number) {
319
343
  markActivity(`${req.method} ${url.pathname}`);
320
344
 
321
345
  if (req.method === "OPTIONS") {
322
- if (!isAllowedRequestOrigin(req, config)) {
346
+ const managementPreflight = url.pathname.startsWith("/api/");
347
+ const allowed = managementPreflight
348
+ ? isAllowedManagementOrigin(req, config)
349
+ : isAllowedRequestOrigin(req, config);
350
+ if (!allowed) {
323
351
  return new Response(null, { status: 403, headers: corsHeaders() });
324
352
  }
325
- return new Response(null, { status: 204, headers: corsHeaders(req, config) });
353
+ return new Response(null, {
354
+ status: 204,
355
+ headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, config),
356
+ });
326
357
  }
327
358
 
328
359
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
@@ -358,10 +389,11 @@ export function startServer(port?: number) {
358
389
  }
359
390
 
360
391
  if (url.pathname.startsWith("/api/")) {
361
- const apiAuthError = requireApiAuth(req, config, "management");
362
- if (apiAuthError) return withCors(apiAuthError, req, config);
392
+ const apiAuthError = requireManagementAuth(req, managementAuth, config);
393
+ if (apiAuthError) return withManagementCors(apiAuthError, req, config);
363
394
  const mgmtResponse = await handleManagementAPI(req, url, config);
364
- if (mgmtResponse) return withCors(mgmtResponse, req, config);
395
+ if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
396
+ return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
365
397
  }
366
398
 
367
399
  if (url.pathname === "/v1/models" && req.method === "GET") {
@@ -484,6 +516,36 @@ export function startServer(port?: number) {
484
516
  return withCors(response, req, config);
485
517
  }
486
518
 
519
+ if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
520
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
521
+ if (apiAuthError) return withCors(apiAuthError, req, config);
522
+ if (!isAllowedRequestOrigin(req, config)) {
523
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
524
+ }
525
+ const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
526
+ const { resolveArtifactPath } = await import("../images/artifacts");
527
+ const artifactPath = resolveArtifactPath(id);
528
+ if (!artifactPath) {
529
+ return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config);
530
+ }
531
+ const file = Bun.file(artifactPath);
532
+ const ext = artifactPath.split(".").pop()?.toLowerCase();
533
+ const contentType =
534
+ ext === "png" ? "image/png"
535
+ : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
536
+ : ext === "webp" ? "image/webp"
537
+ : ext === "gif" ? "image/gif"
538
+ : "application/octet-stream";
539
+ return withCors(new Response(file, {
540
+ status: 200,
541
+ headers: {
542
+ "content-type": contentType,
543
+ "cache-control": "private, max-age=3600",
544
+ "x-content-type-options": "nosniff",
545
+ },
546
+ }), req, config);
547
+ }
548
+
487
549
  if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
488
550
  disableResponsesRequestTimeout(req, requestServer);
489
551
  if (isDraining()) {
@@ -675,7 +737,10 @@ export function startServer(port?: number) {
675
737
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
676
738
  }
677
739
 
678
- const guiFile = serveGuiFile(url.pathname);
740
+ const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
741
+ ? issueGuiSession(req, config, managementAuth)
742
+ : null;
743
+ const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined);
679
744
  if (guiFile) return guiFile;
680
745
  if (url.pathname === "/" && req.method === "GET") {
681
746
  return jsonResponse(rootFallbackPayload());
@@ -876,5 +941,8 @@ export function startServer(port?: number) {
876
941
  .catch(() => {});
877
942
  }
878
943
 
944
+ // Opt-in storage policy (default OFF). Never blocks listen; cancellable on shutdown.
945
+ scheduleStorageCleanupStartupRun();
946
+
879
947
  return server;
880
948
  }
@@ -1,4 +1,10 @@
1
1
  import { flushResponseState } from "../responses/state";
2
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
3
+ import {
4
+ abortStorageCleanupPolicyJob,
5
+ setStorageCleanupPolicyJobLiveApply,
6
+ } from "../storage/policy-job";
7
+ import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
2
8
 
3
9
  // ---------------------------------------------------------------------------
4
10
  // Active turn tracking + graceful shutdown drain
@@ -6,6 +12,7 @@ import { flushResponseState } from "../responses/state";
6
12
 
7
13
  const activeTurns = new Set<AbortController>();
8
14
  let draining = false;
15
+ let recyclingForExit = false;
9
16
  let _serverRef: ReturnType<typeof Bun.serve> | undefined;
10
17
 
11
18
  export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
@@ -14,6 +21,18 @@ export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
14
21
  export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
15
22
  export function isDraining(): boolean { return draining; }
16
23
  export function getActiveTurnCount(): number { return activeTurns.size; }
24
+ /** Live listen port of the Bun server, when started. */
25
+ export function getServerListenPort(): number | undefined {
26
+ const port = _serverRef?.port;
27
+ return typeof port === "number" && port > 0 ? port : undefined;
28
+ }
29
+ /**
30
+ * Mark this process as a recycle (dashboard drain-and-restart). Exit cleanup
31
+ * must keep Codex/Grok/system-env injection so the replacement process inherits
32
+ * a working fence — unlike an intentional `ocx stop` teardown.
33
+ */
34
+ export function markRecyclingForExit(): void { recyclingForExit = true; }
35
+ export function isRecyclingForExit(): boolean { return recyclingForExit; }
17
36
 
18
37
  export function trackStreamLifetime(
19
38
  body: ReadableStream<Uint8Array>,
@@ -67,7 +86,12 @@ export async function drainAndShutdown(
67
86
  }
68
87
  // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
69
88
  // previous_response_id chain survives the restart this shutdown is usually part of.
70
- flushResponseState();
89
+ await flushResponseState();
90
+ // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
91
+ stopStorageCleanupScheduler();
92
+ abortStorageCleanupPolicyJob();
93
+ setStorageCleanupPolicyLiveSink(null);
94
+ setStorageCleanupPolicyJobLiveApply(null);
71
95
  s?.stop(true);
72
96
  draining = false;
73
97
  }