okengine 0.22.0 → 0.23.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 (262) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +13 -9
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/gate/auth.mdx +4 -1
  13. package/site/content/docs/elements/store/index.mdx +1 -1
  14. package/site/content/docs/elements/store/kv.mdx +5 -5
  15. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  16. package/site/content/docs/recipes/index.mdx +6 -6
  17. package/site/content/docs/recipes/postgres.mdx +12 -8
  18. package/site/content/docs/recipes/redis.mdx +4 -4
  19. package/site/content/docs/reference/cli.mdx +2 -1
  20. package/site/content/docs/reference/configuration.mdx +9 -7
  21. package/site/content/docs/reference/fx.mdx +7 -6
  22. package/src/auth/api-key-sql.test.ts +44 -0
  23. package/src/auth/api-key-sql.ts +1 -0
  24. package/src/auth/api-keys.ts +30 -12
  25. package/src/auth/config.ts +6 -2
  26. package/src/auth/gate-auth.test.ts +20 -0
  27. package/src/cli/agents-md.test.ts +25 -0
  28. package/src/cli/ask-vault-gaps.test.ts +38 -1
  29. package/src/cli/ask-vault-gaps.ts +35 -2
  30. package/src/cli/decide.ts +174 -0
  31. package/src/cli/decision-lock-watch.test.ts +45 -0
  32. package/src/cli/decision-lock-watch.ts +51 -0
  33. package/src/cli/dev-app-runner.ts +34 -8
  34. package/src/cli/dev-auth-secret.test.ts +49 -0
  35. package/src/cli/dev-auth-secret.ts +49 -0
  36. package/src/cli/dev-hot.test.ts +41 -0
  37. package/src/cli/dev-hot.ts +48 -0
  38. package/src/cli/dev.test.ts +1 -1
  39. package/src/cli/dev.ts +24 -6
  40. package/src/cli/eval.ts +142 -1
  41. package/src/cli/index.ts +5 -0
  42. package/src/cli/load-config.images.test.ts +2 -2
  43. package/src/cli/load-config.ts +1 -1
  44. package/src/cli/mcp-from-console.ts +48 -0
  45. package/src/cli/registry.ts +35 -1
  46. package/src/client/agent.ts +307 -0
  47. package/src/client/transport.test.ts +20 -0
  48. package/src/client/transport.ts +2 -1
  49. package/src/client/types.ts +1 -1
  50. package/src/client-react/index.ts +7 -1
  51. package/src/client-react/use-agent-run.test.ts +271 -0
  52. package/src/client-react/use-agent-run.ts +193 -0
  53. package/src/compiler/ai-approval.test.ts +36 -0
  54. package/src/compiler/ai-repair.test.ts +22 -0
  55. package/src/compiler/decisions.extract.test.ts +164 -0
  56. package/src/compiler/effects-infer.ts +27 -1
  57. package/src/compiler/extract.ts +268 -12
  58. package/src/compiler/response.ts +35 -2
  59. package/src/config/index.ts +21 -3
  60. package/src/console/server/ai-runs-flows.ts +269 -0
  61. package/src/console/server/ai-runs.test.ts +323 -0
  62. package/src/console/server/ai-runs.ts +562 -0
  63. package/src/console/server/ai.ts +12 -1
  64. package/src/console/server/app.ts +10 -1
  65. package/src/console/server/decisions-flows.ts +157 -0
  66. package/src/console/server/decisions.test.ts +66 -0
  67. package/src/console/server/decisions.ts +229 -0
  68. package/src/console/server/flows.ts +50 -2
  69. package/src/console/server/invoke-user-flow.ts +9 -0
  70. package/src/console/server/runs-ingest.ts +16 -0
  71. package/src/console/server/serve.ts +5 -0
  72. package/src/console/server/state.ts +25 -1
  73. package/src/console/server/store-stats.test.ts +47 -0
  74. package/src/console/server/store-stats.ts +92 -18
  75. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  76. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
  80. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  81. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  82. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  83. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  84. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  85. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  86. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  87. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  88. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  89. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  90. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
  92. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  93. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  94. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  95. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
  96. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  97. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  98. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  99. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  100. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  101. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  102. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  103. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  104. package/src/console/ui-next/dist/index.html +4 -3
  105. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  106. package/src/console/ui-next/src/client.ts +224 -1
  107. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  108. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  109. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  110. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  111. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  112. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  113. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  114. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  115. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  116. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  117. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  118. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  119. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  120. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  121. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  122. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  123. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  124. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  125. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  126. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  127. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  128. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  129. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  130. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  131. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  132. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  133. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  134. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  135. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  137. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  138. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  139. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  140. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  141. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  142. package/src/console/ui-next/src/router.tsx +17 -0
  143. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  144. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  145. package/src/console/ui-next/vite.config.ts +9 -0
  146. package/src/docker/recipes/dragonfly.ts +3 -2
  147. package/src/docker/recipes/redis.ts +1 -1
  148. package/src/drivers/ai-anthropic.ts +212 -21
  149. package/src/drivers/ai-mock.ts +34 -1
  150. package/src/drivers/ai-openai-compatible.ts +67 -26
  151. package/src/drivers/ai-preconnect.ts +18 -0
  152. package/src/drivers/ai-stream.test.ts +62 -0
  153. package/src/drivers/ai-types.ts +20 -0
  154. package/src/drivers/journal-postgres.ts +289 -2
  155. package/src/drivers/memory-ddl.test.ts +18 -0
  156. package/src/drivers/memory.ts +21 -1
  157. package/src/drivers/pg-rls.test.ts +31 -0
  158. package/src/drivers/pg-rls.ts +15 -0
  159. package/src/drivers/postgres.test.ts +9 -0
  160. package/src/drivers/postgres.ts +49 -11
  161. package/src/elements/ai/agent-event-slot.ts +27 -0
  162. package/src/elements/ai/approval-http.ts +207 -0
  163. package/src/elements/ai/approval.test.ts +741 -0
  164. package/src/elements/ai/approval.ts +228 -0
  165. package/src/elements/ai/decisions/bind.ts +163 -0
  166. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  167. package/src/elements/ai/decisions/certificate.ts +377 -0
  168. package/src/elements/ai/decisions/certify.ts +314 -0
  169. package/src/elements/ai/decisions/decide.test.ts +953 -0
  170. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  171. package/src/elements/ai/decisions/export.test.ts +120 -0
  172. package/src/elements/ai/decisions/export.ts +146 -0
  173. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  174. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  175. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  176. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  177. package/src/elements/ai/decisions/http.test.ts +154 -0
  178. package/src/elements/ai/decisions/http.ts +240 -0
  179. package/src/elements/ai/decisions/labels.test.ts +282 -0
  180. package/src/elements/ai/decisions/labels.ts +257 -0
  181. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  182. package/src/elements/ai/decisions/openrouter.ts +36 -0
  183. package/src/elements/ai/decisions/provider.ts +102 -0
  184. package/src/elements/ai/decisions/typesafe.ts +36 -0
  185. package/src/elements/ai/declare.ts +252 -1
  186. package/src/elements/ai/events.test.ts +379 -0
  187. package/src/elements/ai/events.ts +138 -0
  188. package/src/elements/ai/run-events.test.ts +461 -0
  189. package/src/elements/ai/run-events.ts +522 -0
  190. package/src/elements/ai/runtime.ts +1186 -105
  191. package/src/elements/ai/stream-turn.ts +214 -0
  192. package/src/elements/ai/stream.live.test.ts +76 -0
  193. package/src/elements/ai/subagent.test.ts +136 -0
  194. package/src/elements/ai.test.ts +497 -3
  195. package/src/elements/ai.ts +16 -1
  196. package/src/elements/clock/durable.ts +27 -1
  197. package/src/elements/store/sql-session.test.ts +51 -0
  198. package/src/elements/store/sql-session.ts +111 -1
  199. package/src/i18n/catalogs/ar.ts +5 -0
  200. package/src/i18n/catalogs/en.ts +5 -0
  201. package/src/index.ts +2 -0
  202. package/src/kernel/agent-event-store.ts +448 -0
  203. package/src/kernel/app.ts +103 -1
  204. package/src/kernel/boot-bind/ai.ts +3 -0
  205. package/src/kernel/boot-bind/store.test.ts +26 -0
  206. package/src/kernel/boot-bind/store.ts +82 -6
  207. package/src/kernel/boot.ts +15 -4
  208. package/src/kernel/builtin-errors.ts +2 -0
  209. package/src/kernel/capability.ts +3 -0
  210. package/src/kernel/decision-label-store.ts +441 -0
  211. package/src/kernel/effects.test.ts +2 -1
  212. package/src/kernel/effects.ts +13 -1
  213. package/src/kernel/element-registries.ts +3 -0
  214. package/src/kernel/errors-text.ts +4 -0
  215. package/src/kernel/errors.ts +2 -0
  216. package/src/kernel/flow.test.ts +2 -3
  217. package/src/kernel/fx-decide.ts +761 -0
  218. package/src/kernel/fx.test.ts +12 -5
  219. package/src/kernel/fx.ts +312 -29
  220. package/src/kernel/http-frame.test.ts +94 -0
  221. package/src/kernel/http-frame.ts +203 -0
  222. package/src/kernel/journal.ts +104 -0
  223. package/src/kernel/json-result.ts +10 -0
  224. package/src/kernel/pipeline.test.ts +45 -0
  225. package/src/kernel/sse-id.ts +32 -0
  226. package/src/manifest/diff.ts +1 -0
  227. package/src/manifest/types.ts +29 -2
  228. package/src/mcp/ai-tools.test.ts +202 -0
  229. package/src/mcp/authorization.ts +66 -0
  230. package/src/mcp/session.ts +3 -0
  231. package/src/mcp/tools.ts +168 -0
  232. package/src/release/http-graph.test.ts +2 -0
  233. package/src/release/http-graph.ts +5 -0
  234. package/src/release/measure.ts +39 -14
  235. package/src/runs/collect.ts +4 -1
  236. package/src/runs/parquet.test.ts +27 -1
  237. package/src/runs/parquet.ts +19 -0
  238. package/src/runs/types.ts +31 -0
  239. package/src/runtime/json-code-block.test.ts +106 -47
  240. package/src/runtime/json-code-block.ts +1209 -46
  241. package/src/term.test.ts +13 -0
  242. package/src/term.ts +81 -49
  243. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  244. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  245. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  246. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  247. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  248. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  249. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  250. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  251. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  252. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  253. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  254. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  255. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  256. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  257. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  258. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  259. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  260. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  261. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  262. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -240,12 +240,13 @@ elements.
