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.
Files changed (47) hide show
  1. package/README.md +11 -19
  2. package/package.json +4 -2
  3. package/src/commands/addon.ts +5 -4
  4. package/src/commands/automations.ts +63 -0
  5. package/src/commands/install.ts +98 -280
  6. package/src/commands/logs.ts +1 -1
  7. package/src/commands/restart.ts +5 -4
  8. package/src/commands/rollback.ts +4 -3
  9. package/src/commands/scan.ts +66 -32
  10. package/src/commands/service.ts +5 -4
  11. package/src/commands/start.ts +5 -4
  12. package/src/commands/status.ts +1 -1
  13. package/src/commands/stop.ts +2 -4
  14. package/src/commands/uninstall.ts +3 -5
  15. package/src/commands/update.ts +19 -2
  16. package/src/commands/validate.ts +16 -34
  17. package/src/install-flow.test.ts +153 -154
  18. package/src/lib/admin-skills/index.test.ts +70 -0
  19. package/src/lib/admin-skills/index.ts +113 -0
  20. package/src/lib/browser.ts +20 -0
  21. package/src/lib/cli-compose.ts +2 -20
  22. package/src/lib/cli-state.ts +1 -1
  23. package/src/lib/docker.ts +8 -214
  24. package/src/lib/env.ts +12 -83
  25. package/src/lib/io.ts +130 -0
  26. package/src/lib/opencode-subprocess.ts +14 -6
  27. package/src/lib/paths.ts +2 -2
  28. package/src/lib/ui-server.ts +150 -0
  29. package/src/main.test.ts +76 -173
  30. package/src/main.ts +131 -7
  31. package/e2e/start-wizard-server.ts +0 -59
  32. package/src/commands/admin.ts +0 -43
  33. package/src/commands/install-services.test.ts +0 -13
  34. package/src/commands/install-services.ts +0 -9
  35. package/src/commands/upgrade.ts +0 -12
  36. package/src/lib/embedded-assets.ts +0 -115
  37. package/src/lib/varlock.ts +0 -126
  38. package/src/setup-wizard/index.html +0 -321
  39. package/src/setup-wizard/server-errors.test.ts +0 -418
  40. package/src/setup-wizard/server-integration.test.ts +0 -511
  41. package/src/setup-wizard/server.test.ts +0 -508
  42. package/src/setup-wizard/server.ts +0 -342
  43. package/src/setup-wizard/wizard-renderers.js +0 -1294
  44. package/src/setup-wizard/wizard-state.js +0 -346
  45. package/src/setup-wizard/wizard-validators.js +0 -81
  46. package/src/setup-wizard/wizard.css +0 -1611
  47. package/src/setup-wizard/wizard.js +0 -613
@@ -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(REPO_ROOT, 'core/assistant/opencode');
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 vaultDir = join(homeDir, 'vault');
43
- const dataDir = join(homeDir, 'data');
42
+ const stateDir = join(homeDir, 'state');
43
+ const stackDir = join(configDir, 'stack');
44
44
 
45
- // stack/ — seed core compose only
46
- mkdirSync(join(homeDir, 'stack'), { recursive: true });
47
- Bun.spawnSync(['cp', join(OPENPALM_SRC, 'stack', 'core.compose.yml'), join(homeDir, 'stack', 'core.compose.yml')]);
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(homeDir, 'registry', 'addons'));
51
- cpTree(join(OPENPALM_SRC, 'registry', 'automations'), join(homeDir, 'registry', 'automations'));
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(homeDir, 'stack', 'addons', addon));
55
+ cpTree(join(OPENPALM_SRC, 'state', 'registry', 'addons', addon), join(stackDir, 'addons', addon));
56
56
  }
57
57
 
58
- // config/automations/ — enabled only (start empty)
59
- mkdirSync(join(configDir, 'automations'), { recursive: true });
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
- // data/assistant/ — opencode config
77
- const assistantDir = join(dataDir, 'assistant');
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
- const stackVault = join(vaultDir, 'stack');
87
- mkdirSync(stackVault, { recursive: true });
88
- if (!existsSync(join(stackVault, 'guardian.env'))) {
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(stackVault, 'auth.json'))) {
92
- writeFileSync(join(stackVault, 'auth.json'), '{}\n');
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, 'guardian'),
100
- join(vaultDir, 'user'),
101
- join(vaultDir, 'stack'),
102
- dataDir,
103
- join(dataDir, 'admin'),
104
- join(dataDir, 'memory'),
105
- join(dataDir, 'guardian'),
106
- join(dataDir, 'stash'),
107
- join(dataDir, 'workspace'),
108
- join(homeDir, 'logs'),
109
- join(homeDir, 'logs/opencode'),
110
- join(homeDir, 'backups'),
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
- capabilities: {
120
- llm: 'ollama/qwen2.5-coder:3b',
121
- embeddings: { provider: 'ollama', model: 'nomic-embed-text:latest', dims: 768 },
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 = resolveVars(raw, vars);
136
+ const resolved = expandEnvVars(raw, vars);
170
137
  if (!resolved.startsWith('/')) continue;
171
138
  const basename = resolved.split('/').pop() ?? '';
172
- const isFile = basename.includes('.') && !basename.startsWith('.');
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 admin+chat', async () => {
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, 'data/workspace');
164
+ process.env.OP_WORK_DIR = join(homeDir, 'workspace');
198
165
 
199
166
  // Step 1: Seed from local .openpalm/
200
- seedFromLocal(homeDir, ['admin', 'chat']);
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(configDir);
177
+ const stackSpec = readStackSpec(join(homeDir, 'config', 'stack'));
211
178
  expect(stackSpec).not.toBeNull();
212
179
  expect(stackSpec!.version).toBe(2);
213
- expect(stackSpec!.capabilities.llm).toBe('ollama/qwen2.5-coder:3b');
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/admin/compose.yml'))).toBe(true);
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/admin/compose.yml'))).toBe(true);
221
- expect(existsSync(join(homeDir, 'registry/addons/chat/compose.yml'))).toBe(true);
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
- 'vault/stack/stack.env',
227
- 'vault/stack/guardian.env',
228
- 'vault/stack/auth.json',
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, 'vault/stack/stack.env')),
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/admin/compose.yml'),
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 (this is what ensureVolumeMountTargets does)
264
- const { ensureVolumeMountTargets } = await import('./commands/install.ts') as any;
265
- // Can't import private function, so replicate the check
266
- // Only check mounts inside homeDir (ignore Docker socket, etc.)
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 data directories ────────────────────────────────────
298
- for (const dir of ['admin', 'assistant', 'memory', 'guardian', 'stash', 'workspace']) {
299
- expect(existsSync(join(homeDir, `data/${dir}`))).toBe(true);
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 active automations dir exists but catalog is separate ──
303
- expect(existsSync(join(homeDir, 'config/automations'))).toBe(true);
304
- const automations = readdirSync(join(homeDir, 'config/automations'));
305
- expect(automations.length).toBe(0);
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, 'data/workspace');
282
+ process.env.OP_WORK_DIR = join(homeDir, 'workspace');
312
283
 
313
- seedFromLocal(homeDir, ['admin', 'chat']);
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, 'vault/stack/stack.env');
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/admin/compose.yml'),
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
- for (const mount of extractVolumeMountPaths(composeFiles, vars)) {
328
- if (!mount.path.startsWith(homeDir)) continue; // skip Docker socket etc.
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, 'data/workspace');
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
- const stackEnv = join(homeDir, 'vault/stack/stack.env');
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', 'init', 'memory', 'scheduler']);
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
+ }