@openpalm/lib 0.10.1 → 0.11.0-beta.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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -22,6 +22,7 @@ import { isSetupComplete } from "./setup-status.js";
|
|
|
22
22
|
import {
|
|
23
23
|
performSetup,
|
|
24
24
|
buildSecretsFromSetup,
|
|
25
|
+
buildAuthJsonFromSetup,
|
|
25
26
|
buildSystemSecretsFromSetup,
|
|
26
27
|
} from "./setup.js";
|
|
27
28
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
@@ -33,18 +34,8 @@ import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
|
33
34
|
function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
34
35
|
return {
|
|
35
36
|
version: 2,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
embeddings: {
|
|
39
|
-
provider: "openai",
|
|
40
|
-
model: "text-embedding-3-small",
|
|
41
|
-
dims: 1536,
|
|
42
|
-
},
|
|
43
|
-
memory: {
|
|
44
|
-
userId: "test_user",
|
|
45
|
-
customInstructions: "",
|
|
46
|
-
},
|
|
47
|
-
},
|
|
37
|
+
llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
|
|
38
|
+
embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
|
|
48
39
|
security: { adminToken: "test-admin-token-12345" },
|
|
49
40
|
owner: { name: "Test User", email: "test@example.com" },
|
|
50
41
|
connections: [
|
|
@@ -62,28 +53,26 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
62
53
|
|
|
63
54
|
/** Seed the minimal asset files that ensure* functions expect to find at OP_HOME. */
|
|
64
55
|
function seedRequiredAssets(homeDir: string): void {
|
|
65
|
-
mkdirSync(join(homeDir, "stack"), { recursive: true });
|
|
66
|
-
writeFileSync(join(homeDir, "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
67
|
-
mkdirSync(join(homeDir, "
|
|
68
|
-
writeFileSync(join(homeDir, "
|
|
69
|
-
writeFileSync(join(homeDir, "
|
|
70
|
-
mkdirSync(join(homeDir, "
|
|
71
|
-
|
|
72
|
-
mkdirSync(join(homeDir, "
|
|
73
|
-
writeFileSync(join(homeDir, "
|
|
74
|
-
|
|
75
|
-
writeFileSync(join(homeDir, "
|
|
76
|
-
writeFileSync(join(homeDir, "config", "automations", "cleanup-data.yml"), "name: cleanup-data\nschedule: weekly\n");
|
|
77
|
-
writeFileSync(join(homeDir, "config", "automations", "validate-config.yml"), "name: validate-config\nschedule: hourly\n");
|
|
56
|
+
mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
|
|
57
|
+
writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
58
|
+
mkdirSync(join(homeDir, "state", "assistant"), { recursive: true });
|
|
59
|
+
writeFileSync(join(homeDir, "state", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
|
|
60
|
+
writeFileSync(join(homeDir, "state", "assistant", "AGENTS.md"), "# Agents\n");
|
|
61
|
+
mkdirSync(join(homeDir, "state"), { recursive: true });
|
|
62
|
+
// Automations live in state/registry/automations (shipped catalog) and stash/tasks (user tasks)
|
|
63
|
+
mkdirSync(join(homeDir, "state", "registry", "automations"), { recursive: true });
|
|
64
|
+
writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-logs.md"), "---\nschedule: \"0 4 * * 0\"\ndescription: cleanup logs\n---\n");
|
|
65
|
+
writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-data.md"), "---\nschedule: \"0 5 * * 0\"\ndescription: cleanup data\n---\n");
|
|
66
|
+
writeFileSync(join(homeDir, "state", "registry", "automations", "validate-config.md"), "---\nschedule: \"0 3 * * *\"\ndescription: validate config\n---\n");
|
|
78
67
|
}
|
|
79
68
|
|
|
80
69
|
// ── Shared test fixture ──────────────────────────────────────────────────
|
|
81
70
|
|
|
82
71
|
let homeDir: string;
|
|
83
72
|
let configDir: string;
|
|
84
|
-
let
|
|
85
|
-
let
|
|
86
|
-
let
|
|
73
|
+
let stateDir: string;
|
|
74
|
+
let stackDir: string;
|
|
75
|
+
let cacheDir: string;
|
|
87
76
|
|
|
88
77
|
const savedEnv: Record<string, string | undefined> = {};
|
|
89
78
|
|
|
@@ -100,31 +89,36 @@ function restoreEnv(): void {
|
|
|
100
89
|
function createFullDirTree(): void {
|
|
101
90
|
homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
|
|
102
91
|
configDir = join(homeDir, "config");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
92
|
+
stateDir = join(homeDir, "state");
|
|
93
|
+
stackDir = join(configDir, "stack");
|
|
94
|
+
cacheDir = join(homeDir, "cache");
|
|
106
95
|
|
|
107
96
|
for (const dir of [
|
|
108
97
|
homeDir,
|
|
109
98
|
configDir,
|
|
110
|
-
join(
|
|
111
|
-
join(configDir, "channels"),
|
|
99
|
+
join(homeDir, "state", "registry", "automations"),
|
|
112
100
|
join(configDir, "assistant"),
|
|
113
|
-
join(configDir, "
|
|
114
|
-
join(homeDir, "
|
|
115
|
-
join(homeDir, "
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
join(
|
|
120
|
-
join(
|
|
121
|
-
join(
|
|
122
|
-
join(
|
|
123
|
-
join(
|
|
124
|
-
join(
|
|
125
|
-
join(
|
|
126
|
-
|
|
127
|
-
join(
|
|
101
|
+
join(configDir, "akm"),
|
|
102
|
+
join(homeDir, "stash"),
|
|
103
|
+
join(homeDir, "workspace"),
|
|
104
|
+
stackDir,
|
|
105
|
+
join(stackDir, "addons"),
|
|
106
|
+
stateDir,
|
|
107
|
+
join(stateDir, "assistant"),
|
|
108
|
+
join(stateDir, "admin"),
|
|
109
|
+
join(stateDir, "guardian"),
|
|
110
|
+
join(stateDir, "logs"),
|
|
111
|
+
join(stateDir, "logs", "opencode"),
|
|
112
|
+
join(stateDir, "registry"),
|
|
113
|
+
join(stateDir, "registry", "addons"),
|
|
114
|
+
join(stateDir, "backups"),
|
|
115
|
+
join(stateDir, "akm"),
|
|
116
|
+
join(stateDir, "akm", "data"),
|
|
117
|
+
join(stateDir, "akm", "state"),
|
|
118
|
+
cacheDir,
|
|
119
|
+
join(cacheDir, "akm"),
|
|
120
|
+
join(cacheDir, "guardian"),
|
|
121
|
+
join(cacheDir, "rollback"),
|
|
128
122
|
]) {
|
|
129
123
|
mkdirSync(dir, { recursive: true });
|
|
130
124
|
}
|
|
@@ -133,27 +127,16 @@ function createFullDirTree(): void {
|
|
|
133
127
|
seedRequiredAssets(homeDir);
|
|
134
128
|
}
|
|
135
129
|
|
|
136
|
-
/** Seed the minimal
|
|
130
|
+
/** Seed the minimal stack.env needed for most tests. */
|
|
137
131
|
function seedMinimalEnvFiles(): void {
|
|
138
|
-
mkdirSync(
|
|
139
|
-
mkdirSync(join(vaultDir, "stack"), { recursive: true });
|
|
140
|
-
writeFileSync(
|
|
141
|
-
join(vaultDir, "user", "user.env"),
|
|
142
|
-
[
|
|
143
|
-
"# OpenPalm — User Extensions",
|
|
144
|
-
"# Add any custom environment variables here.",
|
|
145
|
-
"# These are loaded by compose alongside stack.env.",
|
|
146
|
-
"",
|
|
147
|
-
].join("\n")
|
|
148
|
-
);
|
|
132
|
+
mkdirSync(stackDir, { recursive: true });
|
|
149
133
|
|
|
150
134
|
writeFileSync(
|
|
151
|
-
join(
|
|
135
|
+
join(stackDir, "stack.env"),
|
|
152
136
|
[
|
|
153
137
|
"# OpenPalm — Stack Configuration",
|
|
154
|
-
"
|
|
138
|
+
"OP_UI_TOKEN=",
|
|
155
139
|
"OP_ASSISTANT_TOKEN=",
|
|
156
|
-
"OP_MEMORY_TOKEN=",
|
|
157
140
|
"OPENAI_API_KEY=",
|
|
158
141
|
"OPENAI_BASE_URL=",
|
|
159
142
|
"ANTHROPIC_API_KEY=",
|
|
@@ -184,51 +167,42 @@ describe("Fresh Install", () => {
|
|
|
184
167
|
rmSync(homeDir, { recursive: true, force: true });
|
|
185
168
|
});
|
|
186
169
|
|
|
187
|
-
// Scenario 1: ensureSecrets
|
|
188
|
-
|
|
170
|
+
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
|
|
171
|
+
// does create stack.env with required keys when files do not exist.
|
|
172
|
+
it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
|
|
189
173
|
const state: ControlPlaneState = {
|
|
190
174
|
adminToken: "",
|
|
191
175
|
assistantToken: "",
|
|
192
|
-
setupToken: "",
|
|
193
176
|
homeDir,
|
|
194
177
|
configDir,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
178
|
+
stashDir: join(homeDir, "stash"),
|
|
179
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
180
|
+
cacheDir,
|
|
181
|
+
stateDir,
|
|
182
|
+
stackDir,
|
|
199
183
|
services: {},
|
|
200
184
|
artifacts: { compose: "" },
|
|
201
185
|
artifactMeta: [],
|
|
202
186
|
audit: [],
|
|
203
187
|
};
|
|
204
188
|
|
|
205
|
-
// No user.env exists yet
|
|
206
|
-
expect(existsSync(join(vaultDir, "user", "user.env"))).toBe(false);
|
|
207
|
-
|
|
208
189
|
ensureSecrets(state);
|
|
209
190
|
|
|
210
|
-
//
|
|
211
|
-
const
|
|
212
|
-
expect(userContent).toContain("User Extensions");
|
|
213
|
-
|
|
214
|
-
// API keys and owner info are seeded in stack.env
|
|
215
|
-
const stackContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
|
|
191
|
+
// API keys and owner info are seeded in state/stack.env.
|
|
192
|
+
const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
216
193
|
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
217
194
|
expect(stackContent).toContain("OWNER_NAME=");
|
|
218
195
|
});
|
|
219
196
|
|
|
220
197
|
// Scenario 2: isSetupComplete returns false before setup
|
|
221
198
|
it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
|
|
222
|
-
mkdirSync(
|
|
223
|
-
mkdirSync(join(vaultDir, "user"), { recursive: true });
|
|
199
|
+
mkdirSync(stateDir, { recursive: true });
|
|
224
200
|
writeFileSync(
|
|
225
|
-
join(
|
|
201
|
+
join(stackDir, "stack.env"),
|
|
226
202
|
"OP_SETUP_COMPLETE=false\n"
|
|
227
203
|
);
|
|
228
|
-
// Empty user.env so fallback check doesn't trigger
|
|
229
|
-
writeFileSync(join(vaultDir, "user", "user.env"), "");
|
|
230
204
|
|
|
231
|
-
expect(isSetupComplete(
|
|
205
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
232
206
|
});
|
|
233
207
|
|
|
234
208
|
// Scenario 3: performSetup succeeds from completely empty state
|
|
@@ -242,15 +216,21 @@ describe("Fresh Install", () => {
|
|
|
242
216
|
expect(result.ok).toBe(true);
|
|
243
217
|
});
|
|
244
218
|
|
|
245
|
-
// Scenario 4: performSetup
|
|
246
|
-
|
|
219
|
+
// Scenario 4: performSetup must NOT mark OP_SETUP_COMPLETE.
|
|
220
|
+
//
|
|
221
|
+
// The flag is set by setup-deploy.ts:startDeploy AFTER the Docker stack is
|
|
222
|
+
// confirmed healthy. If performSetup wrote it eagerly, a deploy failure
|
|
223
|
+
// would leave the wizard convinced setup was complete and bounce the user
|
|
224
|
+
// into a broken admin UI.
|
|
225
|
+
it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
|
|
247
226
|
seedMinimalEnvFiles();
|
|
248
227
|
|
|
249
228
|
await performSetup(makeValidSpec());
|
|
250
229
|
|
|
251
|
-
const stackEnv = readFileSync(join(
|
|
230
|
+
const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
252
231
|
const parsed = parseEnvContent(stackEnv);
|
|
253
|
-
|
|
232
|
+
// Either entirely absent, or still the seeded "false" — never "true".
|
|
233
|
+
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
254
234
|
});
|
|
255
235
|
});
|
|
256
236
|
|
|
@@ -270,23 +250,21 @@ describe("Existing Install", () => {
|
|
|
270
250
|
rmSync(homeDir, { recursive: true, force: true });
|
|
271
251
|
});
|
|
272
252
|
|
|
273
|
-
// Scenario 5: ensureSecrets does NOT overwrite existing
|
|
274
|
-
it("ensureSecrets does not overwrite existing
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
mkdirSync(join(vaultDir, "user"), { recursive: true });
|
|
278
|
-
writeFileSync(join(vaultDir, "user", "user.env"), customContent);
|
|
253
|
+
// Scenario 5: ensureSecrets does NOT overwrite existing stack.env
|
|
254
|
+
it("ensureSecrets does not overwrite existing stack.env tokens", () => {
|
|
255
|
+
mkdirSync(stateDir, { recursive: true });
|
|
256
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=my-custom-token\nOP_ASSISTANT_TOKEN=existing-token\n");
|
|
279
257
|
|
|
280
258
|
const state: ControlPlaneState = {
|
|
281
259
|
adminToken: "",
|
|
282
260
|
assistantToken: "",
|
|
283
|
-
setupToken: "",
|
|
284
261
|
homeDir,
|
|
285
262
|
configDir,
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
263
|
+
stashDir: join(homeDir, "stash"),
|
|
264
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
265
|
+
cacheDir,
|
|
266
|
+
stateDir,
|
|
267
|
+
stackDir,
|
|
290
268
|
services: {},
|
|
291
269
|
artifacts: { compose: "" },
|
|
292
270
|
artifactMeta: [],
|
|
@@ -295,21 +273,23 @@ describe("Existing Install", () => {
|
|
|
295
273
|
|
|
296
274
|
ensureSecrets(state);
|
|
297
275
|
|
|
298
|
-
|
|
299
|
-
|
|
276
|
+
// Existing tokens must be preserved
|
|
277
|
+
const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
278
|
+
expect(afterContent).toContain("OP_UI_TOKEN=my-custom-token");
|
|
279
|
+
expect(afterContent).toContain("OP_ASSISTANT_TOKEN=existing-token");
|
|
300
280
|
});
|
|
301
281
|
|
|
302
|
-
// Scenario 6: performSetup re-run preserves
|
|
303
|
-
it("performSetup re-run preserves
|
|
282
|
+
// Scenario 6: performSetup re-run preserves OP_ASSISTANT_TOKEN
|
|
283
|
+
it("performSetup re-run preserves OP_ASSISTANT_TOKEN from first run", async () => {
|
|
304
284
|
// First setup
|
|
305
285
|
await performSetup(makeValidSpec());
|
|
306
286
|
|
|
307
287
|
const secretsAfterFirst = readFileSync(
|
|
308
|
-
join(
|
|
288
|
+
join(stackDir, "stack.env"),
|
|
309
289
|
"utf-8"
|
|
310
290
|
);
|
|
311
291
|
const firstMatch = secretsAfterFirst.match(
|
|
312
|
-
/
|
|
292
|
+
/OP_ASSISTANT_TOKEN=([a-f0-9]+)/
|
|
313
293
|
);
|
|
314
294
|
expect(firstMatch).not.toBeNull();
|
|
315
295
|
const firstToken = firstMatch![1];
|
|
@@ -330,53 +310,41 @@ describe("Existing Install", () => {
|
|
|
330
310
|
);
|
|
331
311
|
|
|
332
312
|
const secretsAfterSecond = readFileSync(
|
|
333
|
-
join(
|
|
313
|
+
join(stackDir, "stack.env"),
|
|
334
314
|
"utf-8"
|
|
335
315
|
);
|
|
336
316
|
const secondMatch = secretsAfterSecond.match(
|
|
337
|
-
/
|
|
317
|
+
/OP_ASSISTANT_TOKEN=([a-f0-9]+)/
|
|
338
318
|
);
|
|
339
319
|
expect(secondMatch).not.toBeNull();
|
|
340
|
-
//
|
|
320
|
+
// OP_ASSISTANT_TOKEN should be preserved across setups
|
|
341
321
|
expect(secondMatch![1]).toBe(firstToken);
|
|
342
322
|
});
|
|
343
323
|
|
|
344
|
-
// Scenario 7: performSetup
|
|
345
|
-
|
|
324
|
+
// Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
|
|
325
|
+
// 4 in the Fresh Install block for the rationale. The deploy phase owns
|
|
326
|
+
// this flag and only writes it after the container stack is healthy.
|
|
327
|
+
it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
|
|
346
328
|
await performSetup(makeValidSpec());
|
|
347
329
|
|
|
348
330
|
const stackEnv = readFileSync(
|
|
349
|
-
join(
|
|
331
|
+
join(stackDir, "stack.env"),
|
|
350
332
|
"utf-8"
|
|
351
333
|
);
|
|
352
334
|
const parsed = parseEnvContent(stackEnv);
|
|
353
|
-
expect(parsed.OP_SETUP_COMPLETE).toBe(
|
|
335
|
+
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
354
336
|
});
|
|
355
337
|
|
|
356
|
-
// Scenario 8: Re-setup with different provider updates
|
|
357
|
-
it("re-setup with different provider updates
|
|
338
|
+
// Scenario 8: Re-setup with different provider updates akm config
|
|
339
|
+
it("re-setup with different provider updates akm config", async () => {
|
|
358
340
|
// First setup with OpenAI
|
|
359
341
|
await performSetup(makeValidSpec());
|
|
360
342
|
|
|
361
|
-
const specAfterFirst = readStackSpec(configDir);
|
|
362
|
-
expect(specAfterFirst).not.toBeNull();
|
|
363
|
-
expect(specAfterFirst!.capabilities.llm).toContain("openai/");
|
|
364
|
-
|
|
365
343
|
// Second setup with Groq
|
|
366
344
|
await performSetup(
|
|
367
345
|
makeValidSpec({
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
embeddings: {
|
|
371
|
-
provider: "groq",
|
|
372
|
-
model: "text-embedding-3-small",
|
|
373
|
-
dims: 1536,
|
|
374
|
-
},
|
|
375
|
-
memory: {
|
|
376
|
-
userId: "test_user",
|
|
377
|
-
customInstructions: "",
|
|
378
|
-
},
|
|
379
|
-
},
|
|
346
|
+
llm: { provider: "groq", model: "llama3-70b-8192", baseUrl: "https://api.groq.com/openai/v1" },
|
|
347
|
+
embedding: { provider: "groq", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.groq.com/openai/v1" },
|
|
380
348
|
connections: [
|
|
381
349
|
{
|
|
382
350
|
id: "groq-main",
|
|
@@ -389,12 +357,13 @@ describe("Existing Install", () => {
|
|
|
389
357
|
})
|
|
390
358
|
);
|
|
391
359
|
|
|
392
|
-
|
|
360
|
+
// stack.yml is just a version marker now
|
|
361
|
+
const specAfterSecond = readStackSpec(stackDir);
|
|
393
362
|
expect(specAfterSecond).not.toBeNull();
|
|
394
|
-
expect(specAfterSecond!.
|
|
363
|
+
expect(specAfterSecond!.version).toBe(2);
|
|
395
364
|
|
|
396
365
|
// stack.env should retain both keys
|
|
397
|
-
const secrets = readFileSync(join(
|
|
366
|
+
const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
398
367
|
expect(secrets).toContain("GROQ_API_KEY");
|
|
399
368
|
});
|
|
400
369
|
});
|
|
@@ -414,21 +383,21 @@ describe("Broken/Corrupt State", () => {
|
|
|
414
383
|
rmSync(homeDir, { recursive: true, force: true });
|
|
415
384
|
});
|
|
416
385
|
|
|
417
|
-
// Scenario 9:
|
|
418
|
-
it("ensureSecrets
|
|
419
|
-
mkdirSync(
|
|
420
|
-
writeFileSync(join(
|
|
386
|
+
// Scenario 9: ensureSecrets is idempotent on repeated calls
|
|
387
|
+
it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
|
|
388
|
+
mkdirSync(stateDir, { recursive: true });
|
|
389
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=existing-token\nOP_ASSISTANT_TOKEN=existing-assistant\n");
|
|
421
390
|
|
|
422
391
|
const state: ControlPlaneState = {
|
|
423
392
|
adminToken: "",
|
|
424
393
|
assistantToken: "",
|
|
425
|
-
setupToken: "",
|
|
426
394
|
homeDir,
|
|
427
395
|
configDir,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
396
|
+
stashDir: join(homeDir, "stash"),
|
|
397
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
398
|
+
cacheDir,
|
|
399
|
+
stateDir,
|
|
400
|
+
stackDir,
|
|
432
401
|
services: {},
|
|
433
402
|
artifacts: { compose: "" },
|
|
434
403
|
artifactMeta: [],
|
|
@@ -437,12 +406,13 @@ describe("Broken/Corrupt State", () => {
|
|
|
437
406
|
|
|
438
407
|
ensureSecrets(state);
|
|
439
408
|
|
|
440
|
-
//
|
|
441
|
-
const content = readFileSync(join(
|
|
442
|
-
expect(content).
|
|
409
|
+
// Existing tokens must be preserved
|
|
410
|
+
const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
411
|
+
expect(content).toContain("OP_UI_TOKEN=existing-token");
|
|
412
|
+
expect(content).toContain("OP_ASSISTANT_TOKEN=existing-assistant");
|
|
443
413
|
});
|
|
444
414
|
|
|
445
|
-
// Scenario 10:
|
|
415
|
+
// Scenario 10: env file with malformed lines
|
|
446
416
|
it("parseEnvFile handles malformed env lines gracefully", () => {
|
|
447
417
|
const malformedContent = [
|
|
448
418
|
"# Comment line",
|
|
@@ -456,10 +426,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
456
426
|
" # indented comment",
|
|
457
427
|
].join("\n");
|
|
458
428
|
|
|
459
|
-
mkdirSync(
|
|
460
|
-
writeFileSync(join(
|
|
429
|
+
mkdirSync(stateDir, { recursive: true });
|
|
430
|
+
writeFileSync(join(stateDir, "test.env"), malformedContent);
|
|
461
431
|
|
|
462
|
-
const parsed = parseEnvFile(join(
|
|
432
|
+
const parsed = parseEnvFile(join(stateDir, "test.env"));
|
|
463
433
|
expect(parsed.VALID_KEY).toBe("valid_value");
|
|
464
434
|
expect(parsed.EXPORTED_KEY).toBe("exported_value");
|
|
465
435
|
expect(parsed.ANOTHER_VALID).toBe("value");
|
|
@@ -468,30 +438,23 @@ describe("Broken/Corrupt State", () => {
|
|
|
468
438
|
// Scenario 11: stack.env missing OP_SETUP_COMPLETE
|
|
469
439
|
it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
|
|
470
440
|
// stack.env without OP_SETUP_COMPLETE
|
|
471
|
-
mkdirSync(
|
|
472
|
-
mkdirSync(join(vaultDir, "user"), { recursive: true });
|
|
441
|
+
mkdirSync(stateDir, { recursive: true });
|
|
473
442
|
writeFileSync(
|
|
474
|
-
join(
|
|
443
|
+
join(stackDir, "stack.env"),
|
|
475
444
|
"OP_IMAGE_TAG=latest\n"
|
|
476
445
|
);
|
|
477
446
|
|
|
478
|
-
|
|
479
|
-
writeFileSync(
|
|
480
|
-
join(vaultDir, "user", "user.env"),
|
|
481
|
-
"export OP_ADMIN_TOKEN=\nexport ADMIN_TOKEN=\n"
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
expect(isSetupComplete(vaultDir)).toBe(false);
|
|
447
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
485
448
|
});
|
|
486
449
|
|
|
487
450
|
it("isSetupComplete falls back to true when admin token is set but OP_SETUP_COMPLETE missing", () => {
|
|
488
|
-
mkdirSync(
|
|
451
|
+
mkdirSync(stateDir, { recursive: true });
|
|
489
452
|
writeFileSync(
|
|
490
|
-
join(
|
|
491
|
-
"OP_IMAGE_TAG=latest\nexport
|
|
453
|
+
join(stackDir, "stack.env"),
|
|
454
|
+
"OP_IMAGE_TAG=latest\nexport OP_UI_TOKEN=my-real-token\n"
|
|
492
455
|
);
|
|
493
456
|
|
|
494
|
-
expect(isSetupComplete(
|
|
457
|
+
expect(isSetupComplete(stackDir)).toBe(true);
|
|
495
458
|
});
|
|
496
459
|
|
|
497
460
|
// Scenario 12: API key with special characters round-trips
|
|
@@ -512,39 +475,39 @@ describe("Broken/Corrupt State", () => {
|
|
|
512
475
|
|
|
513
476
|
// Scenario 13: Missing stack.yml returns null
|
|
514
477
|
it("readStackSpec returns null when stack.yml missing", () => {
|
|
515
|
-
const spec = readStackSpec(
|
|
478
|
+
const spec = readStackSpec(stackDir);
|
|
516
479
|
expect(spec).toBeNull();
|
|
517
480
|
});
|
|
518
481
|
|
|
519
|
-
// Scenario 14:
|
|
482
|
+
// Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
520
483
|
it("performSetup creates missing subdirectories", async () => {
|
|
521
484
|
// Seed the minimal env files first
|
|
522
485
|
seedMinimalEnvFiles();
|
|
523
486
|
|
|
524
|
-
// Remove
|
|
525
|
-
rmSync(join(
|
|
487
|
+
// Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
|
|
488
|
+
rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
|
|
526
489
|
|
|
527
490
|
const result = await performSetup(
|
|
528
491
|
makeValidSpec()
|
|
529
492
|
);
|
|
530
493
|
expect(result.ok).toBe(true);
|
|
531
494
|
|
|
532
|
-
// Artifacts should exist in
|
|
533
|
-
expect(existsSync(join(homeDir, "stack", "core.compose.yml"))).toBe(
|
|
495
|
+
// Artifacts should exist in config/stack/
|
|
496
|
+
expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
|
|
534
497
|
true
|
|
535
498
|
);
|
|
536
|
-
//
|
|
537
|
-
expect(existsSync(join(
|
|
499
|
+
// stash/tasks dir should be recreated by ensureHomeDirs
|
|
500
|
+
expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
|
|
538
501
|
});
|
|
539
502
|
|
|
540
503
|
// Scenario 15: openpalm.yaml with old version
|
|
541
504
|
it("readStackSpec returns null for version 1 spec", () => {
|
|
542
505
|
writeFileSync(
|
|
543
|
-
join(
|
|
506
|
+
join(stackDir, STACK_SPEC_FILENAME),
|
|
544
507
|
"version: 1\nconnections: []\n"
|
|
545
508
|
);
|
|
546
509
|
|
|
547
|
-
const spec = readStackSpec(
|
|
510
|
+
const spec = readStackSpec(stackDir);
|
|
548
511
|
expect(spec).toBeNull();
|
|
549
512
|
});
|
|
550
513
|
});
|
|
@@ -564,15 +527,15 @@ describe("Environment Edge Cases", () => {
|
|
|
564
527
|
rmSync(homeDir, { recursive: true, force: true });
|
|
565
528
|
});
|
|
566
529
|
|
|
567
|
-
// Scenario 16: Commented-out ADMIN_TOKEN but
|
|
568
|
-
it("isSetupComplete detects
|
|
569
|
-
mkdirSync(
|
|
530
|
+
// Scenario 16: Commented-out ADMIN_TOKEN but OP_UI_TOKEN set
|
|
531
|
+
it("isSetupComplete detects OP_UI_TOKEN when ADMIN_TOKEN is commented out", () => {
|
|
532
|
+
mkdirSync(stateDir, { recursive: true });
|
|
570
533
|
writeFileSync(
|
|
571
|
-
join(
|
|
572
|
-
"SOME_OTHER_KEY=value\nexport
|
|
534
|
+
join(stackDir, "stack.env"),
|
|
535
|
+
"SOME_OTHER_KEY=value\nexport OP_UI_TOKEN=real-token-here\n"
|
|
573
536
|
);
|
|
574
537
|
|
|
575
|
-
expect(isSetupComplete(
|
|
538
|
+
expect(isSetupComplete(stackDir)).toBe(true);
|
|
576
539
|
});
|
|
577
540
|
|
|
578
541
|
// Scenario 17: export prefix on env vars
|
|
@@ -628,21 +591,11 @@ describe("Setup Input Variations", () => {
|
|
|
628
591
|
rmSync(homeDir, { recursive: true, force: true });
|
|
629
592
|
});
|
|
630
593
|
|
|
631
|
-
// Scenario 20: Ollama
|
|
632
|
-
it("Ollama
|
|
594
|
+
// Scenario 20: Ollama setup
|
|
595
|
+
it("Ollama setup writes akm config with ollama provider", async () => {
|
|
633
596
|
const input = makeValidSpec({
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
embeddings: {
|
|
637
|
-
provider: "ollama",
|
|
638
|
-
model: "nomic-embed-text",
|
|
639
|
-
dims: 768,
|
|
640
|
-
},
|
|
641
|
-
memory: {
|
|
642
|
-
userId: "test_user",
|
|
643
|
-
customInstructions: "",
|
|
644
|
-
},
|
|
645
|
-
},
|
|
597
|
+
llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
|
|
598
|
+
embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
|
|
646
599
|
connections: [
|
|
647
600
|
{
|
|
648
601
|
id: "ollama-local",
|
|
@@ -657,23 +610,22 @@ describe("Setup Input Variations", () => {
|
|
|
657
610
|
const result = await performSetup(input);
|
|
658
611
|
expect(result.ok).toBe(true);
|
|
659
612
|
|
|
660
|
-
|
|
661
|
-
const spec = readStackSpec(configDir);
|
|
613
|
+
const spec = readStackSpec(stackDir);
|
|
662
614
|
expect(spec).not.toBeNull();
|
|
663
|
-
expect(spec!.
|
|
615
|
+
expect(spec!.version).toBe(2);
|
|
664
616
|
});
|
|
665
617
|
|
|
666
618
|
// Scenario 21: Multiple providers map to correct env vars
|
|
667
|
-
it("multiple providers each write their API key
|
|
619
|
+
it("multiple providers each write their API key into auth.json keyed by providerId", () => {
|
|
668
620
|
const conns: SetupConnection[] = [
|
|
669
621
|
{ id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-openai" },
|
|
670
622
|
{ id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-groq" },
|
|
671
623
|
{ id: "anthropic-1", name: "Anthropic", provider: "anthropic", baseUrl: "", apiKey: "sk-ant-api03" },
|
|
672
624
|
];
|
|
673
|
-
const
|
|
674
|
-
expect(
|
|
675
|
-
expect(
|
|
676
|
-
expect(
|
|
625
|
+
const keys = buildAuthJsonFromSetup(conns);
|
|
626
|
+
expect(keys.openai).toBe("sk-openai");
|
|
627
|
+
expect(keys.groq).toBe("gsk-groq");
|
|
628
|
+
expect(keys.anthropic).toBe("sk-ant-api03");
|
|
677
629
|
});
|
|
678
630
|
|
|
679
631
|
// Scenario 21b: OAuth providers (no API key) are silently skipped
|
|
@@ -682,19 +634,22 @@ describe("Setup Input Variations", () => {
|
|
|
682
634
|
{ id: "github-copilot", name: "GitHub Copilot", provider: "github-copilot", baseUrl: "", apiKey: "" },
|
|
683
635
|
{ id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-test" },
|
|
684
636
|
];
|
|
685
|
-
const
|
|
686
|
-
expect(
|
|
687
|
-
expect(
|
|
637
|
+
const keys = buildAuthJsonFromSetup(conns);
|
|
638
|
+
expect(keys.openai).toBe("sk-test");
|
|
639
|
+
expect(keys["github-copilot"]).toBeUndefined();
|
|
688
640
|
});
|
|
689
641
|
|
|
690
|
-
// Scenario 22: buildSecretsFromSetup
|
|
691
|
-
|
|
642
|
+
// Scenario 22: buildSecretsFromSetup writes non-credential vars only;
|
|
643
|
+
// API keys flow into auth.json via buildAuthJsonFromSetup.
|
|
644
|
+
it("buildSecretsFromSetup does not write API keys; buildAuthJsonFromSetup does", () => {
|
|
692
645
|
const spec = makeValidSpec();
|
|
693
646
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
647
|
+
const keys = buildAuthJsonFromSetup(spec.connections);
|
|
694
648
|
|
|
695
|
-
// API
|
|
696
|
-
expect(secrets.OPENAI_API_KEY).
|
|
697
|
-
|
|
649
|
+
// API keys go to auth.json, not stack.env
|
|
650
|
+
expect(secrets.OPENAI_API_KEY).toBeUndefined();
|
|
651
|
+
expect(keys.openai).toBe("sk-test-key-123");
|
|
652
|
+
// Config vars (capability resolution) are not in stack.env user-secrets either
|
|
698
653
|
expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
|
|
699
654
|
expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
|
|
700
655
|
expect(secrets.EMBEDDING_MODEL).toBeUndefined();
|
|
@@ -721,69 +676,52 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
721
676
|
it("writes stack.yml and readStackSpec returns v2", async () => {
|
|
722
677
|
await performSetup(makeValidSpec());
|
|
723
678
|
|
|
724
|
-
const spec = readStackSpec(
|
|
679
|
+
const spec = readStackSpec(stackDir);
|
|
725
680
|
expect(spec).not.toBeNull();
|
|
726
681
|
expect(spec!.version).toBe(2);
|
|
727
|
-
expect(spec!.capabilities.llm).toBe("openai/gpt-4o");
|
|
728
|
-
expect(spec!.capabilities.embeddings.model).toBe("text-embedding-3-small");
|
|
729
682
|
});
|
|
730
683
|
|
|
731
|
-
it("writes
|
|
684
|
+
it("writes akm config with embedding dims from setup spec", async () => {
|
|
732
685
|
const input = makeValidSpec({
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
embeddings: {
|
|
736
|
-
provider: "ollama",
|
|
737
|
-
model: "nomic-embed-text",
|
|
738
|
-
dims: 0, // Resolved from lookup
|
|
739
|
-
},
|
|
740
|
-
memory: {
|
|
741
|
-
userId: "test_user",
|
|
742
|
-
customInstructions: "",
|
|
743
|
-
},
|
|
744
|
-
},
|
|
686
|
+
llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
|
|
687
|
+
embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
|
|
745
688
|
connections: [
|
|
746
|
-
{
|
|
747
|
-
id: "ollama-1",
|
|
748
|
-
name: "Ollama",
|
|
749
|
-
provider: "ollama",
|
|
750
|
-
baseUrl: "http://localhost:11434",
|
|
751
|
-
apiKey: "",
|
|
752
|
-
},
|
|
689
|
+
{ id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
|
|
753
690
|
],
|
|
754
691
|
});
|
|
755
692
|
|
|
756
693
|
await performSetup(input);
|
|
757
694
|
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
expect(
|
|
695
|
+
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
696
|
+
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
697
|
+
expect(config.embedding.dimension).toBe(768);
|
|
761
698
|
});
|
|
762
699
|
|
|
763
700
|
it("writes core.compose.yml to stack/", async () => {
|
|
764
701
|
await performSetup(makeValidSpec());
|
|
765
702
|
|
|
766
703
|
expect(
|
|
767
|
-
existsSync(join(homeDir, "stack", "core.compose.yml"))
|
|
704
|
+
existsSync(join(homeDir, "config", "stack", "core.compose.yml"))
|
|
768
705
|
).toBe(true);
|
|
769
706
|
});
|
|
770
707
|
|
|
771
708
|
it("writes admin and assistant tokens to stack.env", async () => {
|
|
772
709
|
await performSetup(makeValidSpec());
|
|
773
710
|
|
|
774
|
-
const secrets = parseEnvFile(join(
|
|
775
|
-
expect(secrets.
|
|
711
|
+
const secrets = parseEnvFile(join(stackDir, "stack.env"));
|
|
712
|
+
expect(secrets.OP_UI_TOKEN).toBe("test-admin-token-12345");
|
|
776
713
|
expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
|
|
777
714
|
expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
|
|
778
715
|
});
|
|
779
716
|
|
|
780
|
-
it("writes
|
|
717
|
+
it("writes akm config with llm provider and model", async () => {
|
|
781
718
|
await performSetup(makeValidSpec());
|
|
782
719
|
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
expect(
|
|
786
|
-
expect(
|
|
720
|
+
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
721
|
+
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
722
|
+
expect(config.llm.provider).toBe("openai");
|
|
723
|
+
expect(config.llm.model).toBe("gpt-4o");
|
|
724
|
+
expect(config.embedding.model).toBe("text-embedding-3-small");
|
|
787
725
|
});
|
|
788
726
|
});
|
|
789
727
|
|