openpalm 0.10.2 → 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/README.md +11 -19
- package/package.json +4 -2
- package/src/commands/addon.ts +5 -4
- package/src/commands/automations.ts +63 -0
- package/src/commands/install.ts +98 -280
- package/src/commands/logs.ts +1 -1
- package/src/commands/restart.ts +5 -4
- package/src/commands/rollback.ts +4 -3
- package/src/commands/scan.ts +66 -32
- package/src/commands/service.ts +5 -4
- package/src/commands/start.ts +5 -4
- package/src/commands/status.ts +1 -1
- package/src/commands/stop.ts +2 -4
- package/src/commands/uninstall.ts +3 -5
- package/src/commands/update.ts +19 -2
- package/src/commands/validate.ts +16 -34
- package/src/install-flow.test.ts +153 -154
- package/src/lib/admin-skills/index.test.ts +70 -0
- package/src/lib/admin-skills/index.ts +113 -0
- package/src/lib/browser.ts +20 -0
- package/src/lib/cli-compose.ts +2 -20
- package/src/lib/cli-state.ts +1 -1
- package/src/lib/docker.ts +8 -214
- package/src/lib/env.ts +12 -83
- package/src/lib/io.ts +130 -0
- package/src/lib/opencode-subprocess.ts +14 -6
- package/src/lib/paths.ts +2 -2
- package/src/lib/ui-server.ts +150 -0
- package/src/main.test.ts +76 -173
- package/src/main.ts +131 -7
- package/e2e/start-wizard-server.ts +0 -59
- package/src/commands/admin.ts +0 -43
- package/src/commands/install-services.test.ts +0 -13
- package/src/commands/install-services.ts +0 -9
- package/src/commands/upgrade.ts +0 -12
- package/src/lib/embedded-assets.ts +0 -115
- package/src/lib/varlock.ts +0 -126
- package/src/setup-wizard/index.html +0 -321
- package/src/setup-wizard/server-errors.test.ts +0 -418
- package/src/setup-wizard/server-integration.test.ts +0 -511
- package/src/setup-wizard/server.test.ts +0 -508
- package/src/setup-wizard/server.ts +0 -342
- package/src/setup-wizard/wizard-renderers.js +0 -1294
- package/src/setup-wizard/wizard-state.js +0 -346
- package/src/setup-wizard/wizard-validators.js +0 -81
- package/src/setup-wizard/wizard.css +0 -1611
- package/src/setup-wizard/wizard.js +0 -613
package/src/install-flow.test.ts
CHANGED
|
@@ -20,13 +20,13 @@ import {
|
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
22
|
import { parse as yamlParse } from 'yaml';
|
|
23
|
-
import { readStackSpec } from '@openpalm/lib';
|
|
23
|
+
import { readStackSpec, parseEnvFile, expandEnvVars } from '@openpalm/lib';
|
|
24
24
|
|
|
25
25
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
const REPO_ROOT = resolve(import.meta.dir, '../../..');
|
|
28
28
|
const OPENPALM_SRC = join(REPO_ROOT, '.openpalm');
|
|
29
|
-
const ASSISTANT_SRC = join(
|
|
29
|
+
const ASSISTANT_SRC = join(OPENPALM_SRC, 'config', 'assistant');
|
|
30
30
|
const SKIP_INSTALL_FLOW_IN_CI = process.env.CI === 'true';
|
|
31
31
|
|
|
32
32
|
/** Copy a directory tree using cp -a (preserves structure, fast). */
|
|
@@ -39,42 +39,28 @@ function cpTree(src: string, dest: string): void {
|
|
|
39
39
|
/** Seed the OP_HOME directory from the local repo (no network). */
|
|
40
40
|
function seedFromLocal(homeDir: string, enabledAddons: string[] = []): void {
|
|
41
41
|
const configDir = join(homeDir, 'config');
|
|
42
|
-
const
|
|
43
|
-
const
|
|
42
|
+
const stateDir = join(homeDir, 'state');
|
|
43
|
+
const stackDir = join(configDir, 'stack');
|
|
44
44
|
|
|
45
|
-
// stack/ — seed core compose only
|
|
46
|
-
mkdirSync(
|
|
47
|
-
Bun.spawnSync(['cp', join(OPENPALM_SRC, 'stack', 'core.compose.yml'), join(
|
|
45
|
+
// config/stack/ — seed core compose only
|
|
46
|
+
mkdirSync(stackDir, { recursive: true });
|
|
47
|
+
Bun.spawnSync(['cp', join(OPENPALM_SRC, 'config', 'stack', 'core.compose.yml'), join(stackDir, 'core.compose.yml')]);
|
|
48
48
|
|
|
49
|
-
// registry/ — shipped catalog source
|
|
50
|
-
cpTree(join(OPENPALM_SRC, 'registry', 'addons'), join(
|
|
51
|
-
cpTree(join(OPENPALM_SRC, 'registry', 'automations'), join(
|
|
49
|
+
// state/registry/ — shipped catalog source
|
|
50
|
+
cpTree(join(OPENPALM_SRC, 'state', 'registry', 'addons'), join(stateDir, 'registry', 'addons'));
|
|
51
|
+
cpTree(join(OPENPALM_SRC, 'state', 'registry', 'automations'), join(stateDir, 'registry', 'automations'));
|
|
52
52
|
|
|
53
|
-
// stack/addons/ — enabled runtime overlays only
|
|
53
|
+
// config/stack/addons/ — enabled runtime overlays only
|
|
54
54
|
for (const addon of enabledAddons) {
|
|
55
|
-
cpTree(join(OPENPALM_SRC, 'registry', 'addons', addon), join(
|
|
55
|
+
cpTree(join(OPENPALM_SRC, 'state', 'registry', 'addons', addon), join(stackDir, 'addons', addon));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
mkdirSync(join(
|
|
60
|
-
|
|
61
|
-
// vault/ — schemas only
|
|
62
|
-
for (const sub of ['user', 'stack']) {
|
|
63
|
-
const srcDir = join(OPENPALM_SRC, 'vault', sub);
|
|
64
|
-
const destDir = join(vaultDir, sub);
|
|
65
|
-
mkdirSync(destDir, { recursive: true });
|
|
66
|
-
if (existsSync(srcDir)) {
|
|
67
|
-
for (const f of readdirSync(srcDir)) {
|
|
68
|
-
if (f.endsWith('.schema')) {
|
|
69
|
-
const content = readFileSync(join(srcDir, f));
|
|
70
|
-
Bun.spawnSync(['cp', join(srcDir, f), join(destDir, f)]);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
58
|
+
// stash/tasks/ — active AKM task files (populated by setup)
|
|
59
|
+
mkdirSync(join(homeDir, 'stash', 'tasks'), { recursive: true });
|
|
75
60
|
|
|
76
|
-
//
|
|
77
|
-
|
|
61
|
+
// config/assistant/ — opencode project config (opencode.jsonc, openpalm.md, system.md)
|
|
62
|
+
// OPENCODE_CONFIG_DIR points at this directory inside the container.
|
|
63
|
+
const assistantDir = join(configDir, 'assistant');
|
|
78
64
|
mkdirSync(assistantDir, { recursive: true });
|
|
79
65
|
if (existsSync(ASSISTANT_SRC)) {
|
|
80
66
|
for (const f of readdirSync(ASSISTANT_SRC)) {
|
|
@@ -83,31 +69,32 @@ function seedFromLocal(homeDir: string, enabledAddons: string[] = []): void {
|
|
|
83
69
|
}
|
|
84
70
|
|
|
85
71
|
// Seed file-based volume mount targets (CLI bootstrapInstall does this)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Bun.spawnSync(['touch', join(stackVault, 'guardian.env')]);
|
|
72
|
+
mkdirSync(stackDir, { recursive: true });
|
|
73
|
+
if (!existsSync(join(stackDir, 'guardian.env'))) {
|
|
74
|
+
Bun.spawnSync(['touch', join(stackDir, 'guardian.env')]);
|
|
90
75
|
}
|
|
91
|
-
if (!existsSync(join(
|
|
92
|
-
writeFileSync(join(
|
|
76
|
+
if (!existsSync(join(configDir, 'auth.json'))) {
|
|
77
|
+
writeFileSync(join(configDir, 'auth.json'), '{}\n');
|
|
93
78
|
}
|
|
94
79
|
|
|
95
80
|
// Create required directories
|
|
96
81
|
for (const dir of [
|
|
97
82
|
configDir,
|
|
98
83
|
join(configDir, 'assistant'),
|
|
99
|
-
join(configDir, '
|
|
100
|
-
|
|
101
|
-
join(
|
|
102
|
-
|
|
103
|
-
join(
|
|
104
|
-
join(
|
|
105
|
-
join(
|
|
106
|
-
join(
|
|
107
|
-
join(
|
|
108
|
-
join(homeDir, '
|
|
109
|
-
join(homeDir, '
|
|
110
|
-
join(
|
|
84
|
+
join(configDir, 'akm'),
|
|
85
|
+
stackDir,
|
|
86
|
+
join(stackDir, 'addons'),
|
|
87
|
+
stateDir,
|
|
88
|
+
join(stateDir, 'assistant'),
|
|
89
|
+
join(stateDir, 'admin'),
|
|
90
|
+
join(stateDir, 'guardian'),
|
|
91
|
+
join(homeDir, 'stash'),
|
|
92
|
+
join(homeDir, 'workspace'),
|
|
93
|
+
join(homeDir, 'cache'),
|
|
94
|
+
join(homeDir, 'cache', 'akm'),
|
|
95
|
+
join(stateDir, 'logs'),
|
|
96
|
+
join(stateDir, 'logs', 'opencode'),
|
|
97
|
+
join(stateDir, 'backups'),
|
|
111
98
|
]) {
|
|
112
99
|
mkdirSync(dir, { recursive: true });
|
|
113
100
|
}
|
|
@@ -116,13 +103,9 @@ function seedFromLocal(homeDir: string, enabledAddons: string[] = []): void {
|
|
|
116
103
|
function makeSetupSpec(): Record<string, unknown> {
|
|
117
104
|
return {
|
|
118
105
|
version: 2,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
memory: { userId: 'testuser', customInstructions: '' },
|
|
123
|
-
slm: 'ollama/qwen2.5-coder:3b',
|
|
124
|
-
},
|
|
125
|
-
security: { adminToken: 'test-admin-token-12345' },
|
|
106
|
+
llm: { provider: 'ollama', model: 'qwen2.5-coder:3b', baseUrl: 'http://host.docker.internal:11434' },
|
|
107
|
+
embedding: { provider: 'ollama', model: 'nomic-embed-text:latest', dims: 768, baseUrl: 'http://host.docker.internal:11434' },
|
|
108
|
+
security: { uiLoginPassword: 'test-admin-token-12345' },
|
|
126
109
|
owner: { name: 'Test', email: 'test@test.com' },
|
|
127
110
|
connections: [{
|
|
128
111
|
id: 'ollama',
|
|
@@ -134,22 +117,6 @@ function makeSetupSpec(): Record<string, unknown> {
|
|
|
134
117
|
};
|
|
135
118
|
}
|
|
136
119
|
|
|
137
|
-
/** Parse env vars from stack.env for compose variable substitution. */
|
|
138
|
-
function parseEnvFile(path: string): Record<string, string> {
|
|
139
|
-
const vars: Record<string, string> = {};
|
|
140
|
-
if (!existsSync(path)) return vars;
|
|
141
|
-
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
142
|
-
const m = line.match(/^(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
143
|
-
if (m) vars[m[1]] = m[2];
|
|
144
|
-
}
|
|
145
|
-
return vars;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Resolve ${VAR:-default} patterns in a string. */
|
|
149
|
-
function resolveVars(str: string, vars: Record<string, string>): string {
|
|
150
|
-
return str.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
120
|
/** Extract all host-side volume mount paths from compose files. */
|
|
154
121
|
function extractVolumeMountPaths(
|
|
155
122
|
composeFiles: string[],
|
|
@@ -166,10 +133,10 @@ function extractVolumeMountPaths(
|
|
|
166
133
|
for (const vol of svc.volumes) {
|
|
167
134
|
const raw = typeof vol === 'string' ? vol.split(':')[0] : (vol?.source ?? '');
|
|
168
135
|
if (!raw || typeof raw !== 'string') continue;
|
|
169
|
-
const resolved =
|
|
136
|
+
const resolved = expandEnvVars(raw, vars);
|
|
170
137
|
if (!resolved.startsWith('/')) continue;
|
|
171
138
|
const basename = resolved.split('/').pop() ?? '';
|
|
172
|
-
const isFile = basename.includes('.')
|
|
139
|
+
const isFile = basename.includes('.');
|
|
173
140
|
results.push({ path: resolved, isFile });
|
|
174
141
|
}
|
|
175
142
|
}
|
|
@@ -191,13 +158,13 @@ describe('install flow — tier 1 (file validation)', () => {
|
|
|
191
158
|
if (homeDir) rmSync(homeDir, { recursive: true, force: true });
|
|
192
159
|
});
|
|
193
160
|
|
|
194
|
-
tier1Test('seed + performSetup produces complete file structure for
|
|
161
|
+
tier1Test('seed + performSetup produces complete file structure for chat addon', async () => {
|
|
195
162
|
homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
|
|
196
163
|
process.env.OP_HOME = homeDir;
|
|
197
|
-
process.env.OP_WORK_DIR = join(homeDir, '
|
|
164
|
+
process.env.OP_WORK_DIR = join(homeDir, 'workspace');
|
|
198
165
|
|
|
199
166
|
// Step 1: Seed from local .openpalm/
|
|
200
|
-
seedFromLocal(homeDir, ['
|
|
167
|
+
seedFromLocal(homeDir, ['chat']);
|
|
201
168
|
|
|
202
169
|
// Step 2: Run performSetup
|
|
203
170
|
const { performSetup } = await import('@openpalm/lib');
|
|
@@ -207,76 +174,62 @@ describe('install flow — tier 1 (file validation)', () => {
|
|
|
207
174
|
|
|
208
175
|
// ── Validate stack.yml via lib parser ─────────────────────────
|
|
209
176
|
const configDir = join(homeDir, 'config');
|
|
210
|
-
const stackSpec = readStackSpec(
|
|
177
|
+
const stackSpec = readStackSpec(join(homeDir, 'config', 'stack'));
|
|
211
178
|
expect(stackSpec).not.toBeNull();
|
|
212
179
|
expect(stackSpec!.version).toBe(2);
|
|
213
|
-
|
|
180
|
+
// stack.yml carries only { version: 2 } — LLM config lives in akm config.json
|
|
181
|
+
const akmConfigPath = join(homeDir, 'config/akm/config.json');
|
|
182
|
+
expect(existsSync(akmConfigPath)).toBe(true);
|
|
183
|
+
const akmConfig = JSON.parse(readFileSync(akmConfigPath, 'utf-8'));
|
|
184
|
+
expect(akmConfig.llm?.provider).toBe('ollama');
|
|
185
|
+
expect(akmConfig.llm?.model).toBe('qwen2.5-coder:3b');
|
|
214
186
|
|
|
215
187
|
// ── Validate compose files exist ─────────────────────────────────
|
|
216
|
-
expect(existsSync(join(homeDir, 'stack/core.compose.yml'))).toBe(true);
|
|
217
|
-
expect(existsSync(join(homeDir, 'stack/addons/
|
|
218
|
-
expect(existsSync(join(homeDir, 'stack/addons/chat/compose.yml'))).toBe(true);
|
|
188
|
+
expect(existsSync(join(homeDir, 'config/stack/core.compose.yml'))).toBe(true);
|
|
189
|
+
expect(existsSync(join(homeDir, 'config/stack/addons/chat/compose.yml'))).toBe(true);
|
|
219
190
|
|
|
220
|
-
expect(existsSync(join(homeDir, 'registry/addons/
|
|
221
|
-
expect(existsSync(join(homeDir, 'registry/
|
|
222
|
-
expect(existsSync(join(homeDir, 'registry/automations/cleanup-logs.yml'))).toBe(true);
|
|
191
|
+
expect(existsSync(join(homeDir, 'state/registry/addons/chat/compose.yml'))).toBe(true);
|
|
192
|
+
expect(existsSync(join(homeDir, 'state/registry/automations/cleanup-logs.md'))).toBe(true);
|
|
223
193
|
|
|
224
194
|
// ── Validate vault files are regular files (not directories) ─────
|
|
195
|
+
// Note: vault/user/user.env is no longer
|
|
196
|
+
// seeded — user-managed env secrets live in akm vault:user
|
|
197
|
+
// (data/stash/vaults/user.env) and the assistant entrypoint sources
|
|
198
|
+
// it directly. The compose env_file mount for vault/user/user.env
|
|
199
|
+
// has been removed too.
|
|
225
200
|
for (const relPath of [
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'vault/user/user.env',
|
|
201
|
+
'config/stack/stack.env',
|
|
202
|
+
'config/stack/guardian.env',
|
|
203
|
+
'config/auth.json',
|
|
230
204
|
]) {
|
|
231
205
|
const fullPath = join(homeDir, relPath);
|
|
232
206
|
expect(existsSync(fullPath)).toBe(true);
|
|
233
207
|
expect(statSync(fullPath).isFile()).toBe(true);
|
|
234
208
|
}
|
|
235
209
|
|
|
236
|
-
// ── Validate vault schemas ───────────────────────────────────────
|
|
237
|
-
for (const relPath of [
|
|
238
|
-
'vault/user/user.env.schema',
|
|
239
|
-
'vault/stack/stack.env.schema',
|
|
240
|
-
]) {
|
|
241
|
-
const fullPath = join(homeDir, relPath);
|
|
242
|
-
expect(existsSync(fullPath)).toBe(true);
|
|
243
|
-
expect(statSync(fullPath).isFile()).toBe(true);
|
|
244
|
-
expect(readFileSync(fullPath, 'utf-8').length).toBeGreaterThan(0);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
210
|
// ── Validate all volume mount targets exist as user-owned ────────
|
|
248
211
|
const stackEnvVars = {
|
|
249
|
-
...parseEnvFile(join(homeDir, '
|
|
212
|
+
...parseEnvFile(join(homeDir, 'config/stack/stack.env')),
|
|
250
213
|
...process.env as Record<string, string>,
|
|
251
214
|
};
|
|
252
215
|
// OP_HOME must resolve to absolute path
|
|
253
216
|
stackEnvVars.OP_HOME = homeDir;
|
|
254
217
|
|
|
255
218
|
const allComposeFiles = [
|
|
256
|
-
join(homeDir, 'stack/core.compose.yml'),
|
|
257
|
-
join(homeDir, 'stack/addons/
|
|
258
|
-
join(homeDir, 'stack/addons/chat/compose.yml'),
|
|
219
|
+
join(homeDir, 'config/stack/core.compose.yml'),
|
|
220
|
+
join(homeDir, 'config/stack/addons/chat/compose.yml'),
|
|
259
221
|
];
|
|
260
222
|
const mounts = extractVolumeMountPaths(allComposeFiles, stackEnvVars);
|
|
261
223
|
expect(mounts.length).toBeGreaterThan(0);
|
|
262
224
|
|
|
263
|
-
// Ensure they all exist first
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
225
|
+
// Ensure they all exist first via the canonical lib helper. Only mounts
|
|
226
|
+
// under homeDir are touched; external paths (Docker socket, etc.) are left
|
|
227
|
+
// alone by ensureComposeVolumeTargets itself, but we also filter the
|
|
228
|
+
// verification loop below to homeDir to keep the assertion local.
|
|
229
|
+
const { ensureComposeVolumeTargets, createState } = await import('@openpalm/lib');
|
|
230
|
+
ensureComposeVolumeTargets(createState());
|
|
267
231
|
const homeMounts = mounts.filter(m => m.path.startsWith(homeDir));
|
|
268
232
|
|
|
269
|
-
for (const mount of homeMounts) {
|
|
270
|
-
if (!existsSync(mount.path)) {
|
|
271
|
-
if (mount.isFile) {
|
|
272
|
-
mkdirSync(join(mount.path, '..'), { recursive: true });
|
|
273
|
-
Bun.spawnSync(['touch', mount.path]);
|
|
274
|
-
} else {
|
|
275
|
-
mkdirSync(mount.path, { recursive: true });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
233
|
for (const mount of homeMounts) {
|
|
281
234
|
expect(existsSync(mount.path)).toBe(true);
|
|
282
235
|
const stat = lstatSync(mount.path);
|
|
@@ -294,56 +247,64 @@ describe('install flow — tier 1 (file validation)', () => {
|
|
|
294
247
|
const rootFiles = new TextDecoder().decode(rootOwned.stdout).trim();
|
|
295
248
|
expect(rootFiles).toBe('');
|
|
296
249
|
|
|
297
|
-
// ── Validate
|
|
298
|
-
for (const dir of ['admin', 'assistant', '
|
|
299
|
-
expect(existsSync(join(homeDir,
|
|
250
|
+
// ── Validate state and stash directories ────────────────────────────────────
|
|
251
|
+
for (const dir of ['state/admin', 'state/assistant', 'state/guardian', 'stash', 'workspace']) {
|
|
252
|
+
expect(existsSync(join(homeDir, dir))).toBe(true);
|
|
300
253
|
}
|
|
301
254
|
|
|
302
|
-
// ── Validate
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
255
|
+
// ── Validate akm-improve is seeded into stash/tasks/ ──
|
|
256
|
+
// performSetup seeds the akm-improve maintenance automation on first
|
|
257
|
+
// install as an AKM markdown task; everything else stays in the registry
|
|
258
|
+
// catalog until enabled.
|
|
259
|
+
const tasksDir = join(homeDir, 'stash/tasks');
|
|
260
|
+
expect(existsSync(tasksDir)).toBe(true);
|
|
261
|
+
const tasks = readdirSync(tasksDir).sort();
|
|
262
|
+
expect(tasks).toEqual(['akm-improve.md']);
|
|
263
|
+
|
|
264
|
+
const akmImprovePath = join(homeDir, 'stash/tasks/akm-improve.md');
|
|
265
|
+
const akmImproveContent = readFileSync(akmImprovePath, 'utf-8');
|
|
266
|
+
expect(akmImproveContent).toContain('akm');
|
|
267
|
+
expect(akmImproveContent).toContain('improve');
|
|
268
|
+
// Confirm we're on the 0.8.0+ command, not the removed `index --enrich`.
|
|
269
|
+
expect(akmImproveContent).not.toMatch(/--enrich\b/);
|
|
270
|
+
|
|
271
|
+
// ── Re-run setup: user edits to akm-improve.md must survive ─────
|
|
272
|
+
const userEdited = '---\nschedule: "0 9 * * *"\nenabled: false\ncommand: ["akm","improve","--auto-accept","safe"]\n---\n';
|
|
273
|
+
writeFileSync(akmImprovePath, userEdited);
|
|
274
|
+
const reSetup = await performSetup(spec as any);
|
|
275
|
+
expect(reSetup.ok).toBe(true);
|
|
276
|
+
expect(readFileSync(akmImprovePath, 'utf-8')).toBe(userEdited);
|
|
306
277
|
}, 30_000);
|
|
307
278
|
|
|
308
279
|
tier1Test('compose config validates with selected addons', async () => {
|
|
309
280
|
homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
|
|
310
281
|
process.env.OP_HOME = homeDir;
|
|
311
|
-
process.env.OP_WORK_DIR = join(homeDir, '
|
|
282
|
+
process.env.OP_WORK_DIR = join(homeDir, 'workspace');
|
|
312
283
|
|
|
313
|
-
seedFromLocal(homeDir, ['
|
|
284
|
+
seedFromLocal(homeDir, ['chat']);
|
|
314
285
|
|
|
315
286
|
const { performSetup } = await import('@openpalm/lib');
|
|
316
287
|
const result = await performSetup(makeSetupSpec() as any);
|
|
317
288
|
expect(result.ok).toBe(true);
|
|
318
289
|
|
|
319
290
|
// Ensure all volume mount targets exist so compose doesn't complain
|
|
320
|
-
const stackEnv = join(homeDir, '
|
|
321
|
-
const vars = { ...parseEnvFile(stackEnv), OP_HOME: homeDir };
|
|
291
|
+
const stackEnv = join(homeDir, 'config/stack/stack.env');
|
|
322
292
|
const composeFiles = [
|
|
323
|
-
join(homeDir, 'stack/core.compose.yml'),
|
|
324
|
-
join(homeDir, 'stack/addons/
|
|
325
|
-
join(homeDir, 'stack/addons/chat/compose.yml'),
|
|
293
|
+
join(homeDir, 'config/stack/core.compose.yml'),
|
|
294
|
+
join(homeDir, 'config/stack/addons/chat/compose.yml'),
|
|
326
295
|
];
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (!existsSync(mount.path)) {
|
|
330
|
-
if (mount.isFile) {
|
|
331
|
-
mkdirSync(join(mount.path, '..'), { recursive: true });
|
|
332
|
-
Bun.spawnSync(['touch', mount.path]);
|
|
333
|
-
} else {
|
|
334
|
-
mkdirSync(mount.path, { recursive: true });
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
296
|
+
const { ensureComposeVolumeTargets, createState } = await import('@openpalm/lib');
|
|
297
|
+
ensureComposeVolumeTargets(createState());
|
|
338
298
|
|
|
339
299
|
// Run docker compose config --quiet
|
|
300
|
+
// Note: vault/user/user.env is no longer a
|
|
301
|
+
// compose env_file. Only stack.env (and guardian.env, when present)
|
|
302
|
+
// are passed to compose.
|
|
340
303
|
const proc = Bun.spawnSync([
|
|
341
304
|
'docker', 'compose', '--project-name', 'openpalm-test',
|
|
342
305
|
'-f', composeFiles[0],
|
|
343
306
|
'-f', composeFiles[1],
|
|
344
|
-
'-f', composeFiles[2],
|
|
345
307
|
'--env-file', stackEnv,
|
|
346
|
-
'--env-file', join(homeDir, 'vault/user/user.env'),
|
|
347
308
|
'config', '--quiet',
|
|
348
309
|
], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, OP_HOME: homeDir } });
|
|
349
310
|
|
|
@@ -354,10 +315,48 @@ describe('install flow — tier 1 (file validation)', () => {
|
|
|
354
315
|
expect(proc.exitCode).toBe(0);
|
|
355
316
|
}, 30_000);
|
|
356
317
|
|
|
318
|
+
tier1Test('seedOpenPalmDir copies the built-in stash skill on first install', async () => {
|
|
319
|
+
homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
|
|
320
|
+
process.env.OP_HOME = homeDir;
|
|
321
|
+
process.env.OP_WORK_DIR = join(homeDir, 'workspace');
|
|
322
|
+
|
|
323
|
+
const { seedOpenPalmDir } = await import('./lib/io.ts');
|
|
324
|
+
await seedOpenPalmDir('local', homeDir, join(homeDir, 'config'), join(homeDir, 'state'));
|
|
325
|
+
|
|
326
|
+
// The shipped config-diagnostics skill must land on disk with valid frontmatter.
|
|
327
|
+
const skillPath = join(homeDir, 'stash/skills/config-diagnostics/SKILL.md');
|
|
328
|
+
expect(existsSync(skillPath)).toBe(true);
|
|
329
|
+
const skill = readFileSync(skillPath, 'utf-8');
|
|
330
|
+
expect(skill).toContain('name: config-diagnostics');
|
|
331
|
+
expect(skill).toContain('type: skill');
|
|
332
|
+
expect(skill.startsWith('---')).toBe(true);
|
|
333
|
+
}, 30_000);
|
|
334
|
+
|
|
335
|
+
tier1Test('seedOpenPalmDir preserves user edits to seeded stash assets', async () => {
|
|
336
|
+
homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
|
|
337
|
+
process.env.OP_HOME = homeDir;
|
|
338
|
+
process.env.OP_WORK_DIR = join(homeDir, 'workspace');
|
|
339
|
+
|
|
340
|
+
const { seedOpenPalmDir } = await import('./lib/io.ts');
|
|
341
|
+
|
|
342
|
+
// First install seeds the asset.
|
|
343
|
+
await seedOpenPalmDir('local', homeDir, join(homeDir, 'config'), join(homeDir, 'state'));
|
|
344
|
+
const skillPath = join(homeDir, 'stash/skills/config-diagnostics/SKILL.md');
|
|
345
|
+
expect(existsSync(skillPath)).toBe(true);
|
|
346
|
+
|
|
347
|
+
// User edits the seeded skill.
|
|
348
|
+
const userEdit = '# User-edited skill — do not clobber\n';
|
|
349
|
+
writeFileSync(skillPath, userEdit);
|
|
350
|
+
|
|
351
|
+
// Re-install must not overwrite the user's edit (skipExisting).
|
|
352
|
+
await seedOpenPalmDir('local', homeDir, join(homeDir, 'config'), join(homeDir, 'state'));
|
|
353
|
+
expect(readFileSync(skillPath, 'utf-8')).toBe(userEdit);
|
|
354
|
+
}, 30_000);
|
|
355
|
+
|
|
357
356
|
tier1Test('performSetup with no addons produces only core services', async () => {
|
|
358
357
|
homeDir = mkdtempSync(join(tmpdir(), 'openpalm-install-test-'));
|
|
359
358
|
process.env.OP_HOME = homeDir;
|
|
360
|
-
process.env.OP_WORK_DIR = join(homeDir, '
|
|
359
|
+
process.env.OP_WORK_DIR = join(homeDir, 'workspace');
|
|
361
360
|
|
|
362
361
|
seedFromLocal(homeDir);
|
|
363
362
|
|
|
@@ -365,21 +364,21 @@ describe('install flow — tier 1 (file validation)', () => {
|
|
|
365
364
|
const result = await performSetup(makeSetupSpec() as any);
|
|
366
365
|
expect(result.ok).toBe(true);
|
|
367
366
|
|
|
368
|
-
const noAddonSpec = readStackSpec(join(homeDir, 'config'));
|
|
367
|
+
const noAddonSpec = readStackSpec(join(homeDir, 'config', 'stack'));
|
|
369
368
|
expect(noAddonSpec).not.toBeNull();
|
|
370
369
|
|
|
371
|
-
// Core compose only, no addon files in the compose list
|
|
372
|
-
|
|
370
|
+
// Core compose only, no addon files in the compose list.
|
|
371
|
+
// Only state/stack.env is needed for `compose config`.
|
|
372
|
+
const stackEnv = join(homeDir, 'config/stack/stack.env');
|
|
373
373
|
const proc = Bun.spawnSync([
|
|
374
374
|
'docker', 'compose', '--project-name', 'openpalm-test',
|
|
375
|
-
'-f', join(homeDir, 'stack/core.compose.yml'),
|
|
375
|
+
'-f', join(homeDir, 'config/stack/core.compose.yml'),
|
|
376
376
|
'--env-file', stackEnv,
|
|
377
|
-
'--env-file', join(homeDir, 'vault/user/user.env'),
|
|
378
377
|
'config', '--services',
|
|
379
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
378
|
+
], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, OP_HOME: homeDir } });
|
|
380
379
|
|
|
381
380
|
const services = new TextDecoder().decode(proc.stdout).trim().split('\n').sort();
|
|
382
|
-
expect(services).toEqual(['assistant', 'guardian'
|
|
381
|
+
expect(services).toEqual(['assistant', 'guardian']);
|
|
383
382
|
}, 30_000);
|
|
384
383
|
});
|
|
385
384
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
validateContainerOp,
|
|
4
|
+
validateDestructiveOp,
|
|
5
|
+
validatePathArg,
|
|
6
|
+
validateAddonName,
|
|
7
|
+
} from "./index.ts";
|
|
8
|
+
|
|
9
|
+
describe("validateContainerOp", () => {
|
|
10
|
+
it("rejects path traversal in service name", () => {
|
|
11
|
+
const r = validateContainerOp("../../etc/passwd");
|
|
12
|
+
expect(r.ok).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects unknown service name", () => {
|
|
16
|
+
const r = validateContainerOp("evil-service");
|
|
17
|
+
expect(r.ok).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("accepts a valid core service name", () => {
|
|
21
|
+
// CORE_SERVICES contains "assistant"
|
|
22
|
+
const r = validateContainerOp("assistant");
|
|
23
|
+
expect(r.ok).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("validateDestructiveOp", () => {
|
|
28
|
+
it("rejects empty confirmation", () => {
|
|
29
|
+
const r = validateDestructiveOp("");
|
|
30
|
+
expect(r.ok).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects wrong confirmation string", () => {
|
|
34
|
+
const r = validateDestructiveOp("yes");
|
|
35
|
+
expect(r.ok).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("accepts correct confirmation", () => {
|
|
39
|
+
const r = validateDestructiveOp("yes-i-am-sure");
|
|
40
|
+
expect(r.ok).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("validatePathArg", () => {
|
|
45
|
+
it("rejects path traversal", () => {
|
|
46
|
+
expect(validatePathArg("../../secrets").ok).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects shell injection characters", () => {
|
|
50
|
+
expect(validatePathArg("foo$(rm -rf /)").ok).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accepts a normal relative path", () => {
|
|
54
|
+
expect(validatePathArg("stash/tasks/my-task.md").ok).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("validateAddonName", () => {
|
|
59
|
+
it("rejects names with slashes", () => {
|
|
60
|
+
expect(validateAddonName("../../admin").ok).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects names with spaces", () => {
|
|
64
|
+
expect(validateAddonName("my addon").ok).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts a clean addon name", () => {
|
|
68
|
+
expect(validateAddonName("voice-channel").ok).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin skills allowlist.
|
|
3
|
+
*
|
|
4
|
+
* Validates arguments for every admin skill call before they reach the admin API
|
|
5
|
+
* or lib functions. This is the security boundary between the assistant subprocess
|
|
6
|
+
* and the control plane.
|
|
7
|
+
*
|
|
8
|
+
* Four invariants enforced:
|
|
9
|
+
* 1. No ".." in path arguments (path traversal).
|
|
10
|
+
* 2. Service names must be in CORE_SERVICES.
|
|
11
|
+
* 3. Destructive operations require confirmation: "yes-i-am-sure".
|
|
12
|
+
* 4. No raw shell strings (sub-shell expansions, pipes, redirects).
|
|
13
|
+
*/
|
|
14
|
+
import { CORE_SERVICES } from "@openpalm/lib";
|
|
15
|
+
|
|
16
|
+
// ── Invariant helpers ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** INV-1: No path traversal */
|
|
19
|
+
function assertNoPathTraversal(value: string, field: string): string | null {
|
|
20
|
+
if (value.includes("..")) {
|
|
21
|
+
return `${field}: path traversal ("..") is not allowed`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** INV-2: Service name must be in CORE_SERVICES */
|
|
27
|
+
function assertValidServiceName(value: string, field: string): string | null {
|
|
28
|
+
const valid = new Set<string>(CORE_SERVICES);
|
|
29
|
+
if (!valid.has(value as never)) {
|
|
30
|
+
return `${field}: "${value}" is not a valid service name (allowed: ${[...valid].join(", ")})`;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** INV-3: Destructive ops require explicit confirmation */
|
|
36
|
+
function assertConfirmation(confirmation: unknown, field = "confirmation"): string | null {
|
|
37
|
+
if (confirmation !== "yes-i-am-sure") {
|
|
38
|
+
return `${field}: destructive operation requires confirmation === "yes-i-am-sure"`;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** INV-4: No shell special characters in string arguments */
|
|
44
|
+
const SHELL_INJECTION_RE = /[$`|&;<>(){}[\]\\!]/;
|
|
45
|
+
function assertNoShellInjection(value: string, field: string): string | null {
|
|
46
|
+
if (SHELL_INJECTION_RE.test(value)) {
|
|
47
|
+
return `${field}: shell special characters are not allowed in admin skill arguments`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Public validation entry points ───────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export type ValidationResult =
|
|
55
|
+
| { ok: true }
|
|
56
|
+
| { ok: false; error: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate arguments for a container operation (up/down/restart/start/stop).
|
|
60
|
+
*
|
|
61
|
+
* @param serviceName The name of the service to act on
|
|
62
|
+
*/
|
|
63
|
+
export function validateContainerOp(serviceName: string): ValidationResult {
|
|
64
|
+
const err =
|
|
65
|
+
assertNoPathTraversal(serviceName, "serviceName") ??
|
|
66
|
+
assertValidServiceName(serviceName, "serviceName") ??
|
|
67
|
+
assertNoShellInjection(serviceName, "serviceName");
|
|
68
|
+
if (err) return { ok: false, error: err };
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate arguments for a destructive operation (uninstall, wipe, etc.).
|
|
74
|
+
*
|
|
75
|
+
* @param confirmation Must equal "yes-i-am-sure"
|
|
76
|
+
*/
|
|
77
|
+
export function validateDestructiveOp(confirmation: unknown): ValidationResult {
|
|
78
|
+
const err = assertConfirmation(confirmation);
|
|
79
|
+
if (err) return { ok: false, error: err };
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a filesystem path argument passed to any admin skill.
|
|
85
|
+
*
|
|
86
|
+
* @param path The path string to validate
|
|
87
|
+
*/
|
|
88
|
+
export function validatePathArg(path: string): ValidationResult {
|
|
89
|
+
const err =
|
|
90
|
+
assertNoPathTraversal(path, "path") ??
|
|
91
|
+
assertNoShellInjection(path, "path");
|
|
92
|
+
if (err) return { ok: false, error: err };
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate an addon name (same rules as service name but addons are not in CORE_SERVICES;
|
|
98
|
+
* still must not contain shell characters or path traversal).
|
|
99
|
+
*
|
|
100
|
+
* @param name The addon name
|
|
101
|
+
*/
|
|
102
|
+
export function validateAddonName(name: string): ValidationResult {
|
|
103
|
+
// Addon names are not fixed like CORE_SERVICES, but must be clean identifiers.
|
|
104
|
+
const ADDON_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
105
|
+
if (!ADDON_NAME_RE.test(name)) {
|
|
106
|
+
return { ok: false, error: `name: "${name}" is not a valid addon name (alphanumeric, _ and - only)` };
|
|
107
|
+
}
|
|
108
|
+
const err =
|
|
109
|
+
assertNoPathTraversal(name, "name") ??
|
|
110
|
+
assertNoShellInjection(name, "name");
|
|
111
|
+
if (err) return { ok: false, error: err };
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|