@openpalm/lib 0.9.9 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +158 -886
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -0,0 +1,185 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import type { ControlPlaneState } from './types.js';
4
+
5
+ export type SecretScope = 'user' | 'system';
6
+ export type SecretKind = 'core' | 'component' | 'custom';
7
+
8
+ export type SecretEntryMetadata = {
9
+ key: string;
10
+ scope: SecretScope;
11
+ kind: SecretKind;
12
+ provider: 'plaintext' | 'pass';
13
+ present: boolean;
14
+ envKey?: string;
15
+ updatedAt?: string;
16
+ };
17
+
18
+ export type IndexedSecretEntry = {
19
+ envKey: string;
20
+ scope: SecretScope;
21
+ kind: Exclude<SecretKind, 'core'>;
22
+ updatedAt: string;
23
+ };
24
+
25
+ type CoreSecretMapping = {
26
+ secretKey: string;
27
+ envKey: string;
28
+ scope: SecretScope;
29
+ };
30
+
31
+ const STATIC_CORE_MAPPINGS: CoreSecretMapping[] = [
32
+ // Core authentication tokens
33
+ { secretKey: 'openpalm/admin-token', envKey: 'OP_ADMIN_TOKEN', scope: 'system' },
34
+ { secretKey: 'openpalm/assistant-token', envKey: 'OP_ASSISTANT_TOKEN', scope: 'system' },
35
+ { secretKey: 'openpalm/memory/auth-token', envKey: 'OP_MEMORY_TOKEN', scope: 'system' },
36
+ { secretKey: 'openpalm/opencode/server-password', envKey: 'OP_OPENCODE_PASSWORD', scope: 'system' },
37
+ // LLM provider API keys
38
+ { secretKey: 'openpalm/openai/api-key', envKey: 'OPENAI_API_KEY', scope: 'user' },
39
+ { secretKey: 'openpalm/anthropic/api-key', envKey: 'ANTHROPIC_API_KEY', scope: 'user' },
40
+ { secretKey: 'openpalm/groq/api-key', envKey: 'GROQ_API_KEY', scope: 'user' },
41
+ { secretKey: 'openpalm/mistral/api-key', envKey: 'MISTRAL_API_KEY', scope: 'user' },
42
+ { secretKey: 'openpalm/google/api-key', envKey: 'GOOGLE_API_KEY', scope: 'user' },
43
+ { secretKey: 'openpalm/together/api-key', envKey: 'TOGETHER_API_KEY', scope: 'user' },
44
+ { secretKey: 'openpalm/deepseek/api-key', envKey: 'DEEPSEEK_API_KEY', scope: 'user' },
45
+ { secretKey: 'openpalm/xai/api-key', envKey: 'XAI_API_KEY', scope: 'user' },
46
+ { secretKey: 'openpalm/huggingface/token', envKey: 'HF_TOKEN', scope: 'user' },
47
+ { secretKey: 'openpalm/mcp/api-key', envKey: 'MCP_API_KEY', scope: 'user' },
48
+ { secretKey: 'openpalm/embedding/api-key', envKey: 'EMBEDDING_API_KEY', scope: 'user' },
49
+ { secretKey: 'openpalm/lmstudio/api-key', envKey: 'LMSTUDIO_API_KEY', scope: 'user' },
50
+ { secretKey: 'openpalm/openviking/api-key', envKey: 'OPENVIKING_API_KEY', scope: 'user' },
51
+ { secretKey: 'openpalm/openviking/vlm-api-key', envKey: 'VLM_API_KEY', scope: 'user' },
52
+ // Channel-specific credentials
53
+ { secretKey: 'openpalm/discord/bot-token', envKey: 'DISCORD_BOT_TOKEN', scope: 'user' },
54
+ { secretKey: 'openpalm/slack/bot-token', envKey: 'SLACK_BOT_TOKEN', scope: 'user' },
55
+ { secretKey: 'openpalm/slack/app-token', envKey: 'SLACK_APP_TOKEN', scope: 'user' },
56
+ { secretKey: 'openpalm/voice/stt-api-key', envKey: 'STT_API_KEY', scope: 'user' },
57
+ { secretKey: 'openpalm/voice/tts-api-key', envKey: 'TTS_API_KEY', scope: 'user' },
58
+ ];
59
+
60
+ // 128 bits of the SHA-256 digest keeps collision risk negligible while
61
+ // leaving enough room for the OP_SECRET_ prefix in env var names.
62
+ const HASH_PREFIX_LENGTH = 32;
63
+
64
+ type SecretIndexFile = {
65
+ entries: Record<string, IndexedSecretEntry>;
66
+ };
67
+
68
+ function secretIndexPath(state: ControlPlaneState): string {
69
+ return `${state.dataDir}/secrets/plaintext-index.json`;
70
+ }
71
+
72
+ function normalizeIndexedKey(key: string): string {
73
+ return key.trim().replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
74
+ }
75
+
76
+ export function sanitizeSecretSegment(input: string): string {
77
+ return input
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9]+/g, '-')
81
+ .replace(/^-+|-+$/g, '')
82
+ .slice(0, 63);
83
+ }
84
+
85
+ export function secretKeyFromComponentField(instanceId: string, fieldName: string): string {
86
+ return `openpalm/component/${sanitizeSecretSegment(instanceId)}/${sanitizeSecretSegment(fieldName)}`;
87
+ }
88
+
89
+ export function classifySecretKey(key: string): SecretKind {
90
+ if (key.startsWith('openpalm/component/')) return 'component';
91
+ if (key.startsWith('openpalm/custom/')) return 'custom';
92
+ return 'core';
93
+ }
94
+
95
+ export function generatePlaintextEnvKey(secretKey: string): string {
96
+ const digest = createHash('sha256').update(secretKey).digest('hex').slice(0, HASH_PREFIX_LENGTH).toUpperCase();
97
+ return `OP_SECRET_${digest}`;
98
+ }
99
+
100
+ export function classifySecretScope(key: string): SecretScope {
101
+ if (key.startsWith('openpalm/component/')) return 'system';
102
+ if (key.startsWith('openpalm/custom/')) return 'user';
103
+ const coreMapping = STATIC_CORE_MAPPINGS.find((m) => m.secretKey === key);
104
+ if (coreMapping) return coreMapping.scope;
105
+ return 'system';
106
+ }
107
+
108
+ export function getCoreSecretMappings(systemEnv: Record<string, string>): CoreSecretMapping[] {
109
+ const dynamicMappings: CoreSecretMapping[] = [];
110
+ for (const envKey of Object.keys(systemEnv)) {
111
+ const match = envKey.match(/^CHANNEL_([A-Z0-9_]+)_SECRET$/);
112
+ if (!match?.[1]) continue;
113
+ dynamicMappings.push({
114
+ secretKey: `openpalm/channel/${match[1].toLowerCase()}/secret`,
115
+ envKey,
116
+ scope: 'system',
117
+ });
118
+ }
119
+ return [...STATIC_CORE_MAPPINGS, ...dynamicMappings];
120
+ }
121
+
122
+ export function findCoreSecretByKey(
123
+ key: string,
124
+ systemEnv: Record<string, string>,
125
+ ): CoreSecretMapping | null {
126
+ return getCoreSecretMappings(systemEnv).find((entry) => entry.secretKey === key) ?? null;
127
+ }
128
+
129
+ export function findCoreSecretByEnvKey(
130
+ envKey: string,
131
+ systemEnv: Record<string, string>,
132
+ ): CoreSecretMapping | null {
133
+ return getCoreSecretMappings(systemEnv).find((entry) => entry.envKey === envKey) ?? null;
134
+ }
135
+
136
+ export function readPlaintextSecretIndex(state: ControlPlaneState): SecretIndexFile {
137
+ const path = secretIndexPath(state);
138
+ if (!existsSync(path)) {
139
+ return { entries: {} };
140
+ }
141
+
142
+ try {
143
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as SecretIndexFile;
144
+ return parsed && typeof parsed === 'object' && parsed.entries ? parsed : { entries: {} };
145
+ } catch {
146
+ return { entries: {} };
147
+ }
148
+ }
149
+
150
+ export function writePlaintextSecretIndex(state: ControlPlaneState, index: SecretIndexFile): void {
151
+ const dir = `${state.dataDir}/secrets`;
152
+ mkdirSync(dir, { recursive: true });
153
+ writeFileSync(secretIndexPath(state), JSON.stringify(index, null, 2) + '\n');
154
+ }
155
+
156
+ export function ensurePlaintextSecretEntry(
157
+ state: ControlPlaneState,
158
+ key: string,
159
+ scope?: SecretScope,
160
+ ): IndexedSecretEntry {
161
+ const normalizedKey = normalizeIndexedKey(key);
162
+ const index = readPlaintextSecretIndex(state);
163
+ const existing = index.entries[normalizedKey];
164
+ if (existing) {
165
+ return existing;
166
+ }
167
+
168
+ const entry: IndexedSecretEntry = {
169
+ envKey: generatePlaintextEnvKey(normalizedKey),
170
+ scope: scope ?? (normalizedKey.startsWith('openpalm/component/') ? 'system' : 'user'),
171
+ kind: classifySecretKey(normalizedKey) === 'component' ? 'component' : 'custom',
172
+ updatedAt: new Date().toISOString(),
173
+ };
174
+ index.entries[normalizedKey] = entry;
175
+ writePlaintextSecretIndex(state, index);
176
+ return entry;
177
+ }
178
+
179
+ export function removePlaintextSecretEntry(state: ControlPlaneState, key: string): void {
180
+ const normalizedKey = normalizeIndexedKey(key);
181
+ const index = readPlaintextSecretIndex(state);
182
+ if (!index.entries[normalizedKey]) return;
183
+ delete index.entries[normalizedKey];
184
+ writePlaintextSecretIndex(state, index);
185
+ }
@@ -1,169 +1,243 @@
1
- /**
2
- * Secrets and connection key management for the OpenPalm control plane.
3
- */
4
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
1
+ /** Secrets and capability key management. */
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync } from "node:fs";
5
3
  import { randomBytes } from "node:crypto";
