@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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.
Files changed (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -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/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -3,6 +3,10 @@ import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
3
3
  import { decodeCursorArgsMap } from "./arg-codec";
4
4
  import { normalizeArgKeys } from "./arg-normalize";
5
5
  import {
6
+ cursorShellBridgeArgsValid,
7
+ cursorShellBridgeDropError,
8
+ defaultShellBridgeArgNormalizeSchema,
9
+ isCodexShellBridgeToolName,
6
10
  normalizeCursorWireName,
7
11
  OCX_RESPONSES_TOOL_PROVIDER,
8
12
  resolveShellBridgeAliasKey,
@@ -324,10 +328,18 @@ export function mapSyntheticMcpExecToToolEvents(
324
328
  out.push(...commitToolCall(options.state, callId, finalArgs));
325
329
  return out;
326
330
  }
331
+ const responsesName = responsesToolNameFromCursorWire(cursorWireName);
332
+ const normSchema = defaultShellBridgeArgNormalizeSchema(responsesName);
333
+ const normalizedArgs = JSON.stringify(normalizeArgKeys(decodeCursorArgsMap(args?.args), normSchema));
334
+ if (!cursorShellBridgeArgsValid(normalizedArgs, responsesName, normSchema)) {
335
+ if (isCodexShellBridgeToolName(responsesName)) {
336
+ return [{ type: "error", message: cursorShellBridgeDropError(responsesName) }];
337
+ }
338
+ }
327
339
  // Stateless fallback (no shared event state): emit a complete, self-contained tool call.
328
340
  return [
329
- { type: "tool_call_start", id: callId, name: responsesToolNameFromCursorWire(cursorWireName) },
330
- { type: "tool_call_delta", arguments: decodeMcpArgs(args) },
341
+ { type: "tool_call_start", id: callId, name: responsesName },
342
+ ...(normalizedArgs.length > 2 ? [{ type: "tool_call_delta" as const, arguments: normalizedArgs }] : []),
331
343
  { type: "tool_call_end", id: callId },
332
344
  ];
333
345
  }
@@ -361,9 +373,19 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
361
373
  * recorded in `openToolCalls`. Because each completion emits a whole non-interleaved unit, the bridge
362
374
  * (which tracks a single current tool call) serializes parallel Cursor calls correctly.
363
375
  */
376
+ function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] {
377
+ state.openToolCalls.delete(callId);
378
+ state.completedToolCalls.add(callId);
379
+ return [{ type: "error", message: cursorShellBridgeDropError(toolName) }];
380
+ }
381
+
364
382
  function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] {
365
383
  const open = state.openToolCalls.get(callId);
366
384
  if (!open) return [];
385
+ const schema = toolSchemaForWireName(state, open.name);
386
+ if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
387
+ if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
388
+ }
367
389
  const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }];
368
390
  if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs });
369
391
  out.push(...endToolCall(state, callId));
@@ -38,7 +38,6 @@ import {
38
38
  } from "./gen/agent_pb";
