@openpalm/lib 0.10.2 → 0.11.0-beta.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.
- 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 +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- 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 -24
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +103 -65
- 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 +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +82 -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 +105 -27
- 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 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +138 -239
- package/src/control-plane/setup.ts +215 -130
- 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 +52 -142
- 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 +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- 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,19 +34,9 @@ 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
|
-
|
|
39
|
-
provider: "openai",
|
|
40
|
-
model: "text-embedding-3-small",
|
|
41
|
-
dims: 1536,
|
|
42
|
-
},
|
|
43
|
-
memory: {
|
|
44
|
-
userId: "test_user",
|
|
45
|
-
customInstructions: "",
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
security: { adminToken: "test-admin-token-12345" },
|
|
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" },
|
|
39
|
+
security: { uiLoginPassword: "test-admin-token-12345" },
|
|
49
40
|
owner: { name: "Test User", email: "test@example.com" },
|
|
50
41
|
connections: [
|
|
51
42
|
{
|
|
@@ -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,15 @@ 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
|
-
"
|
|
155
|
-
"OP_ASSISTANT_TOKEN=",
|
|
156
|
-
"OP_MEMORY_TOKEN=",
|
|
138
|
+
"OP_UI_LOGIN_PASSWORD=",
|
|
157
139
|
"OPENAI_API_KEY=",
|
|
158
140
|
"OPENAI_BASE_URL=",
|
|
159
141
|
"ANTHROPIC_API_KEY=",
|
|
@@ -184,51 +166,39 @@ describe("Fresh Install", () => {
|
|
|
184
166
|
rmSync(homeDir, { recursive: true, force: true });
|
|
185
167
|
});
|
|
186
168
|
|
|
187
|
-
// Scenario 1: ensureSecrets
|
|
188
|
-
|
|
169
|
+
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
|
|
170
|
+
// does create stack.env with required keys when files do not exist.
|
|
171
|
+
it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
|
|
189
172
|
const state: ControlPlaneState = {
|
|
190
|
-
adminToken: "",
|
|
191
|
-
assistantToken: "",
|
|
192
|
-
setupToken: "",
|
|
193
173
|
homeDir,
|
|
194
174
|
configDir,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
175
|
+
stashDir: join(homeDir, "stash"),
|
|
176
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
177
|
+
cacheDir,
|
|
178
|
+
stateDir,
|
|
179
|
+
stackDir,
|
|
199
180
|
services: {},
|
|
200
181
|
artifacts: { compose: "" },
|
|
201
182
|
artifactMeta: [],
|
|
202
|
-
audit: [],
|
|
203
183
|
};
|
|
204
184
|
|
|
205
|
-
// No user.env exists yet
|
|
206
|
-
expect(existsSync(join(vaultDir, "user", "user.env"))).toBe(false);
|
|
207
|
-
|
|
208
185
|
ensureSecrets(state);
|
|
209
186
|
|
|
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");
|
|
187
|
+
// API keys and owner info are seeded in state/stack.env.
|
|
188
|
+
const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
216
189
|
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
217
190
|
expect(stackContent).toContain("OWNER_NAME=");
|
|
218
191
|
});
|
|
219
192
|
|
|
220
193
|
// Scenario 2: isSetupComplete returns false before setup
|
|
221
194
|
it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
|
|
222
|
-
mkdirSync(
|
|
223
|
-
mkdirSync(join(vaultDir, "user"), { recursive: true });
|
|
195
|
+
mkdirSync(stateDir, { recursive: true });
|
|
224
196
|
writeFileSync(
|
|
225
|
-
join(
|
|
197
|
+
join(stackDir, "stack.env"),
|
|
226
198
|
"OP_SETUP_COMPLETE=false\n"
|
|
227
199
|
);
|
|
228
|
-
// Empty user.env so fallback check doesn't trigger
|
|
229
|
-
writeFileSync(join(vaultDir, "user", "user.env"), "");
|
|
230
200
|
|
|
231
|
-
expect(isSetupComplete(
|
|
201
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
232
202
|
});
|
|
233
203
|
|
|
234
204
|
// Scenario 3: performSetup succeeds from completely empty state
|
|
@@ -242,15 +212,21 @@ describe("Fresh Install", () => {
|
|
|
242
212
|
expect(result.ok).toBe(true);
|
|
243
213
|
});
|
|
244
214
|
|
|
245
|
-
// Scenario 4: performSetup
|
|
246
|
-
|
|
215
|
+
// Scenario 4: performSetup must NOT mark OP_SETUP_COMPLETE.
|
|
216
|
+
//
|
|
217
|
+
// The flag is set by setup-deploy.ts:startDeploy AFTER the Docker stack is
|
|
218
|
+
// confirmed healthy. If performSetup wrote it eagerly, a deploy failure
|
|
219
|
+
// would leave the wizard convinced setup was complete and bounce the user
|
|
220
|
+
// into a broken admin UI.
|
|
221
|
+
it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
|
|
247
222
|
seedMinimalEnvFiles();
|
|
248
223
|
|
|
249
224
|
await performSetup(makeValidSpec());
|
|
250
225
|
|
|
251
|
-
const stackEnv = readFileSync(join(
|
|
226
|
+
const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
252
227
|
const parsed = parseEnvContent(stackEnv);
|
|
253
|
-
|
|
228
|
+
// Either entirely absent, or still the seeded "false" — never "true".
|
|
229
|
+
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
254
230
|
});
|
|
255
231
|
});
|
|
256
232
|
|
|
@@ -270,113 +246,71 @@ describe("Existing Install", () => {
|
|
|
270
246
|
rmSync(homeDir, { recursive: true, force: true });
|
|
271
247
|
});
|
|
272
248
|
|
|
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);
|
|
249
|
+
// Scenario 5: ensureSecrets does NOT overwrite existing stack.env
|
|
250
|
+
it("ensureSecrets does not overwrite existing stack.env tokens", () => {
|
|
251
|
+
mkdirSync(stateDir, { recursive: true });
|
|
252
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=my-custom-password\n");
|
|
279
253
|
|
|
280
254
|
const state: ControlPlaneState = {
|
|
281
|
-
adminToken: "",
|
|
282
|
-
assistantToken: "",
|
|
283
|
-
setupToken: "",
|
|
284
255
|
homeDir,
|
|
285
256
|
configDir,
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
257
|
+
stashDir: join(homeDir, "stash"),
|
|
258
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
259
|
+
cacheDir,
|
|
260
|
+
stateDir,
|
|
261
|
+
stackDir,
|
|
290
262
|
services: {},
|
|
291
263
|
artifacts: { compose: "" },
|
|
292
264
|
artifactMeta: [],
|
|
293
|
-
audit: [],
|
|
294
265
|
};
|
|
295
266
|
|
|
296
267
|
ensureSecrets(state);
|
|
297
268
|
|
|
298
|
-
|
|
299
|
-
|
|
269
|
+
// Existing password must be preserved
|
|
270
|
+
const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
271
|
+
expect(afterContent).toContain("OP_UI_LOGIN_PASSWORD=my-custom-password");
|
|
300
272
|
});
|
|
301
273
|
|
|
302
|
-
// Scenario 6: performSetup re-run
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
274
|
+
// Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
|
|
275
|
+
// operator supplies a new one in the spec. This is intentional — the
|
|
276
|
+
// wizard "rerun" path is how an operator rotates the password. The
|
|
277
|
+
// legacy OP_ASSISTANT_TOKEN preservation test was removed with the token.
|
|
278
|
+
it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when spec changes", async () => {
|
|
279
|
+
await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
|
|
306
280
|
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
"utf-8"
|
|
310
|
-
);
|
|
311
|
-
const firstMatch = secretsAfterFirst.match(
|
|
312
|
-
/OP_MEMORY_TOKEN=([a-f0-9]+)/
|
|
313
|
-
);
|
|
314
|
-
expect(firstMatch).not.toBeNull();
|
|
315
|
-
const firstToken = firstMatch![1];
|
|
281
|
+
const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
282
|
+
expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
|
|
316
283
|
|
|
317
|
-
|
|
318
|
-
await performSetup(
|
|
319
|
-
makeValidSpec({
|
|
320
|
-
connections: [
|
|
321
|
-
{
|
|
322
|
-
id: "openai-main",
|
|
323
|
-
name: "OpenAI",
|
|
324
|
-
provider: "openai",
|
|
325
|
-
baseUrl: "https://api.openai.com",
|
|
326
|
-
apiKey: "sk-different-key-999",
|
|
327
|
-
},
|
|
328
|
-
],
|
|
329
|
-
})
|
|
330
|
-
);
|
|
284
|
+
await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
|
|
331
285
|
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
"utf-8"
|
|
335
|
-
);
|
|
336
|
-
const secondMatch = secretsAfterSecond.match(
|
|
337
|
-
/OP_MEMORY_TOKEN=([a-f0-9]+)/
|
|
338
|
-
);
|
|
339
|
-
expect(secondMatch).not.toBeNull();
|
|
340
|
-
// OP_MEMORY_TOKEN should be preserved (buildSystemSecretsFromSetup does not overwrite it)
|
|
341
|
-
expect(secondMatch![1]).toBe(firstToken);
|
|
286
|
+
const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
287
|
+
expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
|
|
342
288
|
});
|
|
343
289
|
|
|
344
|
-
// Scenario 7: performSetup
|
|
345
|
-
|
|
290
|
+
// Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
|
|
291
|
+
// 4 in the Fresh Install block for the rationale. The deploy phase owns
|
|
292
|
+
// this flag and only writes it after the container stack is healthy.
|
|
293
|
+
it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
|
|
346
294
|
await performSetup(makeValidSpec());
|
|
347
295
|
|
|
348
296
|
const stackEnv = readFileSync(
|
|
349
|
-
join(
|
|
297
|
+
join(stackDir, "stack.env"),
|
|
350
298
|
"utf-8"
|
|
351
299
|
);
|
|
352
300
|
const parsed = parseEnvContent(stackEnv);
|
|
353
|
-
expect(parsed.OP_SETUP_COMPLETE).toBe(
|
|
301
|
+
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
354
302
|
});
|
|
355
303
|
|
|
356
|
-
// Scenario 8: Re-setup with different provider updates
|
|
357
|
-
it("re-setup with different provider updates
|
|
304
|
+
// Scenario 8: Re-setup with different provider updates akm config
|
|
305
|
+
it("re-setup with different provider updates akm config", async () => {
|
|
358
306
|
// First setup with OpenAI
|
|
359
307
|
await performSetup(makeValidSpec());
|
|
360
308
|
|
|
361
|
-
const specAfterFirst = readStackSpec(configDir);
|
|
362
|
-
expect(specAfterFirst).not.toBeNull();
|
|
363
|
-
expect(specAfterFirst!.capabilities.llm).toContain("openai/");
|
|
364
|
-
|
|
365
309
|
// Second setup with Groq
|
|
366
310
|
await performSetup(
|
|
367
311
|
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
|
-
},
|
|
312
|
+
llm: { provider: "groq", model: "llama3-70b-8192", baseUrl: "https://api.groq.com/openai/v1" },
|
|
313
|
+
embedding: { provider: "groq", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.groq.com/openai/v1" },
|
|
380
314
|
connections: [
|
|
381
315
|
{
|
|
382
316
|
id: "groq-main",
|
|
@@ -389,12 +323,13 @@ describe("Existing Install", () => {
|
|
|
389
323
|
})
|
|
390
324
|
);
|
|
391
325
|
|
|
392
|
-
|
|
326
|
+
// stack.yml is just a version marker now
|
|
327
|
+
const specAfterSecond = readStackSpec(stackDir);
|
|
393
328
|
expect(specAfterSecond).not.toBeNull();
|
|
394
|
-
expect(specAfterSecond!.
|
|
329
|
+
expect(specAfterSecond!.version).toBe(2);
|
|
395
330
|
|
|
396
331
|
// stack.env should retain both keys
|
|
397
|
-
const secrets = readFileSync(join(
|
|
332
|
+
const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
398
333
|
expect(secrets).toContain("GROQ_API_KEY");
|
|
399
334
|
});
|
|
400
335
|
});
|
|
@@ -414,35 +349,32 @@ describe("Broken/Corrupt State", () => {
|
|
|
414
349
|
rmSync(homeDir, { recursive: true, force: true });
|
|
415
350
|
});
|
|
416
351
|
|
|
417
|
-
// Scenario 9:
|
|
418
|
-
it("ensureSecrets
|
|
419
|
-
mkdirSync(
|
|
420
|
-
writeFileSync(join(
|
|
352
|
+
// Scenario 9: ensureSecrets is idempotent on repeated calls
|
|
353
|
+
it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
|
|
354
|
+
mkdirSync(stateDir, { recursive: true });
|
|
355
|
+
writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=existing-password\n");
|
|
421
356
|
|
|
422
357
|
const state: ControlPlaneState = {
|
|
423
|
-
adminToken: "",
|
|
424
|
-
assistantToken: "",
|
|
425
|
-
setupToken: "",
|
|
426
358
|
homeDir,
|
|
427
359
|
configDir,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
360
|
+
stashDir: join(homeDir, "stash"),
|
|
361
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
362
|
+
cacheDir,
|
|
363
|
+
stateDir,
|
|
364
|
+
stackDir,
|
|
432
365
|
services: {},
|
|
433
366
|
artifacts: { compose: "" },
|
|
434
367
|
artifactMeta: [],
|
|
435
|
-
audit: [],
|
|
436
368
|
};
|
|
437
369
|
|
|
438
370
|
ensureSecrets(state);
|
|
439
371
|
|
|
440
|
-
//
|
|
441
|
-
const content = readFileSync(join(
|
|
442
|
-
expect(content).
|
|
372
|
+
// Existing password must be preserved
|
|
373
|
+
const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
|
|
374
|
+
expect(content).toContain("OP_UI_LOGIN_PASSWORD=existing-password");
|
|
443
375
|
});
|
|
444
376
|
|
|
445
|
-
// Scenario 10:
|
|
377
|
+
// Scenario 10: env file with malformed lines
|
|
446
378
|
it("parseEnvFile handles malformed env lines gracefully", () => {
|
|
447
379
|
const malformedContent = [
|
|
448
380
|
"# Comment line",
|
|
@@ -456,10 +388,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
456
388
|
" # indented comment",
|
|
457
389
|
].join("\n");
|
|
458
390
|
|
|
459
|
-
mkdirSync(
|
|
460
|
-
writeFileSync(join(
|
|
391
|
+
mkdirSync(stateDir, { recursive: true });
|
|
392
|
+
writeFileSync(join(stateDir, "test.env"), malformedContent);
|
|
461
393
|
|
|
462
|
-
const parsed = parseEnvFile(join(
|
|
394
|
+
const parsed = parseEnvFile(join(stateDir, "test.env"));
|
|
463
395
|
expect(parsed.VALID_KEY).toBe("valid_value");
|
|
464
396
|
expect(parsed.EXPORTED_KEY).toBe("exported_value");
|
|
465
397
|
expect(parsed.ANOTHER_VALID).toBe("value");
|
|
@@ -468,30 +400,23 @@ describe("Broken/Corrupt State", () => {
|
|
|
468
400
|
// Scenario 11: stack.env missing OP_SETUP_COMPLETE
|
|
469
401
|
it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
|
|
470
402
|
// stack.env without OP_SETUP_COMPLETE
|
|
471
|
-
mkdirSync(
|
|
472
|
-
mkdirSync(join(vaultDir, "user"), { recursive: true });
|
|
403
|
+
mkdirSync(stateDir, { recursive: true });
|
|
473
404
|
writeFileSync(
|
|
474
|
-
join(
|
|
405
|
+
join(stackDir, "stack.env"),
|
|
475
406
|
"OP_IMAGE_TAG=latest\n"
|
|
476
407
|
);
|
|
477
408
|
|
|
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);
|
|
409
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
485
410
|
});
|
|
486
411
|
|
|
487
|
-
it("isSetupComplete falls back to true when
|
|
488
|
-
mkdirSync(
|
|
412
|
+
it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
|
|
413
|
+
mkdirSync(stateDir, { recursive: true });
|
|
489
414
|
writeFileSync(
|
|
490
|
-
join(
|
|
491
|
-
"OP_IMAGE_TAG=latest\nexport
|
|
415
|
+
join(stackDir, "stack.env"),
|
|
416
|
+
"OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
|
|
492
417
|
);
|
|
493
418
|
|
|
494
|
-
expect(isSetupComplete(
|
|
419
|
+
expect(isSetupComplete(stackDir)).toBe(true);
|
|
495
420
|
});
|
|
496
421
|
|
|
497
422
|
// Scenario 12: API key with special characters round-trips
|
|
@@ -512,39 +437,39 @@ describe("Broken/Corrupt State", () => {
|
|
|
512
437
|
|
|
513
438
|
// Scenario 13: Missing stack.yml returns null
|
|
514
439
|
it("readStackSpec returns null when stack.yml missing", () => {
|
|
515
|
-
const spec = readStackSpec(
|
|
440
|
+
const spec = readStackSpec(stackDir);
|
|
516
441
|
expect(spec).toBeNull();
|
|
517
442
|
});
|
|
518
443
|
|
|
519
|
-
// Scenario 14:
|
|
444
|
+
// Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
520
445
|
it("performSetup creates missing subdirectories", async () => {
|
|
521
446
|
// Seed the minimal env files first
|
|
522
447
|
seedMinimalEnvFiles();
|
|
523
448
|
|
|
524
|
-
// Remove
|
|
525
|
-
rmSync(join(
|
|
449
|
+
// Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
|
|
450
|
+
rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
|
|
526
451
|
|
|
527
452
|
const result = await performSetup(
|
|
528
453
|
makeValidSpec()
|
|
529
454
|
);
|
|
530
455
|
expect(result.ok).toBe(true);
|
|
531
456
|
|
|
532
|
-
// Artifacts should exist in
|
|
533
|
-
expect(existsSync(join(homeDir, "stack", "core.compose.yml"))).toBe(
|
|
457
|
+
// Artifacts should exist in config/stack/
|
|
458
|
+
expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
|
|
534
459
|
true
|
|
535
460
|
);
|
|
536
|
-
//
|
|
537
|
-
expect(existsSync(join(
|
|
461
|
+
// stash/tasks dir should be recreated by ensureHomeDirs
|
|
462
|
+
expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
|
|
538
463
|
});
|
|
539
464
|
|
|
540
465
|
// Scenario 15: openpalm.yaml with old version
|
|
541
466
|
it("readStackSpec returns null for version 1 spec", () => {
|
|
542
467
|
writeFileSync(
|
|
543
|
-
join(
|
|
468
|
+
join(stackDir, STACK_SPEC_FILENAME),
|
|
544
469
|
"version: 1\nconnections: []\n"
|
|
545
470
|
);
|
|
546
471
|
|
|
547
|
-
const spec = readStackSpec(
|
|
472
|
+
const spec = readStackSpec(stackDir);
|
|
548
473
|
expect(spec).toBeNull();
|
|
549
474
|
});
|
|
550
475
|
});
|
|
@@ -564,15 +489,15 @@ describe("Environment Edge Cases", () => {
|
|
|
564
489
|
rmSync(homeDir, { recursive: true, force: true });
|
|
565
490
|
});
|
|
566
491
|
|
|
567
|
-
// Scenario 16:
|
|
568
|
-
it("isSetupComplete detects
|
|
569
|
-
mkdirSync(
|
|
492
|
+
// Scenario 16: isSetupComplete picks up OP_UI_LOGIN_PASSWORD when set
|
|
493
|
+
it("isSetupComplete detects OP_UI_LOGIN_PASSWORD", () => {
|
|
494
|
+
mkdirSync(stateDir, { recursive: true });
|
|
570
495
|
writeFileSync(
|
|
571
|
-
join(
|
|
572
|
-
"SOME_OTHER_KEY=value\nexport
|
|
496
|
+
join(stackDir, "stack.env"),
|
|
497
|
+
"SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
|
|
573
498
|
);
|
|
574
499
|
|
|
575
|
-
expect(isSetupComplete(
|
|
500
|
+
expect(isSetupComplete(stackDir)).toBe(true);
|
|
576
501
|
});
|
|
577
502
|
|
|
578
503
|
// Scenario 17: export prefix on env vars
|
|
@@ -628,21 +553,11 @@ describe("Setup Input Variations", () => {
|
|
|
628
553
|
rmSync(homeDir, { recursive: true, force: true });
|
|
629
554
|
});
|
|
630
555
|
|
|
631
|
-
// Scenario 20: Ollama
|
|
632
|
-
it("Ollama
|
|
556
|
+
// Scenario 20: Ollama setup
|
|
557
|
+
it("Ollama setup writes akm config with ollama provider", async () => {
|
|
633
558
|
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
|
-
},
|
|
559
|
+
llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
|
|
560
|
+
embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
|
|
646
561
|
connections: [
|
|
647
562
|
{
|
|
648
563
|
id: "ollama-local",
|
|
@@ -657,23 +572,22 @@ describe("Setup Input Variations", () => {
|
|
|
657
572
|
const result = await performSetup(input);
|
|
658
573
|
expect(result.ok).toBe(true);
|
|
659
574
|
|
|
660
|
-
|
|
661
|
-
const spec = readStackSpec(configDir);
|
|
575
|
+
const spec = readStackSpec(stackDir);
|
|
662
576
|
expect(spec).not.toBeNull();
|
|
663
|
-
expect(spec!.
|
|
577
|
+
expect(spec!.version).toBe(2);
|
|
664
578
|
});
|
|
665
579
|
|
|
666
580
|
// Scenario 21: Multiple providers map to correct env vars
|
|
667
|
-
it("multiple providers each write their API key
|
|
581
|
+
it("multiple providers each write their API key into auth.json keyed by providerId", () => {
|
|
668
582
|
const conns: SetupConnection[] = [
|
|
669
583
|
{ id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-openai" },
|
|
670
584
|
{ id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-groq" },
|
|
671
585
|
{ id: "anthropic-1", name: "Anthropic", provider: "anthropic", baseUrl: "", apiKey: "sk-ant-api03" },
|
|
672
586
|
];
|
|
673
|
-
const
|
|
674
|
-
expect(
|
|
675
|
-
expect(
|
|
676
|
-
expect(
|
|
587
|
+
const keys = buildAuthJsonFromSetup(conns);
|
|
588
|
+
expect(keys.openai).toBe("sk-openai");
|
|
589
|
+
expect(keys.groq).toBe("gsk-groq");
|
|
590
|
+
expect(keys.anthropic).toBe("sk-ant-api03");
|
|
677
591
|
});
|
|
678
592
|
|
|
679
593
|
// Scenario 21b: OAuth providers (no API key) are silently skipped
|
|
@@ -682,19 +596,22 @@ describe("Setup Input Variations", () => {
|
|
|
682
596
|
{ id: "github-copilot", name: "GitHub Copilot", provider: "github-copilot", baseUrl: "", apiKey: "" },
|
|
683
597
|
{ id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-test" },
|
|
684
598
|
];
|
|
685
|
-
const
|
|
686
|
-
expect(
|
|
687
|
-
expect(
|
|
599
|
+
const keys = buildAuthJsonFromSetup(conns);
|
|
600
|
+
expect(keys.openai).toBe("sk-test");
|
|
601
|
+
expect(keys["github-copilot"]).toBeUndefined();
|
|
688
602
|
});
|
|
689
603
|
|
|
690
|
-
// Scenario 22: buildSecretsFromSetup
|
|
691
|
-
|
|
604
|
+
// Scenario 22: buildSecretsFromSetup writes non-credential vars only;
|
|
605
|
+
// API keys flow into auth.json via buildAuthJsonFromSetup.
|
|
606
|
+
it("buildSecretsFromSetup does not write API keys; buildAuthJsonFromSetup does", () => {
|
|
692
607
|
const spec = makeValidSpec();
|
|
693
608
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
609
|
+
const keys = buildAuthJsonFromSetup(spec.connections);
|
|
694
610
|
|
|
695
|
-
// API
|
|
696
|
-
expect(secrets.OPENAI_API_KEY).
|
|
697
|
-
|
|
611
|
+
// API keys go to auth.json, not stack.env
|
|
612
|
+
expect(secrets.OPENAI_API_KEY).toBeUndefined();
|
|
613
|
+
expect(keys.openai).toBe("sk-test-key-123");
|
|
614
|
+
// Config vars (capability resolution) are not in stack.env user-secrets either
|
|
698
615
|
expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
|
|
699
616
|
expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
|
|
700
617
|
expect(secrets.EMBEDDING_MODEL).toBeUndefined();
|
|
@@ -721,69 +638,50 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
721
638
|
it("writes stack.yml and readStackSpec returns v2", async () => {
|
|
722
639
|
await performSetup(makeValidSpec());
|
|
723
640
|
|
|
724
|
-
const spec = readStackSpec(
|
|
641
|
+
const spec = readStackSpec(stackDir);
|
|
725
642
|
expect(spec).not.toBeNull();
|
|
726
643
|
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
644
|
});
|
|
730
645
|
|
|
731
|
-
it("writes
|
|
646
|
+
it("writes akm config with embedding dims from setup spec", async () => {
|
|
732
647
|
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
|
-
},
|
|
648
|
+
llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
|
|
649
|
+
embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
|
|
745
650
|
connections: [
|
|
746
|
-
{
|
|
747
|
-
id: "ollama-1",
|
|
748
|
-
name: "Ollama",
|
|
749
|
-
provider: "ollama",
|
|
750
|
-
baseUrl: "http://localhost:11434",
|
|
751
|
-
apiKey: "",
|
|
752
|
-
},
|
|
651
|
+
{ id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
|
|
753
652
|
],
|
|
754
653
|
});
|
|
755
654
|
|
|
756
655
|
await performSetup(input);
|
|
757
656
|
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
expect(
|
|
657
|
+
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
658
|
+
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
659
|
+
expect(config.embedding.dimension).toBe(768);
|
|
761
660
|
});
|
|
762
661
|
|
|
763
662
|
it("writes core.compose.yml to stack/", async () => {
|
|
764
663
|
await performSetup(makeValidSpec());
|
|
765
664
|
|
|
766
665
|
expect(
|
|
767
|
-
existsSync(join(homeDir, "stack", "core.compose.yml"))
|
|
666
|
+
existsSync(join(homeDir, "config", "stack", "core.compose.yml"))
|
|
768
667
|
).toBe(true);
|
|
769
668
|
});
|
|
770
669
|
|
|
771
|
-
it("writes
|
|
670
|
+
it("writes the UI login password to stack.env", async () => {
|
|
772
671
|
await performSetup(makeValidSpec());
|
|
773
672
|
|
|
774
|
-
const secrets = parseEnvFile(join(
|
|
775
|
-
expect(secrets.
|
|
776
|
-
expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
|
|
777
|
-
expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
|
|
673
|
+
const secrets = parseEnvFile(join(stackDir, "stack.env"));
|
|
674
|
+
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
778
675
|
});
|
|
779
676
|
|
|
780
|
-
it("writes
|
|
677
|
+
it("writes akm config with llm provider and model", async () => {
|
|
781
678
|
await performSetup(makeValidSpec());
|
|
782
679
|
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
expect(
|
|
786
|
-
expect(
|
|
680
|
+
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
681
|
+
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
682
|
+
expect(config.llm.provider).toBe("openai");
|
|
683
|
+
expect(config.llm.model).toBe("gpt-4o");
|
|
684
|
+
expect(config.embedding.model).toBe("text-embedding-3-small");
|
|
787
685
|
});
|
|
788
686
|
});
|
|
789
687
|
|