@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.
- 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/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 +9 -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) =>
|
|
@@ -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 {
|
|
@@ -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: {
|
|
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.
|
|
75
|
+
it("rejects missing security.uiLoginPassword", () => {
|
|
76
76
|
const spec = makeValidSpec();
|
|
77
|
-
spec.security.
|
|
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.
|
|
80
|
+
expect(result.errors.some((e) => e.includes("security.uiLoginPassword"))).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it("rejects short security.
|
|
83
|
+
it("rejects short security.uiLoginPassword", () => {
|
|
84
84
|
const spec = makeValidSpec();
|
|
85
|
-
spec.security.
|
|
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
|
|
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
|
-
|
|
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.
|
|
310
|
-
expect(
|
|
311
|
-
expect(secrets.OP_ASSISTANT_TOKEN).
|
|
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
|
-
"
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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/.
|