@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.3
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/package.json +1 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- package/src/control-plane/compose-args.test.ts +0 -3
- 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 +1 -2
- package/src/control-plane/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +26 -66
- package/src/control-plane/lifecycle.ts +8 -40
- 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 +8 -1
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +64 -0
- package/src/control-plane/registry.ts +113 -0
- package/src/control-plane/secret-backend.test.ts +5 -8
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +13 -7
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +18 -14
- package/src/control-plane/setup.ts +22 -36
- package/src/control-plane/spec-to-env.ts +12 -1
- package/src/control-plane/types.ts +0 -18
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +13 -4
- package/src/logger.ts +1 -1
- package/src/control-plane/audit.ts +0 -41
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the 0.11.0 auth-migration shim.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
chmodSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { migrateAuth0110 } from "./migrate-0110.js";
|
|
18
|
+
import type { ControlPlaneState } from "./types.js";
|
|
19
|
+
|
|
20
|
+
function makeState(homeDir: string): ControlPlaneState {
|
|
21
|
+
return {
|
|
22
|
+
homeDir,
|
|
23
|
+
configDir: join(homeDir, "config"),
|
|
24
|
+
stashDir: join(homeDir, "stash"),
|
|
25
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
26
|
+
cacheDir: join(homeDir, "cache"),
|
|
27
|
+
stateDir: join(homeDir, "state"),
|
|
28
|
+
stackDir: join(homeDir, "config", "stack"),
|
|
29
|
+
services: {},
|
|
30
|
+
artifacts: { compose: "" },
|
|
31
|
+
artifactMeta: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function seedStackEnv(stackDir: string, content: string): string {
|
|
36
|
+
mkdirSync(stackDir, { recursive: true });
|
|
37
|
+
const path = join(stackDir, "stack.env");
|
|
38
|
+
writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
|
|
39
|
+
chmodSync(path, 0o600);
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("migrateAuth0110", () => {
|
|
44
|
+
let homeDir: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("no-ops on a fresh install (no stack.env)", () => {
|
|
55
|
+
const state = makeState(homeDir);
|
|
56
|
+
const result = migrateAuth0110(state);
|
|
57
|
+
expect(result.migrated).toBe(false);
|
|
58
|
+
expect(result.reason).toContain("fresh install");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
|
|
62
|
+
const state = makeState(homeDir);
|
|
63
|
+
const stackEnvPath = seedStackEnv(
|
|
64
|
+
state.stackDir,
|
|
65
|
+
[
|
|
66
|
+
"# header",
|
|
67
|
+
"OP_UI_TOKEN=legacy-token-value",
|
|
68
|
+
"OP_ASSISTANT_TOKEN=some-assistant-token",
|
|
69
|
+
"OP_OPENCODE_PASSWORD=opencode-secret",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const result = migrateAuth0110(state);
|
|
75
|
+
expect(result.migrated).toBe(true);
|
|
76
|
+
expect(result.reason).toContain("promoted OP_UI_TOKEN");
|
|
77
|
+
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
78
|
+
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
79
|
+
|
|
80
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
81
|
+
expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
|
|
82
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
83
|
+
expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
84
|
+
// Unrelated keys preserved
|
|
85
|
+
expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
|
|
86
|
+
|
|
87
|
+
// Perms preserved
|
|
88
|
+
expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
|
|
89
|
+
|
|
90
|
+
// Migration log appended
|
|
91
|
+
const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
|
|
92
|
+
expect(existsSync(logPath)).toBe(true);
|
|
93
|
+
const log = readFileSync(logPath, "utf-8");
|
|
94
|
+
expect(log).toContain("migrate-auth-0110");
|
|
95
|
+
expect(log).toContain("promoted OP_UI_TOKEN");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
|
|
99
|
+
const state = makeState(homeDir);
|
|
100
|
+
const stackEnvPath = seedStackEnv(
|
|
101
|
+
state.stackDir,
|
|
102
|
+
[
|
|
103
|
+
"OP_UI_LOGIN_PASSWORD=new-password",
|
|
104
|
+
"OP_UI_TOKEN=legacy-value",
|
|
105
|
+
"",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = migrateAuth0110(state);
|
|
110
|
+
expect(result.migrated).toBe(true);
|
|
111
|
+
expect(result.reason).not.toContain("promoted");
|
|
112
|
+
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
113
|
+
|
|
114
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
115
|
+
expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
|
|
116
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
|
|
120
|
+
const state = makeState(homeDir);
|
|
121
|
+
const stackEnvPath = seedStackEnv(
|
|
122
|
+
state.stackDir,
|
|
123
|
+
[
|
|
124
|
+
"OP_UI_LOGIN_PASSWORD=pw",
|
|
125
|
+
"OP_ASSISTANT_TOKEN=stale",
|
|
126
|
+
"",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const result = migrateAuth0110(state);
|
|
131
|
+
expect(result.migrated).toBe(true);
|
|
132
|
+
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
133
|
+
expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("is idempotent: second run reports already-migrated", () => {
|
|
137
|
+
const state = makeState(homeDir);
|
|
138
|
+
seedStackEnv(
|
|
139
|
+
state.stackDir,
|
|
140
|
+
[
|
|
141
|
+
"OP_UI_TOKEN=t",
|
|
142
|
+
"OP_ASSISTANT_TOKEN=t2",
|
|
143
|
+
"",
|
|
144
|
+
].join("\n"),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const first = migrateAuth0110(state);
|
|
148
|
+
expect(first.migrated).toBe(true);
|
|
149
|
+
|
|
150
|
+
const second = migrateAuth0110(state);
|
|
151
|
+
expect(second.migrated).toBe(false);
|
|
152
|
+
expect(second.reason).toContain("already migrated");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
|
|
156
|
+
const state = makeState(homeDir);
|
|
157
|
+
const stackEnvPath = seedStackEnv(
|
|
158
|
+
state.stackDir,
|
|
159
|
+
[
|
|
160
|
+
"OP_UI_TOKEN=",
|
|
161
|
+
"OP_ASSISTANT_TOKEN=foo",
|
|
162
|
+
"",
|
|
163
|
+
].join("\n"),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const result = migrateAuth0110(state);
|
|
167
|
+
expect(result.migrated).toBe(true);
|
|
168
|
+
// Empty-string OP_UI_TOKEN should NOT be promoted as a password.
|
|
169
|
+
expect(result.reason).not.toContain("promoted");
|
|
170
|
+
|
|
171
|
+
const after = readFileSync(stackEnvPath, "utf-8");
|
|
172
|
+
// The empty OP_UI_TOKEN line is still removed.
|
|
173
|
+
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
174
|
+
// No OP_UI_LOGIN_PASSWORD added (would be an empty value).
|
|
175
|
+
expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot migration for the 0.11.0 auth refactor.
|
|
3
|
+
*
|
|
4
|
+
* Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
|
|
5
|
+
* config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
|
|
6
|
+
* replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
|
|
7
|
+
* operators get locked out the moment they run the new UI build because the
|
|
8
|
+
* login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
|
|
9
|
+
* which is empty on existing installs.
|
|
10
|
+
*
|
|
11
|
+
* Migration logic (idempotent):
|
|
12
|
+
* - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
|
|
13
|
+
* OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
|
|
14
|
+
* - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
|
|
15
|
+
* no longer used).
|
|
16
|
+
* - Append a one-line summary to state/logs/migration-0.11.0.log.
|
|
17
|
+
* - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
|
|
18
|
+
* already migrated or set up fresh.
|
|
19
|
+
*
|
|
20
|
+
* Called from ensureSecrets so it runs before any auth-required code path
|
|
21
|
+
* gets a chance to see the half-migrated state.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
chmodSync,
|
|
28
|
+
appendFileSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { dirname } from "node:path";
|
|
32
|
+
import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
|
|
33
|
+
import { migration0110LogPath } from "./paths.js";
|
|
34
|
+
import type { ControlPlaneState } from "./types.js";
|
|
35
|
+
|
|
36
|
+
export type MigrateAuth0110Result = {
|
|
37
|
+
/** True if any change was written to stack.env. */
|
|
38
|
+
migrated: boolean;
|
|
39
|
+
/** Human-readable description of what changed (or why nothing did). */
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
|
|
44
|
+
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
45
|
+
if (!existsSync(stackEnvPath)) {
|
|
46
|
+
return { migrated: false, reason: "no stack.env yet (fresh install)" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const before = readFileSync(stackEnvPath, "utf-8");
|
|
50
|
+
const parsed = parseEnvContent(before);
|
|
51
|
+
const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
|
|
52
|
+
const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
|
|
53
|
+
const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
|
|
54
|
+
const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
|
|
55
|
+
|
|
56
|
+
if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
|
|
57
|
+
return { migrated: false, reason: "already migrated" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let content = before;
|
|
61
|
+
const changes: string[] = [];
|
|
62
|
+
|
|
63
|
+
if (!hasLoginPw && hasUiToken) {
|
|
64
|
+
content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
|
|
65
|
+
changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
|
|
66
|
+
}
|
|
67
|
+
if (hasUiTokenLine) {
|
|
68
|
+
content = removeEnvKey(content, "OP_UI_TOKEN");
|
|
69
|
+
changes.push("removed OP_UI_TOKEN");
|
|
70
|
+
}
|
|
71
|
+
if (hasAssistantToken) {
|
|
72
|
+
content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
|
|
73
|
+
changes.push("removed OP_ASSISTANT_TOKEN");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (changes.length === 0) {
|
|
77
|
+
return { migrated: false, reason: "no changes needed" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Preserve the 0600 mode the existing file should already have.
|
|
81
|
+
writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
82
|
+
try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
|
|
83
|
+
|
|
84
|
+
// Best-effort audit line. The migration log is small and append-only;
|
|
85
|
+
// if it fails (perm error, fs full), we don't roll back the migration.
|
|
86
|
+
try {
|
|
87
|
+
const logPath = migration0110LogPath(state);
|
|
88
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
89
|
+
appendFileSync(
|
|
90
|
+
logPath,
|
|
91
|
+
`${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
|
|
92
|
+
"utf-8",
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
/* best-effort */
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { migrated: true, reason: changes.join("; ") };
|
|
99
|
+
}
|
|
@@ -52,8 +52,15 @@ export const akmStateDir = (s: ControlPlaneState): string => `${s.stat
|
|
|
52
52
|
export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
|
|
53
53
|
export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
|
|
54
54
|
export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Guardian's own audit log of channel ingress (HMAC verify, replay, rate
|
|
57
|
+
* limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
|
|
58
|
+
* `admin-audit.jsonl` — OpenCode session logs are the audit trail for
|
|
59
|
+
* chat + tool activity.
|
|
60
|
+
*/
|
|
56
61
|
export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
|
|
62
|
+
/** One-shot 0.11.0 migration log (OP_UI_TOKEN → OPENCODE_SERVER_PASSWORD, endpoints.json move) */
|
|
63
|
+
export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
|
|
57
64
|
export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
|
|
58
65
|
export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
|
|
59
66
|
export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
|
|
@@ -343,8 +343,9 @@ describe("registry component sensitive fields", () => {
|
|
|
343
343
|
|
|
344
344
|
for (const id of fullAddonIds) {
|
|
345
345
|
it(`${id}: has at least one @sensitive field (channel secret)`, () => {
|
|
346
|
-
// ollama
|
|
347
|
-
|
|
346
|
+
// ollama and voice are local inference servers — no channel secret
|
|
347
|
+
// or upstream API key needed (LAN-only, no auth by design).
|
|
348
|
+
if (id === "ollama" || id === "voice") return;
|
|
348
349
|
const schema = readComponentFile(id, ".env.schema");
|
|
349
350
|
const entries = parseEnvSchema(schema);
|
|
350
351
|
const sensitiveEntries = entries.filter((e) =>
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
getRegistryAddonConfig,
|
|
22
22
|
listAvailableAddonIds,
|
|
23
23
|
getAddonServiceNames,
|
|
24
|
+
getAddonProfiles,
|
|
25
|
+
getAddonProfileSelection,
|
|
26
|
+
setAddonProfileSelection,
|
|
24
27
|
enableAddon,
|
|
25
28
|
disableAddonByName,
|
|
26
29
|
setAddonEnabled,
|
|
@@ -391,6 +394,67 @@ describe("materialized registry catalog", () => {
|
|
|
391
394
|
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
392
395
|
});
|
|
393
396
|
|
|
397
|
+
it("parses compose profiles + openpalm.profile.* labels per addon", () => {
|
|
398
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
399
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
400
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
401
|
+
|
|
402
|
+
mkdirSync(addonDir, { recursive: true });
|
|
403
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
404
|
+
writeFileSync(
|
|
405
|
+
join(addonDir, 'compose.yml'),
|
|
406
|
+
[
|
|
407
|
+
'services:',
|
|
408
|
+
' voice:',
|
|
409
|
+
' profiles: [cpu]',
|
|
410
|
+
' image: openpalm/voice:cpu',
|
|
411
|
+
' labels:',
|
|
412
|
+
' openpalm.profile.label: CPU',
|
|
413
|
+
' openpalm.profile.default: "true"',
|
|
414
|
+
' voice-cuda:',
|
|
415
|
+
' profiles: [cuda]',
|
|
416
|
+
' image: openpalm/voice:cuda',
|
|
417
|
+
' labels:',
|
|
418
|
+
' openpalm.profile.label: NVIDIA',
|
|
419
|
+
' openpalm.profile.requires: nvidia-container-toolkit',
|
|
420
|
+
'',
|
|
421
|
+
].join('\n'),
|
|
422
|
+
);
|
|
423
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
424
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
425
|
+
|
|
426
|
+
materializeRegistryCatalog(sourceRoot);
|
|
427
|
+
|
|
428
|
+
const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
|
|
429
|
+
expect(profiles).toEqual([
|
|
430
|
+
{ id: 'cpu', services: ['voice'], label: 'CPU', default: true },
|
|
431
|
+
{ id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("round-trips addon profile selection through stack.env", () => {
|
|
436
|
+
const sourceRoot = join(tmpDir, 'repo');
|
|
437
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
|
|
438
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
439
|
+
|
|
440
|
+
mkdirSync(addonDir, { recursive: true });
|
|
441
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
442
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
|
|
443
|
+
writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
|
|
444
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
445
|
+
|
|
446
|
+
materializeRegistryCatalog(sourceRoot);
|
|
447
|
+
|
|
448
|
+
const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
|
|
449
|
+
mkdirSync(stackDir, { recursive: true });
|
|
450
|
+
writeFileSync(join(stackDir, 'stack.env'), '');
|
|
451
|
+
|
|
452
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
|
|
453
|
+
setAddonProfileSelection(stackDir, 'voice', 'cuda');
|
|
454
|
+
expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
|
|
455
|
+
expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
|
|
456
|
+
});
|
|
457
|
+
|
|
394
458
|
it("installs and uninstalls automations through stash/tasks", () => {
|
|
395
459
|
const sourceRoot = join(tmpDir, 'repo');
|
|
396
460
|
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
@@ -12,6 +12,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
12
12
|
import { createLogger } from '../logger.js';
|
|
13
13
|
import { isChannelAddon } from './channels.js';
|
|
14
14
|
import { randomHex, writeChannelSecrets } from './config-persistence.js';
|
|
15
|
+
import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
|
|
15
16
|
import {
|
|
16
17
|
resolveRegistryAddonsDir,
|
|
17
18
|
resolveRegistryAutomationsDir,
|
|
@@ -339,6 +340,118 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
|
339
340
|
return [];
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
export type AddonProfile = {
|
|
344
|
+
id: string;
|
|
345
|
+
services: string[];
|
|
346
|
+
label?: string;
|
|
347
|
+
requires?: string;
|
|
348
|
+
default?: boolean;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
352
|
+
if (!existsSync(composePath)) return [];
|
|
353
|
+
|
|
354
|
+
let parsed: unknown;
|
|
355
|
+
try {
|
|
356
|
+
parsed = parseYaml(readFileSync(composePath, "utf-8"));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
logger.warn("failed to parse addon compose profiles", {
|
|
359
|
+
composePath,
|
|
360
|
+
error: error instanceof Error ? error.message : String(error),
|
|
361
|
+
});
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const services = parsed && typeof parsed === "object"
|
|
366
|
+
? (parsed as { services?: unknown }).services
|
|
367
|
+
: undefined;
|
|
368
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
369
|
+
|
|
370
|
+
const byProfile = new Map<string, AddonProfile>();
|
|
371
|
+
for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
|
|
372
|
+
if (!svcRaw || typeof svcRaw !== "object") continue;
|
|
373
|
+
const svc = svcRaw as { profiles?: unknown; labels?: unknown };
|
|
374
|
+
if (!Array.isArray(svc.profiles)) continue;
|
|
375
|
+
const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
|
|
376
|
+
if (profileIds.length === 0) continue;
|
|
377
|
+
|
|
378
|
+
const labels = readServiceLabels(svc.labels);
|
|
379
|
+
const label = labels["openpalm.profile.label"];
|
|
380
|
+
const requires = labels["openpalm.profile.requires"];
|
|
381
|
+
const isDefault = labels["openpalm.profile.default"] === "true";
|
|
382
|
+
|
|
383
|
+
for (const id of profileIds) {
|
|
384
|
+
const existing = byProfile.get(id);
|
|
385
|
+
if (existing) {
|
|
386
|
+
existing.services.push(svcName);
|
|
387
|
+
if (!existing.label && label) existing.label = label;
|
|
388
|
+
if (!existing.requires && requires) existing.requires = requires;
|
|
389
|
+
if (!existing.default && isDefault) existing.default = true;
|
|
390
|
+
} else {
|
|
391
|
+
const profile: AddonProfile = { id, services: [svcName] };
|
|
392
|
+
if (label) profile.label = label;
|
|
393
|
+
if (requires) profile.requires = requires;
|
|
394
|
+
if (isDefault) profile.default = true;
|
|
395
|
+
byProfile.set(id, profile);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return [...byProfile.values()];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
404
|
+
if (!raw) return {};
|
|
405
|
+
const out: Record<string, string> = {};
|
|
406
|
+
if (Array.isArray(raw)) {
|
|
407
|
+
for (const entry of raw) {
|
|
408
|
+
if (typeof entry !== "string") continue;
|
|
409
|
+
const eq = entry.indexOf("=");
|
|
410
|
+
if (eq < 0) continue;
|
|
411
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
412
|
+
}
|
|
413
|
+
} else if (typeof raw === "object") {
|
|
414
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
415
|
+
if (v == null) continue;
|
|
416
|
+
out[k] = String(v);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
|
|
423
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
424
|
+
|
|
425
|
+
const composeCandidates = [
|
|
426
|
+
join(homeDir, "config", "stack", "addons", name, "compose.yml"),
|
|
427
|
+
join(homeDir, "state", "registry", "addons", name, "compose.yml"),
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
for (const composePath of composeCandidates) {
|
|
431
|
+
const profiles = readAddonProfiles(composePath);
|
|
432
|
+
if (profiles.length > 0) return profiles;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function profileEnvKey(name: string): string {
|
|
439
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
440
|
+
return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
444
|
+
const env = readStackEnv(stackDir);
|
|
445
|
+
const value = env[profileEnvKey(name)];
|
|
446
|
+
return value && value.trim() ? value.trim() : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
|
|
450
|
+
const trimmed = profile.trim();
|
|
451
|
+
if (!trimmed) throw new Error('Profile id cannot be empty');
|
|
452
|
+
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
453
|
+
}
|
|
454
|
+
|
|
342
455
|
export function enableAddon(homeDir: string, name: string): MutationResult {
|
|
343
456
|
try {
|
|
344
457
|
copyAddonFromRegistry(homeDir, name);
|
|
@@ -27,8 +27,6 @@ function createState(): ControlPlaneState {
|
|
|
27
27
|
mkdirSync(cacheDir, { recursive: true });
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
|
-
adminToken: 'admin-token',
|
|
31
|
-
assistantToken: '',
|
|
32
30
|
homeDir: rootDir,
|
|
33
31
|
configDir,
|
|
34
32
|
stashDir: join(rootDir, 'stash'),
|
|
@@ -39,7 +37,6 @@ function createState(): ControlPlaneState {
|
|
|
39
37
|
services: {},
|
|
40
38
|
artifacts: { compose: '' },
|
|
41
39
|
artifactMeta: [],
|
|
42
|
-
audit: [],
|
|
43
40
|
};
|
|
44
41
|
}
|
|
45
42
|
|
|
@@ -188,16 +185,16 @@ describe('plaintext backend (via detectSecretBackend)', () => {
|
|
|
188
185
|
mkdirSync(dirname(akmPath), { recursive: true });
|
|
189
186
|
writeFileSync(akmPath, 'OPENAI_API_KEY=akm-vault-openai\n');
|
|
190
187
|
|
|
191
|
-
// Stack.env already exists from ensureSecrets — seed
|
|
188
|
+
// Stack.env already exists from ensureSecrets — seed the system password.
|
|
192
189
|
const stackEnvPath = join(state.stackDir, "stack.env");
|
|
193
190
|
const stackContent = readFileSync(stackEnvPath, 'utf-8')
|
|
194
|
-
.replace(/^
|
|
191
|
+
.replace(/^OP_UI_LOGIN_PASSWORD=.*$/m, 'OP_UI_LOGIN_PASSWORD=stack-login-password');
|
|
195
192
|
writeFileSync(stackEnvPath, stackContent);
|
|
196
193
|
|
|
197
194
|
// System scope reads stack.env exclusively.
|
|
198
|
-
expect(await backend.exists('openpalm/
|
|
199
|
-
const systemEntries = await backend.list('openpalm/
|
|
200
|
-
expect(systemEntries.find((e) => e.key === 'openpalm/
|
|
195
|
+
expect(await backend.exists('openpalm/ui-login-password')).toBe(true);
|
|
196
|
+
const systemEntries = await backend.list('openpalm/ui-login-password');
|
|
197
|
+
expect(systemEntries.find((e) => e.key === 'openpalm/ui-login-password')?.present).toBe(true);
|
|
201
198
|
|
|
202
199
|
// User scope reads akm vault file.
|
|
203
200
|
const userEntries = await backend.list('openpalm/openai/');
|
|
@@ -29,9 +29,8 @@ type CoreSecretMapping = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const STATIC_CORE_MAPPINGS: CoreSecretMapping[] = [
|
|
32
|
-
// Core authentication
|
|
33
|
-
{ secretKey: 'openpalm/
|
|
34
|
-
{ secretKey: 'openpalm/assistant-token', envKey: 'OP_ASSISTANT_TOKEN', scope: 'system' },
|
|
32
|
+
// Core authentication
|
|
33
|
+
{ secretKey: 'openpalm/ui-login-password', envKey: 'OP_UI_LOGIN_PASSWORD', scope: 'system' },
|
|
35
34
|
{ secretKey: 'openpalm/opencode/server-password', envKey: 'OP_OPENCODE_PASSWORD', scope: 'system' },
|
|
36
35
|
// LLM provider API keys
|
|
37
36
|
{ secretKey: 'openpalm/openai/api-key', envKey: 'OPENAI_API_KEY', scope: 'user' },
|
|
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSyn
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createLogger } from "../logger.js";
|
|
5
5
|
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
6
|
+
import { migrateAuth0110 } from './migrate-0110.js';
|
|
6
7
|
import type { ControlPlaneState } from "./types.js";
|
|
7
8
|
import { resolveConfigDir } from "./home.js";
|
|
8
9
|
|
|
@@ -58,11 +59,13 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
58
59
|
const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
|
|
59
60
|
const updates: Record<string, string> = {};
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
// OP_UI_LOGIN_PASSWORD seeds the operator login secret. ensureSecrets
|
|
63
|
+
// generates a random fallback the first time so the stack is never
|
|
64
|
+
// installed with an empty password slot; the wizard / CLI install path
|
|
65
|
+
// overwrites it with the operator's chosen value via
|
|
66
|
+
// buildSystemSecretsFromSetup().
|
|
67
|
+
if (!existing.OP_UI_LOGIN_PASSWORD) {
|
|
68
|
+
updates.OP_UI_LOGIN_PASSWORD = randomBytes(32).toString("hex");
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
if (!existsSync(systemEnvPath)) {
|
|
@@ -71,8 +74,7 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
71
74
|
"# All secrets and configuration live here. Advanced users may edit directly.",
|
|
72
75
|
"",
|
|
73
76
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
74
|
-
"
|
|
75
|
-
"OP_ASSISTANT_TOKEN=",
|
|
77
|
+
"OP_UI_LOGIN_PASSWORD=",
|
|
76
78
|
"",
|
|
77
79
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
78
80
|
"OP_OPENCODE_PASSWORD=",
|
|
@@ -104,6 +106,10 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
104
106
|
export function ensureSecrets(state: ControlPlaneState): void {
|
|
105
107
|
enforceVaultDirMode(state.stackDir);
|
|
106
108
|
|
|
109
|
+
// Migrate pre-0.11.0 installs (OP_UI_TOKEN/OP_ASSISTANT_TOKEN → OP_UI_LOGIN_PASSWORD)
|
|
110
|
+
// before any code path that reads OP_UI_LOGIN_PASSWORD sees an empty value.
|
|
111
|
+
migrateAuth0110(state);
|
|
112
|
+
|
|
107
113
|
ensureSystemSecrets(state);
|
|
108
114
|
ensureGuardianEnv(state.stackDir);
|
|
109
115
|
ensureAuthJson(state.configDir);
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
"security": {
|
|
31
31
|
"type": "object",
|
|
32
32
|
"description": "Security settings for the instance.",
|
|
33
|
-
"required": ["
|
|
33
|
+
"required": ["uiLoginPassword"],
|
|
34
34
|
"additionalProperties": false,
|
|
35
35
|
"properties": {
|
|
36
|
-
"
|
|
36
|
+
"uiLoginPassword": {
|
|
37
37
|
"type": "string",
|
|
38
|
-
"description": "
|
|
38
|
+
"description": "Operator login password for the OpenPalm UI. Persisted to stack.env as OP_UI_LOGIN_PASSWORD; the UI's op_session cookie value is compared against it on every authenticated request.",
|
|
39
39
|
"minLength": 8
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -2,6 +2,11 @@ import { parseEnvFile } from './env.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Check if setup is complete by reading config/stack/stack.env.
|
|
5
|
+
*
|
|
6
|
+
* Phase 4 of the auth/proxy refactor replaced the legacy `OP_UI_TOKEN`
|
|
7
|
+
* sentinel with `OP_UI_LOGIN_PASSWORD`. The presence of a non-empty value
|
|
8
|
+
* implies the operator (or the install wizard) has seeded the login
|
|
9
|
+
* secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
|
|
5
10
|
*/
|
|
6
11
|
export function isSetupComplete(stackDir: string): boolean {
|
|
7
12
|
const parsed = parseEnvFile(`${stackDir}/stack.env`);
|
|
@@ -9,5 +14,5 @@ export function isSetupComplete(stackDir: string): boolean {
|
|
|
9
14
|
return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
return (parsed.
|
|
17
|
+
return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
|
|
13
18
|
}
|
|
@@ -34,8 +34,8 @@ export function validateSetupSpec(input: unknown): { valid: boolean; errors: str
|
|
|
34
34
|
function validateSecurity(body: Record<string, unknown>, errors: string[]): void {
|
|
35
35
|
const security = requireObj(body.security, "security object is required", errors);
|
|
36
36
|
if (!security) return;
|
|
37
|
-
if (!requireStr(security, "
|
|
38
|
-
if ((security.
|
|
37
|
+
if (!requireStr(security, "uiLoginPassword", "security.uiLoginPassword is required and must be a non-empty string", errors)) return;
|
|
38
|
+
if ((security.uiLoginPassword as string).length < 8) errors.push("security.uiLoginPassword must be at least 8 characters");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function validateOwner(body: Record<string, unknown>, errors: string[]): void {
|