230 lines
6.2 KiB
TypeScript
230 lines
6.2 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import lockfile from "proper-lockfile";
|
|
import { CONFIG_DIR_NAME } from "../config.ts";
|
|
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
|
|
|
export type ProjectTrustDecision = boolean | null;
|
|
|
|
export interface ProjectTrustStoreEntry {
|
|
path: string;
|
|
decision: boolean;
|
|
}
|
|
|
|
export interface ProjectTrustUpdate {
|
|
path: string;
|
|
decision: ProjectTrustDecision;
|
|
}
|
|
|
|
export interface ProjectTrustOption {
|
|
label: string;
|
|
trusted: boolean;
|
|
updates: ProjectTrustUpdate[];
|
|
savedPath?: string;
|
|
}
|
|
|
|
type TrustFile = Record<string, boolean | null | undefined>;
|
|
|
|
function normalizeCwd(cwd: string): string {
|
|
return canonicalizePath(resolvePath(cwd));
|
|
}
|
|
|
|
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
|
|
let currentDir = normalizeCwd(cwd);
|
|
while (true) {
|
|
const value = data[currentDir];
|
|
if (value === true || value === false) {
|
|
return { path: currentDir, decision: value };
|
|
}
|
|
|
|
const parentDir = dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
return null;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export function getProjectTrustPath(cwd: string): string {
|
|
return normalizeCwd(cwd);
|
|
}
|
|
|
|
export function getProjectTrustParentPath(cwd: string): string | undefined {
|
|
const trustPath = getProjectTrustPath(cwd);
|
|
const parentDir = dirname(trustPath);
|
|
return parentDir === trustPath ? undefined : parentDir;
|
|
}
|
|
|
|
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
|
|
const trustPath = getProjectTrustPath(cwd);
|
|
const trustOptions: ProjectTrustOption[] = [
|
|
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
|
|
];
|
|
const parentPath = getProjectTrustParentPath(cwd);
|
|
if (parentPath !== undefined) {
|
|
trustOptions.push({
|
|
label: `Trust parent folder (${parentPath})`,
|
|
trusted: true,
|
|
updates: [
|
|
{ path: parentPath, decision: true },
|
|
{ path: trustPath, decision: null },
|
|
],
|
|
savedPath: parentPath,
|
|
});
|
|
}
|
|
if (options?.includeSessionOnly) {
|
|
trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] });
|
|
}
|
|
trustOptions.push({
|
|
label: "Do not trust",
|
|
trusted: false,
|
|
updates: [{ path: trustPath, decision: false }],
|
|
savedPath: trustPath,
|
|
});
|
|
if (options?.includeSessionOnly) {
|
|
trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] });
|
|
}
|
|
return trustOptions;
|
|
}
|
|
|
|
function readTrustFile(path: string): TrustFile {
|
|
if (!existsSync(path)) {
|
|
return {};
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Failed to read trust store ${path}: ${message}`);
|
|
}
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Invalid trust store ${path}: expected an object`);
|
|
}
|
|
|
|
const data: TrustFile = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (value !== true && value !== false && value !== null) {
|
|
throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`);
|
|
}
|
|
data[key] = value;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function writeTrustFile(path: string, data: TrustFile): void {
|
|
const sorted: TrustFile = {};
|
|
for (const key of Object.keys(data).sort()) {
|
|
const value = data[key];
|
|
if (value === true || value === false || value === null) {
|
|
sorted[key] = value;
|
|
}
|
|
}
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
|
|
}
|
|
|
|
function acquireTrustLockSync(path: string): () => void {
|
|
const trustDir = dirname(path);
|
|
mkdirSync(trustDir, { recursive: true });
|
|
const maxAttempts = 10;
|
|
const delayMs = 20;
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` });
|
|
} catch (error) {
|
|
const code =
|
|
typeof error === "object" && error !== null && "code" in error
|
|
? String((error as { code?: unknown }).code)
|
|
: undefined;
|
|
if (code !== "ELOCKED" || attempt === maxAttempts) {
|
|
throw error;
|
|
}
|
|
lastError = error;
|
|
const start = Date.now();
|
|
while (Date.now() - start < delayMs) {
|
|
// Sleep synchronously to avoid changing trust store callers to async.
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastError instanceof Error) {
|
|
throw lastError;
|
|
}
|
|
throw new Error("Failed to acquire trust store lock");
|
|
}
|
|
|
|
function withTrustFileLock<T>(path: string, fn: () => T): T {
|
|
const release = acquireTrustLockSync(path);
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
export function hasProjectConfigDir(cwd: string): boolean {
|
|
return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME));
|
|
}
|
|
|
|
export function hasProjectTrustInputs(cwd: string): boolean {
|
|
let currentDir = canonicalizePath(resolvePath(cwd));
|
|
if (hasProjectConfigDir(currentDir)) {
|
|
return true;
|
|
}
|
|
|
|
while (true) {
|
|
if (existsSync(join(currentDir, ".agents", "skills"))) {
|
|
return true;
|
|
}
|
|
|
|
const parentDir = dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
return false;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export class ProjectTrustStore {
|
|
private trustPath: string;
|
|
|
|
constructor(agentDir: string) {
|
|
this.trustPath = join(resolvePath(agentDir), "trust.json");
|
|
}
|
|
|
|
get(cwd: string): ProjectTrustDecision {
|
|
return this.getEntry(cwd)?.decision ?? null;
|
|
}
|
|
|
|
getEntry(cwd: string): ProjectTrustStoreEntry | null {
|
|
return withTrustFileLock(this.trustPath, () => {
|
|
const data = readTrustFile(this.trustPath);
|
|
return findNearestTrustEntry(data, cwd);
|
|
});
|
|
}
|
|
|
|
set(cwd: string, decision: ProjectTrustDecision): void {
|
|
this.setMany([{ path: cwd, decision }]);
|
|
}
|
|
|
|
setMany(decisions: ProjectTrustUpdate[]): void {
|
|
withTrustFileLock(this.trustPath, () => {
|
|
const data = readTrustFile(this.trustPath);
|
|
for (const { path, decision } of decisions) {
|
|
const key = normalizeCwd(path);
|
|
if (decision === null) {
|
|
delete data[key];
|
|
} else {
|
|
data[key] = decision;
|
|
}
|
|
}
|
|
writeTrustFile(this.trustPath, data);
|
|
});
|
|
}
|
|
}
|