@openpalm/lib 0.10.1 → 0.11.0-beta.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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -15,16 +15,17 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
15
15
|
let tempDir: string;
|
|
16
16
|
|
|
17
17
|
function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
|
|
18
|
+
const configDir = join(tempDir, "config");
|
|
18
19
|
return {
|
|
19
20
|
adminToken: "test",
|
|
20
21
|
assistantToken: "test",
|
|
21
|
-
setupToken: "test",
|
|
22
22
|
homeDir: tempDir,
|
|
23
|
-
configDir
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
logsDir: join(tempDir, "logs"),
|
|
23
|
+
configDir,
|
|
24
|
+
stashDir: join(tempDir, "stash"),
|
|
25
|
+
workspaceDir: join(tempDir, "workspace"),
|
|
27
26
|
cacheDir: join(tempDir, "cache"),
|
|
27
|
+
stateDir: join(tempDir, "state"),
|
|
28
|
+
stackDir: join(configDir, "stack"),
|
|
28
29
|
services: {},
|
|
29
30
|
artifacts: { compose: "" },
|
|
30
31
|
artifactMeta: [],
|
|
@@ -34,28 +35,25 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function seedCoreCompose(): void {
|
|
37
|
-
const stackDir = join(tempDir, "stack");
|
|
38
|
+
const stackDir = join(tempDir, "config", "stack");
|
|
38
39
|
mkdirSync(stackDir, { recursive: true });
|
|
39
40
|
writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function seedEnvFiles(files: { stack?: boolean;
|
|
43
|
+
function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
|
|
44
|
+
const stackDir = join(tempDir, "config", "stack");
|
|
43
45
|
if (files.stack) {
|
|
44
|
-
mkdirSync(
|
|
45
|
-
writeFileSync(join(
|
|
46
|
-
}
|
|
47
|
-
if (files.user) {
|
|
48
|
-
mkdirSync(join(tempDir, "vault", "user"), { recursive: true });
|
|
49
|
-
writeFileSync(join(tempDir, "vault", "user", "user.env"), "SECRET=val");
|
|
46
|
+
mkdirSync(stackDir, { recursive: true });
|
|
47
|
+
writeFileSync(join(stackDir, "stack.env"), "KEY=val");
|
|
50
48
|
}
|
|
51
49
|
if (files.guardian) {
|
|
52
|
-
mkdirSync(
|
|
53
|
-
writeFileSync(join(
|
|
50
|
+
mkdirSync(stackDir, { recursive: true });
|
|
51
|
+
writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
|
|
54
52
|
}
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
function seedAddon(name: string): void {
|
|
58
|
-
const addonDir = join(tempDir, "stack", "addons", name);
|
|
56
|
+
const addonDir = join(tempDir, "config", "stack", "addons", name);
|
|
59
57
|
mkdirSync(addonDir, { recursive: true });
|
|
60
58
|
writeFileSync(join(addonDir, "compose.yml"), "services: {}");
|
|
61
59
|
}
|
|
@@ -98,13 +96,16 @@ describe("buildComposeOptions", () => {
|
|
|
98
96
|
});
|
|
99
97
|
|
|
100
98
|
it("returns env files in correct order", () => {
|
|
101
|
-
|
|
99
|
+
// Note: vault/user/user.env is no longer a
|
|
100
|
+
// compose env_file. The runtime env file list is: stack.env, guardian.env.
|
|
101
|
+
// Even when a legacy user.env is present on disk, it is intentionally
|
|
102
|
+
// excluded from the compose args.
|
|
103
|
+
seedEnvFiles({ stack: true, guardian: true });
|
|
102
104
|
const state = makeState();
|
|
103
105
|
const opts = buildComposeOptions(state);
|
|
104
|
-
expect(opts.envFiles).toHaveLength(
|
|
106
|
+
expect(opts.envFiles).toHaveLength(2);
|
|
105
107
|
expect(opts.envFiles[0]).toContain("stack.env");
|
|
106
|
-
expect(opts.envFiles[1]).toContain("
|
|
107
|
-
expect(opts.envFiles[2]).toContain("guardian.env");
|
|
108
|
+
expect(opts.envFiles[1]).toContain("guardian.env");
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
it("excludes missing env files", () => {
|
|
@@ -136,8 +137,11 @@ describe("buildComposeCliArgs", () => {
|
|
|
136
137
|
});
|
|
137
138
|
|
|
138
139
|
it("includes --env-file flags for env files that exist", () => {
|
|
140
|
+
// Note: vault/user/user.env is no longer
|
|
141
|
+
// listed in the compose env_file set. Only stack.env and guardian.env
|
|
142
|
+
// (when present) are passed via --env-file.
|
|
139
143
|
seedCoreCompose();
|
|
140
|
-
seedEnvFiles({ stack: true,
|
|
144
|
+
seedEnvFiles({ stack: true, guardian: true });
|
|
141
145
|
const state = makeState();
|
|
142
146
|
const args = buildComposeCliArgs(state);
|
|
143
147
|
const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
|
|
@@ -6,67 +6,54 @@
|
|
|
6
6
|
* the rollback module (snapshot to ~/.cache/openpalm/rollback/).
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { parse as yamlParse } from "yaml";
|
|
11
|
+
import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
10
12
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
11
13
|
import { isChannelAddon } from "./channels.js";
|
|
12
|
-
import { readStackSpec } from "./stack-spec.js";
|
|
13
|
-
import { writeCapabilityVars } from "./spec-to-env.js";
|
|
14
14
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
15
|
|
|
16
|
-
import { generateRedactSchema } from "./redact-schema.js";
|
|
17
|
-
import { readStackEnv } from "./secrets.js";
|
|
18
16
|
import {
|
|
19
17
|
readCoreCompose,
|
|
20
|
-
ensureUserEnvSchema,
|
|
21
|
-
ensureSystemEnvSchema,
|
|
22
18
|
} from "./core-assets.js";
|
|
23
19
|
export { sha256, randomHex } from "./crypto.js";
|
|
24
20
|
import { sha256, randomHex } from "./crypto.js";
|
|
25
21
|
|
|
26
22
|
const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
|
|
27
23
|
|
|
28
|
-
// ── Stack Config (stack.yml) ─────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Check whether Ollama is enabled via active stack/addons/ overlay.
|
|
32
|
-
*/
|
|
33
|
-
export function isOllamaEnabled(state: ControlPlaneState): boolean {
|
|
34
|
-
return listEnabledAddonIds(state.homeDir).includes("ollama");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Check whether admin is enabled via active stack/addons/ overlay.
|
|
39
|
-
*/
|
|
40
|
-
export function isAdminEnabled(state: ControlPlaneState): boolean {
|
|
41
|
-
return listEnabledAddonIds(state.homeDir).includes("admin");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
24
|
// ── Env File Management ──────────────────────────────────────────────
|
|
45
25
|
|
|
46
26
|
/**
|
|
47
27
|
* Return the env files used for docker compose --env-file args.
|
|
48
28
|
* These are the live vault env files.
|
|
49
29
|
*
|
|
50
|
-
* Order: stack.env ->
|
|
30
|
+
* Order: stack.env -> guardian.env
|
|
31
|
+
*
|
|
32
|
+
* Note: `vault/user/user.env` is no longer a
|
|
33
|
+
* compose env_file. User-managed env secrets live in the akm
|
|
34
|
+
* `vault:user` store and are sourced by the assistant entrypoint at
|
|
35
|
+
* container startup. The legacy file is migrated into akm and deleted
|
|
36
|
+
* on upgrade; subsequent `docker compose` invocations must not reference
|
|
37
|
+
* it (compose interpolates `${VAR}` against the merged --env-file
|
|
38
|
+
* contents, and a stale user.env would shadow the akm-sourced values).
|
|
51
39
|
*/
|
|
52
40
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
53
41
|
return [
|
|
54
|
-
`${state.
|
|
55
|
-
`${state.
|
|
56
|
-
`${state.vaultDir}/stack/guardian.env`,
|
|
42
|
+
`${state.stackDir}/stack.env`,
|
|
43
|
+
`${state.stackDir}/guardian.env`,
|
|
57
44
|
].filter(existsSync);
|
|
58
45
|
}
|
|
59
46
|
|
|
60
47
|
/**
|
|
61
|
-
* Write system-managed values to
|
|
48
|
+
* Write system-managed values to config/stack/stack.env.
|
|
62
49
|
*
|
|
63
50
|
* Channel HMAC secrets are NOT written here — they belong in guardian.env.
|
|
64
51
|
* Use writeChannelSecrets() for channel secrets.
|
|
65
52
|
*/
|
|
66
53
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
67
|
-
mkdirSync(
|
|
54
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
68
55
|
|
|
69
|
-
const systemEnvPath = `${state.
|
|
56
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
70
57
|
|
|
71
58
|
let base = "";
|
|
72
59
|
if (existsSync(systemEnvPath)) {
|
|
@@ -98,18 +85,16 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
98
85
|
"# Auto-generated fallback.",
|
|
99
86
|
"",
|
|
100
87
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
101
|
-
`
|
|
88
|
+
`OP_UI_TOKEN=\${OP_UI_TOKEN}`,
|
|
102
89
|
`OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
|
|
103
90
|
"",
|
|
104
91
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
105
|
-
`OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
|
|
106
92
|
"OP_OPENCODE_PASSWORD=",
|
|
107
93
|
"",
|
|
108
94
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
109
95
|
`OP_HOME=${state.homeDir}`,
|
|
110
96
|
`OP_UID=${uid}`,
|
|
111
97
|
`OP_GID=${gid}`,
|
|
112
|
-
`OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
|
|
113
98
|
"",
|
|
114
99
|
"# ── Images ──────────────────────────────────────────────────────────",
|
|
115
100
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
@@ -119,7 +104,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
119
104
|
`OP_ASSISTANT_PORT=3800`,
|
|
120
105
|
`OP_ADMIN_PORT=3880`,
|
|
121
106
|
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
122
|
-
`OP_MEMORY_PORT=3898`,
|
|
123
107
|
`OP_GUARDIAN_PORT=3899`,
|
|
124
108
|
""
|
|
125
109
|
].join("\n");
|
|
@@ -191,19 +175,19 @@ function extractChannelSecrets(parsed: Record<string, string>): Record<string, s
|
|
|
191
175
|
}
|
|
192
176
|
|
|
193
177
|
/**
|
|
194
|
-
* Read channel HMAC secrets from
|
|
178
|
+
* Read channel HMAC secrets from config/stack/guardian.env.
|
|
195
179
|
*/
|
|
196
|
-
export function readChannelSecrets(
|
|
197
|
-
return extractChannelSecrets(parseEnvFile(`${
|
|
180
|
+
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
181
|
+
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
198
182
|
}
|
|
199
183
|
|
|
200
184
|
/**
|
|
201
|
-
* Write channel HMAC secrets to
|
|
185
|
+
* Write channel HMAC secrets to state/guardian.env.
|
|
202
186
|
* Merges with existing content; does not overwrite unrelated entries.
|
|
203
187
|
*/
|
|
204
|
-
export function writeChannelSecrets(
|
|
205
|
-
const guardianPath = `${
|
|
206
|
-
mkdirSync(
|
|
188
|
+
export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
|
|
189
|
+
const guardianPath = `${stackDir}/guardian.env`;
|
|
190
|
+
mkdirSync(stackDir, { recursive: true });
|
|
207
191
|
|
|
208
192
|
let base = "";
|
|
209
193
|
if (existsSync(guardianPath)) {
|
|
@@ -223,48 +207,103 @@ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, st
|
|
|
223
207
|
chmodSync(guardianPath, 0o600);
|
|
224
208
|
}
|
|
225
209
|
|
|
210
|
+
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Parse all enabled compose files and pre-create every host-side volume
|
|
214
|
+
* mount target as the current user. This prevents Docker from creating
|
|
215
|
+
* them as root-owned, which causes EACCES inside non-root containers.
|
|
216
|
+
*
|
|
217
|
+
* For file mounts (basename contains a `.`), creates an empty file.
|
|
218
|
+
* For directory mounts (basename has no `.`), creates the directory.
|
|
219
|
+
*
|
|
220
|
+
* Heuristic: a basename containing a `.` is treated as a file. This
|
|
221
|
+
* intentionally includes leading-dot files (e.g. `.env`) because Docker
|
|
222
|
+
* bind mounts to them must be regular files. Bare directory names like
|
|
223
|
+
* `stack` or `addons` lack extensions and are created as directories.
|
|
224
|
+
*
|
|
225
|
+
* Only mount sources under `state.homeDir` are touched; external paths
|
|
226
|
+
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
227
|
+
*/
|
|
228
|
+
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
229
|
+
const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
|
|
230
|
+
if (composeFiles.length === 0) return;
|
|
231
|
+
|
|
232
|
+
const envVars: Record<string, string> = {
|
|
233
|
+
...(process.env as Record<string, string>),
|
|
234
|
+
...parseEnvFile(`${state.stackDir}/stack.env`),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
for (const file of composeFiles) {
|
|
238
|
+
let doc: Record<string, unknown>;
|
|
239
|
+
try {
|
|
240
|
+
doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>;
|
|
241
|
+
} catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const services = doc?.services;
|
|
245
|
+
if (!services || typeof services !== 'object') continue;
|
|
246
|
+
|
|
247
|
+
for (const svc of Object.values(services as Record<string, unknown>)) {
|
|
248
|
+
if (!svc || typeof svc !== 'object') continue;
|
|
249
|
+
const svcRecord = svc as Record<string, unknown>;
|
|
250
|
+
if (!Array.isArray(svcRecord.volumes)) continue;
|
|
251
|
+
for (const vol of svcRecord.volumes as unknown[]) {
|
|
252
|
+
const volRecord = typeof vol === 'object' && vol !== null
|
|
253
|
+
? (vol as Record<string, unknown>)
|
|
254
|
+
: null;
|
|
255
|
+
const rawSource = typeof vol === 'string'
|
|
256
|
+
? vol.split(':')[0]
|
|
257
|
+
: String(volRecord?.source ?? '');
|
|
258
|
+
if (!rawSource) continue;
|
|
259
|
+
|
|
260
|
+
const hostPath = expandEnvVars(rawSource, envVars);
|
|
261
|
+
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
262
|
+
if (existsSync(hostPath)) continue;
|
|
263
|
+
|
|
264
|
+
// A basename containing a `.` (anywhere, including leading) is a file.
|
|
265
|
+
// Bare names like `stack` or `data` are directories.
|
|
266
|
+
const basename = hostPath.split('/').pop() ?? '';
|
|
267
|
+
const isFile = basename.includes('.');
|
|
268
|
+
|
|
269
|
+
if (isFile) {
|
|
270
|
+
mkdirSync(dirname(hostPath), { recursive: true });
|
|
271
|
+
writeFileSync(hostPath, '');
|
|
272
|
+
} else {
|
|
273
|
+
mkdirSync(hostPath, { recursive: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
226
280
|
// ── Persistence (direct-write to live paths) ────────────────────────
|
|
227
281
|
|
|
228
282
|
export function writeRuntimeFiles(
|
|
229
283
|
state: ControlPlaneState
|
|
230
284
|
): void {
|
|
231
|
-
// Write core compose to stack/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
writeFileSync(`${stackDir}/core.compose.yml`, state.artifacts.compose);
|
|
285
|
+
// Write core compose to config/stack/
|
|
286
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
287
|
+
writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
|
|
235
288
|
|
|
236
289
|
// Load persisted channel HMAC secrets from guardian.env,
|
|
237
290
|
// then generate new ones for new channel addons.
|
|
238
|
-
const channelSecrets = readChannelSecrets(state.
|
|
239
|
-
const addonStackDir = `${state.homeDir}/stack`;
|
|
291
|
+
const channelSecrets = readChannelSecrets(state.stackDir);
|
|
240
292
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
241
|
-
const composePath = `${
|
|
293
|
+
const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
|
|
242
294
|
if (isChannelAddon(composePath) && !channelSecrets[addon]) {
|
|
243
295
|
channelSecrets[addon] = randomHex(16);
|
|
244
296
|
}
|
|
245
297
|
}
|
|
246
298
|
|
|
247
299
|
// Write channel secrets to guardian.env (the canonical source)
|
|
248
|
-
writeChannelSecrets(state.
|
|
300
|
+
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
249
301
|
|
|
250
302
|
// Write system.env (no channel secrets — those live in guardian.env)
|
|
251
303
|
writeSystemEnv(state);
|
|
252
304
|
|
|
253
|
-
// Ensure
|
|
254
|
-
|
|
255
|
-
ensureSystemEnvSchema();
|
|
256
|
-
|
|
257
|
-
const spec = readStackSpec(state.configDir);
|
|
258
|
-
// Write OP_CAP_* capability vars to stack.env from stack spec
|
|
259
|
-
if (spec) {
|
|
260
|
-
writeCapabilityVars(spec, state.vaultDir);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Generate redact.env.schema from canonical mappings
|
|
264
|
-
const systemEnv = readStackEnv(state.vaultDir);
|
|
265
|
-
const redactDir = `${state.dataDir}/secrets`;
|
|
266
|
-
mkdirSync(redactDir, { recursive: true });
|
|
267
|
-
writeFileSync(`${redactDir}/redact.env.schema`, generateRedactSchema(systemEnv));
|
|
305
|
+
// Ensure state directory exists
|
|
306
|
+
mkdirSync(state.stateDir, { recursive: true });
|
|
268
307
|
|
|
269
308
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
270
309
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seedStashAssets } from "./core-assets.js";
|
|
6
|
+
|
|
7
|
+
describe("seedStashAssets", () => {
|
|
8
|
+
let homeDir: string;
|
|
9
|
+
const originalHome = process.env.OP_HOME;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
homeDir = mkdtempSync(join(tmpdir(), "stash-seed-test-"));
|
|
13
|
+
process.env.OP_HOME = homeDir;
|
|
14
|
+
mkdirSync(join(homeDir, "stash"), { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env.OP_HOME = originalHome;
|
|
19
|
+
// Restore writable mode in case a test chmod'd the stash dir.
|
|
20
|
+
try {
|
|
21
|
+
chmodSync(join(homeDir, "stash"), 0o755);
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore — dir may not exist
|
|
24
|
+
}
|
|
25
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("writes every seed under stash/ on first run", () => {
|
|
29
|
+
const seeds = {
|
|
30
|
+
"skills/test-skill/SKILL.md": "---\nname: test-skill\ntype: skill\n---\nhello\n",
|
|
31
|
+
"commands/test-cmd.md": "---\nname: test-cmd\ntype: command\n---\nrun me\n",
|
|
32
|
+
};
|
|
33
|
+
const written = seedStashAssets(seeds);
|
|
34
|
+
|
|
35
|
+
expect(written.sort()).toEqual(Object.keys(seeds).sort());
|
|
36
|
+
for (const [rel, content] of Object.entries(seeds)) {
|
|
37
|
+
const target = join(homeDir, "stash", rel);
|
|
38
|
+
expect(existsSync(target)).toBe(true);
|
|
39
|
+
expect(readFileSync(target, "utf-8")).toBe(content);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not overwrite existing files (user edits win)", () => {
|
|
44
|
+
const seeds = { "skills/keep-mine/SKILL.md": "ORIGINAL SEED\n" };
|
|
45
|
+
const userEdit = "USER EDIT — must not be overwritten\n";
|
|
46
|
+
|
|
47
|
+
// Simulate a previous install: seed first.
|
|
48
|
+
seedStashAssets(seeds);
|
|
49
|
+
const target = join(homeDir, "stash/skills/keep-mine/SKILL.md");
|
|
50
|
+
expect(readFileSync(target, "utf-8")).toBe("ORIGINAL SEED\n");
|
|
51
|
+
|
|
52
|
+
// User edits the file.
|
|
53
|
+
writeFileSync(target, userEdit);
|
|
54
|
+
|
|
55
|
+
// Re-run: must return [] and leave the user's content intact.
|
|
56
|
+
const written = seedStashAssets(seeds);
|
|
57
|
+
expect(written).toEqual([]);
|
|
58
|
+
expect(readFileSync(target, "utf-8")).toBe(userEdit);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("creates nested directories under stash/ as needed", () => {
|
|
62
|
+
const seeds = { "skills/deep/nested/asset/SKILL.md": "x" };
|
|
63
|
+
seedStashAssets(seeds);
|
|
64
|
+
expect(existsSync(join(homeDir, "stash/skills/deep/nested/asset/SKILL.md"))).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns an empty list when called with no seeds", () => {
|
|
68
|
+
expect(seedStashAssets({})).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects seed keys that escape the stash directory", () => {
|
|
72
|
+
// Path-traversal guard: ../ sequences in keys must throw rather than
|
|
73
|
+
// silently writing outside stash/.
|
|
74
|
+
expect(() =>
|
|
75
|
+
seedStashAssets({ "../../etc/cron.d/evil": "owned\n" }),
|
|
76
|
+
).toThrow(/escapes stash dir/);
|
|
77
|
+
|
|
78
|
+
// Confirm the malicious payload was NOT written anywhere relative to
|
|
79
|
+
// the temp home.
|
|
80
|
+
expect(existsSync(join(homeDir, "..", "..", "etc", "cron.d", "evil"))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects seed keys that traverse through the stash dir back out", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
seedStashAssets({ "skills/../../../escape.md": "x" }),
|
|
86
|
+
).toThrow(/escapes stash dir/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("surfaces errors when the stash directory is read-only", () => {
|
|
90
|
+
// Skip when running as root (chmod is a no-op for the superuser).
|
|
91
|
+
const uid = process.getuid?.();
|
|
92
|
+
if (uid === 0) return;
|
|
93
|
+
|
|
94
|
+
const stashDir = join(homeDir, "stash");
|
|
95
|
+
chmodSync(stashDir, 0o555);
|
|
96
|
+
try {
|
|
97
|
+
expect(() =>
|
|
98
|
+
seedStashAssets({ "skills/readonly/SKILL.md": "nope\n" }),
|
|
99
|
+
).toThrow();
|
|
100
|
+
} finally {
|
|
101
|
+
chmodSync(stashDir, 0o755);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -3,95 +3,92 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Manages source-of-truth files for the ~/.openpalm/ layout:
|
|
5
5
|
* stack/ — compose runtime assets (core.compose.yml)
|
|
6
|
-
* vault/ — env schemas
|
|
7
6
|
*
|
|
8
7
|
* This module manages runtime-owned core files only.
|
|
9
8
|
* Registry catalog refresh is handled separately in registry.ts.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* is the responsibility of `refreshCoreAssets()` (GitHub download) or
|
|
13
|
-
* the CLI install command (which downloads assets before calling setup).
|
|
9
|
+
* Env validation has moved to `akm vault` + the in-house redactor — the
|
|
10
|
+
* historical `.env.schema` files (varlock format) were retired in #391.
|
|
14
11
|
*/
|
|
15
12
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
13
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
|
|
18
15
|
import { createLogger } from "../logger.js";
|
|
19
16
|
import { sha256 } from "./crypto.js";
|
|
20
17
|
|
|
21
18
|
const logger = createLogger("core-assets");
|
|
22
19
|
|
|
23
|
-
// ── Env Schema Files (vault/) ────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Ensure the user env schema directory exists and return the expected
|
|
27
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
28
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
29
|
-
*/
|
|
30
|
-
export function ensureUserEnvSchema(): string {
|
|
31
|
-
const vaultDir = resolveVaultDir();
|
|
32
|
-
const dir = `${vaultDir}/user`;
|
|
33
|
-
mkdirSync(dir, { recursive: true });
|
|
34
|
-
const path = `${dir}/user.env.schema`;
|
|
35
|
-
return path;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Ensure the system env schema directory exists and return the expected
|
|
40
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
41
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
42
|
-
*/
|
|
43
|
-
export function ensureSystemEnvSchema(): string {
|
|
44
|
-
const vaultDir = resolveVaultDir();
|
|
45
|
-
const dir = `${vaultDir}/stack`;
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const path = `${dir}/stack.env.schema`;
|
|
48
|
-
return path;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Memory data directory ────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
export function ensureMemoryDir(dataDir?: string): string {
|
|
54
|
-
const resolved = dataDir ?? resolveDataDir();
|
|
55
|
-
const dir = `${resolved}/memory`;
|
|
56
|
-
mkdirSync(dir, { recursive: true });
|
|
57
|
-
return dir;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
20
|
// ── Core Compose (stack/) ─────────────────────────────────────────────
|
|
61
21
|
|
|
62
|
-
function coreComposePath(): string {
|
|
63
|
-
return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
22
|
export function ensureCoreCompose(): string {
|
|
67
|
-
const path =
|
|
23
|
+
const path = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
|
|
68
24
|
mkdirSync(dirname(path), { recursive: true });
|
|
69
25
|
return path;
|
|
70
26
|
}
|
|
71
27
|
|
|
72
28
|
export function readCoreCompose(): string {
|
|
73
|
-
|
|
74
|
-
return readFileSync(path, "utf-8");
|
|
29
|
+
return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
|
|
75
30
|
}
|
|
76
31
|
|
|
77
32
|
// ── OpenCode System Config ──────────────────────────────────────────
|
|
78
33
|
|
|
79
34
|
export function ensureOpenCodeSystemConfig(): void {
|
|
80
|
-
const dir = `${
|
|
35
|
+
const dir = `${resolveStateDir()}/assistant`;
|
|
81
36
|
mkdirSync(dir, { recursive: true });
|
|
82
37
|
}
|
|
83
38
|
|
|
39
|
+
// ── Shared akm stash (skills / commands / agents) ────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Seed the shared akm stash with built-in skills / commands / agents.
|
|
43
|
+
*
|
|
44
|
+
* Idempotent: **never overwrites** an existing file — user edits to a
|
|
45
|
+
* seeded asset always win, which preserves the same "config doesn't
|
|
46
|
+
* overwrite user edits" contract that governs the rest of OP_HOME.
|
|
47
|
+
*
|
|
48
|
+
* Returns the list of stash-relative paths that were actually written
|
|
49
|
+
* (empty on re-run when every seed already exists on disk).
|
|
50
|
+
*
|
|
51
|
+
* `seeds` is a map of stash-relative path → file content. Keys MUST be
|
|
52
|
+
* forward-slash relative paths that stay inside `data/stash/`; any key
|
|
53
|
+
* that escapes the stash directory after canonicalization throws,
|
|
54
|
+
* preventing a malicious caller from writing arbitrary files. Source of
|
|
55
|
+
* truth for the seeded files lives at `.openpalm/stash/` in the
|
|
56
|
+
* repo; the CLI embeds them at build time and passes the embedded
|
|
57
|
+
* record directly.
|
|
58
|
+
*/
|
|
59
|
+
export function seedStashAssets(seeds: Record<string, string>): string[] {
|
|
60
|
+
const stashDir = resolveStashDir();
|
|
61
|
+
const normalizedStash = resolve(stashDir);
|
|
62
|
+
const written: string[] = [];
|
|
63
|
+
for (const [relPath, content] of Object.entries(seeds)) {
|
|
64
|
+
const targetPath = join(stashDir, relPath);
|
|
65
|
+
const normalizedTarget = resolve(targetPath);
|
|
66
|
+
if (
|
|
67
|
+
normalizedTarget !== normalizedStash &&
|
|
68
|
+
!normalizedTarget.startsWith(normalizedStash + sep)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(`Seed path escapes stash dir: ${relPath}`);
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(targetPath)) continue;
|
|
73
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
74
|
+
writeFileSync(targetPath, content);
|
|
75
|
+
written.push(relPath);
|
|
76
|
+
}
|
|
77
|
+
return written;
|
|
78
|
+
}
|
|
79
|
+
|
|
84
80
|
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
85
81
|
|
|
86
82
|
const REPO = "itlackey/openpalm";
|
|
87
83
|
const VERSION = process.env.OP_ASSET_VERSION ?? "main";
|
|
88
84
|
|
|
85
|
+
// Stash seeds are intentionally NOT in this list — they use seedStashAssets()
|
|
86
|
+
// which never overwrites existing files (user edits win on re-install).
|
|
89
87
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
90
|
-
{ relPath: "stack/core.compose.yml",
|
|
91
|
-
{ relPath: "
|
|
92
|
-
{ relPath: "
|
|
93
|
-
{ relPath: "
|
|
94
|
-
{ relPath: "vault/stack/stack.env.schema", githubFilename: ".openpalm/vault/stack/stack.env.schema" },
|
|
88
|
+
{ relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
|
|
89
|
+
{ relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
|
|
90
|
+
{ relPath: "config/assistant/openpalm.md", githubFilename: ".openpalm/config/assistant/openpalm.md" },
|
|
91
|
+
{ relPath: "config/assistant/system.md", githubFilename: ".openpalm/config/assistant/system.md" },
|
|
95
92
|
];
|
|
96
93
|
|
|
97
94
|
async function downloadAsset(filename: string): Promise<string> {
|