@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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.66.0",
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.66.0",
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.57.1",
106
- "@mariozechner/pi-ai": "^0.57.1",
107
- "@mariozechner/pi-coding-agent": "^0.57.1",
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,12 @@
1
+ export {
2
+ __resetEncryptionKeyForTests,
3
+ getEncryptionKey,
4
+ resolveEncryptionKey,
5
+ } from "./key-bootstrap";
6
+ export {
7
+ AES_KEY_BYTES,
8
+ decryptSecret,
9
+ encryptSecret,
10
+ IV_BYTES,
11
+ TAG_BYTES,
12
+ } from "./secrets-cipher";
@@ -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 templateBytes = (globalThis as any).__testMigrationTemplate as Uint8Array | undefined;
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: row.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<SwarmConfigRow, [string, number, string | null, string | null, string, string]>(
4463
- `UPDATE swarm_config SET value = ?, isSecret = ?, envPath = ?, description = ?, lastUpdatedAt = ?
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(data.value, isSecret, envPath, description, now, existing.id);
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(id, data.scope, scopeId, data.key, data.value, isSecret, envPath, description, now, now);
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
+ }