Files
oh-my-pi/packages/utils/src/dirs.ts
T
can1357 5004ba057f feat(auth-broker): added encrypted local snapshot cache
- Added AES-GCM cache for at-rest broker snapshots keyed on token, with URL as additional data.
- Added `onSnapshot` hook to RemoteAuthCredentialStore for persisting applied snapshots.
- Exposed cache read/write and TTL defaults through the coding-agent re-exports.
- Added `getAuthBrokerSnapshotCachePath` with `OMP_AUTH_BROKER_SNAPSHOT_CACHE` override.
2026-06-05 11:09:47 +02:00

582 lines
21 KiB
TypeScript

/**
* Centralized path helpers for omp config directories.
*
* Uses PI_CONFIG_DIR (default ".omp") for the config root and
* PI_CODING_AGENT_DIR to override the agent directory.
*
* On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
* variables are set, paths are redirected to XDG-compliant locations under
* $XDG_*_HOME/omp/. This requires running `omp config migrate` first to
* move data to the new locations. No filesystem existence checks are performed
* — if the env var is set, omp trusts that the migration has been done.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { engines, version } from "../package.json" with { type: "json" };
/** App name (e.g. "omp") */
export const APP_NAME: string = "omp";
/** Config directory name (e.g. ".omp") */
export const CONFIG_DIR_NAME: string = ".omp";
/** Version (e.g. "1.0.0") */
export const VERSION: string = version;
/** Minimum Bun version */
export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
// =============================================================================
// Project directory
// =============================================================================
/**
* On macOS, strip /private prefix only when both paths resolve to the same location.
* This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
*/
function standardizeMacOSPath(p: string): string {
if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
const stripped = p.slice("/private".length);
try {
if (fs.realpathSync(p) === fs.realpathSync(stripped)) {
return stripped;
}
} catch {}
return p;
}
export function resolveEquivalentPath(inputPath: string): string {
const resolvedPath = path.resolve(inputPath);
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
export function normalizePathForComparison(inputPath: string): string {
const resolvedPath = resolveEquivalentPath(inputPath);
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
}
export function pathIsWithin(root: string, candidate: string): boolean {
const normalizedRoot = normalizePathForComparison(root);
const normalizedCandidate = normalizePathForComparison(candidate);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export function relativePathWithinRoot(root: string, candidate: string): string | null {
if (!pathIsWithin(root, candidate)) return null;
const normalizedRoot = normalizePathForComparison(root);
const normalizedCandidate = normalizePathForComparison(candidate);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative || null;
}
let projectDir = standardizeMacOSPath(process.cwd());
/** Get the project directory. */
export function getProjectDir(): string {
return projectDir;
}
/** Set the project directory. */
export function setProjectDir(dir: string): void {
projectDir = standardizeMacOSPath(path.resolve(dir));
process.chdir(projectDir);
}
/** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
export function getConfigDirName(): string {
return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
}
/** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
export function getConfigAgentDirName(): string {
return `${getConfigDirName()}/agent`;
}
// =============================================================================
// DirResolver — cached, XDG-aware path resolution
// =============================================================================
type XdgCategory = "data" | "state" | "cache";
/**
* Resolves and caches all omp directory paths. On Linux, when XDG environment
* variables are set, paths are redirected under $XDG_*_HOME/omp/. A new
* instance is created whenever the agent directory changes, which naturally
* invalidates all cached paths.
*/
class DirResolver {
readonly configRoot: string;
readonly agentDir: string;
// Per-category base dirs. Without XDG, all three equal configRoot / agentDir.
// With XDG on Linux, they point to $XDG_*_HOME/omp/.
readonly #rootDirs: Record<XdgCategory, string>;
readonly #agentDirs: Record<XdgCategory, string>;
readonly #rootCache = new Map<string, string>();
readonly #agentCache = new Map<string, string>();
constructor(agentDirOverride?: string) {
this.configRoot = path.join(os.homedir(), getConfigDirName());
const defaultAgent = path.join(this.configRoot, "agent");
this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
const isDefault = this.agentDir === defaultAgent;
// XDG is a Linux convention. On other platforms, or for non-default
// profiles, all categories resolve to the legacy paths.
let xdgData: string | undefined;
let xdgState: string | undefined;
let xdgCache: string | undefined;
if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
const resolveIf = (envVar: string) => {
const value = process.env[envVar];
if (value) {
try {
const joined = path.join(value, APP_NAME);
if (fs.existsSync(joined)) {
return joined;
}
} catch {}
}
return undefined;
};
xdgData = resolveIf("XDG_DATA_HOME");
xdgState = resolveIf("XDG_STATE_HOME");
xdgCache = resolveIf("XDG_CACHE_HOME");
}
this.#rootDirs = {
data: xdgData ?? this.configRoot,
state: xdgState ?? this.configRoot,
cache: xdgCache ?? this.configRoot,
};
// XDG flattens the agent/ prefix: ~/.omp/agent/sessions → $XDG_DATA_HOME/omp/sessions
this.#agentDirs = {
data: xdgData ?? this.agentDir,
state: xdgState ?? this.agentDir,
cache: xdgCache ?? this.agentDir,
};
}
/** Config-root subdirectory, with optional XDG override. */
rootSubdir(subdir: string, xdg?: XdgCategory): string {
const cached = this.#rootCache.get(subdir);
if (cached) return cached;
const base = xdg ? this.#rootDirs[xdg] : this.configRoot;
const result = path.join(base, subdir);
this.#rootCache.set(subdir, result);
return result;
}
/** Agent subdirectory, with optional XDG override. */
agentSubdir(userAgentDir: string | undefined, subdir: string, xdg?: XdgCategory): string {
if (!userAgentDir || userAgentDir === this.agentDir) {
const cached = this.#agentCache.get(subdir);
if (cached) return cached;
const base = xdg ? this.#agentDirs[xdg] : this.agentDir;
const result = path.join(base, subdir);
this.#agentCache.set(subdir, result);
return result;
}
return path.join(userAgentDir, subdir);
}
}
let dirs = new DirResolver(process.env.PI_CODING_AGENT_DIR);
// Anchor home for the resolver. Captured at module load to stay stable across
// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
// production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
// tests passing a temp HOME short-circuit to a deterministic path.
const RESOLVER_HOME = os.homedir();
// =============================================================================
// Root directories
// =============================================================================
/** Get the config root directory (~/.omp). */
export function getConfigRootDir(): string {
return dirs.configRoot;
}
/** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
export function setAgentDir(dir: string): void {
dirs = new DirResolver(dir);
process.env.PI_CODING_AGENT_DIR = dir;
}
/** Get the agent config directory (~/.omp/agent). */
export function getAgentDir(): string {
return dirs.agentDir;
}
/** Get the project-local config directory (.omp). */
export function getProjectAgentDir(cwd: string = getProjectDir()): string {
return path.join(cwd, CONFIG_DIR_NAME);
}
// =============================================================================
// Config-root subdirectories (~/.omp/*)
// =============================================================================
/** Get the reports directory (~/.omp/reports). */
export function getReportsDir(): string {
return dirs.rootSubdir("reports", "state");
}
/** Get the logs directory (~/.omp/logs). */
export function getLogsDir(): string {
return dirs.rootSubdir("logs", "state");
}
/** Get the path to a dated log file (~/.omp/logs/omp.YYYY-MM-DD.log). */
export function getLogPath(date = new Date()): string {
return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
}
/**
* Get the plugins directory (~/.omp/plugins or its XDG equivalent).
*
* No-arg form (production callers) goes through the XDG-aware DirResolver so
* reads and writes always agree. The optional `home` parameter is for test
* isolation: when it differs from `os.homedir()` it short-circuits the resolver
* and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
* deterministic path. Passing `os.homedir()` explicitly is identical to the
* no-arg form — XDG semantics are preserved.
*/
export function getPluginsDir(home?: string): string {
if (home !== undefined && home !== RESOLVER_HOME) {
return path.join(home, getConfigDirName(), "plugins");
}
return dirs.rootSubdir("plugins", "data");
}
/** Where npm installs packages (~/.omp/plugins/node_modules). */
export function getPluginsNodeModules(home?: string): string {
return path.join(getPluginsDir(home), "node_modules");
}
/** Plugin manifest (~/.omp/plugins/package.json). */
export function getPluginsPackageJson(home?: string): string {
return path.join(getPluginsDir(home), "package.json");
}
/** Plugin lock file (~/.omp/plugins/omp-plugins.lock.json). */
export function getPluginsLockfile(home?: string): string {
return path.join(getPluginsDir(home), "omp-plugins.lock.json");
}
/** Get the remote mount directory (~/.omp/remote). */
export function getRemoteDir(): string {
return dirs.rootSubdir("remote", "data");
}
/** Get the agent-managed worktrees directory (~/.omp/wt). */
export function getWorktreesDir(): string {
return dirs.rootSubdir("wt", "data");
}
/** Get the SSH control socket directory (~/.omp/ssh-control). */
export function getSshControlDir(): string {
return dirs.rootSubdir("ssh-control", "state");
}
/** Get the remote host info directory (~/.omp/remote-host). */
export function getRemoteHostDir(): string {
return dirs.rootSubdir("remote-host", "data");
}
/** Get the managed Python venv directory (~/.omp/python-env). */
export function getPythonEnvDir(): string {
return dirs.rootSubdir("python-env", "data");
}
/** Get the shared Python gateway state directory (~/.omp/agent/python-gateway; XDG default: $XDG_STATE_HOME/omp/python-gateway). */
export function getPythonGatewayDir(): string {
return dirs.agentSubdir(undefined, "python-gateway", "state");
}
/** Get the puppeteer sandbox directory (~/.omp/puppeteer). */
export function getPuppeteerDir(): string {
return dirs.rootSubdir("puppeteer", "cache");
}
/**
* Stable 7-character hex digest of an absolute filesystem path.
*
* Used to pack the project identity into a single short fs-safe segment
* (e.g. PR-checkout and task-isolation worktree dirs under `~/.omp/wt/`).
* Bun.hash is non-cryptographic — collision space is ~2^28, which is fine
* for naming a handful of repos on a single machine. Same input on the
* same Bun runtime yields the same output.
*/
export function hashPath(absPath: string): string {
return Bun.hash(path.resolve(absPath)).toString(16).padStart(16, "0").slice(-7);
}
/** Get the path to a single worktree directory (~/.omp/wt/<segment>). */
export function getWorktreeDir(segment: string): string {
return path.join(getWorktreesDir(), segment);
}
/** Get the GPU cache path (~/.omp/gpu_cache.json). */
export function getGpuCachePath(): string {
return dirs.rootSubdir("gpu_cache.json", "cache");
}
/**
* Get the GitHub view cache database path (~/.omp/cache/github-cache.db).
* Honors the `OMP_GITHUB_CACHE_DB` env var when set so tests can isolate the
* cache file without touching the rest of the config root.
*/
export function getGithubCacheDbPath(): string {
const override = process.env.OMP_GITHUB_CACHE_DB;
if (override) return override;
return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
}
/**
* Get the encrypted auth-broker snapshot cache path (~/.omp/cache/auth-broker-snapshot.enc).
* Honors the `OMP_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
* operators can isolate or relocate the cache file.
*/
export function getAuthBrokerSnapshotCachePath(): string {
const override = process.env.OMP_AUTH_BROKER_SNAPSHOT_CACHE;
if (override) return override;
return dirs.rootSubdir(path.join("cache", "auth-broker-snapshot.enc"), "cache");
}
/** Get the local FastEmbed model cache directory (~/.omp/cache/fastembed). */
export function getFastembedCacheDir(): string {
return dirs.rootSubdir(path.join("cache", "fastembed"), "cache");
}
/** Get the natives directory (~/.omp/natives). */
export function getNativesDir(): string {
return dirs.rootSubdir("natives", "cache");
}
/** Get the stats database path (~/.omp/stats.db). */
export function getStatsDbPath(): string {
return dirs.rootSubdir("stats.db", "data");
}
/** Get the autoresearch state directory (~/.omp/autoresearch). */
export function getAutoresearchDir(): string {
return dirs.rootSubdir("autoresearch", "state");
}
/** Get the per-project autoresearch state directory (~/.omp/autoresearch/<encoded-project>). */
export function getAutoresearchProjectDir(encodedProject: string): string {
return path.join(getAutoresearchDir(), encodedProject);
}
/** Get the per-project autoresearch SQLite database path (~/.omp/autoresearch/<encoded-project>.db). */
export function getAutoresearchDbPath(encodedProject: string): string {
return path.join(getAutoresearchDir(), `${encodedProject}.db`);
}
/** Get the per-run artifact directory (~/.omp/autoresearch/<encoded-project>/runs/<runId>). */
export function getAutoresearchRunDir(encodedProject: string, runId: number): string {
return path.join(getAutoresearchProjectDir(encodedProject), "runs", String(runId).padStart(4, "0"));
}
// =============================================================================
// Agent subdirectories (~/.omp/agent/*)
// =============================================================================
/** Get the path to agent.db (SQLite database for settings and auth storage). */
export function getAgentDbPath(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "agent.db", "data");
}
/** Get the path to history.db (SQLite database for session history). */
export function getHistoryDbPath(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "history.db", "data");
}
/** Get the path to models.db (model cache database). */
export function getModelDbPath(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "models.db", "data");
}
/** Get the tiny title model cache directory (~/.omp/agent/cache/tiny-models). */
export function getTinyModelsCacheDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, path.join("cache", "tiny-models"), "cache");
}
/** Get the sessions directory (~/.omp/agent/sessions). */
export function getSessionsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "sessions", "data");
}
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
export function getBlobsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "blobs", "data");
}
/** Get the custom themes directory (~/.omp/agent/themes). */
export function getCustomThemesDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "themes");
}
/** Get the tools directory (~/.omp/agent/tools). */
export function getToolsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "tools");
}
/** Get the slash commands directory (~/.omp/agent/commands). */
export function getCommandsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "commands");
}
/** Get the prompts directory (~/.omp/agent/prompts). */
export function getPromptsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "prompts");
}
/** Get the user-level Python modules directory (~/.omp/agent/modules). */
export function getAgentModulesDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "modules");
}
/** Get the memories directory (~/.omp/agent/memories). */
export function getMemoriesDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "memories", "state");
}
/** Get the terminal sessions directory (~/.omp/agent/terminal-sessions). */
export function getTerminalSessionsDir(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
}
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
export function getCrashLogPath(agentDir?: string): string {
return dirs.agentSubdir(agentDir, "omp-crash.log", "state");
}
/** Get the debug log path (~/.omp/agent/omp-debug.log). */
export function getDebugLogPath(agentDir?: string): string {
return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
}
// =============================================================================
// Project subdirectories (.omp/*)
// =============================================================================
/** Get the project-level Python modules directory (.omp/modules). */
export function getProjectModulesDir(cwd: string = getProjectDir()): string {
return path.join(getProjectAgentDir(cwd), "modules");
}
/** Get the project-level prompts directory (.omp/prompts). */
export function getProjectPromptsDir(cwd: string = getProjectDir()): string {
return path.join(getProjectAgentDir(cwd), "prompts");
}
/** Get the project-level plugin overrides path (.omp/plugin-overrides.json). */
export function getProjectPluginOverridesPath(cwd: string = getProjectDir()): string {
return path.join(getProjectAgentDir(cwd), "plugin-overrides.json");
}
// =============================================================================
// MCP config paths
// =============================================================================
/** Get the primary MCP config file path (first candidate). */
export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
if (scope === "user") {
return path.join(getAgentDir(), "mcp.json");
}
return path.join(getProjectAgentDir(cwd), "mcp.json");
}
/** Get the SSH config file path. */
export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
if (scope === "user") {
return path.join(getAgentDir(), "ssh.json");
}
return path.join(getProjectAgentDir(cwd), "ssh.json");
}
// =============================================================================
// Install identity
// =============================================================================
let cachedInstallId: string | null = null;
const INSTALL_ID_FILE = "install-id";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Persistent per-install UUID stored at `~/.omp/install-id`.
*
* Generated lazily on first call and persisted with `O_CREAT|O_EXCL` so
* concurrent first-call races don't clobber each other (loser re-reads the
* winner's id). Survives independently of agent state: deleting
* `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
* pushes (and similar telemetry) keys on this id.
*/
export function getInstallId(): string {
if (cachedInstallId) return cachedInstallId;
const filePath = path.join(getConfigRootDir(), INSTALL_ID_FILE);
let observedInvalid = false;
try {
const existing = fs.readFileSync(filePath, "utf8").trim();
if (UUID_RE.test(existing)) {
cachedInstallId = existing;
return existing;
}
// File present but unparseable — fall through and overwrite below.
observedInvalid = existing.length > 0;
} catch {}
const next = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// If we already saw garbage in the file, unlink first so O_EXCL doesn't
// trip on it. Ignored if the unlink races against another writer.
if (observedInvalid) {
try {
fs.unlinkSync(filePath);
} catch {}
}
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
try {
fs.writeSync(fd, `${next}\n`);
} finally {
fs.closeSync(fd);
}
} catch (err) {
// Lost the create race — re-read whatever the winner wrote.
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
try {
const existing = fs.readFileSync(filePath, "utf8").trim();
if (UUID_RE.test(existing)) {
cachedInstallId = existing;
return existing;
}
} catch {}
}
// Any other failure: keep the generated id in-memory so the rest of
// this process has a stable value; future processes will retry.
}
cachedInstallId = next;
return next;
}
/** Test-only: clear cached install id. Never call from production code. */
export function __resetInstallIdCacheForTests(): void {
cachedInstallId = null;
}