4
+ import { createLogger } from "../logger.js";
6
5
  import { parseEnvFile, mergeEnvContent } from './env.js';
7
6
  import type { ControlPlaneState } from "./types.js";
8
- import { resolveConfigHome } from "./paths.js";
7
+ import { resolveConfigDir } from "./home.js";
9
8
 
10
9
  const OPENCODE_STARTER_CONFIG = JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n";
10
+ const logger = createLogger("secrets");
11
11
 
12
- // ── Connection Key Management ───────────────────────────────────────────
13
-
14
- export const ALLOWED_CONNECTION_KEYS = new Set([
15
- "OPENAI_API_KEY",
16
- "ANTHROPIC_API_KEY",
17
- "GROQ_API_KEY",
18
- "MISTRAL_API_KEY",
19
- "GOOGLE_API_KEY",
20
- "SYSTEM_LLM_PROVIDER",
21
- "SYSTEM_LLM_BASE_URL",
22
- "SYSTEM_LLM_MODEL",
23
- "OPENAI_BASE_URL",
24
- "EMBEDDING_MODEL",
25
- "EMBEDDING_DIMS",
26
- "MEMORY_USER_ID",
27
- "MEMORY_AUTH_TOKEN",
28
- "OWNER_NAME",
29
- "OWNER_EMAIL",
30
- ]);
31
-
32
- export const REQUIRED_LLM_PROVIDER_KEYS = [
33
- "OPENAI_API_KEY",
34
- "ANTHROPIC_API_KEY",
35
- "GROQ_API_KEY",
36
- "MISTRAL_API_KEY",
37
- "GOOGLE_API_KEY"
38
- ];
39
12
 
