/** * dpi 共享配置模块:被 extensions/ 下各扩展以相对路径 import。 * * 约定: * - 纯函数 + 类型,import 时零副作用(目录创建延迟到写入时刻); * - 本文件不放在 extensions/ 下——pi 会把 extensions/ 里每个 .ts 当扩展加载, * 没有 default 导出函数的文件会产生加载错误; * - 所有读取一律容错回退默认,绝不抛异常阻断 pi 启动。 */ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { execFileSync } from "node:child_process"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; /** 远端类型:github = GitHub(OAuth);ssh = scp/ssh 协议(本机 key);http = 通用 HTTPS(用户名+令牌);local = 本地路径 */ export type RemoteKind = "github" | "ssh" | "http" | "local"; /** dpi 持久化配置(~/.pi/agent/dpi/config.json) */ export interface DpiConfig { /** 内容仓库地址(github 类型归一化为 https://github.com/user/repo.git,其余类型保留用户输入原样);空串 = 未绑定 */ repoUrl: string; /** 远端类型;旧配置缺失时 loadConfig 按 repoUrl 推断 */ remoteKind: RemoteKind; /** 内容仓库本地克隆路径,默认 /dpi/repo */ repoPath: string; /** 同步分支,默认 main */ branch: string; /** 显式代理(如 http://127.0.0.1:7890);空串 = 走环境变量/直连 */ proxy: string; /** 当前激活 agent,默认 coder */ currentAgent: string; /** 会话存档开关,默认 true */ recordSessions: boolean; /** 当前选中的通用 gateway profile;空串表示未选择 */ currentGateway: string; } const DEFAULTS: DpiConfig = { repoUrl: "", remoteKind: "github", repoPath: "", branch: "main", proxy: "", currentAgent: "coder", recordSessions: true, currentGateway: "", }; /** 旧配置迁移:remoteKind 缺失/非法时按 repoUrl 推断远端类型(推断不出回退 github)。导出供测试。 * 判定顺序与 parseRepoRemote 对齐:scp-like/ssh 协议先行(git@github.com 属 ssh, * 不能因包含 github.com 被判成 github)。 */ export function inferRemoteKind(repoUrl: string): RemoteKind { const s = repoUrl.trim().toLowerCase(); if (!s) return "github"; if (s.startsWith("git@") || s.startsWith("ssh://") || /^[^/@:]+@[^/:]+:.+/.test(s)) { return "ssh"; } if (s.includes("github.com")) return "github"; if (s.startsWith("http://") || s.startsWith("https://")) return "http"; if (s.startsWith("/") || s.startsWith("~") || s.startsWith("file://")) return "local"; return "github"; } /** 该远端类型是否需要访问令牌(github/http 需要;ssh 走本机 key、local 零认证) */ export function remoteNeedsToken(kind: RemoteKind): boolean { return kind === "github" || kind === "http"; } /** pi 的 agent 目录(与 pi 本体约定一致) */ export function agentDir(): string { return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); } /** dpi 私有目录路径(纯计算,不创建目录) */ export function dpiDir(): string { return join(agentDir(), "dpi"); } /** 确保 dpi 目录存在且为 0700(抄 pi auth-storage 约定);仅写入路径调用 */ function ensureDpiDir(): string { const dir = dpiDir(); mkdirSync(dir, { recursive: true, mode: 0o700 }); try { chmodSync(dir, 0o700); } catch { // chmod 失败不致命 } return dir; } export function configPath(): string { return join(dpiDir(), "config.json"); } export function tokenPath(): string { return join(dpiDir(), "token"); } /** 完整默认配置(repoPath 在此展开为绝对路径) */ export function defaultConfig(): DpiConfig { return { ...DEFAULTS, repoPath: join(dpiDir(), "repo") }; } /** 当前机器名(归一化为小写 [a-z0-9-],如 MacBook-Air → macbook-air) */ export function machineName(): string { try { return ( hostname() .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") || "unknown" ); } catch { return "unknown"; } } /** 读取配置;文件缺失/损坏/字段类型错误一律回退默认,绝不抛异常。 * 主文件损坏/缺失时回退到备份(config.json.bak),备份也无则整体回退默认。 */ export function loadConfig(): DpiConfig { const cfg = defaultConfig(); try { const raw = readStoredConfig(); if (typeof raw.repoUrl === "string") cfg.repoUrl = raw.repoUrl; // remoteKind 白名单校验;缺失/非法一律按 repoUrl 推断,旧配置无缝迁移 if ( typeof raw.remoteKind === "string" && (["github", "ssh", "http", "local"] as const).includes(raw.remoteKind as RemoteKind) ) { cfg.remoteKind = raw.remoteKind as RemoteKind; } else { cfg.remoteKind = inferRemoteKind(cfg.repoUrl); } if (typeof raw.repoPath === "string" && raw.repoPath !== "") cfg.repoPath = raw.repoPath; if (typeof raw.branch === "string" && raw.branch !== "") cfg.branch = raw.branch; if (typeof raw.proxy === "string") cfg.proxy = raw.proxy; if (typeof raw.currentAgent === "string" && raw.currentAgent !== "") { cfg.currentAgent = raw.currentAgent; } if (typeof raw.recordSessions === "boolean") cfg.recordSessions = raw.recordSessions; if (typeof raw.currentGateway === "string" && /^[a-z0-9][a-z0-9-]*$/.test(raw.currentGateway)) { cfg.currentGateway = raw.currentGateway; } } catch { // 配置文件损坏:整体回退默认 } // 机器层覆写:内容仓库 machines/.json 中的白名单字段优先于全局配置, // 让代理、会话存档等机器相关设置随仓库同步(nixos hosts/ 式分层) try { const machineFile = join(cfg.repoPath, "machines", `${machineName()}.json`); if (existsSync(machineFile)) { const raw = JSON.parse(readFileSync(machineFile, "utf-8")) as Record; if (typeof raw.proxy === "string") cfg.proxy = raw.proxy; if (typeof raw.recordSessions === "boolean") cfg.recordSessions = raw.recordSessions; } } catch { // 机器文件损坏:忽略,保留全局配置 } return cfg; } /** 磁盘上的原始配置 JSON(含未知字段与 schema,不经过 DpiConfig 字段校验) */ type StoredConfig = Record; /** 备份路径:每次成功写入后刷新,主文件损坏时恢复用 */ function backupPath(): string { return `${configPath()}.bak`; } /** 写锁目录路径:mkdir 原子性充当互斥量 */ function lockPath(): string { return `${configPath()}.lock`; } /** 读取某个 JSON 文件为原始对象;缺失/损坏/非对象一律返回 null */ function readStoredFile(path: string): StoredConfig | null { try { const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as StoredConfig) : null; } catch { return null; } } /** 读取存储的原始配置:主文件优先,损坏/缺失回退备份,两者皆无返回空对象 */ function readStoredConfig(): StoredConfig { const main = readStoredFile(configPath()); if (main) return main; const backup = readStoredFile(backupPath()); if (backup) return backup; return {}; } /** 获取跨进程写锁(dpi/config.json.lock 目录);超时后降级为无锁继续。返回释放函数。 */ function acquireConfigLock(): () => void { ensureDpiDir(); const started = Date.now(); for (;;) { try { mkdirSync(lockPath(), { mode: 0o700 }); writeFileSync(join(lockPath(), "pid"), `${process.pid}\n`, "utf-8"); return () => rmSync(lockPath(), { recursive: true, force: true }); } catch { try { if (Date.now() - statSync(lockPath()).mtimeMs > 30_000) { rmSync(lockPath(), { recursive: true, force: true }); continue; } } catch { // 锁恰好消失;重试 } if (Date.now() - started > 5_000) return () => {}; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); } } } /** 原子写入原始配置:备份旧文件 → 写临时文件 → rename 覆盖 → 刷新备份。 * 写失败时主文件保持上一次完整状态不变,备份仍可恢复。 */ function writeStoredConfig(raw: StoredConfig): void { const release = acquireConfigLock(); try { ensureDpiDir(); if (existsSync(configPath())) copyFileSync(configPath(), backupPath()); const tmp = `${configPath()}.tmp-${process.pid}`; writeFileSync(tmp, `${JSON.stringify({ schema: 1, ...raw }, null, 2)}\n`, { mode: 0o600 }); renameSync(tmp, configPath()); copyFileSync(configPath(), backupPath()); } finally { release(); } } /** 合并写入配置(读取原始存储-合并-原子覆写)。 * 空串补丁默认不生效(保护已有有效值不被误清),显式清除路径须传 { allowEmpty: true }。 */ export function saveConfig( patch: Partial, options: { allowEmpty?: boolean } = {}, ): DpiConfig { const raw = readStoredConfig(); for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; if (typeof value === "string" && value === "" && options.allowEmpty !== true) continue; raw[key] = value; } writeStoredConfig(raw); return loadConfig(); } /** git 远端操作 opts(可能触发 lazy fetch/push 的操作用):私有仓库带 token,ssh/local 零凭证 */ export function gitAuthOpts(timeoutMs = 8000): import("./git.ts").GitOptions { const cfg = loadConfig(); return remoteNeedsToken(cfg.remoteKind) ? { tokenFile: tokenPath(), proxy: cfg.proxy, timeoutMs } : { noAuth: true, timeoutMs }; } export function hasToken(): boolean { return readToken() !== ""; } /** 读取 token:取最后一个非空行(兼容旧单行格式;通用 HTTPS 两行格式为 用户名\n令牌);缺失/损坏返回空串 */ export function readToken(): string { try { if (!existsSync(tokenPath())) return ""; const lines = tokenLines(); return lines[lines.length - 1] ?? ""; } catch { return ""; } } /** 读取 token 用户名(两行格式的第一行);单行旧格式/缺失返回空串 */ export function readTokenUser(): string { try { if (!existsSync(tokenPath())) return ""; const lines = tokenLines(); return lines.length >= 2 ? lines[0] : ""; } catch { return ""; } } /** token 文件按行拆分(逐行 trim、丢空行) */ function tokenLines(): string[] { return readFileSync(tokenPath(), "utf-8") .split("\n") .map((l) => l.trim()) .filter((l) => l !== ""); } /** 写 token:给了 user 写「用户名\n令牌」两行(通用 HTTPS),否则单行旧格式;文件 0600,写后 chmod 兜底(抄 pi auth-storage 约定) */ export function writeToken(token: string, user?: string): void { ensureDpiDir(); const content = user ? `${user}\n${token}\n` : `${token}\n`; writeFileSync(tokenPath(), content, { mode: 0o600 }); try { chmodSync(tokenPath(), 0o600); } catch { // chmod 失败不致命 } } /** 清除 token;文件不存在视为已清除 */ export function clearToken(): void { try { unlinkSync(tokenPath()); } catch { // 不存在视为已清除 } } /** 扫描内容仓库 agents/ 下所有含 SYSTEM.md 的子目录,得到可用 agent 列表 */ export function scanAgents(repoPath: string): string[] { try { const agentsDir = join(repoPath, "agents"); if (!existsSync(agentsDir)) return []; return readdirSync(agentsDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && existsSync(join(agentsDir, e.name, "SYSTEM.md"))) .map((e) => e.name) .sort(); } catch { return []; } } /** agent 声明文件(agents//agent.json):从技能注册表组合该 agent 的能力 */ export interface AgentManifest { /** 一句话简介;缺省时调用方可回退到 SYSTEM.md 首行 */ description?: string; /** 声明的技能名(对应仓库根 skills// 注册表条目) */ skills: string[]; /** 声明的扩展名(对应仓库根 extensions/.ts 注册表条目),缺省回退 [] */ extensions: string[]; } /** * 读取 agents//agent.json;缺失/损坏回退空声明。 * 技能名与扩展名做白名单校验(同时是防路径穿越),损坏字段静默丢弃。 */ export function readAgentManifest(repoPath: string, agent: string): AgentManifest { try { const raw = JSON.parse( readFileSync(join(repoPath, "agents", agent, "agent.json"), "utf-8"), ) as Record; const skills = Array.isArray(raw.skills) ? raw.skills.filter( (s): s is string => typeof s === "string" && /^[\w-]+$/.test(s), ) : []; const extensions = Array.isArray(raw.extensions) ? raw.extensions.filter( (s): s is string => typeof s === "string" && /^[\w-]+$/.test(s), ) : []; const description = typeof raw.description === "string" && raw.description !== "" ? raw.description : undefined; return { description, skills, extensions }; } catch { return { skills: [], extensions: [] }; } } /** * 写回 agents//agent.json 的 skills 声明(读取-修改-整体覆写), * 保留 description、extensions 等其他字段;JSON 2 空格缩进 + 末尾换行,普通权限。 * agent 名与技能名一律白名单校验防路径穿越;读取/写入失败返回 false,绝不抛异常。 */ export function writeAgentManifestSkills( repoPath: string, agent: string, skills: string[], ): boolean { try { if (!/^[\w-]+$/.test(agent)) return false; const file = join(repoPath, "agents", agent, "agent.json"); const raw = existsSync(file) ? (JSON.parse(readFileSync(file, "utf-8")) as Record) : {}; // 白名单过滤 + 去重,保持声明干净 raw.skills = [...new Set(skills.filter((s) => /^[\w-]+$/.test(s)))]; writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); return true; } catch { return false; } } /** * 写回 agents//agent.json 的 extensions 声明(与 writeAgentManifestSkills 对称), * 保留 description、skills 等其他字段;agent 名与扩展名一律白名单校验防路径穿越。 */ export function writeAgentManifestExtensions( repoPath: string, agent: string, extensions: string[], ): boolean { try { if (!/^[\w-]+$/.test(agent)) return false; const file = join(repoPath, "agents", agent, "agent.json"); const raw = existsSync(file) ? (JSON.parse(readFileSync(file, "utf-8")) as Record) : {}; // 白名单过滤 + 去重,保持声明干净 raw.extensions = [...new Set(extensions.filter((s) => /^[\w-]+$/.test(s)))]; writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); return true; } catch { return false; } } /** * 把当前 agent 的扩展声明同步为 settings.json 里内容包的 extensions 过滤器 * (per-agent 扩展加载的裁决点)。 * * 机制:读当前 agent 的 agent.json.extensions,把 settings.json packages 中 * source === cfg.repoPath 的条目(字符串/对象形式都认)重写为 * { source: cfg.repoPath, extensions: ["extensions/.ts", ...] }; * 声明为空则 extensions: [](= 全部禁载)。pi 的过滤发生在 jiti import 之前, * 被过滤的扩展文件根本不会执行,因此这是真隔离;但改动要等下一次 ctx.reload() * 重读 settings 后才生效(调用方负责触发)。 * * 其他 packages 条目与其他 settings 字段原样保留;找不到该条目视为无改动。 * agent 名与扩展名白名单校验防路径穿越;全部容错,返回是否有改动。 */ export function syncExtensionFilter(cfg: DpiConfig): boolean { const settingsPath = join(agentDir(), "settings.json"); try { if (!cfg.repoUrl || !cfg.repoPath) return false; const agent = /^[\w-]+$/.test(cfg.currentAgent) ? cfg.currentAgent : "coder"; const declared = readAgentManifest(cfg.repoPath, agent).extensions; // 只保留注册表中真实存在的扩展(单文件 extensions/.ts 或目录型 // extensions//index.ts,与 agent-loader 卡片校验对称),避免脏路径进过滤器 const filter = declared .filter((name) => { const dir = join(cfg.repoPath, "extensions", name); return ( existsSync(join(cfg.repoPath, "extensions", `${name}.ts`)) || existsSync(join(dir, "index.ts")) ); }) .map((name) => { const file = join(cfg.repoPath, "extensions", `${name}.ts`); return existsSync(file) ? `extensions/${name}.ts` : `extensions/${name}/index.ts`; }); const raw = existsSync(settingsPath) ? (JSON.parse(readFileSync(settingsPath, "utf-8")) as Record) : {}; const packages = Array.isArray(raw.packages) ? [...(raw.packages as unknown[])] : []; const idx = packages.findIndex((p) => typeof p === "string" ? p === cfg.repoPath : (p as { source?: unknown })?.source === cfg.repoPath, ); if (idx < 0) return false; // 未声明为 pi 包:无改动 // 注意:对象条目的 filter 省略某资源键时,pi 会绕过 manifest 回退到约定目录 // 全量收集(collectDefaultResources)——仓库根的 skills/ 约定目录会被整个加载, // 冲掉引擎按 agent.json 声明的技能隔离。故显式 skills: [] 禁用包级技能收集, // 技能发现完全留给引擎的 resources_discover。prompts/themes 在 manifest 中有 // 声明,省略时按 manifest 收集,行为正确,无需显式列出。 const next = { source: cfg.repoPath, extensions: filter, skills: [] }; // 与现状完全一致则不写盘(保持 mtime 稳定,幂等安全) if (JSON.stringify(packages[idx]) === JSON.stringify(next)) return false; packages[idx] = next; raw.packages = packages; writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); return true; } catch { return false; // settings 读写失败不阻断调用流程 } } /** * 严格技能模式:settings.json 顶层 skills 置为 ["!*"]——pi 自动发现的全局/项目 * 技能(~/.pi/agent/skills、~/.agents/skills、项目 .agents/skills)全部禁用, * 技能来源只剩两条:内容包(skills: [] 已禁)+ resources_discover(dpi 按 * agent.json 声明注入)。即「装了 pi-dpi 的 agent 技能严格由 agent.json 决定」。 * 幂等:已为 ["!*"] 不写盘;返回是否有改动。全部容错。 */ export function syncStrictSkills(): boolean { const settingsPath = join(agentDir(), "settings.json"); try { const raw = existsSync(settingsPath) ? (JSON.parse(readFileSync(settingsPath, "utf-8")) as Record) : {}; const strict = ["!*"]; if (JSON.stringify(raw.skills) === JSON.stringify(strict)) return false; raw.skills = strict; writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); return true; } catch { return false; } } /** * 确保内容仓库依赖已安装:package.json 声明了 dependencies 且 node_modules 缺失时 * 同步执行 npm install --omit=peer(核心包由 pi 别名提供,无需安装;跳过 peer * 避免把 pi 全套装进来)。阻塞式等待保证本次会话的扩展加载可用;失败静默。 */ export function ensureRepoDeps(repoPath: string): void { try { const pkg = join(repoPath, "package.json"); if (!existsSync(pkg)) return; const raw = JSON.parse(readFileSync(pkg, "utf-8")) as Record; const deps = raw.dependencies as Record | undefined; if (!deps || Object.keys(deps).length === 0) return; if (existsSync(join(repoPath, "node_modules"))) return; execFileSync("npm", ["install", "--omit=peer"], { cwd: repoPath, stdio: "ignore", timeout: 120000, }); } catch { // 安装失败静默:扩展可能暂时不可用,下次会话重试 } } /** * 把内容仓库的本地路径声明进 pi 的 settings.json packages(声明式加载的关键一步)。 * 已在列表中(字符串或对象形式)则不重复添加。返回是否有改动。 * 注意:pi 运行中改写 settings.json 后需要 ctx.reload() 才会生效(调用方负责)。 */ export function ensurePackageInSettings(source: string): boolean { const settingsPath = join(agentDir(), "settings.json"); try { const raw = existsSync(settingsPath) ? (JSON.parse(readFileSync(settingsPath, "utf-8")) as Record) : {}; const packages = Array.isArray(raw.packages) ? [...(raw.packages as unknown[])] : []; const declared = packages.some((p) => typeof p === "string" ? p === source : (p as { source?: unknown })?.source === source, ); if (declared) return false; packages.push(source); raw.packages = packages; writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); return true; } catch { return false; // settings 读写失败不阻断绑定流程 } }