240
240
 
241
241
  ## AI
242
242
 
243
- | Signature | Records | Returns |
244
- | ----------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------- |
245
- | `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
246
- | `fx.run(agent, input?)` | `ask` | Agent result |
247
- | `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
248
- | `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
243
+ | Signature | Records | Returns |
244
+ | ----------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
245
+ | `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
246
+ | `fx.run(agent, input?)` | `ask` | Agent result |
247
+ | `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
248
+ | `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
249
+ | `fx.decide(decision, input)` | `decide` | `$.<question>.how` is `auto`, `reviewed`, or `abstained`. `audited` is a boolean flag, not a `how`. Abstain makes the value `null`. |
249
250
 
250
251
  AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).
251
252
 
@@ -44,4 +44,48 @@ describe("api-key SQL persist", () => {
44
44
  expect(other.keys.get("key_sql")?.creatorId).toBe("u1");
45
45
  expect(other.keys.get("key_sql")?.hash).toBe(created.row.hash);
46
46
  });
47
+
48
+ test("authenticate reloads an oke_ secret missing from memory", async () => {
49
+ const { authenticateApiKey } = await import("./api-keys.ts");
50
+ const rows = new Map<string, Record<string, unknown>>();
51
+ const sql: ApiKeySqlExec = {
52
+ async execute(_q, params) {
53
+ const id = String(params[0]);
54
+ rows.set(id, {
55
+ id,
56
+ plane: params[1],
57
+ hash: params[2],
58
+ name: params[3],
59
+ scopes: params[4],
60
+ expires_at: params[5],
61
+ rate_limit: params[6],
62
+ ip_allowlist: params[7],
63
+ creator_id: params[8],
64
+ creator_scopes: params[9],
65
+ created_at: params[10],
66
+ last_used_at: params[11],
67
+ revoked_at: params[12],
68
+ });
69
+ },
70
+ async all() {
71
+ return [...rows.values()];
72
+ },
73
+ };
74
+ const writer = createApiKeyStore({ pepper: "shared" });
75
+ bindApiKeySqlPersist(writer, sql);
76
+ const created = await createApiKey(writer, {
77
+ plane: "user",
78
+ name: "Auth Test",
79
+ scopes: ["member"],
80
+ creatorId: "user_admin",
81
+ creatorScopes: ["member"],
82
+ id: "key_live",
83
+ });
84
+ const reader = createApiKeyStore({ pepper: "shared" });
85
+ bindApiKeySqlPersist(reader, sql);
86
+ expect(reader.keys.size).toBe(0);
87
+ const row = await authenticateApiKey(reader, created.secret);
88
+ expect(row?.id).toBe("key_live");
89
+ expect(reader.keys.has("key_live")).toBe(true);
90
+ });
47
91
  });
