@desplega.ai/agent-swarm 1.66.0 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +3 -3
- package/package.json +4 -4
- package/src/be/crypto/index.ts +12 -0
- package/src/be/crypto/key-bootstrap.ts +147 -0
- package/src/be/crypto/secrets-cipher.ts +90 -0
- package/src/be/db.ts +228 -8
- package/src/be/migrations/038_encrypted_secrets.sql +10 -0
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/github/handlers.ts +2 -2
- package/src/gitlab/handlers.ts +1 -1
- package/src/http/config.ts +14 -2
- package/src/http/core.ts +4 -2
- package/src/http/index.ts +31 -15
- package/src/providers/pi-mono-adapter.ts +1 -1
- package/src/slack/handlers.test.ts +34 -1
- package/src/slack/handlers.ts +37 -7
- package/src/tests/codex-oauth-storage.test.ts +1 -1
- package/src/tests/key-bootstrap.test.ts +213 -0
- package/src/tests/mcp-server-resolved-env.test.ts +0 -2
- package/src/tests/memory-store.test.ts +1 -2
- package/src/tests/preload.ts +11 -1
- package/src/tests/reload-config.test.ts +45 -14
- package/src/tests/runner-fallback-output.test.ts +0 -1
- package/src/tests/secrets-cipher.test.ts +132 -0
- package/src/tests/swarm-config-encryption.test.ts +419 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +456 -0
- package/src/tests/swarm-config-schema.test.ts +134 -0
- package/src/tests/task-reactions.test.ts +1 -1
- package/src/tools/swarm-config/delete-config.ts +2 -2
- package/src/tools/swarm-config/set-config.ts +13 -0
- package/src/types.ts +5 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.67.0",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -1038,7 +1038,7 @@
|
|
|
1038
1038
|
}
|
|
1039
1039
|
},
|
|
1040
1040
|
"delete": {
|
|
1041
|
-
"summary": "Delete a config entry",
|
|
1041
|
+
"summary": "Delete a config entry by ID (including legacy reserved rows for cleanup)",
|
|
1042
1042
|
"tags": [
|
|
1043
1043
|
"Config"
|
|
1044
1044
|
],
|
|
@@ -1115,7 +1115,7 @@
|
|
|
1115
1115
|
}
|
|
1116
1116
|
},
|
|
1117
1117
|
"put": {
|
|
1118
|
-
"summary": "Create or update a config entry",
|
|
1118
|
+
"summary": "Create or update a config entry (reserved env-only keys are rejected)",
|
|
1119
1119
|
"tags": [
|
|
1120
1120
|
"Config"
|
|
1121
1121
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.0",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.sh <contact@desplega.sh>",
|
|
@@ -102,9 +102,9 @@
|
|
|
102
102
|
"@desplega.ai/localtunnel": "^2.2.0",
|
|
103
103
|
"@inkjs/ui": "^2.0.0",
|
|
104
104
|
"@linear/sdk": "^77.0.0",
|
|
105
|
-
"@mariozechner/pi-agent-core": "^0.
|
|
106
|
-
"@mariozechner/pi-ai": "^0.
|
|
107
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
105
|
+
"@mariozechner/pi-agent-core": "^0.67.2",
|
|
106
|
+
"@mariozechner/pi-ai": "^0.67.2",
|
|
107
|
+
"@mariozechner/pi-coding-agent": "^0.67.2",
|
|
108
108
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
109
109
|
"@openai/codex-sdk": "^0.118.0",
|
|
110
110
|
"@openfort/openfort-node": "^0.9.1",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AES_KEY_BYTES } from "./secrets-cipher";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Encryption key bootstrap.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order (first match wins):
|
|
10
|
+
* 1. `SECRETS_ENCRYPTION_KEY` env var — base64 string, must decode to 32 bytes
|
|
11
|
+
* 2. `SECRETS_ENCRYPTION_KEY_FILE` env var — path to file containing base64
|
|
12
|
+
* 3. `<dirname(dbPath)>/.encryption-key` — on-disk file
|
|
13
|
+
* 4. Auto-generate 32 random bytes, write to path from step 3 with mode 0600
|
|
14
|
+
* only when generation is allowed for the current DB state
|
|
15
|
+
*
|
|
16
|
+
* Special cases:
|
|
17
|
+
* - `dbPath === ":memory:"`: steps 3-4 are skipped; an explicit env-var key is required.
|
|
18
|
+
* - Malformed key at any source: throw immediately (do NOT fall through to
|
|
19
|
+
* auto-generation, which would silently clobber a broken-but-present key).
|
|
20
|
+
* - Callers can disable auto-generation for existing DBs via `allowGenerate=false`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
let cachedKey: Buffer | null = null;
|
|
24
|
+
|
|
25
|
+
const ENV_KEY = "SECRETS_ENCRYPTION_KEY";
|
|
26
|
+
const ENV_KEY_FILE = "SECRETS_ENCRYPTION_KEY_FILE";
|
|
27
|
+
const KEY_FILENAME = ".encryption-key";
|
|
28
|
+
|
|
29
|
+
function decodeAndValidate(source: string, content: string): Buffer {
|
|
30
|
+
const trimmed = content.trim();
|
|
31
|
+
let decoded: Buffer;
|
|
32
|
+
try {
|
|
33
|
+
decoded = Buffer.from(trimmed, "base64");
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Invalid encryption key at ${source}: base64 decode failed: ${(err as Error).message}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (decoded.length !== AES_KEY_BYTES) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Invalid encryption key at ${source}: expected ${AES_KEY_BYTES} bytes after base64 decode, got ${decoded.length} bytes`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return decoded;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ResolveEncryptionKeyOptions = {
|
|
48
|
+
allowGenerate?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the encryption key according to the order documented above. Idempotent:
|
|
53
|
+
* the first successful call caches the key, and subsequent calls return it without
|
|
54
|
+
* re-reading env vars or disk.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveEncryptionKey(
|
|
57
|
+
dbPath: string,
|
|
58
|
+
options: ResolveEncryptionKeyOptions = {},
|
|
59
|
+
): Buffer {
|
|
60
|
+
if (cachedKey) return cachedKey;
|
|
61
|
+
|
|
62
|
+
const allowGenerate = options.allowGenerate ?? true;
|
|
63
|
+
|
|
64
|
+
// 1. SECRETS_ENCRYPTION_KEY env var
|
|
65
|
+
const envKey = process.env[ENV_KEY];
|
|
66
|
+
if (envKey && envKey.length > 0) {
|
|
67
|
+
cachedKey = decodeAndValidate(`env:${ENV_KEY}`, envKey);
|
|
68
|
+
return cachedKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. SECRETS_ENCRYPTION_KEY_FILE env var
|
|
72
|
+
const envKeyFile = process.env[ENV_KEY_FILE];
|
|
73
|
+
if (envKeyFile && envKeyFile.length > 0) {
|
|
74
|
+
if (!existsSync(envKeyFile)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Invalid encryption key at env:${ENV_KEY_FILE} (${envKeyFile}): file does not exist`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const content = readFileSync(envKeyFile, "utf8");
|
|
80
|
+
cachedKey = decodeAndValidate(`env:${ENV_KEY_FILE} (${envKeyFile})`, content);
|
|
81
|
+
return cachedKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// In-memory databases must not auto-generate a key in CWD — fail fast.
|
|
85
|
+
if (dbPath === ":memory:") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`In-memory database requires ${ENV_KEY} or ${ENV_KEY_FILE} to be set explicitly`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const keyFilePath = path.join(path.dirname(dbPath), KEY_FILENAME);
|
|
92
|
+
|
|
93
|
+
// 3. On-disk .encryption-key file
|
|
94
|
+
if (existsSync(keyFilePath)) {
|
|
95
|
+
const content = readFileSync(keyFilePath, "utf8");
|
|
96
|
+
cachedKey = decodeAndValidate(`file:${keyFilePath}`, content);
|
|
97
|
+
return cachedKey;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!allowGenerate) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Refusing to auto-generate ${KEY_FILENAME} for an existing database with encrypted secret rows. Restore ${ENV_KEY}, ${ENV_KEY_FILE}, or ${keyFilePath} before booting.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Auto-generate. Use `wx` flag for TOCTOU safety — fail if file reappeared
|
|
107
|
+
// between existsSync and write (another process won the race).
|
|
108
|
+
const generated = randomBytes(AES_KEY_BYTES);
|
|
109
|
+
const encoded = generated.toString("base64");
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(keyFilePath, encoded, { flag: "wx", mode: 0o600 });
|
|
112
|
+
console.warn(
|
|
113
|
+
`[secrets] Generated new encryption key at ${keyFilePath}. BACK THIS FILE UP. Losing it means losing all encrypted secrets.`,
|
|
114
|
+
);
|
|
115
|
+
cachedKey = generated;
|
|
116
|
+
return cachedKey;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
119
|
+
if (code === "EEXIST") {
|
|
120
|
+
// Another process won the race — re-read its file.
|
|
121
|
+
const content = readFileSync(keyFilePath, "utf8");
|
|
122
|
+
cachedKey = decodeAndValidate(`file:${keyFilePath}`, content);
|
|
123
|
+
return cachedKey;
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return the cached encryption key. Throws if `resolveEncryptionKey` has not
|
|
131
|
+
* been called yet (or has been reset via the test hook).
|
|
132
|
+
*/
|
|
133
|
+
export function getEncryptionKey(): Buffer {
|
|
134
|
+
if (!cachedKey) {
|
|
135
|
+
throw new Error("Encryption key not resolved yet — call resolveEncryptionKey(dbPath) first");
|
|
136
|
+
}
|
|
137
|
+
return cachedKey;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Test-only: clear the module-level cache so the next `resolveEncryptionKey`
|
|
142
|
+
* call re-reads env vars / disk. Used by integration tests that simulate
|
|
143
|
+
* rotation, missing-key, or wrong-key scenarios.
|
|
144
|
+
*/
|
|
145
|
+
export function __resetEncryptionKeyForTests(): void {
|
|
146
|
+
cachedKey = null;
|
|
147
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AES-256-GCM secrets cipher.
|
|
5
|
+
*
|
|
6
|
+
* Encrypted payload layout (before base64):
|
|
7
|
+
* [ iv (12 bytes) || ciphertext (variable) || auth tag (16 bytes) ]
|
|
8
|
+
*
|
|
9
|
+
* A fresh random IV is generated per `encryptSecret` call, so encrypting the
|
|
10
|
+
* same plaintext twice produces different ciphertexts. Decryption verifies the
|
|
11
|
+
* auth tag and throws on any tampering.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const AES_KEY_BYTES = 32;
|
|
15
|
+
export const IV_BYTES = 12;
|
|
16
|
+
export const TAG_BYTES = 16;
|
|
17
|
+
|
|
18
|
+
const ALGORITHM = "aes-256-gcm" as const;
|
|
19
|
+
|
|
20
|
+
function assertKey(key: Buffer): void {
|
|
21
|
+
if (!Buffer.isBuffer(key) || key.length !== AES_KEY_BYTES) {
|
|
22
|
+
const got = Buffer.isBuffer(key) ? `${key.length} bytes` : typeof key;
|
|
23
|
+
throw new Error(`Invalid encryption key: expected ${AES_KEY_BYTES} bytes, got ${got}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt a UTF-8 plaintext string with AES-256-GCM.
|
|
29
|
+
*
|
|
30
|
+
* @param plaintext - Any UTF-8 string (empty allowed).
|
|
31
|
+
* @param key - 32-byte key buffer.
|
|
32
|
+
* @returns base64-encoded `iv || ciphertext || authTag`.
|
|
33
|
+
*/
|
|
34
|
+
export function encryptSecret(plaintext: string, key: Buffer): string {
|
|
35
|
+
assertKey(key);
|
|
36
|
+
if (typeof plaintext !== "string") {
|
|
37
|
+
throw new Error(`encryptSecret: plaintext must be a string, got ${typeof plaintext}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const iv = randomBytes(IV_BYTES);
|
|
41
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
42
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
43
|
+
const authTag = cipher.getAuthTag();
|
|
44
|
+
if (authTag.length !== TAG_BYTES) {
|
|
45
|
+
// Should never happen with aes-256-gcm, but guard anyway.
|
|
46
|
+
throw new Error(`encryptSecret: unexpected auth tag length ${authTag.length}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Buffer.concat([iv, ciphertext, authTag]).toString("base64");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decrypt a payload produced by `encryptSecret`. Throws on any tampering
|
|
54
|
+
* (invalid length, corrupted ciphertext/iv, or bad auth tag).
|
|
55
|
+
*/
|
|
56
|
+
export function decryptSecret(encoded: string, key: Buffer): string {
|
|
57
|
+
assertKey(key);
|
|
58
|
+
if (typeof encoded !== "string") {
|
|
59
|
+
throw new Error(`decryptSecret: encoded payload must be a string, got ${typeof encoded}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let buf: Buffer;
|
|
63
|
+
try {
|
|
64
|
+
buf = Buffer.from(encoded, "base64");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new Error(`decryptSecret: invalid base64 payload: ${(err as Error).message}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (buf.length < IV_BYTES + TAG_BYTES) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`decryptSecret: payload too short (${buf.length} bytes, need at least ${IV_BYTES + TAG_BYTES})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const iv = buf.subarray(0, IV_BYTES);
|
|
76
|
+
const authTag = buf.subarray(buf.length - TAG_BYTES);
|
|
77
|
+
const ciphertext = buf.subarray(IV_BYTES, buf.length - TAG_BYTES);
|
|
78
|
+
|
|
79
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
80
|
+
decipher.setAuthTag(authTag);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
84
|
+
return plaintext.toString("utf8");
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`decryptSecret: auth tag verification failed or payload corrupted: ${(err as Error).message}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/be/db.ts
CHANGED
|
@@ -57,9 +57,11 @@ import type {
|
|
|
57
57
|
WorkflowVersion,
|
|
58
58
|
} from "../types";
|
|
59
59
|
import { deriveProviderFromKeyType } from "../utils/credentials";
|
|
60
|
+
import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
|
|
60
61
|
import { normalizeDate, normalizeDateRequired } from "./date-utils";
|
|
61
62
|
import { runMigrations } from "./migrations/runner";
|
|
62
63
|
import { seedDefaultTemplates } from "./seed";
|
|
64
|
+
import { isReservedConfigKey, reservedKeyError } from "./swarm-config-guard";
|
|
63
65
|
|
|
64
66
|
let db: Database | null = null;
|
|
65
67
|
let sqliteVecAvailable = false;
|
|
@@ -76,11 +78,18 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
|
|
|
76
78
|
// Fast path for tests: restore from pre-built template that already has
|
|
77
79
|
// migrations, seeds, and all post-init work baked in. Only the per-connection
|
|
78
80
|
// PRAGMA and the in-memory resolver function need to be set.
|
|
79
|
-
const
|
|
81
|
+
const templateGlobals = globalThis as typeof globalThis & {
|
|
82
|
+
__testMigrationTemplate?: Uint8Array;
|
|
83
|
+
};
|
|
84
|
+
const templateBytes = templateGlobals.__testMigrationTemplate;
|
|
80
85
|
if (templateBytes) {
|
|
81
86
|
db = Database.deserialize(templateBytes);
|
|
82
87
|
db.run("PRAGMA foreign_keys = ON;");
|
|
83
88
|
configureDbResolver(resolvePromptTemplate);
|
|
89
|
+
// Ensure the encryption key is resolved even when restoring from the test
|
|
90
|
+
// template. The cache may have been cleared via __resetEncryptionKeyForTests
|
|
91
|
+
// between test suites; this call is a no-op if the cache is already warm.
|
|
92
|
+
resolveEncryptionKey(dbPath);
|
|
84
93
|
return db;
|
|
85
94
|
}
|
|
86
95
|
|
|
@@ -234,6 +243,37 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
|
|
|
234
243
|
// Seed default prompt templates from the in-memory code registry
|
|
235
244
|
seedDefaultTemplates();
|
|
236
245
|
|
|
246
|
+
const hasExistingEncryptedSecrets =
|
|
247
|
+
(database
|
|
248
|
+
.prepare<{ present: number }, []>(
|
|
249
|
+
"SELECT EXISTS(SELECT 1 FROM swarm_config WHERE isSecret = 1 AND encrypted = 1) as present",
|
|
250
|
+
)
|
|
251
|
+
.get()?.present ?? 0) === 1;
|
|
252
|
+
|
|
253
|
+
// Track whether user provided the key (for backup decision)
|
|
254
|
+
const userProvidedKey = !!(
|
|
255
|
+
process.env.SECRETS_ENCRYPTION_KEY?.length || process.env.SECRETS_ENCRYPTION_KEY_FILE?.length
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Resolve the secrets encryption key after migrations so we can tell whether
|
|
259
|
+
// this DB already contains encrypted secret rows (must reuse an explicit or
|
|
260
|
+
// on-disk key) or is still plaintext-only (safe to generate a new key before
|
|
261
|
+
// auto-migrating legacy plaintext rows).
|
|
262
|
+
resolveEncryptionKey(dbPath, { allowGenerate: !hasExistingEncryptedSecrets });
|
|
263
|
+
|
|
264
|
+
// Auto-encrypt any legacy plaintext secrets that predate the encryption
|
|
265
|
+
// feature. Runs after all compatibility guards; failures are fatal because
|
|
266
|
+
// continuing would leave secrets at rest in plaintext — the opposite of the
|
|
267
|
+
// guarantee this feature provides.
|
|
268
|
+
try {
|
|
269
|
+
autoEncryptLegacyPlaintextSecrets(database, dbPath, { createBackup: !userProvidedKey });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(
|
|
272
|
+
`[secrets] FATAL: failed to auto-encrypt legacy secrets: ${(err as Error).message}`,
|
|
273
|
+
);
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
|
|
237
277
|
return db;
|
|
238
278
|
}
|
|
239
279
|
|
|
@@ -4315,23 +4355,128 @@ type SwarmConfigRow = {
|
|
|
4315
4355
|
description: string | null;
|
|
4316
4356
|
createdAt: string;
|
|
4317
4357
|
lastUpdatedAt: string;
|
|
4358
|
+
encrypted: number; // SQLite boolean: 0 = plaintext, 1 = AES-256-GCM ciphertext
|
|
4318
4359
|
};
|
|
4319
4360
|
|
|
4361
|
+
type SwarmConfigLookupRow = {
|
|
4362
|
+
id: string;
|
|
4363
|
+
scope: string;
|
|
4364
|
+
scopeId: string | null;
|
|
4365
|
+
key: string;
|
|
4366
|
+
isSecret: number;
|
|
4367
|
+
encrypted: number;
|
|
4368
|
+
};
|
|
4369
|
+
|
|
4370
|
+
const RESERVED_CONFIG_PLACEHOLDER = "[reserved key stored in swarm_config; delete this row]";
|
|
4371
|
+
|
|
4320
4372
|
function rowToSwarmConfig(row: SwarmConfigRow): SwarmConfig {
|
|
4373
|
+
const isEncrypted = row.encrypted === 1;
|
|
4374
|
+
if (isReservedConfigKey(row.key)) {
|
|
4375
|
+
return {
|
|
4376
|
+
id: row.id,
|
|
4377
|
+
scope: row.scope as "global" | "agent" | "repo",
|
|
4378
|
+
scopeId: row.scopeId ?? null,
|
|
4379
|
+
key: row.key,
|
|
4380
|
+
value: RESERVED_CONFIG_PLACEHOLDER,
|
|
4381
|
+
isSecret: row.isSecret === 1,
|
|
4382
|
+
envPath: row.envPath ?? null,
|
|
4383
|
+
description: row.description ?? null,
|
|
4384
|
+
createdAt: row.createdAt,
|
|
4385
|
+
lastUpdatedAt: row.lastUpdatedAt,
|
|
4386
|
+
encrypted: isEncrypted,
|
|
4387
|
+
};
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
let value = row.value;
|
|
4391
|
+
if (isEncrypted) {
|
|
4392
|
+
try {
|
|
4393
|
+
value = decryptSecret(row.value, getEncryptionKey());
|
|
4394
|
+
} catch (err) {
|
|
4395
|
+
throw new Error(
|
|
4396
|
+
`Failed to decrypt config '${row.key}' (id=${row.id}): check SECRETS_ENCRYPTION_KEY matches the key used at encryption time`,
|
|
4397
|
+
{ cause: err },
|
|
4398
|
+
);
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4321
4401
|
return {
|
|
4322
4402
|
id: row.id,
|
|
4323
4403
|
scope: row.scope as "global" | "agent" | "repo",
|
|
4324
4404
|
scopeId: row.scopeId ?? null,
|
|
4325
4405
|
key: row.key,
|
|
4326
|
-
value
|
|
4406
|
+
value,
|
|
4327
4407
|
isSecret: row.isSecret === 1,
|
|
4328
4408
|
envPath: row.envPath ?? null,
|
|
4329
4409
|
description: row.description ?? null,
|
|
4330
4410
|
createdAt: row.createdAt,
|
|
4331
4411
|
lastUpdatedAt: row.lastUpdatedAt,
|
|
4412
|
+
encrypted: isEncrypted,
|
|
4332
4413
|
};
|
|
4333
4414
|
}
|
|
4334
4415
|
|
|
4416
|
+
/**
|
|
4417
|
+
* Scan swarm_config for any rows flagged `isSecret = 1` whose `encrypted`
|
|
4418
|
+
* column is still 0 (plaintext), encrypt them in a single transaction, and
|
|
4419
|
+
* flip the flag. Called exactly once during `initDb` on the main path — never
|
|
4420
|
+
* on the test template fast-path.
|
|
4421
|
+
*
|
|
4422
|
+
* Exported for tests so they can simulate a pre-existing legacy row without
|
|
4423
|
+
* needing to replay a full boot.
|
|
4424
|
+
*/
|
|
4425
|
+
export function autoEncryptLegacyPlaintextSecrets(
|
|
4426
|
+
database: Database,
|
|
4427
|
+
dbPath: string,
|
|
4428
|
+
options: { createBackup?: boolean } = {},
|
|
4429
|
+
): void {
|
|
4430
|
+
const rows = database
|
|
4431
|
+
.prepare<{ id: string; key: string; value: string }, []>(
|
|
4432
|
+
"SELECT id, key, value FROM swarm_config WHERE isSecret = 1 AND encrypted = 0",
|
|
4433
|
+
)
|
|
4434
|
+
.all();
|
|
4435
|
+
if (rows.length === 0) return;
|
|
4436
|
+
|
|
4437
|
+
const key = getEncryptionKey();
|
|
4438
|
+
|
|
4439
|
+
// Create plaintext backup if key was auto-generated (not user-provided)
|
|
4440
|
+
if (options.createBackup) {
|
|
4441
|
+
const { writeFileSync } = require("node:fs");
|
|
4442
|
+
const backupPath = `${dbPath}.backup.secrets-${new Date().toISOString().split("T")[0]}.env`;
|
|
4443
|
+
const backupLines = [
|
|
4444
|
+
"# PLAINTEXT SECRET BACKUP - CREATED DURING AUTO-ENCRYPTION MIGRATION",
|
|
4445
|
+
"# This file was created because you did not provide SECRETS_ENCRYPTION_KEY",
|
|
4446
|
+
"# DELETE THIS FILE after verifying your encryption key is safely backed up",
|
|
4447
|
+
"#",
|
|
4448
|
+
"# Encryption key location:",
|
|
4449
|
+
"# - Check: <data-dir>/.encryption-key",
|
|
4450
|
+
"# - Or set: SECRETS_ENCRYPTION_KEY=<base64-key>",
|
|
4451
|
+
"",
|
|
4452
|
+
...rows.map((r) => `${r.key}=${r.value}`),
|
|
4453
|
+
"",
|
|
4454
|
+
].join("\n");
|
|
4455
|
+
|
|
4456
|
+
try {
|
|
4457
|
+
writeFileSync(backupPath, backupLines, { mode: 0o600 });
|
|
4458
|
+
console.warn(`[secrets] Created plaintext backup: ${backupPath}`);
|
|
4459
|
+
console.warn(`[secrets] DELETE THIS FILE after verifying your encryption key is backed up!`);
|
|
4460
|
+
} catch (err) {
|
|
4461
|
+
console.error(`[secrets] Failed to create backup file: ${(err as Error).message}`);
|
|
4462
|
+
// Continue with encryption even if backup fails - the secrets are still in DB
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
console.log(`[secrets] Encrypting ${rows.length} legacy plaintext secret(s)...`);
|
|
4467
|
+
|
|
4468
|
+
const txn = database.transaction((items: { id: string; value: string }[]) => {
|
|
4469
|
+
const stmt = database.prepare<unknown, [string, string]>(
|
|
4470
|
+
"UPDATE swarm_config SET value = ?, encrypted = 1 WHERE id = ?",
|
|
4471
|
+
);
|
|
4472
|
+
for (const r of items) {
|
|
4473
|
+
stmt.run(encryptSecret(r.value, key), r.id);
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
txn(rows);
|
|
4477
|
+
console.log(`[secrets] Auto-migrated ${rows.length} secret(s) to encrypted storage.`);
|
|
4478
|
+
}
|
|
4479
|
+
|
|
4335
4480
|
/**
|
|
4336
4481
|
* Mask secret values in config entries for API responses.
|
|
4337
4482
|
*/
|
|
@@ -4412,6 +4557,23 @@ export function getSwarmConfigs(filters?: {
|
|
|
4412
4557
|
.map(rowToSwarmConfig);
|
|
4413
4558
|
}
|
|
4414
4559
|
|
|
4560
|
+
/**
|
|
4561
|
+
* Global configs that are allowed to flow into process.env.
|
|
4562
|
+
* Reserved env-only keys are filtered in SQL before decryption so a corrupted
|
|
4563
|
+
* legacy reserved row cannot block startup or reload.
|
|
4564
|
+
*/
|
|
4565
|
+
export function getInjectableGlobalConfigs(): SwarmConfig[] {
|
|
4566
|
+
return getDb()
|
|
4567
|
+
.prepare<SwarmConfigRow, []>(
|
|
4568
|
+
`SELECT * FROM swarm_config
|
|
4569
|
+
WHERE scope = 'global'
|
|
4570
|
+
AND UPPER(key) NOT IN ('API_KEY', 'SECRETS_ENCRYPTION_KEY')
|
|
4571
|
+
ORDER BY key ASC`,
|
|
4572
|
+
)
|
|
4573
|
+
.all()
|
|
4574
|
+
.map(rowToSwarmConfig);
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4415
4577
|
/**
|
|
4416
4578
|
* Get a single config entry by ID.
|
|
4417
4579
|
*/
|
|
@@ -4422,6 +4584,34 @@ export function getSwarmConfigById(id: string): SwarmConfig | null {
|
|
|
4422
4584
|
return row ? rowToSwarmConfig(row) : null;
|
|
4423
4585
|
}
|
|
4424
4586
|
|
|
4587
|
+
/**
|
|
4588
|
+
* Get config metadata by ID without decrypting the value. Used by cleanup
|
|
4589
|
+
* paths so unreadable secret rows can still be inspected and removed.
|
|
4590
|
+
*/
|
|
4591
|
+
export function getSwarmConfigLookupById(id: string): {
|
|
4592
|
+
id: string;
|
|
4593
|
+
scope: "global" | "agent" | "repo";
|
|
4594
|
+
scopeId: string | null;
|
|
4595
|
+
key: string;
|
|
4596
|
+
isSecret: boolean;
|
|
4597
|
+
encrypted: boolean;
|
|
4598
|
+
} | null {
|
|
4599
|
+
const row = getDb()
|
|
4600
|
+
.prepare<SwarmConfigLookupRow, [string]>(
|
|
4601
|
+
"SELECT id, scope, scopeId, key, isSecret, encrypted FROM swarm_config WHERE id = ?",
|
|
4602
|
+
)
|
|
4603
|
+
.get(id);
|
|
4604
|
+
if (!row) return null;
|
|
4605
|
+
return {
|
|
4606
|
+
id: row.id,
|
|
4607
|
+
scope: row.scope as "global" | "agent" | "repo",
|
|
4608
|
+
scopeId: row.scopeId ?? null,
|
|
4609
|
+
key: row.key,
|
|
4610
|
+
isSecret: row.isSecret === 1,
|
|
4611
|
+
encrypted: row.encrypted === 1,
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4425
4615
|
/**
|
|
4426
4616
|
* Upsert a config entry. Inserts or updates by (scope, scopeId, key) unique constraint.
|
|
4427
4617
|
*/
|
|
@@ -4434,12 +4624,21 @@ export function upsertSwarmConfig(data: {
|
|
|
4434
4624
|
envPath?: string | null;
|
|
4435
4625
|
description?: string | null;
|
|
4436
4626
|
}): SwarmConfig {
|
|
4627
|
+
if (isReservedConfigKey(data.key)) {
|
|
4628
|
+
throw reservedKeyError(data.key);
|
|
4629
|
+
}
|
|
4630
|
+
|
|
4437
4631
|
const now = new Date().toISOString();
|
|
4438
4632
|
const scopeId = data.scope === "global" ? null : (data.scopeId ?? null);
|
|
4439
4633
|
const isSecret = data.isSecret ? 1 : 0;
|
|
4440
4634
|
const envPath = data.envPath ?? null;
|
|
4441
4635
|
const description = data.description ?? null;
|
|
4442
4636
|
|
|
4637
|
+
// Encrypt secret values at rest. Non-secret values are stored verbatim so
|
|
4638
|
+
// they remain queryable and diffable. rowToSwarmConfig reverses this on read.
|
|
4639
|
+
const storedValue = data.isSecret ? encryptSecret(data.value, getEncryptionKey()) : data.value;
|
|
4640
|
+
const encryptedFlag: number = data.isSecret ? 1 : 0;
|
|
4641
|
+
|
|
4443
4642
|
// Manual check for existing entry because SQLite's UNIQUE constraint
|
|
4444
4643
|
// treats NULL != NULL, so ON CONFLICT never fires when scopeId is NULL (global scope).
|
|
4445
4644
|
const existing =
|
|
@@ -4459,11 +4658,14 @@ export function upsertSwarmConfig(data: {
|
|
|
4459
4658
|
|
|
4460
4659
|
if (existing) {
|
|
4461
4660
|
row = getDb()
|
|
4462
|
-
.prepare<
|
|
4463
|
-
|
|
4661
|
+
.prepare<
|
|
4662
|
+
SwarmConfigRow,
|
|
4663
|
+
[string, number, string | null, string | null, number, string, string]
|
|
4664
|
+
>(
|
|
4665
|
+
`UPDATE swarm_config SET value = ?, isSecret = ?, envPath = ?, description = ?, encrypted = ?, lastUpdatedAt = ?
|
|
4464
4666
|
WHERE id = ? RETURNING *`,
|
|
4465
4667
|
)
|
|
4466
|
-
.get(
|
|
4668
|
+
.get(storedValue, isSecret, envPath, description, encryptedFlag, now, existing.id);
|
|
4467
4669
|
} else {
|
|
4468
4670
|
const id = crypto.randomUUID();
|
|
4469
4671
|
row = getDb()
|
|
@@ -4480,16 +4682,31 @@ export function upsertSwarmConfig(data: {
|
|
|
4480
4682
|
string | null,
|
|
4481
4683
|
string,
|
|
4482
4684
|
string,
|
|
4685
|
+
number,
|
|
4483
4686
|
]
|
|
4484
4687
|
>(
|
|
4485
|
-
`INSERT INTO swarm_config (id, scope, scopeId, key, value, isSecret, envPath, description, createdAt, lastUpdatedAt)
|
|
4486
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
4688
|
+
`INSERT INTO swarm_config (id, scope, scopeId, key, value, isSecret, envPath, description, createdAt, lastUpdatedAt, encrypted)
|
|
4689
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
4487
4690
|
)
|
|
4488
|
-
.get(
|
|
4691
|
+
.get(
|
|
4692
|
+
id,
|
|
4693
|
+
data.scope,
|
|
4694
|
+
scopeId,
|
|
4695
|
+
data.key,
|
|
4696
|
+
storedValue,
|
|
4697
|
+
isSecret,
|
|
4698
|
+
envPath,
|
|
4699
|
+
description,
|
|
4700
|
+
now,
|
|
4701
|
+
now,
|
|
4702
|
+
encryptedFlag,
|
|
4703
|
+
);
|
|
4489
4704
|
}
|
|
4490
4705
|
|
|
4491
4706
|
if (!row) throw new Error("Failed to upsert swarm config");
|
|
4492
4707
|
|
|
4708
|
+
// rowToSwarmConfig transparently decrypts `storedValue` back to plaintext so
|
|
4709
|
+
// the returned object (and downstream writeEnvFile) sees the original value.
|
|
4493
4710
|
const config = rowToSwarmConfig(row);
|
|
4494
4711
|
|
|
4495
4712
|
// Write to envPath if set
|
|
@@ -4506,6 +4723,9 @@ export function upsertSwarmConfig(data: {
|
|
|
4506
4723
|
|
|
4507
4724
|
/**
|
|
4508
4725
|
* Delete a config entry by ID.
|
|
4726
|
+
*
|
|
4727
|
+
* Intentionally does not decrypt or block reserved keys. Legacy rows that
|
|
4728
|
+
* predate hardening must remain removable through remediation paths.
|
|
4509
4729
|
*/
|
|
4510
4730
|
export function deleteSwarmConfig(id: string): boolean {
|
|
4511
4731
|
const result = getDb().run("DELETE FROM swarm_config WHERE id = ?", [id]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Add encryption flag to swarm_config
|
|
2
|
+
-- Values with encrypted=1 are stored as base64(iv || ciphertext || authTag) AES-256-GCM.
|
|
3
|
+
-- Legacy plaintext rows default to encrypted=0 and will be auto-migrated on next boot
|
|
4
|
+
-- by initDb() before normal config reads occur.
|
|
5
|
+
|
|
6
|
+
ALTER TABLE swarm_config ADD COLUMN encrypted INTEGER NOT NULL DEFAULT 0;
|
|
7
|
+
|
|
8
|
+
-- The column defaults to 0 so pre-existing rows altered by this migration stay
|
|
9
|
+
-- marked as plaintext until initDb() auto-encrypts legacy isSecret=1 rows.
|
|
10
|
+
-- From then on, the write path will set encrypted=1 explicitly for isSecret=1 rows.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards against storing reserved keys in the swarm_config table.
|
|
3
|
+
*
|
|
4
|
+
* `API_KEY` and `SECRETS_ENCRYPTION_KEY` must live only in the process
|
|
5
|
+
* environment (or a .env file) — persisting them in swarm_config would
|
|
6
|
+
* create a chicken-and-egg problem:
|
|
7
|
+
* - `API_KEY` controls access to the HTTP API that reads swarm_config.
|
|
8
|
+
* - `SECRETS_ENCRYPTION_KEY` is required to decrypt secrets stored in
|
|
9
|
+
* swarm_config, so it cannot itself be stored encrypted there.
|
|
10
|
+
*
|
|
11
|
+
* Matching is case-insensitive so `api_key`, `Api_Key`, etc. are all
|
|
12
|
+
* rejected at every write path (DB helpers, HTTP routes, MCP tools).
|
|
13
|
+
*/
|
|
14
|
+
const RESERVED_KEYS = new Set(["API_KEY", "SECRETS_ENCRYPTION_KEY"]);
|
|
15
|
+
|
|
16
|
+
export function isReservedConfigKey(key: string): boolean {
|
|
17
|
+
return RESERVED_KEYS.has(key.toUpperCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reservedKeyError(key: string): Error {
|
|
21
|
+
return new Error(
|
|
22
|
+
`Key '${key}' is reserved and cannot be stored in swarm_config. ` +
|
|
23
|
+
`Set it as an environment variable instead.`,
|
|
24
|
+
);
|
|
25
|
+
}
|