@openpalm/lib 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 (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
package/README.md CHANGED
@@ -19,7 +19,7 @@ Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
19
19
  ## Important context
20
20
 
21
21
  - Some filenames still use legacy names like `staging`; those modules now support the direct-write compose model
22
- - `config/` is user-owned, `vault/stack/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
22
+ - `config/` is user-owned, `config/stack/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
23
23
  - New reusable control-plane logic belongs here, not duplicated in consumers
24
24
 
25
25
  ## Main module areas
@@ -30,7 +30,7 @@ Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
30
30
  | `control-plane/env` and `control-plane/secrets` | Read, merge, and patch env files |
31
31
  | `control-plane/lifecycle` and `control-plane/docker` | Compose operations and stack lifecycle helpers |
32
32
  | `control-plane/channels` and `control-plane/components` | Addon discovery and install/uninstall logic |
33
- | `control-plane/memory-config` | Memory service configuration helpers |
33
+ | `control-plane/provider-models` | Provider model discovery helpers |
34
34
  | `control-plane/scheduler` | Automation parsing and scheduler helpers |
35
35
  | `logger` | Shared structured logger |
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.10.2",
3
+ "version": "0.11.0-beta.2",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -23,8 +23,12 @@
23
23
  "directory": "packages/lib"
24
24
  },
25
25
  "dependencies": {
26
- "croner": "^9.0.0",
27
- "dotenv": "^16.4.7",
26
+ "dotenv": "^17.4.2",
27
+ "tar": "^7.5.15",
28
28
  "yaml": "^2.8.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/tar": "^7.0.87",
32
+ "bun-types": "^1.3.14"
29
33
  }
