@desplega.ai/agent-swarm 1.165.0 → 1.166.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 (188) hide show
  1. package/dist/{acp-adapter-w6zws6me.js → acp-adapter-yzfxn8vj.js} +4 -4
  2. package/dist/{actions-jna0b3wm.js → actions-qz3m692w.js} +20 -24
  3. package/dist/{amp-adapter-0eve81pz.js → amp-adapter-j1xbmsqz.js} +4 -4
  4. package/dist/{app-75hm5cfa.js → app-43vrdmv8.js} +6 -7
  5. package/dist/{assistant-yzd51j33.js → assistant-ertffmka.js} +21 -25
  6. package/dist/{boot-reembed-z158xq32.js → boot-reembed-998jtpdg.js} +9 -12
  7. package/dist/{boot-reembed-2ex2mcsh.js → boot-reembed-s7k7c6v2.js} +10 -12
  8. package/dist/boot-scrub-sweep-nepsk4t1.js +211 -0
  9. package/dist/{boot-seeds-2bmnh18p.js → boot-seeds-vvzs1zhk.js} +8 -8
  10. package/dist/{claude-adapter-jbj1mycz.js → claude-adapter-2zvmekrp.js} +9 -11
  11. package/dist/{claude-managed-adapter-17ggz09t.js → claude-managed-adapter-s49wbk8t.js} +3 -3
  12. package/dist/{claude-managed-setup-ptm1nzm2.js → claude-managed-setup-cvhqq8qn.js} +4 -4
  13. package/dist/{claude-sdk-session-9re1g7r2.js → claude-sdk-session-kkbxtw5q.js} +2618 -2600
  14. package/dist/{cli-k6xx3pve.js → cli-0nhdwxd5.js} +4 -4
  15. package/dist/{cli-n9ehc28t.js → cli-0qjcsxgz.js} +4 -4
  16. package/dist/{cli-zn2v3t8v.js → cli-3ddybbbx.js} +1 -1
  17. package/dist/{cli-5anb0ev3.js → cli-3qn4r334.js} +71 -14
  18. package/dist/{cli-1kzfvd1z.js → cli-4r1sjfc4.js} +9 -5
  19. package/dist/{cli-ba66wyhy.js → cli-66zh26sp.js} +6 -6
  20. package/dist/{cli-9xb89xca.js → cli-68r1j4sg.js} +6 -6
  21. package/dist/{cli-xnzrzets.js → cli-8kgvmkak.js} +2 -2
  22. package/dist/{cli-28smfvbn.js → cli-8t229cmr.js} +5 -5
  23. package/dist/{cli-9866bd07.js → cli-aqcey741.js} +1 -1
  24. package/dist/{cli-90v942q1.js → cli-cpfmqz3n.js} +8 -8
  25. package/dist/{cli-50eg13rj.js → cli-df56tqdw.js} +459 -282
  26. package/dist/{cli-8gpbwy09.js → cli-dw2q9f7g.js} +5 -5
  27. package/dist/{cli-0n4fasz0.js → cli-ecknp7p1.js} +15 -15
  28. package/dist/{cli-xb3c6aat.js → cli-f2n17zhp.js} +1 -1
  29. package/dist/{cli-ftry7975.js → cli-feh3dkah.js} +1 -1
  30. package/dist/{cli-amwhs8h2.js → cli-fn697bnt.js} +2 -2
  31. package/dist/{cli-prt9emct.js → cli-gt8azyet.js} +3 -3
  32. package/dist/{cli-59jn5twa.js → cli-h3x613ze.js} +2 -2
  33. package/dist/{cli-mmzwwptd.js → cli-hf5s9pgf.js} +1 -1
  34. package/dist/cli-j2jbr8af.js +516 -0
  35. package/dist/{cli-wfajtmf7.js → cli-jhm6hzjn.js} +593 -10
  36. package/dist/{cli-xtgtyafv.js → cli-jvj6a0cv.js} +190 -439
  37. package/dist/{cli-hpryr6em.js → cli-mb75wpxf.js} +1 -1
  38. package/dist/{cli-420bdgw0.js → cli-mv2520nm.js} +1 -1
  39. package/dist/{cli-pjxnr1jn.js → cli-mwegs4af.js} +3 -3
  40. package/dist/{cli-5b2fjjzh.js → cli-p4ztb80h.js} +2 -2
  41. package/dist/{cli-e7t4snpa.js → cli-rggdq8wq.js} +4 -4
  42. package/dist/{cli-bdv39kgj.js → cli-rsy933yz.js} +5 -5
  43. package/dist/{cli-gh5yqzpe.js → cli-v2a8x45d.js} +2 -2
  44. package/dist/{cli-bns5c8k9.js → cli-vzc73wet.js} +43 -47
  45. package/dist/cli-wt7ccy4r.js +2654 -0
  46. package/dist/{cli-j0pdrmwg.js → cli-x7kpr72a.js} +3 -3
  47. package/dist/{cli-x7afk5wm.js → cli-xvc3q9br.js} +1 -1
  48. package/dist/{cli-9s3h836g.js → cli-xvdgasgy.js} +1 -1
  49. package/dist/{cli-zvckv750.js → cli-y59h2pk9.js} +2 -2
  50. package/dist/{cli-qdv8kgz7.js → cli-yshaazs1.js} +13884 -3241
  51. package/dist/{cli-nfygw434.js → cli-ysnyhws9.js} +2 -2
  52. package/dist/{cli-fw6vdcq5.js → cli-zp67qg5x.js} +34 -1
  53. package/dist/{cli-ma3zvh39.js → cli-zy7vwsne.js} +6066 -5829
  54. package/dist/cli.js +24 -20
  55. package/dist/{codex-adapter-3qnxshje.js → codex-adapter-dw1wxqt8.js} +7 -7
  56. package/dist/{codex-login-brkxhhdg.js → codex-login-terbdsqq.js} +2 -2
  57. package/dist/{codex-session-runner-612wnapp.js → codex-session-runner-3cgb8y1g.js} +7 -7
  58. package/dist/{commands-2ybfcxkj.js → commands-mhxam6z2.js} +6 -6
  59. package/dist/{cursor-adapter-p3jgdehv.js → cursor-adapter-2qda3st7.js} +3 -3
  60. package/dist/{db-f4ans0jj.js → db-sjks418k.js} +8 -6
  61. package/dist/{devin-adapter-dhzt01ff.js → devin-adapter-bffjj5n4.js} +2 -2
  62. package/dist/{dispatcher-pk182apd.js → dispatcher-1es759rn.js} +18 -22
  63. package/dist/{dsh-adapter-7twaeps1.js → dsh-adapter-bek4cyw9.js} +3 -3
  64. package/dist/{e2b-y8t3jzyz.js → e2b-nd50eccg.js} +1 -1
  65. package/dist/{event-bus-w7ac4n3c.js → event-bus-vnfthafg.js} +2 -2
  66. package/dist/{grok-adapter-yt9pab3k.js → grok-adapter-ar9cw9jg.js} +5 -5
  67. package/dist/{handlers-g2wnr4t9.js → handlers-1gkbhktr.js} +21 -25
  68. package/dist/{hook-f7jt9vnm.js → hook-ds2zxq9v.js} +14 -16
  69. package/dist/{hook-bbdpnkb4.js → hook-vxa3tcsy.js} +8 -10
  70. package/dist/{http-w40q4w94.js → http-3h21z9md.js} +247 -164
  71. package/dist/{index-056j5kzn.js → index-9b65j1m5.js} +8 -8
  72. package/dist/{index-9bfssf54.js → index-bn6cy8ps.js} +18 -22
  73. package/dist/{index-cj1r9q83.js → index-dkfxpkmm.js} +19 -23
  74. package/dist/{index-7n70jwbb.js → index-f8xnqrye.js} +19 -21
  75. package/dist/{index-fdk1t4d6.js → index-n0rxa4ca.js} +11962 -9900
  76. package/dist/{index-k8xc083e.js → index-n99fycq7.js} +18 -22
  77. package/dist/{keepalive-1tj10tf4.js → keepalive-3xspa9jw.js} +11 -8
  78. package/dist/{lead-st0bg7v4.js → lead-6qphc124.js} +34 -38
  79. package/dist/{maintenance-nz71zats.js → maintenance-0teem1bb.js} +7 -9
  80. package/dist/{node-f0ykebbv.js → node-9sg7ajby.js} +25 -7
  81. package/dist/{oauth-refresh-sweep-aefzwvvq.js → oauth-refresh-sweep-sh9q25xp.js} +10 -7
  82. package/dist/{onboard-kgpr9mbn.js → onboard-54qmnqxe.js} +2 -2
  83. package/dist/{opencode-adapter-0c8e70dk.js → opencode-adapter-wjfa2wvd.js} +2 -2
  84. package/dist/{otel-impl-x8zp4y5a.js → otel-impl-ycqzp2a1.js} +2 -2
  85. package/dist/{pi-mono-adapter-t280ah5c.js → pi-mono-adapter-e77e9zst.js} +4 -4
  86. package/dist/{pricing-refresh-9e0q7cwk.js → pricing-refresh-atnkngqs.js} +7 -7
  87. package/dist/{rbac-roles-kcp6zge2.js → rbac-roles-9pjphh9q.js} +6 -6
  88. package/dist/{rbac-roles-790j3ga8.js → rbac-roles-j78wdksm.js} +6 -6
  89. package/dist/{render-v2-tch4nefj.js → render-v2-pxjs49fx.js} +6 -7
  90. package/dist/{setup-rcm303ns.js → setup-ymej0xtx.js} +2 -2
  91. package/dist/{work-objects-ctg7t70w.js → work-objects-3nh4ymzb.js} +22 -26
  92. package/dist/{worker-5dp0xm55.js → worker-qa9fkz5s.js} +34 -38
  93. package/dist/{x-v3yw66zs.js → x-ma4gnfgy.js} +2 -2
  94. package/openapi.json +5 -2
  95. package/package.json +9 -5
  96. package/src/be/automation-preflight.ts +9 -4
  97. package/src/be/boot-scrub-sweep.ts +302 -0
  98. package/src/be/crypto/key-bootstrap.ts +1 -1
  99. package/src/be/db/approvals.ts +15 -1
  100. package/src/be/db/codex-oauth-device-flows.ts +40 -0
  101. package/src/be/db/config-secrets.ts +31 -0
  102. package/src/be/db/runtime.ts +4 -0
  103. package/src/be/db/script-runs.ts +15 -0
  104. package/src/be/db/tasks/write.ts +1 -1
  105. package/src/be/db-queries/mcp-oauth.ts +5 -0
  106. package/src/be/db-queries/oauth.ts +77 -0
  107. package/src/be/db.ts +162 -59
  108. package/src/be/events.ts +17 -1
  109. package/src/be/memory/index-content.ts +2 -2
  110. package/src/be/memory/providers/sqlite-store.ts +63 -11
  111. package/src/be/memory/types.ts +14 -0
  112. package/src/be/migrations/201_workflow_replay_columns.sql +10 -0
  113. package/src/be/migrations/202_codex_oauth_device_flows.sql +24 -0
  114. package/src/be/scripts/db.ts +28 -4
  115. package/src/be/scripts/source-secrets.ts +37 -0
  116. package/src/be/scrub-json.ts +32 -0
  117. package/src/be/sealed-json.ts +59 -0
  118. package/src/be/secret-registry.ts +131 -0
  119. package/src/be/workflow-replay.ts +77 -0
  120. package/src/http/codex-oauth-device.ts +8 -10
  121. package/src/http/db-query-bounded.ts +3 -0
  122. package/src/http/db-query-shared.ts +107 -15
  123. package/src/http/db-query.ts +10 -2
  124. package/src/http/index.ts +23 -6
  125. package/src/http/script-runs.ts +9 -1
  126. package/src/http/scripts.ts +15 -0
  127. package/src/http/workflows.ts +3 -2
  128. package/src/oauth/mcp-wrapper.ts +1 -1
  129. package/src/providers/acp-adapter.ts +25 -11
  130. package/src/providers/acp-target-catalog.ts +35 -1
  131. package/src/providers/acp-targets.ts +69 -3
  132. package/src/providers/amp-adapter.ts +1 -1
  133. package/src/providers/cursor-adapter.ts +1 -1
  134. package/src/script-workflows/supervisor.ts +11 -2
  135. package/src/scripts-runtime/eval-harness.ts +2 -12
  136. package/src/scripts-runtime/executors/quickjs-bundle.ts +152 -0
  137. package/src/scripts-runtime/executors/quickjs-prelude.ts +285 -0
  138. package/src/scripts-runtime/executors/quickjs-runner.ts +580 -0
  139. package/src/scripts-runtime/executors/quickjs-worker.ts +81 -0
  140. package/src/scripts-runtime/executors/quickjs.ts +196 -0
  141. package/src/scripts-runtime/executors/registry.ts +2 -0
  142. package/src/scripts-runtime/executors/sourcemap.ts +79 -0
  143. package/src/scripts-runtime/script-error-hints.ts +14 -0
  144. package/src/server.ts +3 -1
  145. package/src/tests/acp-adapter.test.ts +127 -2
  146. package/src/tests/boot-scrub-sweep.test.ts +390 -0
  147. package/src/tests/check-text-columns.test.ts +145 -0
  148. package/src/tests/claude-sdk-process.fixture.ts +13 -2
  149. package/src/tests/codex-oauth-device.test.ts +23 -37
  150. package/src/tests/db-query-denylist.test.ts +202 -0
  151. package/src/tests/db-task-read-contract.test.ts +1 -0
  152. package/src/tests/db-task-write-contract.test.ts +1 -0
  153. package/src/tests/entrypoint-acp-target-binary.test.ts +31 -1
  154. package/src/tests/events-scrub.test.ts +88 -0
  155. package/src/tests/fixtures/secret-corpus.ts +320 -0
  156. package/src/tests/script-connections.test.ts +5 -0
  157. package/src/tests/script-executor-conformance.test.ts +3 -0
  158. package/src/tests/script-executor-quickjs.test.ts +442 -0
  159. package/src/tests/script-executor-registry.test.ts +6 -1
  160. package/src/tests/script-runs-http.test.ts +88 -1
  161. package/src/tests/script-runs-scrub.test.ts +275 -0
  162. package/src/tests/scripts-http.test.ts +82 -1
  163. package/src/tests/scrubbed-text-12e.test.ts +289 -0
  164. package/src/tests/sealed-key-guard.test.ts +166 -0
  165. package/src/tests/secret-canary-sweep.test.ts +958 -0
  166. package/src/tests/secret-registry.test.ts +355 -0
  167. package/src/tests/secret-rules.test.ts +230 -0
  168. package/src/tests/secret-scrubber.test.ts +3 -2
  169. package/src/tests/workflow-replay.test.ts +518 -0
  170. package/src/tests/workflow-scrub.test.ts +242 -0
  171. package/src/tools/db-query.ts +1 -1
  172. package/src/tools/inject-learning.ts +1 -1
  173. package/src/tools/workflows/get-workflow-run.ts +3 -2
  174. package/src/types.ts +1 -1
  175. package/src/utils/secret-rules.generated.ts +2055 -0
  176. package/src/utils/secret-scrubber.ts +363 -29
  177. package/src/workflows/engine.ts +39 -4
  178. package/src/workflows/recovery.ts +10 -1
  179. package/src/workflows/resume.ts +62 -3
  180. package/src/workflows/retry-poller.ts +3 -0
  181. package/dist/boot-scrub-logs-246yn076.js +0 -99
  182. package/dist/cli-3a4m1aws.js +0 -572
  183. package/dist/cli-a87b04k8.js +0 -951
  184. package/dist/cli-cvk6yzw5.js +0 -40
  185. package/dist/cli-h1spv8v4.js +0 -427
  186. package/dist/cli-jqt9hrnz.js +0 -168
  187. package/dist/cli-yhmwmv0g.js +0 -9281
  188. package/src/be/boot-scrub-logs.ts +0 -132
