@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14
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 +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +42 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** Secrets and capability key management. */
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync, renameSync } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
4
3
|
import { createLogger } from "../logger.js";
|
|
5
4
|
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
6
|
-
import { migrateAuth0110 } from './migrate-0110.js';
|
|
7
5
|
import type { ControlPlaneState } from "./types.js";
|
|
8
6
|
import { resolveConfigDir } from "./home.js";
|
|
7
|
+
import { authJsonPath as resolveAuthJsonPath, stackEnvPathFromStackDir } from "./paths.js";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { listSecretNames, readSecret, resolveSecretsDir, writeSecret } from './secrets-files.js';
|
|
9
10
|
|
|
10
11
|
const OPENCODE_STARTER_CONFIG = JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n";
|
|
11
12
|
const logger = createLogger("secrets");
|
|
@@ -22,6 +23,22 @@ export const PLAIN_CONFIG_KEYS = new Set([
|
|
|
22
23
|
const VAULT_DIR_MODE = 0o700;
|
|
23
24
|
const VAULT_FILE_MODE = 0o600;
|
|
24
25
|
|
|
26
|
+
export const SECRET_ENV_KEY_RE = /(?:^OP_UI_LOGIN_PASSWORD$|^OP_OPENCODE_PASSWORD$|_API_KEY$|_TOKEN$|_SECRET$|_PASSWORD$)/;
|
|
27
|
+
const SECRET_LIKE_STACK_ENV_KEY_RE = /(SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CLIENT_SECRET|AUTH_JSON|CREDENTIALS)/;
|
|
28
|
+
const NON_SECRET_STACK_ENV_KEY_ALLOWLIST = new Set<string>();
|
|
29
|
+
|
|
30
|
+
export function isSecretLikeStackEnvKey(key: string): boolean {
|
|
31
|
+
return SECRET_LIKE_STACK_ENV_KEY_RE.test(key) && !NON_SECRET_STACK_ENV_KEY_ALLOWLIST.has(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertNoSecretLikeStackEnvKeys(updates: Record<string, string>): void {
|
|
35
|
+
for (const key of Object.keys(updates)) {
|
|
36
|
+
if (isSecretLikeStackEnvKey(key)) {
|
|
37
|
+
throw new Error(`Refusing to write secret-like key to stack.env: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
function enforceVaultDirMode(vaultDir: string): void {
|
|
26
43
|
mkdirSync(vaultDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
27
44
|
try {
|
|
@@ -46,8 +63,39 @@ function writeVaultFile(path: string, content: string): void {
|
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
66
|
+
export function stackSecretsDir(stackDir: string): string {
|
|
67
|
+
return resolveSecretsDir(stackDir);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stackSecretPath(stackDir: string, envKey: string): string {
|
|
71
|
+
return `${stackSecretsDir(stackDir)}/${envKey.toLowerCase()}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readStackSecretEnv(stackDir: string): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const name of listSecretNames(stackDir)) {
|
|
77
|
+
const envKey = name.toUpperCase();
|
|
78
|
+
try {
|
|
79
|
+
out[envKey] = (readSecret(stackDir, name) ?? '').replace(/[\r\n]+$/, '');
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore unreadable secret files; callers treat missing values as absent
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeStackSecretEnv(state: ControlPlaneState, updates: Record<string, string>): void {
|
|
88
|
+
if (Object.keys(updates).length === 0) return;
|
|
89
|
+
resolveSecretsDir(state.stackDir);
|
|
90
|
+
for (const [envKey, value] of Object.entries(updates)) {
|
|
91
|
+
if (!/^[A-Z0-9_]+$/.test(envKey)) throw new Error(`Invalid secret env key: ${envKey}`);
|
|
92
|
+
writeSecret(state.stackDir, envKey.toLowerCase(), value.endsWith('\n') ? value : `${value}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
49
96
|
function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomment = false): void {
|
|
50
97
|
if (Object.keys(updates).length === 0) return;
|
|
98
|
+
assertNoSecretLikeStackEnvKeys(updates);
|
|
51
99
|
const raw = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
52
100
|
let merged = mergeEnvContent(raw, updates, { uncomment });
|
|
53
101
|
if (!merged.endsWith("\n")) merged += "\n";
|
|
@@ -55,88 +103,45 @@ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomm
|
|
|
55
103
|
}
|
|
56
104
|
|
|
57
105
|
function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
58
|
-
const systemEnvPath = `${state.
|
|
59
|
-
|
|
106
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
107
|
+
enforceVaultDirMode(dirname(systemEnvPath));
|
|
60
108
|
const updates: Record<string, string> = {};
|
|
61
109
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
updates.
|
|
110
|
+
// Bootstrap only explicit host-provided overrides. Setup is allowed to be
|
|
111
|
+
// genuinely unconfigured until the wizard/CLI writes the chosen password.
|
|
112
|
+
if (process.env.OP_UI_LOGIN_PASSWORD) {
|
|
113
|
+
updates.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
|
|
114
|
+
}
|
|
115
|
+
if (process.env.OP_OPENCODE_PASSWORD) {
|
|
116
|
+
updates.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
|
|
69
117
|
}
|
|
70
118
|
|
|
119
|
+
writeStackSecretEnv(state, updates);
|
|
120
|
+
|
|
71
121
|
if (!existsSync(systemEnvPath)) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"",
|
|
79
|
-
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
80
|
-
"OP_OPENCODE_PASSWORD=",
|
|
81
|
-
"",
|
|
82
|
-
"# ── Provider API Keys ────────────────────────────────────────────────",
|
|
83
|
-
"OPENAI_API_KEY=",
|
|
84
|
-
"OPENAI_BASE_URL=",
|
|
85
|
-
"ANTHROPIC_API_KEY=",
|
|
86
|
-
"GROQ_API_KEY=",
|
|
87
|
-
"MISTRAL_API_KEY=",
|
|
88
|
-
"GOOGLE_API_KEY=",
|
|
89
|
-
"MCP_API_KEY=",
|
|
90
|
-
"EMBEDDING_API_KEY=",
|
|
91
|
-
"LMSTUDIO_API_KEY=",
|
|
92
|
-
"",
|
|
93
|
-
"# ── Owner ────────────────────────────────────────────────────────────",
|
|
94
|
-
`OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
|
|
95
|
-
`OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
|
|
122
|
+
const header = [
|
|
123
|
+
"# OpenPalm — Stack Configuration",
|
|
124
|
+
"# Non-secret stack configuration only. File-based secrets live in knowledge/secrets/.",
|
|
125
|
+
"",
|
|
126
|
+
"# ── Authentication ──────────────────────────────────────────────────",
|
|
127
|
+
"OP_SETUP_COMPLETE=false",
|
|
96
128
|
"",
|
|
97
129
|
].join("\n");
|
|
98
|
-
|
|
99
|
-
writeVaultFile(systemEnvPath, content.endsWith("\n") ? content : content + "\n");
|
|
130
|
+
writeVaultFile(systemEnvPath, header.endsWith("\n") ? header : header + "\n");
|
|
100
131
|
return;
|
|
101
132
|
}
|
|
102
|
-
|
|
103
|
-
mergeVaultEnvFile(systemEnvPath, updates, true);
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
export function ensureSecrets(state: ControlPlaneState): void {
|
|
107
136
|
enforceVaultDirMode(state.stackDir);
|
|
108
137
|
|
|
109
|
-
// Migrate pre-0.11.0 installs (OP_UI_TOKEN/OP_ASSISTANT_TOKEN → OP_UI_LOGIN_PASSWORD)
|
|
110
|
-
// before any code path that reads OP_UI_LOGIN_PASSWORD sees an empty value.
|
|
111
|
-
migrateAuth0110(state);
|
|
112
|
-
|
|
113
138
|
ensureSystemSecrets(state);
|
|
114
|
-
|
|
115
|
-
ensureAuthJson(state.configDir);
|
|
139
|
+
ensureAuthJson(state);
|
|
116
140
|
}
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
* This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
|
|
122
|
-
*/
|
|
123
|
-
function ensureGuardianEnv(stackDir: string): void {
|
|
124
|
-
const guardianEnvPath = `${stackDir}/guardian.env`;
|
|
125
|
-
mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
126
|
-
if (!existsSync(guardianEnvPath)) {
|
|
127
|
-
writeVaultFile(guardianEnvPath, [
|
|
128
|
-
"# Guardian channel HMAC secrets — managed by openpalm",
|
|
129
|
-
"# Each enabled channel gets a CHANNEL_<NAME>_SECRET entry.",
|
|
130
|
-
"",
|
|
131
|
-
].join("\n"));
|
|
132
|
-
} else {
|
|
133
|
-
try { chmodSync(guardianEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function ensureAuthJson(configDir: string): void {
|
|
138
|
-
const authJsonPath = `${configDir}/auth.json`;
|
|
139
|
-
mkdirSync(configDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
142
|
+
function ensureAuthJson(state: ControlPlaneState): void {
|
|
143
|
+
const authJsonPath = resolveAuthJsonPath(state);
|
|
144
|
+
mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
140
145
|
|
|
141
146
|
if (existsSync(authJsonPath)) {
|
|
142
147
|
try {
|
|
@@ -162,22 +167,24 @@ export function updateSecretsEnv(
|
|
|
162
167
|
state: ControlPlaneState,
|
|
163
168
|
updates: Record<string, string>
|
|
164
169
|
): void {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
const secretUpdates: Record<string, string> = {};
|
|
171
|
+
const stackUpdates: Record<string, string> = {};
|
|
172
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
173
|
+
if (SECRET_ENV_KEY_RE.test(key)) secretUpdates[key] = value;
|
|
174
|
+
else stackUpdates[key] = value;
|
|
168
175
|
}
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
writeStackSecretEnv(state, secretUpdates);
|
|
177
|
+
if (Object.keys(stackUpdates).length > 0) patchSecretsEnvFile(state.stackDir, stackUpdates);
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
/**
|
|
174
181
|
* Merge-write provider API keys into OpenCode's auth.json at
|
|
175
|
-
* `${
|
|
176
|
-
* api-key auth: `{ <providerId>: { type: "api", key
|
|
182
|
+
* `${stackDir}/auth.json` (knowledge/secrets/auth.json). Each entry uses
|
|
183
|
+
* OpenCode's schema for api-key auth: `{ <providerId>: { type: "api", key } }`.
|
|
177
184
|
*
|
|
178
|
-
* This file is bind-mounted into the assistant
|
|
179
|
-
*
|
|
180
|
-
* see core.compose.yml.
|
|
185
|
+
* This file is bind-mounted into both the assistant and guardian containers
|
|
186
|
+
* so every OpenCode instance picks up new credentials on its next restart —
|
|
187
|
+
* see core.compose.yml (assistant) and channels.compose.yml (guardian).
|
|
181
188
|
*
|
|
182
189
|
* Existing entries (OAuth tokens, other providers) are preserved.
|
|
183
190
|
* Empty values DELETE the corresponding entry.
|
|
@@ -188,8 +195,8 @@ export function writeAuthJsonProviderKeys(
|
|
|
188
195
|
): void {
|
|
189
196
|
if (Object.keys(providerKeys).length === 0) return;
|
|
190
197
|
|
|
191
|
-
const authJsonPath =
|
|
192
|
-
mkdirSync(
|
|
198
|
+
const authJsonPath = resolveAuthJsonPath(state);
|
|
199
|
+
mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
193
200
|
|
|
194
201
|
let current: Record<string, unknown> = {};
|
|
195
202
|
if (existsSync(authJsonPath)) {
|
|
@@ -227,16 +234,25 @@ export function writeAuthJsonProviderKeys(
|
|
|
227
234
|
writeVaultFile(authJsonPath, JSON.stringify(current, null, 2) + "\n");
|
|
228
235
|
}
|
|
229
236
|
|
|
230
|
-
/** Read and parse
|
|
237
|
+
/** Read and parse knowledge/env/stack.env. Returns {} if the file does not exist. */
|
|
231
238
|
export function readStackEnv(stackDir: string): Record<string, string> {
|
|
232
|
-
|
|
239
|
+
const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
|
|
240
|
+
const nonSecret: Record<string, string> = {};
|
|
241
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
242
|
+
if (!isSecretLikeStackEnvKey(key)) nonSecret[key] = value;
|
|
243
|
+
}
|
|
244
|
+
return nonSecret;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function readStackRuntimeEnv(stackDir: string): Record<string, string> {
|
|
248
|
+
return { ...readStackEnv(stackDir), ...readStackSecretEnv(stackDir) };
|
|
233
249
|
}
|
|
234
250
|
|
|
235
251
|
export function updateSystemSecretsEnv(
|
|
236
252
|
state: ControlPlaneState,
|
|
237
253
|
updates: Record<string, string>
|
|
238
254
|
): void {
|
|
239
|
-
const systemEnvPath = `${state.
|
|
255
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
240
256
|
enforceVaultDirMode(state.stackDir);
|
|
241
257
|
if (!existsSync(systemEnvPath)) {
|
|
242
258
|
ensureSystemSecrets(state);
|
|
@@ -250,9 +266,20 @@ export function patchSecretsEnvFile(
|
|
|
250
266
|
): void {
|
|
251
267
|
if (Object.keys(patches).length === 0) return;
|
|
252
268
|
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
const stackPatches: Record<string, string> = {};
|
|
270
|
+
const secretPatches: Record<string, string> = {};
|
|
271
|
+
for (const [key, value] of Object.entries(patches)) {
|
|
272
|
+
if (SECRET_ENV_KEY_RE.test(key)) secretPatches[key] = value;
|
|
273
|
+
else stackPatches[key] = value;
|
|
274
|
+
}
|
|
275
|
+
if (Object.keys(secretPatches).length > 0) {
|
|
276
|
+
writeStackSecretEnv({ stackDir, homeDir: '', configDir: '', stashDir: '', workspaceDir: '', dataDir: '', services: {}, artifacts: { compose: '' }, artifactMeta: [] }, secretPatches);
|
|
277
|
+
}
|
|
278
|
+
if (Object.keys(stackPatches).length === 0) return;
|
|
279
|
+
assertNoSecretLikeStackEnvKeys(stackPatches);
|
|
280
|
+
|
|
281
|
+
const stackEnvPath = stackEnvPathFromStackDir(stackDir);
|
|
282
|
+
enforceVaultDirMode(dirname(stackEnvPath));
|
|
256
283
|
|
|
257
284
|
let existingContent = "";
|
|
258
285
|
try {
|
|
@@ -263,7 +290,7 @@ export function patchSecretsEnvFile(
|
|
|
263
290
|
// start fresh
|
|
264
291
|
}
|
|
265
292
|
|
|
266
|
-
let result = mergeEnvContent(existingContent,
|
|
293
|
+
let result = mergeEnvContent(existingContent, stackPatches);
|
|
267
294
|
if (!result.endsWith("\n")) result += "\n";
|
|
268
295
|
writeVaultFile(stackEnvPath, result);
|
|
269
296
|
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"properties": {
|
|
36
36
|
"uiLoginPassword": {
|
|
37
37
|
"type": "string",
|
|
38
|
-
"description": "Operator login password for the OpenPalm UI. Persisted
|
|
38
|
+
"description": "Operator login password for the OpenPalm UI. Persisted as knowledge/secrets/op_ui_login_password; the UI's op_session cookie value is compared against it on every authenticated request.",
|
|
39
39
|
"minLength": 8
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import { parseEnvFile } from './env.js';
|
|
2
|
+
import { stackEnvPathFromStackDir } from './paths.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Check if setup is complete by reading
|
|
5
|
+
* Check if setup is complete by reading knowledge/env/stack.env.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* implies the operator (or the install wizard) has seeded the login
|
|
9
|
-
* secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
|
|
7
|
+
* Only OP_SETUP_COMPLETE=true is authoritative. Secrets live in
|
|
8
|
+
* knowledge/secrets and are not completion sentinels.
|
|
10
9
|
*/
|
|
11
10
|
export function isSetupComplete(stackDir: string): boolean {
|
|
12
|
-
const parsed = parseEnvFile(
|
|
13
|
-
|
|
14
|
-
return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
|
|
11
|
+
const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
|
|
12
|
+
return parsed.OP_SETUP_COMPLETE === "true";
|
|
18
13
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./setup.js";
|
|
12
12
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
13
13
|
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
14
|
+
import { readSecret } from './secrets-files.js';
|
|
14
15
|
|
|
15
16
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -38,15 +39,15 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
38
39
|
function seedRequiredAssets(homeDir: string): void {
|
|
39
40
|
mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
|
|
40
41
|
writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
41
|
-
mkdirSync(join(homeDir, "
|
|
42
|
-
writeFileSync(join(homeDir, "
|
|
43
|
-
writeFileSync(join(homeDir, "
|
|
44
|
-
mkdirSync(join(homeDir, "
|
|
45
|
-
// Automations live in
|
|
46
|
-
mkdirSync(join(homeDir, "
|
|
47
|
-
writeFileSync(join(homeDir, "
|
|
48
|
-
writeFileSync(join(homeDir, "
|
|
49
|
-
writeFileSync(join(homeDir, "
|
|
42
|
+
mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
|
|
43
|
+
writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
|
|
44
|
+
writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
|
|
45
|
+
mkdirSync(join(homeDir, "data"), { recursive: true });
|
|
46
|
+
// Automations live in knowledge/tasks as AKM-owned task files.
|
|
47
|
+
mkdirSync(join(homeDir, "data", "registry", "automations"), { recursive: true });
|
|
48
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
|
|
49
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
|
|
50
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// ── Tests: validateSetupSpec ────────────────────────────────────────────
|
|
@@ -204,7 +205,6 @@ describe("buildSecretsFromSetup", () => {
|
|
|
204
205
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
205
206
|
expect(secrets.OP_UI_LOGIN_PASSWORD).toBeUndefined();
|
|
206
207
|
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
207
|
-
expect(secrets.ADMIN_TOKEN).toBeUndefined();
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
it("does not include SYSTEM_LLM_* in user secrets", () => {
|
|
@@ -305,10 +305,7 @@ describe("buildAuthJsonFromSetup", () => {
|
|
|
305
305
|
});
|
|
306
306
|
|
|
307
307
|
describe("buildSystemSecretsFromSetup", () => {
|
|
308
|
-
|
|
309
|
-
// helper writes now is OP_UI_LOGIN_PASSWORD. OP_OPENCODE_PASSWORD is
|
|
310
|
-
// generated by ensureSystemSecrets() and persists across reruns.
|
|
311
|
-
it("returns OP_UI_LOGIN_PASSWORD equal to the supplied operator password", () => {
|
|
308
|
+
it("returns the file-based UI login password update", () => {
|
|
312
309
|
const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
|
|
313
310
|
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
314
311
|
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
@@ -321,7 +318,7 @@ describe("buildSystemSecretsFromSetup", () => {
|
|
|
321
318
|
describe("performSetup", () => {
|
|
322
319
|
let homeDir: string;
|
|
323
320
|
let configDir: string;
|
|
324
|
-
let
|
|
321
|
+
let dataDir: string;
|
|
325
322
|
let stackDir: string;
|
|
326
323
|
|
|
327
324
|
const savedEnv: Record<string, string | undefined> = {};
|
|
@@ -329,44 +326,41 @@ describe("performSetup", () => {
|
|
|
329
326
|
beforeEach(() => {
|
|
330
327
|
homeDir = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
|
|
331
328
|
configDir = join(homeDir, "config");
|
|
332
|
-
|
|
329
|
+
dataDir = join(homeDir, "data");
|
|
333
330
|
stackDir = join(configDir, "stack");
|
|
334
331
|
|
|
335
332
|
// Create required directory structure
|
|
336
333
|
for (const dir of [
|
|
337
334
|
homeDir,
|
|
338
335
|
configDir,
|
|
339
|
-
join(homeDir, "
|
|
336
|
+
join(homeDir, "data", "registry", "automations"),
|
|
340
337
|
join(configDir, "assistant"),
|
|
341
338
|
join(configDir, "akm"),
|
|
342
339
|
stackDir,
|
|
343
340
|
join(stackDir, "addons"),
|
|
344
|
-
join(homeDir, "
|
|
341
|
+
join(homeDir, "knowledge"),
|
|
342
|
+
join(homeDir, "knowledge", "env"),
|
|
343
|
+
join(homeDir, "knowledge", "secrets"),
|
|
345
344
|
join(homeDir, "workspace"),
|
|
346
|
-
|
|
347
|
-
join(
|
|
348
|
-
|
|
349
|
-
join(
|
|
350
|
-
join(
|
|
351
|
-
join(
|
|
352
|
-
join(
|
|
353
|
-
join(
|
|
345
|
+
dataDir,
|
|
346
|
+
join(dataDir, "assistant"),
|
|
347
|
+
join(dataDir, "admin"),
|
|
348
|
+
join(dataDir, "guardian"),
|
|
349
|
+
join(dataDir, "akm", "cache"),
|
|
350
|
+
join(dataDir, "akm", "data"),
|
|
351
|
+
join(dataDir, "logs"),
|
|
352
|
+
join(dataDir, "backups"),
|
|
353
|
+
join(dataDir, "rollback"),
|
|
354
354
|
]) {
|
|
355
355
|
mkdirSync(dir, { recursive: true });
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
// Create stub stack.env so isSetupComplete doesn't crash
|
|
359
359
|
writeFileSync(
|
|
360
|
-
join(
|
|
360
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
361
361
|
[
|
|
362
362
|
"OP_SETUP_COMPLETE=false",
|
|
363
|
-
"OP_UI_LOGIN_PASSWORD=",
|
|
364
|
-
"OPENAI_API_KEY=",
|
|
365
363
|
"OPENAI_BASE_URL=",
|
|
366
|
-
"ANTHROPIC_API_KEY=",
|
|
367
|
-
"GROQ_API_KEY=",
|
|
368
|
-
"MISTRAL_API_KEY=",
|
|
369
|
-
"GOOGLE_API_KEY=",
|
|
370
364
|
"OP_OWNER_NAME=",
|
|
371
365
|
"OP_OWNER_EMAIL=",
|
|
372
366
|
"",
|
|
@@ -394,12 +388,11 @@ describe("performSetup", () => {
|
|
|
394
388
|
expect(result.error).toBeDefined();
|
|
395
389
|
});
|
|
396
390
|
|
|
397
|
-
it("writes
|
|
391
|
+
it("writes the UI login password to knowledge/secrets", async () => {
|
|
398
392
|
const result = await performSetup(makeValidSpec());
|
|
399
393
|
expect(result.ok).toBe(true);
|
|
400
394
|
|
|
401
|
-
|
|
402
|
-
expect(secretsContent).toContain("test-admin-token-12345");
|
|
395
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
|
|
403
396
|
});
|
|
404
397
|
|
|
405
398
|
it("writes akm config.json with llm and embedding", async () => {
|
|
@@ -479,9 +472,16 @@ describe("performSetup", () => {
|
|
|
479
472
|
const spec = readStackSpec(stackDir);
|
|
480
473
|
expect(spec).not.toBeNull();
|
|
481
474
|
expect(spec!.version).toBe(2);
|
|
475
|
+
|
|
476
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
477
|
+
expect(stackEnv).not.toContain('OPENAI_API_KEY=');
|
|
478
|
+
expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
|
|
479
|
+
|
|
480
|
+
const authJson = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), 'utf-8')) as Record<string, { key: string }>;
|
|
481
|
+
expect(authJson.openai.key).toBe('sk-secondary');
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
-
it("
|
|
484
|
+
it("splits channel credentials between secret files and stack.env", async () => {
|
|
485
485
|
const input = makeValidSpec({
|
|
486
486
|
channelCredentials: {
|
|
487
487
|
discord: {
|
|
@@ -494,9 +494,11 @@ describe("performSetup", () => {
|
|
|
494
494
|
const result = await performSetup(input);
|
|
495
495
|
expect(result.ok).toBe(true);
|
|
496
496
|
|
|
497
|
-
|
|
498
|
-
expect(
|
|
499
|
-
|
|
497
|
+
expect(readSecret(stackDir, 'discord_bot_token')).toBe("discord-bot-token-xyz\n");
|
|
498
|
+
expect(readSecret(stackDir, 'discord_application_id')).toBeNull();
|
|
499
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
500
|
+
expect(stackEnv).toContain('DISCORD_APPLICATION_ID=discord-app-id-123');
|
|
501
|
+
expect(stackEnv).not.toContain('DISCORD_BOT_TOKEN=');
|
|
500
502
|
});
|
|
501
503
|
|
|
502
504
|
it("ensureOpenCodeConfig never writes forbidden keys (providers, smallModel, model) to the user config", async () => {
|