@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -0,0 +1,570 @@
1
+ /**
2
+ * /api/codex-prompt — the Codex prompt-layer surface.
3
+ *
4
+ * This route is an adapter over `src/codex/prompt-layers.ts`. It owns three
5
+ * things the core module deliberately does not: the projection of
6
+ * `LAYER_INVENTORY` into a DTO, request-shape and size policy, and the
7
+ * translation of a `WriteError` into an HTTP status. It owns no file access of
8
+ * its own and defines no second inventory.
9
+ *
10
+ * Privacy: the snapshot carries file paths and the user's own prompt text —
11
+ * text they typed into this same dashboard. It carries no token, API key, or
12
+ * account identifier, and nothing here is written to a log sink.
13
+ *
14
+ * Auth: the standard management gate covers /api/**, and unsafe methods already
15
+ * require Origin + CSRF. This is a local-config write, not an action that spends
16
+ * the user's GitHub identity, so it does NOT carry the `agent_consent_required`
17
+ * treatment `sidebar-routes.ts` applies to starring.
18
+ *
19
+ * Plan: devlog/_plan/260802_codex_set_prompt_composer/020 + 021 (021 supersedes
20
+ * 020 wherever the landed WP1 module moved).
21
+ */
22
+ import { jsonResponse } from "../auth-cors";
23
+ import { readFileSync } from "node:fs";
24
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
25
+ import type { ManagementContext } from "./context";
26
+ import {
27
+ LAYER_INVENTORY,
28
+ MAX_BASE_VARIANTS,
29
+ adoptDeveloperInstructions,
30
+ composeProjection,
31
+ findInvalidCharacter,
32
+ inspectOwnership,
33
+ normalizeBody,
34
+ previewAdopt,
35
+ previewSalvage,
36
+ readPromptLayers,
37
+ salvageProjection,
38
+ selectBaseVariant,
39
+ setToggle,
40
+ writeBaseVariant,
41
+ writeCustomLayers,
42
+ type BaseSelection,
43
+ type CustomLayer,
44
+ type Paths,
45
+ type PromptLayerSnapshot,
46
+ type WriteError,
47
+ type WriteResult,
48
+ } from "../../codex/prompt-layers";
49
+
50
+ /**
51
+ * Third-party extension layers cannot be enumerated (devlog 001 class E), and no
52
+ * WP1 export can say so — it is a statement about what opencodex can know, not
53
+ * about the user's file. Stating it explicitly beats implying the inventory is
54
+ * exhaustive. If class E ever becomes enumerable, this constant is what changes.
55
+ */
56
+ const EXTENSION_LAYERS_ENUMERABLE = false;
57
+
58
+ /** Route policy, not a Codex limit: Codex validates nothing beyond readable-and-non-empty. */
59
+ const MAX_LAYERS = 32;
60
+ const MAX_TITLE_CHARS = 80;
61
+ const MAX_BODY_BYTES = 64 * 1024;
62
+ const MAX_COMPOSED_BYTES = 128 * 1024;
63
+
64
+ const LAYER_ID = /^[a-z0-9]{6}$/;
65
+
66
+ /**
67
+ * Every WriteError gets a status. This is a Record, not a switch with a default:
68
+ * a variant added to the union upstream breaks `bun run typecheck` here instead
69
+ * of silently becoming a 500 in front of a user.
70
+ */
71
+ const WRITE_ERROR_STATUS: Record<WriteError, number> = {
72
+ config_unreadable: 409,
73
+ stale_revision: 409,
74
+ developer_instructions_not_owned: 409,
75
+ unknown_layer: 400,
76
+ store_unreadable: 409,
77
+ invalid_characters: 400,
78
+ write_superseded: 409,
79
+ // Not the caller's fault and not a race: the filesystem refused the write and the
80
+ // transaction rolled itself back. 500 rather than 409 — retrying the same request
81
+ // unchanged will fail the same way until the disk or the path is fixed.
82
+ write_failed: 500,
83
+ recovery_required: 409,
84
+ locked: 409,
85
+ };
86
+
87
+ /** Read-only view for the route test that asserts every mapping is a client error. */
88
+ export const WRITE_ERROR_STATUS_FOR_TESTS: Readonly<Record<WriteError, number>> = WRITE_ERROR_STATUS;
89
+
90
+ function fail(ctx: ManagementContext, code: string, status: number, message?: string, extra?: Record<string, unknown>): Response {
91
+ return jsonResponse({ ok: false, code, ...(message ? { message } : {}), ...(extra ?? {}) }, status, ctx.req, ctx.config);
92
+ }
93
+
94
+ function failWrite(ctx: ManagementContext, result: Extract<WriteResult, { ok: false }>): Response {
95
+ return fail(ctx, result.error, WRITE_ERROR_STATUS[result.error], result.detail);
96
+ }
97
+
98
+ function paths(ctx: ManagementContext): Paths | undefined {
99
+ return ctx.deps.codexPromptPaths;
100
+ }
101
+
102
+ function utf8Bytes(value: string): number {
103
+ return Buffer.byteLength(value, "utf8");
104
+ }
105
+
106
+ /** The DTO. `inventory` is `LAYER_INVENTORY` serialized — never a second table. */
107
+ function serialize(snapshot: PromptLayerSnapshot, configBytes: string | null): Record<string, unknown> {
108
+ return {
109
+ configPath: snapshot.configPath,
110
+ storePath: snapshot.storePath,
111
+ configExists: snapshot.configExists,
112
+ readable: snapshot.readable,
113
+ developerInstructionsOwned: snapshot.developerInstructionsOwned,
114
+ /**
115
+ * `developerInstructionsOwned: false` conflates two very different states: the
116
+ * key is ABSENT (an ordinary first run - the first write creates it) and the key
117
+ * is EXTERNAL (someone else wrote it, and we must not overwrite it). A GUI that
118
+ * cannot tell them apart hides its own create affordance from every new user,
119
+ * which is exactly what happened. The state is named here rather than guessed
120
+ * there.
121
+ */
122
+ developerInstructionsState: inspectOwnership(configBytes).state,
123
+ drift: snapshot.drift,
124
+ revision: snapshot.revision,
125
+ inventory: LAYER_INVENTORY.map(d => ({
126
+ id: d.id,
127
+ class: d.class,
128
+ key: d.key,
129
+ default: d.default,
130
+ order: d.order,
131
+ })),
132
+ toggles: snapshot.toggles,
133
+ extensionLayersEnumerable: EXTENSION_LAYERS_ENUMERABLE,
134
+ custom: snapshot.custom,
135
+ modelInstructionsFile: snapshot.modelInstructionsFile,
136
+ baseVariants: snapshot.baseVariants,
137
+ /**
138
+ * Three-valued on purpose. `external` is not folded into `default`: the key being
139
+ * set to somebody else's file means the base prompt IS replaced, and reporting that
140
+ * as "Codex's own prompt" would be a lie the GUI then repeats to the user.
141
+ */
142
+ baseSelection: snapshot.baseSelection,
143
+ maxBaseVariants: MAX_BASE_VARIANTS,
144
+ };
145
+ }
146
+
147
+ /** Re-read the bytes the snapshot was derived from, so the DTO can name the ownership state. */
148
+ function configBytesOf(snapshot: PromptLayerSnapshot): string | null {
149
+ if (!snapshot.configExists) return null;
150
+ try {
151
+ return readFileSync(snapshot.configPath, "utf8");
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function ok(ctx: ManagementContext, changed: boolean, snapshot: PromptLayerSnapshot): Response {
158
+ return jsonResponse({ ok: true, changed, snapshot: serialize(snapshot, configBytesOf(snapshot)) }, 200, ctx.req, ctx.config);
159
+ }
160
+
161
+ /** Re-read after a mutation so the GUI can publish server truth, not optimistic state. */
162
+ function settle(ctx: ManagementContext, result: WriteResult): Response {
163
+ return result.ok ? ok(ctx, result.changed, result.snapshot) : failWrite(ctx, result);
164
+ }
165
+
166
+ type ValidationError = { code: string; message: string; extra?: Record<string, unknown> };
167
+
168
+ /**
169
+ * Request validation, entirely before any file access. WP1 validates characters
170
+ * and normalizes; shape and size policy is the route's, and the GUI's identical
171
+ * client-side rules are courtesy — this is the boundary.
172
+ */
173
+ function validateLayers(raw: unknown): { layers: CustomLayer[] } | { error: ValidationError } {
174
+ if (!Array.isArray(raw)) return { error: { code: "invalid_body", message: "layers must be an array" } };
175
+ if (raw.length > MAX_LAYERS) {
176
+ return { error: { code: "too_many_layers", message: `at most ${MAX_LAYERS} layers` } };
177
+ }
178
+ const seen = new Set<string>();
179
+ const layers: CustomLayer[] = [];
180
+ for (const entry of raw) {
181
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
182
+ return { error: { code: "invalid_body", message: "each layer must be an object" } };
183
+ }
184
+ const v = entry as Record<string, unknown>;
185
+ if (typeof v.id !== "string" || !LAYER_ID.test(v.id)) {
186
+ return { error: { code: "invalid_layer_id", message: "id must be six lowercase alphanumerics" } };
187
+ }
188
+ if (seen.has(v.id)) {
189
+ return { error: { code: "duplicate_layer_id", message: `duplicate id ${v.id}` } };
190
+ }
191
+ seen.add(v.id);
192
+ if (typeof v.title !== "string" || v.title.trim().length === 0
193
+ || v.title.length > MAX_TITLE_CHARS || /[\r\n]/.test(v.title)) {
194
+ return { error: { code: "invalid_title", message: "title must be 1-80 characters on a single line" } };
195
+ }
196
+ if (typeof v.body !== "string") {
197
+ return { error: { code: "invalid_body", message: "body must be a string" } };
198
+ }
199
+ if (typeof v.enabled !== "boolean") {
200
+ return { error: { code: "invalid_body", message: "enabled must be a boolean" } };
201
+ }
202
+ // Normalize first, then measure and scan: tabs and CRLF are normalized rather
203
+ // than rejected, so the cap must apply to what would actually be stored.
204
+ const body = normalizeBody(v.body);
205
+ if (utf8Bytes(body) > MAX_BODY_BYTES) {
206
+ return { error: { code: "body_too_large", message: `layer ${v.id} exceeds ${MAX_BODY_BYTES} bytes` } };
207
+ }
208
+ const invalid = findInvalidCharacter(body);
209
+ if (invalid !== null) {
210
+ return {
211
+ error: {
212
+ code: "invalid_characters",
213
+ message: `layer ${v.id}: code point ${invalid.position} is a ${invalid.reason}`,
214
+ extra: { layerId: v.id, position: invalid.position, reason: invalid.reason },
215
+ },
216
+ };
217
+ }
218
+ layers.push({ id: v.id, title: v.title, body, enabled: v.enabled });
219
+ }
220
+ const composed = composeProjection(layers);
221
+ if (utf8Bytes(composed) > MAX_COMPOSED_BYTES) {
222
+ return { error: { code: "composed_too_large", message: `composed prompt exceeds ${MAX_COMPOSED_BYTES} bytes` } };
223
+ }
224
+ return { layers };
225
+ }
226
+
227
+ function revisionOf(body: Record<string, unknown>): string | null {
228
+ return typeof body.revision === "string" && body.revision.length > 0 ? body.revision : null;
229
+ }
230
+
231
+ /**
232
+ * Adopt-shaped size policy, applied wherever a config value is imported as a
233
+ * layer. The `owned-malformed` repair branch imports through the same WP1 call
234
+ * as `/adopt`, so it must pass the same caps: a route that enforces a limit on
235
+ * one path and not the other does not have a limit.
236
+ *
237
+ * Runs AFTER the read-only preview and BEFORE any write. The value being
238
+ * measured lives in config.toml, so it cannot be checked before a read; both
239
+ * limits are UTF-8 byte length, and the composed cap measures what
240
+ * `composeProjection` will actually produce — the imported layer plus every
241
+ * already-enabled custom layer.
242
+ */
243
+ function adoptCapFailure(ctx: ManagementContext, decoded: string): Response | null {
244
+ if (utf8Bytes(decoded) > MAX_BODY_BYTES) {
245
+ return fail(ctx, "body_too_large", 400, `the existing value exceeds ${MAX_BODY_BYTES} bytes`);
246
+ }
247
+ const existing = readPromptLayers(paths(ctx)).custom;
248
+ const composed = composeProjection([
249
+ { id: "adopted", title: "Imported from config.toml", body: decoded, enabled: true },
250
+ ...existing,
251
+ ]);
252
+ if (utf8Bytes(composed) > MAX_COMPOSED_BYTES) {
253
+ return fail(ctx, "composed_too_large", 400, `the composed prompt would exceed ${MAX_COMPOSED_BYTES} bytes`);
254
+ }
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Malformed JSON must be a 400, never an empty object. Swallowing a parse error
260
+ * into `{}` made a syntactically invalid adopt or repair request return a
261
+ * successful PREVIEW, and an invalid custom request return `stale_revision` —
262
+ * two answers that describe neither the request nor the file.
263
+ */
264
+ async function readBody(ctx: ManagementContext): Promise<Record<string, unknown> | null> {
265
+ try {
266
+ const parsed: unknown = await readManagementJsonBody(ctx.req);
267
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
268
+ ? parsed as Record<string, unknown>
269
+ : null;
270
+ } catch (error) {
271
+ rethrowManagementBodyTooLarge(error);
272
+ return null;
273
+ }
274
+ }
275
+
276
+ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise<Response | null> {
277
+ const { req, url } = ctx;
278
+ if (!url.pathname.startsWith("/api/codex-prompt")) return null;
279
+
280
+ // Every mutating verb on this surface requires a MINTED GUI SESSION, not merely a
281
+ // request that passed the auth gate. The gate accepts the raw admin token before
282
+ // it consults the session table (management-auth.ts:462), and this endpoint writes
283
+ // the user's `$CODEX_HOME/config.toml` — the file that decides what the model
284
+ // reads. A token any local process can read off disk is the wrong credential for
285
+ // rewriting a prompt.
286
+ //
287
+ // Same principal check and the same honest limit as the star endpoint
288
+ // (sidebar-routes.ts:42): a process running as the user can mint its own session
289
+ // from the loopback dashboard bootstrap, and can edit config.toml directly without
290
+ // this proxy at all. So this is not a technical barrier against a determined local
291
+ // agent. What it removes is the CASUAL path — an agent that would have PUT here
292
+ // because the endpoint existed and the token was lying in `~/.opencodex` — and it
293
+ // makes the refusal legible instead of silent. The real boundary is normative and
294
+ // lives in AGENTS.md.
295
+ //
296
+ // Reads stay open to the admin token: describing the prompt stack changes nothing,
297
+ // and the dashboard's own cold load needs it.
298
+ if (req.method !== "GET" && req.method !== "HEAD" && ctx.principal !== "gui-session") {
299
+ return fail(
300
+ ctx,
301
+ "dashboard_session_required",
302
+ 403,
303
+ "prompt layers are written from the dashboard; an admin token alone cannot rewrite config.toml",
304
+ );
305
+ }
306
+
307
+ if (url.pathname === "/api/codex-prompt" && req.method === "GET") {
308
+ // Pure read. A GET must never repair drift — it is reported here and
309
+ // resolved only by an explicit, revision-checked POST.
310
+ const snapshot = readPromptLayers(paths(ctx));
311
+ return jsonResponse(serialize(snapshot, configBytesOf(snapshot)), 200, req, ctx.config);
312
+ }
313
+
314
+ if (url.pathname === "/api/codex-prompt/text" && req.method === "GET") {
315
+ // The dialog used to claim Codex does not expose layer text. It does:
316
+ // `codex debug prompt-input` renders the model-visible input list, and this
317
+ // reads it. Bounded and fail-soft - an unavailable probe degrades to "we could
318
+ // not read it", never to an error page.
319
+ // No caller-supplied directory: the probe reads CODEX_HOME and nothing else.
320
+ // A `cwd` parameter would have let any authenticated request read an arbitrary
321
+ // folder's AGENTS.md through this endpoint.
322
+ const { probePromptText } = await import("../../codex/prompt-text-probe");
323
+ return jsonResponse(await probePromptText(), 200, req, ctx.config);
324
+ }
325
+
326
+ if (url.pathname === "/api/codex-prompt/toggle" && req.method === "PUT") {
327
+ const body = await readBody(ctx);
328
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
329
+ const id = body.id;
330
+ if (typeof id !== "string") return fail(ctx, "invalid_body", 400, "id must be a string");
331
+ if (typeof body.enabled !== "boolean") return fail(ctx, "invalid_body", 400, "enabled must be a boolean");
332
+ const revision = revisionOf(body);
333
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
334
+
335
+ // Derived from the inventory, not a hand-maintained deny-list: classes base,
336
+ // feature-gated, runtime-conditional and extension-unknown are all covered by
337
+ // one rule, and a new upstream layer is protected the day WP1 lists it. The
338
+ // check must precede setToggle, which collapses every non-toggle id into
339
+ // unknown_layer.
340
+ const descriptor = LAYER_INVENTORY.find(d => d.id === id);
341
+ if (!descriptor) return fail(ctx, "unknown_layer", 400, `no layer ${id}`);
342
+ if (descriptor.class !== "config-toggle") {
343
+ return fail(ctx, "layer_not_toggleable", 409, `${id} is ${descriptor.class} and has no switch`, {
344
+ layerClass: descriptor.class,
345
+ });
346
+ }
347
+ return settle(ctx, setToggle(id, body.enabled, revision, paths(ctx)));
348
+ }
349
+
350
+ if (url.pathname === "/api/codex-prompt/custom" && req.method === "PUT") {
351
+ const body = await readBody(ctx);
352
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
353
+ const revision = revisionOf(body);
354
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
355
+ const validated = validateLayers(body.layers);
356
+ if ("error" in validated) {
357
+ const status = validated.error.code === "invalid_characters" ? 400 : 400;
358
+ return fail(ctx, validated.error.code, status, validated.error.message, validated.error.extra);
359
+ }
360
+ return settle(ctx, writeCustomLayers(validated.layers, revision, paths(ctx)));
361
+ }
362
+
363
+ if (url.pathname === "/api/codex-prompt/base/select" && req.method === "PUT") {
364
+ const body = await readBody(ctx);
365
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
366
+ const revision = revisionOf(body);
367
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
368
+ const kind = body.kind;
369
+ // `external` is a state we REPORT, never one a caller may ask for: selecting it
370
+ // would mean writing a path we do not own.
371
+ if (kind !== "default" && kind !== "variant") {
372
+ return fail(ctx, "invalid_body", 400, 'kind must be "default" or "variant"');
373
+ }
374
+ let selection: BaseSelection;
375
+ if (kind === "default") {
376
+ selection = { kind: "default" };
377
+ } else {
378
+ if (typeof body.id !== "string") return fail(ctx, "invalid_body", 400, "id must be a string");
379
+ selection = { kind: "variant", id: body.id };
380
+ }
381
+ return settle(ctx, selectBaseVariant(selection, revision, paths(ctx)));
382
+ }
383
+
384
+ if (url.pathname === "/api/codex-prompt/base" && req.method === "PUT") {
385
+ const body = await readBody(ctx);
386
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
387
+ const revision = revisionOf(body);
388
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
389
+ // A null id CREATES; a string id edits. `default` is not an id, and the writer
390
+ // rejects it independently - this check only makes the refusal legible.
391
+ const id = body.id === null || body.id === undefined ? null : body.id;
392
+ if (id !== null && typeof id !== "string") return fail(ctx, "invalid_body", 400, "id must be a string or null");
393
+ if (id === "default") return fail(ctx, "unknown_layer", 400, "the default variant has no stored body");
394
+ if (body.delete === true) {
395
+ if (typeof id !== "string") return fail(ctx, "invalid_body", 400, "delete requires an id");
396
+ return settle(ctx, writeBaseVariant({ id, delete: true }, revision, paths(ctx)));
397
+ }
398
+ if (typeof body.title !== "string") return fail(ctx, "invalid_body", 400, "title must be a string");
399
+ if (typeof body.body !== "string") return fail(ctx, "invalid_body", 400, "body must be a string");
400
+ return settle(ctx, writeBaseVariant({ id, title: body.title, body: body.body }, revision, paths(ctx)));
401
+ }
402
+
403
+ if (url.pathname === "/api/codex-prompt/adopt" && req.method === "POST") {
404
+ const body = await readBody(ctx);
405
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
406
+ // An unreadable file must say so. Both adopt and repair inspect read-derived
407
+ // state before ever reaching WP1's own `config_unreadable`, so without this
408
+ // the user was told "nothing to adopt" about a file we could not open.
409
+ const readState = readPromptLayers(paths(ctx));
410
+ if (!readState.readable) {
411
+ return fail(ctx, "config_unreadable", 409, "the configuration file exists but could not be read", {
412
+ path: readState.configPath,
413
+ });
414
+ }
415
+ const preview = previewAdopt(paths(ctx));
416
+
417
+ if (preview.reason === "nothing_to_adopt") {
418
+ return fail(ctx, "nothing_to_adopt", 409, "developer_instructions is absent or already owned", {
419
+ path: preview.path,
420
+ });
421
+ }
422
+ if (preview.reason === "unsupported_form") {
423
+ // Translated here because adoptDeveloperInstructions collapses this into
424
+ // developer_instructions_not_owned, which would tell the user nothing about
425
+ // where their text is or why it cannot be imported.
426
+ return fail(ctx, "adopt_unsupported_form", 409, preview.detail, {
427
+ path: preview.path,
428
+ line: preview.line,
429
+ rawLine: preview.rawLine,
430
+ });
431
+ }
432
+ if (preview.reason === "invalid_characters") {
433
+ return fail(ctx, "invalid_characters", 400, preview.detail, { path: preview.path, line: preview.line });
434
+ }
435
+
436
+ const capFailure = adoptCapFailure(ctx, preview.decodedBody ?? "");
437
+ if (capFailure) return capFailure;
438
+
439
+ if (body.confirm !== true) {
440
+ // Preview writes nothing, by construction: previewAdopt is a pure read.
441
+ return jsonResponse({
442
+ ok: true,
443
+ changed: false,
444
+ preview: {
445
+ rawLine: preview.rawLine,
446
+ decodedBody: preview.decodedBody,
447
+ path: preview.path,
448
+ line: preview.line,
449
+ },
450
+ }, 200, req, ctx.config);
451
+ }
452
+
453
+ const revision = revisionOf(body);
454
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
455
+ return settle(ctx, adoptDeveloperInstructions(revision, paths(ctx)));
456
+ }
457
+
458
+ if (url.pathname === "/api/codex-prompt/repair" && req.method === "POST") {
459
+ const body = await readBody(ctx);
460
+ if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
461
+ const snapshot = readPromptLayers(paths(ctx));
462
+ const drift = snapshot.drift;
463
+ if (!snapshot.readable) {
464
+ return fail(ctx, "config_unreadable", 409, "the configuration file exists but could not be read", {
465
+ path: snapshot.configPath,
466
+ });
467
+ }
468
+ if (drift === null) return fail(ctx, "nothing_to_repair", 409, "no drift is present");
469
+
470
+ const confirm = body.confirm === true;
471
+ const revision = revisionOf(body);
472
+
473
+ if (drift === "projection-stale") {
474
+ if (!confirm) {
475
+ return jsonResponse({
476
+ ok: true,
477
+ changed: false,
478
+ preview: { drift, projection: composeProjection(snapshot.custom) },
479
+ }, 200, req, ctx.config);
480
+ }
481
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
482
+ return settle(ctx, writeCustomLayers(snapshot.custom, revision, paths(ctx)));
483
+ }
484
+
485
+ if (drift === "store-missing") {
486
+ const preview = previewSalvage(paths(ctx));
487
+ if (preview.reason !== "ok") {
488
+ return fail(ctx, "nothing_to_repair", 409, "no live projection to salvage");
489
+ }
490
+ if (!confirm) {
491
+ // backupDir, not a filename: a read-only preview must not reserve one.
492
+ return jsonResponse({
493
+ ok: true,
494
+ changed: false,
495
+ preview: {
496
+ drift,
497
+ body: preview.body,
498
+ backupDir: preview.backupDir,
499
+ unrecoverable: preview.unrecoverable,
500
+ },
501
+ }, 200, req, ctx.config);
502
+ }
503
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
504
+ // Pre-check the revision against the snapshot we just read.
505
+ // `salvageProjection` writes its durable backup BEFORE entering the
506
+ // transaction that validates the revision (prompt-layers.ts:942-955), so a
507
+ // stale tab would otherwise leave an orphan .salvage-*.txt behind on a
508
+ // request that changes nothing. This closes the ordinary path; the narrow
509
+ // race between this check and WP1's own is documented in devlog 021 §8.1
510
+ // and is deliberately NOT fixed by reaching into WP1's write path.
511
+ if (revision !== snapshot.revision) {
512
+ return fail(ctx, "stale_revision", 409, "the configuration moved since it was read");
513
+ }
514
+ return settle(ctx, salvageProjection(revision, paths(ctx)));
515
+ }
516
+
517
+ if (drift === "owned-malformed") {
518
+ const mode = body.mode;
519
+ // Whitelist, not a single-value denial: an arbitrary or missing mode used to
520
+ // fall through to adopt, so a client that sent `mode: "reset"` mutated the
521
+ // file through a verb the contract never offered.
522
+ if (mode !== undefined && mode !== "adopt" && mode !== "replace") {
523
+ return fail(ctx, "invalid_body", 400, "mode must be \"adopt\" or \"replace\"", { drift });
524
+ }
525
+ if (mode === "replace") {
526
+ // No WP1 export performs this. Writing one here would put a second write
527
+ // path beside the journal transaction, in the one place in this unit where
528
+ // a bug destroys a user's configuration.
529
+ return fail(ctx, "repair_unsupported", 409,
530
+ "replacing a reshaped developer_instructions is not supported from this route; edit the line by hand or adopt it", {
531
+ drift, path: snapshot.configPath,
532
+ });
533
+ }
534
+ const preview = previewAdopt(paths(ctx));
535
+ if (preview.reason === "unsupported_form") {
536
+ return fail(ctx, "adopt_unsupported_form", 409, preview.detail, {
537
+ drift, path: preview.path, line: preview.line, rawLine: preview.rawLine,
538
+ });
539
+ }
540
+ if (preview.reason !== "ok") {
541
+ return fail(ctx, "nothing_to_repair", 409, "nothing to adopt", { drift });
542
+ }
543
+ if (!confirm) {
544
+ return jsonResponse({
545
+ ok: true,
546
+ changed: false,
547
+ preview: { drift, rawLine: preview.rawLine, decodedBody: preview.decodedBody, line: preview.line },
548
+ }, 200, req, ctx.config);
549
+ }
550
+ if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
551
+ // Same import, same caps. This branch reaches adoptDeveloperInstructions
552
+ // exactly as /adopt does, so skipping the size policy here would mean the
553
+ // policy is bypassable by choosing the other endpoint. Proven by driving
554
+ // the "BOTH import paths" test red with this call deleted.
555
+ const capFailure = adoptCapFailure(ctx, preview.decodedBody ?? "");
556
+ if (capFailure) return capFailure;
557
+ return settle(ctx, adoptDeveloperInstructions(revision, paths(ctx)));
558
+ }
559
+
560
+ // journal-present: recovery lives inside WP1's commit and is not exported.
561
+ // Any ordinary mutation replays it on its own path, so the honest answer is
562
+ // to name the state rather than duplicate the transaction here.
563
+ return fail(ctx, "repair_unsupported", 409,
564
+ "a write journal is present; recovery runs automatically on the next write", {
565
+ drift, storePath: snapshot.storePath,
566
+ });
567
+ }
568
+
569
+ return null;
570
+ }
@@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCa
5
5
  import {
6
6
  DEFAULT_SUBAGENT_MODELS,
7
7
  codexAutoStartEnabled,
8
+ deleteConfigTopLevelKey,
8
9
  hasOwnProvider,
9
10
  isValidProviderName,
10
11
  multiAgentGuidanceEnabled,
@@ -246,7 +247,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
246
247
  }
247
248
  const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../../combos");
248
249
  delete config.combos![id];
249
- if (Object.keys(config.combos!).length === 0) delete config.combos;
250
+ if (Object.keys(config.combos!).length === 0) deleteConfigTopLevelKey(config, "combos");
250
251
  saveConfigPreservingClaudeCode(config);
251
252
  reconcileLiveStateStores();
252
253
  clearComboSelectionState(id);