/** * One Code's own plugin state root (pure). * * All plugin state One Code writes — marketplaces, installs, overrides, usage, * favorites — lives under `/plugins`, never under `~/.claude`. * `~/.claude/plugins` is a read-only input (plugins installed by Claude Code * keep working read-through; see lib/plugins.ts). * * Callers pass `getAgentDir()` so the root follows the distribution mode: * `~/.onecode/agent/plugins` under the bundled app (PI_CODING_AGENT_DIR), * `~/.pi/agent/plugins` when running as a plain pi extension. */ import { join, relative, resolve } from "node:path"; export function pluginRoot(agentDir: string): string { return join(agentDir, "plugins"); } /** * One path segment safe for any untrusted plugin/marketplace name (matches * Claude Code's sanitizer; versions keep their dots). Never empty — an * all-symbol name must not collapse to the parent directory. */ export function sanitizePathSegment(value: string, allowDots = false): string { const pattern = allowDots ? /[^a-zA-Z0-9\-_.]/g : /[^a-zA-Z0-9\-_]/g; const cleaned = value.replace(pattern, "-"); // A dots-only result (`.`, `..`, `...`) is not a directory name: `join` // normalises it and the segment escapes into the parent — a `version: ".."` // would install into the cache root and wipe siblings (review M4). Treat any // all-dots segment as empty. if (cleaned.length === 0 || /^\.+$/.test(cleaned)) return "unnamed"; return cleaned; } /** * True when `target` resolves strictly inside `base` — the containment check * for third-party-authored relative paths (rejects `..` escapes and absolute * overrides). */ export function pathWithinBase(base: string, target: string): boolean { const rel = relative(resolve(base), resolve(base, target)); return rel !== "" && !rel.startsWith("..") && resolve(rel) !== rel; }