@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
@@ -2,10 +2,10 @@
2
2
  * Snapshot-based rollback for the OpenPalm control plane.
3
3
  *
4
4
  * Before writing validated changes to live paths, the current state
5
- * is snapshotted to ~/.cache/openpalm/rollback/. On deploy failure
5
+ * is snapshotted to OP_HOME/data/rollback/. On deploy failure
6
6
  * (or manual `openpalm rollback`), the snapshot is restored.
7
7
  */
8
- import { mkdirSync, copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
10
  import type { ControlPlaneState } from "./types.js";
11
11
  import { resolveRollbackDir } from "./home.js";
@@ -14,9 +14,11 @@ import { resolveRollbackDir } from "./home.js";
14
14
  * Only config/ system files are included — user-editable config files
15
15
  * are never overwritten by lifecycle operations. */
16
16
  const SNAPSHOT_FILES = [
17
- "config/stack/stack.env",
18
- "config/stack/guardian.env",
19
- "config/auth.json",
17
+ "knowledge/env/stack.env",
18
+ "config/stack/services.compose.yml",
19
+ "config/stack/channels.compose.yml",
20
+ "config/stack/custom.compose.yml",
21
+ "knowledge/secrets/auth.json",
20
22
  ];
21
23
 
22
24
  /**
@@ -30,8 +32,7 @@ function safeCopy(src: string, dest: string): void {
30
32
 
31
33
  /**
32
34
  * Save the current live configuration files to the rollback directory.
33
- * Also snapshots stack/core.compose.yml and all addon compose.yml files
34
- * under stack/addons/.
35
+ * Also snapshots stack/core.compose.yml.
35
36
  */
36
37
  export function snapshotCurrentState(state: ControlPlaneState): void {
37
38
  const rollbackDir = resolveRollbackDir();
@@ -48,22 +49,6 @@ export function snapshotCurrentState(state: ControlPlaneState): void {
48
49
  const coreCompose = join(state.homeDir, "config/stack/core.compose.yml");
49
50
  safeCopy(coreCompose, join(rollbackDir, "config/stack/core.compose.yml"));
50
51
 
51
- // Snapshot config/stack/addons/*/compose.yml
52
- const addonsDir = join(state.homeDir, "config/stack/addons");
53
- if (existsSync(addonsDir)) {
54
- for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
55
- if (entry.isDirectory()) {
56
- const addonCompose = join(addonsDir, entry.name, "compose.yml");
57
- if (existsSync(addonCompose)) {
58
- safeCopy(
59
- addonCompose,
60
- join(rollbackDir, "config/stack/addons", entry.name, "compose.yml"),
61
- );
62
- }
63
- }
64
- }
65
- }
66
-
67
52
  // Write a timestamp marker
68
53
  writeFileSync(
69
54
  join(rollbackDir, ".snapshot-ts"),
@@ -94,21 +79,6 @@ export function restoreSnapshot(state: ControlPlaneState): void {
94
79
  safeCopy(srcCoreCompose, join(state.homeDir, "config/stack/core.compose.yml"));
95
80
  }
96
81
 
97
- // Restore config/stack/addons/*/compose.yml
98
- const srcAddons = join(rollbackDir, "config/stack/addons");
99
- if (existsSync(srcAddons)) {
100
- for (const entry of readdirSync(srcAddons, { withFileTypes: true })) {
101
- if (entry.isDirectory()) {
102
- const srcAddonCompose = join(srcAddons, entry.name, "compose.yml");
103
- if (existsSync(srcAddonCompose)) {
104
- safeCopy(
105
- srcAddonCompose,
106
- join(state.homeDir, "config/stack/addons", entry.name, "compose.yml"),
107
- );
108
- }
109
- }
110
- }
111
- }
112
82
  }
113
83
 
114
84
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Automation scheduler — types and akm CLI integration.
3
3
  *
4
- * Automations are AKM markdown task files at ${stashDir}/tasks/*.md.
4
+ * Automations are AKM task files at ${stashDir}/tasks/*.yml.
5
5
  * Scheduling is handled by the OS cron daemon (via `akm tasks sync`).
6
6
  * Execution is handled by `akm tasks run <id>`.
7
7
  */
@@ -10,6 +10,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { createLogger } from "../logger.js";
12
12
  import { loadMarkdownTasks, taskToAutomationConfig } from "./markdown-task.js";
13
+ import { assertAkmEnvComplete } from "./akm-user-env.js";
13
14
 
14
15
  const logger = createLogger("scheduler");
15
16
 
@@ -69,8 +70,9 @@ export async function executeAutomation(
69
70
  id: string,
70
71
  akmEnv: NodeJS.ProcessEnv,
71
72
  ): Promise<AutomationRunResult> {
72
- // Strip .md suffix if caller passes the full filename
73
- const taskId = id.replace(/\.md$/, "");
73
+ assertAkmEnvComplete(akmEnv); // I-6: never let akm fall back to the global config
74
+ // Strip file suffix if caller passes the full filename.
75
+ const taskId = id.replace(/\.(?:ya?ml|md)$/, "");
74
76
  return new Promise((resolve) => {
75
77
  execFile(
76
78
  "akm",
@@ -89,9 +91,10 @@ export async function executeAutomation(
89
91
  });
90
92
  }
91
93
 
92
- // ── Sync crontab with stash/tasks/*.md ───────────────────────────────────
94
+ // ── Sync crontab with knowledge/tasks/*.yml ──────────────────────────────────
93
95
 
94
96
  export async function syncAutomations(akmEnv: NodeJS.ProcessEnv): Promise<void> {
97
+ assertAkmEnvComplete(akmEnv); // I-6: never let akm fall back to the global config
95
98
  return new Promise((resolve, reject) => {
96
99
  execFile(
97
100
  "akm",
@@ -112,11 +115,11 @@ export async function syncAutomations(akmEnv: NodeJS.ProcessEnv): Promise<void>
112
115
 
113
116
  export function readAutomationLogs(
114
117
  id: string,
115
- cacheDir: string,
118
+ dataDir: string,
116
119
  limit: number = 50,
117
120
  ): string[] {
118
- const taskId = id.replace(/\.md$/, "");
119
- const logDir = join(cacheDir, "akm", "tasks", "logs", taskId);
121
+ const taskId = id.replace(/\.(?:ya?ml|md)$/, "");
122
+ const logDir = join(dataDir, "akm", "cache", "tasks", "logs", taskId);
120
123
  if (!existsSync(logDir)) return [];
121
124
 
122
125
  const logFiles = readdirSync(logDir, { withFileTypes: true })
@@ -0,0 +1,159 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ auditComposeSecrets,
7
+ auditFileBasedSecrets,
8
+ auditSecretFilesystem,
9
+ auditStackEnv,
10
+ isSecretLikeKey,
11
+ } from './secret-audit.js';
12
+
13
+ let tempDir: string;
14
+
15
+ beforeEach(() => {
16
+ tempDir = mkdtempSync(join(tmpdir(), 'secret-audit-test-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(tempDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe('isSecretLikeKey', () => {
24
+ it('detects secret-like keys but allows file indirection keys', () => {
25
+ expect(isSecretLikeKey('OPENAI_API_KEY')).toBe(true);
26
+ expect(isSecretLikeKey('OP_UI_LOGIN_PASSWORD')).toBe(true);
27
+ expect(isSecretLikeKey('CHANNEL_CHAT_SECRET')).toBe(true);
28
+ expect(isSecretLikeKey('OPENAI_API_KEY_FILE')).toBe(false);
29
+ expect(isSecretLikeKey('OP_IMAGE_TAG')).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('auditStackEnv', () => {
34
+ it('rejects secret-like keys in stack.env', () => {
35
+ const issues = auditStackEnv({
36
+ OP_HOME: '/home/me/.openpalm',
37
+ OP_IMAGE_TAG: 'latest',
38
+ OPENAI_API_KEY: 'sk-test',
39
+ OP_UI_LOGIN_PASSWORD: 'secret',
40
+ });
41
+
42
+ expect(issues.map((entry) => entry.code)).toEqual([
43
+ 'stack-env-secret-key',
44
+ 'stack-env-secret-key',
45
+ ]);
46
+ });
47
+
48
+ it('accepts non-secret runtime configuration', () => {
49
+ expect(auditStackEnv({
50
+ OP_HOME: '/home/me/.openpalm',
51
+ OP_UID: '1000',
52
+ OP_GID: '1000',
53
+ OP_ASSISTANT_PORT: '3800',
54
+ OPENAI_BASE_URL: 'http://localhost:11434/v1',
55
+ })).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe('auditComposeSecrets', () => {
60
+ it('rejects service env_file and direct secret-like environment values', () => {
61
+ const issues = auditComposeSecrets(`
62
+ services:
63
+ guardian:
64
+ env_file:
65
+ - ./service.env
66
+ environment:
67
+ OPENCODE_SERVER_PASSWORD: secret
68
+ `);
69
+
70
+ expect(issues.map((entry) => entry.code)).toEqual([
71
+ 'compose-service-env-file',
72
+ 'compose-secret-env-var',
73
+ ]);
74
+ });
75
+
76
+ it('accepts *_FILE environment variables and in-boundary secret grants', () => {
77
+ const issues = auditComposeSecrets({
78
+ services: {
79
+ assistant: {
80
+ environment: {
81
+ OPENAI_API_KEY_FILE: '/run/secrets/provider_openai_api_key',
82
+ },
83
+ secrets: ['provider_openai_api_key'],
84
+ },
85
+ guardian: {
86
+ environment: ['GUARDIAN_CHANNEL_SECRET_FILE=/run/secrets/guardian_channel_secret'],
87
+ secrets: [{ source: 'guardian_channel_secret' }, { source: 'channel_chat_hmac' }],
88
+ },
89
+ chat: {
90
+ image: 'openpalm/channel:latest',
91
+ secrets: ['channel_chat_hmac'],
92
+ },
93
+ },
94
+ });
95
+
96
+ expect(issues).toEqual([]);
97
+ });
98
+
99
+ it('rejects cross-boundary secret grants', () => {
100
+ const issues = auditComposeSecrets({
101
+ services: {
102
+ assistant: { secrets: ['guardian_channel_secret'] },
103
+ chat: { image: 'openpalm/channel:latest', secrets: ['channel_slack_hmac'] },
104
+ guardian: { secrets: ['admin_session_key'] },
105
+ },
106
+ });
107
+
108
+ expect(issues.map((entry) => entry.code)).toEqual([
109
+ 'compose-secret-boundary',
110
+ 'compose-secret-boundary',
111
+ 'compose-secret-boundary',
112
+ ]);
113
+ });
114
+ });
115
+
116
+ describe('auditSecretFilesystem', () => {
117
+ it('requires a 0700 secrets directory and 0600 secret files', () => {
118
+ const secretsDir = join(tempDir, 'config', 'stack', 'secrets');
119
+ mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
120
+ chmodSync(secretsDir, 0o700);
121
+ const secretPath = join(secretsDir, 'provider_openai_api_key');
122
+ writeFileSync(secretPath, 'sk-test\n', { mode: 0o600 });
123
+ chmodSync(secretPath, 0o600);
124
+
125
+ expect(auditSecretFilesystem(secretsDir)).toEqual([]);
126
+ });
127
+
128
+ it('reports unsafe directory and file permissions', () => {
129
+ const secretsDir = join(tempDir, 'secrets');
130
+ mkdirSync(secretsDir, { recursive: true, mode: 0o755 });
131
+ chmodSync(secretsDir, 0o755);
132
+ const secretPath = join(secretsDir, 'admin_session_key');
133
+ writeFileSync(secretPath, 'secret\n', { mode: 0o644 });
134
+ chmodSync(secretPath, 0o644);
135
+
136
+ expect(auditSecretFilesystem(secretsDir).map((entry) => entry.code)).toEqual([
137
+ 'secrets-dir-mode',
138
+ 'secret-file-mode',
139
+ ]);
140
+ });
141
+ });
142
+
143
+ describe('auditFileBasedSecrets', () => {
144
+ it('combines stack env, compose, and filesystem checks', () => {
145
+ const secretsDir = join(tempDir, 'secrets');
146
+ mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
147
+ chmodSync(secretsDir, 0o700);
148
+ writeFileSync(join(secretsDir, 'provider_openai_api_key'), 'sk-test\n', { mode: 0o600 });
149
+
150
+ const result = auditFileBasedSecrets({
151
+ stackEnvContent: 'OP_HOME=/tmp/openpalm\nOPENAI_API_KEY=bad\n',
152
+ composeConfig: 'services:\n assistant:\n environment:\n OPENAI_API_KEY_FILE: /run/secrets/provider_openai_api_key\n',
153
+ secretsDir,
154
+ });
155
+
156
+ expect(result.ok).toBe(false);
157
+ expect(result.issues.map((entry) => entry.code)).toEqual(['stack-env-secret-key']);
158
+ });
159
+ });
@@ -0,0 +1,255 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { parseEnvContent, parseEnvFile } from './env.js';
5
+
6
+ export type SecretAuditSeverity = 'error' | 'warning';
7
+
8
+ export type SecretAuditIssue = {
9
+ severity: SecretAuditSeverity;
10
+ code: string;
11
+ message: string;
12
+ path?: string;
13
+ };
14
+
15
+ export type SecretAuditResult = {
16
+ ok: boolean;
17
+ issues: SecretAuditIssue[];
18
+ };
19
+
20
+ export type SecretAuditOptions = {
21
+ stackEnvPath?: string;
22
+ stackEnvContent?: string;
23
+ composeConfig?: string | unknown;
24
+ secretsDir?: string;
25
+ };
26
+
27
+ type ComposeService = {
28
+ image?: unknown;
29
+ env_file?: unknown;
30
+ environment?: unknown;
31
+ secrets?: unknown;
32
+ networks?: unknown;
33
+ };
34
+
35
+ type ComposeConfig = {
36
+ services?: Record<string, ComposeService>;
37
+ };
38
+
39
+ const SECRET_FILE_MODE = 0o600;
40
+ const SECRET_DIR_MODE = 0o700;
41
+
42
+ const NON_SECRET_STACK_KEYS = new Set([
43
+ 'COMPOSE_PROJECT_NAME',
44
+ 'OP_PROJECT_NAME',
45
+ 'OP_HOME',
46
+ 'OP_UID',
47
+ 'OP_GID',
48
+ 'OP_IMAGE_NAMESPACE',
49
+ 'OP_IMAGE_TAG',
50
+ 'OP_SETUP_COMPLETE',
51
+ 'OP_ASSISTANT_BIND_ADDRESS',
52
+ 'OP_ASSISTANT_PORT',
53
+ 'OP_ASSISTANT_SSH_BIND_ADDRESS',
54
+ 'OP_ASSISTANT_SSH_PORT',
55
+ 'OPENCODE_ENABLE_SSH',
56
+ 'OP_CHAT_BIND_ADDRESS',
57
+ 'OP_CHAT_PORT',
58
+ 'OP_API_BIND_ADDRESS',
59
+ 'OP_API_PORT',
60
+ 'OP_VOICE_BIND_ADDRESS',
61
+ 'OP_VOICE_PORT',
62
+ 'OP_OLLAMA_BIND_ADDRESS',
63
+ 'OP_VOICE_PROFILE',
64
+ 'OP_OLLAMA_PROFILE',
65
+ 'OP_HOST_UI_PORT',
66
+ 'OP_OWNER_NAME',
67
+ 'OP_OWNER_EMAIL',
68
+ 'OPENAI_BASE_URL',
69
+ ]);
70
+
71
+ function issue(code: string, message: string, path?: string): SecretAuditIssue {
72
+ return { severity: 'error', code, message, path };
73
+ }
74
+
75
+ export function isSecretLikeKey(key: string): boolean {
76
+ const normalized = key.toUpperCase();
77
+ if (normalized.endsWith('_FILE')) return false;
78
+ return /(^|_)(SECRET|TOKEN|PASSWORD|PASS|API_KEY|PRIVATE_KEY|CREDENTIAL|CREDENTIALS)(_|$)/.test(normalized);
79
+ }
80
+
81
+ function parseComposeConfig(input: string | unknown): ComposeConfig {
82
+ if (typeof input === 'string') {
83
+ const parsed = parseYaml(input) as unknown;
84
+ return isRecord(parsed) ? parsed as ComposeConfig : {};
85
+ }
86
+ return isRecord(input) ? input as ComposeConfig : {};
87
+ }
88
+
89
+ function isRecord(value: unknown): value is Record<string, unknown> {
90
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
91
+ }
92
+
93
+ function environmentEntries(environment: unknown): Array<[string, unknown]> {
94
+ if (Array.isArray(environment)) {
95
+ return environment.flatMap((entry) => {
96
+ if (typeof entry !== 'string') return [];
97
+ const eq = entry.indexOf('=');
98
+ return eq > 0 ? [[entry.slice(0, eq), entry.slice(eq + 1)] as [string, string]] : [[entry, '']];
99
+ });
100
+ }
101
+ if (isRecord(environment)) return Object.entries(environment);
102
+ return [];
103
+ }
104
+
105
+ function serviceSecrets(secrets: unknown): string[] {
106
+ if (!Array.isArray(secrets)) return [];
107
+ return secrets.flatMap((entry) => {
108
+ if (typeof entry === 'string') return [entry];
109
+ if (!isRecord(entry)) return [];
110
+ const source = entry.source ?? entry.target;
111
+ return typeof source === 'string' ? [source] : [];
112
+ });
113
+ }
114
+
115
+ function serviceNetworks(networks: unknown): string[] {
116
+ if (Array.isArray(networks)) return networks.filter((entry): entry is string => typeof entry === 'string');
117
+ if (isRecord(networks)) return Object.keys(networks);
118
+ return [];
119
+ }
120
+
121
+ function normalizedSecretName(name: string): string {
122
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
123
+ }
124
+
125
+ function isChannelService(name: string, service: ComposeService): boolean {
126
+ const normalized = normalizedSecretName(name);
127
+ if (normalized.startsWith('channel_')) return true;
128
+ const image = typeof service.image === 'string' ? service.image.toLowerCase() : '';
129
+ if (image.includes('/channel') || image.endsWith(':channel') || image.includes('openpalm/channel')) return true;
130
+ return serviceNetworks(service.networks).includes('channel_lan') && name !== 'guardian';
131
+ }
132
+
133
+ function allowedSecretForService(serviceName: string, service: ComposeService, secretName: string): boolean {
134
+ const serviceId = normalizedSecretName(serviceName.replace(/^channel[-_]/i, ''));
135
+ const secretId = normalizedSecretName(secretName);
136
+
137
+ if (serviceName === 'assistant') {
138
+ return /^(assistant|opencode|provider|llm|embedding|akm|user)_/.test(secretId);
139
+ }
140
+ if (serviceName === 'guardian') {
141
+ return secretId.startsWith('guardian_') || secretId.startsWith('channel_');
142
+ }
143
+ if (serviceName === 'admin') {
144
+ return /^(admin|ui|openpalm)_/.test(secretId);
145
+ }
146
+ if (isChannelService(serviceName, service)) {
147
+ return secretId.startsWith(`channel_${serviceId}_`) || secretId.startsWith(`${serviceId}_`);
148
+ }
149
+ return secretId.startsWith(`${serviceId}_`);
150
+ }
151
+
152
+ export function auditStackEnv(env: Record<string, string>, label = 'stack.env'): SecretAuditIssue[] {
153
+ const issues: SecretAuditIssue[] = [];
154
+ for (const key of Object.keys(env)) {
155
+ if (NON_SECRET_STACK_KEYS.has(key)) continue;
156
+ if (isSecretLikeKey(key)) {
157
+ issues.push(issue(
158
+ 'stack-env-secret-key',
159
+ `${label} must not contain secret-like key ${key}; store it as a file under knowledge/secrets and expose ${key}_FILE instead.`,
160
+ `${label}:${key}`,
161
+ ));
162
+ }
163
+ }
164
+ return issues;
165
+ }
166
+
167
+ export function auditComposeSecrets(composeConfig: string | unknown): SecretAuditIssue[] {
168
+ const compose = parseComposeConfig(composeConfig);
169
+ const issues: SecretAuditIssue[] = [];
170
+ for (const [serviceName, service] of Object.entries(compose.services ?? {})) {
171
+ if (service.env_file !== undefined) {
172
+ issues.push(issue(
173
+ 'compose-service-env-file',
174
+ `service ${serviceName} must not use env_file; pass non-secrets explicitly and use Docker secrets for secret values.`,
175
+ `services.${serviceName}.env_file`,
176
+ ));
177
+ }
178
+
179
+ for (const [key] of environmentEntries(service.environment)) {
180
+ if (isSecretLikeKey(key)) {
181
+ issues.push(issue(
182
+ 'compose-secret-env-var',
183
+ `service ${serviceName} environment key ${key} is secret-like; expose only ${key}_FILE.`,
184
+ `services.${serviceName}.environment.${key}`,
185
+ ));
186
+ }
187
+ }
188
+
189
+ for (const secretName of serviceSecrets(service.secrets)) {
190
+ if (!allowedSecretForService(serviceName, service, secretName)) {
191
+ issues.push(issue(
192
+ 'compose-secret-boundary',
193
+ `service ${serviceName} is not allowed to mount secret ${secretName}.`,
194
+ `services.${serviceName}.secrets`,
195
+ ));
196
+ }
197
+ }
198
+ }
199
+ return issues;
200
+ }
201
+
202
+ export function auditSecretFilesystem(secretsDir: string): SecretAuditIssue[] {
203
+ const issues: SecretAuditIssue[] = [];
204
+ if (!existsSync(secretsDir)) {
205
+ issues.push(issue('secrets-dir-missing', `secrets directory does not exist: ${secretsDir}`, secretsDir));
206
+ return issues;
207
+ }
208
+
209
+ const dirStat = statSync(secretsDir);
210
+ if (!dirStat.isDirectory()) {
211
+ issues.push(issue('secrets-dir-not-directory', `secrets path is not a directory: ${secretsDir}`, secretsDir));
212
+ return issues;
213
+ }
214
+ if ((dirStat.mode & 0o777) !== SECRET_DIR_MODE) {
215
+ issues.push(issue('secrets-dir-mode', `secrets directory must be 0700, got ${formatMode(dirStat.mode)}.`, secretsDir));
216
+ }
217
+
218
+ for (const entry of readdirSync(secretsDir, { withFileTypes: true })) {
219
+ const path = join(secretsDir, entry.name);
220
+ if (entry.isDirectory()) {
221
+ issues.push(...auditSecretFilesystem(path));
222
+ continue;
223
+ }
224
+ if (!entry.isFile()) continue;
225
+ const fileStat = statSync(path);
226
+ if ((fileStat.mode & 0o777) !== SECRET_FILE_MODE) {
227
+ issues.push(issue('secret-file-mode', `secret file must be 0600, got ${formatMode(fileStat.mode)}.`, path));
228
+ }
229
+ }
230
+ return issues;
231
+ }
232
+
233
+ function formatMode(mode: number): string {
234
+ return `0${(mode & 0o777).toString(8)}`;
235
+ }
236
+
237
+ export function auditFileBasedSecrets(options: SecretAuditOptions): SecretAuditResult {
238
+ const issues: SecretAuditIssue[] = [];
239
+
240
+ if (options.stackEnvContent !== undefined) {
241
+ issues.push(...auditStackEnv(parseEnvContent(options.stackEnvContent), 'stack.env'));
242
+ } else if (options.stackEnvPath) {
243
+ issues.push(...auditStackEnv(parseEnvFile(options.stackEnvPath), options.stackEnvPath));
244
+ }
245
+
246
+ if (options.composeConfig !== undefined) {
247
+ issues.push(...auditComposeSecrets(options.composeConfig));
248
+ }
249
+
250
+ if (options.secretsDir) {
251
+ issues.push(...auditSecretFilesystem(options.secretsDir));
252
+ }
253
+
254
+ return { ok: issues.length === 0, issues };
255
+ }
@@ -62,7 +62,7 @@ type SecretIndexFile = {
62
62
  };
63
63
 
64
64
  function secretIndexPath(state: ControlPlaneState): string {
65
- return `${state.stateDir}/secrets/plaintext-index.json`;
65
+ return `${state.dataDir}/secrets/plaintext-index.json`;
66
66
  }
67
67
 
68
68
  function normalizeIndexedKey(key: string): string {
@@ -144,7 +144,7 @@ export function readPlaintextSecretIndex(state: ControlPlaneState): SecretIndexF
144
144
  }
145
145
 
146
146
  export function writePlaintextSecretIndex(state: ControlPlaneState, index: SecretIndexFile): void {
147
- const dir = `${state.stateDir}/secrets`;
147
+ const dir = `${state.dataDir}/secrets`;
148
148
  mkdirSync(dir, { recursive: true });
149
149
  writeFileSync(secretIndexPath(state), JSON.stringify(index, null, 2) + '\n');
150
150
  }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { buildEnvFiles } from './config-persistence.js';
6
+ import { assertNoSecretLikeStackEnvKeys, patchSecretsEnvFile } from './secrets.js';
7
+ import { listSecretNames, readSecret, resolveSecretsDir, secretPath, writeSecret, listSecretFiles, readSecretFile, writeSecretFile, removeSecretFile, assertSafeSecretFilename } from './secrets-files.js';
8
+ import type { ControlPlaneState } from './types.js';
9
+
10
+ function tempStackDir(): string {
11
+ return mkdtempSync(join(tmpdir(), 'openpalm-secrets-files-'));
12
+ }
13
+
14
+ describe('file-based control-plane secrets', () => {
15
+ it('creates the secrets directory and files with private permissions', () => {
16
+ const stackDir = tempStackDir();
17
+
18
+ writeSecret(stackDir, 'channel_chat_secret', 'value');
19
+
20
+ expect(resolveSecretsDir(stackDir)).toBe(join(stackDir, 'knowledge', 'secrets'));
21
+ expect(statSync(resolveSecretsDir(stackDir)).mode & 0o777).toBe(0o700);
22
+ expect(statSync(secretPath(stackDir, 'channel_chat_secret')).mode & 0o777).toBe(0o600);
23
+ expect(readSecret(stackDir, 'channel_chat_secret')).toBe('value');
24
+ expect(listSecretNames(stackDir)).toEqual(['channel_chat_secret']);
25
+ });
26
+
27
+ it('rejects invalid secret names', () => {
28
+ const stackDir = tempStackDir();
29
+
30
+ expect(() => writeSecret(stackDir, 'CHANNEL_CHAT_SECRET', 'value')).toThrow(/Invalid secret name/);
31
+ expect(() => writeSecret(stackDir, 'channel-chat-secret', 'value')).toThrow(/Invalid secret name/);
32
+ });
33
+
34
+ it('rejects secret-like stack.env keys', () => {
35
+ expect(() => assertNoSecretLikeStackEnvKeys({ OPENAI_API_KEY: 'sk-test' })).toThrow(/OPENAI_API_KEY/);
36
+ expect(() => assertNoSecretLikeStackEnvKeys({ OP_OWNER_NAME: 'Ada' })).not.toThrow();
37
+ });
38
+
39
+ it('does not include file-based secrets in compose env files', () => {
40
+ const stackDir = tempStackDir();
41
+ const stashDir = join(stackDir, 'knowledge');
42
+ const stackEnv = join(stashDir, 'env', 'stack.env');
43
+ mkdirSync(join(stashDir, 'env'), { recursive: true });
44
+ writeFileSync(stackEnv, 'OP_HOME=/tmp/openpalm\n');
45
+ writeSecret(stackDir, 'channel_chat_secret', 'value');
46
+ const state = { stackDir, stashDir } as ControlPlaneState;
47
+
48
+ expect(buildEnvFiles(state)).toEqual([stackEnv]);
49
+ });
50
+
51
+ it('routes secret patches to lower-case secret files instead of stack.env', () => {
52
+ const stackDir = tempStackDir();
53
+ writeFileSync(join(stackDir, 'stack.env'), 'OP_SETUP_COMPLETE=false\n');
54
+
55
+ patchSecretsEnvFile(stackDir, { OP_UI_LOGIN_PASSWORD: 'pw', OP_IMAGE_TAG: 'latest' });
56
+
57
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe('pw\n');
58
+ expect(listSecretNames(stackDir)).toContain('op_ui_login_password');
59
+ });
60
+ });
61
+
62
+ describe('secrets-dir file browser API (admin Secrets tab)', () => {
63
+ it('lists ALL files incl. dotted names like auth.json, with sizes', () => {
64
+ const stackDir = tempStackDir();
65
+ writeSecret(stackDir, 'channel_api_secret', 'abc'); // regex-valid secret
66
+ writeFileSync(join(resolveSecretsDir(stackDir), 'auth.json'), '{"k":1}'); // dotted file
67
+ const files = listSecretFiles(stackDir);
68
+ const names = files.map((f) => f.name);
69
+ expect(names).toContain('auth.json'); // included (strict listSecretNames would exclude it)
70
+ expect(names).toContain('channel_api_secret');
71
+ expect(files.find((f) => f.name === 'auth.json')!.size).toBe('{"k":1}'.length);
72
+ // strict API still excludes the dotted file
73
+ expect(listSecretNames(stackDir)).not.toContain('auth.json');
74
+ });
75
+
76
+ it('reads, writes (0600), and removes a dotted file by basename', () => {
77
+ const stackDir = tempStackDir();
78
+ writeSecretFile(stackDir, 'auth.json', '{"token":"x"}');
79
+ expect(statSync(join(resolveSecretsDir(stackDir), 'auth.json')).mode & 0o777).toBe(0o600);
80
+ expect(readSecretFile(stackDir, 'auth.json')).toBe('{"token":"x"}');
81
+ removeSecretFile(stackDir, 'auth.json');
82
+ expect(readSecretFile(stackDir, 'auth.json')).toBeNull();
83
+ });
84
+
85
+ it('rejects path traversal and unsafe names', () => {
86
+ expect(() => assertSafeSecretFilename('../escape')).toThrow();
87
+ expect(() => assertSafeSecretFilename('a/b')).toThrow();
88
+ expect(() => assertSafeSecretFilename('..')).toThrow();
89
+ expect(() => assertSafeSecretFilename('')).toThrow();
90
+ // valid names
91
+ expect(() => assertSafeSecretFilename('auth.json')).not.toThrow();
92
+ expect(() => assertSafeSecretFilename('op_ui_login_password')).not.toThrow();
93
+ expect(() => assertSafeSecretFilename('discord_bot_token')).not.toThrow();
94
+ });
95
+
96
+ it('readSecretFile returns null for a missing file', () => {
97
+ expect(readSecretFile(tempStackDir(), 'nope.txt')).toBeNull();
98
+ });
99
+ });