40
- /** Keys that are non-secret config returned unmasked in connection responses. */
13
+ /** Keys whose values are shown unmasked in the UI (not secrets). */
41
14
  export const PLAIN_CONFIG_KEYS = new Set([
42
- "SYSTEM_LLM_PROVIDER",
43
- "SYSTEM_LLM_BASE_URL",
44
- "SYSTEM_LLM_MODEL",
45
15
  "OPENAI_BASE_URL",
46
- "EMBEDDING_MODEL",
47
- "EMBEDDING_DIMS",
48
- "MEMORY_USER_ID",
49
16
  "OWNER_NAME",
50
17
  "OWNER_EMAIL",
51
18
  ]);
52
19
 
53
- // ── Secrets Management ──────────────────────────────────────────────────
54
20
 
55
- export function ensureSecrets(state: ControlPlaneState): void {
56
- mkdirSync(state.configDir, { recursive: true });
57
- const secretsPath = `${state.configDir}/secrets.env`;
58
- if (existsSync(secretsPath)) {
21
+ const VAULT_DIR_MODE = 0o700;
22
+ const VAULT_FILE_MODE = 0o600;
23
+
24
+ function enforceVaultDirMode(vaultDir: string): void {
25
+ mkdirSync(vaultDir, { recursive: true, mode: VAULT_DIR_MODE });
26
+ try {
27
+ chmodSync(vaultDir, VAULT_DIR_MODE);
28
+ } catch (error) {
29
+ logger.warn("failed to enforce vault directory permissions", {
30
+ vaultDir,
31
+ error: error instanceof Error ? error.message : String(error),
32
+ });
33
+ }
34
+ }
35
+
36
+ function writeVaultFile(path: string, content: string): void {
37
+ writeFileSync(path, content, { mode: VAULT_FILE_MODE });
38
+ try {
39
+ chmodSync(path, VAULT_FILE_MODE);
40
+ } catch (error) {
41
+ logger.warn("failed to enforce vault file permissions", {
42
+ path,
43
+ error: error instanceof Error ? error.message : String(error),
44
+ });
45
+ }
46
+ }
47
+
48
+ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomment = false): void {
49
+ if (Object.keys(updates).length === 0) return;
50
+ const raw = existsSync(path) ? readFileSync(path, "utf-8") : "";
51
+ let merged = mergeEnvContent(raw, updates, { uncomment });
52
+ if (!merged.endsWith("\n")) merged += "\n";
53
+ writeVaultFile(path, merged);
54
+ }
55
+
56
+ function ensureSystemSecrets(state: ControlPlaneState): void {
57
+ const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
58
+ const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
59
+ const updates: Record<string, string> = {};
60
+
61
+ if (!existing.OP_ADMIN_TOKEN && state.adminToken) {
62
+ updates.OP_ADMIN_TOKEN = state.adminToken;
63
+ }
64
+ if (!existing.OP_ASSISTANT_TOKEN) {
65
+ updates.OP_ASSISTANT_TOKEN = randomBytes(32).toString("hex");
66
+ }
67
+ if (!existing.OP_MEMORY_TOKEN) {
68
+ updates.OP_MEMORY_TOKEN = randomBytes(32).toString("hex");
69
+ }
70
+
71
+ if (!existsSync(systemEnvPath)) {
72
+ const header = [
73
+ "# OpenPalm — Stack Configuration",
74
+ "# All secrets and configuration live here. Advanced users may edit directly.",
75
+ "",
76
+ "# ── Authentication ──────────────────────────────────────────────────",
77
+ "OP_ADMIN_TOKEN=",
78
+ "OP_ASSISTANT_TOKEN=",
79
+ "",
80
+ "# ── Service Auth ─────────────────────────────────────────────────────",
81
+ "OP_MEMORY_TOKEN=",
82
+ "OP_OPENCODE_PASSWORD=",
83
+ "",
84
+ "# ── Provider API Keys ────────────────────────────────────────────────",
85
+ "OPENAI_API_KEY=",
86
+ "OPENAI_BASE_URL=",
87
+ "ANTHROPIC_API_KEY=",
88
+ "GROQ_API_KEY=",
89
+ "MISTRAL_API_KEY=",
90
+ "GOOGLE_API_KEY=",
91
+ "OPENVIKING_API_KEY=",
92
+ "MCP_API_KEY=",
93
+ "EMBEDDING_API_KEY=",
94
+ "LMSTUDIO_API_KEY=",
95
+ "",
96
+ "# ── Owner ────────────────────────────────────────────────────────────",
97
+ `OWNER_NAME=${process.env.OWNER_NAME ?? ""}`,
98
+ `OWNER_EMAIL=${process.env.OWNER_EMAIL ?? ""}`,
99
+ "",
100
+ ].join("\n");
101
+ const content = mergeEnvContent(header, updates);
102
+ writeVaultFile(systemEnvPath, content.endsWith("\n") ? content : content + "\n");
59
103
  return;
60
104
  }
61
105
 
62
- const secretLines: string[] = [];
63
- secretLines.push("# OpenPalm Secrets");
64
- secretLines.push("# Edit this file to update admin token and LLM keys.");
65
- secretLines.push("# System-managed secrets (database + channel HMAC) do not belong here.");
66
- secretLines.push("");
67
- secretLines.push("export OPENPALM_ADMIN_TOKEN=");
68
- secretLines.push("export ADMIN_TOKEN=");
69
- secretLines.push("");
70
- secretLines.push("# LLM provider keys");
71
- secretLines.push("export OPENAI_API_KEY=");
72
- secretLines.push("export OPENAI_BASE_URL=");
73
- secretLines.push("export ANTHROPIC_API_KEY=");
74
- secretLines.push("export GROQ_API_KEY=");
75
- secretLines.push("export MISTRAL_API_KEY=");
76
- secretLines.push("export GOOGLE_API_KEY=");
77
- secretLines.push("");
78
- secretLines.push("# Memory");
79
- secretLines.push(`export MEMORY_USER_ID=${process.env.MEMORY_USER_ID ?? process.env.OPENMEMORY_USER_ID ?? "default_user"}`);
80
- secretLines.push("");
81
- secretLines.push("# Service auth tokens (auto-generated)");
82
- secretLines.push(`export MEMORY_AUTH_TOKEN=${randomBytes(32).toString("hex")}`);
83
- secretLines.push("");
84
- secretLines.push("# Owner");
85
- secretLines.push(`export OWNER_NAME=${process.env.OWNER_NAME ?? ""}`);
86
- secretLines.push(`export OWNER_EMAIL=${process.env.OWNER_EMAIL ?? ""}`);
87
- writeFileSync(secretsPath, secretLines.join("\n") + "\n");
106
+ mergeVaultEnvFile(systemEnvPath, updates, true);
107
+ }
108
+
109
+ export function ensureSecrets(state: ControlPlaneState): void {
110
+ enforceVaultDirMode(state.vaultDir);
111
+ mkdirSync(`${state.vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
112
+ mkdirSync(`${state.vaultDir}/user`, { recursive: true, mode: VAULT_DIR_MODE });
113
+
114
+ // user.env is an empty placeholder — users can add custom vars here.
115
+ // All standard config lives in stack.env.
116
+ const userEnvPath = `${state.vaultDir}/user/user.env`;
117
+ if (!existsSync(userEnvPath)) {
118
+ writeVaultFile(userEnvPath, [
119
+ "# OpenPalm — User Extensions",
120
+ "# Add any custom environment variables here.",
121
+ "# These are loaded by compose alongside stack.env.",
122
+ "",
123
+ ].join("\n"));
124
+ } else {
125
+ try { chmodSync(userEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
126
+ }
127
+
128
+ ensureSystemSecrets(state);
129
+ ensureGuardianEnv(state.vaultDir);
130
+ ensureAuthJson(state.vaultDir);
131
+ }
132
+
133
+ /**
134
+ * Ensure vault/stack/guardian.env exists.
135
+ * Channel HMAC secrets (CHANNEL_<NAME>_SECRET) live here exclusively.
136
+ * This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
137
+ */
138
+ function ensureGuardianEnv(vaultDir: string): void {
139
+ const guardianEnvPath = `${vaultDir}/stack/guardian.env`;
140
+ mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
141
+ if (!existsSync(guardianEnvPath)) {
142
+ writeVaultFile(guardianEnvPath, [
143
+ "# Guardian channel HMAC secrets — managed by openpalm",
144
+ "# Each enabled channel gets a CHANNEL_<NAME>_SECRET entry.",
145
+ "",
146
+ ].join("\n"));
147
+ } else {
148
+ try { chmodSync(guardianEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
149
+ }
150
+ }
151
+
152
+ function ensureAuthJson(vaultDir: string): void {
153
+ const authJsonPath = `${vaultDir}/stack/auth.json`;
154
+ mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
155
+
156
+ if (existsSync(authJsonPath)) {
157
+ try {
158
+ if (lstatSync(authJsonPath).isDirectory()) {
159
+ rmSync(authJsonPath, { recursive: true, force: true });
160
+ } else {
161
+ chmodSync(authJsonPath, VAULT_FILE_MODE);
162
+ return;
163
+ }
164
+ } catch (error) {
165
+ logger.warn("failed to repair auth.json path", {
166
+ path: authJsonPath,
167
+ error: error instanceof Error ? error.message : String(error),
168
+ });
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ writeVaultFile(authJsonPath, "{}\n");
88
174
  }
89
175
 
90
176
  export function updateSecretsEnv(
91
177
  state: ControlPlaneState,
92
178
  updates: Record<string, string>
93
179
  ): void {
94
- const secretsPath = `${state.configDir}/secrets.env`;
95
- if (!existsSync(secretsPath)) {
96
- throw new Error("secrets.env does not exist — run setup first");
180
+ const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
181
+ if (!existsSync(stackEnvPath)) {
182
+ throw new Error("vault/stack/stack.env does not exist — run setup first");
97
183
  }
98
184
 
99
- const raw = readFileSync(secretsPath, "utf-8");
100
- writeFileSync(secretsPath, mergeEnvContent(raw, updates, { uncomment: true }));
185
+ mergeVaultEnvFile(stackEnvPath, updates, true);
186
+ }
187
+
188
+ /** Read and parse vault/stack/stack.env. Returns {} if the file does not exist. */
189
+ export function readStackEnv(vaultDir: string): Record<string, string> {
190
+ return parseEnvFile(`${vaultDir}/stack/stack.env`);
101
191
  }
102
192
 
103
- export function readSecretsEnvFile(configDir: string): Record<string, string> {
104
- const parsed = parseEnvFile(`${configDir}/secrets.env`);
105
- const result: Record<string, string> = {};
106
- for (const [key, value] of Object.entries(parsed)) {
107
- if (ALLOWED_CONNECTION_KEYS.has(key)) result[key] = value;
193
+ export function updateSystemSecretsEnv(
194
+ state: ControlPlaneState,
195
+ updates: Record<string, string>
196
+ ): void {
197
+ const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
198
+ enforceVaultDirMode(state.vaultDir);
199
+ if (!existsSync(systemEnvPath)) {
200
+ ensureSystemSecrets(state);
108
201
  }
109
- return result;
202
+ mergeVaultEnvFile(systemEnvPath, updates, true);
110
203
  }
111
204
 
112
205
  export function patchSecretsEnvFile(
113
- configDir: string,
206
+ vaultDir: string,
114
207
  patches: Record<string, string>
115
208
  ): void {
116
- const allowed: Record<string, string> = {};
117
- for (const [key, value] of Object.entries(patches)) {
118
- if (ALLOWED_CONNECTION_KEYS.has(key)) {
119
- allowed[key] = value;
120
- }
121
- }
122
- if (Object.keys(allowed).length === 0) return;
209
+ if (Object.keys(patches).length === 0) return;
123
210
 
124
- const secretsPath = `${configDir}/secrets.env`;
125
- mkdirSync(configDir, { recursive: true });
211
+ const stackEnvPath = `${vaultDir}/stack/stack.env`;
212
+ enforceVaultDirMode(vaultDir);
213
+ mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
126
214
 
127
215
  let existingContent = "";
128
216
  try {
129
- if (existsSync(secretsPath)) {
130
- existingContent = readFileSync(secretsPath, "utf-8");
217
+ if (existsSync(stackEnvPath)) {
218
+ existingContent = readFileSync(stackEnvPath, "utf-8");
131
219
  }
132
220
  } catch {
133
221
  // start fresh
134
222
  }
135
223
 
136
- let result = mergeEnvContent(existingContent, allowed);
224
+ let result = mergeEnvContent(existingContent, patches);
137
225
  if (!result.endsWith("\n")) result += "\n";
138
- writeFileSync(secretsPath, result);
226
+ writeVaultFile(stackEnvPath, result);
139
227
  }
140
228
 
141
- // ── Connection Value Masking ────────────────────────────────────────────
142
229
 
143
- export function maskConnectionValue(key: string, value: string): string {
230
+ export function maskSecretValue(key: string, value: string): string {
144
231
  if (!value) return "";
145
232
  if (PLAIN_CONFIG_KEYS.has(key)) return value;
146
233
  if (value.length <= 4) return "****";
147
234
  return "*".repeat(value.length - 4) + value.slice(-4);
148
235
  }
149
236
 
150
- // ── Secrets Loading ────────────────────────────────────────────────────
151
-
152
- export function loadSecretsEnvFile(configDir?: string): Record<string, string> {
153
- const base = configDir ?? resolveConfigHome();
154
- const parsed = parseEnvFile(`${base}/secrets.env`);
155
- const result: Record<string, string> = {};
156
- for (const [key, value] of Object.entries(parsed)) {
157
- if (/^[A-Z0-9_]+$/.test(key)) result[key] = value;
158
- }
159
- return result;
160
- }
161
-
162
- // ── OpenCode Config ────────────────────────────────────────────────────
163
237
 
164
238
  export function ensureOpenCodeConfig(): void {
165
- const configHome = resolveConfigHome();
166
- const opencodePath = `${configHome}/assistant`;
239
+ const configDir = resolveConfigDir();
240
+ const opencodePath = `${configDir}/assistant`;
167
241
  mkdirSync(opencodePath, { recursive: true });
168
242
 
169
243
  const configFile = `${opencodePath}/opencode.json`;