@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.
@@ -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
- export const adminAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/admin-audit.jsonl`;
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 is a local inference server — no channel secret or API key needed
347
- if (id === "ollama") return;
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 a system token.
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(/^OP_UI_TOKEN=.*$/m, 'OP_UI_TOKEN=stack-admin-token');
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/admin-token')).toBe(true);
199
- const systemEntries = await backend.list('openpalm/admin-token');
200
- expect(systemEntries.find((e) => e.key === 'openpalm/admin-token')?.present).toBe(true);
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 tokens
33
- { secretKey: 'openpalm/admin-token', envKey: 'OP_UI_TOKEN', scope: 'system' },
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
- if (!existing.OP_UI_TOKEN && state.adminToken) {
62
- updates.OP_UI_TOKEN = state.adminToken;
63
- }
64
- if (!existing.OP_ASSISTANT_TOKEN) {
65
- updates.OP_ASSISTANT_TOKEN = randomBytes(32).toString("hex");
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
- "OP_UI_TOKEN=",
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": ["adminToken"],
33
+ "required": ["uiLoginPassword"],
34
34
  "additionalProperties": false,
35
35
  "properties": {
36
- "adminToken": {
36
+ "uiLoginPassword": {
37
37
  "type": "string",
38
- "description": "Admin API authentication token. Used to authenticate CLI and admin UI requests.",
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.OP_UI_TOKEN ?? "").length > 0;
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, "adminToken", "security.adminToken is required and must be a non-empty string", errors)) return;
38
- if ((security.adminToken as string).length < 8) errors.push("security.adminToken must be at least 8 characters");
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 {