/// /** * akm `env:user` helpers. * * The user-managed environment file lives at `${OP_HOME}/knowledge/env/user.env` * and is the canonical home for user-managed configuration (LLM provider keys, * owner info, and any other user-set values). It maps to the akm `env` asset * type (ref `env:user`): a whole `.env` file that akm loads wholesale via * `akm env run env:user` / `akm env path env:user`. The assistant entrypoint * sources this file directly at startup. * * akm (>= 0.8.0) no longer manages individual env entries — the file owner edits * it and akm loads it as a unit. OpenPalm therefore owns the file directly: * writes/deletes are plain atomic .env edits (mode 0600), no akm subprocess. * Values are shell-quoted on write so the entrypoint can `source` the file * safely; `parseEnvFile` (dotenv) unquotes them on read. * * `stack.env` and `knowledge/secrets/` are operator-managed and NOT part of * this file; service secrets are granted as Compose secret files. * * Layout: * knowledge/ — AKM_STASH_DIR: asset content (skills, env, secrets, agents) * data/akm/ — akm operational cache and data */ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; import { dirname } from "node:path"; import { parseEnvFile, upsertEnvValue, removeEnvKey } from "./env.js"; import type { ControlPlaneState } from "./types.js"; /** * Quote a value so the written line is interpreted IDENTICALLY by a POSIX shell * `source` (the assistant entrypoint does `set -a; . user.env`) and by dotenv * (akm `env run` / OpenPalm's `parseEnvFile`). * * The shared `quoteEnvValue` (env.ts) is tuned for dotenv/compose only: it * leaves values with internal spaces bare (`OWNER=Ada Lovelace`) and uses * double-quote+backslash escaping — both of which a shell `source` mis-parses * (word-splitting, `&`/`$` interpretation). POSIX single-quoting is the one * encoding both agree on: everything inside `'...'` is literal in shell AND in * dotenv. Simple token-shaped values are written bare for readability; anything * else is single-quoted, with embedded single quotes closed/escaped/reopened * the POSIX way (`'\''`). */ function quoteForUserEnv(value: string): string { if (value === "") return ""; // Bare-safe: characters that need no quoting in either shell or dotenv. if (/^[A-Za-z0-9_./:@%+,=-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } /** akm ref for the user-managed environment file. */ export const AKM_USER_ENV_REF = "env:user"; const ENV_DIR_MODE = 0o700; const ENV_FILE_MODE = 0o600; /** * Build the env that points akm at the shared OpenPalm stash. We mirror the * layout that the assistant container uses (see * `packages/skeleton/system/stack/core.compose.yml`) so host-side and container-side * runs resolve to the same files. * * Host-side runs use the same explicit directories as the assistant container: * config in config/akm, cache in data/akm/cache, and durable data in * data/akm/data. Used by automation execution (`executeAutomation`). */ export function buildAkmEnv(state: ControlPlaneState): NodeJS.ProcessEnv { return { ...process.env, AKM_STASH_DIR: state.stashDir, AKM_CONFIG_DIR: `${state.configDir}/akm`, AKM_CACHE_DIR: `${state.dataDir}/akm/cache`, AKM_DATA_DIR: `${state.dataDir}/akm/data`, }; } /** The four XDG-base akm env vars that MUST be set together (akm 0.8.0). */ export const AKM_ENV_KEYS = ["AKM_STASH_DIR", "AKM_CONFIG_DIR", "AKM_CACHE_DIR", "AKM_DATA_DIR"] as const; /** * Guard (I-6): every OpenPalm-internal `akm` spawn MUST set all four AKM_* dirs * explicitly. Partially overriding them lets akm fall back to the operator's * GLOBAL ~/.config/akm / ~/.local/share/akm for the unset families — the * documented forensic hazard (akm setup writing the global config regardless of * AKM_STASH_DIR). We check the keys are present as OWN properties of the env * object passed to akm, not merely inherited from process.env (process.env may * carry the operator's global AKM_STASH_DIR, which is exactly what must NOT be * relied upon). `buildAkmEnv` satisfies this by construction. */ export function assertAkmEnvComplete(env: NodeJS.ProcessEnv): void { const missing = AKM_ENV_KEYS.filter((k) => !env[k] || !String(env[k]).trim()); if (missing.length > 0) { throw new Error( `Refusing to spawn akm without all four AKM_* dirs set: missing ${missing.join(", ")}. ` + `Use buildAkmEnv(state) — a partial set lets akm write the operator's global config.`, ); } } /** * Canonical akm `env:user` file path for a control-plane state. * * Deterministic: akm (>= 0.8.0) materializes env files at * `${AKM_STASH_DIR}/env/.env`, and `state.stashDir` is the stash root. * Returns the path regardless of whether the file currently exists. */ export function userEnvPathSync(state: ControlPlaneState): string { return `${state.stashDir}/env/user.env`; } /** * Ensure the user env file exists and return its absolute path. * * Pure filesystem — no akm subprocess. Returns immediately when the file is * already provisioned (the steady state — read paths pay no extra syscalls). * Otherwise creates `knowledge/env/` (0700) and an empty `user.env` (0600). */ export function ensureAkmUserEnv(state: ControlPlaneState): string { const envPath = userEnvPathSync(state); if (existsSync(envPath)) return envPath; mkdirSync(dirname(envPath), { recursive: true, mode: ENV_DIR_MODE }); writeFileSync(envPath, "", { mode: ENV_FILE_MODE }); chmodSync(envPath, ENV_FILE_MODE); return envPath; } /** * Write a single key/value into the user env file (`env:user`). * * The value is shell-quoted before it is written so the assistant entrypoint * can `source` the file without word-splitting on spaces or special * characters. `ensureAkmUserEnv` guarantees the file exists; `chmodSync` * keeps it 0600. Throws on filesystem errors so callers can surface the error. */ export function writeUserEnvKey(state: ControlPlaneState, key: string, value: string): void { const path = ensureAkmUserEnv(state); writeFileSync(path, upsertEnvValue(readFileSync(path, "utf-8"), key, quoteForUserEnv(value))); chmodSync(path, ENV_FILE_MODE); } /** * Remove a key from the user env file (`env:user`). Idempotent: removing an * absent key rewrites the file unchanged. Throws on filesystem errors. */ export function deleteUserEnvKey(state: ControlPlaneState, key: string): void { const path = ensureAkmUserEnv(state); writeFileSync(path, removeEnvKey(readFileSync(path, "utf-8"), key)); chmodSync(path, ENV_FILE_MODE); } /** * Read the user-managed env namespace. Returns `{}` when the file does not * exist yet. Pure sync — no subprocess. */ export function readUserEnvSync(state: ControlPlaneState): Record { return readUserEnvFile(userEnvPathSync(state)); } /** * Return the parsed contents of a user env file (public API used by the admin * UI list endpoint). `parseEnvFile` returns `{}` for a missing or unreadable * file (it backs up corrupt files internally), so no extra guards are needed. */ export function readUserEnvFile(envPath: string): Record { return parseEnvFile(envPath); }