mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Address LLM Wiki host review feedback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
90062af85f
commit
9d45f8820d
4 files changed
+199
-145
No files matched your search
@@ -84,46 +84,6 @@ const DEFAULT_PAPERCLIP_SPACE_ID = "default";
|
||||
const PAPERCLIP_CONFIG_BASENAME = "config.json";
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolvePaperclipHomeDirForAdapter(homeOverride?: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const raw = homeOverride?.trim() || env.PAPERCLIP_HOME?.trim();
|
||||
if (raw) return path.resolve(expandHomePrefix(raw));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceIdForAdapter(instanceIdOverride?: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const raw = instanceIdOverride?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceRootForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipHomeDirForAdapter(input.homeDir, input.env),
|
||||
"instances",
|
||||
resolvePaperclipInstanceIdForAdapter(input.instanceId, input.env),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceConfigPathForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipInstanceRootForAdapter(input), PAPERCLIP_CONFIG_BASENAME);
|
||||
}
|
||||
|
||||
function readJsonIfPresent(filePath: string): unknown | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
@@ -141,58 +101,40 @@ function isPaperclipRuntimeConfig(value: unknown): boolean {
|
||||
return isObject(value) && isObject(value.database) && isObject(value.server);
|
||||
}
|
||||
|
||||
function readActiveSpaceIdFromRegistry(instanceConfigPath: string): string | null {
|
||||
const parsed = readJsonIfPresent(instanceConfigPath);
|
||||
if (!isObject(parsed) || isPaperclipRuntimeConfig(parsed)) return null;
|
||||
const activeSpaceId = parsed.activeSpaceId;
|
||||
return typeof activeSpaceId === "string" && PATH_SEGMENT_RE.test(activeSpaceId) ? activeSpaceId : null;
|
||||
}
|
||||
|
||||
function hasLegacyDefaultSpaceInstall(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): boolean {
|
||||
return isPaperclipRuntimeConfig(readJsonIfPresent(resolvePaperclipInstanceConfigPathForAdapter(input)));
|
||||
}
|
||||
|
||||
function resolvePaperclipSpaceIdForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
const raw =
|
||||
input.spaceId?.trim() ||
|
||||
input.env?.PAPERCLIP_SPACE_ID?.trim() ||
|
||||
process.env.PAPERCLIP_SPACE_ID?.trim() ||
|
||||
readActiveSpaceIdFromRegistry(resolvePaperclipInstanceConfigPathForAdapter(input)) ||
|
||||
DEFAULT_PAPERCLIP_SPACE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_SPACE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolvePaperclipSpacesRootForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipInstanceRootForAdapter(input), "spaces");
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpaceRootForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
const spaceId = resolvePaperclipSpaceIdForAdapter(input);
|
||||
if (spaceId === DEFAULT_PAPERCLIP_SPACE_ID && hasLegacyDefaultSpaceInstall(input)) {
|
||||
return resolvePaperclipInstanceRootForAdapter(input);
|
||||
const env = input.env ?? process.env;
|
||||
const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim();
|
||||
const expandedHome =
|
||||
homeRaw === "~"
|
||||
? os.homedir()
|
||||
: homeRaw?.startsWith("~/")
|
||||
? path.resolve(os.homedir(), homeRaw.slice(2))
|
||||
: homeRaw;
|
||||
const homeDir = path.resolve(expandedHome || path.resolve(os.homedir(), ".paperclip"));
|
||||
const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`);
|
||||
|
||||
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
|
||||
const instanceConfigPath = path.resolve(instanceRoot, PAPERCLIP_CONFIG_BASENAME);
|
||||
const instanceConfig = readJsonIfPresent(instanceConfigPath);
|
||||
const registrySpaceId = isObject(instanceConfig) && !isPaperclipRuntimeConfig(instanceConfig)
|
||||
? instanceConfig.activeSpaceId
|
||||
: null;
|
||||
const spaceId = input.spaceId?.trim() ||
|
||||
env.PAPERCLIP_SPACE_ID?.trim() ||
|
||||
(typeof registrySpaceId === "string" && PATH_SEGMENT_RE.test(registrySpaceId) ? registrySpaceId : null) ||
|
||||
DEFAULT_PAPERCLIP_SPACE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(spaceId)) throw new Error(`Invalid PAPERCLIP_SPACE_ID '${spaceId}'.`);
|
||||
|
||||
if (spaceId === DEFAULT_PAPERCLIP_SPACE_ID && isPaperclipRuntimeConfig(instanceConfig)) {
|
||||
return instanceRoot;
|
||||
}
|
||||
return path.resolve(resolvePaperclipSpacesRootForAdapter(input), spaceId);
|
||||
return path.resolve(instanceRoot, "spaces", spaceId);
|
||||
}
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
const REDACTED_LOG_VALUE = "***REDACTED***";
|
||||
|
||||
@@ -113,6 +113,8 @@ describe("resolveDatabaseTarget", () => {
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_SPACE_ID;
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
PluginWorkspace,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderStatus,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -436,6 +438,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
|
||||
const localFolderFiles = new Map<string, string>();
|
||||
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||
@@ -447,6 +451,43 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
function localFolderKey(companyId: string, folderKey: string): string {
|
||||
return `${companyId}:${folderKey}`;
|
||||
}
|
||||
|
||||
function localFolderFileKey(companyId: string, folderKey: string, relativePath: string): string {
|
||||
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
|
||||
}
|
||||
|
||||
function normalizeLocalFolderRelativePath(relativePath: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const segment of relativePath.split(/[\\/]+/)) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") throw new Error("Local folder path traversal is not allowed");
|
||||
parts.push(segment);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function notConfiguredLocalFolderStatus(folderKey: string): PluginLocalFolderStatus {
|
||||
return {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function issueRelationSummary(issueId: string) {
|
||||
const issue = issues.get(issueId);
|
||||
if (!issue) throw new Error(`Issue not found: ${issueId}`);
|
||||
@@ -543,7 +584,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
},
|
||||
async configure(input) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
const status = {
|
||||
folderKey: input.folderKey,
|
||||
configured: true,
|
||||
path: input.path,
|
||||
@@ -558,77 +599,98 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
healthy: true,
|
||||
problems: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} satisfies PluginLocalFolderStatus;
|
||||
localFolderStatuses.set(localFolderKey(input.companyId, input.folderKey), status);
|
||||
return status;
|
||||
},
|
||||
async status(_companyId, folderKey) {
|
||||
async status(companyId, folderKey) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
return localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey);
|
||||
},
|
||||
async list(_companyId, folderKey, options) {
|
||||
async list(companyId, folderKey, options) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey));
|
||||
if (!status?.configured) throw new Error("Local folder is not configured");
|
||||
const prefix = normalizeLocalFolderRelativePath(options?.relativePath ?? "");
|
||||
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
||||
const entries = new Map<string, PluginLocalFolderEntry>();
|
||||
for (const [key, contents] of localFolderFiles) {
|
||||
const filePrefix = `${localFolderKey(companyId, folderKey)}:`;
|
||||
if (!key.startsWith(filePrefix)) continue;
|
||||
const filePath = key.slice(filePrefix.length);
|
||||
if (prefix && filePath !== prefix && !filePath.startsWith(prefixWithSlash)) continue;
|
||||
const remainder = prefix ? filePath.slice(prefixWithSlash.length) : filePath;
|
||||
const [name] = remainder.split("/");
|
||||
if (!name) continue;
|
||||
const entryPath = prefix ? `${prefix}/${name}` : name;
|
||||
const isNested = remainder.includes("/");
|
||||
if (!options?.recursive && isNested) {
|
||||
entries.set(entryPath, {
|
||||
path: entryPath,
|
||||
name,
|
||||
kind: "directory",
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
entries.set(filePath, {
|
||||
path: filePath,
|
||||
name: filePath.split("/").pop() ?? filePath,
|
||||
kind: "file",
|
||||
size: Buffer.byteLength(contents, "utf8"),
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
const maxEntries = options?.maxEntries && options.maxEntries > 0 ? options.maxEntries : entries.size;
|
||||
const allEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
return {
|
||||
folderKey,
|
||||
relativePath: options?.relativePath ?? null,
|
||||
entries: [],
|
||||
truncated: false,
|
||||
entries: allEntries.slice(0, maxEntries),
|
||||
truncated: allEntries.length > maxEntries,
|
||||
};
|
||||
},
|
||||
async readText() {
|
||||
async readText(companyId, folderKey, relativePath) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
throw new Error("Test harness local folder readText is not implemented");
|
||||
const normalizedPath = normalizeLocalFolderRelativePath(relativePath);
|
||||
const contents = localFolderFiles.get(localFolderFileKey(companyId, folderKey, normalizedPath));
|
||||
if (contents === undefined) throw new Error(`Local folder file not found: ${relativePath}`);
|
||||
return contents;
|
||||
},
|
||||
async writeTextAtomic(_companyId, folderKey) {
|
||||
async writeTextAtomic(companyId, folderKey, relativePath, contents) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
configured: true,
|
||||
path: `memory://${manifest.id}/${companyId}/${folderKey}`,
|
||||
realPath: `memory://${manifest.id}/${companyId}/${folderKey}`,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
readable: true,
|
||||
writable: true,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
healthy: true,
|
||||
problems: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} satisfies PluginLocalFolderStatus;
|
||||
if (status.access !== "readWrite" || !status.writable) {
|
||||
throw new Error("Local folder is not configured for writes");
|
||||
}
|
||||
localFolderStatuses.set(localFolderKey(companyId, folderKey), status);
|
||||
localFolderFiles.set(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath)), contents);
|
||||
return status;
|
||||
},
|
||||
async deleteFile(_companyId, folderKey) {
|
||||
async deleteFile(companyId, folderKey, relativePath) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey);
|
||||
if (status.configured && (status.access !== "readWrite" || !status.writable)) {
|
||||
throw new Error("Local folder is not configured for writes");
|
||||
}
|
||||
localFolderFiles.delete(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath)));
|
||||
return status;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
@@ -1213,10 +1275,64 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
} satisfies PluginManagedSkillResolution;
|
||||
},
|
||||
async reset(skillKey, companyId) {
|
||||
const resolved = await this.reconcile(skillKey, companyId);
|
||||
const existing = await this.get(skillKey, companyId);
|
||||
const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey);
|
||||
if (!declaration) return existing;
|
||||
const now = new Date();
|
||||
const skill = {
|
||||
id: existing.skill?.id ?? randomUUID(),
|
||||
companyId,
|
||||
key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`,
|
||||
slug: declaration.slug ?? skillKey,
|
||||
name: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
markdown: declaration.markdown ?? `# ${declaration.displayName}\n`,
|
||||
sourceType: "catalog",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: {
|
||||
sourceKind: "catalog",
|
||||
pluginManagedResource: {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "skill",
|
||||
resourceKey: skillKey,
|
||||
},
|
||||
},
|
||||
createdAt: existing.skill?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies CompanySkill;
|
||||
const nowIso = now.toISOString();
|
||||
const existingEntity = [...entities.values()].find((entity) =>
|
||||
entity.entityType === "managed_resource" &&
|
||||
entity.scopeKind === "company" &&
|
||||
entity.scopeId === companyId &&
|
||||
entity.externalId === `${manifest.id}:skill:${skillKey}`,
|
||||
);
|
||||
const record: PluginEntityRecord = {
|
||||
id: existingEntity?.id ?? randomUUID(),
|
||||
entityType: "managed_resource",
|
||||
scopeKind: "company",
|
||||
scopeId: companyId,
|
||||
externalId: `${manifest.id}:skill:${skillKey}`,
|
||||
title: declaration.displayName,
|
||||
status: null,
|
||||
data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill },
|
||||
createdAt: existingEntity?.createdAt ?? nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
return {
|
||||
...resolved,
|
||||
status: resolved.skill ? "reset" : resolved.status,
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "skill",
|
||||
resourceKey: skillKey,
|
||||
companyId,
|
||||
skillId: skill.id,
|
||||
skill,
|
||||
status: "reset",
|
||||
defaultDrift: null,
|
||||
} satisfies PluginManagedSkillResolution;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -889,9 +889,6 @@ export function buildHostServices(
|
||||
});
|
||||
const status = await inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
assertWritableConfiguredLocalFolder(status);
|
||||
if (status.access !== "readWrite" || !status.writable) {
|
||||
throw new Error("Local folder is not configured for writes");
|
||||
}
|
||||
await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents);
|
||||
return inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
},
|
||||
@@ -900,9 +897,6 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
const status = await inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
assertWritableConfiguredLocalFolder(status);
|
||||
if (status.access !== "readWrite" || !status.writable) {
|
||||
throw new Error("Local folder is not configured for writes");
|
||||
}
|
||||
await deletePluginLocalFolderFile(status.realPath!, params.relativePath);
|
||||
return inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user