@@ -2,6 +2,7 @@ import { getDb, getDbClient, isSqliteVecAvailable } from "@/be/db";
2
2
  import { cosineSimilarity, deserializeEmbedding, serializeEmbedding } from "@/be/embedding";
3
3
  import { contentSha256 } from "@/commands/profile-sync";
4
4
  import type { AgentMemory, AgentMemoryScope, AgentMemorySource } from "@/types";
5
+ import { scrubSecrets } from "@/utils/secret-scrubber";
5
6
  import {
6
7
  EMBEDDING_DIMENSIONS,
7
8
  isHybridSearchEnabled,
@@ -19,6 +20,7 @@ import type {
19
20
  MemoryInput,
20
21
  MemoryListOptions,
21
22
  MemoryRetrievalSource,
23
+ MemoryScrubFields,
22
24
  MemorySearchOptions,
23
25
  MemoryStats,
24
26
  MemoryStore,
@@ -437,7 +439,12 @@ export class SqliteMemoryStore implements MemoryStore {
437
439
  const now = new Date().toISOString();
438
440
  const key = input.key ?? `${input.scope}/${input.source}/${id}`;
439
441
  const expiresAt = computeExpiresAt(input.source, key);
440
- const contentHash = contentSha256(input.content);
442
+ // Scrub before the hash, the FTS sync and the caller's embedding (callers
443
+ // embed the returned row's content), so all of them see the same text.
444
+ const name = scrubSecrets(input.name);
445
+ const content = scrubSecrets(input.content);
446
+ const summary = input.summary == null ? null : scrubSecrets(input.summary);
447
+ const contentHash = contentSha256(content);
441
448
  const version = 1;
442
449
 
443
450
  const row = await getDbClient().transaction(async (tx) => {
@@ -449,15 +456,15 @@ export class SqliteMemoryStore implements MemoryStore {
449
456
  input.agentId ?? null,
450
457
  input.scope,
451
458
  key,
452
- input.name,
453
- input.content,
454
- input.summary ?? null,
459
+ name,
460
+ content,
461
+ summary,
455
462
  input.source,
456
463
  input.sourceTaskId ?? null,
457
464
  input.sourcePath ?? null,
458
465
  input.chunkIndex ?? 0,
459
466
  input.totalChunks ?? 1,
460
- JSON.stringify(input.tags ?? []),
467
+ JSON.stringify((input.tags ?? []).map((tag) => scrubSecrets(tag))),
461
468
  now,
462
469
  now,
463
470
  now,
@@ -478,7 +485,7 @@ export class SqliteMemoryStore implements MemoryStore {
478
485
  crypto.randomUUID(),
479
486
  inserted.id,
480
487
  version,
481
- input.content,
488
+ content,
482
489
  contentHash,
483
490
  input.intent ?? "create memory",
484
491
  input.agentId ?? null,
@@ -1009,13 +1016,17 @@ export class SqliteMemoryStore implements MemoryStore {
1009
1016
 
1010
1017
  const previousVersion = row.version ?? 1;
1011
1018
  const moving = input.newKey !== undefined && input.newKey !== row.key;
1019
+ // Scrub the edited text before the hash and FTS sync; callers embed the
1020
+ // returned content, so the vector matches the stored row.
1012
1021
  const nextContent = moveOnly
1013
1022
  ? row.content
1014
- : applyEditMode(input.mode, row.content, {
1015
- content: input.content,
1016
- oldString: input.oldString,
1017
- newString: input.newString,
1018
- });
1023
+ : scrubSecrets(
1024
+ applyEditMode(input.mode, row.content, {
1025
+ content: input.content,
1026
+ oldString: input.oldString,
1027
+ newString: input.newString,
1028
+ }),
1029
+ );
1019
1030
 
1020
1031
  const nextHash = contentSha256(nextContent);
1021
1032
  const contentChanged = !moveOnly && nextHash !== row.contentHash;
@@ -1268,6 +1279,47 @@ export class SqliteMemoryStore implements MemoryStore {
1268
1279
  }
1269
1280
  }
1270
1281
 
1282
+ async rewriteForScrub(
1283
+ id: string,
1284
+ before: MemoryScrubFields,
1285
+ after: MemoryScrubFields,
1286
+ ): Promise<boolean> {
1287
+ const contentChanged = after.content !== before.content;
1288
+ return getDbClient().transaction(async (tx) => {
1289
+ // Compare-and-set: the sweep scrubbed `before` outside the write lock, so
1290
+ // skip the row if a concurrent write moved it since.
1291
+ const result = await tx.run(
1292
+ `UPDATE agent_memory
1293
+ SET name = ?, content = ?, summary = ?, contentHash = ?,
1294
+ embedding = CASE WHEN ? = 1 THEN NULL ELSE embedding END,
1295
+ embeddingModel = CASE WHEN ? = 1 THEN NULL ELSE embeddingModel END
1296
+ WHERE id = ? AND name IS ? AND content IS ? AND summary IS ?`,
1297
+ [
1298
+ after.name,
1299
+ after.content,
1300
+ after.summary,
1301
+ contentSha256(after.content),
1302
+ contentChanged ? 1 : 0,
1303
+ contentChanged ? 1 : 0,
1304
+ id,
1305
+ before.name,
1306
+ before.content,
1307
+ before.summary,
1308
+ ],
1309
+ );
1310
+ if (result.changes === 0) return false;
1311
+ // The vector embeds the old (secret-bearing) content. Drop it; the
1312
+ // re-embed backfill picks the row up via `embedding IS NULL`.
1313
+ if (contentChanged && this.vecInitialized && this.getVecTableSchema()) {
1314
+ await tx.run("DELETE FROM memory_vec WHERE memory_id = ?", [id]);
1315
+ }
1316
+ if (after.name !== before.name || contentChanged) {
1317
+ await this.syncFtsRow(id, after.name, after.content);
1318
+ }
1319
+ return true;
1320
+ });
1321
+ }
1322
+
1271
1323
  async getStats(agentId: string): Promise<MemoryStats> {
1272
1324
  const db = getDbClient();
1273
1325
 
@@ -40,6 +40,17 @@ export interface MemoryStore {
40
40
  deleteBySourcePath(sourcePath: string, agentId: string): Promise<number>;
41
41
  purgeExpired(): Promise<number>;
42
42
  updateEmbedding(id: string, embedding: Float32Array, model: string): Promise<void>;
43
+ /**
44
+ * Redact a row in place (boot retro-sweep). Writes only if the row still
45
+ * holds `before`; keeps FTS in sync and drops the embedding when the
46
+ * content changed so the re-embed backfill regenerates it. Returns whether
47
+ * the row was written.
48
+ */
49
+ rewriteForScrub(
50
+ id: string,
51
+ before: MemoryScrubFields,
52
+ after: MemoryScrubFields,
53
+ ): Promise<boolean>;
43
54
  getStats(agentId: string): Promise<MemoryStats>;
44
55
  getHealth(): MemoryHealth;
45
56
  }
@@ -48,6 +59,9 @@ export interface MemoryStore {
48
59
  // Supporting types
49
60
  // ============================================================================
50
61
 
62
+ /** The free-text `agent_memory` columns the boot retro-sweep redacts. */
63
+ export type MemoryScrubFields = { name: string; content: string; summary: string | null };
64
+
51
65
  export interface MemoryInput {
52
66
  agentId: string | null;
53
67
  scope: AgentMemoryScope;
@@ -0,0 +1,10 @@
1
+ -- Byte-exact replay state for workflow runs, sealed at rest.
2
+ --
3
+ -- `workflow_run_steps.output` and `workflow_runs.context` are scrubbed at write
4
+ -- for display and SQL filters (`json_extract(context, '$.swarm.requestedByUserId')`).
5
+ -- Resume, retry, dedup and recovery rebuild the live ctx from these values, so
6
+ -- they need the exact value: these columns hold it, encrypted with `sealJson`
7
+ -- (src/be/sealed-json.ts). NULL for rows written before this migration; their
8
+ -- readers fall back to the plain column.
9
+ ALTER TABLE workflow_run_steps ADD COLUMN output_replay TEXT;
10
+ ALTER TABLE workflow_runs ADD COLUMN context_replay TEXT;
@@ -0,0 +1,24 @@
1
+ -- Codex device-login flow state, moved out of `kv_entries`.
2
+ --
3
+ -- `state` is the encrypted flow (device auth id, user code, poll lease). It
4
+ -- lived in `kv_entries` namespace 'codex-oauth-device', which db-query must
5
+ -- keep readable for general KV debugging. A dedicated table lets db-query deny
6
+ -- this store by table name, with no SQL-text parsing.
7
+ --
8
+ -- expires_at: unix-ms. Expired rows are deleted on read and on every write.
9
+
10
+ CREATE TABLE IF NOT EXISTS codex_oauth_device_flows (
11
+ flow_id TEXT PRIMARY KEY,
12
+ state TEXT NOT NULL,
13
+ expires_at INTEGER NOT NULL,
14
+ created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
15
+ updated_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
16
+ );
17
+
18
+ INSERT OR IGNORE INTO codex_oauth_device_flows (flow_id, state, expires_at, created_at, updated_at)
19
+ SELECT key, value, expires_at, created_at, updated_at
20
+ FROM kv_entries
21
+ WHERE namespace = 'codex-oauth-device'
22
+ AND expires_at IS NOT NULL;
23
+
24
+ DELETE FROM kv_entries WHERE namespace = 'codex-oauth-device';
@@ -8,10 +8,10 @@ import type {
8
8
  ScriptScope,
9
9
  ScriptVersionRecord,
10
10
  } from "../../types";
11
- import { registerVolatileSecret } from "../../utils/secret-scrubber";
12
11
  import { generateBearerToken, generateShortId } from "../../utils/short-id";
13
12
  import { decryptSecret, encryptSecret, getEncryptionKey } from "../crypto";
14
13
  import { computeContentHash, getDbClient } from "../db";
14
+ import { registerStoredSecret, scriptApiSecretName } from "../secret-registry";
15
15
  import { embedScript } from "./embeddings";
16
16
 
17
17
  type ScriptRow = Omit<ScriptRecord, "isScratch" | "typeChecked"> & {
@@ -517,7 +517,7 @@ export async function createScriptApi(args: {
517
517
  ],
518
518
  );
519
519
  if (!row) throw new Error("Failed to create script API endpoint");
520
- if (token) registerVolatileSecret(token, `script-api:${id}`);
520
+ registerStoredSecret(token, scriptApiSecretName(id));
521
521
  return { ...rowToScriptApi(row), token };
522
522
  } catch (err) {
523
523
  if (isUniqueConstraintError(err) && attempt < 4) continue;
@@ -527,6 +527,30 @@ export async function createScriptApi(args: {
527
527
  throw new Error("Failed to allocate a unique script API endpoint id");
528
528
  }
529
529
 
530
+ /**
531
+ * Every stored script API bearer token, decrypted, for the secret registry
532
+ * (src/be/secret-registry.ts). A row that fails to decrypt is counted, not thrown.
533
+ */
534
+ export async function listStoredScriptApiSecrets(): Promise<{
535
+ secrets: { id: string; token: string }[];
536
+ failed: number;
537
+ }> {
538
+ const rows = await getDbClient().query<{ id: string; bearerTokenEncrypted: string }>(
539
+ "SELECT id, bearerTokenEncrypted FROM script_apis WHERE bearerTokenEncrypted IS NOT NULL",
540
+ );
541
+ const secrets: { id: string; token: string }[] = [];
542
+ let failed = 0;
543
+ for (const row of rows) {
544
+ try {
545
+ const token = decryptSecret(row.bearerTokenEncrypted, getEncryptionKey());
546
+ if (token) secrets.push({ id: row.id, token });
547
+ } catch {
548
+ failed++;
549
+ }
550
+ }
551
+ return { secrets, failed };
552
+ }
553
+
530
554
  export async function listScriptApisForScript(scriptId: string): Promise<ScriptApiRecord[]> {
531
555
  const rows = await getDbClient().query<ScriptApiRow>(
532
556
  "SELECT * FROM script_apis WHERE scriptId = ? ORDER BY createdAt DESC",
@@ -554,7 +578,7 @@ export async function getScriptApiSecret(id: string): Promise<string | null> {
554
578
  );
555
579
  if (!row?.bearerTokenEncrypted) return null;
556
580
  const token = decryptSecret(row.bearerTokenEncrypted, getEncryptionKey());
557
- registerVolatileSecret(token, `script-api:${id}`);
581
+ registerStoredSecret(token, scriptApiSecretName(id));
558
582
  return token;
559
583
  }
560
584
 
@@ -604,7 +628,7 @@ export async function rotateScriptApiSecret(
604
628
  [encrypted, updatedBy ?? null, id],
605
629
  );
606
630
  if (!row) return null;
607
- registerVolatileSecret(token, `script-api:${id}`);
631
+ registerStoredSecret(token, scriptApiSecretName(id));
608
632
  return { ...rowToScriptApi(row), token };
609
633
  }
610
634
 
@@ -0,0 +1,37 @@
1
+ import { findSecretsIn } from "../../utils/secret-scrubber";
2
+
3
+ /**
4
+ * Executable source is stored and served verbatim (`scripts.source`,
5
+ * `script_versions.source`, `script_runs.source`): redacting it would change
6
+ * what runs, and sealing it would hide reviewed code. So a source that embeds
7
+ * a secret is refused at write instead, before anything runs or persists.
8
+ *
9
+ * Checked: every registered secret value, in each encoded form the known-value
10
+ * matcher holds, and the gitleaks vendor-token rules. The refusal names the
11
+ * secret's key or the rule id, never the value. Rows written before this check
12
+ * are left as they are.
13
+ */
14
+ export interface SourceSecretRefusal {
15
+ error: "source_contains_secret";
16
+ message: string;
17
+ findings: { kind: "registered-secret" | "gitleaks-rule"; id: string }[];
18
+ }
19
+
20
+ export function refuseSourceWithSecrets(source: string): SourceSecretRefusal | null {
21
+ const { knownValues, gitleaksRules } = findSecretsIn(source);
22
+ if (knownValues.length === 0 && gitleaksRules.length === 0) return null;
23
+ const findings = [
24
+ ...knownValues.map((id) => ({ kind: "registered-secret" as const, id })),
25
+ ...gitleaksRules.map((id) => ({ kind: "gitleaks-rule" as const, id })),
26
+ ];
27
+ const named = findings
28
+ .map((f) =>
29
+ f.kind === "registered-secret" ? `registered secret ${f.id}` : `gitleaks rule ${f.id}`,
30
+ )
31
+ .join(", ");
32
+ return {
33
+ error: "source_contains_secret",
34
+ message: `Script source contains a secret (${named}); nothing was saved or run. Source is stored and shown verbatim, so read secrets at run time instead: a credential binding (ctx.api.<slug> / ctx.mcp.<slug>) or ctx.swarm.config.get("<KEY>").`,
35
+ findings,
36
+ };
37
+ }
@@ -0,0 +1,32 @@
1
+ import { type ScrubbedText, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
2
+
3
+ /** True when `before` parsed as JSON and `after` no longer does. */
4
+ export function breaksJsonValidity(before: string, after: string): boolean {
5
+ try {
6
+ JSON.parse(before);
7
+ } catch {
8
+ return false;
9
+ }
10
+ try {
11
+ JSON.parse(after);
12
+ return false;
13
+ } catch {
14
+ return true;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Serialize `value` for a JSON TEXT column with secrets redacted.
20
+ *
21
+ * Scrubs the serialized text first, so context-keyed passes (`"password":
22
+ * "..."`, auth headers) see the key next to its value. When a redaction breaks
23
+ * JSON validity (a marker swallowed a quote or brace), falls back to scrubbing
24
+ * each string leaf, which always serializes cleanly. Readers keep `JSON.parse`.
25
+ */
26
+ export function scrubJsonValue(value: unknown): ScrubbedText {
27
+ const raw = JSON.stringify(value);
28
+ const scrubbed = scrubSecrets(raw);
29
+ if (!breaksJsonValidity(raw, scrubbed)) return scrubbed;
30
+ // Every string leaf went through scrubSecrets; the brand holds.
31
+ return JSON.stringify(scrubObject(value)) as ScrubbedText;
32
+ }
@@ -0,0 +1,59 @@
1
+ import { decryptSecret, encryptSecret, getEncryptionKey } from "./crypto";
2
+ import { scrubJsonValue } from "./scrub-json";
3
+
4
+ /**
5
+ * Sealed JSON: byte-exact replay state, encrypted at rest.
6
+ *
7
+ * Some JSON columns are execution inputs, not records. The supervisor relaunches
8
+ * a durable script run from `script_runs.args`, and the harness replays
9
+ * `script_run_journal.result` as a step's return value. Redacting them would
10
+ * change what the script sees after a restart, and storing them raw leaves a
11
+ * secret at rest. Sealing keeps the exact bytes for the replay readers and
12
+ * nothing readable for anything that scans the database.
13
+ *
14
+ * Readers pick a view: `openSealedJson` (replay, exact value) or
15
+ * `sealedJsonForDisplay` (API/UI, redacted). Rows written before sealing are
16
+ * plain JSON and both readers still accept them.
17
+ */
18
+
19
+ const SEALED_PREFIX = "sealed:v1:";
20
+
21
+ /**
22
+ * Every column a writer fills with `sealJson`. Boot counts a sealed value here
23
+ * as encrypted data, like an encrypted config or OAuth row: with the key
24
+ * missing, startup refuses instead of generating a replacement key that could
25
+ * never open them. Add a column here when a writer starts sealing it.
26
+ */
27
+ export const SEALED_REPLAY_COLUMNS: readonly { table: string; column: string }[] = [
28
+ { table: "script_runs", column: "args" },
29
+ { table: "script_run_journal", column: "result" },
30
+ { table: "workflow_run_steps", column: "output_replay" },
31
+ { table: "workflow_runs", column: "context_replay" },
32
+ { table: "approval_requests", column: "responses" },
33
+ ];
34
+
35
+ /** SQL `EXISTS` arms matching a sealed value in each `SEALED_REPLAY_COLUMNS` entry. */
36
+ export function sealedReplayExistsSql(): string[] {
37
+ return SEALED_REPLAY_COLUMNS.map(
38
+ ({ table, column }) => `SELECT 1 FROM ${table} WHERE ${column} GLOB '${SEALED_PREFIX}*'`,
39
+ );
40
+ }
41
+
42
+ export function sealJson(value: unknown): string {
43
+ return SEALED_PREFIX + encryptSecret(JSON.stringify(value), getEncryptionKey());
44
+ }
45
+
46
+ export function isSealedJson(text: string): boolean {
47
+ return text.startsWith(SEALED_PREFIX);
48
+ }
49
+
50
+ /** The exact stored value. Only execution and replay paths may call this. */
51
+ export function openSealedJson(text: string): unknown {
52
+ if (!isSealedJson(text)) return JSON.parse(text);
53
+ return JSON.parse(decryptSecret(text.slice(SEALED_PREFIX.length), getEncryptionKey()));
54
+ }
55
+
56
+ /** The stored value with secrets redacted, for API responses and the UI. */
57
+ export function sealedJsonForDisplay(text: string): unknown {
58
+ return JSON.parse(scrubJsonValue(openSealedJson(text)));
59
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * API-side registry of every stored secret value.
3
+ *
4
+ * The scrubber's exact-match pass only knows sensitive env values and values
5
+ * registered at runtime. Without this registry, a secret stored before the
6
+ * last restart (an agent-scoped config secret, an OAuth or MCP OAuth token, a
7
+ * script API bearer token) stays unknown until something reads it again, and
8
+ * the boot retro-sweep cannot redact a value it does not know.
9
+ *
10
+ * At boot, `loadSecretRegistry()` decrypts every stored secret and registers
11
+ * it. Each encrypt site calls `registerStoredSecret()` with the new plaintext,
12
+ * so a write after boot is covered without a reload.
13
+ *
14
+ * Feeds the scrubber only through `registerVolatileSecret`, so worker-side
15
+ * code is untouched. Append-only: a deleted or rotated secret stays
16
+ * registered, since redacting a revoked value is still correct. Never logs a
17
+ * value: names are source markers (`config:<KEY>`, `oauth:<provider>:…`,
18
+ * `script-api:<id>`), and load results are counts.
19
+ */
20
+ import { registerVolatileSecret } from "../utils/secret-scrubber";
21
+ import { listStoredConfigSecrets } from "./db/config-secrets";
22
+ import { listStoredOAuthSecrets, type StoredOAuthSecretKind } from "./db-queries/oauth";
23
+ import { listStoredScriptApiSecrets } from "./scripts/db";
24
+
25
+ export function configSecretName(key: string): string {
26
+ return `config:${key}`;
27
+ }
28
+
29
+ export function oauthSecretName(provider: string, kind: StoredOAuthSecretKind): string {
30
+ return `oauth:${provider}:${kind}`;
31
+ }
32
+
33
+ export function scriptApiSecretName(id: string): string {
34
+ return `script-api:${id}`;
35
+ }
36
+
37
+ /**
38
+ * Base64 text that encodes `value` wherever it sits in a larger byte string.
39
+ * Base64 maps 3 bytes to 4 chars, so the chars depend on the value's byte
40
+ * offset mod 3. For each offset, keep only the chars computed from the value's
41
+ * bytes alone: drop the leading chars that mix in preceding bytes and the
42
+ * trailing chars that depend on following bytes. Offset 0 also keeps the whole
43
+ * padded encoding (the value encoded on its own). This catches
44
+ * `Basic base64(user:secret)` and a secret inside an encoded JSON blob.
45
+ */
46
+ function base64Forms(value: string): string[] {
47
+ const bytes = Buffer.from(value, "utf8");
48
+ const forms = [bytes.toString("base64")];
49
+ for (const offset of [0, 1, 2]) {
50
+ const shifted = Buffer.concat([Buffer.alloc(offset), bytes]);
51
+ // Offset 1 mixes the prefix into the first 2 chars, offset 2 into the first 3.
52
+ const skip = offset === 0 ? 0 : offset + 1;
53
+ const end = Math.floor(shifted.length / 3) * 4;
54
+ forms.push(shifted.toString("base64").slice(skip, end));
55
+ }
56
+ const urlSafe = forms.map((form) =>
57
+ form.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
58
+ );
59
+ return [...forms, ...urlSafe];
60
+ }
61
+
62
+ /**
63
+ * Encoded forms of a known value: base64 (std and url-safe, see
64
+ * `base64Forms`) and URL percent-encoding. Shell and JSON escaping are not
65
+ * repeated here: `registerVolatileSecret` already adds those forms (and
66
+ * drops any form shorter than the scrubber's minimum length).
67
+ */
68
+ export function encodedForms(value: string): string[] {
69
+ const forms = new Set(base64Forms(value));
70
+ const uriEncoded = encodeURIComponent(value);
71
+ if (uriEncoded !== value) forms.add(uriEncoded);
72
+ forms.delete(value);
73
+ forms.delete("");
74
+ return [...forms];
75
+ }
76
+
77
+ /** Register a stored secret value, plus its encoded forms, with the scrubber. */
78
+ export function registerStoredSecret(value: string | null | undefined, name: string): void {
79
+ if (!value) return;
80
+ registerVolatileSecret(value, name);
81
+ for (const form of encodedForms(value)) registerVolatileSecret(form, name);
82
+ }
83
+
84
+ export interface SecretRegistryLoadResult {
85
+ config: number;
86
+ oauth: number;
87
+ scriptApi: number;
88
+ /** Rows that could not be read or decrypted (wrong or missing key). */
89
+ failed: number;
90
+ }
91
+
92
+ /**
93
+ * Register every stored secret. Called once at API boot, before the boot
94
+ * retro-sweep. A row that fails to decrypt is counted and skipped; a source
95
+ * that fails as a whole (e.g. no encryption key resolved) is counted as one
96
+ * failure and the other sources still load.
97
+ */
98
+ export async function loadSecretRegistry(): Promise<SecretRegistryLoadResult> {
99
+ const result: SecretRegistryLoadResult = { config: 0, oauth: 0, scriptApi: 0, failed: 0 };
100
+
101
+ try {
102
+ const { secrets, failed } = await listStoredConfigSecrets();
103
+ for (const { key, value } of secrets) registerStoredSecret(value, configSecretName(key));
104
+ result.config = secrets.length;
105
+ result.failed += failed;
106
+ } catch {
107
+ result.failed += 1;
108
+ }
109
+
110
+ try {
111
+ const { secrets, failed } = await listStoredOAuthSecrets();
112
+ for (const { provider, kind, value } of secrets) {
113
+ registerStoredSecret(value, oauthSecretName(provider, kind));
114
+ }
115
+ result.oauth = secrets.length;
116
+ result.failed += failed;
117
+ } catch {
118
+ result.failed += 1;
119
+ }
120
+
121
+ try {
122
+ const { secrets, failed } = await listStoredScriptApiSecrets();
123
+ for (const { id, token } of secrets) registerStoredSecret(token, scriptApiSecretName(id));
124
+ result.scriptApi = secrets.length;
125
+ result.failed += failed;
126
+ } catch {
127
+ result.failed += 1;
128
+ }
129
+
130
+ return result;
131
+ }
@@ -0,0 +1,77 @@
1
+ import { openSealedJson } from "./sealed-json";
2
+
3
+ /**
4
+ * Which copy of a workflow payload a reader gets.
5
+ *
6
+ * - `replay`: the exact value from the sealed `*_replay` column. Resume, retry,
7
+ * dedup and recovery rebuild the live ctx from it, so a downstream consumer
8
+ * sees the same bytes after a restart as in the uninterrupted run.
9
+ * - `display`: the scrubbed column, for API responses, MCP tools and the UI.
10
+ */
11
+ export type WorkflowPayloadView = "replay" | "display";
12
+
13
+ /**
14
+ * A sealed replay copy exists but cannot be opened (missing or rotated
15
+ * encryption key, corrupt ciphertext). The scrubbed copy holds redaction
16
+ * markers, so replaying it would silently feed them to downstream nodes: the
17
+ * run fails with this error instead.
18
+ */
19
+ export class WorkflowReplayStateError extends Error {
20
+ constructor(
21
+ readonly runId: string,
22
+ readonly target: string,
23
+ cause: unknown,
24
+ ) {
25
+ super(
26
+ `Workflow run ${runId} cannot replay ${target}: its sealed replay copy could not be opened (${
27
+ cause instanceof Error ? cause.message : String(cause)
28
+ }). The redacted copy is never replayed. Restore the SECRETS_ENCRYPTION_KEY that sealed it, then retry the run.`,
29
+ );
30
+ this.name = "WorkflowReplayStateError";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Set `target[key]` from a workflow payload pair.
36
+ *
37
+ * `display` parses the scrubbed column. `replay` opens the sealed column
38
+ * lazily, on first read of the property: status-only readers (cancel, polls,
39
+ * cooldown) never decrypt and keep working without the key. Rows written before
40
+ * the replay columns existed have no sealed copy and fall back to the plain
41
+ * column.
42
+ */
43
+ export function defineWorkflowPayload<T extends object>(
44
+ target: T,
45
+ key: keyof T & string,
46
+ plain: string | null | undefined,
47
+ sealed: string | null | undefined,
48
+ view: WorkflowPayloadView,
49
+ where: { runId: string; target: string },
50
+ ): T {
51
+ if (view === "display" || !sealed) {
52
+ (target as Record<string, unknown>)[key] = plain ? JSON.parse(plain) : undefined;
53
+ return target;
54
+ }
55
+ let opened = false;
56
+ let value: unknown;
57
+ Object.defineProperty(target, key, {
58
+ enumerable: true,
59
+ configurable: true,
60
+ get() {
61
+ if (!opened) {
62
+ try {
63
+ value = openSealedJson(sealed);
64
+ } catch (err) {
65
+ throw new WorkflowReplayStateError(where.runId, where.target, err);
66
+ }
67
+ opened = true;
68
+ }
69
+ return value;
70
+ },
71
+ set(next: unknown) {
72
+ value = next;
73
+ opened = true;
74
+ },
75
+ });
76
+ return target;
77
+ }
@@ -1,7 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { decryptSecret, encryptSecret, getEncryptionKey } from "../be/crypto";
4
- import { getDbClient, getKv, upsertKv, upsertSwarmConfig } from "../be/db";
4
+ import { getDbClient, upsertSwarmConfig } from "../be/db";
5
+ import { getCodexDeviceFlow, upsertCodexDeviceFlow } from "../be/db/codex-oauth-device-flows";
5
6
  import { updateOnboardingAiFromCodexDevice } from "../be/onboarding";
6
7
  import {
7
8
  CODEX_DEVICE_VERIFICATION_URL,
@@ -17,7 +18,6 @@ import { ensureConfigAdmin } from "./config";
17
18
  import { route } from "./route-def";
18
19
  import { jsonError } from "./utils";
19
20
 
20
- const FLOW_NAMESPACE = "codex-oauth-device";
21
21
  const FLOW_TTL_MS = 15 * 60 * 1_000;
22
22
  const POLL_LEASE_MS = 45_000;
23
23
 
@@ -85,10 +85,10 @@ function registerFlowSecrets(state: FlowState): void {
85
85
  }
86
86
 
87
87
  async function readFlow(flowId: string): Promise<FlowState | null> {
88
- const entry = await getKv(FLOW_NAMESPACE, flowId);
89
- if (!entry || typeof entry.value !== "string") return null;
88
+ const entry = await getCodexDeviceFlow(flowId);
89
+ if (!entry) return null;
90
90
  try {
91
- const plaintext = decryptSecret(entry.value, getEncryptionKey());
91
+ const plaintext = decryptSecret(entry.state, getEncryptionKey());
92
92
  const parsed = FlowStateSchema.safeParse(JSON.parse(plaintext));
93
93
  if (!parsed.success) return null;
94
94
  registerFlowSecrets(parsed.data);
@@ -99,11 +99,9 @@ async function readFlow(flowId: string): Promise<FlowState | null> {
99
99
  }
100
100
 
101
101
  async function writeFlow(flowId: string, state: FlowState): Promise<void> {
102
- await upsertKv({
103
- namespace: FLOW_NAMESPACE,
104
- key: flowId,
105
- value: encryptSecret(JSON.stringify(state), getEncryptionKey()),
106
- valueType: "string",
102
+ await upsertCodexDeviceFlow({
103
+ flowId,
104
+ state: encryptSecret(JSON.stringify(state), getEncryptionKey()),
107
105
  expiresAt: state.expiresAt,
108
106
  });
109
107
  }
@@ -37,6 +37,7 @@
37
37
  import { getDb, resolveSqliteVecExtensionPath } from "../be/db";
38
38
  import type { DbQueryResult } from "./db-query-shared";
39
39
  import {
40
+ assertNoDeniedTables,
40
41
  assertSingleStatement,
41
42
  DbQueryConcurrencyCapError,
42
43
  DbQueryTimeoutError,
@@ -203,6 +204,8 @@ export async function executeReadOnlyQueryBounded(
203
204
  maxRows?: number,
204
205
  ): Promise<DbQueryResult> {
205
206
  assertSingleStatement(sql);
207
+ // The child opens its own connection, so the parent checks the program first.
208
+ assertNoDeniedTables(sql);
206
209
  acquireBoundedQuerySlot();
207
210
 
208
211
  try {