@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
@@ -0,0 +1,390 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ BOOT_SCRUB_TARGETS,
5
+ bootScrubCursorKey,
6
+ bootScrubDoneKey,
7
+ runBootScrubSweep,
8
+ } from "../be/boot-scrub-sweep";
9
+ import { closeDb, getDb, getDbClient, initDb, isSqliteVecAvailable } from "../be/db";
10
+ import { getMemoryStore } from "../be/memory";
11
+ import { contentSha256 } from "../commands/profile-sync";
12
+ import {
13
+ refreshSecretScrubberCache,
14
+ registerVolatileSecret,
15
+ SCRUBBER_RULES_VERSION,
16
+ } from "../utils/secret-scrubber";
17
+ import { randomToken, type SyntheticSecret, syntheticSecret } from "./synthetic-secret-helpers";
18
+
19
+ const TEST_DB_PATH = "./test-boot-scrub-sweep.sqlite";
20
+ const AGENT_ID = "5c2b0000-0000-4000-8000-000000000001";
21
+ // Distinctive non-secret words: FTS must still find the row by them after redaction.
22
+ const MARKER_WORD = "quokkazebra";
23
+
24
+ let known: SyntheticSecret;
25
+ let envName: string;
26
+ let envValue: string;
27
+ let password: string;
28
+
29
+ /** Text carrying all three secret forms: known value, env value and a `*_PASSWORD=` assignment. */
30
+ function secretText(label: string): string {
31
+ return `${label} ${MARKER_WORD} token=${known.value} env=${envValue} X_DEMO_PASSWORD=${password} end`;
32
+ }
33
+
34
+ function expectRedacted(value: string | null | undefined, where: string): void {
35
+ expect(value, where).toBeString();
36
+ for (const secret of [known.value, envValue, password]) {
37
+ expect(value!.includes(secret), `${where} still holds a secret`).toBe(false);
38
+ }
39
+ expect(value!, where).toContain("[REDACTED:");
40
+ }
41
+
42
+ async function writeAudit(): Promise<{ tbl: string; id: string }[]> {
43
+ return getDbClient().query<{ tbl: string; id: string }>("SELECT tbl, id FROM scrub_write_audit");
44
+ }
45
+
46
+ async function ftsHits(term: string): Promise<string[]> {
47
+ const rows = await getDbClient().query<{ memory_id: string }>(
48
+ "SELECT memory_id FROM memory_fts WHERE memory_fts MATCH ?",
49
+ [`"${term}"`],
50
+ );
51
+ return rows.map((r) => r.memory_id);
52
+ }
53
+
54
+ const ids = {
55
+ log: crypto.randomUUID(),
56
+ logControl: crypto.randomUUID(),
57
+ task: crypto.randomUUID(),
58
+ taskControl: crypto.randomUUID(),
59
+ event: crypto.randomUUID(),
60
+ run: crypto.randomUUID(),
61
+ step: crypto.randomUUID(),
62
+ memory: "",
63
+ };
64
+
65
+ describe("boot retro-sweep", () => {
66
+ beforeAll(async () => {
67
+ for (const suffix of ["", "-wal", "-shm"]) {
68
+ try {
69
+ await unlink(TEST_DB_PATH + suffix);
70
+ } catch {}
71
+ }
72
+ initDb(TEST_DB_PATH);
73
+ // workflow_run_steps.runId references workflow_runs; the sweep never
74
+ // touches FKs, so seed the step without building a whole workflow.
75
+ getDb().run("PRAGMA foreign_keys = OFF");
76
+
77
+ known = syntheticSecret("sweep");
78
+ envName = "X_DEMO_SWEEP_API_KEY";
79
+ envValue = randomToken(32);
80
+ process.env[envName] = envValue;
81
+ refreshSecretScrubberCache();
82
+ password = randomToken(24);
83
+
84
+ const db = getDbClient();
85
+ // Every UPDATE on a swept table lands here: proves untouched rows saw no write.
86
+ await db.run("CREATE TABLE scrub_write_audit (tbl TEXT NOT NULL, id TEXT NOT NULL)");
87
+ for (const { table } of BOOT_SCRUB_TARGETS) {
88
+ await db.run(
89
+ `CREATE TRIGGER audit_${table} AFTER UPDATE ON ${table}
90
+ BEGIN INSERT INTO scrub_write_audit (tbl, id) VALUES ('${table}', NEW.id); END`,
91
+ );
92
+ }
93
+
94
+ const now = new Date().toISOString();
95
+ await db.run(
96
+ `INSERT INTO session_logs (id, sessionId, iteration, content, lineNumber, createdAt)
97
+ VALUES (?, 's1', 1, ?, 1, ?), (?, 's1', 1, ?, 2, ?)`,
98
+ [ids.log, secretText("log"), now, ids.logControl, "plain log line, nothing to hide", now],
99
+ );
100
+ await db.run(
101
+ `INSERT INTO agent_tasks (id, task, output, failureReason, progress, createdAt, lastUpdatedAt)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?), (?, 'control task', 'control output', NULL, NULL, ?, ?)`,
103
+ [
104
+ ids.task,
105
+ secretText("task"),
106
+ secretText("output"),
107
+ secretText("failure"),
108
+ secretText("progress"),
109
+ now,
110
+ now,
111
+ ids.taskControl,
112
+ now,
113
+ now,
114
+ ],
115
+ );
116
+ await db.run(
117
+ `INSERT INTO events (id, category, event, source, data, createdAt)
118
+ VALUES (?, 'test', 'test.event', 'test', ?, ?)`,
119
+ [ids.event, JSON.stringify({ note: secretText("event"), nested: { n: 1 } }), now],
120
+ );
121
+ await db.run(
122
+ `INSERT INTO workflow_run_steps (id, runId, nodeId, nodeType, input, output, error, diagnostics)
123
+ VALUES (?, ?, 'n1', 'agent-task', ?, ?, ?, ?)`,
124
+ [
125
+ ids.step,
126
+ ids.run,
127
+ JSON.stringify({ prompt: secretText("input") }),
128
+ JSON.stringify({ result: secretText("step-output") }),
129
+ secretText("error"),
130
+ JSON.stringify({ trace: secretText("diagnostics") }),
131
+ ],
132
+ );
133
+
134
+ const store = getMemoryStore();
135
+ const legacy = {
136
+ name: `note ${known.value}`,
137
+ content: secretText("memory"),
138
+ summary: secretText("summary"),
139
+ };
140
+ const memory = await store.store({
141
+ agentId: AGENT_ID,
142
+ scope: "agent",
143
+ source: "manual",
144
+ ...legacy,
145
+ });
146
+ ids.memory = memory.id;
147
+ // store() now scrubs at write; put the raw text back so the row looks like
148
+ // one written before that, which is what the sweep exists for.
149
+ await db.run(
150
+ "UPDATE agent_memory SET name = ?, content = ?, summary = ?, contentHash = ? WHERE id = ?",
151
+ [legacy.name, legacy.content, legacy.summary, contentSha256(legacy.content), memory.id],
152
+ );
153
+ await db.run(
154
+ "UPDATE agent_memory_version SET content = ?, contentHash = ? WHERE memory_id = ?",
155
+ [legacy.content, contentSha256(legacy.content), memory.id],
156
+ );
157
+ await db.run("DELETE FROM memory_fts WHERE memory_id = ?", [memory.id]);
158
+ await db.run("INSERT INTO memory_fts(memory_id, name, content) VALUES (?, ?, ?)", [
159
+ memory.id,
160
+ legacy.name,
161
+ legacy.content,
162
+ ]);
163
+ const embedding = new Float32Array(512);
164
+ embedding[0] = 1;
165
+ await store.updateEmbedding(memory.id, embedding, "test");
166
+ });
167
+
168
+ // The test preload clears volatile secrets after every test.
169
+ beforeEach(() => registerVolatileSecret(known.value, known.name));
170
+
171
+ afterAll(async () => {
172
+ delete process.env[envName];
173
+ known.cleanup();
174
+ refreshSecretScrubberCache();
175
+ closeDb();
176
+ for (const suffix of ["", "-wal", "-shm"]) {
177
+ try {
178
+ await unlink(TEST_DB_PATH + suffix);
179
+ } catch {}
180
+ }
181
+ });
182
+
183
+ test("redacts every target column, keeps context, JSON and FTS, drops the stale vector", async () => {
184
+ const db = getDbClient();
185
+ // Positive controls: the secret is really in the index and the vector exists.
186
+ expect(await ftsHits(password)).toContain(ids.memory);
187
+ const before = await db.get<{ embedding: Uint8Array | null }>(
188
+ "SELECT embedding FROM agent_memory WHERE id = ?",
189
+ [ids.memory],
190
+ );
191
+ expect(before?.embedding).not.toBeNull();
192
+ if (isSqliteVecAvailable()) {
193
+ expect(
194
+ await db.get("SELECT memory_id FROM memory_vec WHERE memory_id = ?", [ids.memory]),
195
+ ).not.toBeNull();
196
+ }
197
+
198
+ const stats = await runBootScrubSweep({ version: SCRUBBER_RULES_VERSION, reembed: false });
199
+ expect(stats).not.toBeNull();
200
+ const byTable = Object.fromEntries(stats!.map((s) => [s.table, s]));
201
+ expect(byTable.session_logs!.changed).toBe(1);
202
+ expect(byTable.agent_tasks!.changed).toBe(1);
203
+ expect(byTable.agent_memory!.changed).toBe(1);
204
+ expect(byTable.agent_memory_version!.changed).toBe(1);
205
+ expect(byTable.events!.changed).toBe(1);
206
+ expect(byTable.workflow_run_steps!.changed).toBe(1);
207
+
208
+ const log = await db.get<{ content: string }>("SELECT content FROM session_logs WHERE id = ?", [
209
+ ids.log,
210
+ ]);
211
+ expectRedacted(log?.content, "session_logs.content");
212
+ expect(log!.content).toContain(MARKER_WORD);
213
+
214
+ const task = await db.get<Record<string, string>>(
215
+ "SELECT task, output, failureReason, progress FROM agent_tasks WHERE id = ?",
216
+ [ids.task],
217
+ );
218
+ for (const col of ["task", "output", "failureReason", "progress"]) {
219
+ expectRedacted(task?.[col], `agent_tasks.${col}`);
220
+ }
221
+
222
+ const mem = await db.get<{
223
+ name: string;
224
+ content: string;
225
+ summary: string;
226
+ contentHash: string;
227
+ embedding: Uint8Array | null;
228
+ }>("SELECT name, content, summary, contentHash, embedding FROM agent_memory WHERE id = ?", [
229
+ ids.memory,
230
+ ]);
231
+ expectRedacted(mem?.name, "agent_memory.name");
232
+ expectRedacted(mem?.content, "agent_memory.content");
233
+ expectRedacted(mem?.summary, "agent_memory.summary");
234
+ expect(mem!.contentHash).toBe(contentSha256(mem!.content));
235
+ expect(mem!.embedding).toBeNull();
236
+ if (isSqliteVecAvailable()) {
237
+ expect(
238
+ await db.get("SELECT memory_id FROM memory_vec WHERE memory_id = ?", [ids.memory]),
239
+ ).toBeNull();
240
+ }
241
+ // FTS was rewritten from the scrubbed text, not left stale.
242
+ expect(await ftsHits(MARKER_WORD)).toContain(ids.memory);
243
+ expect(await ftsHits(password)).not.toContain(ids.memory);
244
+
245
+ const version = await db.get<{ content: string; contentHash: string }>(
246
+ "SELECT content, contentHash FROM agent_memory_version WHERE memory_id = ?",
247
+ [ids.memory],
248
+ );
249
+ expectRedacted(version?.content, "agent_memory_version.content");
250
+ expect(version!.contentHash).toBe(contentSha256(version!.content));
251
+
252
+ const event = await db.get<{ data: string }>("SELECT data FROM events WHERE id = ?", [
253
+ ids.event,
254
+ ]);
255
+ expectRedacted(event?.data, "events.data");
256
+ const parsed = JSON.parse(event!.data) as { note: string; nested: { n: number } };
257
+ expect(parsed.nested.n).toBe(1);
258
+ expect(parsed.note).toContain(MARKER_WORD);
259
+
260
+ const step = await db.get<Record<string, string>>(
261
+ "SELECT input, output, error, diagnostics FROM workflow_run_steps WHERE id = ?",
262
+ [ids.step],
263
+ );
264
+ for (const col of ["input", "output", "error", "diagnostics"]) {
265
+ expectRedacted(step?.[col], `workflow_run_steps.${col}`);
266
+ }
267
+ for (const col of ["input", "output", "diagnostics"]) {
268
+ expect(() => JSON.parse(step![col]!)).not.toThrow();
269
+ }
270
+ });
271
+
272
+ test("control rows without a secret see no write", async () => {
273
+ const audit = await writeAudit();
274
+ // Positive control: the trigger fired for the rows that held a secret.
275
+ expect(audit).toContainEqual({ tbl: "session_logs", id: ids.log });
276
+ expect(audit).toContainEqual({ tbl: "agent_tasks", id: ids.task });
277
+ // The controls were never written.
278
+ expect(audit.map((a) => a.id)).not.toContain(ids.logControl);
279
+ expect(audit.map((a) => a.id)).not.toContain(ids.taskControl);
280
+ const control = await getDbClient().get<{ content: string }>(
281
+ "SELECT content FROM session_logs WHERE id = ?",
282
+ [ids.logControl],
283
+ );
284
+ expect(control?.content).toBe("plain log line, nothing to hide");
285
+ });
286
+
287
+ test("marks the version done and is a no-op on rerun; a new version runs again", async () => {
288
+ const db = getDbClient();
289
+ expect(
290
+ await db.get("SELECT key FROM seed_state WHERE kind = 'maintenance' AND key = ?", [
291
+ bootScrubDoneKey(SCRUBBER_RULES_VERSION),
292
+ ]),
293
+ ).not.toBeNull();
294
+ const cursors = await db.query("SELECT key FROM seed_state WHERE key LIKE 'boot-scrub-v%:%'");
295
+ expect(cursors).toHaveLength(0);
296
+
297
+ const auditBefore = (await writeAudit()).length;
298
+ expect(await runBootScrubSweep({ version: SCRUBBER_RULES_VERSION, reembed: false })).toBeNull();
299
+
300
+ const bumped = SCRUBBER_RULES_VERSION + 100;
301
+ const stats = await runBootScrubSweep({ version: bumped, reembed: false });
302
+ expect(stats).not.toBeNull();
303
+ expect(stats!.every((s) => s.changed === 0)).toBe(true);
304
+ expect(stats!.find((s) => s.table === "session_logs")!.scanned).toBeGreaterThanOrEqual(2);
305
+ expect((await writeAudit()).length).toBe(auditBefore);
306
+ expect(
307
+ await db.get("SELECT key FROM seed_state WHERE kind = 'maintenance' AND key = ?", [
308
+ bootScrubDoneKey(bumped),
309
+ ]),
310
+ ).not.toBeNull();
311
+ });
312
+
313
+ test("an interrupted run resumes from the cursor without rescanning batch 1", async () => {
314
+ const db = getDbClient();
315
+ const now = new Date().toISOString();
316
+ for (let i = 0; i < 5; i++) {
317
+ await db.run(
318
+ `INSERT INTO session_logs (id, sessionId, iteration, content, lineNumber, createdAt)
319
+ VALUES (?, 's2', 1, ?, ?, ?)`,
320
+ [crypto.randomUUID(), secretText(`resume-${i}`), 10 + i, now],
321
+ );
322
+ }
323
+ const version = SCRUBBER_RULES_VERSION + 200;
324
+ let firstBatch: string[] = [];
325
+ await expect(
326
+ runBootScrubSweep({
327
+ version,
328
+ batchSize: 2,
329
+ reembed: false,
330
+ afterBatch: ({ table, ids: batchIds }) => {
331
+ if (table !== "session_logs") return;
332
+ firstBatch = batchIds;
333
+ throw new Error("interrupted after batch 1");
334
+ },
335
+ }),
336
+ ).rejects.toThrow("interrupted after batch 1");
337
+ expect(firstBatch).toHaveLength(2);
338
+
339
+ const cursor = await db.get<{ seededHash: string }>(
340
+ "SELECT seededHash FROM seed_state WHERE kind = 'maintenance' AND key = ?",
341
+ [bootScrubCursorKey(version, "session_logs")],
342
+ );
343
+ expect(cursor?.seededHash).toBe(firstBatch[1]);
344
+ expect(
345
+ await db.get("SELECT key FROM seed_state WHERE key = ?", [bootScrubDoneKey(version)]),
346
+ ).toBeNull();
347
+
348
+ const resumed: string[] = [];
349
+ const stats = await runBootScrubSweep({
350
+ version,
351
+ batchSize: 2,
352
+ reembed: false,
353
+ afterBatch: ({ table, ids: batchIds }) => {
354
+ if (table === "session_logs") resumed.push(...batchIds);
355
+ },
356
+ });
357
+ expect(resumed.length).toBeGreaterThan(0);
358
+ for (const id of firstBatch) expect(resumed).not.toContain(id);
359
+ const total = await db.get<{ n: number }>("SELECT COUNT(*) AS n FROM session_logs");
360
+ expect(stats!.find((s) => s.table === "session_logs")!.scanned).toBe(total!.n - 2);
361
+
362
+ // Batch 1 was committed before the interrupt, the rest by the resumed run.
363
+ const remaining = await db.query<{ content: string }>(
364
+ "SELECT content FROM session_logs WHERE sessionId = 's2'",
365
+ );
366
+ for (const row of remaining) expectRedacted(row.content, "resumed session_logs.content");
367
+ });
368
+
369
+ test("skips a row whose scrub would break valid JSON", async () => {
370
+ const db = getDbClient();
371
+ const id = crypto.randomUUID();
372
+ const data = JSON.stringify({ msg: "BREAKJSON here" });
373
+ await db.run(
374
+ `INSERT INTO events (id, category, event, source, data, createdAt)
375
+ VALUES (?, 'test', 'test.event', 'test', ?, ?)`,
376
+ [id, data, new Date().toISOString()],
377
+ );
378
+ // Force a scrub result that is not valid JSON (an unbalanced quote).
379
+ const stats = await runBootScrubSweep({
380
+ version: SCRUBBER_RULES_VERSION + 300,
381
+ reembed: false,
382
+ scrub: (text) => text.replace("BREAKJSON", '"'),
383
+ });
384
+ const events = stats!.find((s) => s.table === "events")!;
385
+ expect(events.skippedInvalidJson).toBe(1);
386
+ expect(events.changed).toBe(0);
387
+ const row = await db.get<{ data: string }>("SELECT data FROM events WHERE id = ?", [id]);
388
+ expect(row?.data).toBe(data);
389
+ });
390
+ });
@@ -0,0 +1,145 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import {
6
+ checkTextColumns,
7
+ listTextColumns,
8
+ TEXT_COLUMNS_PATH,
9
+ type TextColumnClassification,
10
+ } from "../../scripts/check-text-columns";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTask,
15
+ getDbClient,
16
+ initDb,
17
+ updateTaskProgress,
18
+ } from "../be/db";
19
+ import { runMigrations } from "../be/migrations/runner";
20
+ import { type SyntheticSecret, syntheticSecret } from "./synthetic-secret-helpers";
21
+
22
+ const repoRoot = join(import.meta.dir, "..", "..");
23
+
24
+ async function loadCommitted(): Promise<TextColumnClassification> {
25
+ return (await Bun.file(join(repoRoot, TEXT_COLUMNS_PATH)).json()) as TextColumnClassification;
26
+ }
27
+
28
+ describe("TEXT-column classification check", () => {
29
+ let db: Database;
30
+
31
+ beforeAll(() => {
32
+ db = new Database(":memory:");
33
+ const originalLog = console.log;
34
+ console.log = () => {};
35
+ try {
36
+ runMigrations(db);
37
+ } finally {
38
+ console.log = originalLog;
39
+ }
40
+ });
41
+
42
+ afterAll(() => {
43
+ db.close();
44
+ });
45
+
46
+ test("the committed classification covers the migrated schema exactly", async () => {
47
+ const classification = await loadCommitted();
48
+ expect(listTextColumns(db).length).toBeGreaterThan(100);
49
+ expect(checkTextColumns(db, classification)).toEqual([]);
50
+ });
51
+
52
+ test("a new TEXT column from a later migration fails until it is classified", async () => {
53
+ const classification = await loadCommitted();
54
+ const scratch = new Database(":memory:");
55
+ try {
56
+ const originalLog = console.log;
57
+ console.log = () => {};
58
+ try {
59
+ runMigrations(scratch);
60
+ } finally {
61
+ console.log = originalLog;
62
+ }
63
+ // Stand-in for a future migration that adds a free-text sink.
64
+ scratch.run("CREATE TABLE canary_notes (id TEXT PRIMARY KEY, body TEXT NOT NULL, n INTEGER)");
65
+
66
+ const violations = checkTextColumns(scratch, classification);
67
+ expect(violations).toEqual([
68
+ "canary_notes.id: unclassified TEXT column",
69
+ "canary_notes.body: unclassified TEXT column",
70
+ ]);
71
+
72
+ const classified: TextColumnClassification = {
73
+ ...classification,
74
+ canary_notes: { id: { exempt: "identifier" }, body: { pending: "test batch" } },
75
+ };
76
+ expect(checkTextColumns(scratch, classified)).toEqual([]);
77
+ } finally {
78
+ scratch.close();
79
+ }
80
+ });
81
+
82
+ test("stale and malformed entries are rejected", async () => {
83
+ const classification = await loadCommitted();
84
+ const firstTable = Object.keys(classification)[0] as string;
85
+ const firstColumn = Object.keys(classification[firstTable] ?? {})[0] as string;
86
+ const bad = {
87
+ ...classification,
88
+ [firstTable]: { ...classification[firstTable], [firstColumn]: { exempt: "" } },
89
+ gone_table: { note: "scrubbed" },
90
+ } as TextColumnClassification;
91
+
92
+ const violations = checkTextColumns(db, bad);
93
+ expect(violations).toContain("gone_table.note: stale entry, no such TEXT column");
94
+ expect(
95
+ violations.some((v) => v.startsWith(`${firstTable}.${firstColumn}: invalid entry`)),
96
+ ).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("ScrubbedText at a production writer", () => {
101
+ const dbPath = "./test-check-text-columns-writer.sqlite";
102
+ let secret: SyntheticSecret;
103
+
104
+ async function removeDbFiles(): Promise<void> {
105
+ for (const suffix of ["", "-wal", "-shm"]) {
106
+ try {
107
+ await unlink(dbPath + suffix);
108
+ } catch {}
109
+ }
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ await removeDbFiles();
114
+ initDb(dbPath);
115
+ secret = syntheticSecret("textcol");
116
+ });
117
+
118
+ afterAll(async () => {
119
+ secret.cleanup();
120
+ closeDb();
121
+ await removeDbFiles();
122
+ });
123
+
124
+ test("a column classified scrubbed holds no secret after its real writer runs", async () => {
125
+ const committed = await loadCommitted();
126
+ expect(committed.agent_tasks?.progress).toBe("scrubbed");
127
+
128
+ const agent = await createAgent({
129
+ name: "text-columns-writer",
130
+ isLead: false,
131
+ status: "idle",
132
+ capabilities: [],
133
+ });
134
+ const task = await createTask(agent.id, "classification writer check");
135
+ await updateTaskProgress(task.id, `step 2 of 3 token=${secret.value} done`);
136
+
137
+ const row = await getDbClient().get<{ progress: string }>(
138
+ "SELECT progress FROM agent_tasks WHERE id = ?",
139
+ [task.id],
140
+ );
141
+ expect(row?.progress).toContain("step 2 of 3");
142
+ expect(row?.progress).toContain(`[REDACTED:${secret.name}]`);
143
+ expect(row?.progress.includes(secret.value)).toBe(false);
144
+ });
145
+ });
@@ -1,3 +1,5 @@
1
+ import { rename } from "node:fs/promises";
2
+
1
3
  const mode = process.env.CLAUDE_SDK_FIXTURE_MODE;
2
4
  const stateFile = process.env.CLAUDE_SDK_FIXTURE_STATE_FILE;
3
5
  const sessionId = "11111111-1111-4111-8111-111111111111";
@@ -13,6 +15,15 @@ if (!mode || !stateFile) {
13
15
  process.exit(2);
14
16
  }
15
17
 
18
+ // The test polls for the state file and parses it as soon as it exists.
19
+ // Bun.write creates the file before filling it, so write-then-rename keeps
20
+ // the reader from ever seeing an empty file.
21
+ async function writeStateFile(path: string, state: Record<string, unknown>): Promise<void> {
22
+ const temporaryPath = `${path}.tmp`;
23
+ await Bun.write(temporaryPath, JSON.stringify(state));
24
+ await rename(temporaryPath, path);
25
+ }
26
+
16
27
  function writeMessage(message: Record<string, unknown>): void {
17
28
  console.log(JSON.stringify(message));
18
29
  }
@@ -145,7 +156,7 @@ if (mode === "cancel") {
145
156
  stdout: "ignore",
146
157
  stderr: "ignore",
147
158
  });
148
- await Bun.write(stateFile, JSON.stringify({ fixturePid: process.pid, childPid: child.pid }));
159
+ await writeStateFile(stateFile, { fixturePid: process.pid, childPid: child.pid });
149
160
  }
150
161
 
151
162
  async function handleLine(line: string): Promise<boolean> {
@@ -191,7 +202,7 @@ async function handleLine(line: string): Promise<boolean> {
191
202
  }
192
203
 
193
204
  if (mode !== "queue" || userMessages.length < 3) return false;
194
- await Bun.write(stateFile, JSON.stringify({ argv, userMessages }));
205
+ await writeStateFile(stateFile, { argv, userMessages });
195
206
  for (let index = 0; index < userMessages.length; index++) {
196
207
  const turn = index + 1;
197
208
  writeMessage(assistantMessage(`accepted:${userMessages[index]}`, turn));