30
34
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Admin token file management.
3
+ *
4
+ * Token lives at {homeDir}/state/admin/token, mode 0600.
5
+ * - ensureAdminToken: idempotent — skips write if file already exists and is non-empty.
6
+ * - rotateAdminToken: overwrites unconditionally. Only called by `openpalm admin rotate-token`.
7
+ *
8
+ * Windows note: chmodSync(path, 0o600) is a no-op on Windows.
9
+ * NFS/CIFS warning: mode bits are ignored on network shares. ensureAdminToken warns via console.
10
+ */
11
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { randomBytes } from "node:crypto";
14
+
15
+ function getAdminStateDir(homeDir: string): string {
16
+ return join(homeDir, "state", "admin");
17
+ }
18
+
19
+ function generateToken(): string {
20
+ return randomBytes(32).toString("hex");
21
+ }
22
+
23
+ /**
24
+ * Ensure an admin token file exists at {homeDir}/state/admin/token.
25
+ * Idempotent: if the file already exists and is non-empty, returns the existing token.
26
+ * Creates the directory if necessary. Sets mode 0600 (no-op on Windows).
27
+ *
28
+ * @param homeDir The OP_HOME directory (e.g. ~/.openpalm)
29
+ * @returns The admin token (new or existing)
30
+ */
31
+ export function ensureAdminToken(homeDir: string): string {
32
+ const dir = getAdminStateDir(homeDir);
33
+ mkdirSync(dir, { recursive: true });
34
+
35
+ const tokenPath = join(dir, "token");
36
+
37
+ if (existsSync(tokenPath)) {
38
+ const existing = readFileSync(tokenPath, "utf8").trim();
39
+ if (existing.length > 0) return existing;
40
+ }
41
+
42
+ const token = generateToken();
43
+ writeFileSync(tokenPath, token, { encoding: "utf8", mode: 0o600 });
44
+ try {
45
+ // Some platforms require a separate chmod call to enforce the mode.
46
+ chmodSync(tokenPath, 0o600);
47
+ } catch {
48
+ // Windows — ignore silently
49
+ }
50
+ return token;
51
+ }
52
+
53
+ /**
54
+ * Rotate the admin token. Overwrites the token file unconditionally.
55
+ * Only call this from `openpalm admin rotate-token`.
56
+ *
57
+ * @param homeDir The OP_HOME directory
58
+ * @returns The new admin token
59
+ */
60
+ export function rotateAdminToken(homeDir: string): string {
61
+ const dir = getAdminStateDir(homeDir);
62
+ mkdirSync(dir, { recursive: true });
63
+
64
+ const tokenPath = join(dir, "token");
65
+ const token = generateToken();
66
+ writeFileSync(tokenPath, token, { encoding: "utf8", mode: 0o600 });
67
+ try {
68
+ chmodSync(tokenPath, 0o600);
69
+ } catch {
70
+ // Windows — ignore silently
71
+ }
72
+ return token;
73
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tests for the akm vault helpers. The vault helpers spawn `akm vault set
3
+ * <ref> <key>` and feed the secret VALUE on stdin (akm-cli >= 0.8.0).
4
+ *
5
+ * Tests gate on the akm CLI being on PATH so the suite stays green in
6
+ * environments without akm installed.
7
+ */
8
+ import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test";
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { execFileSync } from "node:child_process";
13
+ import {
14
+ ensureAkmUserVault,
15
+ readAkmUserVaultFile,
16
+ writeAkmVaultKey,
17
+ deleteAkmVaultKey,
18
+ AKM_USER_VAULT_REF,
19
+ } from "./akm-vault.js";
20
+ import type { ControlPlaneState } from "./types.js";
21
+
22
+ function makeState(homeDir: string): ControlPlaneState {
23
+ return {
24
+ homeDir,
25
+ configDir: join(homeDir, "config"),
26
+ stashDir: join(homeDir, "stash"),
27
+ workspaceDir: join(homeDir, "workspace"),
28
+ cacheDir: join(homeDir, "cache"),
29
+ stateDir: join(homeDir, "state"),
30
+ stackDir: join(homeDir, "stack"),
31
+ services: {},
32
+ artifacts: { compose: "" },
33
+ artifactMeta: [],
34
+ };
35
+ }
36
+
37
+ function hasAkmCli(): boolean {
38
+ try {
39
+ execFileSync("akm", ["--version"], { stdio: "ignore" });
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ const AKM_AVAILABLE = hasAkmCli();
47
+
48
+
49
+ describe("writeAkmVaultKey", () => {
50
+ let homeDir: string;
51
+ let state: ControlPlaneState;
52
+
53
+ beforeEach(() => {
54
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-akm-write-"));
55
+ state = makeState(homeDir);
56
+ mkdirSync(state.stashDir, { recursive: true });
57
+ mkdirSync(`${state.stateDir}/cache/akm`, { recursive: true });
58
+ });
59
+
60
+ afterEach(() => {
61
+ rmSync(homeDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it.skipIf(!AKM_AVAILABLE)("writes a key via `akm vault set` (stdin mode, no argv leak)", async () => {
65
+ const value = "argv-free-secret-9988";
66
+ const ok = await writeAkmVaultKey(state, "TOKEN", value);
67
+ expect(ok).toBe(true);
68
+
69
+ const vaultPath = await ensureAkmUserVault(state);
70
+ expect(vaultPath).not.toBeNull();
71
+ if (vaultPath) {
72
+ const stored = readAkmUserVaultFile(vaultPath);
73
+ expect(stored.TOKEN).toBe(value);
74
+ }
75
+ });
76
+
77
+ it.skipIf(!AKM_AVAILABLE)("deleteAkmVaultKey removes a key via `akm vault unset`", async () => {
78
+ await writeAkmVaultKey(state, "TOKEN_A", "value-a");
79
+ await writeAkmVaultKey(state, "TOKEN_B", "value-b");
80
+
81
+ const ok = await deleteAkmVaultKey(state, "TOKEN_A");
82
+ expect(ok).toBe(true);
83
+
84
+ const vaultPath = await ensureAkmUserVault(state);
85
+ if (vaultPath) {
86
+ const stored = readAkmUserVaultFile(vaultPath);
87
+ expect(stored.TOKEN_A).toBeUndefined();
88
+ expect(stored.TOKEN_B).toBe("value-b");
89
+ }
90
+ });
91
+
92
+ it.skipIf(!AKM_AVAILABLE)("deleteAkmVaultKey is idempotent on a missing key", async () => {
93
+ // Deleting a key that was never set should not throw — `akm vault unset`
94
+ // either exits 0 or emits a "not found" message we tolerate.
95
+ const ok = await deleteAkmVaultKey(state, "NEVER_SET_KEY");
96
+ expect(ok).toBe(true);
97
+ });
98
+ });
99
+
100
+
101
+ describe("AKM_USER_VAULT_REF", () => {
102
+ it("exports the canonical akm ref string", () => {
103
+ expect(AKM_USER_VAULT_REF).toBe("vault:user");
104
+ });
105
+ });
@@ -0,0 +1,307 @@
1
+ /// <reference types="bun-types" />
2
+ /**
3
+ * akm `vault:user` helpers.
4
+ *
5
+ * The akm-cli vault store at `${OP_HOME}/stash/vaults/user.env` is the
6
+ * canonical home for user-managed environment secrets. The assistant
7
+ * entrypoint sources this file directly at startup.
8
+ *
9
+ * `stack.env` and `guardian.env` are operator-managed and NOT mirrored
10
+ * into akm — mirroring them would break guardian's HMAC env_file
11
+ * hot-reload contract.
12
+ *
13
+ * SECURITY: every write into the akm vault is performed by spawning
14
+ * `akm vault set <ref> <key>` with the secret VALUE delivered via stdin
15
+ * (akm-cli >= 0.8.0). Values never appear in argv, so they cannot leak
16
+ * through `/proc/<pid>/cmdline`. The matching delete path uses
17
+ * `akm vault unset <ref> <key>` which is naturally argv-safe.
18
+ *
19
+ * Layout:
20
+ * stash/ — AKM_STASH_DIR: asset content (skills, vaults, knowledge, agents)
21
+ * state/akm/ — AKM_DATA_DIR / AKM_STATE_DIR / AKM_CONFIG_DIR: operational metadata
22
+ * cache/akm/ — AKM_CACHE_DIR: regenerable registry artifacts
23
+ */
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { execFile as execFileCb } from "node:child_process";
26
+ import { promisify } from "node:util";
27
+ import { parseEnvFile } from "./env.js";
28
+ import { createLogger } from "../logger.js";
29
+ import type { ControlPlaneState } from "./types.js";
30
+
31
+ const execFile = promisify(execFileCb);
32
+ const logger = createLogger("akm-vault");
33
+
34
+ export const AKM_USER_VAULT_REF = "vault:user";
35
+
36
+ /**
37
+ * Build the env that points akm at the shared OpenPalm stash. We mirror the
38
+ * layout that the assistant/admin containers use (see
39
+ * `.openpalm/config/stack/core.compose.yml`) so host-side and container-side
40
+ * runs resolve to the same vault file.
41
+ *
42
+ * AKM_CONFIG_DIR lives in config/ (alongside stack.env, auth.json) so
43
+ * operators can inspect and version-control akm setup alongside other config.
44
+ * AKM_CACHE_DIR lives in cache/ since registry index and downloaded artifacts
45
+ * are regenerable and should not be indexed alongside stash assets.
46
+ */
47
+ export function buildAkmEnv(state: ControlPlaneState): NodeJS.ProcessEnv {
48
+ const akmOperational = `${state.stateDir}/akm`;
49
+ return {
50
+ ...process.env,
51
+ AKM_STASH_DIR: state.stashDir,
52
+ AKM_CONFIG_DIR: `${state.configDir}/akm`,
53
+ AKM_DATA_DIR: `${akmOperational}/data`,
54
+ AKM_STATE_DIR: `${akmOperational}/state`,
55
+ AKM_CACHE_DIR: `${state.cacheDir}/akm`,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Per-invocation timeout (ms) for every akm subprocess we launch. The CLI is
61
+ * a local binary and these probes (`--version`, `vault create`, `vault path`,
62
+ * `vault set/unset`) complete in well under a second on a healthy host;
63
+ * anything longer means akm is wedged or unreachable. Bounding the call
64
+ * keeps `mirrorUserVaultToAkm` truly best-effort: a stuck akm binary cannot
65
+ * block install/upgrade.
66
+ *
67
+ * Why a wall-clock race instead of execFile's built-in `timeout` option:
68
+ * node's `child_process.execFile` in Bun is implemented on top of `Bun.spawn`,
69
+ * and its `timeout` option only fires once stdout/stderr are wired up. Test
70
+ * suites that stub `Bun.spawn` (e.g. `packages/cli/src/main.test.ts`
71
+ * `mockDockerCli`) return a fake child whose stdout never closes, so neither
72
+ * the underlying promise nor the timeout option ever resolves. A simple
73
+ * `Promise.race` against an unref'd setTimeout converts that failure mode
74
+ * into a fast rejection that `akmAvailable` swallows as "akm not on PATH",
75
+ * without changing behaviour on real hosts.
76
+ */
77
+ const AKM_EXEC_TIMEOUT_MS = 2_000;
78
+
79
+ /**
80
+ * Race a promise against an unref'd setTimeout. If the timeout fires first,
81
+ * reject with `<label> timed out after <ms>ms`. The timer is always cleared
82
+ * in `finally` so it never keeps the event loop alive past resolution. The
83
+ * unref means the timer alone won't block process exit — the surrounding
84
+ * subprocess work owns the liveness.
85
+ */
86
+ function raceWithTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const timeoutPromise = new Promise<never>((_, reject) => {
89
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
90
+ timer.unref?.();
91
+ });
92
+ return Promise.race([promise, timeoutPromise]).finally(() => {
93
+ if (timer) clearTimeout(timer);
94
+ });
95
+ }
96
+
97
+ async function execAkm(args: string[], env: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
98
+ return raceWithTimeout(
99
+ execFile("akm", args, { env }),
100
+ AKM_EXEC_TIMEOUT_MS,
101
+ `akm ${args[0] ?? "?"}`,
102
+ );
103
+ }
104
+
105
+ async function akmAvailable(env: NodeJS.ProcessEnv): Promise<boolean> {
106
+ try {
107
+ await execAkm(["--version"], env);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Return the absolute path of the akm vault file, creating the vault if
116
+ * missing. Callers that have already built the akm env (via `buildAkmEnv`)
117
+ * can pass it in to avoid rebuilding — the result is identical either way.
118
+ */
119
+ export async function ensureAkmUserVault(
120
+ state: ControlPlaneState,
121
+ env: NodeJS.ProcessEnv = buildAkmEnv(state),
122
+ ): Promise<string | null> {
123
+ if (!(await akmAvailable(env))) {
124
+ return null;
125
+ }
126
+ try {
127
+ // `vault create` accepts only the ref on argv — no secret material crosses
128
+ // the process boundary here.
129
+ await execAkm(["vault", "create", AKM_USER_VAULT_REF], env);
130
+ } catch (err) {
131
+ // `create` is documented as a no-op when the vault already exists, but
132
+ // some build channels emit a non-zero exit. Probe `path` to distinguish
133
+ // a real failure from "already exists".
134
+ logger.debug("akm vault create returned non-zero", {
135
+ ref: AKM_USER_VAULT_REF,
136
+ error: err instanceof Error ? err.message : String(err),
137
+ });
138
+ }
139
+ try {
140
+ const { stdout } = await execAkm(["vault", "path", AKM_USER_VAULT_REF], env);
141
+ const path = stdout.trim();
142
+ return path.length > 0 ? path : null;
143
+ } catch (err) {
144
+ logger.warn("akm vault path failed", {
145
+ ref: AKM_USER_VAULT_REF,
146
+ error: err instanceof Error ? err.message : String(err),
147
+ });
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Spawn `akm vault set <ref> <key>` and feed the secret VALUE via stdin.
154
+ * The value never crosses argv, so it cannot leak through
155
+ * `/proc/<pid>/cmdline`. Bounded by AKM_EXEC_TIMEOUT_MS — a stuck akm
156
+ * binary cannot block the calling install/upgrade flow.
157
+ */
158
+ async function akmVaultSetViaStdin(
159
+ ref: string,
160
+ key: string,
161
+ value: string,
162
+ env: NodeJS.ProcessEnv,
163
+ ): Promise<void> {
164
+ // We use Bun.spawn directly because it supports an in-memory stdin pipe
165
+ // (a buffer/string stream) without dragging in an extra dependency, and
166
+ // because akm-cli on >= 0.8.0 reads the value from stdin when no
167
+ // positional `<value>` is provided. (The CLI silently switched stdin to
168
+ // the default in commit c50f9f4; explicit `--stdin` is still accepted
169
+ // for older binaries — but since we pin akm-cli >= 0.8.0-rc2 across all
170
+ // images via Dockerfile ARGs, the implicit form is enough.)
171
+ const child = Bun.spawn(["akm", "vault", "set", ref, key], {
172
+ env,
173
+ stdin: "pipe",
174
+ stdout: "pipe",
175
+ stderr: "pipe",
176
+ });
177
+
178
+ // Feed the secret. `child.stdin` is a FileSink in Bun — write+end then
179
+ // wait for exit. We don't use `await child.stdin.end(value)` because
180
+ // some Bun versions return undefined here; explicit write+end is portable.
181
+ if (child.stdin) {
182
+ // child.stdin is typed as FileSink in Bun
183
+ const sink = child.stdin as { write: (data: string) => unknown; end: () => unknown };
184
+ sink.write(value);
185
+ sink.end();
186
+ }
187
+
188
+ // Wall-clock bound — mirror of the execAkm pattern. On timeout we also
189
+ // SIGKILL the child so the orphaned subprocess doesn't outlive us.
190
+ let exitCode: number;
191
+ try {
192
+ exitCode = await raceWithTimeout(
193
+ child.exited,
194
+ AKM_EXEC_TIMEOUT_MS,
195
+ `akm vault set ${key}`,
196
+ );
197
+ } catch (err) {
198
+ try { child.kill(); } catch { /* best-effort */ }
199
+ throw err;
200
+ }
201
+
202
+ if (exitCode !== 0) {
203
+ const stderrText = child.stderr ? await new Response(child.stderr).text() : "";
204
+ throw new Error(`akm vault set ${key} failed (exit ${exitCode}): ${stderrText.trim()}`);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Write a single key/value into the akm `vault:user` store via
210
+ * `akm vault set <ref> <key>` with the value delivered on stdin.
211
+ *
212
+ * Returns `true` on success, `false` when akm is unavailable or the vault
213
+ * could not be ensured. Throws on akm subprocess failures (non-zero exit
214
+ * with a captured stderr, or wall-clock timeout) so callers can surface
215
+ * the real error instead of silently dropping the write.
216
+ */
217
+ export async function writeAkmVaultKey(
218
+ state: ControlPlaneState,
219
+ key: string,
220
+ value: string,
221
+ ): Promise<boolean> {
222
+ // Build env once and thread it through both the ensure step and the
223
+ // subsequent `akm vault set`. Avoids a redundant `buildAkmEnv` call.
224
+ const env = buildAkmEnv(state);
225
+ const vaultPath = await ensureAkmUserVault(state, env);
226
+ if (!vaultPath) return false;
227
+ await akmVaultSetViaStdin(AKM_USER_VAULT_REF, key, value, env);
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * Remove a key from the akm `vault:user` store via `akm vault unset`.
233
+ * The key name is a normal identifier and crosses argv only — secret
234
+ * values are never involved. Returns `true` if the operation completed
235
+ * (whether or not the key was present), `false` when akm is unavailable.
236
+ */
237
+ export async function deleteAkmVaultKey(
238
+ state: ControlPlaneState,
239
+ key: string,
240
+ ): Promise<boolean> {
241
+ // Build env once and pass it into ensureAkmUserVault so we don't pay
242
+ // for two `buildAkmEnv` calls on a single delete.
243
+ const env = buildAkmEnv(state);
244
+ const vaultPath = await ensureAkmUserVault(state, env);
245
+ if (!vaultPath) return false;
246
+ try {
247
+ await execAkm(["vault", "unset", AKM_USER_VAULT_REF, key], env);
248
+ } catch (err) {
249
+ // `unset` of a missing key is a benign no-op; many akm versions exit 0
250
+ // anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ // Heuristic: tolerate "not found" / "no such" messages so re-running
253
+ // delete on an already-deleted key stays idempotent for callers.
254
+ if (/not\s*found|no\s+such|does\s+not\s+exist/i.test(message)) {
255
+ logger.debug("akm vault unset reported missing key", { key, message });
256
+ return true;
257
+ }
258
+ throw err;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ /**
264
+ * Synchronously resolve the canonical akm `vault:user` file path for a given
265
+ * control-plane state. Used by sync read paths (e.g. plaintext secret backend
266
+ * `list`/`exists`) that cannot await `ensureAkmUserVault`.
267
+ *
268
+ * The path is deterministic: `buildAkmEnv` pins `AKM_STASH_DIR` to
269
+ * `state.stashDir`, and akm-cli (>= 0.8.0) materializes vault files
270
+ * at `${AKM_STASH_DIR}/vaults/<ref>.env`.
271
+ *
272
+ * Returns the path string regardless of whether the file currently exists —
273
+ * callers should `existsSync` if presence matters.
274
+ */
275
+ export function akmUserVaultPathSync(state: ControlPlaneState): string {
276
+ return `${state.stashDir}/vaults/user.env`;
277
+ }
278
+
279
+ /**
280
+ * Read the user-managed env namespace from the akm `vault:user` store.
281
+ *
282
+ * Returns `{}` when the vault file does not exist yet. Pure sync — no subprocess spawn.
283
+ */
284
+ export function readUserVaultSync(state: ControlPlaneState): Record<string, string> {
285
+ const akmPath = akmUserVaultPathSync(state);
286
+ if (existsSync(akmPath)) {
287
+ return readAkmUserVaultFile(akmPath);
288
+ }
289
+ return {};
290
+ }
291
+
292
+ /** Return the parsed contents of the akm vault file (public API used by admin UI list endpoint). */
293
+ export function readAkmUserVaultFile(vaultPath: string): Record<string, string> {
294
+ if (!existsSync(vaultPath)) return {};
295
+ try {
296
+ return parseEnvFile(vaultPath);
297
+ } catch {
298
+ // Fallback: hand-parse if dotenv chokes (e.g. file with stray BOM).
299
+ const raw = readFileSync(vaultPath, "utf-8");
300
+ const out: Record<string, string> = {};
301
+ for (const line of raw.split("\n")) {
302
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
303
+ if (m) out[m[1]] = m[2];
304
+ }
305
+ return out;
306
+ }
307
+ }
@@ -60,7 +60,7 @@ export function isChannelAddon(composePath: string): boolean {
60
60
  */
61
61
  export function discoverChannels(configDir: string): ChannelInfo[] {
62
62
  const homeDir = dirname(configDir);
63
- const addonsDir = `${homeDir}/stack/addons`;
63
+ const addonsDir = `${homeDir}/config/stack/addons`;
64
64
  if (!existsSync(addonsDir)) return [];
65
65
 
66
66
  const entries = readdirSync(addonsDir, { withFileTypes: true });
@@ -91,7 +91,7 @@ export function isAllowedService(value: string, configDir?: string): boolean {
91
91
 
92
92
  if (configDir) {
93
93
  const homeDir = dirname(configDir);
94
- const addonsDir = `${homeDir}/stack/addons`;
94
+ const addonsDir = `${homeDir}/config/stack/addons`;
95
95
  if (!existsSync(addonsDir)) return false;
96
96
 
97
97
  // Check if any addon compose.yml defines this service name (YAML-parsed)
@@ -125,7 +125,7 @@ export function isValidChannel(value: string, configDir?: string): boolean {
125
125
  if (!isValidChannelName(value)) return false;
126
126
  if (configDir) {
127
127
  const homeDir = dirname(configDir);
128
- return existsSync(`${homeDir}/stack/addons/${value}/compose.yml`);
128
+ return existsSync(`${homeDir}/config/stack/addons/${value}/compose.yml`);
129
129
  }
130
130
  return false;
131
131
  }
@@ -149,17 +149,16 @@ describe("guardrail: compose-derived service discovery", () => {
149
149
  });
150
150
  });
151
151
 
152
- // ── Guardrail 5: Env schema paths are correct ──────────────────────────
152
+ // ── Guardrail 5: No varlock or .env.schema references in validate.ts ───
153
153
 
154
- describe("guardrail: env schema validation paths", () => {
155
- test("validate.ts uses correct nested vault schema paths", () => {
154
+ describe("guardrail: validate.ts is varlock-free", () => {
155
+ test("validate.ts does not import child_process or read .env.schema", () => {
156
156
  const validateTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "validate.ts"), "utf-8");
157
- // Must use nested paths
158
- expect(validateTs).toContain("vaultDir}/user/user.env.schema");
159
- expect(validateTs).toContain("vaultDir}/stack/stack.env.schema");
160
- // Must NOT use flat paths
161
- expect(validateTs).not.toContain("vaultDir}/user.env.schema");
162
- expect(validateTs).not.toContain("vaultDir}/system.env.schema");
157
+ // Post-#391: validation is key-presence only — no schema files, no binary.
158
+ expect(validateTs).not.toContain("node:child_process");
159
+ expect(validateTs).not.toContain("execFile");
160
+ expect(validateTs).not.toContain("VARLOCK_BIN");
161
+ expect(validateTs).not.toContain(".env.schema");
163
162
  });
164
163
  });
165
164
 
@@ -15,47 +15,42 @@ import type { ControlPlaneState } from "./types.js";
15
15
  let tempDir: string;
16
16
 
17
17
  function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
18
+ const configDir = join(tempDir, "config");
18
19
  return {
19
- adminToken: "test",
20
- assistantToken: "test",
21
- setupToken: "test",
22
20
  homeDir: tempDir,
23
- configDir: join(tempDir, "config"),
24
- vaultDir: join(tempDir, "vault"),
25
- dataDir: join(tempDir, "data"),
26
- logsDir: join(tempDir, "logs"),
21
+ configDir,
22
+ stashDir: join(tempDir, "stash"),
23
+ workspaceDir: join(tempDir, "workspace"),
27
24
  cacheDir: join(tempDir, "cache"),
25
+ stateDir: join(tempDir, "state"),
26
+ stackDir: join(configDir, "stack"),
28
27
  services: {},
29
28
  artifacts: { compose: "" },
30
29
  artifactMeta: [],
31
- audit: [],
32
30
  ...overrides,
33
31
  };
34
32
  }
35
33
 
36
34
  function seedCoreCompose(): void {
37
- const stackDir = join(tempDir, "stack");
35
+ const stackDir = join(tempDir, "config", "stack");
38
36
  mkdirSync(stackDir, { recursive: true });
39
37
  writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
40
38
  }
41
39
 
42
- function seedEnvFiles(files: { stack?: boolean; user?: boolean; guardian?: boolean } = {}): void {
40
+ function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
41
+ const stackDir = join(tempDir, "config", "stack");
43
42
  if (files.stack) {
44
- mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
45
- writeFileSync(join(tempDir, "vault", "stack", "stack.env"), "KEY=val");
46
- }
47
- if (files.user) {
48
- mkdirSync(join(tempDir, "vault", "user"), { recursive: true });
49
- writeFileSync(join(tempDir, "vault", "user", "user.env"), "SECRET=val");
43
+ mkdirSync(stackDir, { recursive: true });
44
+ writeFileSync(join(stackDir, "stack.env"), "KEY=val");
50
45
  }
51
46
  if (files.guardian) {
52
- mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
53
- writeFileSync(join(tempDir, "vault", "stack", "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
47
+ mkdirSync(stackDir, { recursive: true });
48
+ writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
54
49
  }
55
50
  }
56
51
 
57
52
  function seedAddon(name: string): void {
58
- const addonDir = join(tempDir, "stack", "addons", name);
53
+ const addonDir = join(tempDir, "config", "stack", "addons", name);
59
54
  mkdirSync(addonDir, { recursive: true });
60
55
  writeFileSync(join(addonDir, "compose.yml"), "services: {}");
61
56
  }
@@ -98,13 +93,16 @@ describe("buildComposeOptions", () => {
98
93
  });
99
94
 
100
95
  it("returns env files in correct order", () => {
101
- seedEnvFiles({ stack: true, user: true, guardian: true });
96
+ // Note: vault/user/user.env is no longer a
97
+ // compose env_file. The runtime env file list is: stack.env, guardian.env.
98
+ // Even when a legacy user.env is present on disk, it is intentionally
99
+ // excluded from the compose args.
100
+ seedEnvFiles({ stack: true, guardian: true });
102
101
  const state = makeState();
103
102
  const opts = buildComposeOptions(state);
104
- expect(opts.envFiles).toHaveLength(3);
103
+ expect(opts.envFiles).toHaveLength(2);
105
104
  expect(opts.envFiles[0]).toContain("stack.env");
106
- expect(opts.envFiles[1]).toContain("user.env");
107
- expect(opts.envFiles[2]).toContain("guardian.env");
105
+ expect(opts.envFiles[1]).toContain("guardian.env");
108
106
  });
109
107
 
110
108
  it("excludes missing env files", () => {
@@ -136,8 +134,11 @@ describe("buildComposeCliArgs", () => {
136
134
  });
137
135
 
138
136
  it("includes --env-file flags for env files that exist", () => {
137
+ // Note: vault/user/user.env is no longer
138
+ // listed in the compose env_file set. Only stack.env and guardian.env
139
+ // (when present) are passed via --env-file.
139
140
  seedCoreCompose();
140
- seedEnvFiles({ stack: true, user: true });
141
+ seedEnvFiles({ stack: true, guardian: true });
141
142
  const state = makeState();
142
143
  const args = buildComposeCliArgs(state);
143
144
  const envFileIndices = args.reduce<number[]>((acc, arg, i) => {