@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.13
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 -110
- 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
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* - auth.json is copied byte-for-byte and chmodded 0o600. Its contents
|
|
12
12
|
* are never parsed, logged, or returned to callers.
|
|
13
13
|
* - opencode.json is parsed to strip plugin/mcp/permission keys before
|
|
14
|
-
* writing;
|
|
14
|
+
* writing; provider definitions are always kept, and top-level model
|
|
15
|
+
* defaults are imported only when OP_HOME does not already define them.
|
|
15
16
|
* - Conflict detection compares provider IDs; existing credentials are
|
|
16
17
|
* preserved unless overwriteConflicts=true.
|
|
17
18
|
*/
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
21
|
+
import { dirname } from "node:path";
|
|
20
22
|
import type { ControlPlaneState } from "./types.js";
|
|
21
23
|
import { authJsonPath, assistantConfigDir } from "./paths.js";
|
|
22
24
|
|
|
@@ -31,6 +33,8 @@ export type HostOpenCodeStatus = {
|
|
|
31
33
|
providerCount: number;
|
|
32
34
|
/** Number of credential entries in auth.json (0 when not found) */
|
|
33
35
|
credentialCount: number;
|
|
36
|
+
/** Model preferences from the host's opencode.json, if present */
|
|
37
|
+
modelPreferences?: { model?: string; small_model?: string };
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
export type HostImportResult = {
|
|
@@ -64,7 +68,7 @@ function hostAuthJsonPath(): string {
|
|
|
64
68
|
|
|
65
69
|
// ── opencode.json parsing ────────────────────────────────────────────────────
|
|
66
70
|
|
|
67
|
-
/** Keys that are safe to import from host opencode.json into OP_HOME config */
|
|
71
|
+
/** Keys that are safe to import from host opencode.json into OP_HOME config. */
|
|
68
72
|
const ALLOWED_CONFIG_KEYS = new Set(["$schema", "provider", "model", "small_model", "disabled_providers"]);
|
|
69
73
|
|
|
70
74
|
type OpenCodeJson = Record<string, unknown>;
|
|
@@ -78,8 +82,18 @@ function readJsonFileSafe(path: string): OpenCodeJson | null {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
function stripDisallowedKeys(obj: OpenCodeJson): OpenCodeJson {
|
|
85
|
+
const next: OpenCodeJson = {};
|
|
86
|
+
if (typeof obj.$schema === 'string') next.$schema = obj.$schema;
|
|
87
|
+
if (obj.provider && typeof obj.provider === 'object' && !Array.isArray(obj.provider)) {
|
|
88
|
+
next.provider = obj.provider;
|
|
89
|
+
}
|
|
90
|
+
if (typeof obj.model === 'string') next.model = obj.model;
|
|
91
|
+
if (typeof obj.small_model === 'string') next.small_model = obj.small_model;
|
|
92
|
+
if (Array.isArray(obj.disabled_providers) && obj.disabled_providers.every((entry) => typeof entry === 'string')) {
|
|
93
|
+
next.disabled_providers = obj.disabled_providers;
|
|
94
|
+
}
|
|
81
95
|
return Object.fromEntries(
|
|
82
|
-
Object.entries(
|
|
96
|
+
Object.entries(next).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
|
|
83
97
|
);
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -116,9 +130,16 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
|
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
let providerCount = 0;
|
|
133
|
+
let modelPreferences: { model?: string; small_model?: string } | undefined;
|
|
119
134
|
if (configExists) {
|
|
120
135
|
const parsed = readJsonFileSafe(configPath);
|
|
121
136
|
providerCount = parsed ? countProviders(parsed) : 0;
|
|
137
|
+
if (parsed) {
|
|
138
|
+
const prefs: { model?: string; small_model?: string } = {};
|
|
139
|
+
if (typeof parsed.model === 'string' && parsed.model) prefs.model = parsed.model;
|
|
140
|
+
if (typeof parsed.small_model === 'string' && parsed.small_model) prefs.small_model = parsed.small_model;
|
|
141
|
+
if (prefs.model || prefs.small_model) modelPreferences = prefs;
|
|
142
|
+
}
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
let credentialCount = 0;
|
|
@@ -131,6 +152,7 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
|
|
|
131
152
|
authPath: authExists ? authPath : undefined,
|
|
132
153
|
providerCount,
|
|
133
154
|
credentialCount,
|
|
155
|
+
...(modelPreferences ? { modelPreferences } : {}),
|
|
134
156
|
};
|
|
135
157
|
}
|
|
136
158
|
|
|
@@ -181,21 +203,28 @@ export function importHostOpenCode(
|
|
|
181
203
|
}
|
|
182
204
|
}
|
|
183
205
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
const merged: OpenCodeJson = {
|
|
207
|
+
...existing,
|
|
208
|
+
...(typeof existing.$schema === 'undefined' && typeof sanitized.$schema !== 'undefined'
|
|
209
|
+
? { $schema: sanitized.$schema }
|
|
210
|
+
: {}),
|
|
211
|
+
...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const key of ["model", "small_model", "disabled_providers"] as const) {
|
|
215
|
+
if (typeof merged[key] === 'undefined' && typeof sanitized[key] !== 'undefined') {
|
|
216
|
+
merged[key] = sanitized[key];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
|
|
221
|
+
}
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
// ── auth.json ──────────────────────────────────────────────────────────
|
|
195
225
|
if (status.authPath) {
|
|
196
226
|
const destPath = authJsonPath(state);
|
|
197
|
-
|
|
198
|
-
mkdirSync(destDir, { recursive: true });
|
|
227
|
+
mkdirSync(dirname(destPath), { recursive: true, mode: 0o700 });
|
|
199
228
|
|
|
200
229
|
if (existsSync(destPath) && !overwriteConflicts) {
|
|
201
230
|
// Merge: copy only keys that do not already exist in OP_HOME auth.json
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Edge-case tests for the OpenPalm install and setup flow.
|
|
3
3
|
*
|
|
4
4
|
* Each test creates its own temp directory tree mimicking the single
|
|
5
|
-
* ~/.openpalm/ root layout (config,
|
|
5
|
+
* ~/.openpalm/ root layout (config, knowledge, data, logs), then runs the
|
|
6
6
|
* actual library functions against it. No mocks of code under test.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
29
29
|
import type { ControlPlaneState } from "./types.js";
|
|
30
30
|
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
31
|
+
import { readSecret } from './secrets-files.js';
|
|
31
32
|
|
|
32
33
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
33
34
|
|
|
@@ -55,70 +56,70 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
55
56
|
function seedRequiredAssets(homeDir: string): void {
|
|
56
57
|
mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
|
|
57
58
|
writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
58
|
-
mkdirSync(join(homeDir, "
|
|
59
|
-
writeFileSync(join(homeDir, "
|
|
60
|
-
writeFileSync(join(homeDir, "
|
|
61
|
-
mkdirSync(join(homeDir, "
|
|
62
|
-
// Automations live in
|
|
63
|
-
mkdirSync(join(homeDir, "
|
|
64
|
-
writeFileSync(join(homeDir, "
|
|
65
|
-
writeFileSync(join(homeDir, "
|
|
66
|
-
writeFileSync(join(homeDir, "
|
|
59
|
+
mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
|
|
60
|
+
writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
|
|
61
|
+
writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
|
|
62
|
+
mkdirSync(join(homeDir, "data"), { recursive: true });
|
|
63
|
+
// Automations live in knowledge/tasks as AKM-owned task files.
|
|
64
|
+
mkdirSync(join(homeDir, "knowledge", "tasks"), { recursive: true });
|
|
65
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
|
|
66
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
|
|
67
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// ── Shared test fixture ──────────────────────────────────────────────────
|
|
70
71
|
|
|
71
72
|
let homeDir: string;
|
|
72
73
|
let configDir: string;
|
|
73
|
-
let
|
|
74
|
+
let dataDir: string;
|
|
74
75
|
let stackDir: string;
|
|
75
|
-
let cacheDir: string;
|
|
76
76
|
|
|
77
77
|
const savedEnv: Record<string, string | undefined> = {};
|
|
78
78
|
|
|
79
79
|
function saveAndSetEnv(): void {
|
|
80
80
|
savedEnv.OP_HOME = process.env.OP_HOME;
|
|
81
|
+
savedEnv.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
|
|
82
|
+
savedEnv.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
|
|
81
83
|
process.env.OP_HOME = homeDir;
|
|
84
|
+
delete process.env.OP_UI_LOGIN_PASSWORD;
|
|
85
|
+
delete process.env.OP_OPENCODE_PASSWORD;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
function restoreEnv(): void {
|
|
85
|
-
|
|
89
|
+
if (savedEnv.OP_HOME === undefined) delete process.env.OP_HOME;
|
|
90
|
+
else process.env.OP_HOME = savedEnv.OP_HOME;
|
|
91
|
+
if (savedEnv.OP_UI_LOGIN_PASSWORD === undefined) delete process.env.OP_UI_LOGIN_PASSWORD;
|
|
92
|
+
else process.env.OP_UI_LOGIN_PASSWORD = savedEnv.OP_UI_LOGIN_PASSWORD;
|
|
93
|
+
if (savedEnv.OP_OPENCODE_PASSWORD === undefined) delete process.env.OP_OPENCODE_PASSWORD;
|
|
94
|
+
else process.env.OP_OPENCODE_PASSWORD = savedEnv.OP_OPENCODE_PASSWORD;
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
/** Create a full directory tree matching ensureHomeDirs() output. */
|
|
89
98
|
function createFullDirTree(): void {
|
|
90
99
|
homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
|
|
91
100
|
configDir = join(homeDir, "config");
|
|
92
|
-
|
|
101
|
+
dataDir = join(homeDir, "data");
|
|
93
102
|
stackDir = join(configDir, "stack");
|
|
94
|
-
cacheDir = join(homeDir, "cache");
|
|
95
103
|
|
|
96
104
|
for (const dir of [
|
|
97
105
|
homeDir,
|
|
98
106
|
configDir,
|
|
99
|
-
join(homeDir, "state", "registry", "automations"),
|
|
100
107
|
join(configDir, "assistant"),
|
|
101
108
|
join(configDir, "akm"),
|
|
102
|
-
join(homeDir, "
|
|
109
|
+
join(homeDir, "knowledge"),
|
|
110
|
+
join(homeDir, "knowledge", "env"),
|
|
111
|
+
join(homeDir, "knowledge", "secrets"),
|
|
103
112
|
join(homeDir, "workspace"),
|
|
104
113
|
stackDir,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
join(
|
|
108
|
-
join(
|
|
109
|
-
join(
|
|
110
|
-
join(
|
|
111
|
-
join(
|
|
112
|
-
join(
|
|
113
|
-
join(
|
|
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"),
|
|
114
|
+
dataDir,
|
|
115
|
+
join(dataDir, "assistant"),
|
|
116
|
+
join(dataDir, "admin"),
|
|
117
|
+
join(dataDir, "guardian"),
|
|
118
|
+
join(dataDir, "akm", "cache"),
|
|
119
|
+
join(dataDir, "akm", "data"),
|
|
120
|
+
join(dataDir, "logs"),
|
|
121
|
+
join(dataDir, "backups"),
|
|
122
|
+
join(dataDir, "rollback"),
|
|
122
123
|
]) {
|
|
123
124
|
mkdirSync(dir, { recursive: true });
|
|
124
125
|
}
|
|
@@ -132,16 +133,10 @@ function seedMinimalEnvFiles(): void {
|
|
|
132
133
|
mkdirSync(stackDir, { recursive: true });
|
|
133
134
|
|
|
134
135
|
writeFileSync(
|
|
135
|
-
join(
|
|
136
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
136
137
|
[
|
|
137
138
|
"# OpenPalm — Stack Configuration",
|
|
138
|
-
"OP_UI_LOGIN_PASSWORD=",
|
|
139
|
-
"OPENAI_API_KEY=",
|
|
140
139
|
"OPENAI_BASE_URL=",
|
|
141
|
-
"ANTHROPIC_API_KEY=",
|
|
142
|
-
"GROQ_API_KEY=",
|
|
143
|
-
"MISTRAL_API_KEY=",
|
|
144
|
-
"GOOGLE_API_KEY=",
|
|
145
140
|
"OP_OWNER_NAME=",
|
|
146
141
|
"OP_OWNER_EMAIL=",
|
|
147
142
|
"",
|
|
@@ -166,16 +161,15 @@ describe("Fresh Install", () => {
|
|
|
166
161
|
rmSync(homeDir, { recursive: true, force: true });
|
|
167
162
|
});
|
|
168
163
|
|
|
169
|
-
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-
|
|
164
|
+
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-user-env) but
|
|
170
165
|
// does create stack.env with required keys when files do not exist.
|
|
171
|
-
it("ensureSecrets creates
|
|
166
|
+
it("ensureSecrets creates stack.env with required keys on fresh install", () => {
|
|
172
167
|
const state: ControlPlaneState = {
|
|
173
168
|
homeDir,
|
|
174
169
|
configDir,
|
|
175
|
-
stashDir: join(homeDir, "
|
|
170
|
+
stashDir: join(homeDir, "knowledge"),
|
|
176
171
|
workspaceDir: join(homeDir, "workspace"),
|
|
177
|
-
|
|
178
|
-
stateDir,
|
|
172
|
+
dataDir,
|
|
179
173
|
stackDir,
|
|
180
174
|
services: {},
|
|
181
175
|
artifacts: { compose: "" },
|
|
@@ -184,17 +178,18 @@ describe("Fresh Install", () => {
|
|
|
184
178
|
|
|
185
179
|
ensureSecrets(state);
|
|
186
180
|
|
|
187
|
-
//
|
|
188
|
-
const stackContent = readFileSync(join(
|
|
189
|
-
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
190
|
-
expect(stackContent).toContain("
|
|
181
|
+
// stack.env only carries non-secret setup/config keys.
|
|
182
|
+
const stackContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
183
|
+
expect(stackContent).not.toContain("OPENAI_API_KEY=");
|
|
184
|
+
expect(stackContent).toContain("OP_SETUP_COMPLETE=false");
|
|
185
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
|
|
191
186
|
});
|
|
192
187
|
|
|
193
188
|
// Scenario 2: isSetupComplete returns false before setup
|
|
194
189
|
it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
|
|
195
|
-
mkdirSync(
|
|
190
|
+
mkdirSync(dataDir, { recursive: true });
|
|
196
191
|
writeFileSync(
|
|
197
|
-
join(
|
|
192
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
198
193
|
"OP_SETUP_COMPLETE=false\n"
|
|
199
194
|
);
|
|
200
195
|
|
|
@@ -223,7 +218,7 @@ describe("Fresh Install", () => {
|
|
|
223
218
|
|
|
224
219
|
await performSetup(makeValidSpec());
|
|
225
220
|
|
|
226
|
-
const stackEnv = readFileSync(join(
|
|
221
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
227
222
|
const parsed = parseEnvContent(stackEnv);
|
|
228
223
|
// Either entirely absent, or still the seeded "false" — never "true".
|
|
229
224
|
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
@@ -246,18 +241,17 @@ describe("Existing Install", () => {
|
|
|
246
241
|
rmSync(homeDir, { recursive: true, force: true });
|
|
247
242
|
});
|
|
248
243
|
|
|
249
|
-
// Scenario 5: ensureSecrets
|
|
250
|
-
it("ensureSecrets
|
|
251
|
-
mkdirSync(
|
|
252
|
-
writeFileSync(join(
|
|
244
|
+
// Scenario 5: ensureSecrets creates file-based secrets without stack.env tokens
|
|
245
|
+
it("ensureSecrets creates file-based system secrets", () => {
|
|
246
|
+
mkdirSync(dataDir, { recursive: true });
|
|
247
|
+
writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
|
|
253
248
|
|
|
254
249
|
const state: ControlPlaneState = {
|
|
255
250
|
homeDir,
|
|
256
251
|
configDir,
|
|
257
|
-
stashDir: join(homeDir, "
|
|
252
|
+
stashDir: join(homeDir, "knowledge"),
|
|
258
253
|
workspaceDir: join(homeDir, "workspace"),
|
|
259
|
-
|
|
260
|
-
stateDir,
|
|
254
|
+
dataDir,
|
|
261
255
|
stackDir,
|
|
262
256
|
services: {},
|
|
263
257
|
artifacts: { compose: "" },
|
|
@@ -266,25 +260,23 @@ describe("Existing Install", () => {
|
|
|
266
260
|
|
|
267
261
|
ensureSecrets(state);
|
|
268
262
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
expect(
|
|
263
|
+
const afterContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
264
|
+
expect(afterContent).not.toContain("OP_UI_LOGIN_PASSWORD=");
|
|
265
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
|
|
272
266
|
});
|
|
273
267
|
|
|
274
268
|
// Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
|
|
275
269
|
// operator supplies a new one in the spec. This is intentional — the
|
|
276
270
|
// wizard "rerun" path is how an operator rotates the password. The
|
|
277
271
|
// 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 () => {
|
|
272
|
+
it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD secret file when spec changes", async () => {
|
|
279
273
|
await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
|
|
280
274
|
|
|
281
|
-
|
|
282
|
-
expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
|
|
275
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("first-password-12345\n");
|
|
283
276
|
|
|
284
277
|
await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
|
|
285
278
|
|
|
286
|
-
|
|
287
|
-
expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
|
|
279
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("second-password-12345\n");
|
|
288
280
|
});
|
|
289
281
|
|
|
290
282
|
// Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
|
|
@@ -294,7 +286,7 @@ describe("Existing Install", () => {
|
|
|
294
286
|
await performSetup(makeValidSpec());
|
|
295
287
|
|
|
296
288
|
const stackEnv = readFileSync(
|
|
297
|
-
join(
|
|
289
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
298
290
|
"utf-8"
|
|
299
291
|
);
|
|
300
292
|
const parsed = parseEnvContent(stackEnv);
|
|
@@ -328,9 +320,8 @@ describe("Existing Install", () => {
|
|
|
328
320
|
expect(specAfterSecond).not.toBeNull();
|
|
329
321
|
expect(specAfterSecond!.version).toBe(2);
|
|
330
322
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(secrets).toContain("GROQ_API_KEY");
|
|
323
|
+
const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
|
|
324
|
+
expect(auth.groq.key).toBe("gsk-test-key-456");
|
|
334
325
|
});
|
|
335
326
|
});
|
|
336
327
|
|
|
@@ -351,16 +342,15 @@ describe("Broken/Corrupt State", () => {
|
|
|
351
342
|
|
|
352
343
|
// Scenario 9: ensureSecrets is idempotent on repeated calls
|
|
353
344
|
it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
|
|
354
|
-
mkdirSync(
|
|
355
|
-
writeFileSync(join(
|
|
345
|
+
mkdirSync(dataDir, { recursive: true });
|
|
346
|
+
writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
|
|
356
347
|
|
|
357
348
|
const state: ControlPlaneState = {
|
|
358
349
|
homeDir,
|
|
359
350
|
configDir,
|
|
360
|
-
stashDir: join(homeDir, "
|
|
351
|
+
stashDir: join(homeDir, "knowledge"),
|
|
361
352
|
workspaceDir: join(homeDir, "workspace"),
|
|
362
|
-
|
|
363
|
-
stateDir,
|
|
353
|
+
dataDir,
|
|
364
354
|
stackDir,
|
|
365
355
|
services: {},
|
|
366
356
|
artifacts: { compose: "" },
|
|
@@ -369,9 +359,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
369
359
|
|
|
370
360
|
ensureSecrets(state);
|
|
371
361
|
|
|
372
|
-
// Existing
|
|
373
|
-
const content = readFileSync(join(
|
|
374
|
-
expect(content).toContain("
|
|
362
|
+
// Existing non-secret stack config must be preserved.
|
|
363
|
+
const content = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
364
|
+
expect(content).toContain("OP_SETUP_COMPLETE=false");
|
|
365
|
+
expect(content).not.toContain("OP_UI_LOGIN_PASSWORD=");
|
|
375
366
|
});
|
|
376
367
|
|
|
377
368
|
// Scenario 10: env file with malformed lines
|
|
@@ -388,10 +379,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
388
379
|
" # indented comment",
|
|
389
380
|
].join("\n");
|
|
390
381
|
|
|
391
|
-
mkdirSync(
|
|
392
|
-
writeFileSync(join(
|
|
382
|
+
mkdirSync(dataDir, { recursive: true });
|
|
383
|
+
writeFileSync(join(dataDir, "test.env"), malformedContent);
|
|
393
384
|
|
|
394
|
-
const parsed = parseEnvFile(join(
|
|
385
|
+
const parsed = parseEnvFile(join(dataDir, "test.env"));
|
|
395
386
|
expect(parsed.VALID_KEY).toBe("valid_value");
|
|
396
387
|
expect(parsed.EXPORTED_KEY).toBe("exported_value");
|
|
397
388
|
expect(parsed.ANOTHER_VALID).toBe("value");
|
|
@@ -400,23 +391,25 @@ describe("Broken/Corrupt State", () => {
|
|
|
400
391
|
// Scenario 11: stack.env missing OP_SETUP_COMPLETE
|
|
401
392
|
it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
|
|
402
393
|
// stack.env without OP_SETUP_COMPLETE
|
|
403
|
-
mkdirSync(
|
|
394
|
+
mkdirSync(dataDir, { recursive: true });
|
|
404
395
|
writeFileSync(
|
|
405
|
-
join(
|
|
396
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
406
397
|
"OP_IMAGE_TAG=latest\n"
|
|
407
398
|
);
|
|
408
399
|
|
|
409
400
|
expect(isSetupComplete(stackDir)).toBe(false);
|
|
410
401
|
});
|
|
411
402
|
|
|
412
|
-
it("isSetupComplete
|
|
413
|
-
mkdirSync(
|
|
403
|
+
it("isSetupComplete returns false when OP_UI_LOGIN_PASSWORD is set but OP_SETUP_COMPLETE is missing", () => {
|
|
404
|
+
mkdirSync(dataDir, { recursive: true });
|
|
414
405
|
writeFileSync(
|
|
415
|
-
join(
|
|
406
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
416
407
|
"OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
|
|
417
408
|
);
|
|
418
409
|
|
|
419
|
-
|
|
410
|
+
// Password alone is no longer a proxy for setup completion.
|
|
411
|
+
// Only OP_SETUP_COMPLETE=true counts.
|
|
412
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
420
413
|
});
|
|
421
414
|
|
|
422
415
|
// Scenario 12: API key with special characters round-trips
|
|
@@ -441,13 +434,13 @@ describe("Broken/Corrupt State", () => {
|
|
|
441
434
|
expect(spec).toBeNull();
|
|
442
435
|
});
|
|
443
436
|
|
|
444
|
-
// Scenario 14:
|
|
437
|
+
// Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
445
438
|
it("performSetup creates missing subdirectories", async () => {
|
|
446
439
|
// Seed the minimal env files first
|
|
447
440
|
seedMinimalEnvFiles();
|
|
448
441
|
|
|
449
|
-
// Remove
|
|
450
|
-
rmSync(join(homeDir, "
|
|
442
|
+
// Remove knowledge/tasks dir (performSetup should recreate it via ensureHomeDirs)
|
|
443
|
+
rmSync(join(homeDir, "knowledge", "tasks"), { recursive: true, force: true });
|
|
451
444
|
|
|
452
445
|
const result = await performSetup(
|
|
453
446
|
makeValidSpec()
|
|
@@ -458,8 +451,8 @@ describe("Broken/Corrupt State", () => {
|
|
|
458
451
|
expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
|
|
459
452
|
true
|
|
460
453
|
);
|
|
461
|
-
//
|
|
462
|
-
expect(existsSync(join(homeDir, "
|
|
454
|
+
// knowledge/tasks dir should be recreated by ensureHomeDirs
|
|
455
|
+
expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
|
|
463
456
|
});
|
|
464
457
|
|
|
465
458
|
// Scenario 15: openpalm.yaml with old version
|
|
@@ -489,15 +482,15 @@ describe("Environment Edge Cases", () => {
|
|
|
489
482
|
rmSync(homeDir, { recursive: true, force: true });
|
|
490
483
|
});
|
|
491
484
|
|
|
492
|
-
// Scenario 16: isSetupComplete
|
|
493
|
-
it("isSetupComplete
|
|
494
|
-
mkdirSync(
|
|
485
|
+
// Scenario 16: isSetupComplete requires explicit OP_SETUP_COMPLETE=true
|
|
486
|
+
it("isSetupComplete returns false when only OP_UI_LOGIN_PASSWORD is set", () => {
|
|
487
|
+
mkdirSync(dataDir, { recursive: true });
|
|
495
488
|
writeFileSync(
|
|
496
|
-
join(
|
|
489
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
497
490
|
"SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
|
|
498
491
|
);
|
|
499
492
|
|
|
500
|
-
expect(isSetupComplete(stackDir)).toBe(
|
|
493
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
501
494
|
});
|
|
502
495
|
|
|
503
496
|
// Scenario 17: export prefix on env vars
|
|
@@ -667,11 +660,10 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
667
660
|
).toBe(true);
|
|
668
661
|
});
|
|
669
662
|
|
|
670
|
-
it("writes the UI login password to
|
|
663
|
+
it("writes the UI login password to a secret file", async () => {
|
|
671
664
|
await performSetup(makeValidSpec());
|
|
672
665
|
|
|
673
|
-
|
|
674
|
-
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
666
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
|
|
675
667
|
});
|
|
676
668
|
|
|
677
669
|
it("writes akm config with llm provider and model", async () => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both `performSetup` (config writes) and `startDeploy` (Docker work) need an
|
|
5
5
|
* exclusive lock against concurrent installs. The lock file lives at
|
|
6
|
-
* `<
|
|
6
|
+
* `<dataDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
|
|
7
7
|
*
|
|
8
8
|
* Self-healing rules:
|
|
9
9
|
* - On EEXIST, parse the holder PID. If the process is gone (`process.kill(pid, 0)`
|
|
@@ -89,23 +89,23 @@ function tryCreate(path: string): boolean {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Try to acquire the install lock under `
|
|
92
|
+
* Try to acquire the install lock under `dataDir`. Returns a handle on
|
|
93
93
|
* success or null if the lock is held by a live, recent install (or on any
|
|
94
94
|
* unexpected filesystem error — caller should surface "install_in_progress").
|
|
95
95
|
*
|
|
96
96
|
* Callers MUST call `releaseInstallLock()` in a finally block when done.
|
|
97
97
|
*/
|
|
98
|
-
export function acquireInstallLock(
|
|
98
|
+
export function acquireInstallLock(dataDir: string): InstallLockHandle | null {
|
|
99
99
|
try {
|
|
100
|
-
mkdirSync(
|
|
100
|
+
mkdirSync(dataDir, { recursive: true });
|
|
101
101
|
} catch (err) {
|
|
102
|
-
logger.warn("failed to ensure
|
|
103
|
-
|
|
102
|
+
logger.warn("failed to ensure data dir for install lock", {
|
|
103
|
+
dataDir,
|
|
104
104
|
error: err instanceof Error ? err.message : String(err),
|
|
105
105
|
});
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
|
-
const path = join(
|
|
108
|
+
const path = join(dataDir, ".install.lock");
|
|
109
109
|
|
|
110
110
|
try {
|
|
111
111
|
if (tryCreate(path)) return { path };
|