@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SECRET_NAME_RE = /^[a-z0-9][a-z0-9_]{0,80}$/;
|
|
5
|
+
const SECRETS_DIR_MODE = 0o700;
|
|
6
|
+
const SECRET_FILE_MODE = 0o600;
|
|
7
|
+
|
|
8
|
+
export function validateSecretName(name: string): void {
|
|
9
|
+
if (!SECRET_NAME_RE.test(name)) throw new Error(`Invalid secret name: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveHomeDirFromStackDir(stackDir: string): string {
|
|
13
|
+
const parentDir = dirname(stackDir);
|
|
14
|
+
if (basename(stackDir) === 'stack' && basename(parentDir) === 'config') {
|
|
15
|
+
return dirname(parentDir);
|
|
16
|
+
}
|
|
17
|
+
return stackDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveSecretsDir(stackDir: string): string {
|
|
21
|
+
const dir = join(resolveHomeDirFromStackDir(stackDir), 'knowledge', 'secrets');
|
|
22
|
+
mkdirSync(dir, { recursive: true, mode: SECRETS_DIR_MODE });
|
|
23
|
+
chmodSync(dir, SECRETS_DIR_MODE);
|
|
24
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
if (entry.isFile()) chmodSync(join(dir, entry.name), SECRET_FILE_MODE);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function secretPath(stackDir: string, name: string): string {
|
|
31
|
+
validateSecretName(name);
|
|
32
|
+
return join(resolveSecretsDir(stackDir), name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readSecret(stackDir: string, name: string): string | null {
|
|
36
|
+
const path = secretPath(stackDir, name);
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
39
|
+
return readFileSync(path, 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeSecret(stackDir: string, name: string, value: string): void {
|
|
43
|
+
const path = secretPath(stackDir, name);
|
|
44
|
+
writeFileSync(path, value, { mode: SECRET_FILE_MODE });
|
|
45
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ensureSecret(stackDir: string, name: string, valueFactory: () => string): string {
|
|
49
|
+
const existing = readSecret(stackDir, name);
|
|
50
|
+
if (existing !== null) return existing;
|
|
51
|
+
const value = valueFactory();
|
|
52
|
+
writeSecret(stackDir, name, value);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removeSecret(stackDir: string, name: string): void {
|
|
57
|
+
rmSync(secretPath(stackDir, name), { force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listSecretNames(stackDir: string): string[] {
|
|
61
|
+
const dir = resolveSecretsDir(stackDir);
|
|
62
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isFile() && SECRET_NAME_RE.test(entry.name))
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Raw file access for the Secrets admin tab ──────────────────────────────
|
|
69
|
+
// The admin Secrets tab is a plain file browser/editor for the secrets dir, so
|
|
70
|
+
// it must reach files the strict SECRET_NAME_RE excludes (e.g. `auth.json`). The
|
|
71
|
+
// filename guard below permits dots/dashes but is still traversal-safe (no path
|
|
72
|
+
// separators, no `..`). Names are always basenames within the secrets dir.
|
|
73
|
+
const SECRET_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
74
|
+
|
|
75
|
+
export function assertSafeSecretFilename(name: string): void {
|
|
76
|
+
if (!SECRET_FILENAME_RE.test(name) || name.includes('..')) {
|
|
77
|
+
throw new Error(`Invalid secret file name: ${name}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type SecretFileInfo = { name: string; size: number };
|
|
82
|
+
|
|
83
|
+
/** List every regular file in the secrets dir (incl. auth.json), with byte size. */
|
|
84
|
+
export function listSecretFiles(stackDir: string): SecretFileInfo[] {
|
|
85
|
+
const dir = resolveSecretsDir(stackDir);
|
|
86
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
87
|
+
.filter((entry) => entry.isFile() && SECRET_FILENAME_RE.test(entry.name) && !entry.name.includes('..'))
|
|
88
|
+
.map((entry) => ({ name: entry.name, size: statSync(join(dir, entry.name)).size }))
|
|
89
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read a secrets-dir file by basename (raw contents), or null if absent. */
|
|
93
|
+
export function readSecretFile(stackDir: string, name: string): string | null {
|
|
94
|
+
assertSafeSecretFilename(name);
|
|
95
|
+
const path = join(resolveSecretsDir(stackDir), name);
|
|
96
|
+
if (!existsSync(path)) return null;
|
|
97
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
98
|
+
return readFileSync(path, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write a secrets-dir file by basename (0600). */
|
|
102
|
+
export function writeSecretFile(stackDir: string, name: string, value: string): void {
|
|
103
|
+
assertSafeSecretFilename(name);
|
|
104
|
+
const path = join(resolveSecretsDir(stackDir), name);
|
|
105
|
+
writeFileSync(path, value, { mode: SECRET_FILE_MODE });
|
|
106
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Delete a secrets-dir file by basename. */
|
|
110
|
+
export function removeSecretFile(stackDir: string, name: string): void {
|
|
111
|
+
assertSafeSecretFilename(name);
|
|
112
|
+
rmSync(join(resolveSecretsDir(stackDir), name), { force: true });
|
|
113
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** Secrets and capability key management. */
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync, renameSync } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
4
3
|
import { createLogger } from "../logger.js";
|
|
5
4
|
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
6
|
-
import { migrateAuth0110 } from './migrate-0110.js';
|
|
7
5
|
import type { ControlPlaneState } from "./types.js";
|
|
8
6
|
import { resolveConfigDir } from "./home.js";
|
|
7
|
+
import { authJsonPath as resolveAuthJsonPath, stackEnvPathFromStackDir } from "./paths.js";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { listSecretNames, readSecret, resolveSecretsDir, writeSecret } from './secrets-files.js';
|
|
9
10
|
|
|
10
11
|
const OPENCODE_STARTER_CONFIG = JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n";
|
|
11
12
|
const logger = createLogger("secrets");
|
|
@@ -22,6 +23,22 @@ export const PLAIN_CONFIG_KEYS = new Set([
|
|
|
22
23
|
const VAULT_DIR_MODE = 0o700;
|
|
23
24
|
const VAULT_FILE_MODE = 0o600;
|
|
24
25
|
|
|
26
|
+
export const SECRET_ENV_KEY_RE = /(?:^OP_UI_LOGIN_PASSWORD$|^OP_OPENCODE_PASSWORD$|_API_KEY$|_TOKEN$|_SECRET$|_PASSWORD$)/;
|
|
27
|
+
const SECRET_LIKE_STACK_ENV_KEY_RE = /(SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CLIENT_SECRET|AUTH_JSON|CREDENTIALS)/;
|
|
28
|
+
const NON_SECRET_STACK_ENV_KEY_ALLOWLIST = new Set<string>();
|
|
29
|
+
|
|
30
|
+
export function isSecretLikeStackEnvKey(key: string): boolean {
|
|
31
|
+
return SECRET_LIKE_STACK_ENV_KEY_RE.test(key) && !NON_SECRET_STACK_ENV_KEY_ALLOWLIST.has(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertNoSecretLikeStackEnvKeys(updates: Record<string, string>): void {
|
|
35
|
+
for (const key of Object.keys(updates)) {
|
|
36
|
+
if (isSecretLikeStackEnvKey(key)) {
|
|
37
|
+
throw new Error(`Refusing to write secret-like key to stack.env: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
function enforceVaultDirMode(vaultDir: string): void {
|
|
26
43
|
mkdirSync(vaultDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
27
44
|
try {
|
|
@@ -46,8 +63,39 @@ function writeVaultFile(path: string, content: string): void {
|
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
66
|
+
export function stackSecretsDir(stackDir: string): string {
|
|
67
|
+
return resolveSecretsDir(stackDir);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stackSecretPath(stackDir: string, envKey: string): string {
|
|
71
|
+
return `${stackSecretsDir(stackDir)}/${envKey.toLowerCase()}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readStackSecretEnv(stackDir: string): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const name of listSecretNames(stackDir)) {
|
|
77
|
+
const envKey = name.toUpperCase();
|
|
78
|
+
try {
|
|
79
|
+
out[envKey] = (readSecret(stackDir, name) ?? '').replace(/[\r\n]+$/, '');
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore unreadable secret files; callers treat missing values as absent
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeStackSecretEnv(state: ControlPlaneState, updates: Record<string, string>): void {
|
|
88
|
+
if (Object.keys(updates).length === 0) return;
|
|
89
|
+
resolveSecretsDir(state.stackDir);
|
|
90
|
+
for (const [envKey, value] of Object.entries(updates)) {
|
|
91
|
+
if (!/^[A-Z0-9_]+$/.test(envKey)) throw new Error(`Invalid secret env key: ${envKey}`);
|
|
92
|
+
writeSecret(state.stackDir, envKey.toLowerCase(), value.endsWith('\n') ? value : `${value}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
49
96
|
function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomment = false): void {
|
|
50
97
|
if (Object.keys(updates).length === 0) return;
|
|
98
|
+
assertNoSecretLikeStackEnvKeys(updates);
|
|
51
99
|
const raw = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
52
100
|
let merged = mergeEnvContent(raw, updates, { uncomment });
|
|
53
101
|
if (!merged.endsWith("\n")) merged += "\n";
|
|
@@ -55,88 +103,45 @@ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomm
|
|
|
55
103
|
}
|
|
56
104
|
|
|
57
105
|
function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
58
|
-
const systemEnvPath = `${state.
|
|
59
|
-
|
|
106
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
107
|
+
enforceVaultDirMode(dirname(systemEnvPath));
|
|
60
108
|
const updates: Record<string, string> = {};
|
|
61
109
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
updates.
|
|
110
|
+
// Bootstrap only explicit host-provided overrides. Setup is allowed to be
|
|
111
|
+
// genuinely unconfigured until the wizard/CLI writes the chosen password.
|
|
112
|
+
if (process.env.OP_UI_LOGIN_PASSWORD) {
|
|
113
|
+
updates.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
|
|
114
|
+
}
|
|
115
|
+
if (process.env.OP_OPENCODE_PASSWORD) {
|
|
116
|
+
updates.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
|
|
69
117
|
}
|
|
70
118
|
|
|
119
|
+
writeStackSecretEnv(state, updates);
|
|
120
|
+
|
|
71
121
|
if (!existsSync(systemEnvPath)) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"",
|
|
79
|
-
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
80
|
-
"OP_OPENCODE_PASSWORD=",
|
|
81
|
-
"",
|
|
82
|
-
"# ── Provider API Keys ────────────────────────────────────────────────",
|
|
83
|
-
"OPENAI_API_KEY=",
|
|
84
|
-
"OPENAI_BASE_URL=",
|
|
85
|
-
"ANTHROPIC_API_KEY=",
|
|
86
|
-
"GROQ_API_KEY=",
|
|
87
|
-
"MISTRAL_API_KEY=",
|
|
88
|
-
"GOOGLE_API_KEY=",
|
|
89
|
-
"MCP_API_KEY=",
|
|
90
|
-
"EMBEDDING_API_KEY=",
|
|
91
|
-
"LMSTUDIO_API_KEY=",
|
|
92
|
-
"",
|
|
93
|
-
"# ── Owner ────────────────────────────────────────────────────────────",
|
|
94
|
-
`OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
|
|
95
|
-
`OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
|
|
122
|
+
const header = [
|
|
123
|
+
"# OpenPalm — Stack Configuration",
|
|
124
|
+
"# Non-secret stack configuration only. File-based secrets live in knowledge/secrets/.",
|
|
125
|
+
"",
|
|
126
|
+
"# ── Authentication ──────────────────────────────────────────────────",
|
|
127
|
+
"OP_SETUP_COMPLETE=false",
|
|
96
128
|
"",
|
|
97
129
|
].join("\n");
|
|
98
|
-
|
|
99
|
-
writeVaultFile(systemEnvPath, content.endsWith("\n") ? content : content + "\n");
|
|
130
|
+
writeVaultFile(systemEnvPath, header.endsWith("\n") ? header : header + "\n");
|
|
100
131
|
return;
|
|
101
132
|
}
|
|
102
|
-
|
|
103
|
-
mergeVaultEnvFile(systemEnvPath, updates, true);
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
export function ensureSecrets(state: ControlPlaneState): void {
|
|
107
136
|
enforceVaultDirMode(state.stackDir);
|
|
108
137
|
|
|
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
|
-
|
|
113
138
|
ensureSystemSecrets(state);
|
|
114
|
-
|
|
115
|
-
ensureAuthJson(state.configDir);
|
|
139
|
+
ensureAuthJson(state);
|
|
116
140
|
}
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
* This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
|
|
122
|
-
*/
|
|
123
|
-
function ensureGuardianEnv(stackDir: string): void {
|
|
124
|
-
const guardianEnvPath = `${stackDir}/guardian.env`;
|
|
125
|
-
mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
126
|
-
if (!existsSync(guardianEnvPath)) {
|
|
127
|
-
writeVaultFile(guardianEnvPath, [
|
|
128
|
-
"# Guardian channel HMAC secrets — managed by openpalm",
|
|
129
|
-
"# Each enabled channel gets a CHANNEL_<NAME>_SECRET entry.",
|
|
130
|
-
"",
|
|
131
|
-
].join("\n"));
|
|
132
|
-
} else {
|
|
133
|
-
try { chmodSync(guardianEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function ensureAuthJson(configDir: string): void {
|
|
138
|
-
const authJsonPath = `${configDir}/auth.json`;
|
|
139
|
-
mkdirSync(configDir, { recursive: true, mode: VAULT_DIR_MODE });
|
|
142
|
+
function ensureAuthJson(state: ControlPlaneState): void {
|
|
143
|
+
const authJsonPath = resolveAuthJsonPath(state);
|
|
144
|
+
mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
140
145
|
|
|
141
146
|
if (existsSync(authJsonPath)) {
|
|
142
147
|
try {
|
|
@@ -162,22 +167,24 @@ export function updateSecretsEnv(
|
|
|
162
167
|
state: ControlPlaneState,
|
|
163
168
|
updates: Record<string, string>
|
|
164
169
|
): void {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
const secretUpdates: Record<string, string> = {};
|
|
171
|
+
const stackUpdates: Record<string, string> = {};
|
|
172
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
173
|
+
if (SECRET_ENV_KEY_RE.test(key)) secretUpdates[key] = value;
|
|
174
|
+
else stackUpdates[key] = value;
|
|
168
175
|
}
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
writeStackSecretEnv(state, secretUpdates);
|
|
177
|
+
if (Object.keys(stackUpdates).length > 0) patchSecretsEnvFile(state.stackDir, stackUpdates);
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
/**
|
|
174
181
|
* Merge-write provider API keys into OpenCode's auth.json at
|
|
175
|
-
* `${
|
|
176
|
-
* api-key auth: `{ <providerId>: { type: "api", key
|
|
182
|
+
* `${stackDir}/auth.json` (knowledge/secrets/auth.json). Each entry uses
|
|
183
|
+
* OpenCode's schema for api-key auth: `{ <providerId>: { type: "api", key } }`.
|
|
177
184
|
*
|
|
178
|
-
* This file is bind-mounted into the assistant
|
|
179
|
-
*
|
|
180
|
-
* see core.compose.yml.
|
|
185
|
+
* This file is bind-mounted into both the assistant and guardian containers
|
|
186
|
+
* so every OpenCode instance picks up new credentials on its next restart —
|
|
187
|
+
* see core.compose.yml (assistant) and channels.compose.yml (guardian).
|
|
181
188
|
*
|
|
182
189
|
* Existing entries (OAuth tokens, other providers) are preserved.
|
|
183
190
|
* Empty values DELETE the corresponding entry.
|
|
@@ -188,8 +195,8 @@ export function writeAuthJsonProviderKeys(
|
|
|
188
195
|
): void {
|
|
189
196
|
if (Object.keys(providerKeys).length === 0) return;
|
|
190
197
|
|
|
191
|
-
const authJsonPath =
|
|
192
|
-
mkdirSync(
|
|
198
|
+
const authJsonPath = resolveAuthJsonPath(state);
|
|
199
|
+
mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
193
200
|
|
|
194
201
|
let current: Record<string, unknown> = {};
|
|
195
202
|
if (existsSync(authJsonPath)) {
|
|
@@ -227,16 +234,25 @@ export function writeAuthJsonProviderKeys(
|
|
|
227
234
|
writeVaultFile(authJsonPath, JSON.stringify(current, null, 2) + "\n");
|
|
228
235
|
}
|
|
229
236
|
|
|
230
|
-
/** Read and parse
|
|
237
|
+
/** Read and parse knowledge/env/stack.env. Returns {} if the file does not exist. */
|
|
231
238
|
export function readStackEnv(stackDir: string): Record<string, string> {
|
|
232
|
-
|
|
239
|
+
const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
|
|
240
|
+
const nonSecret: Record<string, string> = {};
|
|
241
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
242
|
+
if (!isSecretLikeStackEnvKey(key)) nonSecret[key] = value;
|
|
243
|
+
}
|
|
244
|
+
return nonSecret;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function readStackRuntimeEnv(stackDir: string): Record<string, string> {
|
|
248
|
+
return { ...readStackEnv(stackDir), ...readStackSecretEnv(stackDir) };
|
|
233
249
|
}
|
|
234
250
|
|
|
235
251
|
export function updateSystemSecretsEnv(
|
|
236
252
|
state: ControlPlaneState,
|
|
237
253
|
updates: Record<string, string>
|
|
238
254
|
): void {
|
|
239
|
-
const systemEnvPath = `${state.
|
|
255
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
240
256
|
enforceVaultDirMode(state.stackDir);
|
|
241
257
|
if (!existsSync(systemEnvPath)) {
|
|
242
258
|
ensureSystemSecrets(state);
|
|
@@ -250,9 +266,20 @@ export function patchSecretsEnvFile(
|
|
|
250
266
|
): void {
|
|
251
267
|
if (Object.keys(patches).length === 0) return;
|
|
252
268
|
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
const stackPatches: Record<string, string> = {};
|
|
270
|
+
const secretPatches: Record<string, string> = {};
|
|
271
|
+
for (const [key, value] of Object.entries(patches)) {
|
|
272
|
+
if (SECRET_ENV_KEY_RE.test(key)) secretPatches[key] = value;
|
|
273
|
+
else stackPatches[key] = value;
|
|
274
|
+
}
|
|
275
|
+
if (Object.keys(secretPatches).length > 0) {
|
|
276
|
+
writeStackSecretEnv({ stackDir, homeDir: '', configDir: '', stashDir: '', workspaceDir: '', dataDir: '', services: {}, artifacts: { compose: '' }, artifactMeta: [] }, secretPatches);
|
|
277
|
+
}
|
|
278
|
+
if (Object.keys(stackPatches).length === 0) return;
|
|
279
|
+
assertNoSecretLikeStackEnvKeys(stackPatches);
|
|
280
|
+
|
|
281
|
+
const stackEnvPath = stackEnvPathFromStackDir(stackDir);
|
|
282
|
+
enforceVaultDirMode(dirname(stackEnvPath));
|
|
256
283
|
|
|
257
284
|
let existingContent = "";
|
|
258
285
|
try {
|
|
@@ -263,7 +290,7 @@ export function patchSecretsEnvFile(
|
|
|
263
290
|
// start fresh
|
|
264
291
|
}
|
|
265
292
|
|
|
266
|
-
let result = mergeEnvContent(existingContent,
|
|
293
|
+
let result = mergeEnvContent(existingContent, stackPatches);
|
|
267
294
|
if (!result.endsWith("\n")) result += "\n";
|
|
268
295
|
writeVaultFile(stackEnvPath, result);
|
|
269
296
|
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"properties": {
|
|
36
36
|
"uiLoginPassword": {
|
|
37
37
|
"type": "string",
|
|
38
|
-
"description": "Operator login password for the OpenPalm UI. Persisted
|
|
38
|
+
"description": "Operator login password for the OpenPalm UI. Persisted as knowledge/secrets/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
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import { parseEnvFile } from './env.js';
|
|
2
|
+
import { stackEnvPathFromStackDir } from './paths.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Check if setup is complete by reading
|
|
5
|
+
* Check if setup is complete by reading knowledge/env/stack.env.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* implies the operator (or the install wizard) has seeded the login
|
|
9
|
-
* secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
|
|
7
|
+
* Only OP_SETUP_COMPLETE=true is authoritative. Secrets live in
|
|
8
|
+
* knowledge/secrets and are not completion sentinels.
|
|
10
9
|
*/
|
|
11
10
|
export function isSetupComplete(stackDir: string): boolean {
|
|
12
|
-
const parsed = parseEnvFile(
|
|
13
|
-
|
|
14
|
-
return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
|
|
11
|
+
const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
|
|
12
|
+
return parsed.OP_SETUP_COMPLETE === "true";
|
|
18
13
|
}
|