39
39
  import {
40
40
  appendCursorGenericToolUseHint,
41
- appendCursorShellAliasHint,
42
41
  cursorToolsForActivePrompt,
43
42
  buildCursorToolGuidanceSystemNote,
44
43
  buildCursorToolDefinitions,
@@ -560,7 +559,7 @@ export function prepareCursorRunRequest(
560
559
  const rawText = activePromptText(request);
561
560
  const lastRole = request.messages.at(-1)?.role;
562
561
  const text = lastRole === "user" || lastRole === "developer"
563
- ? appendCursorShellAliasHint(request.tools, appendCursorGenericToolUseHint(request.tools, rawText))
562
+ ? appendCursorGenericToolUseHint(request.tools, rawText)
564
563
  : rawText;
565
564
  // Tool-result-only turns resume the remembered Cursor conversation with results in history.
566
565
  const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
@@ -12,8 +12,6 @@ export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL;
12
12
  export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const;
13
13
  export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE =
14
14
  'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell; never say native shell is blocked.';
15
- export const CURSOR_SHELL_ALIAS_USER_HINT =
16
- "Use the Codex shell bridge tool listed this turn (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` name). Prefer that bridge over Cursor-native Shell/Read. Never say phrases like \"Native shell access is blocked\" or \"Shell access is blocked\" — silently use the bridge unless that bridge tool itself fails.";
17
15
  const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
18
16
 
19
17
  export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [
@@ -213,17 +211,6 @@ function shellBridgeArgNormalizeSchema(tool: OcxTool): unknown {
213
211
  };
214
212
  }
215
213
 
216
- function activeTextMentionsExecCommand(text: string): boolean {
217
- return /\b(?:exec_command|shell_command)\b/i.test(text);
218
- }
219
-
220
- function looksLikeShellCommandRequest(text: string): boolean {
221
- const hasKnownCommand = /(?:^|[\s`$])(?:echo|pwd|ls|cat|grep|rg|find|python3?|node|bun|npm|pnpm|yarn|git|curl|wget|chmod|mkdir|rm|cp|mv|touch|docker|kubectl|make|cargo|go|pytest)(?=\s|$|[`:;|&])/i.test(text);
222
- const hasRunIntent = /\b(?:run|execute|exec)\b/i.test(text) || /\b(?:stdout|stderr|exit\s+code)\b/i.test(text);
223
- const hasShellTarget = /\b(?:shell|terminal|command|cmd)\b/i.test(text);
224
- return /\b(?:run|execute|exec)\s*:/i.test(text) || hasKnownCommand || (hasRunIntent && hasShellTarget);
225
- }
226
-
227
214
  export function isGenericToolUseCountDemoPrompt(text: string): boolean {
228
215
  const trimmed = text.trim();
229
216
  if (trimmed.length === 0) return false;
@@ -316,23 +303,69 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
316
303
  return execTools && execTools.length > 0 ? execTools : tools;
317
304
  }
318
305
 
319
- export function shouldAppendCursorShellAliasHint(
320
- tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
321
- text: string,
322
- ): boolean {
323
- const trimmed = text.trim();
324
- return trimmed.length > 0
325
- && cursorRequestHasShellAlias(tools)
326
- && !activeTextMentionsExecCommand(trimmed)
327
- && looksLikeShellCommandRequest(trimmed);
306
+ /**
307
+ * Required command payload keys for a shell bridge tool, derived from the advertised schema when present.
308
+ */
309
+ export function shellBridgeRequiredCommandKeys(
310
+ toolName: string,
311
+ schema?: unknown,
312
+ ): readonly ("cmd" | "command")[] {
313
+ if (schema && typeof schema === "object") {
314
+ const required = (schema as Record<string, unknown>).required;
315
+ if (Array.isArray(required)) {
316
+ const keys = required.filter((key): key is "cmd" | "command" => key === "cmd" || key === "command");
317
+ if (keys.length > 0) return keys;
318
+ }
319
+ }
320
+ return toolName === CODEX_SHELL_COMMAND_TOOL ? ["command"] : ["cmd"];
328
321
  }
329
322
 
330
- export function appendCursorShellAliasHint(
331
- tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
332
- text: string,
333
- ): string {
334
- if (!shouldAppendCursorShellAliasHint(tools, text)) return text;
335
- return `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${CURSOR_SHELL_ALIAS_USER_HINT}`;
323
+ /** Normalize-schema defaults used when validating stateless synthetic shell-bridge calls. */
324
+ export function defaultShellBridgeArgNormalizeSchema(toolName: string): unknown {
325
+ return toolName === CODEX_SHELL_COMMAND_TOOL
326
+ ? CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA
327
+ : {
328
+ type: "object",
329
+ properties: CURSOR_EXEC_COMMAND_INPUT_SCHEMA.properties,
330
+ required: ["cmd"],
331
+ };
332
+ }
333
+
334
+ export function cursorShellBridgeDropError(toolName: string): string {
335
+ return `Cursor emitted ${toolName} without a non-empty command; the tool call was dropped.`;
336
+ }
337
+
338
+ /**
339
+ * Extract a non-empty shell command from completed Cursor bridge args using the schema's required
340
+ * command key (`cmd` for bare exec_command, `command` for shell_command).
341
+ */
342
+ export function nonEmptyShellBridgeCommandFromArgs(
343
+ finalArgs: string,
344
+ toolName: string,
345
+ schema?: unknown,
346
+ ): string | undefined {
347
+ let parsed: unknown;
348
+ try {
349
+ parsed = finalArgs.length > 0 ? JSON.parse(finalArgs) : {};
350
+ } catch {
351
+ return undefined;
352
+ }
353
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
354
+ const record = parsed as Record<string, unknown>;
355
+ for (const key of shellBridgeRequiredCommandKeys(toolName, schema)) {
356
+ const value = record[key];
357
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ export function cursorShellBridgeArgsValid(
363
+ finalArgs: string,
364
+ toolName: string,
365
+ schema?: unknown,
366
+ ): boolean {
367
+ return !isCodexShellBridgeToolName(toolName)
368
+ || nonEmptyShellBridgeCommandFromArgs(finalArgs, toolName, schema) !== undefined;
336
369
  }
337
370
 
338
371
  export function cursorToolAllowedByChoice(
@@ -384,6 +417,11 @@ export function buildCursorToolGuidanceSystemNote(
384
417
  const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice);
385
418
  const discoveryTools = discoveryToolLabel(wireNames);
386
419
  const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames);
420
+ // Host-shell-neutral: the Codex client executes bridge commands, and may differ from
421
+ // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls.
422
+ const hostShellNote = hasBareExec
423
+ ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<<EOF`); `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`. POSIX: use portable commands. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
424
+ : undefined;
387
425
  const notes = [
388
426
  `Cursor tool calls: available tool names are exactly ${listedNames}.`,
389
427
  "Use the current tool catalog as ground truth and call only those exact names with their listed argument keys.",
@@ -399,6 +437,7 @@ export function buildCursorToolGuidanceSystemNote(
399
437
  hasBareExec
400
438
  ? "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Prefer the bridge over Cursor-native Shell/Read; do not narrate phrases like \"Native shell access is blocked\" — silently call `shell_command` / `exec_command`."
401
439
  : undefined,
440
+ hostShellNote,
402
441
  "Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.",
403
442
  hasBareExec
404
443
  ? `For file read/search/listing, use ${shellBridgeLabel} when no more specific listed tool is available.`
@@ -418,7 +457,7 @@ export function buildCursorToolGuidanceSystemNote(
418
457
  : undefined,
419
458
  "Do not count or report a tool call unless a tool result was actually returned.",
420
459
  hasBareExec
421
- ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
460
+ ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
422
461
  : undefined,
423
462
  ].filter((note): note is string => typeof note === "string");
424
463
  return notes.join(" ");
@@ -137,6 +137,10 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined {
137
137
  : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined);
138
138
  if (thinkingLevel) out.thinkingConfig = { thinkingLevel };
139
139
  }
140
+ if (Array.isArray(value.responseModalities)) {
141
+ const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m));
142
+ if (valid.length > 0) out.responseModalities = valid;
143
+ }
140
144
  return Object.keys(out).length > 0 ? out : undefined;
141
145
  }
142
146
 
@@ -1,6 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
2
2
  import { debugDroppedFrame } from "../lib/debug";
3
3
  import { createHash } from "node:crypto";
4
+ import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts";
4
5
  import type {
5
6
  AdapterEvent,
6
7
  OcxAssistantMessage,
@@ -233,6 +234,38 @@ function usageFromGemini(usage: Record<string, number> | undefined): OcxUsage |
233
234
  };
234
235
  }
235
236
 
237
+ /**
238
+ * Cap on the buffered non-streaming response body (100 MiB), matching
239
+ * IMAGES_RESPONSE_MAX_BYTES in src/server/images.ts. Enforced by streaming the
240
+ * body with a hard byte cap before JSON.parse — Content-Length alone is not
241
+ * trusted (missing/lying headers must still reject oversized payloads).
242
+ * Streaming SSE responses also cap each data frame before JSON.parse.
243
+ */
244
+ const MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
245
+ const MAX_SSE_FRAME_BYTES = MAX_RESPONSE_BYTES;
246
+
247
+ // Note: imagen-* models use a different API surface (prediction/image-generation
248
+ // schema) and must NOT be treated as responseModalities-capable Gemini models.
249
+ // Explicit allowlist only — never `/gemini/ && /image/` (resurrects media-gen IDs).
250
+ const IMAGE_CAPABLE_MODELS = new Set([
251
+ "gemini-3.1-flash-image",
252
+ "gemini-2.0-flash-preview-image-generation",
253
+ "gemini-3-pro-image-preview",
254
+ ]);
255
+
256
+ function isImageCapableModel(modelId: string): boolean {
257
+ return IMAGE_CAPABLE_MODELS.has(modelId);
258
+ }
259
+
260
+ /**
261
+ * Model-visible markdown link for a materialized artifact. Uses the authenticated
262
+ * opaque HTTP route so remote/container clients can fetch the image without host
263
+ * filesystem paths leaking into the transcript.
264
+ */
265
+ function artifactMarkdownUrl(filePath: string): string {
266
+ return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1");
267
+ }
268
+
236
269
  export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
237
270
  // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
238
271
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
@@ -272,6 +305,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
272
305
  ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)
273
306
  : undefined;
274
307
  if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking };
308
+ if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
309
+ generationConfig.responseModalities = ["TEXT", "IMAGE"];
310
+ }
275
311
  if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig;
276
312
 
277
313
  const method = parsed.stream ? "streamGenerateContent" : "generateContent";
@@ -377,6 +413,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
377
413
  yield { type: "error", message: "No response body" };
378
414
  return;
379
415
  }
416
+ // Streaming responses are processed incrementally (SSE chunks), so the full body
417
+ // is never buffered — no Content-Length pre-check is needed here. Per-image size
418
+ // protection is enforced on each chunk via MAX_ENCODED_BYTES_PER_IMAGE before
419
+ // materializeInlineImage is called (see the inline.data check below).
380
420
 
381
421
  const reader = response.body.getReader();
382
422
  const decoder = new TextDecoder();
@@ -387,9 +427,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
387
427
  let sawAnyFrame = false;
388
428
  let sawTerminalSignal = false;
389
429
 
390
- const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "content" | "terminate"> {
430
+ const handleDataLine = async function* (line: string): AsyncGenerator<AdapterEvent, "continue" | "content" | "terminate"> {
391
431
  const payload = line.slice(5).trim();
392
432
  if (!payload) return "continue";
433
+ if (payload.length > MAX_SSE_FRAME_BYTES) {
434
+ yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` };
435
+ return "terminate";
436
+ }
393
437
  let emittedContentEvent = false;
394
438
 
395
439
  let chunk: Record<string, unknown>;
@@ -452,6 +496,21 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
452
496
  emittedContentEvent = true;
453
497
  yield { type: "text_delta", text: part.text };
454
498
  }
499
+ const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData;
500
+ if (inline && typeof inline.data === "string") {
501
+ if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) {
502
+ yield { type: "error", message: "inline image exceeds per-image size cap" };
503
+ } else {
504
+ try {
505
+ const filePath = await materializeInlineImage(inline.data, imageBudget);
506
+ const escapedPath = artifactMarkdownUrl(filePath);
507
+ emittedContentEvent = true;
508
+ yield { type: "text_delta", text: `\n![image](${escapedPath})\n` };
509
+ } catch {
510
+ yield { type: "error", message: "failed to materialize inline image" };
511
+ }
512
+ }
513
+ }
455
514
  if (part.functionCall) {
456
515
  const id = `call_${crypto.randomUUID().slice(0, 8)}`;
457
516
  toolCallsStarted++;
@@ -464,12 +523,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
464
523
  }
465
524
  return emittedContentEvent ? "content" : "continue";
466
525
  };
526
+ const imageBudget = createImageBudget();
467
527
 
468
528
  try {
469
529
  while (true) {
470
530
  const { done, value } = await reader.read();
471
531
  if (done) break;
472
532
  buffer += decoder.decode(value, { stream: true });
533
+ // Cap incomplete frames before waiting for a newline — otherwise a single
534
+ // unterminated data: payload can grow without bound.
535
+ if (buffer.length > MAX_SSE_FRAME_BYTES) {
536
+ yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` };
537
+ try { await reader.cancel(); } catch { /* ignore */ }
538
+ return;
539
+ }
473
540
 
474
541
  const lines = buffer.split("\n");
475
542
  buffer = lines.pop() ?? "";
@@ -526,7 +593,51 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
526
593
  },
527
594
 
528
595
  async parseResponse(response: Response): Promise<AdapterEvent[]> {
529
- const raw = await response.json() as Record<string, unknown>;
596
+ // Reject oversized responses before JSON parse. Prefer Content-Length when
597
+ // present and truthful; always stream-read with a hard byte cap so a missing
598
+ // or lying Content-Length cannot force a full in-memory buffer + parse.
599
+ const contentLength = Number(response.headers.get("content-length"));
600
+ if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
601
+ try { await response.body?.cancel(); } catch { /* ignore */ }
602
+ return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }];
603
+ }
604
+ let rawText: string;
605
+ try {
606
+ const reader = response.body?.getReader();
607
+ if (!reader) return [{ type: "error", message: "google response had no body" }];
608
+ const chunks: Uint8Array[] = [];
609
+ let total = 0;
610
+ try {
611
+ for (;;) {
612
+ const { done, value } = await reader.read();
613
+ if (done) break;
614
+ total += value.byteLength;
615
+ if (total > MAX_RESPONSE_BYTES) {
616
+ await reader.cancel().catch(() => {});
617
+ return [{ type: "error", message: `google response too large (exceeded ${MAX_RESPONSE_BYTES} bytes)` }];
618
+ }
619
+ chunks.push(value);
620
+ }
621
+ } finally {
622
+ try { await reader.cancel(); } catch { /* ignore */ }
623
+ reader.releaseLock();
624
+ }
625
+ const bytes = new Uint8Array(total);
626
+ let offset = 0;
627
+ for (const chunk of chunks) {
628
+ bytes.set(chunk, offset);
629
+ offset += chunk.byteLength;
630
+ }
631
+ rawText = new TextDecoder().decode(bytes);
632
+ } catch (err) {
633
+ return [{ type: "error", message: err instanceof Error ? err.message : "failed to read google response body" }];
634
+ }
635
+ let raw: Record<string, unknown>;
636
+ try {
637
+ raw = JSON.parse(rawText) as Record<string, unknown>;
638
+ } catch {
639
+ return [{ type: "error", message: "google response was not valid JSON" }];
640
+ }
530
641
  if (raw.error) {
531
642
  const err = raw.error as { message?: string };
532
643
  return [{ type: "error", message: err.message ?? "upstream error" }];
@@ -547,6 +658,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
547
658
  return [{ type: "error", message: "google response contained no candidates" }];
548
659
  }
549
660
  let toolCallsStarted = 0;
661
+ const imageBudget = createImageBudget();
550
662
  if (candidates?.[0]?.content?.parts) {
551
663
  // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path.
552
664
  if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) {
@@ -554,6 +666,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
554
666
  }
555
667
  for (const part of candidates[0].content.parts) {
556
668
  if (part.text) events.push({ type: "text_delta", text: part.text });
669
+ const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData;
670
+ if (inline && typeof inline.data === "string") {
671
+ if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) {
672
+ events.push({ type: "error", message: "inline image exceeds per-image size cap" });
673
+ } else {
674
+ try {
675
+ const filePath = await materializeInlineImage(inline.data, imageBudget);
676
+ const escapedPath = artifactMarkdownUrl(filePath);
677
+ events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` });
678
+ } catch {
679
+ events.push({ type: "error", message: "failed to materialize inline image" });
680
+ }
681
+ }
682
+ }
557
683
  if (part.functionCall) {
558
684
  const id = `call_${crypto.randomUUID().slice(0, 8)}`;
559
685
  toolCallsStarted++;
@@ -14,9 +14,19 @@
14
14
  * to be a specific first-party client.
15
15
  */
16
16
 
17
- /** The exact identity line Codex injects for every model. */
17
+ /** Historical exact identity line Codex injected for every model. */
18
18
  export const CODEX_GPT5_IDENTITY_LINE = "You are Codex, a coding agent based on GPT-5.";
19
19
 
20
+ /** Codex CLI 0.145.0+ wording (#622) — still GPT-5 identity, slightly different phrasing. */
21
+ export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on GPT-5.";
22
+
23
+ /**
24
+ * Known Codex GPT-5 identity sentences. Narrow: only "coding agent" / "an agent" + GPT-5(.x)?
25
+ * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content.
26
+ */
27
+ const CODEX_GPT5_IDENTITY_RE =
28
+ /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g;
29
+
20
30
  /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */
21
31
  export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI.";
22
32
 
@@ -27,7 +37,7 @@ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be
27
37
  * the leak can't reappear in one adapter while being fixed in another.
28
38
  */
29
39
  export function neutralizeIdentity(systemText: string): string {
30
- return systemText.replace(CODEX_GPT5_IDENTITY_LINE, NEUTRAL_IDENTITY_LINE);
40
+ return systemText.replace(CODEX_GPT5_IDENTITY_RE, NEUTRAL_IDENTITY_LINE);
31
41
  }
32
42
 
33
43
  /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */
@@ -52,6 +52,24 @@ const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantRespons
52
52
  const SDK_VERSION = "1.0.27";
53
53
  const NODE_VERSION = "22.21.1";
54
54
  const KIRO_IDE_VERSION = "1.0.0";
55
+ type KiroWireClient = "ide" | "cli";
56
+
57
+ function kiroCliPlatform(): "linux" | "macos" | "windows" {
58
+ return process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
59
+ }
60
+
61
+ function kiroCliUserAgent(includeAppVersion: boolean): string {
62
+ return [
63
+ "aws-sdk-rust/1.3.15",
64
+ "ua/2.1",
65
+ "api/codewhispererstreaming/0.1.17975",
66
+ `os/${kiroCliPlatform()}`,
67
+ "lang/rust/1.92.0",
68
+ ...(includeAppVersion ? ["md/appVersion-2.14.2"] : []),
69
+ "m/F",
70
+ "app/AmazonQ-For-CLI",
71
+ ].join(" ");
72
+ }
55
73
 
56
74
  // Payload construction (conversationState)
57
75
  interface KiroToolUse {
@@ -68,7 +86,10 @@ interface KiroUserInputMessage {
68
86
  content: string;
69
87
  modelId?: string;
70
88
  origin?: string;
71
- userInputMessageContext?: { tools?: unknown[]; toolResults?: KiroToolResult[] };
89
+ userInputMessageContext?: {
90
+ tools?: unknown[];
91
+ toolResults?: KiroToolResult[];
92
+ };
72
93
  images?: KiroImage[];
73
94
  }
74
95
  interface KiroHistoryEntry {
@@ -385,6 +406,7 @@ export function buildKiroPayload(
385
406
  parsed: OcxParsedRequest,
386
407
  profileArn: string | undefined,
387
408
  forcedCompletionMode?: KiroCompletionMode,
409
+ wireClient: KiroWireClient = "ide",
388
410
  ): {
389
411
  payload: Record<string, unknown>;
390
412
  nameMap: Map<string, string>;
@@ -476,7 +498,11 @@ export function buildKiroPayload(
476
498
  if (!priorCalls.has(toolUseId)) {
477
499
  throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
478
500
  }
479
- pushUser(KIRO_TOOL_RESULT_CARRIER_MESSAGE, images, [{
501
+ // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix.
502
+ // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code
503
+ // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the
504
+ // newest user intent behind boilerplate. Backfill below only when nothing else speaks.
505
+ pushUser("", images, [{
480
506
  content: [{ text: resultText }],
481
507
  status: tr.isError ? "error" : "success",
482
508
  toolUseId,
@@ -496,6 +522,16 @@ export function buildKiroPayload(
496
522
  });
497
523
  }
498
524
 
525
+ // Give tool-result turns a carrier sentence ONLY when they carry no other text. This runs
526
+ // before the pop below so the current turn is covered too: skipping it there would ship an
527
+ // empty current content, which validateKiroConversationState accepts (tool results count as
528
+ // payload) and would therefore fail silently.
529
+ for (const turn of turns) {
530
+ if (turn.kind === "user" && !turn.content.trim() && turn.toolResults.length > 0) {
531
+ turn.content = KIRO_TOOL_RESULT_CARRIER_MESSAGE;
532
+ }
533
+ }
534
+
499
535
  const currentTurn = turns.pop();
500
536
  if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
501
537
  const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
@@ -509,7 +545,7 @@ export function buildKiroPayload(
509
545
  userInputMessage: {
510
546
  content: turn.content,
511
547
  modelId,
512
- origin: "AI_EDITOR",
548
+ origin: wireClient === "cli" ? "KIRO_CLI" : "AI_EDITOR",
513
549
  ...(turn.images.length > 0 ? { images: turn.images } : {}),
514
550
  ...(turn.toolResults.length > 0 ? { userInputMessageContext: { toolResults: turn.toolResults } } : {}),
515
551
  },
@@ -539,6 +575,10 @@ export function buildKiroPayload(
539
575
  const payload: Record<string, unknown> = {
540
576
  conversationState: {
541
577
  chatTriggerType: "MANUAL",
578
+ ...(wireClient === "cli" ? {
579
+ agentContinuationId: crypto.randomUUID(),
580
+ agentTaskType: "vibe",
581
+ } : {}),
542
582
  conversationId,
543
583
  currentMessage: { userInputMessage: currentUim },
544
584
  ...(history.length > 0 ? { history } : {}),
@@ -1445,10 +1485,26 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1445
1485
  if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
1446
1486
  throw new Error("kiro token missing — run ocx login kiro");
1447
1487
  }
1448
- const region = resolveKiroApiRegion();
1449
- const profileArn = resolveKiroProfileArn();
1488
+ const region = resolveKiroApiRegion(parsed._kiroAuthContext);
1489
+ const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext);
1490
+ const isApiKey = provider.apiKey.trim().startsWith("ksk_");
1491
+ const profileArn = isApiKey ? undefined : resolvedProfileArn;
1492
+ // Builder ID and Kiro API keys have no profile ARN and are accepted only on Kiro's CLI
1493
+ // request path. Enterprise profiles retain the existing IDE-shaped request.
1494
+ const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide";
1450
1495
  const fp = fingerprint().slice(0, 64);
1451
- const headers: Record<string, string> = {
1496
+ const headers: Record<string, string> = wireClient === "cli" ? {
1497
+ authorization: `Bearer ${provider.apiKey}`,
1498
+ "content-type": "application/x-amz-json-1.0",
1499
+ accept: "*/*",
1500
+ "x-amz-target": AMZ_TARGET,
1501
+ "user-agent": kiroCliUserAgent(true),
1502
+ "x-amz-user-agent": kiroCliUserAgent(false),
1503
+ "x-amzn-codewhisperer-optout": "true",
1504
+ "amz-sdk-request": "attempt=1; max=3",
1505
+ "amz-sdk-invocation-id": invocationId(),
1506
+ ...(isApiKey ? { tokentype: "API_KEY" } : {}),
1507
+ } : {
1452
1508
  authorization: `Bearer ${provider.apiKey}`,
1453
1509
  "content-type": "application/x-amz-json-1.0",
1454
1510
  accept: "application/vnd.amazon.eventstream",
@@ -1460,7 +1516,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1460
1516
  "amz-sdk-invocation-id": invocationId(),
1461
1517
  };
1462
1518
  if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn;
1463
- const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode);
1519
+ const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode, wireClient);
1464
1520
  await normalizeKiroImages(built.payload);
1465
1521
  const contextInputEstimate = estimateKiroPayloadInputTokens(built.payload, parsed.modelId);
1466
1522
  const body = JSON.stringify(built.payload);
@@ -1472,6 +1528,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1472
1528
  messageCount: kiroPayloadMessages(parsed).length,
1473
1529
  toolCount: parsed.context.tools?.length ?? 0,
1474
1530
  hasProfileArn: Boolean(profileArn),
1531
+ wireClient,
1475
1532
  hasPreviousResponseId: Boolean(parsed.previousResponseId),
1476
1533
  });
1477
1534
  return {
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
5
6
  import type { OcxProviderConfig, OcxParsedRequest } from "../types";
6
7
  import { createOpenAIChatAdapter } from "./openai-chat";
7
8
  import type { ProviderAdapter, AdapterRequest } from "./base";
@@ -59,6 +60,7 @@ export function getMimoClientId(): string {
59
60
  } catch { /* fall through to regenerate */ }
60
61
  const fresh = randomUUID();
61
62
  try {
63
+ recordOwnedConfigPath(dir, file);
62
64
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
65
  writeFileSync(file, `${fresh}\n`, "utf8");
64
66
  } catch { /* persist best-effort; still usable for this process */ }