@cleocode/core 2026.9.16 → 2026.9.17
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/changesets/parser.d.ts.map +1 -1
- package/dist/changesets/parser.js +67 -0
- package/dist/changesets/parser.js.map +1 -1
- package/dist/crypto/credentials.d.ts +128 -26
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +183 -142
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/docs/export-document.js +14705 -12949
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/index.d.ts +1 -1
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +1 -1
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/nexus-schema-residency.d.ts +1 -1
- package/dist/doctor/nexus-schema-residency.d.ts.map +1 -1
- package/dist/doctor/nexus-schema-residency.js +1 -0
- package/dist/doctor/nexus-schema-residency.js.map +1 -1
- package/dist/doctor/project-identity.d.ts +124 -0
- package/dist/doctor/project-identity.d.ts.map +1 -0
- package/dist/doctor/project-identity.js +307 -0
- package/dist/doctor/project-identity.js.map +1 -0
- package/dist/doctor/superseded-store.d.ts +8 -0
- package/dist/doctor/superseded-store.d.ts.map +1 -1
- package/dist/doctor/superseded-store.js +48 -3
- package/dist/doctor/superseded-store.js.map +1 -1
- package/dist/init.d.ts +7 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +17 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +5 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -2
- package/dist/internal.js.map +1 -1
- package/dist/llm/credentials-store.d.ts +10 -0
- package/dist/llm/credentials-store.d.ts.map +1 -1
- package/dist/llm/credentials-store.js +7 -1
- package/dist/llm/credentials-store.js.map +1 -1
- package/dist/llm/plugin-facade.js +13368 -1550
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/nexus/analyze-orchestrator.d.ts +20 -3
- package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
- package/dist/nexus/analyze-orchestrator.js +159 -10
- package/dist/nexus/analyze-orchestrator.js.map +1 -1
- package/dist/nexus/assessment-store.d.ts +57 -0
- package/dist/nexus/assessment-store.d.ts.map +1 -0
- package/dist/nexus/assessment-store.js +73 -0
- package/dist/nexus/assessment-store.js.map +1 -0
- package/dist/nexus/freshness.d.ts +131 -0
- package/dist/nexus/freshness.d.ts.map +1 -0
- package/dist/nexus/freshness.js +391 -0
- package/dist/nexus/freshness.js.map +1 -0
- package/dist/nexus/graph-manifest.d.ts +88 -0
- package/dist/nexus/graph-manifest.d.ts.map +1 -0
- package/dist/nexus/graph-manifest.js +114 -0
- package/dist/nexus/graph-manifest.js.map +1 -0
- package/dist/nexus/knowledge.d.ts +21 -1
- package/dist/nexus/knowledge.d.ts.map +1 -1
- package/dist/nexus/knowledge.js +76 -37
- package/dist/nexus/knowledge.js.map +1 -1
- package/dist/nexus/path-map.d.ts +55 -0
- package/dist/nexus/path-map.d.ts.map +1 -0
- package/dist/nexus/path-map.js +87 -0
- package/dist/nexus/path-map.js.map +1 -0
- package/dist/nexus/projects-clean.d.ts +21 -28
- package/dist/nexus/projects-clean.d.ts.map +1 -1
- package/dist/nexus/projects-clean.js +168 -85
- package/dist/nexus/projects-clean.js.map +1 -1
- package/dist/nexus/query.d.ts.map +1 -1
- package/dist/nexus/query.js +3 -2
- package/dist/nexus/query.js.map +1 -1
- package/dist/nexus/registry-hygiene.d.ts +70 -0
- package/dist/nexus/registry-hygiene.d.ts.map +1 -0
- package/dist/nexus/registry-hygiene.js +113 -0
- package/dist/nexus/registry-hygiene.js.map +1 -0
- package/dist/nexus/registry.d.ts +2 -2
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +48 -12
- package/dist/nexus/registry.js.map +1 -1
- package/dist/paths.d.ts +26 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +99 -6
- package/dist/paths.js.map +1 -1
- package/dist/render/nexus/graph/index.d.ts.map +1 -1
- package/dist/render/nexus/graph/index.js +8 -0
- package/dist/render/nexus/graph/index.js.map +1 -1
- package/dist/scaffold/ensure-config.d.ts +12 -3
- package/dist/scaffold/ensure-config.d.ts.map +1 -1
- package/dist/scaffold/ensure-config.js +61 -38
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/scaffold/project-identity.d.ts +136 -0
- package/dist/scaffold/project-identity.d.ts.map +1 -0
- package/dist/scaffold/project-identity.js +225 -0
- package/dist/scaffold/project-identity.js.map +1 -0
- package/dist/selfimprove/fix-gen.js +13319 -1556
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/store/agent-api-key.d.ts +114 -0
- package/dist/store/agent-api-key.d.ts.map +1 -0
- package/dist/store/agent-api-key.js +136 -0
- package/dist/store/agent-api-key.js.map +1 -0
- package/dist/store/agent-registry-accessor.d.ts +4 -2
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +39 -89
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +7 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -1
- package/dist/store/api-key-kdf.js +7 -0
- package/dist/store/api-key-kdf.js.map +1 -1
- package/dist/store/backup-crypto.d.ts +49 -0
- package/dist/store/backup-crypto.d.ts.map +1 -1
- package/dist/store/backup-crypto.js +154 -0
- package/dist/store/backup-crypto.js.map +1 -1
- package/dist/store/credential-transfer.d.ts +303 -0
- package/dist/store/credential-transfer.d.ts.map +1 -0
- package/dist/store/credential-transfer.js +774 -0
- package/dist/store/credential-transfer.js.map +1 -0
- package/dist/store/exodus/column-transforms.d.ts +79 -4
- package/dist/store/exodus/column-transforms.d.ts.map +1 -1
- package/dist/store/exodus/column-transforms.js +121 -4
- package/dist/store/exodus/column-transforms.js.map +1 -1
- package/dist/store/exodus/index.d.ts +1 -0
- package/dist/store/exodus/index.d.ts.map +1 -1
- package/dist/store/exodus/index.js +1 -0
- package/dist/store/exodus/index.js.map +1 -1
- package/dist/store/exodus/migrate.d.ts +17 -1
- package/dist/store/exodus/migrate.d.ts.map +1 -1
- package/dist/store/exodus/migrate.js +155 -33
- package/dist/store/exodus/migrate.js.map +1 -1
- package/dist/store/exodus/on-open.d.ts +14 -0
- package/dist/store/exodus/on-open.d.ts.map +1 -1
- package/dist/store/exodus/on-open.js +115 -6
- package/dist/store/exodus/on-open.js.map +1 -1
- package/dist/store/exodus/reconcile.d.ts +103 -0
- package/dist/store/exodus/reconcile.d.ts.map +1 -0
- package/dist/store/exodus/reconcile.js +728 -0
- package/dist/store/exodus/reconcile.js.map +1 -0
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +14 -2
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/store/exodus/runtime-targets.d.ts +39 -0
- package/dist/store/exodus/runtime-targets.d.ts.map +1 -0
- package/dist/store/exodus/runtime-targets.js +63 -0
- package/dist/store/exodus/runtime-targets.js.map +1 -0
- package/dist/store/exodus/table-name-map.d.ts.map +1 -1
- package/dist/store/exodus/table-name-map.js +5 -0
- package/dist/store/exodus/table-name-map.js.map +1 -1
- package/dist/store/exodus/table-order.d.ts +35 -0
- package/dist/store/exodus/table-order.d.ts.map +1 -0
- package/dist/store/exodus/table-order.js +62 -0
- package/dist/store/exodus/table-order.js.map +1 -0
- package/dist/store/exodus/verify-migration.d.ts +4 -1
- package/dist/store/exodus/verify-migration.d.ts.map +1 -1
- package/dist/store/exodus/verify-migration.js +142 -10
- package/dist/store/exodus/verify-migration.js.map +1 -1
- package/dist/store/global-salt.d.ts +16 -0
- package/dist/store/global-salt.d.ts.map +1 -1
- package/dist/store/global-salt.js +22 -7
- package/dist/store/global-salt.js.map +1 -1
- package/dist/store/legacy-tasks-lineage.d.ts +86 -0
- package/dist/store/legacy-tasks-lineage.d.ts.map +1 -0
- package/dist/store/legacy-tasks-lineage.js +285 -0
- package/dist/store/legacy-tasks-lineage.js.map +1 -0
- package/dist/store/migration-manager.d.ts +27 -0
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +63 -0
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/portable-bundle-import.d.ts +117 -0
- package/dist/store/portable-bundle-import.d.ts.map +1 -0
- package/dist/store/portable-bundle-import.js +589 -0
- package/dist/store/portable-bundle-import.js.map +1 -0
- package/dist/store/portable-bundle-relocate.d.ts +66 -0
- package/dist/store/portable-bundle-relocate.d.ts.map +1 -0
- package/dist/store/portable-bundle-relocate.js +304 -0
- package/dist/store/portable-bundle-relocate.js.map +1 -0
- package/dist/store/portable-bundle-scan.d.ts +167 -0
- package/dist/store/portable-bundle-scan.d.ts.map +1 -0
- package/dist/store/portable-bundle-scan.js +569 -0
- package/dist/store/portable-bundle-scan.js.map +1 -0
- package/dist/store/portable-bundle.d.ts +146 -0
- package/dist/store/portable-bundle.d.ts.map +1 -0
- package/dist/store/portable-bundle.js +674 -0
- package/dist/store/portable-bundle.js.map +1 -0
- package/dist/store/regenerators.d.ts +3 -1
- package/dist/store/regenerators.d.ts.map +1 -1
- package/dist/store/regenerators.js +6 -2
- package/dist/store/regenerators.js.map +1 -1
- package/dist/store/schema/cleo-global/nexus.d.ts +98 -0
- package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
- package/dist/store/schema/cleo-global/nexus.js +19 -0
- package/dist/store/schema/cleo-global/nexus.js.map +1 -1
- package/dist/store/schema/nexus-schema.d.ts +102 -0
- package/dist/store/schema/nexus-schema.d.ts.map +1 -1
- package/dist/store/schema/nexus-schema.js +24 -0
- package/dist/store/schema/nexus-schema.js.map +1 -1
- package/dist/store/split-brain-import.d.ts +176 -0
- package/dist/store/split-brain-import.d.ts.map +1 -0
- package/dist/store/split-brain-import.js +680 -0
- package/dist/store/split-brain-import.js.map +1 -0
- package/dist/store/sqlite.d.ts +29 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +57 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +30 -5
- package/dist/system/health.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +60 -0
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +14 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +33 -0
- package/dist/validation/doctor/checks.js.map +1 -1
- package/migrations/drizzle-cleo-global/20260924000000_t12354-project-paths/migration.sql +40 -0
- package/migrations/drizzle-nexus/20260924000000_t12315-nexus-parse-cache/migration.sql +21 -0
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +3 -3
- package/templates/cleo-gitignore +7 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-transfer.ts — move stored credentials between devices without
|
|
3
|
+
* moving the device key.
|
|
4
|
+
*
|
|
5
|
+
* Every stored credential is encrypted under a key derived from the local
|
|
6
|
+
* `machine-key` (`crypto/credentials.ts`). Copying that key to another device
|
|
7
|
+
* would make the two devices one trust domain, so it is never exported. A
|
|
8
|
+
* credential crosses devices one of two ways instead:
|
|
9
|
+
*
|
|
10
|
+
* - **Sealed** (encrypted backups) — {@link sealCredentials} decrypts every
|
|
11
|
+
* local credential and seals the plaintexts under a passphrase-derived key
|
|
12
|
+
* (scrypt + AES-256-GCM, the same envelope as encrypted bundles).
|
|
13
|
+
* {@link unsealCredentials} on the target device opens that payload and
|
|
14
|
+
* re-encrypts each credential under the TARGET machine-key.
|
|
15
|
+
* - **Re-entered** (unencrypted backups) — {@link redactCredentialCiphertexts}
|
|
16
|
+
* blanks the device-bound ciphertexts in the STAGED snapshot (they are
|
|
17
|
+
* useless elsewhere) and {@link listCredentialsForReentry} names every
|
|
18
|
+
* credential with the one command that re-enters it.
|
|
19
|
+
*
|
|
20
|
+
* This module also migrates project credentials from the legacy path-bound
|
|
21
|
+
* KDF to the project-identity KDF ({@link migrateProjectCredentials}), so a
|
|
22
|
+
* moved project directory keeps its credentials.
|
|
23
|
+
*
|
|
24
|
+
* Stores covered:
|
|
25
|
+
*
|
|
26
|
+
* | Store | Location | Encryption |
|
|
27
|
+
* |-------|----------|------------|
|
|
28
|
+
* | `project-agent` | project `cleo.db` → `tasks_agent_credentials.api_key_encrypted` | project KDF |
|
|
29
|
+
* | `service-connection` | global `cleo.db` → `service_connections.credentials_enc` | global KDF, id `service:<provider>:<label>` |
|
|
30
|
+
* | `llm-pool` | `<cleoHome>/llm-credentials.json` | none (0600 plaintext file) |
|
|
31
|
+
* | `agent-registry` | global `cleo.db` → `agent_registry_agents.api_key_encrypted` | global KDF, id `agent:<agentId>` (T12352) |
|
|
32
|
+
*
|
|
33
|
+
* The LLM pool stays a plaintext 0600 file on purpose (T12326 decision).
|
|
34
|
+
* Encrypting it under the machine-key would add little on-device protection,
|
|
35
|
+
* because the key sits in the same directory with the same mode. It would also
|
|
36
|
+
* force a key read into the synchronous resolver hot path
|
|
37
|
+
* (`pickCredentialForProviderSync`) and break the tools that read the file
|
|
38
|
+
* directly. What matters is that the pool never enters an unencrypted bundle,
|
|
39
|
+
* and this module guarantees that: the pool is either sealed or listed for
|
|
40
|
+
* re-entry.
|
|
41
|
+
*
|
|
42
|
+
* Callers pass FILE PATHS (live stores, or a backup's staged snapshots); the
|
|
43
|
+
* module opens short-lived raw handles and closes them before returning.
|
|
44
|
+
*
|
|
45
|
+
* @task T12326
|
|
46
|
+
* @module store/credential-transfer
|
|
47
|
+
*/
|
|
48
|
+
import fs from 'node:fs';
|
|
49
|
+
import { createRequire } from 'node:module';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import { decryptGlobal, decryptProjectSecret, encryptGlobal, encryptProjectSecret, } from '../crypto/credentials.js';
|
|
52
|
+
import { addCredential, credentialsStorePath, isStoredCredential, } from '../llm/credentials-store.js';
|
|
53
|
+
import { getCleoHome } from '../paths.js';
|
|
54
|
+
import { openAgentApiKey, sealAgentApiKey } from './agent-api-key.js';
|
|
55
|
+
import { decryptBundle, encryptBundle } from './backup-crypto.js';
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// node:sqlite interop (createRequire — Vitest strips `node:` prefix)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
const _require = createRequire(import.meta.url);
|
|
60
|
+
const { DatabaseSync } = _require('node:sqlite');
|
|
61
|
+
/**
|
|
62
|
+
* Error thrown by credential transfer. Raised BEFORE any write, so a caller
|
|
63
|
+
* that catches it knows no store was modified.
|
|
64
|
+
*
|
|
65
|
+
* @task T12326
|
|
66
|
+
*/
|
|
67
|
+
export class CredentialTransferError extends Error {
|
|
68
|
+
/** Machine-readable failure code. */
|
|
69
|
+
code;
|
|
70
|
+
/**
|
|
71
|
+
* @param code - Failure code.
|
|
72
|
+
* @param message - Human-readable explanation, including the remedy.
|
|
73
|
+
*/
|
|
74
|
+
constructor(code, message) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'CredentialTransferError';
|
|
77
|
+
this.code = code;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Sealed payload shape
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/** Discriminator written into every sealed payload. */
|
|
84
|
+
const PAYLOAD_FORMAT = 'cleo-credential-transfer';
|
|
85
|
+
/** Payload schema version. */
|
|
86
|
+
const PAYLOAD_VERSION = 1;
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Re-entry commands
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/** Quote a shell argument only when it needs quoting. */
|
|
91
|
+
function shellArg(value) {
|
|
92
|
+
return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the re-entry record for a credential: its identity, why it must be
|
|
96
|
+
* re-entered, and the one command that re-enters it.
|
|
97
|
+
*
|
|
98
|
+
* @param descriptor - The credential.
|
|
99
|
+
* @param reason - Why it could not be carried over.
|
|
100
|
+
* @returns The re-entry record.
|
|
101
|
+
* @task T12326
|
|
102
|
+
*/
|
|
103
|
+
export function reentryFor(descriptor, reason) {
|
|
104
|
+
return { ...descriptor, reason, reentryCommand: reentryCommandFor(descriptor) };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The one command that re-enters a credential. Secrets appear as a
|
|
108
|
+
* placeholder, never a value.
|
|
109
|
+
*/
|
|
110
|
+
function reentryCommandFor(descriptor) {
|
|
111
|
+
switch (descriptor.store) {
|
|
112
|
+
case 'project-agent':
|
|
113
|
+
case 'agent-registry':
|
|
114
|
+
return `cleo agent register --id ${shellArg(descriptor.id)} --name ${shellArg(descriptor.label)} --api-key <API_KEY>`;
|
|
115
|
+
case 'service-connection': {
|
|
116
|
+
const { provider, label } = splitProviderLabel(descriptor.id);
|
|
117
|
+
return `cleo service connect ${shellArg(provider)} --label ${shellArg(label)} --token <TOKEN>`;
|
|
118
|
+
}
|
|
119
|
+
case 'llm-pool': {
|
|
120
|
+
const { provider, label } = splitProviderLabel(descriptor.id);
|
|
121
|
+
return `printf '%s' "$API_KEY" | cleo llm add ${shellArg(provider)} --label ${shellArg(label)} --api-key-stdin`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Split a `<provider>:<label>` id (labels may contain `:`). */
|
|
126
|
+
function splitProviderLabel(id) {
|
|
127
|
+
const idx = id.indexOf(':');
|
|
128
|
+
return idx < 0
|
|
129
|
+
? { provider: id, label: 'default' }
|
|
130
|
+
: { provider: id.slice(0, idx), label: id.slice(idx + 1) };
|
|
131
|
+
}
|
|
132
|
+
/** Open a database file, run `fn`, always close. Returns `fallback` when the file is absent. */
|
|
133
|
+
function withDb(dbPath, fallback, fn) {
|
|
134
|
+
if (dbPath === undefined || !fs.existsSync(dbPath))
|
|
135
|
+
return fallback;
|
|
136
|
+
const db = new DatabaseSync(dbPath);
|
|
137
|
+
try {
|
|
138
|
+
return fn(db);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
db.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* True when `table` exists in `db` and has every one of `columns`. Stores in
|
|
146
|
+
* the wild include older or partial schemas; a table without the columns this
|
|
147
|
+
* module reads is treated as holding no credentials rather than failing the
|
|
148
|
+
* caller (a backup must not abort on it).
|
|
149
|
+
*/
|
|
150
|
+
function hasTable(db, table, columns = []) {
|
|
151
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) ===
|
|
152
|
+
undefined) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (columns.length === 0)
|
|
156
|
+
return true;
|
|
157
|
+
const present = new Set(db.prepare(`PRAGMA table_info("${table}")`).all().map((c) => c.name));
|
|
158
|
+
return columns.every((c) => present.has(c));
|
|
159
|
+
}
|
|
160
|
+
/** Read `tasks_agent_credentials` rows that carry ciphertext. */
|
|
161
|
+
function readProjectAgentRows(dbPath) {
|
|
162
|
+
return withDb(dbPath, [], (db) => {
|
|
163
|
+
if (!hasTable(db, 'tasks_agent_credentials', ['agent_id', 'display_name', 'api_key_encrypted'])) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
const rows = db
|
|
167
|
+
.prepare("SELECT agent_id, display_name, api_key_encrypted FROM tasks_agent_credentials WHERE api_key_encrypted <> '' ORDER BY agent_id")
|
|
168
|
+
.all();
|
|
169
|
+
return rows.map((r) => ({
|
|
170
|
+
descriptor: { store: 'project-agent', id: r.agent_id, label: r.display_name },
|
|
171
|
+
material: r.api_key_encrypted,
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Read `service_connections` rows that carry ciphertext. */
|
|
176
|
+
function readServiceConnectionRows(dbPath) {
|
|
177
|
+
return withDb(dbPath, [], (db) => {
|
|
178
|
+
if (!hasTable(db, 'service_connections', ['provider', 'label', 'credentials_enc']))
|
|
179
|
+
return [];
|
|
180
|
+
const rows = db
|
|
181
|
+
.prepare("SELECT provider, label, credentials_enc FROM service_connections WHERE credentials_enc IS NOT NULL AND credentials_enc <> '' ORDER BY provider, label")
|
|
182
|
+
.all();
|
|
183
|
+
return rows.map((r) => ({
|
|
184
|
+
descriptor: {
|
|
185
|
+
store: 'service-connection',
|
|
186
|
+
id: `${r.provider}:${r.label}`,
|
|
187
|
+
label: `${r.provider} service connection "${r.label}"`,
|
|
188
|
+
},
|
|
189
|
+
material: r.credentials_enc,
|
|
190
|
+
}));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Read `agent_registry_agents` rows that carry a stored key (any format). */
|
|
194
|
+
function readAgentRegistryRows(dbPath) {
|
|
195
|
+
return withDb(dbPath, [], (db) => {
|
|
196
|
+
if (!hasTable(db, 'agent_registry_agents', ['agent_id', 'name', 'api_key_encrypted'])) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const rows = db
|
|
200
|
+
.prepare("SELECT agent_id, name, api_key_encrypted FROM agent_registry_agents WHERE api_key_encrypted IS NOT NULL AND api_key_encrypted <> '' ORDER BY agent_id")
|
|
201
|
+
.all();
|
|
202
|
+
return rows.map((r) => ({
|
|
203
|
+
descriptor: { store: 'agent-registry', id: r.agent_id, label: r.name },
|
|
204
|
+
material: r.api_key_encrypted,
|
|
205
|
+
}));
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Read the LLM pool file's entries. A missing or malformed file yields none. */
|
|
209
|
+
function readLlmPoolRows(storePath) {
|
|
210
|
+
if (storePath === undefined || !fs.existsSync(storePath))
|
|
211
|
+
return [];
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(fs.readFileSync(storePath, 'utf-8'));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
if (typeof parsed !== 'object' || parsed === null || !('credentials' in parsed))
|
|
220
|
+
return [];
|
|
221
|
+
const credentials = parsed.credentials;
|
|
222
|
+
if (!Array.isArray(credentials))
|
|
223
|
+
return [];
|
|
224
|
+
return credentials.filter(isStoredCredential).map((c) => ({
|
|
225
|
+
descriptor: {
|
|
226
|
+
store: 'llm-pool',
|
|
227
|
+
id: `${c.provider}:${c.label}`,
|
|
228
|
+
label: `${c.provider} LLM credential "${c.label}"`,
|
|
229
|
+
},
|
|
230
|
+
material: JSON.stringify(c),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
/** Every credential row across the given sources, still encrypted. */
|
|
234
|
+
function readAllRows(sources) {
|
|
235
|
+
return [
|
|
236
|
+
...readProjectAgentRows(sources.projectDbPath),
|
|
237
|
+
...readServiceConnectionRows(sources.globalDbPath),
|
|
238
|
+
...readAgentRegistryRows(sources.globalDbPath),
|
|
239
|
+
...readLlmPoolRows(sources.llmStorePath),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Unencrypted-bundle path: list + redact
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
/**
|
|
246
|
+
* List every credential in `sources` with the command that re-enters it.
|
|
247
|
+
* Reads identities only — nothing is decrypted.
|
|
248
|
+
*
|
|
249
|
+
* Use for an UNENCRYPTED backup: its import cannot carry credentials, so the
|
|
250
|
+
* import report prints this list.
|
|
251
|
+
*
|
|
252
|
+
* @param sources - Stores to enumerate.
|
|
253
|
+
* @param reason - Why the credentials must be re-entered (shown per entry).
|
|
254
|
+
* @returns One re-entry record per credential.
|
|
255
|
+
* @task T12326
|
|
256
|
+
*/
|
|
257
|
+
export function listCredentialsForReentry(sources, reason = 'unencrypted backups do not carry credentials') {
|
|
258
|
+
return readAllRows(sources).map((row) => reentryFor(row.descriptor, reason));
|
|
259
|
+
}
|
|
260
|
+
/** Resolve a path for comparison, following symlinks when the file exists. */
|
|
261
|
+
function canonicalPath(p) {
|
|
262
|
+
try {
|
|
263
|
+
return fs.realpathSync(p);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return path.resolve(p);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Refuse to redact anything that looks like a live store: the global
|
|
271
|
+
* `cleo.db`, or any database directly inside a `.cleo/` directory.
|
|
272
|
+
*/
|
|
273
|
+
function assertStagedCopy(dbPath) {
|
|
274
|
+
const resolved = canonicalPath(dbPath);
|
|
275
|
+
const liveGlobal = canonicalPath(path.join(getCleoHome(), 'cleo.db'));
|
|
276
|
+
if (resolved === liveGlobal || path.basename(path.dirname(resolved)) === '.cleo') {
|
|
277
|
+
throw new CredentialTransferError('E_CREDENTIAL_LIVE_STORE', `Refusing to redact credentials in a live store (${resolved}). ` +
|
|
278
|
+
'Redaction applies only to staged backup snapshots.');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Blank the device-bound ciphertexts in STAGED snapshot databases and report
|
|
283
|
+
* what was removed.
|
|
284
|
+
*
|
|
285
|
+
* An unencrypted bundle must not carry credential ciphertext: it cannot be
|
|
286
|
+
* decrypted on another device, and it would be decryptable by anyone who
|
|
287
|
+
* later obtains this device's machine-key. Sets
|
|
288
|
+
* `tasks_agent_credentials.api_key_encrypted = ''` (the column is NOT NULL)
|
|
289
|
+
* and `service_connections.credentials_enc = NULL`; every non-secret column
|
|
290
|
+
* is kept. The LLM pool FILE is not touched — the bundle writer must exclude
|
|
291
|
+
* it, and can take its entries from the returned list.
|
|
292
|
+
*
|
|
293
|
+
* @param sources - Staged snapshot paths (`projectDbPath` / `globalDbPath`);
|
|
294
|
+
* `llmStorePath` is listed, not modified.
|
|
295
|
+
* @returns Re-entry records for every redacted or excluded credential.
|
|
296
|
+
* @throws {CredentialTransferError} `E_CREDENTIAL_LIVE_STORE` when a path is
|
|
297
|
+
* a live store.
|
|
298
|
+
* @task T12326
|
|
299
|
+
*/
|
|
300
|
+
export function redactCredentialCiphertexts(sources) {
|
|
301
|
+
if (sources.projectDbPath !== undefined)
|
|
302
|
+
assertStagedCopy(sources.projectDbPath);
|
|
303
|
+
if (sources.globalDbPath !== undefined)
|
|
304
|
+
assertStagedCopy(sources.globalDbPath);
|
|
305
|
+
const listed = listCredentialsForReentry(sources);
|
|
306
|
+
withDb(sources.projectDbPath, undefined, (db) => {
|
|
307
|
+
if (hasTable(db, 'tasks_agent_credentials', ['api_key_encrypted'])) {
|
|
308
|
+
db.prepare("UPDATE tasks_agent_credentials SET api_key_encrypted = ''").run();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
withDb(sources.globalDbPath, undefined, (db) => {
|
|
312
|
+
if (hasTable(db, 'service_connections', ['credentials_enc'])) {
|
|
313
|
+
db.prepare('UPDATE service_connections SET credentials_enc = NULL WHERE credentials_enc IS NOT NULL').run();
|
|
314
|
+
}
|
|
315
|
+
if (hasTable(db, 'agent_registry_agents', ['api_key_encrypted'])) {
|
|
316
|
+
db.prepare('UPDATE agent_registry_agents SET api_key_encrypted = NULL WHERE api_key_encrypted IS NOT NULL').run();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
return listed;
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Encrypted-bundle path: seal + unseal
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
/** Require a project id when project credentials are present. */
|
|
325
|
+
function requireProjectId(projectId, action) {
|
|
326
|
+
if (projectId === undefined || projectId.length === 0) {
|
|
327
|
+
throw new CredentialTransferError('E_CREDENTIAL_PROJECT_ID', `Cannot ${action} project credentials without the project's id ` +
|
|
328
|
+
'(the projectId in .cleo/project-info.json).');
|
|
329
|
+
}
|
|
330
|
+
return projectId;
|
|
331
|
+
}
|
|
332
|
+
/** Decrypt one stored row with the LOCAL keys. */
|
|
333
|
+
async function openRow(row, sources) {
|
|
334
|
+
switch (row.descriptor.store) {
|
|
335
|
+
case 'project-agent': {
|
|
336
|
+
const projectId = requireProjectId(sources.projectId, 'seal');
|
|
337
|
+
const result = await decryptProjectSecret(row.material, {
|
|
338
|
+
projectId,
|
|
339
|
+
legacyProjectPaths: sources.legacyProjectPaths,
|
|
340
|
+
cleoHome: sources.cleoHome,
|
|
341
|
+
});
|
|
342
|
+
return result.plaintext;
|
|
343
|
+
}
|
|
344
|
+
case 'service-connection':
|
|
345
|
+
return decryptGlobal(row.material, `service:${row.descriptor.id}`, {
|
|
346
|
+
cleoHome: sources.cleoHome,
|
|
347
|
+
});
|
|
348
|
+
case 'agent-registry': {
|
|
349
|
+
const key = await openAgentApiKey(row.material, row.descriptor.id, {
|
|
350
|
+
cleoHome: sources.cleoHome,
|
|
351
|
+
});
|
|
352
|
+
if (key.requiresReauth)
|
|
353
|
+
throw new Error(key.reason ?? 'the stored key is not recoverable');
|
|
354
|
+
return key.apiKey;
|
|
355
|
+
}
|
|
356
|
+
case 'llm-pool':
|
|
357
|
+
return row.material;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Seal every credential in `sources` under a passphrase.
|
|
362
|
+
*
|
|
363
|
+
* Each credential is decrypted with THIS device's keys and the plaintexts are
|
|
364
|
+
* sealed together (scrypt-derived key, AES-256-GCM — {@link encryptBundle}).
|
|
365
|
+
* The payload carries credentials, never key material: the machine-key and
|
|
366
|
+
* global-salt stay on this device. A credential this device cannot decrypt is
|
|
367
|
+
* not sealed; it is reported in `reentry` (and carried into the payload so
|
|
368
|
+
* the importing device reports it too).
|
|
369
|
+
*
|
|
370
|
+
* @param sources - Stores to read (live stores or staged snapshots).
|
|
371
|
+
* @param passphrase - User passphrase; the same one opens the payload.
|
|
372
|
+
* @returns Sealed bytes plus what was sealed and what needs re-entry. A
|
|
373
|
+
* project credential with no `sources.projectId` is reported for re-entry.
|
|
374
|
+
* @task T12326
|
|
375
|
+
*/
|
|
376
|
+
export async function sealCredentials(sources, passphrase) {
|
|
377
|
+
const rows = readAllRows(sources);
|
|
378
|
+
const entries = [];
|
|
379
|
+
const reentry = [];
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
try {
|
|
382
|
+
entries.push({ ...row.descriptor, secret: await openRow(row, sources) });
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
reentry.push(reentryFor(row.descriptor, `not decryptable on the exporting device: ${err instanceof Error ? err.message : String(err)}`));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const payload = {
|
|
389
|
+
format: PAYLOAD_FORMAT,
|
|
390
|
+
version: PAYLOAD_VERSION,
|
|
391
|
+
createdAt: new Date().toISOString(),
|
|
392
|
+
entries,
|
|
393
|
+
reentry,
|
|
394
|
+
};
|
|
395
|
+
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
396
|
+
try {
|
|
397
|
+
return {
|
|
398
|
+
sealed: encryptBundle(plaintext, passphrase),
|
|
399
|
+
sealedCredentials: entries.map(({ store, id, label }) => ({ store, id, label })),
|
|
400
|
+
reentry,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
plaintext.fill(0);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Type guard for a credential descriptor field set. */
|
|
408
|
+
function isDescriptor(value) {
|
|
409
|
+
if (typeof value !== 'object' || value === null)
|
|
410
|
+
return false;
|
|
411
|
+
const store = 'store' in value ? value.store : undefined;
|
|
412
|
+
return ((store === 'project-agent' ||
|
|
413
|
+
store === 'service-connection' ||
|
|
414
|
+
store === 'llm-pool' ||
|
|
415
|
+
store === 'agent-registry') &&
|
|
416
|
+
'id' in value &&
|
|
417
|
+
typeof value.id === 'string' &&
|
|
418
|
+
'label' in value &&
|
|
419
|
+
typeof value.label === 'string');
|
|
420
|
+
}
|
|
421
|
+
/** Type guard for a sealed entry. */
|
|
422
|
+
function isSealedEntry(value) {
|
|
423
|
+
return isDescriptor(value) && 'secret' in value && typeof value.secret === 'string';
|
|
424
|
+
}
|
|
425
|
+
/** Type guard for a carried re-entry record. */
|
|
426
|
+
function isReentry(value) {
|
|
427
|
+
return (isDescriptor(value) &&
|
|
428
|
+
'reason' in value &&
|
|
429
|
+
typeof value.reason === 'string' &&
|
|
430
|
+
'reentryCommand' in value &&
|
|
431
|
+
typeof value.reentryCommand === 'string');
|
|
432
|
+
}
|
|
433
|
+
/** Parse and validate a decrypted payload. */
|
|
434
|
+
function parsePayload(bytes) {
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
parsed = undefined;
|
|
441
|
+
}
|
|
442
|
+
if (typeof parsed !== 'object' ||
|
|
443
|
+
parsed === null ||
|
|
444
|
+
!('format' in parsed) ||
|
|
445
|
+
parsed.format !== PAYLOAD_FORMAT ||
|
|
446
|
+
!('version' in parsed) ||
|
|
447
|
+
parsed.version !== PAYLOAD_VERSION ||
|
|
448
|
+
!('entries' in parsed) ||
|
|
449
|
+
!Array.isArray(parsed.entries) ||
|
|
450
|
+
!parsed.entries.every(isSealedEntry) ||
|
|
451
|
+
!('reentry' in parsed) ||
|
|
452
|
+
!Array.isArray(parsed.reentry) ||
|
|
453
|
+
!parsed.reentry.every(isReentry)) {
|
|
454
|
+
throw new CredentialTransferError('E_CREDENTIAL_PAYLOAD', 'The sealed credential payload opened but is not a supported credential payload.');
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
format: PAYLOAD_FORMAT,
|
|
458
|
+
version: PAYLOAD_VERSION,
|
|
459
|
+
createdAt: 'createdAt' in parsed && typeof parsed.createdAt === 'string' ? parsed.createdAt : '',
|
|
460
|
+
entries: parsed.entries,
|
|
461
|
+
reentry: parsed.reentry,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Update one ciphertext cell in `table`; returns whether a row matched. A
|
|
466
|
+
* table missing any of `columns` matches nothing (reported, never thrown).
|
|
467
|
+
*/
|
|
468
|
+
function updateCell(dbPath, table, columns, sql, params) {
|
|
469
|
+
return withDb(dbPath, false, (db) => {
|
|
470
|
+
if (!hasTable(db, table, columns))
|
|
471
|
+
return false;
|
|
472
|
+
return Number(db.prepare(sql).run(...params).changes) > 0;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Open a sealed payload and write every credential into the local stores,
|
|
477
|
+
* re-encrypted under THIS device's machine-key.
|
|
478
|
+
*
|
|
479
|
+
* Order of operations: the passphrase is checked and the payload validated
|
|
480
|
+
* BEFORE anything is written — a wrong passphrase leaves every store
|
|
481
|
+
* untouched. Database rows are updated in place (the restore must already
|
|
482
|
+
* have put the rows there); a credential whose row is absent, or whose store
|
|
483
|
+
* has no target, is reported for re-entry instead of being dropped silently.
|
|
484
|
+
*
|
|
485
|
+
* Call after the restored database files and any restored `global-salt` are in
|
|
486
|
+
* place: the global KDF reads the salt. Pass `targets.cleoHome` so the salt is
|
|
487
|
+
* read fresh from that home rather than from the process memo.
|
|
488
|
+
*
|
|
489
|
+
* @param sealed - Bytes from {@link sealCredentials}.
|
|
490
|
+
* @param passphrase - The passphrase used to seal.
|
|
491
|
+
* @param targets - Restored stores to write into.
|
|
492
|
+
* @returns What was restored and what must be re-entered (with commands).
|
|
493
|
+
* @throws {CredentialTransferError} `E_CREDENTIAL_PASSPHRASE` (wrong
|
|
494
|
+
* passphrase or corrupted payload) or `E_CREDENTIAL_PAYLOAD` — nothing written.
|
|
495
|
+
* @task T12326
|
|
496
|
+
*/
|
|
497
|
+
export async function unsealCredentials(sealed, passphrase, targets) {
|
|
498
|
+
let opened;
|
|
499
|
+
try {
|
|
500
|
+
opened = decryptBundle(Buffer.from(sealed), passphrase);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
throw new CredentialTransferError('E_CREDENTIAL_PASSPHRASE', 'Cannot open the sealed credentials: wrong passphrase, or the payload is corrupted. ' +
|
|
504
|
+
'Nothing was written. Re-run the import with the passphrase used for the export.');
|
|
505
|
+
}
|
|
506
|
+
let payload;
|
|
507
|
+
try {
|
|
508
|
+
payload = parsePayload(opened);
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
opened.fill(0);
|
|
512
|
+
}
|
|
513
|
+
// Phase 1 — encrypt everything under the local keys (no writes yet).
|
|
514
|
+
const writes = [];
|
|
515
|
+
const reentry = [...payload.reentry];
|
|
516
|
+
for (const entry of payload.entries) {
|
|
517
|
+
const descriptor = {
|
|
518
|
+
store: entry.store,
|
|
519
|
+
id: entry.id,
|
|
520
|
+
label: entry.label,
|
|
521
|
+
};
|
|
522
|
+
switch (entry.store) {
|
|
523
|
+
case 'project-agent': {
|
|
524
|
+
const { projectDbPath, projectId } = targets;
|
|
525
|
+
if (projectDbPath === undefined || projectId === undefined || projectId === '') {
|
|
526
|
+
reentry.push(reentryFor(descriptor, 'no project store to restore into'));
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
const ciphertext = await encryptProjectSecret(entry.secret, projectId, {
|
|
530
|
+
cleoHome: targets.cleoHome,
|
|
531
|
+
});
|
|
532
|
+
writes.push({
|
|
533
|
+
entry,
|
|
534
|
+
apply: async () => updateCell(projectDbPath, 'tasks_agent_credentials', ['agent_id', 'api_key_encrypted'], 'UPDATE tasks_agent_credentials SET api_key_encrypted = ? WHERE agent_id = ?', [ciphertext, entry.id]),
|
|
535
|
+
});
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case 'service-connection': {
|
|
539
|
+
const { globalDbPath } = targets;
|
|
540
|
+
if (globalDbPath === undefined) {
|
|
541
|
+
reentry.push(reentryFor(descriptor, 'no global store to restore into'));
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
const ciphertext = await encryptGlobal(entry.secret, `service:${entry.id}`, {
|
|
545
|
+
cleoHome: targets.cleoHome,
|
|
546
|
+
});
|
|
547
|
+
const { provider, label } = splitProviderLabel(entry.id);
|
|
548
|
+
writes.push({
|
|
549
|
+
entry,
|
|
550
|
+
apply: async () => updateCell(globalDbPath, 'service_connections', ['provider', 'label', 'credentials_enc'], 'UPDATE service_connections SET credentials_enc = ? WHERE provider = ? AND label = ?', [ciphertext, provider, label]),
|
|
551
|
+
});
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
case 'agent-registry': {
|
|
555
|
+
const { globalDbPath } = targets;
|
|
556
|
+
if (globalDbPath === undefined) {
|
|
557
|
+
reentry.push(reentryFor(descriptor, 'no global store to restore into'));
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const stored = await sealAgentApiKey(entry.secret, entry.id, {
|
|
561
|
+
cleoHome: targets.cleoHome,
|
|
562
|
+
});
|
|
563
|
+
writes.push({
|
|
564
|
+
entry,
|
|
565
|
+
apply: async () => updateCell(globalDbPath, 'agent_registry_agents', ['agent_id', 'api_key_encrypted', 'requires_reauth'], 'UPDATE agent_registry_agents SET api_key_encrypted = ?, requires_reauth = 0 WHERE agent_id = ?', [stored ?? '', entry.id]),
|
|
566
|
+
});
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case 'llm-pool': {
|
|
570
|
+
if (targets.restoreLlmPool === false) {
|
|
571
|
+
reentry.push(reentryFor(descriptor, 'LLM pool restore was not requested'));
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
if (targets.cleoHome !== undefined &&
|
|
575
|
+
canonicalPath(targets.cleoHome) !== canonicalPath(getCleoHome())) {
|
|
576
|
+
reentry.push(reentryFor(descriptor, 'the LLM pool of a non-active CLEO home cannot be written'));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
let credential;
|
|
580
|
+
try {
|
|
581
|
+
credential = JSON.parse(entry.secret);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
credential = undefined;
|
|
585
|
+
}
|
|
586
|
+
if (!isStoredCredential(credential)) {
|
|
587
|
+
reentry.push(reentryFor(descriptor, 'sealed LLM entry is malformed'));
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
const stored = credential;
|
|
591
|
+
writes.push({
|
|
592
|
+
entry,
|
|
593
|
+
apply: async () => {
|
|
594
|
+
await addCredential(stored);
|
|
595
|
+
return true;
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Phase 2 — write.
|
|
603
|
+
const restored = [];
|
|
604
|
+
for (const { entry, apply } of writes) {
|
|
605
|
+
const descriptor = {
|
|
606
|
+
store: entry.store,
|
|
607
|
+
id: entry.id,
|
|
608
|
+
label: entry.label,
|
|
609
|
+
};
|
|
610
|
+
if (await apply())
|
|
611
|
+
restored.push(descriptor);
|
|
612
|
+
else
|
|
613
|
+
reentry.push(reentryFor(descriptor, 'the restored store has no row for it'));
|
|
614
|
+
}
|
|
615
|
+
return { restored, reentry };
|
|
616
|
+
}
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// Project KDF migration (path-bound → project identity)
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
/**
|
|
621
|
+
* Re-encrypt project credentials from the legacy path-bound KDF to the
|
|
622
|
+
* project-identity KDF, in place.
|
|
623
|
+
*
|
|
624
|
+
* Each ciphertext is opened with {@link decryptProjectSecret}; a legacy one is
|
|
625
|
+
* rewritten with the re-wrapped ciphertext it returns. Current ciphertexts are
|
|
626
|
+
* left alone. A credential no candidate key opens is left UNTOUCHED (never
|
|
627
|
+
* deleted) and reported with its re-entry command — typically because it was
|
|
628
|
+
* encrypted at a path not in `legacyProjectPaths`, or on another device.
|
|
629
|
+
* Idempotent: a second run reports everything as `current`. With `dryRun`
|
|
630
|
+
* nothing is written and `migrated` lists what WOULD be migrated.
|
|
631
|
+
*
|
|
632
|
+
* @param sources - `projectDbPath`, `projectId`, and `legacyProjectPaths`.
|
|
633
|
+
* @param options - `dryRun` to report without writing.
|
|
634
|
+
* @returns Migrated, already-current, and unrecoverable credentials.
|
|
635
|
+
* @throws {CredentialTransferError} `E_CREDENTIAL_PROJECT_ID` when rows exist
|
|
636
|
+
* and `projectId` is missing.
|
|
637
|
+
* @task T12326
|
|
638
|
+
*/
|
|
639
|
+
export async function migrateProjectCredentials(sources, options = {}) {
|
|
640
|
+
const rows = readProjectAgentRows(sources.projectDbPath);
|
|
641
|
+
const migrated = [];
|
|
642
|
+
const current = [];
|
|
643
|
+
const reentry = [];
|
|
644
|
+
if (rows.length === 0 || sources.projectDbPath === undefined) {
|
|
645
|
+
return { migrated, current, reentry };
|
|
646
|
+
}
|
|
647
|
+
const projectId = requireProjectId(sources.projectId, 'migrate');
|
|
648
|
+
const dbPath = sources.projectDbPath;
|
|
649
|
+
for (const row of rows) {
|
|
650
|
+
let result;
|
|
651
|
+
try {
|
|
652
|
+
result = await decryptProjectSecret(row.material, {
|
|
653
|
+
projectId,
|
|
654
|
+
legacyProjectPaths: sources.legacyProjectPaths,
|
|
655
|
+
cleoHome: sources.cleoHome,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
reentry.push(reentryFor(row.descriptor, err instanceof Error ? err.message : String(err)));
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (result.rewrapped === null) {
|
|
663
|
+
current.push(row.descriptor);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (options.dryRun === true) {
|
|
667
|
+
migrated.push(row.descriptor);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
// Compare-and-swap on the old ciphertext: a concurrent writer wins.
|
|
671
|
+
const swapped = updateCell(dbPath, 'tasks_agent_credentials', ['agent_id', 'api_key_encrypted'], 'UPDATE tasks_agent_credentials SET api_key_encrypted = ? WHERE agent_id = ? AND api_key_encrypted = ?', [result.rewrapped, row.descriptor.id, row.material]);
|
|
672
|
+
(swapped ? migrated : current).push(row.descriptor);
|
|
673
|
+
}
|
|
674
|
+
return { migrated, current, reentry };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Migrate a project's stored credentials off the path-bound KDF, resolving
|
|
678
|
+
* everything from the project root: the store is `<root>/.cleo/cleo.db`, the
|
|
679
|
+
* identity is `projectId` from `.cleo/project-info.json`, and the legacy
|
|
680
|
+
* candidates are the root as given and its real path.
|
|
681
|
+
*
|
|
682
|
+
* This is the trigger used by `cleo upgrade` and `cleo doctor credentials`.
|
|
683
|
+
* It is idempotent and never deletes; unrecoverable rows come back in
|
|
684
|
+
* `reentry` with the command that re-enters each one. A project with no
|
|
685
|
+
* credential rows returns empty lists without opening anything for write.
|
|
686
|
+
*
|
|
687
|
+
* @param projectRoot - Absolute project root.
|
|
688
|
+
* @param options - `dryRun` to report without writing; `cleoHome` to key with another home.
|
|
689
|
+
* @returns What was (or would be) migrated, what is current, and what must be re-entered.
|
|
690
|
+
* @throws {CredentialTransferError} `E_CREDENTIAL_PROJECT_ID` when credential
|
|
691
|
+
* rows exist but project-info.json has no `projectId`.
|
|
692
|
+
* @task T12326
|
|
693
|
+
*/
|
|
694
|
+
export async function migrateProjectCredentialsAtRoot(projectRoot, options = {}) {
|
|
695
|
+
const root = path.resolve(projectRoot);
|
|
696
|
+
const cleoDir = path.join(root, '.cleo');
|
|
697
|
+
const projectDbPath = path.join(cleoDir, 'cleo.db');
|
|
698
|
+
let projectId = null;
|
|
699
|
+
try {
|
|
700
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(cleoDir, 'project-info.json'), 'utf-8'));
|
|
701
|
+
if (typeof parsed === 'object' &&
|
|
702
|
+
parsed !== null &&
|
|
703
|
+
'projectId' in parsed &&
|
|
704
|
+
typeof parsed.projectId === 'string' &&
|
|
705
|
+
parsed.projectId.length > 0) {
|
|
706
|
+
projectId = parsed.projectId;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
projectId = null;
|
|
711
|
+
}
|
|
712
|
+
const result = await migrateProjectCredentials({
|
|
713
|
+
projectDbPath,
|
|
714
|
+
...(projectId !== null ? { projectId } : {}),
|
|
715
|
+
legacyProjectPaths: [root, canonicalPath(root)],
|
|
716
|
+
...(options.cleoHome !== undefined ? { cleoHome: options.cleoHome } : {}),
|
|
717
|
+
}, { dryRun: options.dryRun === true });
|
|
718
|
+
return { ...result, projectDbPath, projectId, dryRun: options.dryRun === true };
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Find agents whose stored API key cannot be recovered and, unless `dryRun`,
|
|
722
|
+
* flag them `requires_reauth = 1` (T12352).
|
|
723
|
+
*
|
|
724
|
+
* Before T12352 `api_key_encrypted` held a derived HMAC instead of the key.
|
|
725
|
+
* The real key was discarded, so those rows cannot be migrated, only flagged
|
|
726
|
+
* and listed with the command that re-registers the key. Never modifies the
|
|
727
|
+
* stored value; idempotent.
|
|
728
|
+
*
|
|
729
|
+
* @param globalDbPath - Global `cleo.db`.
|
|
730
|
+
* @param options - `dryRun` to report without flagging; `cleoHome` for the keys.
|
|
731
|
+
* @returns Recoverable agents, agents to re-register, and how many were flagged.
|
|
732
|
+
* @task T12352
|
|
733
|
+
*/
|
|
734
|
+
export async function auditAgentRegistryKeys(globalDbPath, options = {}) {
|
|
735
|
+
const current = [];
|
|
736
|
+
const reentry = [];
|
|
737
|
+
const unrecoverable = [];
|
|
738
|
+
for (const row of readAgentRegistryRows(globalDbPath)) {
|
|
739
|
+
const key = await openAgentApiKey(row.material, row.descriptor.id, {
|
|
740
|
+
...(options.cleoHome !== undefined ? { cleoHome: options.cleoHome } : {}),
|
|
741
|
+
});
|
|
742
|
+
if (key.requiresReauth) {
|
|
743
|
+
reentry.push(reentryFor(row.descriptor, key.reason ?? 'the stored key is not recoverable'));
|
|
744
|
+
unrecoverable.push(row.descriptor.id);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
current.push(row.descriptor);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
let flagged = 0;
|
|
751
|
+
if (options.dryRun !== true && unrecoverable.length > 0) {
|
|
752
|
+
flagged = withDb(globalDbPath, 0, (db) => {
|
|
753
|
+
if (!hasTable(db, 'agent_registry_agents', ['agent_id', 'requires_reauth']))
|
|
754
|
+
return 0;
|
|
755
|
+
const stmt = db.prepare('UPDATE agent_registry_agents SET requires_reauth = 1 WHERE agent_id = ? AND requires_reauth = 0');
|
|
756
|
+
let n = 0;
|
|
757
|
+
for (const id of unrecoverable)
|
|
758
|
+
n += Number(stmt.run(id).changes);
|
|
759
|
+
return n;
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
return { globalDbPath, current, reentry, flagged, dryRun: options.dryRun === true };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The live LLM pool path, re-exported so a bundle writer need not import the
|
|
766
|
+
* LLM module to fill {@link CredentialSources.llmStorePath}.
|
|
767
|
+
*
|
|
768
|
+
* @returns `<cleoHome>/llm-credentials.json`.
|
|
769
|
+
* @task T12326
|
|
770
|
+
*/
|
|
771
|
+
export function liveLlmCredentialStorePath() {
|
|
772
|
+
return credentialsStorePath();
|
|
773
|
+
}
|
|
774
|
+
//# sourceMappingURL=credential-transfer.js.map
|