@openpalm/lib 0.9.8 → 0.10.1
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/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- 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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
95
|
-
if (!existsSync(
|
|
96
|
-
throw new Error("
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
202
|
+
mergeVaultEnvFile(systemEnvPath, updates, true);
|
|
110
203
|
}
|
|
111
204
|
|
|
112
205
|
export function patchSecretsEnvFile(
|
|
113
|
-
|
|
206
|
+
vaultDir: string,
|
|
114
207
|
patches: Record<string, string>
|
|
115
208
|
): void {
|
|
116
|
-
|
|
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
|
|
125
|
-
|
|
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(
|
|
130
|
-
existingContent = readFileSync(
|
|
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,
|
|
224
|
+
let result = mergeEnvContent(existingContent, patches);
|
|
137
225
|
if (!result.endsWith("\n")) result += "\n";
|
|
138
|
-
|
|
226
|
+
writeVaultFile(stackEnvPath, result);
|
|
139
227
|
}
|
|
140
228
|
|
|
141
|
-
// ── Connection Value Masking ────────────────────────────────────────────
|
|
142
229
|
|
|
143
|
-
export function
|
|
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
|
|
166
|
-
const opencodePath = `${
|
|
239
|
+
const configDir = resolveConfigDir();
|
|
240
|
+
const opencodePath = `${configDir}/assistant`;
|
|
167
241
|
mkdirSync(opencodePath, { recursive: true });
|
|
168
242
|
|
|
169
243
|
const configFile = `${opencodePath}/opencode.json`;
|