@@ -97,6 +97,7 @@ export function bindApiKeySqlPersist(
97
97
  table: string = AUTH_TABLES.apiKeys,
98
98
  ): void {
99
99
  store.persist = (row) => persistApiKeyRow(sql, row, table);
100
+ store.reload = () => hydrateApiKeyStore(sql, store, table);
100
101
  }
101
102
 
102
103
  /** Runtime that can open the app's shared primary SQL connection. */
@@ -85,6 +85,11 @@ export interface ApiKeyStore {
85
85
  pepper?: string;
86
86
  /** Optional write-through persist (SQL). */
87
87
  persist?: (row: ApiKeyRow) => void | Promise<void>;
88
+ /**
89
+ * Reload rows from the durable table. `oke dev` keeps Console and the app
90
+ * in separate processes; a miss on an `oke_` secret refreshes this cache.
91
+ */
92
+ reload?: () => void | Promise<void>;
88
93
  /** Sliding-window hits for per-key rateLimit (subject = key id). */
89
94
  rateHits?: Map<string, number[]>;
90
95
  }
@@ -202,20 +207,33 @@ export async function authenticateApiKey(
202
207
  const now = options.now ?? (() => Date.now());
203
208
  const pepper = store.pepper ?? DEFAULT_PEPPER;
204
209
  const hash = await hashApiKeySecret(secret, pepper);
205
- for (const row of store.keys.values()) {
206
- if (row.hash !== hash) continue;
207
- if (row.revokedAt !== null) return null;
208
- if (row.expiresAt !== null && row.expiresAt <= now()) return null;
209
- if (row.ipAllowlist.length > 0) {
210
- const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
211
- if (!allowed) return null;
210
+ let row = matchApiKeyHash(store, hash);
211
+ if (!row && secret.startsWith("oke_") && store.reload) {
212
+ try {
213
+ await store.reload();
214
+ } catch {
215
+ // Durable read missed — the in-memory miss still stands.
212
216
  }
213
- if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
214
- row.lastUsedAt = now();
215
- await persistRow(store, row);
216
- return row;
217
+ row = matchApiKeyHash(store, hash);
218
+ }
219
+ if (!row) return null;
220
+ if (row.revokedAt !== null) return null;
221
+ if (row.expiresAt !== null && row.expiresAt <= now()) return null;
222
+ if (row.ipAllowlist.length > 0) {
223
+ const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
224
+ if (!allowed) return null;
225
+ }
226
+ if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
227
+ row.lastUsedAt = now();
228
+ await persistRow(store, row);
229
+ return row;
230
+ }
231
+
232
+ function matchApiKeyHash(store: ApiKeyStore, hash: string): ApiKeyRow | undefined {
233
+ for (const row of store.keys.values()) {
234
+ if (row.hash === hash) return row;
217
235
  }
218
- return null;
236
+ return undefined;
219
237
  }
220
238
 
221
239
  /**
@@ -4,6 +4,7 @@
4
4
  * Better Auth is inspiration for shape only; this is OKE-native.
5
5
  */
6
6
 
7
+ import { createEnv } from "../runtime/primitives.ts";
7
8
  import type { PasswordHashOptions } from "../runtime/types.ts";
8
9
  import type { SessionStore } from "./sessions.ts";
9
10
  import { createApiKeyStore, type ApiKeyStore } from "./api-keys.ts";
@@ -226,9 +227,12 @@ export function mintDevAuthSecret(): string {
226
227
  export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAuth {
227
228
  const { auth, env } = options;
228
229
  const isProd = env === "prod" || env === "production";
229
- let secret = auth.secret;
230
+ let secret = auth.secret?.trim() ?? "";
230
231
  let secretMinted = false;
231
- if (!secret || secret.length === 0) {
232
+ if (secret.length === 0) {
233
+ secret = createEnv().get("OKE_AUTH_SECRET")?.trim() ?? "";
234
+ }
235
+ if (secret.length === 0) {
232
236
  if (isProd) {
233
237
  throw new Error(
234
238
  "gate.auth: secret is required in production (set gate.auth.secret or OKE_AUTH_SECRET)",
@@ -21,13 +21,33 @@ afterEach(() => {
21
21
  });
22
22
 
23
23
  describe("gate.auth — config", () => {
24
+ const prevAuthSecret = process.env.OKE_AUTH_SECRET;
25
+
26
+ afterEach(() => {
27
+ if (prevAuthSecret === undefined) delete process.env.OKE_AUTH_SECRET;
28
+ else process.env.OKE_AUTH_SECRET = prevAuthSecret;
29
+ });
30
+
24
31
  test("prod without secret fails", () => {
32
+ delete process.env.OKE_AUTH_SECRET;
25
33
  expect(() =>
26
34
  resolveGateAuth({ auth: { emailAndPassword: { enabled: true } }, env: "prod" }),
27
35
  ).toThrow(/secret is required/);
28
36
  });
29
37
 
38
+ test("OKE_AUTH_SECRET is the pepper when gate.auth.secret is omitted", () => {
39
+ process.env.OKE_AUTH_SECRET = "shared-dev-pepper";
40
+ const resolved = resolveGateAuth({
41
+ auth: { emailAndPassword: { enabled: true } },
42
+ env: "dev",
43
+ });
44
+ expect(resolved.secret).toBe("shared-dev-pepper");
45
+ expect(resolved.secretMinted).toBe(false);
46
+ expect(resolved.apiKeyStore.pepper).toBe("shared-dev-pepper");
47
+ });
48
+
30
49
  test("dev mints secret", () => {
50
+ delete process.env.OKE_AUTH_SECRET;
31
51
  const resolved = resolveGateAuth({
32
52
  auth: { emailAndPassword: { enabled: true } },
33
53
  env: "dev",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Commands named in the agent contract must exist on the CLI.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { describe, expect, test } from "bun:test";
7
+ import { commandNames } from "./registry.ts";
8
+
9
+ describe("AGENTS.md commands", () => {
10
+ test("every oke command mentioned in AGENTS.md is registered", () => {
11
+ const text = readFileSync(new URL("../../AGENTS.md", import.meta.url), "utf8");
12
+ const registered = new Set(commandNames());
13
+ const mentioned = new Set<string>();
14
+ for (const match of text.matchAll(/`oke ([a-z][a-z0-9-]*)/g)) {
15
+ const name = match[1];
16
+ if (name) mentioned.add(name);
17
+ }
18
+ expect(mentioned.has("eval")).toBe(true);
19
+ expect(mentioned.has("decide")).toBe(true);
20
+ expect(text).not.toContain("oke decide certify");
21
+ for (const name of mentioned) {
22
+ expect(registered.has(name)).toBe(true);
23
+ }
24
+ });
25
+ });
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
+ import { EventEmitter } from "node:events";
6
7
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
8
  import { tmpdir } from "node:os";
8
9
  import { join, resolve } from "node:path";
9
10
  import { resetRequiredEnvNames, resetSecrets } from "../elements/vault/declare.ts";
10
11
  import { resetBindings } from "../kernel/on.ts";
11
- import { maybeAskVaultGaps, probeVaultGaps } from "./ask-vault-gaps.ts";
12
+ import { maybeAskVaultGaps, probeVaultGaps, secretPromptWrite } from "./ask-vault-gaps.ts";
13
+ import { promptHidden } from "./vault-secure-input.ts";
12
14
 
13
15
  const OKE = resolve(import.meta.dir, "../index.ts");
14
16
 
@@ -93,6 +95,41 @@ describe("maybeAskVaultGaps", () => {
93
95
  });
94
96
  });
95
97
 
98
+ describe("secretPromptWrite", () => {
99
+ test("keeps the mask on the prompt line", async () => {
100
+ const stdin = new EventEmitter() as EventEmitter & {
101
+ setRawMode?: (mode: boolean) => void;
102
+ off?: (event: string, listener: (...args: unknown[]) => void) => EventEmitter;
103
+ };
104
+ stdin.setRawMode = () => undefined;
105
+ stdin.off = (event: string | symbol, listener: (...args: unknown[]) => void) => {
106
+ stdin.removeListener(event, listener);
107
+ return stdin;
108
+ };
109
+ const shown: string[] = [];
110
+ const echoed: string[] = [];
111
+ const pending = promptHidden("Enter value for OPENROUTER_API_KEY: ", {
112
+ stdin,
113
+ write: secretPromptWrite(
114
+ (text) => shown.push(text),
115
+ (text) => echoed.push(text),
116
+ ),
117
+ exit: () => {
118
+ throw new Error("exit should not run");
119
+ },
120
+ });
121
+ await Promise.resolve();
122
+ stdin.emit("data", "sk");
123
+ stdin.emit("data", "\r");
124
+ expect(await pending).toBe("sk");
125
+ expect(shown).toHaveLength(1);
126
+ expect(shown[0]).toContain("Enter value for OPENROUTER_API_KEY: ");
127
+ expect(shown[0]?.endsWith("\n")).toBe(false);
128
+ expect(echoed.join("")).toBe("**\n");
129
+ expect(shown.join("")).not.toContain("*");
130
+ });
131
+ });
132
+
96
133
  describe("probeVaultGaps", () => {
97
134
  test("sees secrets on oke({ secrets }) after consume drains the registry", async () => {
98
135
  const dir = mkdtempSync(join(tmpdir(), "probe-vault-"));
@@ -14,7 +14,7 @@ import { buildVaultBootChain, normalizeVaultDriverId } from "../elements/vault/b
14
14
  import { createVaultRuntime, VaultBootError, type VaultGap } from "../elements/vault/runtime.ts";
15
15
  import type { OkeOptions } from "../kernel/app.ts";
16
16
  import { requiredEnvRegistry, secretRegistry } from "../kernel/element-registries.ts";
17
- import { formatCliChrome } from "../term.ts";
17
+ import { formatCliChrome, formatStatusLine } from "../term.ts";
18
18
  import { loadOkeConfig } from "./load-config.ts";
19
19
  import { openEnvStore } from "./vault-cmd.ts";
20
20
  import { promptHidden } from "./vault-secure-input.ts";
@@ -147,6 +147,35 @@ function unionSecrets(
147
147
  return out;
148
148
  }
149
149
 
150
+ /**
151
+ * Writer for {@link promptHidden} during `oke dev`.
152
+ *
153
+ * The question is one chrome row with no trailing newline, so mask
154
+ * characters stay on that row. Echo (`*`, backspace, Enter) goes straight
155
+ * to stdout: `formatCliChrome` would turn each `*` into its own `│` line,
156
+ * and the anchored board would count each of those writes as a new row.
157
+ *
158
+ * @param sink - Line-oriented stdout (the dev board wrapper)
159
+ * @param echo - Raw echo. Defaults to `process.stdout.write`
160
+ */
161
+ export function secretPromptWrite(
162
+ sink: (text: string) => void,
163
+ echo: (text: string) => void = (text) => {
164
+ process.stdout.write(text);
165
+ },
166
+ ): (text: string) => void {
167
+ let opened = false;
168
+ return (text: string) => {
169
+ if (!opened) {
170
+ opened = true;
171
+ const line = formatStatusLine(text);
172
+ sink(line.endsWith("\n") ? line.slice(0, -1) : line);
173
+ return;
174
+ }
175
+ echo(text);
176
+ };
177
+ }
178
+
150
179
  /**
151
180
  * After compose hydrate (and usually schema push), fill Vault boot gaps
152
181
  * one-by-one into `.env.local`. Skips when non-TTY or no gaps.
@@ -175,7 +204,11 @@ export async function maybeAskVaultGaps(options: AskVaultGapsOptions): Promise<n
175
204
  const envPath = resolve(cwd, options.envFile ?? ".env.local");
176
205
  const store = await openEnvStore(envPath);
177
206
  const read =
178
- options.readSecret ?? ((prompt: string) => promptHidden(prompt, { write: chromeWrite }));
207
+ options.readSecret ??
208
+ ((prompt: string) =>
209
+ promptHidden(prompt, {
210
+ write: secretPromptWrite(write),
211
+ }));
179
212
 
180
213
  const remaining: VaultGap[] = [];
181
214
  for (const gap of gaps) {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `oke decide promote` — fetch the operator candidate and write the lockfile.
3
+ * The command does not recompute a certificate.
4
+ */
5
+
6
+ import { join, resolve } from "node:path";
7
+ import {
8
+ DECISION_LOCK_FILENAME,
9
+ lockFromCandidate,
10
+ parseDecisionLockfile,
11
+ type DecisionLockfile,
12
+ } from "../elements/ai/decisions/certificate.ts";
13
+ import { DECISION_EXPORT_WARNING } from "../elements/ai/decisions/export.ts";
14
+
15
+ /** Options for {@link promoteDecision}. */
16
+ export interface PromoteDecisionOptions {
17
+ readonly name: string;
18
+ /** Origin of the running app, including scheme and port. */
19
+ readonly origin: string;
20
+ /** Lockfile path. Defaults to `oke-decisions.lock.json` in the app root. */
21
+ readonly lockPath?: string;
22
+ /** Operator credential header value (`Authorization`). */
23
+ readonly authorization?: string;
24
+ /** Injected fetch. Defaults to the global fetch. */
25
+ readonly fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
26
+ /** Existing lockfile body. When omitted, the file is read if it exists. */
27
+ readonly current?: DecisionLockfile;
28
+ }
29
+
30
+ /**
31
+ * Fetch one candidate and write it into the app lockfile.
32
+ *
33
+ * @param options - Decision name, origin, and credentials
34
+ */
35
+ export async function promoteDecision(options: PromoteDecisionOptions): Promise<DecisionLockfile> {
36
+ const fetcher = options.fetcher ?? fetch;
37
+ const url = `${options.origin.replace(/\/$/, "")}/_oke/decisions/${encodeURIComponent(options.name)}/candidate`;
38
+ const headers = new Headers();
39
+ if (options.authorization) headers.set("authorization", options.authorization);
40
+ const res = await fetcher(url, { headers });
41
+ if (!res.ok) {
42
+ throw new Error(`oke decide promote: ${res.status} from ${url}`);
43
+ }
44
+ const candidate: unknown = await res.json();
45
+ const root = resolve(process.env["OKE_ROOT_DIR"] ?? ".");
46
+ const path = resolve(options.lockPath ?? join(root, DECISION_LOCK_FILENAME));
47
+ let current = options.current;
48
+ if (!current) {
49
+ const file = Bun.file(path);
50
+ if (await file.exists()) current = parseDecisionLockfile(await file.json());
51
+ }
52
+ const next = lockFromCandidate(options.name, candidate, current);
53
+ const { persistDecisionDrift } = await import("../elements/ai/decisions/labels.ts");
54
+ persistDecisionDrift(options.name, false);
55
+ await Bun.write(path, `${JSON.stringify(next, null, 2)}\n`);
56
+ return next;
57
+ }
58
+
59
+ /** Options for {@link exportDecisionLabelsFile}. */
60
+ export interface ExportDecisionLabelsOptions {
61
+ readonly name: string;
62
+ /** Origin of the running app, including scheme and port. */
63
+ readonly origin: string;
64
+ /** File the JSONL is written to. */
65
+ readonly out: string;
66
+ /** Operator credential header value (`Authorization`). */
67
+ readonly authorization?: string;
68
+ /** Tenant the operator is exporting. A mismatch is rejected by the app. */
69
+ readonly tenant?: string;
70
+ /** Injected fetch. Defaults to the global fetch. */
71
+ readonly fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
72
+ /** Where the production-data warning is printed. Defaults to stdout. */
73
+ readonly write?: (text: string) => void;
74
+ }
75
+
76
+ /**
77
+ * Fetch reviewed labels and write seed JSONL. Prints that the file holds production data.
78
+ *
79
+ * @param options - Decision name, origin, and output path
80
+ */
81
+ export async function exportDecisionLabelsFile(
82
+ options: ExportDecisionLabelsOptions,
83
+ ): Promise<void> {
84
+ const fetcher = options.fetcher ?? fetch;
85
+ const url = new URL(
86
+ `/_oke/decisions/${encodeURIComponent(options.name)}/labels`,
87
+ options.origin.endsWith("/") ? options.origin : `${options.origin}/`,
88
+ );
89
+ if (options.tenant) url.searchParams.set("tenant", options.tenant);
90
+ const headers = new Headers();
91
+ if (options.authorization) headers.set("authorization", options.authorization);
92
+ const res = await fetcher(url.toString(), { headers });
93
+ if (!res.ok) {
94
+ throw new Error(`oke decide labels: ${res.status} from ${url}`);
95
+ }
96
+ const body = (await res.json()) as { lines?: string; data?: { lines?: string } };
97
+ const lines = body.data?.lines ?? body.lines ?? "";
98
+ const write = options.write ?? ((text: string) => process.stdout.write(text));
99
+ write(`${DECISION_EXPORT_WARNING}\n`);
100
+ await Bun.write(options.out, lines);
101
+ }
102
+
103
+ /**
104
+ * `oke decide labels <name> --export`.
105
+ *
106
+ * @param name - Decision name
107
+ * @param rest - Flags after the name
108
+ */
109
+ async function exportLabelsCommand(name: string | undefined, rest: string[]): Promise<number> {
110
+ if (!name || !rest.includes("--export")) {
111
+ console.error("oke decide: expected `oke decide labels <name> --export`");
112
+ return 1;
113
+ }
114
+ let origin = process.env.OKE_ORIGIN ?? "http://127.0.0.1:6530";
115
+ let out = `${name}.labels.jsonl`;
116
+ let authorization = process.env.OKE_OPERATOR_AUTHORIZATION;
117
+ let tenant: string | undefined;
118
+ for (let i = 0; i < rest.length; i++) {
119
+ const arg = rest[i];
120
+ if (arg === "--origin") origin = rest[++i] ?? origin;
121
+ else if (arg === "--out") out = rest[++i] ?? out;
122
+ else if (arg === "--authorization") authorization = rest[++i];
123
+ else if (arg === "--tenant") tenant = rest[++i];
124
+ }
125
+ await exportDecisionLabelsFile({
126
+ name,
127
+ origin,
128
+ out,
129
+ ...(authorization !== undefined ? { authorization } : {}),
130
+ ...(tenant !== undefined ? { tenant } : {}),
131
+ });
132
+ return 0;
133
+ }
134
+
135
+ /**
136
+ * CLI entry for `oke decide`.
137
+ *
138
+ * @param args - Remaining argv after `decide`
139
+ */
140
+ export async function decideCli(args: string[]): Promise<number> {
141
+ const [sub, name, ...rest] = args;
142
+ if (sub === "--help" || sub === "-h" || !sub) {
143
+ console.log(`oke decide promote <name> [--origin URL] [--lock path]
144
+ oke decide labels <name> --export [--out file] [--origin URL]
145
+
146
+ promote fetches the operator candidate and writes oke-decisions.lock.json.
147
+ labels --export writes reviewed labels as seed JSONL. The file contains production data.
148
+ `);
149
+ return sub ? 0 : 1;
150
+ }
151
+ if (sub === "labels") return exportLabelsCommand(name, rest);
152
+ if (sub !== "promote" || !name) {
153
+ console.error(
154
+ "oke decide: expected `oke decide promote <name>` or `oke decide labels <name> --export`",
155
+ );
156
+ return 1;
157
+ }
158
+ let origin = process.env.OKE_ORIGIN ?? "http://127.0.0.1:6530";
159
+ let lockPath: string | undefined;
160
+ let authorization = process.env.OKE_OPERATOR_AUTHORIZATION;
161
+ for (let i = 0; i < rest.length; i++) {
162
+ const arg = rest[i];
163
+ if (arg === "--origin") origin = rest[++i] ?? origin;
164
+ else if (arg === "--lock") lockPath = rest[++i];
165
+ else if (arg === "--authorization") authorization = rest[++i];
166
+ }
167
+ await promoteDecision({
168
+ name,
169
+ origin,
170
+ ...(lockPath !== undefined ? { lockPath } : {}),
171
+ ...(authorization !== undefined ? { authorization } : {}),
172
+ });
173
+ return 0;
174
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `oke dev` reloads the decision lockfile without a restart.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdtemp } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import {
10
+ DECISION_LOCK_FILENAME,
11
+ getDecisionLock,
12
+ resetDecisionCertificates,
13
+ } from "../elements/ai/decisions/certificate.ts";
14
+ import { watchDecisionLockfile } from "./decision-lock-watch.ts";
15
+
16
+ describe("decision lockfile watch", () => {
17
+ test("a change calls setDecisionLock without restarting", async () => {
18
+ resetDecisionCertificates();
19
+ const root = await mkdtemp(join(tmpdir(), "oke-lock-"));
20
+ const path = join(root, DECISION_LOCK_FILENAME);
21
+ await Bun.write(
22
+ path,
23
+ `${JSON.stringify({ decisions: { triage: { model: "m", questions: {} } } })}\n`,
24
+ );
25
+ let listener: ((event: "rename" | "change", filename: string | null) => void) | undefined;
26
+ const watch = watchDecisionLockfile(root, (_path, _options, next) => {
27
+ listener = next;
28
+ return { close() {} };
29
+ });
30
+ listener?.("change", DECISION_LOCK_FILENAME);
31
+ await watch.flushed();
32
+ expect(getDecisionLock()?.decisions.triage?.model).toBe("m");
33
+
34
+ await Bun.write(
35
+ path,
36
+ `${JSON.stringify({ decisions: { route: { model: "n", questions: {} } } })}\n`,
37
+ );
38
+ listener?.("change", DECISION_LOCK_FILENAME);
39
+ await watch.flushed();
40
+ expect(getDecisionLock()?.decisions.route?.model).toBe("n");
41
+ expect(getDecisionLock()?.decisions.triage).toBeUndefined();
42
+ watch.close();
43
+ resetDecisionCertificates();
44
+ });
45
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `oke dev` reloads `oke-decisions.lock.json` without restarting.
3
+ * Production reads the file once, at boot.
4
+ */
5
+
6
+ import { watch } from "node:fs";
7
+ import {
8
+ DECISION_LOCK_FILENAME,
9
+ loadDecisionLockfile,
10
+ } from "../elements/ai/decisions/certificate.ts";
11
+
12
+ /** Handle for the lockfile watcher. */
13
+ export interface DecisionLockWatch {
14
+ close(): void;
15
+ /** Resolves after the latest reload this watcher started. */
16
+ flushed(): Promise<void>;
17
+ }
18
+
19
+ /**
20
+ * Install `setDecisionLock` whenever the lockfile changes.
21
+ *
22
+ * @param root - App root
23
+ * @param watchFs - Injected watch. The dev server uses `node:fs` on the root.
24
+ */
25
+ export function watchDecisionLockfile(
26
+ root: string,
27
+ watchFs?: (
28
+ path: string,
29
+ options: { readonly recursive: true },
30
+ listener: (event: "rename" | "change", filename: string | null) => void,
31
+ ) => { close(): void },
32
+ ): DecisionLockWatch {
33
+ let pending: Promise<void> = Promise.resolve();
34
+ const reload = (): void => {
35
+ pending = loadDecisionLockfile(root).then(
36
+ () => undefined,
37
+ () => undefined,
38
+ );
39
+ };
40
+ if (watchFs) {
41
+ const handle = watchFs(`${root}/${DECISION_LOCK_FILENAME}`, { recursive: true }, () => {
42
+ reload();
43
+ });
44
+ return { close: () => handle.close(), flushed: () => pending };
45
+ }
46
+ const handle = watch(root, (_event, filename) => {
47
+ if (filename?.toString() !== DECISION_LOCK_FILENAME) return;
48
+ reload();
49
+ });
50
+ return { close: () => handle.close(), flushed: () => pending };
51
+ }