@openpalm/lib 0.11.0-beta.1 → 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.
@@ -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) =>
@@ -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 {
@@ -19,7 +19,7 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
19
19
  version: 2,
20
20
  llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
21
21
  embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
22
- security: { adminToken: "test-admin-token-12345" },
22
+ security: { uiLoginPassword: "test-admin-token-12345" },
23
23
  owner: { name: "Test User", email: "test@example.com" },
24
24
  connections: [
25
25
  {
@@ -72,17 +72,17 @@ describe("validateSetupSpec", () => {
72
72
  expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
73
73
  });
74
74
 
75
- it("rejects missing security.adminToken", () => {
75
+ it("rejects missing security.uiLoginPassword", () => {
76
76
  const spec = makeValidSpec();
77
- spec.security.adminToken = "";
77
+ spec.security.uiLoginPassword = "";
78
78
  const result = validateSetupSpec(spec);
79
79
  expect(result.valid).toBe(false);
80
- expect(result.errors.some((e) => e.includes("security.adminToken"))).toBe(true);
80
+ expect(result.errors.some((e) => e.includes("security.uiLoginPassword"))).toBe(true);
81
81
  });
82
82
 
83
- it("rejects short security.adminToken", () => {
83
+ it("rejects short security.uiLoginPassword", () => {
84
84
  const spec = makeValidSpec();
85
- spec.security.adminToken = "short";
85
+ spec.security.uiLoginPassword = "short";
86
86
  const result = validateSetupSpec(spec);
87
87
  expect(result.valid).toBe(false);
88
88
  expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
@@ -199,9 +199,10 @@ describe("validateSetupSpec", () => {
199
199
  // ── Tests: buildSecretsFromSetup ─────────────────────────────────────────
200
200
 
201
201
  describe("buildSecretsFromSetup", () => {
202
- it("does not include admin token in user secrets", () => {
202
+ it("does not include UI login password in user secrets", () => {
203
203
  const spec = makeValidSpec();
204
204
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
205
+ expect(secrets.OP_UI_LOGIN_PASSWORD).toBeUndefined();
205
206
  expect(secrets.OP_UI_TOKEN).toBeUndefined();
206
207
  expect(secrets.ADMIN_TOKEN).toBeUndefined();
207
208
  });
@@ -304,11 +305,14 @@ describe("buildAuthJsonFromSetup", () => {
304
305
  });
305
306
 
306
307
  describe("buildSystemSecretsFromSetup", () => {
307
- it("includes distinct admin and assistant credentials", () => {
308
+ // Phase 4: assistant token was removed; the only stack.env secret this
309
+ // helper writes now is OP_UI_LOGIN_PASSWORD. OP_OPENCODE_PASSWORD is
310
+ // generated by ensureSystemSecrets() and persists across reruns.
311
+ it("returns OP_UI_LOGIN_PASSWORD equal to the supplied operator password", () => {
308
312
  const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
309
- expect(secrets.OP_UI_TOKEN).toBe("test-admin-token-12345");
310
- expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
311
- expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
313
+ expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
314
+ expect(secrets.OP_UI_TOKEN).toBeUndefined();
315
+ expect(secrets.OP_ASSISTANT_TOKEN).toBeUndefined();
312
316
  });
313
317
  });
314
318
 
@@ -356,7 +360,7 @@ describe("performSetup", () => {
356
360
  join(stackDir, "stack.env"),
357
361
  [
358
362
  "OP_SETUP_COMPLETE=false",
359
- "OP_UI_TOKEN=",
363
+ "OP_UI_LOGIN_PASSWORD=",
360
364
  "OPENAI_API_KEY=",
361
365
  "OPENAI_BASE_URL=",
362
366
  "ANTHROPIC_API_KEY=",
@@ -384,13 +388,13 @@ describe("performSetup", () => {
384
388
 
385
389
  it("returns an error for invalid input", async () => {
386
390
  const result = await performSetup(
387
- { security: { adminToken: "short" } } as SetupSpec
391
+ { security: { uiLoginPassword: "short" } } as SetupSpec
388
392
  );
389
393
  expect(result.ok).toBe(false);
390
394
  expect(result.error).toBeDefined();
391
395
  });
392
396
 
393
- it("writes stack.env with the admin token", async () => {
397
+ it("writes stack.env with the UI login password", async () => {
394
398
  const result = await performSetup(makeValidSpec());
395
399
  expect(result.ok).toBe(true);
396
400
 
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { randomBytes } from "node:crypto";
11
10
  import { createLogger } from "../logger.js";
12
11
  import {
13
12
  PROVIDER_KEY_MAP,
@@ -70,7 +69,12 @@ export type SetupSpec = {
70
69
  embedding?: { provider: string; model: string; dims: number; baseUrl?: string };
71
70
  tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
72
71
  stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
73
- security: { adminToken: string };
72
+ /**
73
+ * Operator-supplied UI login password. Persisted to stack.env as
74
+ * `OP_UI_LOGIN_PASSWORD`. Replaces the legacy `adminToken` field
75
+ * (Phase 4 of docs/technical/auth-and-proxy-refactor-plan.md).
76
+ */
77
+ security: { uiLoginPassword: string };
74
78
  owner?: { name?: string; email?: string };
75
79
  connections: SetupConnection[];
76
80
  channelCredentials?: Record<string, Record<string, string>>;
@@ -121,41 +125,26 @@ export function buildAuthJsonFromSetup(
121
125
  }
122
126
 
123
127
  /**
124
- * Build the system-secret env update.
128
+ * Build the system-secret env update for the wizard / CLI install path.
125
129
  *
126
- * `OP_ASSISTANT_TOKEN` is critical: rotating it on a running stack would
127
- * invalidate every container's auth. We therefore distinguish three cases:
128
- * - existing system env has a non-empty token reuse it (idempotent rerun).
129
- * - existing system env explicitly contains `OP_ASSISTANT_TOKEN=` (blank) →
130
- * throw rather than silently rotate. This means a user edited stack.env
131
- * or a previous run wrote it blank; either way silent rotation breaks the
132
- * running stack.
133
- * - the key is absent entirely → generate a fresh token (first install).
130
+ * Phase 4 of the auth/proxy refactor collapsed the legacy
131
+ * `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
132
+ * secret (`OP_UI_LOGIN_PASSWORD`). The browser stores the cookie value =
133
+ * password; `requireAdmin()` compares the cookie against
134
+ * `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
134
135
  *
135
- * If you legitimately need to rotate the token, delete the OP_ASSISTANT_TOKEN
136
- * line from stack.env (rather than blanking it) before re-running setup.
136
+ * `OP_OPENCODE_PASSWORD` is generated by `ensureSystemSecrets()` on first
137
+ * run and persists across reruns it is not regenerated here.
138
+ *
139
+ * `existingSystemEnv` is unused now but the parameter is kept so callers
140
+ * compile unchanged. It can be removed in a follow-up cleanup.
137
141
  */
138
142
  export function buildSystemSecretsFromSetup(
139
- adminToken: string,
140
- existingSystemEnv: Record<string, string> = {}
143
+ uiLoginPassword: string,
144
+ _existingSystemEnv: Record<string, string> = {}
141
145
  ): Record<string, string> {
142
- const hasKey = Object.prototype.hasOwnProperty.call(existingSystemEnv, "OP_ASSISTANT_TOKEN");
143
- const existing = existingSystemEnv.OP_ASSISTANT_TOKEN;
144
- let token: string;
145
- if (existing) {
146
- token = existing;
147
- } else if (hasKey) {
148
- throw new Error(
149
- "OP_ASSISTANT_TOKEN is present but blank in config/stack/stack.env. " +
150
- "Refusing to silently rotate the token (it would break the running stack). " +
151
- "Restore the previous value or remove the line entirely to generate a fresh one.",
152
- );
153
- } else {
154
- token = randomBytes(32).toString("hex");
155
- }
156
146
  return {
157
- OP_UI_TOKEN: adminToken,
158
- OP_ASSISTANT_TOKEN: token,
147
+ OP_UI_LOGIN_PASSWORD: uiLoginPassword,
159
148
  };
160
149
  }
161
150
 
@@ -205,7 +194,7 @@ export async function performSetup(
205
194
  if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
206
195
 
207
196
  const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
208
- const state = opts?.state ?? createState(security.adminToken);
197
+ const state = opts?.state ?? createState();
209
198
 
210
199
  // Acquire install lock to prevent two concurrent setup runs from racing on
211
200
  // the same config directory. The lock lives in stateDir so it is co-located
@@ -238,7 +227,7 @@ export async function performSetup(
238
227
  }
239
228
  }
240
229
  updateSecretsEnv(state, updates);
241
- updateSystemSecretsEnv(state, buildSystemSecretsFromSetup(security.adminToken, existingSystemEnv));
230
+ updateSystemSecretsEnv(state, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
242
231
  // Provider API keys land in OpenCode's auth.json (bind-mounted into
243
232
  // the assistant container) — never in stack.env.
244
233
  writeAuthJsonProviderKeys(state, providerKeys);
@@ -248,9 +237,6 @@ export async function performSetup(
248
237
  return { ok: false, error: `Failed to persist setup outputs: ${message}` };
249
238
  }
250
239
 
251
- state.adminToken = security.adminToken;
252
- state.assistantToken = readStackEnv(state.stackDir).OP_ASSISTANT_TOKEN ?? state.assistantToken;
253
-
254
240
  // Everything from here through the OP_SETUP_COMPLETE write is wrapped in a
255
241
  // single try/catch so that a disk-full or permission-denied mid-way returns a
256
242
  // clean error rather than leaving a broken half-installed ~/.openpalm/.