@cleocode/core 2026.4.11 → 2026.4.13

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 (184) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +9 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +96 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/backup-crypto.test.ts +101 -0
  128. package/src/store/__tests__/backup-pack.test.ts +491 -0
  129. package/src/store/__tests__/backup-unpack.test.ts +298 -0
  130. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  131. package/src/store/__tests__/global-salt.test.ts +195 -0
  132. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  133. package/src/store/__tests__/regenerators.test.ts +234 -0
  134. package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
  135. package/src/store/__tests__/restore-json-merge.test.ts +521 -0
  136. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  137. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  138. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  139. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  140. package/src/store/__tests__/t310-readiness.test.ts +111 -0
  141. package/src/store/__tests__/t311-integration.test.ts +661 -0
  142. package/src/store/agent-registry-accessor.ts +847 -140
  143. package/src/store/api-key-kdf.ts +104 -0
  144. package/src/store/backup-crypto.ts +209 -0
  145. package/src/store/backup-pack.ts +739 -0
  146. package/src/store/backup-unpack.ts +583 -0
  147. package/src/store/conduit-sqlite.ts +655 -0
  148. package/src/store/global-salt.ts +175 -0
  149. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  150. package/src/store/regenerators.ts +243 -0
  151. package/src/store/restore-conflict-report.ts +317 -0
  152. package/src/store/restore-json-merge.ts +653 -0
  153. package/src/store/signaldock-sqlite.ts +431 -254
  154. package/src/store/sqlite-backup.ts +185 -10
  155. package/src/store/t310-readiness.ts +119 -0
  156. package/src/system/backup.ts +2 -62
  157. package/src/system/runtime.ts +4 -6
  158. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  159. package/src/tasks/add.ts +99 -9
  160. package/src/tasks/complete.ts +4 -1
  161. package/src/tasks/find.ts +4 -1
  162. package/src/tasks/labels.ts +4 -1
  163. package/src/tasks/relates.ts +16 -4
  164. package/src/tasks/show.ts +4 -1
  165. package/src/tasks/update.ts +32 -3
  166. package/src/validation/__tests__/error-hints.test.ts +97 -0
  167. package/src/validation/engine.ts +16 -1
  168. package/src/validation/param-utils.ts +10 -7
  169. package/src/validation/protocols/_shared.ts +14 -6
  170. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  171. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  172. package/src/validation/protocols/cant/consensus.cant +74 -0
  173. package/src/validation/protocols/cant/contribution.cant +82 -0
  174. package/src/validation/protocols/cant/decomposition.cant +92 -0
  175. package/src/validation/protocols/cant/implementation.cant +67 -0
  176. package/src/validation/protocols/cant/provenance.cant +88 -0
  177. package/src/validation/protocols/cant/release.cant +96 -0
  178. package/src/validation/protocols/cant/research.cant +66 -0
  179. package/src/validation/protocols/cant/specification.cant +67 -0
  180. package/src/validation/protocols/cant/testing.cant +88 -0
  181. package/src/validation/protocols/cant/validation.cant +65 -0
  182. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  183. package/templates/config.template.json +0 -1
  184. package/templates/global-config.template.json +0 -1
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Tests for conduit-sqlite.ts — project-tier conduit.db module.
3
+ *
4
+ * Covers path resolution, DDL creation, idempotent re-open, FTS5 triggers,
5
+ * getConduitNativeDb, and integrity_check.
6
+ *
7
+ * Uses real node:sqlite DatabaseSync (genuine SQLite operations, not mocks).
8
+ * All filesystem interactions occur in tmp directories; the real user's
9
+ * project root is never touched.
10
+ *
11
+ * @task T344
12
+ * @epic T310
13
+ */
14
+
15
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { DatabaseSync } from 'node:sqlite';
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20
+ import {
21
+ applyConduitSchema,
22
+ attachAgentToProject,
23
+ CONDUIT_DB_FILENAME,
24
+ CONDUIT_SCHEMA_VERSION,
25
+ checkConduitDbHealth,
26
+ closeConduitDb,
27
+ detachAgentFromProject,
28
+ ensureConduitDb,
29
+ getConduitDbPath,
30
+ getConduitNativeDb,
31
+ getProjectAgentRef,
32
+ listProjectAgentRefs,
33
+ updateProjectAgentLastUsed,
34
+ } from '../conduit-sqlite.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Expected project-local messaging + tracking table names in conduit.db. */
41
+ const EXPECTED_TABLES = [
42
+ '_conduit_meta',
43
+ '_conduit_migrations',
44
+ 'attachment_approvals',
45
+ 'attachment_contributors',
46
+ 'attachment_versions',
47
+ 'attachments',
48
+ 'conversations',
49
+ 'dead_letters',
50
+ 'delivery_jobs',
51
+ 'message_pins',
52
+ 'messages',
53
+ 'project_agent_refs',
54
+ ];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Suite
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe('conduit-sqlite', () => {
61
+ let tmpRoot: string;
62
+
63
+ beforeEach(() => {
64
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cleo-t344-'));
65
+ // Ensure each test starts with a clean singleton.
66
+ closeConduitDb();
67
+ });
68
+
69
+ afterEach(() => {
70
+ closeConduitDb();
71
+ rmSync(tmpRoot, { recursive: true, force: true });
72
+ });
73
+
74
+ // TC-001 — path resolution
75
+ it('getConduitDbPath resolves to <projectRoot>/.cleo/conduit.db', () => {
76
+ const expected = join(tmpRoot, '.cleo', CONDUIT_DB_FILENAME);
77
+ expect(getConduitDbPath(tmpRoot)).toBe(expected);
78
+ expect(getConduitDbPath(tmpRoot)).toMatch(/\.cleo[/\\]conduit\.db$/);
79
+ });
80
+
81
+ // TC-002 — fresh install creates file + directory
82
+ it('ensureConduitDb creates .cleo/ dir and conduit.db on a fresh project root', () => {
83
+ expect(existsSync(join(tmpRoot, '.cleo'))).toBe(false);
84
+
85
+ const result = ensureConduitDb(tmpRoot);
86
+
87
+ expect(result.path).toBe(getConduitDbPath(tmpRoot));
88
+ expect(result.action).toBe('created');
89
+ expect(existsSync(join(tmpRoot, '.cleo'))).toBe(true);
90
+ expect(existsSync(join(tmpRoot, '.cleo', 'conduit.db'))).toBe(true);
91
+ });
92
+
93
+ // TC-002 continued — all 11 messaging tables + 2 tracking tables created
94
+ it('ensureConduitDb creates all expected tables on fresh install', () => {
95
+ ensureConduitDb(tmpRoot);
96
+ const db = getConduitNativeDb();
97
+ expect(db).not.toBeNull();
98
+
99
+ const rows = db!
100
+ .prepare(
101
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
102
+ )
103
+ .all() as Array<{ name: string }>;
104
+
105
+ const tableNames = rows.map((r) => r.name).sort();
106
+ // messages_fts appears as a virtual table with shadow tables; the main
107
+ // virtual table itself is type='table' in sqlite_master.
108
+ const nonFts = tableNames.filter((n) => !n.startsWith('messages_fts'));
109
+ expect(nonFts).toEqual(EXPECTED_TABLES);
110
+ });
111
+
112
+ // TC-011 — messages_fts virtual table created
113
+ it('ensureConduitDb creates messages_fts virtual table', () => {
114
+ ensureConduitDb(tmpRoot);
115
+ const db = getConduitNativeDb();
116
+ expect(db).not.toBeNull();
117
+
118
+ const row = db!
119
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'")
120
+ .get() as { name: string } | undefined;
121
+ expect(row?.name).toBe('messages_fts');
122
+ });
123
+
124
+ // TC-012 — FTS5 triggers created and functional
125
+ it('messages_fts triggers are created and FTS search works after insert', () => {
126
+ ensureConduitDb(tmpRoot);
127
+ const db = getConduitNativeDb();
128
+ expect(db).not.toBeNull();
129
+
130
+ // Verify all three triggers exist.
131
+ const triggers = db!
132
+ .prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
133
+ .all() as Array<{ name: string }>;
134
+ const triggerNames = triggers.map((t) => t.name);
135
+ expect(triggerNames).toContain('messages_ai');
136
+ expect(triggerNames).toContain('messages_ad');
137
+ expect(triggerNames).toContain('messages_au');
138
+
139
+ // Insert a conversation first (FK constraint on messages).
140
+ db!.exec(`INSERT INTO conversations (id, participants, created_at, updated_at)
141
+ VALUES ('conv-1', '["a","b"]', 1000, 1000)`);
142
+
143
+ // Insert a message — messages_ai trigger should populate messages_fts.
144
+ db!.exec(`INSERT INTO messages
145
+ (id, conversation_id, from_agent_id, to_agent_id, content, created_at)
146
+ VALUES ('msg-1', 'conv-1', 'agent-a', 'agent-b', 'hello world', 1000)`);
147
+
148
+ const ftsRow = db!
149
+ .prepare("SELECT * FROM messages_fts WHERE messages_fts MATCH 'hello'")
150
+ .get() as Record<string, unknown> | undefined;
151
+ expect(ftsRow).toBeDefined();
152
+ });
153
+
154
+ // TC-003 — idempotent re-open
155
+ it('ensureConduitDb returns action=exists on second call for same project root', () => {
156
+ const first = ensureConduitDb(tmpRoot);
157
+ expect(first.action).toBe('created');
158
+
159
+ // Close the singleton to simulate a second process open.
160
+ closeConduitDb();
161
+
162
+ const second = ensureConduitDb(tmpRoot);
163
+ expect(second.action).toBe('exists');
164
+ expect(second.path).toBe(first.path);
165
+ });
166
+
167
+ it('ensureConduitDb is idempotent: data survives across two opens', () => {
168
+ ensureConduitDb(tmpRoot);
169
+ const db1 = getConduitNativeDb()!;
170
+ db1.exec(`INSERT INTO project_agent_refs (agent_id, attached_at)
171
+ VALUES ('test-agent', '2026-04-12T00:00:00Z')`);
172
+ closeConduitDb();
173
+
174
+ ensureConduitDb(tmpRoot);
175
+ const db2 = getConduitNativeDb()!;
176
+ const row = db2
177
+ .prepare('SELECT agent_id FROM project_agent_refs WHERE agent_id = ?')
178
+ .get('test-agent') as { agent_id: string } | undefined;
179
+ expect(row?.agent_id).toBe('test-agent');
180
+ });
181
+
182
+ // getConduitNativeDb — returns null before init
183
+ it('getConduitNativeDb returns null before ensureConduitDb is called', () => {
184
+ expect(getConduitNativeDb()).toBeNull();
185
+ });
186
+
187
+ // getConduitNativeDb — returns live handle after init
188
+ it('getConduitNativeDb returns the live DatabaseSync handle after ensureConduitDb', () => {
189
+ ensureConduitDb(tmpRoot);
190
+ const db = getConduitNativeDb();
191
+ expect(db).not.toBeNull();
192
+ expect(db!.isOpen).toBe(true);
193
+ });
194
+
195
+ // getConduitNativeDb — returns null after close
196
+ it('getConduitNativeDb returns null after closeConduitDb', () => {
197
+ ensureConduitDb(tmpRoot);
198
+ closeConduitDb();
199
+ expect(getConduitNativeDb()).toBeNull();
200
+ });
201
+
202
+ // integrity_check
203
+ it('conduit.db passes PRAGMA integrity_check after creation', () => {
204
+ ensureConduitDb(tmpRoot);
205
+ const db = getConduitNativeDb()!;
206
+ const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
207
+ expect(result.integrity_check).toBe('ok');
208
+ });
209
+
210
+ // project_agent_refs schema
211
+ it('project_agent_refs has expected columns with correct constraints', () => {
212
+ ensureConduitDb(tmpRoot);
213
+ const db = getConduitNativeDb()!;
214
+
215
+ const cols = db.prepare('PRAGMA table_info(project_agent_refs)').all() as Array<{
216
+ cid: number;
217
+ name: string;
218
+ type: string;
219
+ notnull: number;
220
+ dflt_value: unknown;
221
+ pk: number;
222
+ }>;
223
+
224
+ const colNames = cols.map((c) => c.name);
225
+ expect(colNames).toEqual([
226
+ 'agent_id',
227
+ 'attached_at',
228
+ 'role',
229
+ 'capabilities_override',
230
+ 'last_used_at',
231
+ 'enabled',
232
+ ]);
233
+
234
+ const agentIdCol = cols.find((c) => c.name === 'agent_id');
235
+ expect(agentIdCol?.pk).toBe(1);
236
+
237
+ const enabledCol = cols.find((c) => c.name === 'enabled');
238
+ expect(enabledCol?.notnull).toBe(1);
239
+ expect(enabledCol?.dflt_value).toBe('1');
240
+ });
241
+
242
+ // partial index on project_agent_refs
243
+ it('idx_project_agent_refs_enabled partial index exists on project_agent_refs', () => {
244
+ ensureConduitDb(tmpRoot);
245
+ const db = getConduitNativeDb()!;
246
+
247
+ const indices = db.prepare('PRAGMA index_list(project_agent_refs)').all() as Array<{
248
+ seq: number;
249
+ name: string;
250
+ unique: number;
251
+ origin: string;
252
+ partial: number;
253
+ }>;
254
+ const enabledIdx = indices.find((i) => i.name === 'idx_project_agent_refs_enabled');
255
+ expect(enabledIdx).toBeDefined();
256
+ expect(enabledIdx?.partial).toBe(1);
257
+ });
258
+
259
+ // applyConduitSchema — idempotent on existing db
260
+ it('applyConduitSchema is idempotent when called twice on the same db', () => {
261
+ const dbPath = join(tmpRoot, 'manual.db');
262
+ mkdirSync(tmpRoot, { recursive: true });
263
+ const db = new DatabaseSync(dbPath);
264
+ expect(() => applyConduitSchema(db)).not.toThrow();
265
+ expect(() => applyConduitSchema(db)).not.toThrow();
266
+ db.close();
267
+ });
268
+
269
+ // schema version recorded in _conduit_meta
270
+ it('ensureConduitDb records CONDUIT_SCHEMA_VERSION in _conduit_meta', () => {
271
+ ensureConduitDb(tmpRoot);
272
+ const db = getConduitNativeDb()!;
273
+ const meta = db.prepare("SELECT value FROM _conduit_meta WHERE key = 'schema_version'").get() as
274
+ | { value: string }
275
+ | undefined;
276
+ expect(meta?.value).toBe(CONDUIT_SCHEMA_VERSION);
277
+ });
278
+
279
+ // migration row recorded in _conduit_migrations
280
+ it('ensureConduitDb records initial migration in _conduit_migrations', () => {
281
+ ensureConduitDb(tmpRoot);
282
+ const db = getConduitNativeDb()!;
283
+ const mig = db
284
+ .prepare(
285
+ "SELECT name FROM _conduit_migrations WHERE name = '2026-04-12-000000_initial_conduit'",
286
+ )
287
+ .get() as { name: string } | undefined;
288
+ expect(mig?.name).toBe('2026-04-12-000000_initial_conduit');
289
+ });
290
+
291
+ // checkConduitDbHealth — db absent
292
+ it('checkConduitDbHealth returns exists=false when conduit.db does not exist', () => {
293
+ const health = checkConduitDbHealth(tmpRoot);
294
+ expect(health.exists).toBe(false);
295
+ expect(health.tableCount).toBe(0);
296
+ expect(health.walMode).toBe(false);
297
+ expect(health.schemaVersion).toBeNull();
298
+ expect(health.foreignKeysEnabled).toBe(false);
299
+ });
300
+
301
+ // checkConduitDbHealth — after creation
302
+ it('checkConduitDbHealth returns correct health after ensureConduitDb', () => {
303
+ ensureConduitDb(tmpRoot);
304
+ closeConduitDb();
305
+
306
+ const health = checkConduitDbHealth(tmpRoot);
307
+ expect(health.exists).toBe(true);
308
+ expect(health.walMode).toBe(true);
309
+ expect(health.schemaVersion).toBe(CONDUIT_SCHEMA_VERSION);
310
+ expect(health.foreignKeysEnabled).toBe(true);
311
+ // At minimum all non-FTS tables + FTS main table + meta tables.
312
+ expect(health.tableCount).toBeGreaterThanOrEqual(EXPECTED_TABLES.length);
313
+ });
314
+
315
+ // ensureConduitDb with pre-existing .cleo/ dir
316
+ it('ensureConduitDb works when .cleo/ dir already exists', () => {
317
+ mkdirSync(join(tmpRoot, '.cleo'), { recursive: true });
318
+ expect(() => ensureConduitDb(tmpRoot)).not.toThrow();
319
+ expect(existsSync(join(tmpRoot, '.cleo', 'conduit.db'))).toBe(true);
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // project_agent_refs CRUD (T353)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe('project_agent_refs CRUD (T353)', () => {
327
+ it('TC-004: attachAgentToProject inserts a new row with enabled=1', () => {
328
+ ensureConduitDb(tmpRoot);
329
+ const db = getConduitNativeDb()!;
330
+ attachAgentToProject(db, 'agent-1');
331
+ const ref = getProjectAgentRef(db, 'agent-1');
332
+ expect(ref).not.toBeNull();
333
+ expect(ref?.agentId).toBe('agent-1');
334
+ expect(ref?.enabled).toBe(1);
335
+ expect(ref?.role).toBeNull();
336
+ expect(ref?.capabilitiesOverride).toBeNull();
337
+ expect(ref?.lastUsedAt).toBeNull();
338
+ });
339
+
340
+ it('TC-005: attachAgentToProject re-enables an existing enabled=0 row without duplicate', () => {
341
+ ensureConduitDb(tmpRoot);
342
+ const db = getConduitNativeDb()!;
343
+ attachAgentToProject(db, 'agent-1');
344
+ detachAgentFromProject(db, 'agent-1');
345
+ const detached = getProjectAgentRef(db, 'agent-1');
346
+ expect(detached?.enabled).toBe(0);
347
+ attachAgentToProject(db, 'agent-1', { role: 'reviewer' });
348
+ const reattached = getProjectAgentRef(db, 'agent-1');
349
+ expect(reattached?.enabled).toBe(1);
350
+ expect(reattached?.role).toBe('reviewer');
351
+ const count = (
352
+ db
353
+ .prepare('SELECT COUNT(*) AS c FROM project_agent_refs WHERE agent_id = ?')
354
+ .get('agent-1') as {
355
+ c: number;
356
+ }
357
+ ).c;
358
+ expect(count).toBe(1);
359
+ });
360
+
361
+ it('TC-006: detachAgentFromProject sets enabled=0 without deleting', () => {
362
+ ensureConduitDb(tmpRoot);
363
+ const db = getConduitNativeDb()!;
364
+ attachAgentToProject(db, 'agent-1');
365
+ detachAgentFromProject(db, 'agent-1');
366
+ const ref = getProjectAgentRef(db, 'agent-1');
367
+ expect(ref).not.toBeNull();
368
+ expect(ref?.enabled).toBe(0);
369
+ });
370
+
371
+ it('TC-007: listProjectAgentRefs returns only enabled=1 rows by default', () => {
372
+ ensureConduitDb(tmpRoot);
373
+ const db = getConduitNativeDb()!;
374
+ attachAgentToProject(db, 'agent-1');
375
+ attachAgentToProject(db, 'agent-2');
376
+ attachAgentToProject(db, 'agent-3');
377
+ detachAgentFromProject(db, 'agent-2');
378
+ const enabled = listProjectAgentRefs(db);
379
+ expect(enabled.length).toBe(2);
380
+ expect(enabled.map((r) => r.agentId).sort()).toEqual(['agent-1', 'agent-3']);
381
+ });
382
+
383
+ it('TC-008: listProjectAgentRefs returns all rows when enabledOnly=false', () => {
384
+ ensureConduitDb(tmpRoot);
385
+ const db = getConduitNativeDb()!;
386
+ attachAgentToProject(db, 'agent-1');
387
+ attachAgentToProject(db, 'agent-2');
388
+ detachAgentFromProject(db, 'agent-2');
389
+ const all = listProjectAgentRefs(db, { enabledOnly: false });
390
+ expect(all.length).toBe(2);
391
+ });
392
+
393
+ it('TC-009: getProjectAgentRef returns null for unknown agent', () => {
394
+ ensureConduitDb(tmpRoot);
395
+ const db = getConduitNativeDb()!;
396
+ const ref = getProjectAgentRef(db, 'nonexistent');
397
+ expect(ref).toBeNull();
398
+ });
399
+
400
+ it('TC-010: updateProjectAgentLastUsed sets last_used_at to current ISO timestamp', () => {
401
+ ensureConduitDb(tmpRoot);
402
+ const db = getConduitNativeDb()!;
403
+ attachAgentToProject(db, 'agent-1');
404
+ const before = new Date().toISOString();
405
+ updateProjectAgentLastUsed(db, 'agent-1');
406
+ const after = new Date().toISOString();
407
+ const ref = getProjectAgentRef(db, 'agent-1');
408
+ expect(ref?.lastUsedAt).not.toBeNull();
409
+ expect(ref!.lastUsedAt! >= before).toBe(true);
410
+ expect(ref!.lastUsedAt! <= after).toBe(true);
411
+ });
412
+ });
413
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Unit tests for global-salt subsystem.
3
+ *
4
+ * @task T348
5
+ * @epic T310
6
+ */
7
+
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import {
14
+ __clearGlobalSaltCache,
15
+ GLOBAL_SALT_FILENAME,
16
+ GLOBAL_SALT_SIZE,
17
+ getGlobalSalt,
18
+ getGlobalSaltPath,
19
+ validateGlobalSalt,
20
+ } from '../global-salt.js';
21
+
22
+ // Mock getCleoHome to use a per-test tmp directory
23
+ let tmpHome: string;
24
+ vi.mock('../../paths.js', () => ({
25
+ getCleoHome: () => tmpHome,
26
+ }));
27
+
28
+ describe('global-salt', () => {
29
+ beforeEach(() => {
30
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t348-'));
31
+ __clearGlobalSaltCache();
32
+ });
33
+
34
+ afterEach(() => {
35
+ fs.rmSync(tmpHome, { recursive: true, force: true });
36
+ });
37
+
38
+ // -------------------------------------------------------------------------
39
+ // getGlobalSaltPath
40
+ // -------------------------------------------------------------------------
41
+
42
+ describe('getGlobalSaltPath', () => {
43
+ it('returns <cleoHome>/global-salt', () => {
44
+ expect(getGlobalSaltPath()).toBe(path.join(tmpHome, GLOBAL_SALT_FILENAME));
45
+ });
46
+ });
47
+
48
+ // -------------------------------------------------------------------------
49
+ // getGlobalSalt — first-run generation
50
+ // -------------------------------------------------------------------------
51
+
52
+ describe('getGlobalSalt — first-run generation', () => {
53
+ it('generates a Buffer of exactly 32 bytes on first call', () => {
54
+ const salt = getGlobalSalt();
55
+ expect(salt).toBeInstanceOf(Buffer);
56
+ expect(salt.length).toBe(GLOBAL_SALT_SIZE);
57
+ });
58
+
59
+ it('persists the salt file to disk', () => {
60
+ getGlobalSalt();
61
+ expect(fs.existsSync(getGlobalSaltPath())).toBe(true);
62
+ });
63
+
64
+ it('writes the salt file with mode 0o600 on POSIX', () => {
65
+ if (process.platform === 'win32') return;
66
+ getGlobalSalt();
67
+ const mode = fs.statSync(getGlobalSaltPath()).mode & 0o777;
68
+ expect(mode).toBe(0o600);
69
+ });
70
+
71
+ it('creates the cleoHome directory if it does not exist', () => {
72
+ // Remove the tmp dir to simulate a missing cleoHome
73
+ fs.rmSync(tmpHome, { recursive: true, force: true });
74
+ expect(() => getGlobalSalt()).not.toThrow();
75
+ expect(fs.existsSync(tmpHome)).toBe(true);
76
+ expect(fs.existsSync(getGlobalSaltPath())).toBe(true);
77
+ });
78
+
79
+ it('leaves no lingering .tmp- files after a successful write', () => {
80
+ getGlobalSalt();
81
+ const files = fs.readdirSync(tmpHome);
82
+ const lingering = files.filter((f) => f.includes('.tmp-'));
83
+ expect(lingering).toEqual([]);
84
+ });
85
+ });
86
+
87
+ // -------------------------------------------------------------------------
88
+ // getGlobalSalt — memoization
89
+ // -------------------------------------------------------------------------
90
+
91
+ describe('getGlobalSalt — memoization', () => {
92
+ it('returns the same Buffer instance on repeated calls (memoized)', () => {
93
+ const first = getGlobalSalt();
94
+ const second = getGlobalSalt();
95
+ expect(second.equals(first)).toBe(true);
96
+ });
97
+
98
+ it('returns the same bytes after a cache clear (simulated process restart)', () => {
99
+ const first = getGlobalSalt();
100
+ __clearGlobalSaltCache();
101
+ const second = getGlobalSalt();
102
+ expect(second.equals(first)).toBe(true);
103
+ });
104
+
105
+ it('NEVER overwrites an existing file on subsequent calls', () => {
106
+ getGlobalSalt();
107
+ const mtime1 = fs.statSync(getGlobalSaltPath()).mtimeMs;
108
+
109
+ // Spin-wait 2 ms so mtime could differ if the file were rewritten
110
+ const until = Date.now() + 2;
111
+ while (Date.now() < until) {
112
+ // busy wait
113
+ }
114
+
115
+ __clearGlobalSaltCache();
116
+ getGlobalSalt();
117
+ const mtime2 = fs.statSync(getGlobalSaltPath()).mtimeMs;
118
+ expect(mtime2).toBe(mtime1);
119
+ });
120
+ });
121
+
122
+ // -------------------------------------------------------------------------
123
+ // getGlobalSalt — validation on existing file
124
+ // -------------------------------------------------------------------------
125
+
126
+ describe('getGlobalSalt — validation on existing file', () => {
127
+ it('throws with "wrong size" when the file has incorrect byte count', () => {
128
+ fs.mkdirSync(tmpHome, { recursive: true });
129
+ fs.writeFileSync(getGlobalSaltPath(), Buffer.alloc(16), { mode: 0o600 });
130
+ __clearGlobalSaltCache();
131
+ expect(() => getGlobalSalt()).toThrow(/wrong size/);
132
+ });
133
+
134
+ it('throws with "wrong permissions" on POSIX when mode is too permissive', () => {
135
+ if (process.platform === 'win32') return;
136
+ fs.mkdirSync(tmpHome, { recursive: true });
137
+ const randomBytes = crypto.randomBytes(GLOBAL_SALT_SIZE);
138
+ fs.writeFileSync(getGlobalSaltPath(), randomBytes, { mode: 0o644 });
139
+ __clearGlobalSaltCache();
140
+ expect(() => getGlobalSalt()).toThrow(/wrong permissions/);
141
+ });
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // validateGlobalSalt
146
+ // -------------------------------------------------------------------------
147
+
148
+ describe('validateGlobalSalt', () => {
149
+ it('is a no-op when the salt file does not exist', () => {
150
+ expect(() => validateGlobalSalt()).not.toThrow();
151
+ });
152
+
153
+ it('does not throw when the salt file is valid', () => {
154
+ getGlobalSalt(); // generates a valid file
155
+ expect(() => validateGlobalSalt()).not.toThrow();
156
+ });
157
+
158
+ it('throws with "validation failed" and "size" when size is wrong', () => {
159
+ fs.mkdirSync(tmpHome, { recursive: true });
160
+ fs.writeFileSync(getGlobalSaltPath(), Buffer.alloc(10), { mode: 0o600 });
161
+ expect(() => validateGlobalSalt()).toThrow(/validation failed.*size/i);
162
+ });
163
+
164
+ it('throws with "validation failed" and "permissions" on POSIX when mode is wrong', () => {
165
+ if (process.platform === 'win32') return;
166
+ fs.mkdirSync(tmpHome, { recursive: true });
167
+ const randomBytes = crypto.randomBytes(GLOBAL_SALT_SIZE);
168
+ fs.writeFileSync(getGlobalSaltPath(), randomBytes, { mode: 0o644 });
169
+ expect(() => validateGlobalSalt()).toThrow(/validation failed.*permissions/i);
170
+ });
171
+ });
172
+
173
+ // -------------------------------------------------------------------------
174
+ // __clearGlobalSaltCache — test utility
175
+ // -------------------------------------------------------------------------
176
+
177
+ describe('__clearGlobalSaltCache', () => {
178
+ it('allows re-generation in a fresh tmp dir after cache is cleared', () => {
179
+ const first = getGlobalSalt();
180
+
181
+ // Simulate switching to a different machine / user by using a new tmpHome
182
+ fs.rmSync(tmpHome, { recursive: true, force: true });
183
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t348-fresh-'));
184
+ __clearGlobalSaltCache();
185
+
186
+ const second = getGlobalSalt();
187
+ // Different directory => different (freshly generated) salt
188
+ expect(fs.existsSync(getGlobalSaltPath())).toBe(true);
189
+ // The new salt is a valid 32-byte buffer (may or may not equal first by chance)
190
+ expect(second.length).toBe(GLOBAL_SALT_SIZE);
191
+ // Suppress unused variable warning
192
+ expect(first.length).toBe(GLOBAL_SALT_SIZE);
193
+ });
194
+ });
195
+ });