@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.
- package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
- package/dist/codebase-map/analyzers/architecture.js +0 -1
- package/dist/codebase-map/analyzers/architecture.js.map +1 -1
- package/dist/conduit/local-transport.d.ts +18 -8
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +23 -13
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.js +175 -68950
- package/dist/index.js.map +1 -7
- package/dist/init.d.ts +1 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +8 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -6
- package/dist/internal.js.map +1 -1
- package/dist/memory/learnings.d.ts +2 -2
- package/dist/memory/patterns.d.ts +6 -6
- package/dist/output.d.ts +32 -11
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +67 -67
- package/dist/output.js.map +1 -1
- package/dist/paths.js +80 -14
- package/dist/paths.js.map +1 -1
- package/dist/skills/dynamic-skill-generator.d.ts +0 -2
- package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
- package/dist/skills/dynamic-skill-generator.js.map +1 -1
- package/dist/store/agent-registry-accessor.d.ts +203 -12
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +618 -100
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +73 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -0
- package/dist/store/api-key-kdf.js +84 -0
- package/dist/store/api-key-kdf.js.map +1 -0
- package/dist/store/cleanup-legacy.js +171 -0
- package/dist/store/cleanup-legacy.js.map +1 -0
- package/dist/store/conduit-sqlite.d.ts +184 -0
- package/dist/store/conduit-sqlite.d.ts.map +1 -0
- package/dist/store/conduit-sqlite.js +570 -0
- package/dist/store/conduit-sqlite.js.map +1 -0
- package/dist/store/global-salt.d.ts +78 -0
- package/dist/store/global-salt.d.ts.map +1 -0
- package/dist/store/global-salt.js +147 -0
- package/dist/store/global-salt.js.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.js +555 -0
- package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
- package/dist/store/nexus-sqlite.js +28 -3
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/signaldock-sqlite.d.ts +122 -19
- package/dist/store/signaldock-sqlite.d.ts.map +1 -1
- package/dist/store/signaldock-sqlite.js +401 -251
- package/dist/store/signaldock-sqlite.js.map +1 -1
- package/dist/store/sqlite-backup.js +122 -4
- package/dist/store/sqlite-backup.js.map +1 -1
- package/dist/system/backup.d.ts +0 -26
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/runtime.d.ts +0 -2
- package/dist/system/runtime.d.ts.map +1 -1
- package/dist/system/runtime.js +3 -3
- package/dist/system/runtime.js.map +1 -1
- package/dist/tasks/add.d.ts +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +98 -23
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +4 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +4 -1
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/labels.d.ts.map +1 -1
- package/dist/tasks/labels.js +4 -1
- package/dist/tasks/labels.js.map +1 -1
- package/dist/tasks/relates.d.ts.map +1 -1
- package/dist/tasks/relates.js +16 -4
- package/dist/tasks/relates.js.map +1 -1
- package/dist/tasks/show.d.ts.map +1 -1
- package/dist/tasks/show.js +4 -1
- package/dist/tasks/show.js.map +1 -1
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +32 -6
- package/dist/tasks/update.js.map +1 -1
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/engine.js +16 -4
- package/dist/validation/engine.js.map +1 -1
- package/dist/validation/param-utils.d.ts +5 -3
- package/dist/validation/param-utils.d.ts.map +1 -1
- package/dist/validation/param-utils.js +8 -6
- package/dist/validation/param-utils.js.map +1 -1
- package/dist/validation/protocols/_shared.d.ts.map +1 -1
- package/dist/validation/protocols/_shared.js +13 -6
- package/dist/validation/protocols/_shared.js.map +1 -1
- package/package.json +9 -7
- package/src/adapters/__tests__/manager.test.ts +0 -1
- package/src/codebase-map/analyzers/architecture.ts +0 -1
- package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
- package/src/conduit/__tests__/local-transport.test.ts +14 -12
- package/src/conduit/local-transport.ts +23 -13
- package/src/config.ts +0 -1
- package/src/errors.ts +24 -0
- package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
- package/src/init.ts +1 -2
- package/src/internal.ts +96 -2
- package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
- package/src/memory/__tests__/engine-compat.test.ts +2 -2
- package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
- package/src/observability/__tests__/index.test.ts +4 -4
- package/src/observability/__tests__/log-filter.test.ts +4 -4
- package/src/output.ts +73 -75
- package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
- package/src/sessions/__tests__/session-grade.test.ts +2 -2
- package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
- package/src/skills/dynamic-skill-generator.ts +0 -2
- package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
- package/src/store/__tests__/api-key-kdf.test.ts +113 -0
- package/src/store/__tests__/backup-crypto.test.ts +101 -0
- package/src/store/__tests__/backup-pack.test.ts +491 -0
- package/src/store/__tests__/backup-unpack.test.ts +298 -0
- package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
- package/src/store/__tests__/global-salt.test.ts +195 -0
- package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
- package/src/store/__tests__/regenerators.test.ts +234 -0
- package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
- package/src/store/__tests__/restore-json-merge.test.ts +521 -0
- package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
- package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
- package/src/store/__tests__/sqlite-backup.test.ts +5 -1
- package/src/store/__tests__/t310-integration.test.ts +1150 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/agent-registry-accessor.ts +847 -140
- package/src/store/api-key-kdf.ts +104 -0
- package/src/store/backup-crypto.ts +209 -0
- package/src/store/backup-pack.ts +739 -0
- package/src/store/backup-unpack.ts +583 -0
- package/src/store/conduit-sqlite.ts +655 -0
- package/src/store/global-salt.ts +175 -0
- package/src/store/migrate-signaldock-to-conduit.ts +669 -0
- package/src/store/regenerators.ts +243 -0
- package/src/store/restore-conflict-report.ts +317 -0
- package/src/store/restore-json-merge.ts +653 -0
- package/src/store/signaldock-sqlite.ts +431 -254
- package/src/store/sqlite-backup.ts +185 -10
- package/src/store/t310-readiness.ts +119 -0
- package/src/system/backup.ts +2 -62
- package/src/system/runtime.ts +4 -6
- package/src/tasks/__tests__/error-hints.test.ts +256 -0
- package/src/tasks/add.ts +99 -9
- package/src/tasks/complete.ts +4 -1
- package/src/tasks/find.ts +4 -1
- package/src/tasks/labels.ts +4 -1
- package/src/tasks/relates.ts +16 -4
- package/src/tasks/show.ts +4 -1
- package/src/tasks/update.ts +32 -3
- package/src/validation/__tests__/error-hints.test.ts +97 -0
- package/src/validation/engine.ts +16 -1
- package/src/validation/param-utils.ts +10 -7
- package/src/validation/protocols/_shared.ts +14 -6
- package/src/validation/protocols/cant/architecture-decision.cant +80 -0
- package/src/validation/protocols/cant/artifact-publish.cant +95 -0
- package/src/validation/protocols/cant/consensus.cant +74 -0
- package/src/validation/protocols/cant/contribution.cant +82 -0
- package/src/validation/protocols/cant/decomposition.cant +92 -0
- package/src/validation/protocols/cant/implementation.cant +67 -0
- package/src/validation/protocols/cant/provenance.cant +88 -0
- package/src/validation/protocols/cant/release.cant +96 -0
- package/src/validation/protocols/cant/research.cant +66 -0
- package/src/validation/protocols/cant/specification.cant +67 -0
- package/src/validation/protocols/cant/testing.cant +88 -0
- package/src/validation/protocols/cant/validation.cant +65 -0
- package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
- package/templates/config.template.json +0 -1
- package/templates/global-config.template.json +0 -1
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T310 integration test suite: conduit + signaldock cross-project agent lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Covers 12 end-to-end scenarios across the full conduit + signaldock topology
|
|
5
|
+
* introduced by ADR-037 and spec §7.5 (cross-DB integration), §7.6 (migration),
|
|
6
|
+
* and §7.8 (KDF / reauth).
|
|
7
|
+
*
|
|
8
|
+
* All filesystem interactions occur inside fresh tmp directories per test.
|
|
9
|
+
* The real user's $XDG_DATA_HOME and project directories are never touched.
|
|
10
|
+
* `getCleoHome()` is redirected to a per-test tmp directory via `vi.doMock`.
|
|
11
|
+
*
|
|
12
|
+
* Test approach: `vi.resetModules()` before each test then `vi.doMock()` for
|
|
13
|
+
* paths.js → fresh module import chain → functions use isolated tmp dirs.
|
|
14
|
+
*
|
|
15
|
+
* @task T371
|
|
16
|
+
* @epic T310
|
|
17
|
+
* @why Verifies the full cross-DB lifecycle contract (ADR-037) including:
|
|
18
|
+
* conduit.db creation, global signaldock.db identity, project_agent_refs
|
|
19
|
+
* INNER/OUTER join semantics, cross-project isolation, migration, KDF, and
|
|
20
|
+
* backup registry.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
mkdtempSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
35
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Logger mock — prevents pino from attempting to open real log files.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
vi.mock('../../logger.js', () => ({
|
|
42
|
+
getLogger: () => ({
|
|
43
|
+
info: vi.fn(),
|
|
44
|
+
warn: vi.fn(),
|
|
45
|
+
error: vi.fn(),
|
|
46
|
+
debug: vi.fn(),
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create isolated tmp directory pair for one test.
|
|
56
|
+
*
|
|
57
|
+
* @param prefix - Short identifier for debug visibility.
|
|
58
|
+
* @returns cleoHome and one projectRoot, plus cleanup.
|
|
59
|
+
*/
|
|
60
|
+
function makeTmpPair(prefix: string): {
|
|
61
|
+
cleoHome: string;
|
|
62
|
+
projectRoot: string;
|
|
63
|
+
cleanup: () => void;
|
|
64
|
+
} {
|
|
65
|
+
const base = mkdtempSync(join(tmpdir(), `cleo-t371-${prefix}-`));
|
|
66
|
+
const cleoHome = join(base, 'cleo-home');
|
|
67
|
+
const projectRoot = join(base, 'project');
|
|
68
|
+
mkdirSync(cleoHome, { recursive: true });
|
|
69
|
+
mkdirSync(join(projectRoot, '.cleo'), { recursive: true });
|
|
70
|
+
return {
|
|
71
|
+
cleoHome,
|
|
72
|
+
projectRoot,
|
|
73
|
+
cleanup: () => rmSync(base, { recursive: true, force: true }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a second isolated project root under the same base directory.
|
|
79
|
+
* Used for cross-project scenarios.
|
|
80
|
+
*
|
|
81
|
+
* @param base - Base tmp directory.
|
|
82
|
+
* @returns Absolute path to the second project root.
|
|
83
|
+
*/
|
|
84
|
+
function makeSecondProject(base: string): string {
|
|
85
|
+
const projectB = join(base, 'project-b');
|
|
86
|
+
mkdirSync(join(projectB, '.cleo'), { recursive: true });
|
|
87
|
+
return projectB;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Seed a deterministic machine-key and global-salt so KDF is testable without
|
|
92
|
+
* random entropy. Returns the fixed buffers used.
|
|
93
|
+
*
|
|
94
|
+
* @param cleoHome - Global tier home directory.
|
|
95
|
+
* @param saltByte - Byte value to fill the 32-byte global-salt. Defaults to 0xcd.
|
|
96
|
+
* @param keyByte - Byte value to fill the 32-byte machine-key. Defaults to 0xab.
|
|
97
|
+
*/
|
|
98
|
+
function seedKeys(
|
|
99
|
+
cleoHome: string,
|
|
100
|
+
saltByte = 0xcd,
|
|
101
|
+
keyByte = 0xab,
|
|
102
|
+
): { machineKey: Buffer; globalSalt: Buffer } {
|
|
103
|
+
const machineKey = Buffer.alloc(32, keyByte);
|
|
104
|
+
const globalSalt = Buffer.alloc(32, saltByte);
|
|
105
|
+
writeFileSync(join(cleoHome, 'machine-key'), machineKey, { mode: 0o600 });
|
|
106
|
+
writeFileSync(join(cleoHome, 'global-salt'), globalSalt, { mode: 0o600 });
|
|
107
|
+
return { machineKey, globalSalt };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Insert a minimal agent row directly into a global signaldock.db.
|
|
112
|
+
* Used in seeding tests without going through the full accessor layer.
|
|
113
|
+
*/
|
|
114
|
+
function insertGlobalAgent(
|
|
115
|
+
globalDb: DatabaseSync,
|
|
116
|
+
agentId: string,
|
|
117
|
+
name: string,
|
|
118
|
+
requiresReauth = 0,
|
|
119
|
+
): void {
|
|
120
|
+
const now = Math.floor(Date.now() / 1000);
|
|
121
|
+
globalDb
|
|
122
|
+
.prepare(
|
|
123
|
+
`INSERT OR IGNORE INTO agents
|
|
124
|
+
(id, agent_id, name, class, privacy_tier, capabilities, skills,
|
|
125
|
+
transport_type, api_base_url, classification, transport_config,
|
|
126
|
+
is_active, status, created_at, updated_at, requires_reauth)
|
|
127
|
+
VALUES (?, ?, ?, 'custom', 'public', '[]', '[]', 'http',
|
|
128
|
+
'https://api.signaldock.io', NULL, '{}', 1, 'online', ?, ?, ?)`,
|
|
129
|
+
)
|
|
130
|
+
.run(crypto.randomUUID(), agentId, name, now, now, requiresReauth);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Insert a project_agent_refs row directly into a conduit.db.
|
|
135
|
+
* Used in seeding scenarios that bypass the accessor layer.
|
|
136
|
+
*/
|
|
137
|
+
function insertConduitRef(conduitDb: DatabaseSync, agentId: string, enabled = 1): void {
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
conduitDb
|
|
140
|
+
.prepare(
|
|
141
|
+
`INSERT OR IGNORE INTO project_agent_refs
|
|
142
|
+
(agent_id, attached_at, role, capabilities_override, last_used_at, enabled)
|
|
143
|
+
VALUES (?, ?, NULL, NULL, NULL, ?)`,
|
|
144
|
+
)
|
|
145
|
+
.run(agentId, now, enabled);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a legacy project-tier signaldock.db at `<projectRoot>/.cleo/signaldock.db`
|
|
150
|
+
* with the pre-T310 schema and optionally seed agents / messages / conversations.
|
|
151
|
+
*/
|
|
152
|
+
function createLegacySignaldockDb(
|
|
153
|
+
projectRoot: string,
|
|
154
|
+
agents: Array<{
|
|
155
|
+
id: string;
|
|
156
|
+
agentId: string;
|
|
157
|
+
name: string;
|
|
158
|
+
classification?: string;
|
|
159
|
+
createdAt?: number;
|
|
160
|
+
lastUsedAt?: number;
|
|
161
|
+
}> = [],
|
|
162
|
+
messages: Array<{
|
|
163
|
+
id: string;
|
|
164
|
+
conversationId: string;
|
|
165
|
+
fromAgentId: string;
|
|
166
|
+
toAgentId: string;
|
|
167
|
+
content: string;
|
|
168
|
+
createdAt: number;
|
|
169
|
+
}> = [],
|
|
170
|
+
conversations: Array<{
|
|
171
|
+
id: string;
|
|
172
|
+
participants: string;
|
|
173
|
+
createdAt: number;
|
|
174
|
+
updatedAt: number;
|
|
175
|
+
}> = [],
|
|
176
|
+
): string {
|
|
177
|
+
const dbPath = join(projectRoot, '.cleo', 'signaldock.db');
|
|
178
|
+
const db = new DatabaseSync(dbPath);
|
|
179
|
+
const now = Math.floor(Date.now() / 1000);
|
|
180
|
+
|
|
181
|
+
db.exec(`
|
|
182
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
183
|
+
id TEXT PRIMARY KEY,
|
|
184
|
+
agent_id TEXT NOT NULL UNIQUE,
|
|
185
|
+
name TEXT NOT NULL,
|
|
186
|
+
class TEXT NOT NULL DEFAULT 'custom',
|
|
187
|
+
classification TEXT,
|
|
188
|
+
created_at INTEGER NOT NULL,
|
|
189
|
+
updated_at INTEGER NOT NULL,
|
|
190
|
+
api_key_encrypted TEXT,
|
|
191
|
+
last_used_at INTEGER,
|
|
192
|
+
requires_reauth INTEGER NOT NULL DEFAULT 0
|
|
193
|
+
);
|
|
194
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
195
|
+
id TEXT PRIMARY KEY,
|
|
196
|
+
participants TEXT NOT NULL,
|
|
197
|
+
visibility TEXT NOT NULL DEFAULT 'private',
|
|
198
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
199
|
+
last_message_at INTEGER,
|
|
200
|
+
created_at INTEGER NOT NULL,
|
|
201
|
+
updated_at INTEGER NOT NULL
|
|
202
|
+
);
|
|
203
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
204
|
+
id TEXT PRIMARY KEY,
|
|
205
|
+
conversation_id TEXT NOT NULL,
|
|
206
|
+
from_agent_id TEXT NOT NULL,
|
|
207
|
+
to_agent_id TEXT NOT NULL,
|
|
208
|
+
content TEXT NOT NULL,
|
|
209
|
+
content_type TEXT NOT NULL DEFAULT 'text',
|
|
210
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
211
|
+
attachments TEXT NOT NULL DEFAULT '[]',
|
|
212
|
+
group_id TEXT,
|
|
213
|
+
metadata TEXT DEFAULT '{}',
|
|
214
|
+
reply_to TEXT,
|
|
215
|
+
created_at INTEGER NOT NULL,
|
|
216
|
+
delivered_at INTEGER,
|
|
217
|
+
read_at INTEGER
|
|
218
|
+
);
|
|
219
|
+
`);
|
|
220
|
+
|
|
221
|
+
for (const agent of agents) {
|
|
222
|
+
db.prepare(
|
|
223
|
+
`INSERT INTO agents
|
|
224
|
+
(id, agent_id, name, classification, created_at, updated_at, api_key_encrypted, last_used_at)
|
|
225
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`,
|
|
226
|
+
).run(
|
|
227
|
+
agent.id,
|
|
228
|
+
agent.agentId,
|
|
229
|
+
agent.name,
|
|
230
|
+
agent.classification ?? null,
|
|
231
|
+
agent.createdAt ?? now,
|
|
232
|
+
now,
|
|
233
|
+
agent.lastUsedAt ?? null,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const conv of conversations) {
|
|
238
|
+
db.prepare(
|
|
239
|
+
`INSERT INTO conversations (id, participants, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
|
240
|
+
).run(conv.id, conv.participants, conv.createdAt, conv.updatedAt);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const msg of messages) {
|
|
244
|
+
db.prepare(
|
|
245
|
+
`INSERT INTO messages
|
|
246
|
+
(id, conversation_id, from_agent_id, to_agent_id, content, created_at)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
248
|
+
).run(msg.id, msg.conversationId, msg.fromAgentId, msg.toAgentId, msg.content, msg.createdAt);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
db.close();
|
|
252
|
+
return dbPath;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create a pre-seeded global signaldock.db (schema applied, agents optionally inserted).
|
|
257
|
+
* Returns the path to the created file.
|
|
258
|
+
*/
|
|
259
|
+
function createGlobalSignaldockDbFile(
|
|
260
|
+
cleoHome: string,
|
|
261
|
+
agents: Array<{ id: string; agentId: string; name: string; requiresReauth?: number }> = [],
|
|
262
|
+
): string {
|
|
263
|
+
const dbPath = join(cleoHome, 'signaldock.db');
|
|
264
|
+
const db = new DatabaseSync(dbPath);
|
|
265
|
+
const now = Math.floor(Date.now() / 1000);
|
|
266
|
+
|
|
267
|
+
db.exec(`
|
|
268
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
269
|
+
id TEXT PRIMARY KEY,
|
|
270
|
+
agent_id TEXT NOT NULL UNIQUE,
|
|
271
|
+
name TEXT NOT NULL,
|
|
272
|
+
class TEXT NOT NULL DEFAULT 'custom',
|
|
273
|
+
privacy_tier TEXT NOT NULL DEFAULT 'public',
|
|
274
|
+
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
275
|
+
skills TEXT NOT NULL DEFAULT '[]',
|
|
276
|
+
messages_sent INTEGER NOT NULL DEFAULT 0,
|
|
277
|
+
messages_received INTEGER NOT NULL DEFAULT 0,
|
|
278
|
+
conversation_count INTEGER NOT NULL DEFAULT 0,
|
|
279
|
+
friend_count INTEGER NOT NULL DEFAULT 0,
|
|
280
|
+
status TEXT NOT NULL DEFAULT 'online',
|
|
281
|
+
payment_config TEXT,
|
|
282
|
+
api_key_hash TEXT,
|
|
283
|
+
created_at INTEGER NOT NULL,
|
|
284
|
+
updated_at INTEGER NOT NULL,
|
|
285
|
+
transport_type TEXT NOT NULL DEFAULT 'http',
|
|
286
|
+
api_key_encrypted TEXT,
|
|
287
|
+
api_base_url TEXT NOT NULL DEFAULT 'https://api.signaldock.io',
|
|
288
|
+
classification TEXT,
|
|
289
|
+
transport_config TEXT NOT NULL DEFAULT '{}',
|
|
290
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
291
|
+
last_used_at INTEGER,
|
|
292
|
+
requires_reauth INTEGER NOT NULL DEFAULT 0
|
|
293
|
+
);
|
|
294
|
+
CREATE UNIQUE INDEX IF NOT EXISTS agents_agent_id_idx ON agents(agent_id);
|
|
295
|
+
CREATE TABLE IF NOT EXISTS capabilities (
|
|
296
|
+
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
297
|
+
description TEXT NOT NULL, category TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
298
|
+
);
|
|
299
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
300
|
+
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
301
|
+
description TEXT NOT NULL, category TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
302
|
+
);
|
|
303
|
+
CREATE TABLE IF NOT EXISTS agent_capabilities (
|
|
304
|
+
agent_id TEXT NOT NULL, capability_id TEXT NOT NULL, PRIMARY KEY (agent_id, capability_id)
|
|
305
|
+
);
|
|
306
|
+
CREATE TABLE IF NOT EXISTS agent_skills (
|
|
307
|
+
agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id)
|
|
308
|
+
);
|
|
309
|
+
CREATE TABLE IF NOT EXISTS agent_connections (
|
|
310
|
+
id TEXT PRIMARY KEY NOT NULL, agent_id TEXT NOT NULL,
|
|
311
|
+
transport_type TEXT NOT NULL DEFAULT 'http', connection_id TEXT,
|
|
312
|
+
connected_at BIGINT NOT NULL, last_heartbeat BIGINT NOT NULL,
|
|
313
|
+
connection_metadata TEXT, created_at BIGINT NOT NULL,
|
|
314
|
+
UNIQUE(agent_id, connection_id)
|
|
315
|
+
);
|
|
316
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
317
|
+
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE,
|
|
318
|
+
password_hash TEXT NOT NULL, name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
319
|
+
);
|
|
320
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
321
|
+
id TEXT PRIMARY KEY NOT NULL, user_id TEXT NOT NULL, account_id TEXT NOT NULL,
|
|
322
|
+
provider_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
323
|
+
);
|
|
324
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
325
|
+
id TEXT PRIMARY KEY NOT NULL, user_id TEXT NOT NULL, token TEXT NOT NULL UNIQUE,
|
|
326
|
+
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
327
|
+
);
|
|
328
|
+
CREATE TABLE IF NOT EXISTS verifications (
|
|
329
|
+
id TEXT PRIMARY KEY NOT NULL, identifier TEXT NOT NULL, value TEXT NOT NULL,
|
|
330
|
+
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
331
|
+
);
|
|
332
|
+
CREATE TABLE IF NOT EXISTS organization (
|
|
333
|
+
id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL,
|
|
334
|
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
335
|
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
336
|
+
);
|
|
337
|
+
CREATE TABLE IF NOT EXISTS claim_codes (
|
|
338
|
+
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, code TEXT NOT NULL UNIQUE,
|
|
339
|
+
expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
|
|
340
|
+
);
|
|
341
|
+
CREATE TABLE IF NOT EXISTS org_agent_keys (
|
|
342
|
+
id TEXT PRIMARY KEY NOT NULL, organization_id TEXT NOT NULL,
|
|
343
|
+
agent_id TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
344
|
+
);
|
|
345
|
+
CREATE TABLE IF NOT EXISTS _signaldock_meta (
|
|
346
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL,
|
|
347
|
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
348
|
+
);
|
|
349
|
+
CREATE TABLE IF NOT EXISTS _signaldock_migrations (
|
|
350
|
+
name TEXT PRIMARY KEY,
|
|
351
|
+
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
352
|
+
);
|
|
353
|
+
`);
|
|
354
|
+
|
|
355
|
+
for (const agent of agents) {
|
|
356
|
+
db.prepare(
|
|
357
|
+
`INSERT OR IGNORE INTO agents
|
|
358
|
+
(id, agent_id, name, created_at, updated_at, requires_reauth)
|
|
359
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
360
|
+
).run(agent.id, agent.agentId, agent.name, now, now, agent.requiresReauth ?? 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
db.close();
|
|
364
|
+
return dbPath;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run the signaldock→conduit migration with an isolated cleoHome mock.
|
|
369
|
+
* Always resets modules first so the import chain gets the fresh mock.
|
|
370
|
+
*/
|
|
371
|
+
async function runMigration(
|
|
372
|
+
projectRoot: string,
|
|
373
|
+
cleoHome: string,
|
|
374
|
+
): Promise<import('../migrate-signaldock-to-conduit.js').MigrationResult> {
|
|
375
|
+
vi.resetModules();
|
|
376
|
+
vi.doMock('../../paths.js', () => ({
|
|
377
|
+
getCleoHome: () => cleoHome,
|
|
378
|
+
getProjectRoot: () => projectRoot,
|
|
379
|
+
}));
|
|
380
|
+
vi.doMock('../global-salt.js', () => ({
|
|
381
|
+
getGlobalSalt: () => Buffer.alloc(32, 0xab),
|
|
382
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
383
|
+
}));
|
|
384
|
+
vi.doMock('../signaldock-sqlite.js', () => ({
|
|
385
|
+
ensureGlobalSignaldockDb: vi.fn(async () => ({
|
|
386
|
+
action: 'exists',
|
|
387
|
+
path: join(cleoHome, 'signaldock.db'),
|
|
388
|
+
})),
|
|
389
|
+
getGlobalSignaldockDbPath: () => join(cleoHome, 'signaldock.db'),
|
|
390
|
+
}));
|
|
391
|
+
const { migrateSignaldockToConduit } = await import('../migrate-signaldock-to-conduit.js');
|
|
392
|
+
return migrateSignaldockToConduit(projectRoot);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Suite
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
describe('T310: conduit + signaldock integration', () => {
|
|
400
|
+
let base: ReturnType<typeof makeTmpPair>;
|
|
401
|
+
|
|
402
|
+
beforeEach(() => {
|
|
403
|
+
vi.resetModules();
|
|
404
|
+
base = makeTmpPair('s');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
afterEach(() => {
|
|
408
|
+
vi.restoreAllMocks();
|
|
409
|
+
base.cleanup();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// -------------------------------------------------------------------------
|
|
413
|
+
// Scenario 1: Fresh install creates conduit.db + global signaldock.db + global-salt
|
|
414
|
+
// -------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
it('Scenario 1: fresh install creates conduit.db, global signaldock.db, and global-salt', async () => {
|
|
417
|
+
const { cleoHome, projectRoot } = base;
|
|
418
|
+
seedKeys(cleoHome);
|
|
419
|
+
|
|
420
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
421
|
+
vi.doMock('../global-salt.js', () => ({
|
|
422
|
+
getGlobalSalt: () => Buffer.alloc(32, 0xcd),
|
|
423
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
424
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
const { ensureConduitDb } = await import('../conduit-sqlite.js');
|
|
428
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
429
|
+
|
|
430
|
+
const conduitResult = ensureConduitDb(projectRoot);
|
|
431
|
+
expect(conduitResult.action).toBe('created');
|
|
432
|
+
expect(existsSync(conduitResult.path)).toBe(true);
|
|
433
|
+
|
|
434
|
+
const sdResult = await ensureGlobalSignaldockDb();
|
|
435
|
+
expect(sdResult.action).toBe('created');
|
|
436
|
+
expect(existsSync(sdResult.path)).toBe(true);
|
|
437
|
+
|
|
438
|
+
// Global-salt already written by seedKeys; verify it exists
|
|
439
|
+
expect(existsSync(join(cleoHome, 'global-salt'))).toBe(true);
|
|
440
|
+
|
|
441
|
+
// project_agent_refs table must exist and be empty
|
|
442
|
+
const conduitDb = new DatabaseSync(conduitResult.path, { readonly: true });
|
|
443
|
+
const refs = conduitDb.prepare('SELECT COUNT(*) as n FROM project_agent_refs').get() as {
|
|
444
|
+
n: number;
|
|
445
|
+
};
|
|
446
|
+
expect(refs.n).toBe(0);
|
|
447
|
+
conduitDb.close();
|
|
448
|
+
|
|
449
|
+
// global agents table must exist and be empty
|
|
450
|
+
const globalDb = new DatabaseSync(sdResult.path, { readonly: true });
|
|
451
|
+
const agents = globalDb.prepare('SELECT COUNT(*) as n FROM agents').get() as { n: number };
|
|
452
|
+
expect(agents.n).toBe(0);
|
|
453
|
+
globalDb.close();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// -------------------------------------------------------------------------
|
|
457
|
+
// Scenario 2: createProjectAgent writes identity globally + attachment locally
|
|
458
|
+
// -------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
it('Scenario 2: createProjectAgent writes global identity and local project_agent_refs row', async () => {
|
|
461
|
+
const { cleoHome, projectRoot } = base;
|
|
462
|
+
const { machineKey, globalSalt } = seedKeys(cleoHome);
|
|
463
|
+
|
|
464
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
465
|
+
vi.doMock('../global-salt.js', () => ({
|
|
466
|
+
getGlobalSalt: () => globalSalt,
|
|
467
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
468
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
469
|
+
}));
|
|
470
|
+
|
|
471
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
472
|
+
const { ensureConduitDb, closeConduitDb } = await import('../conduit-sqlite.js');
|
|
473
|
+
const { createProjectAgent } = await import('../agent-registry-accessor.js');
|
|
474
|
+
const { deriveApiKey } = await import('../api-key-kdf.js');
|
|
475
|
+
|
|
476
|
+
await ensureGlobalSignaldockDb();
|
|
477
|
+
ensureConduitDb(projectRoot);
|
|
478
|
+
closeConduitDb();
|
|
479
|
+
|
|
480
|
+
const spec = {
|
|
481
|
+
agentId: 'integ-agent-sc2',
|
|
482
|
+
displayName: 'Scenario Two Agent',
|
|
483
|
+
apiKey: 'sk_test_sc2',
|
|
484
|
+
apiBaseUrl: 'https://api.signaldock.io',
|
|
485
|
+
privacyTier: 'public' as const,
|
|
486
|
+
capabilities: [],
|
|
487
|
+
skills: [],
|
|
488
|
+
transportType: 'http' as const,
|
|
489
|
+
transportConfig: {},
|
|
490
|
+
isActive: true,
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const result = createProjectAgent(projectRoot, spec);
|
|
494
|
+
expect(result.agentId).toBe(spec.agentId);
|
|
495
|
+
expect(result.projectRef).not.toBeNull();
|
|
496
|
+
expect(result.projectRef?.enabled).toBe(1);
|
|
497
|
+
|
|
498
|
+
// Verify global signaldock.db has the agent row
|
|
499
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'), { readonly: true });
|
|
500
|
+
const agentRow = globalDb
|
|
501
|
+
.prepare('SELECT agent_id FROM agents WHERE agent_id = ?')
|
|
502
|
+
.get(spec.agentId) as { agent_id: string } | undefined;
|
|
503
|
+
expect(agentRow).toBeDefined();
|
|
504
|
+
expect(agentRow?.agent_id).toBe(spec.agentId);
|
|
505
|
+
globalDb.close();
|
|
506
|
+
|
|
507
|
+
// Verify conduit.db has a project_agent_refs row
|
|
508
|
+
const conduitDb = new DatabaseSync(join(projectRoot, '.cleo', 'conduit.db'), {
|
|
509
|
+
readonly: true,
|
|
510
|
+
});
|
|
511
|
+
const refRow = conduitDb
|
|
512
|
+
.prepare('SELECT agent_id, enabled FROM project_agent_refs WHERE agent_id = ?')
|
|
513
|
+
.get(spec.agentId) as { agent_id: string; enabled: number } | undefined;
|
|
514
|
+
expect(refRow).toBeDefined();
|
|
515
|
+
expect(refRow?.enabled).toBe(1);
|
|
516
|
+
conduitDb.close();
|
|
517
|
+
|
|
518
|
+
// Verify KDF: key should be HMAC-SHA256(machineKey || globalSalt, agentId)
|
|
519
|
+
const expectedKey = deriveApiKey({ machineKey, globalSalt, agentId: spec.agentId });
|
|
520
|
+
expect(expectedKey).toHaveLength(32);
|
|
521
|
+
// KDF must be different from legacy scheme (which uses projectPath instead of globalSalt)
|
|
522
|
+
const { deriveLegacyProjectKey } = await import('../api-key-kdf.js');
|
|
523
|
+
const legacyKey = deriveLegacyProjectKey(machineKey, projectRoot);
|
|
524
|
+
expect(Buffer.compare(expectedKey, legacyKey)).not.toBe(0);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// -------------------------------------------------------------------------
|
|
528
|
+
// Scenario 3: lookupAgent cross-DB join — default project-scoped
|
|
529
|
+
// -------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
it('Scenario 3: lookupAgent returns null without project ref; returns agent with includeGlobal=true', async () => {
|
|
532
|
+
const { cleoHome, projectRoot } = base;
|
|
533
|
+
const { globalSalt } = seedKeys(cleoHome);
|
|
534
|
+
|
|
535
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
536
|
+
vi.doMock('../global-salt.js', () => ({
|
|
537
|
+
getGlobalSalt: () => globalSalt,
|
|
538
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
539
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
543
|
+
const { ensureConduitDb, closeConduitDb } = await import('../conduit-sqlite.js');
|
|
544
|
+
const { lookupAgent } = await import('../agent-registry-accessor.js');
|
|
545
|
+
|
|
546
|
+
await ensureGlobalSignaldockDb();
|
|
547
|
+
ensureConduitDb(projectRoot);
|
|
548
|
+
closeConduitDb();
|
|
549
|
+
|
|
550
|
+
// Seed agent X into global only (no project ref)
|
|
551
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'));
|
|
552
|
+
insertGlobalAgent(globalDb, 'global-agent-x', 'Agent X');
|
|
553
|
+
globalDb.close();
|
|
554
|
+
|
|
555
|
+
// Default (INNER JOIN): must return null because no project_agent_refs row
|
|
556
|
+
const defaultResult = lookupAgent(projectRoot, 'global-agent-x');
|
|
557
|
+
expect(defaultResult).toBeNull();
|
|
558
|
+
|
|
559
|
+
// includeGlobal=true: must return the agent with projectRef: null
|
|
560
|
+
const globalResult = lookupAgent(projectRoot, 'global-agent-x', { includeGlobal: true });
|
|
561
|
+
expect(globalResult).not.toBeNull();
|
|
562
|
+
expect(globalResult?.agentId).toBe('global-agent-x');
|
|
563
|
+
expect(globalResult?.projectRef).toBeNull();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// -------------------------------------------------------------------------
|
|
567
|
+
// Scenario 4: listAgentsForProject — INNER vs OUTER join semantics
|
|
568
|
+
// -------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
it('Scenario 4: listAgentsForProject INNER returns only attached; OUTER returns all with correct projectRef', async () => {
|
|
571
|
+
const { cleoHome, projectRoot } = base;
|
|
572
|
+
const { globalSalt } = seedKeys(cleoHome);
|
|
573
|
+
|
|
574
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
575
|
+
vi.doMock('../global-salt.js', () => ({
|
|
576
|
+
getGlobalSalt: () => globalSalt,
|
|
577
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
578
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
579
|
+
}));
|
|
580
|
+
|
|
581
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
582
|
+
const { ensureConduitDb, closeConduitDb } = await import('../conduit-sqlite.js');
|
|
583
|
+
const { listAgentsForProject } = await import('../agent-registry-accessor.js');
|
|
584
|
+
|
|
585
|
+
await ensureGlobalSignaldockDb();
|
|
586
|
+
ensureConduitDb(projectRoot);
|
|
587
|
+
closeConduitDb();
|
|
588
|
+
|
|
589
|
+
// Seed 3 global agents
|
|
590
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'));
|
|
591
|
+
insertGlobalAgent(globalDb, 'agent-x', 'Agent X');
|
|
592
|
+
insertGlobalAgent(globalDb, 'agent-y', 'Agent Y');
|
|
593
|
+
insertGlobalAgent(globalDb, 'agent-z', 'Agent Z');
|
|
594
|
+
globalDb.close();
|
|
595
|
+
|
|
596
|
+
// Attach only Y and Z to the project
|
|
597
|
+
const conduitDb = new DatabaseSync(join(projectRoot, '.cleo', 'conduit.db'));
|
|
598
|
+
insertConduitRef(conduitDb, 'agent-y');
|
|
599
|
+
insertConduitRef(conduitDb, 'agent-z');
|
|
600
|
+
conduitDb.close();
|
|
601
|
+
|
|
602
|
+
// Default INNER join: should return only Y and Z
|
|
603
|
+
const innerResult = listAgentsForProject(projectRoot);
|
|
604
|
+
expect(innerResult).toHaveLength(2);
|
|
605
|
+
const innerIds = innerResult.map((a) => a.agentId).sort();
|
|
606
|
+
expect(innerIds).toEqual(['agent-y', 'agent-z']);
|
|
607
|
+
// Both must have populated projectRef
|
|
608
|
+
for (const agent of innerResult) {
|
|
609
|
+
expect(agent.projectRef).not.toBeNull();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// includeGlobal=true: should return all 3 agents
|
|
613
|
+
const globalResult = listAgentsForProject(projectRoot, { includeGlobal: true });
|
|
614
|
+
expect(globalResult).toHaveLength(3);
|
|
615
|
+
const globalIds = globalResult.map((a) => a.agentId).sort();
|
|
616
|
+
expect(globalIds).toEqual(['agent-x', 'agent-y', 'agent-z']);
|
|
617
|
+
|
|
618
|
+
// X must have projectRef: null (not attached)
|
|
619
|
+
const agentX = globalResult.find((a) => a.agentId === 'agent-x');
|
|
620
|
+
expect(agentX?.projectRef).toBeNull();
|
|
621
|
+
|
|
622
|
+
// Y and Z must have populated projectRef
|
|
623
|
+
const agentY = globalResult.find((a) => a.agentId === 'agent-y');
|
|
624
|
+
expect(agentY?.projectRef).not.toBeNull();
|
|
625
|
+
const agentZ = globalResult.find((a) => a.agentId === 'agent-z');
|
|
626
|
+
expect(agentZ?.projectRef).not.toBeNull();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// -------------------------------------------------------------------------
|
|
630
|
+
// Scenario 5: Cross-project visibility isolation
|
|
631
|
+
// -------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
it('Scenario 5: agent created in project A is invisible to project B by default', async () => {
|
|
634
|
+
const { cleoHome, projectRoot: projectA } = base;
|
|
635
|
+
const projectB = makeSecondProject(join(cleoHome, '..'));
|
|
636
|
+
const { globalSalt } = seedKeys(cleoHome);
|
|
637
|
+
|
|
638
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
639
|
+
vi.doMock('../global-salt.js', () => ({
|
|
640
|
+
getGlobalSalt: () => globalSalt,
|
|
641
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
642
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
643
|
+
}));
|
|
644
|
+
|
|
645
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
646
|
+
const { ensureConduitDb, closeConduitDb } = await import('../conduit-sqlite.js');
|
|
647
|
+
const { createProjectAgent, listAgentsForProject } = await import(
|
|
648
|
+
'../agent-registry-accessor.js'
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
await ensureGlobalSignaldockDb();
|
|
652
|
+
ensureConduitDb(projectA);
|
|
653
|
+
closeConduitDb();
|
|
654
|
+
ensureConduitDb(projectB);
|
|
655
|
+
closeConduitDb();
|
|
656
|
+
|
|
657
|
+
// Create agent in A
|
|
658
|
+
createProjectAgent(projectA, {
|
|
659
|
+
agentId: 'cross-project-agent',
|
|
660
|
+
displayName: 'Cross Project Agent',
|
|
661
|
+
apiKey: 'sk_test_cross',
|
|
662
|
+
apiBaseUrl: 'https://api.signaldock.io',
|
|
663
|
+
privacyTier: 'public',
|
|
664
|
+
capabilities: [],
|
|
665
|
+
skills: [],
|
|
666
|
+
transportType: 'http',
|
|
667
|
+
transportConfig: {},
|
|
668
|
+
isActive: true,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// A must see the agent
|
|
672
|
+
const inA = listAgentsForProject(projectA);
|
|
673
|
+
const agentInA = inA.find((a) => a.agentId === 'cross-project-agent');
|
|
674
|
+
expect(agentInA).toBeDefined();
|
|
675
|
+
|
|
676
|
+
// B must NOT see the agent by default (INNER JOIN — no project_agent_refs row in B)
|
|
677
|
+
const inB = listAgentsForProject(projectB);
|
|
678
|
+
const agentInB = inB.find((a) => a.agentId === 'cross-project-agent');
|
|
679
|
+
expect(agentInB).toBeUndefined();
|
|
680
|
+
|
|
681
|
+
// B with includeGlobal=true should see the agent but with projectRef: null
|
|
682
|
+
const inBGlobal = listAgentsForProject(projectB, { includeGlobal: true });
|
|
683
|
+
const agentInBGlobal = inBGlobal.find((a) => a.agentId === 'cross-project-agent');
|
|
684
|
+
expect(agentInBGlobal).toBeDefined();
|
|
685
|
+
expect(agentInBGlobal?.projectRef).toBeNull();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// -------------------------------------------------------------------------
|
|
689
|
+
// Scenario 6: Attach + detach across projects
|
|
690
|
+
// -------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
it('Scenario 6: attach to B makes agent visible in B; detach from A leaves B intact; global untouched', async () => {
|
|
693
|
+
const { cleoHome, projectRoot: projectA } = base;
|
|
694
|
+
const projectB = makeSecondProject(join(cleoHome, '..'));
|
|
695
|
+
const { globalSalt } = seedKeys(cleoHome);
|
|
696
|
+
|
|
697
|
+
vi.doMock('../../paths.js', () => ({ getCleoHome: () => cleoHome }));
|
|
698
|
+
vi.doMock('../global-salt.js', () => ({
|
|
699
|
+
getGlobalSalt: () => globalSalt,
|
|
700
|
+
getGlobalSaltPath: () => join(cleoHome, 'global-salt'),
|
|
701
|
+
__clearGlobalSaltCache: vi.fn(),
|
|
702
|
+
}));
|
|
703
|
+
|
|
704
|
+
const { ensureGlobalSignaldockDb } = await import('../signaldock-sqlite.js');
|
|
705
|
+
const { ensureConduitDb, closeConduitDb } = await import('../conduit-sqlite.js');
|
|
706
|
+
const {
|
|
707
|
+
createProjectAgent,
|
|
708
|
+
attachAgentToProject,
|
|
709
|
+
detachAgentFromProject,
|
|
710
|
+
listAgentsForProject,
|
|
711
|
+
} = await import('../agent-registry-accessor.js');
|
|
712
|
+
|
|
713
|
+
await ensureGlobalSignaldockDb();
|
|
714
|
+
ensureConduitDb(projectA);
|
|
715
|
+
closeConduitDb();
|
|
716
|
+
ensureConduitDb(projectB);
|
|
717
|
+
closeConduitDb();
|
|
718
|
+
|
|
719
|
+
// Create agent in A
|
|
720
|
+
createProjectAgent(projectA, {
|
|
721
|
+
agentId: 'attach-detach-agent',
|
|
722
|
+
displayName: 'Attach Detach Agent',
|
|
723
|
+
apiKey: 'sk_test_ad',
|
|
724
|
+
apiBaseUrl: 'https://api.signaldock.io',
|
|
725
|
+
privacyTier: 'public',
|
|
726
|
+
capabilities: [],
|
|
727
|
+
skills: [],
|
|
728
|
+
transportType: 'http',
|
|
729
|
+
transportConfig: {},
|
|
730
|
+
isActive: true,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Attach to B
|
|
734
|
+
attachAgentToProject(projectB, 'attach-detach-agent');
|
|
735
|
+
|
|
736
|
+
// Now both A and B should see the agent
|
|
737
|
+
const inA = listAgentsForProject(projectA);
|
|
738
|
+
expect(inA.find((a) => a.agentId === 'attach-detach-agent')).toBeDefined();
|
|
739
|
+
const inB = listAgentsForProject(projectB);
|
|
740
|
+
expect(inB.find((a) => a.agentId === 'attach-detach-agent')).toBeDefined();
|
|
741
|
+
|
|
742
|
+
// Detach from A
|
|
743
|
+
detachAgentFromProject(projectA, 'attach-detach-agent');
|
|
744
|
+
|
|
745
|
+
// A must no longer see it
|
|
746
|
+
const inAAfter = listAgentsForProject(projectA);
|
|
747
|
+
expect(inAAfter.find((a) => a.agentId === 'attach-detach-agent')).toBeUndefined();
|
|
748
|
+
|
|
749
|
+
// B must still see it
|
|
750
|
+
const inBAfter = listAgentsForProject(projectB);
|
|
751
|
+
expect(inBAfter.find((a) => a.agentId === 'attach-detach-agent')).toBeDefined();
|
|
752
|
+
|
|
753
|
+
// Global identity must still exist
|
|
754
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'), { readonly: true });
|
|
755
|
+
const globalRow = globalDb
|
|
756
|
+
.prepare("SELECT agent_id FROM agents WHERE agent_id = 'attach-detach-agent'")
|
|
757
|
+
.get() as { agent_id: string } | undefined;
|
|
758
|
+
globalDb.close();
|
|
759
|
+
expect(globalRow).toBeDefined();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// -------------------------------------------------------------------------
|
|
763
|
+
// Scenario 7: Migration from legacy signaldock.db
|
|
764
|
+
// -------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
it('Scenario 7: migration preserves messages, creates conduit.db, global signaldock.db, .pre-t310.bak', async () => {
|
|
767
|
+
const { cleoHome, projectRoot } = base;
|
|
768
|
+
const now = Math.floor(Date.now() / 1000);
|
|
769
|
+
|
|
770
|
+
// Seed global signaldock.db (the migration will populate it)
|
|
771
|
+
createGlobalSignaldockDbFile(cleoHome, []);
|
|
772
|
+
|
|
773
|
+
// Create legacy project signaldock.db with 3 agents + 5 messages
|
|
774
|
+
createLegacySignaldockDb(
|
|
775
|
+
projectRoot,
|
|
776
|
+
[
|
|
777
|
+
{ id: 'a1', agentId: 'mig-agent-1', name: 'Mig Agent 1', createdAt: now },
|
|
778
|
+
{ id: 'a2', agentId: 'mig-agent-2', name: 'Mig Agent 2', createdAt: now },
|
|
779
|
+
{ id: 'a3', agentId: 'mig-agent-3', name: 'Mig Agent 3', createdAt: now },
|
|
780
|
+
],
|
|
781
|
+
[
|
|
782
|
+
{
|
|
783
|
+
id: 'msg-1',
|
|
784
|
+
conversationId: 'conv-1',
|
|
785
|
+
fromAgentId: 'mig-agent-1',
|
|
786
|
+
toAgentId: 'mig-agent-2',
|
|
787
|
+
content: 'hello',
|
|
788
|
+
createdAt: now,
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
id: 'msg-2',
|
|
792
|
+
conversationId: 'conv-1',
|
|
793
|
+
fromAgentId: 'mig-agent-2',
|
|
794
|
+
toAgentId: 'mig-agent-1',
|
|
795
|
+
content: 'world',
|
|
796
|
+
createdAt: now + 1,
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
id: 'msg-3',
|
|
800
|
+
conversationId: 'conv-1',
|
|
801
|
+
fromAgentId: 'mig-agent-1',
|
|
802
|
+
toAgentId: 'mig-agent-2',
|
|
803
|
+
content: 'foo',
|
|
804
|
+
createdAt: now + 2,
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
id: 'msg-4',
|
|
808
|
+
conversationId: 'conv-1',
|
|
809
|
+
fromAgentId: 'mig-agent-2',
|
|
810
|
+
toAgentId: 'mig-agent-1',
|
|
811
|
+
content: 'bar',
|
|
812
|
+
createdAt: now + 3,
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
id: 'msg-5',
|
|
816
|
+
conversationId: 'conv-1',
|
|
817
|
+
fromAgentId: 'mig-agent-1',
|
|
818
|
+
toAgentId: 'mig-agent-2',
|
|
819
|
+
content: 'baz',
|
|
820
|
+
createdAt: now + 4,
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
[
|
|
824
|
+
{
|
|
825
|
+
id: 'conv-1',
|
|
826
|
+
participants: '["mig-agent-1","mig-agent-2"]',
|
|
827
|
+
createdAt: now,
|
|
828
|
+
updatedAt: now,
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const result = await runMigration(projectRoot, cleoHome);
|
|
834
|
+
|
|
835
|
+
expect(result.status).toBe('migrated');
|
|
836
|
+
expect(result.agentsCopied).toBe(3);
|
|
837
|
+
expect(result.errors).toHaveLength(0);
|
|
838
|
+
|
|
839
|
+
// conduit.db created
|
|
840
|
+
const conduitPath = join(projectRoot, '.cleo', 'conduit.db');
|
|
841
|
+
expect(existsSync(conduitPath)).toBe(true);
|
|
842
|
+
|
|
843
|
+
// .pre-t310.bak created; legacy signaldock.db renamed
|
|
844
|
+
const bakPath = join(projectRoot, '.cleo', 'signaldock.db.pre-t310.bak');
|
|
845
|
+
expect(existsSync(bakPath)).toBe(true);
|
|
846
|
+
expect(existsSync(join(projectRoot, '.cleo', 'signaldock.db'))).toBe(false);
|
|
847
|
+
|
|
848
|
+
// conduit.db has 5 messages + 3 project_agent_refs
|
|
849
|
+
const conduitDb = new DatabaseSync(conduitPath, { readonly: true });
|
|
850
|
+
const msgCount = conduitDb.prepare('SELECT COUNT(*) as n FROM messages').get() as { n: number };
|
|
851
|
+
expect(msgCount.n).toBe(5);
|
|
852
|
+
const refCount = conduitDb.prepare('SELECT COUNT(*) as n FROM project_agent_refs').get() as {
|
|
853
|
+
n: number;
|
|
854
|
+
};
|
|
855
|
+
expect(refCount.n).toBe(3);
|
|
856
|
+
conduitDb.close();
|
|
857
|
+
|
|
858
|
+
// global signaldock.db has 3 agents with requires_reauth=1
|
|
859
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'), { readonly: true });
|
|
860
|
+
const globalAgents = globalDb
|
|
861
|
+
.prepare('SELECT agent_id, requires_reauth FROM agents ORDER BY agent_id')
|
|
862
|
+
.all() as Array<{ agent_id: string; requires_reauth: number }>;
|
|
863
|
+
expect(globalAgents).toHaveLength(3);
|
|
864
|
+
for (const agent of globalAgents) {
|
|
865
|
+
expect(agent.requires_reauth).toBe(1);
|
|
866
|
+
}
|
|
867
|
+
globalDb.close();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// -------------------------------------------------------------------------
|
|
871
|
+
// Scenario 8: Migration multi-project deduplication
|
|
872
|
+
// -------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
it('Scenario 8: migration on two projects deduplicates shared agent in global signaldock.db', async () => {
|
|
875
|
+
const { cleoHome } = base;
|
|
876
|
+
|
|
877
|
+
// Two isolated project roots sharing the same cleoHome
|
|
878
|
+
const projectA = join(cleoHome, '..', 'project-a');
|
|
879
|
+
const projectB = join(cleoHome, '..', 'project-b');
|
|
880
|
+
mkdirSync(join(projectA, '.cleo'), { recursive: true });
|
|
881
|
+
mkdirSync(join(projectB, '.cleo'), { recursive: true });
|
|
882
|
+
|
|
883
|
+
const now = Math.floor(Date.now() / 1000);
|
|
884
|
+
|
|
885
|
+
// Project A has agent X
|
|
886
|
+
createGlobalSignaldockDbFile(cleoHome, []);
|
|
887
|
+
createLegacySignaldockDb(projectA, [
|
|
888
|
+
{ id: 'ax-id', agentId: 'shared-agent-x', name: 'Agent X from A', createdAt: now },
|
|
889
|
+
]);
|
|
890
|
+
|
|
891
|
+
// Migrate A first
|
|
892
|
+
const resultA = await runMigration(projectA, cleoHome);
|
|
893
|
+
expect(resultA.status).toBe('migrated');
|
|
894
|
+
expect(resultA.agentsCopied).toBe(1);
|
|
895
|
+
|
|
896
|
+
// Project B has agent X + agent Y
|
|
897
|
+
createLegacySignaldockDb(projectB, [
|
|
898
|
+
{ id: 'bx-id', agentId: 'shared-agent-x', name: 'Agent X from B', createdAt: now },
|
|
899
|
+
{ id: 'by-id', agentId: 'agent-y-unique', name: 'Agent Y', createdAt: now },
|
|
900
|
+
]);
|
|
901
|
+
|
|
902
|
+
// Migrate B second
|
|
903
|
+
const resultB = await runMigration(projectB, cleoHome);
|
|
904
|
+
expect(resultB.status).toBe('migrated');
|
|
905
|
+
|
|
906
|
+
// Global must have exactly 2 agents (X + Y), not 3 (INSERT OR IGNORE deduplicates X)
|
|
907
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'), { readonly: true });
|
|
908
|
+
const allAgents = globalDb
|
|
909
|
+
.prepare('SELECT agent_id FROM agents ORDER BY agent_id')
|
|
910
|
+
.all() as Array<{ agent_id: string }>;
|
|
911
|
+
const agentIds = allAgents.map((a) => a.agent_id);
|
|
912
|
+
expect(agentIds).toContain('shared-agent-x');
|
|
913
|
+
expect(agentIds).toContain('agent-y-unique');
|
|
914
|
+
// X must appear exactly once
|
|
915
|
+
expect(agentIds.filter((id) => id === 'shared-agent-x')).toHaveLength(1);
|
|
916
|
+
globalDb.close();
|
|
917
|
+
|
|
918
|
+
// Both projects must have their own project_agent_refs
|
|
919
|
+
const conduitA = new DatabaseSync(join(projectA, '.cleo', 'conduit.db'), { readonly: true });
|
|
920
|
+
const refsA = conduitA.prepare('SELECT agent_id FROM project_agent_refs').all() as Array<{
|
|
921
|
+
agent_id: string;
|
|
922
|
+
}>;
|
|
923
|
+
conduitA.close();
|
|
924
|
+
expect(refsA.map((r) => r.agent_id)).toContain('shared-agent-x');
|
|
925
|
+
|
|
926
|
+
const conduitB = new DatabaseSync(join(projectB, '.cleo', 'conduit.db'), { readonly: true });
|
|
927
|
+
const refsB = conduitB
|
|
928
|
+
.prepare('SELECT agent_id FROM project_agent_refs ORDER BY agent_id')
|
|
929
|
+
.all() as Array<{ agent_id: string }>;
|
|
930
|
+
conduitB.close();
|
|
931
|
+
expect(refsB.map((r) => r.agent_id)).toContain('shared-agent-x');
|
|
932
|
+
expect(refsB.map((r) => r.agent_id)).toContain('agent-y-unique');
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// -------------------------------------------------------------------------
|
|
936
|
+
// Scenario 9: Migration is idempotent
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
it('Scenario 9: second migration call returns no-op; no duplicate rows', async () => {
|
|
940
|
+
const { cleoHome, projectRoot } = base;
|
|
941
|
+
const now = Math.floor(Date.now() / 1000);
|
|
942
|
+
|
|
943
|
+
createGlobalSignaldockDbFile(cleoHome, []);
|
|
944
|
+
createLegacySignaldockDb(projectRoot, [
|
|
945
|
+
{ id: 'idem-id', agentId: 'idempotent-agent', name: 'Idempotent Agent', createdAt: now },
|
|
946
|
+
]);
|
|
947
|
+
|
|
948
|
+
// First migration
|
|
949
|
+
const first = await runMigration(projectRoot, cleoHome);
|
|
950
|
+
expect(first.status).toBe('migrated');
|
|
951
|
+
expect(first.agentsCopied).toBe(1);
|
|
952
|
+
|
|
953
|
+
// Second migration — conduit.db already exists
|
|
954
|
+
const second = await runMigration(projectRoot, cleoHome);
|
|
955
|
+
expect(second.status).toBe('no-op');
|
|
956
|
+
expect(second.errors).toHaveLength(0);
|
|
957
|
+
|
|
958
|
+
// Verify no duplicate rows in conduit.db
|
|
959
|
+
const conduitDb = new DatabaseSync(join(projectRoot, '.cleo', 'conduit.db'), {
|
|
960
|
+
readonly: true,
|
|
961
|
+
});
|
|
962
|
+
const refRows = conduitDb
|
|
963
|
+
.prepare("SELECT COUNT(*) as n FROM project_agent_refs WHERE agent_id = 'idempotent-agent'")
|
|
964
|
+
.get() as { n: number };
|
|
965
|
+
expect(refRows.n).toBe(1);
|
|
966
|
+
conduitDb.close();
|
|
967
|
+
|
|
968
|
+
// Verify no duplicate rows in global signaldock.db
|
|
969
|
+
const globalDb = new DatabaseSync(join(cleoHome, 'signaldock.db'), { readonly: true });
|
|
970
|
+
const globalRows = globalDb
|
|
971
|
+
.prepare("SELECT COUNT(*) as n FROM agents WHERE agent_id = 'idempotent-agent'")
|
|
972
|
+
.get() as { n: number };
|
|
973
|
+
globalDb.close();
|
|
974
|
+
expect(globalRows.n).toBe(1);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// -------------------------------------------------------------------------
|
|
978
|
+
// Scenario 10: KDF binds to machine + salt + agentId
|
|
979
|
+
// -------------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
it('Scenario 10: KDF output changes with salt, agentId, and machineKey independently', async () => {
|
|
982
|
+
vi.resetModules();
|
|
983
|
+
const { deriveApiKey } = await import('../api-key-kdf.js');
|
|
984
|
+
|
|
985
|
+
const machineKey = Buffer.alloc(32, 0x01);
|
|
986
|
+
const s1 = Buffer.alloc(32, 0x10);
|
|
987
|
+
const s2 = Buffer.alloc(32, 0x20);
|
|
988
|
+
|
|
989
|
+
// K1 = derive(agentId=X, salt=S1)
|
|
990
|
+
const k1 = deriveApiKey({ machineKey, globalSalt: s1, agentId: 'agent-x' });
|
|
991
|
+
// K2 = derive(agentId=X, salt=S2) — must differ (salt change)
|
|
992
|
+
const k2 = deriveApiKey({ machineKey, globalSalt: s2, agentId: 'agent-x' });
|
|
993
|
+
expect(Buffer.compare(k1, k2)).not.toBe(0);
|
|
994
|
+
|
|
995
|
+
// K3 = derive(agentId=Y, salt=S1) — must differ from K1 (agentId change)
|
|
996
|
+
const k3 = deriveApiKey({ machineKey, globalSalt: s1, agentId: 'agent-y' });
|
|
997
|
+
expect(Buffer.compare(k1, k3)).not.toBe(0);
|
|
998
|
+
|
|
999
|
+
// K4 = derive with different machineKey — must differ from K1 (machine change)
|
|
1000
|
+
const differentMachineKey = Buffer.alloc(32, 0x99);
|
|
1001
|
+
const k4 = deriveApiKey({
|
|
1002
|
+
machineKey: differentMachineKey,
|
|
1003
|
+
globalSalt: s1,
|
|
1004
|
+
agentId: 'agent-x',
|
|
1005
|
+
});
|
|
1006
|
+
expect(Buffer.compare(k1, k4)).not.toBe(0);
|
|
1007
|
+
|
|
1008
|
+
// All derived keys must be exactly 32 bytes
|
|
1009
|
+
for (const k of [k1, k2, k3, k4]) {
|
|
1010
|
+
expect(k).toHaveLength(32);
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// -------------------------------------------------------------------------
|
|
1015
|
+
// Scenario 11: Backup registry includes conduit + global signaldock + global-salt
|
|
1016
|
+
// -------------------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
it('Scenario 11: vacuumIntoBackupAll snapshots conduit.db; vacuumIntoGlobalBackup snapshots signaldock; backupGlobalSalt copies salt', async () => {
|
|
1019
|
+
vi.resetModules();
|
|
1020
|
+
|
|
1021
|
+
const { cleoHome, projectRoot } = base;
|
|
1022
|
+
const cleoDir = join(projectRoot, '.cleo');
|
|
1023
|
+
|
|
1024
|
+
// Seed project DBs
|
|
1025
|
+
const conduitPath = join(cleoDir, 'conduit.db');
|
|
1026
|
+
const tasksPath = join(cleoDir, 'tasks.db');
|
|
1027
|
+
const brainPath = join(cleoDir, 'brain.db');
|
|
1028
|
+
const sdPath = join(cleoHome, 'signaldock.db');
|
|
1029
|
+
const saltPath = join(cleoHome, 'global-salt');
|
|
1030
|
+
|
|
1031
|
+
for (const dbPath of [conduitPath, tasksPath, brainPath, sdPath]) {
|
|
1032
|
+
const db = new DatabaseSync(dbPath);
|
|
1033
|
+
db.exec(
|
|
1034
|
+
`CREATE TABLE IF NOT EXISTS stub (id INTEGER PRIMARY KEY); INSERT INTO stub VALUES (1);`,
|
|
1035
|
+
);
|
|
1036
|
+
db.close();
|
|
1037
|
+
}
|
|
1038
|
+
// Write a 32-byte global-salt file
|
|
1039
|
+
writeFileSync(saltPath, Buffer.alloc(32, 0xef), { mode: 0o600 });
|
|
1040
|
+
|
|
1041
|
+
// Open live handles that the backup module will call
|
|
1042
|
+
const conduitDb = new DatabaseSync(conduitPath);
|
|
1043
|
+
const tasksDb = new DatabaseSync(tasksPath);
|
|
1044
|
+
const brainDb = new DatabaseSync(brainPath);
|
|
1045
|
+
const sdDb = new DatabaseSync(sdPath);
|
|
1046
|
+
|
|
1047
|
+
// Mock the native DB getters and path helpers
|
|
1048
|
+
vi.doMock('../sqlite.js', () => ({ getNativeDb: () => tasksDb, getDb: () => tasksDb }));
|
|
1049
|
+
vi.doMock('../brain-sqlite.js', () => ({ getBrainNativeDb: () => brainDb }));
|
|
1050
|
+
vi.doMock('../conduit-sqlite.js', () => ({ getConduitNativeDb: () => conduitDb }));
|
|
1051
|
+
vi.doMock('../signaldock-sqlite.js', () => ({
|
|
1052
|
+
getGlobalSignaldockNativeDb: () => sdDb,
|
|
1053
|
+
getGlobalSignaldockDbPath: () => sdPath,
|
|
1054
|
+
}));
|
|
1055
|
+
vi.doMock('../nexus-sqlite.js', () => ({ getNexusNativeDb: () => null }));
|
|
1056
|
+
vi.doMock('../global-salt.js', () => ({ getGlobalSaltPath: () => saltPath }));
|
|
1057
|
+
vi.doMock('../../paths.js', () => ({
|
|
1058
|
+
getCleoHome: () => cleoHome,
|
|
1059
|
+
getCleoDir: () => cleoDir,
|
|
1060
|
+
}));
|
|
1061
|
+
|
|
1062
|
+
const { vacuumIntoBackupAll, vacuumIntoGlobalBackup, backupGlobalSalt, listSqliteBackupsAll } =
|
|
1063
|
+
await import('../sqlite-backup.js');
|
|
1064
|
+
|
|
1065
|
+
// Project-tier backup (tasks, brain, conduit)
|
|
1066
|
+
await vacuumIntoBackupAll({ cwd: projectRoot, force: true });
|
|
1067
|
+
|
|
1068
|
+
tasksDb.close();
|
|
1069
|
+
brainDb.close();
|
|
1070
|
+
conduitDb.close();
|
|
1071
|
+
|
|
1072
|
+
const projectBackupDir = join(cleoDir, 'backups', 'sqlite');
|
|
1073
|
+
expect(existsSync(projectBackupDir)).toBe(true);
|
|
1074
|
+
|
|
1075
|
+
const allBackups = listSqliteBackupsAll(projectRoot);
|
|
1076
|
+
expect(allBackups).toHaveProperty('conduit');
|
|
1077
|
+
expect(allBackups['conduit']?.length).toBeGreaterThanOrEqual(1);
|
|
1078
|
+
|
|
1079
|
+
// Conduit snapshot must pass integrity_check
|
|
1080
|
+
const conduitSnapPath = allBackups['conduit']?.[0]?.path;
|
|
1081
|
+
expect(conduitSnapPath).toBeDefined();
|
|
1082
|
+
if (conduitSnapPath) {
|
|
1083
|
+
const snapDb = new DatabaseSync(conduitSnapPath, { readonly: true });
|
|
1084
|
+
const ic = snapDb.prepare('PRAGMA integrity_check').get() as Record<string, unknown>;
|
|
1085
|
+
snapDb.close();
|
|
1086
|
+
expect(ic['integrity_check']).toBe('ok');
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Global-tier backup: signaldock
|
|
1090
|
+
const sdResult = await vacuumIntoGlobalBackup('signaldock', { cleoHomeOverride: cleoHome });
|
|
1091
|
+
sdDb.close();
|
|
1092
|
+
|
|
1093
|
+
expect(sdResult.snapshotPath).toBeTruthy();
|
|
1094
|
+
expect(existsSync(sdResult.snapshotPath)).toBe(true);
|
|
1095
|
+
expect(sdResult.snapshotPath).toContain('signaldock-');
|
|
1096
|
+
|
|
1097
|
+
// Global-tier backup: global-salt
|
|
1098
|
+
const saltResult = await backupGlobalSalt({ cleoHomeOverride: cleoHome });
|
|
1099
|
+
expect(saltResult.snapshotPath).toBeTruthy();
|
|
1100
|
+
expect(existsSync(saltResult.snapshotPath)).toBe(true);
|
|
1101
|
+
|
|
1102
|
+
// Verify salt backup is exactly 32 bytes and has 0o600 permissions
|
|
1103
|
+
const saltBackupStat = statSync(saltResult.snapshotPath);
|
|
1104
|
+
expect(saltBackupStat.size).toBe(32);
|
|
1105
|
+
if (process.platform !== 'win32') {
|
|
1106
|
+
expect(saltBackupStat.mode & 0o777).toBe(0o600);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Verify content matches the original salt
|
|
1110
|
+
const saltBackupContent = readFileSync(saltResult.snapshotPath);
|
|
1111
|
+
expect(Buffer.compare(saltBackupContent, Buffer.alloc(32, 0xef))).toBe(0);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// -------------------------------------------------------------------------
|
|
1115
|
+
// Scenario 12: CLI startup wires migration (smoke — needsMigration detection)
|
|
1116
|
+
// -------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
it('Scenario 12: needsSignaldockToConduitMigration detects legacy correctly in all three states', async () => {
|
|
1119
|
+
const { projectRoot } = base;
|
|
1120
|
+
const cleoHome = base.cleoHome;
|
|
1121
|
+
|
|
1122
|
+
vi.doMock('../../paths.js', () => ({
|
|
1123
|
+
getCleoHome: () => cleoHome,
|
|
1124
|
+
getProjectRoot: () => projectRoot,
|
|
1125
|
+
}));
|
|
1126
|
+
|
|
1127
|
+
const { needsSignaldockToConduitMigration } = await import(
|
|
1128
|
+
'../migrate-signaldock-to-conduit.js'
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
// State 1: neither signaldock.db nor conduit.db — fresh install, no migration needed
|
|
1132
|
+
const freshResult = needsSignaldockToConduitMigration(projectRoot);
|
|
1133
|
+
expect(freshResult).toBe(false);
|
|
1134
|
+
|
|
1135
|
+
// State 2: signaldock.db present, conduit.db absent — migration needed
|
|
1136
|
+
writeFileSync(join(projectRoot, '.cleo', 'signaldock.db'), '');
|
|
1137
|
+
// Put a real valid SQLite DB there so the detection test doesn't fail on file format
|
|
1138
|
+
const tmpDb = new DatabaseSync(join(projectRoot, '.cleo', 'signaldock.db'));
|
|
1139
|
+
tmpDb.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
|
1140
|
+
tmpDb.close();
|
|
1141
|
+
|
|
1142
|
+
const needsMig = needsSignaldockToConduitMigration(projectRoot);
|
|
1143
|
+
expect(needsMig).toBe(true);
|
|
1144
|
+
|
|
1145
|
+
// State 3: conduit.db present — migration already done, no-op
|
|
1146
|
+
writeFileSync(join(projectRoot, '.cleo', 'conduit.db'), '');
|
|
1147
|
+
const afterMig = needsSignaldockToConduitMigration(projectRoot);
|
|
1148
|
+
expect(afterMig).toBe(false);
|
|
1149
|
+
});
|
|
1150
|
+
});
|