Refine codex runtime skills and portability assets
This commit is contained in:
parent
01afa92424
commit
b4e06c63e2
12 changed files with 277 additions and 205 deletions
|
|
@ -40,7 +40,7 @@ Operational fields:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
|
||||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
|
|
@ -136,6 +135,10 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCodexWorkspaceSkillsDir(cwd: string): string {
|
||||||
|
return path.join(cwd, ".agents", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
skillsHome?: string;
|
skillsHome?: string;
|
||||||
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
|
|
@ -154,18 +157,8 @@ export async function ensureCodexSkillsInjected(
|
||||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
|
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
if (skillsEntries.length === 0) return;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd());
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
||||||
skillsHome,
|
|
||||||
skillsEntries.map((entry) => entry.runtimeName),
|
|
||||||
);
|
|
||||||
for (const skillName of removedSkills) {
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const linkSkill = options.linkSkill;
|
const linkSkill = options.linkSkill;
|
||||||
for (const entry of skillsEntries) {
|
for (const entry of skillsEntries) {
|
||||||
const target = path.join(skillsHome, entry.runtimeName);
|
const target = path.join(skillsHome, entry.runtimeName);
|
||||||
|
|
@ -279,10 +272,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId);
|
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId);
|
||||||
const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId);
|
const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId);
|
||||||
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome;
|
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome;
|
||||||
|
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
|
||||||
await ensureCodexSkillsInjected(
|
await ensureCodexSkillsInjected(
|
||||||
onLog,
|
onLog,
|
||||||
{
|
{
|
||||||
skillsHome: path.join(effectiveCodexHome, "skills"),
|
skillsHome: codexWorkspaceSkillsDir,
|
||||||
skillsEntries: codexSkillEntries,
|
skillsEntries: codexSkillEntries,
|
||||||
desiredSkillNames,
|
desiredSkillNames,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,82 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
|
AdapterSkillEntry,
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
buildPersistentSkillSnapshot,
|
|
||||||
ensurePaperclipSkillSymlink,
|
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
readInstalledSkillTargets,
|
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { resolveCodexHomeDir } from "./codex-home.js";
|
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function asString(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCodexSkillsHome(config: Record<string, unknown>, companyId?: string) {
|
|
||||||
const env =
|
|
||||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
|
||||||
? (config.env as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const configuredCodexHome = asString(env.CODEX_HOME);
|
|
||||||
const home = configuredCodexHome
|
|
||||||
? path.resolve(configuredCodexHome)
|
|
||||||
: resolveCodexHomeDir(process.env, companyId);
|
|
||||||
return path.join(home, "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildCodexSkillSnapshot(
|
async function buildCodexSkillSnapshot(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
companyId?: string,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const skillsHome = resolveCodexSkillsHome(config, companyId);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||||
return buildPersistentSkillSnapshot({
|
key: entry.key,
|
||||||
|
runtimeName: entry.runtimeName,
|
||||||
|
desired: desiredSet.has(entry.key),
|
||||||
|
managed: true,
|
||||||
|
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||||
|
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||||
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: entry.source,
|
||||||
|
targetPath: null,
|
||||||
|
detail: desiredSet.has(entry.key)
|
||||||
|
? "Will be linked into the workspace .agents/skills directory on the next run."
|
||||||
|
: null,
|
||||||
|
required: Boolean(entry.required),
|
||||||
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
}));
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
for (const desiredSkill of desiredSkills) {
|
||||||
|
if (availableByKey.has(desiredSkill)) continue;
|
||||||
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
|
entries.push({
|
||||||
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "missing",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: null,
|
||||||
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||||
|
|
||||||
|
return {
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
availableEntries,
|
supported: true,
|
||||||
|
mode: "ephemeral",
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
installed,
|
entries,
|
||||||
skillsHome,
|
warnings,
|
||||||
locationLabel: "$CODEX_HOME/skills",
|
};
|
||||||
missingDetail: "Configured but not currently linked into the Codex skills home.",
|
|
||||||
externalConflictDetail: "Skill name is occupied by an external installation.",
|
|
||||||
externalDetail: "Installed outside Paperclip management.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
return buildCodexSkillSnapshot(ctx.config, ctx.companyId);
|
return buildCodexSkillSnapshot(ctx.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncCodexSkills(
|
export async function syncCodexSkills(
|
||||||
ctx: AdapterSkillContext,
|
ctx: AdapterSkillContext,
|
||||||
desiredSkills: string[],
|
_desiredSkills: string[],
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
return buildCodexSkillSnapshot(ctx.config);
|
||||||
const desiredSet = new Set([
|
|
||||||
...desiredSkills,
|
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
|
||||||
]);
|
|
||||||
const skillsHome = resolveCodexSkillsHome(ctx.config, ctx.companyId);
|
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
|
||||||
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
if (!desiredSet.has(available.key)) continue;
|
|
||||||
const target = path.join(skillsHome, available.runtimeName);
|
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
const available = availableByRuntimeName.get(name);
|
|
||||||
if (!available) continue;
|
|
||||||
if (desiredSet.has(available.key)) continue;
|
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildCodexSkillSnapshot(ctx.config, ctx.companyId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCodexDesiredSkillNames(
|
export function resolveCodexDesiredSkillNames(
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ export type {
|
||||||
InstanceUserRoleGrant,
|
InstanceUserRoleGrant,
|
||||||
CompanyPortabilityInclude,
|
CompanyPortabilityInclude,
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,20 @@ export interface CompanyPortabilityEnvInput {
|
||||||
portability: "portable" | "system_dependent";
|
portability: "portable" | "system_dependent";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CompanyPortabilityFileEntry =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
encoding: "base64";
|
||||||
|
data: string;
|
||||||
|
contentType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CompanyPortabilityCompanyManifestEntry {
|
export interface CompanyPortabilityCompanyManifestEntry {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
brandColor: string | null;
|
brandColor: string | null;
|
||||||
|
logoPath: string | null;
|
||||||
requireBoardApprovalForNewAgents: boolean;
|
requireBoardApprovalForNewAgents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +119,7 @@ export interface CompanyPortabilityManifest {
|
||||||
export interface CompanyPortabilityExportResult {
|
export interface CompanyPortabilityExportResult {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, string>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
paperclipExtensionPath: string;
|
paperclipExtensionPath: string;
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +132,7 @@ export interface CompanyPortabilityExportPreviewFile {
|
||||||
export interface CompanyPortabilityExportPreviewResult {
|
export interface CompanyPortabilityExportPreviewResult {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, string>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
fileInventory: CompanyPortabilityExportPreviewFile[];
|
fileInventory: CompanyPortabilityExportPreviewFile[];
|
||||||
counts: {
|
counts: {
|
||||||
files: number;
|
files: number;
|
||||||
|
|
@ -140,7 +149,7 @@ export type CompanyPortabilitySource =
|
||||||
| {
|
| {
|
||||||
type: "inline";
|
type: "inline";
|
||||||
rootPath?: string | null;
|
rootPath?: string | null;
|
||||||
files: Record<string, string>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "github";
|
type: "github";
|
||||||
|
|
@ -207,7 +216,7 @@ export interface CompanyPortabilityPreviewResult {
|
||||||
issuePlans: CompanyPortabilityPreviewIssuePlan[];
|
issuePlans: CompanyPortabilityPreviewIssuePlan[];
|
||||||
};
|
};
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, string>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
envInputs: CompanyPortabilityEnvInput[];
|
envInputs: CompanyPortabilityEnvInput[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
|
||||||
export type {
|
export type {
|
||||||
CompanyPortabilityInclude,
|
CompanyPortabilityInclude,
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,21 @@ export const portabilityEnvInputSchema = z.object({
|
||||||
portability: z.enum(["portable", "system_dependent"]),
|
portability: z.enum(["portable", "system_dependent"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const portabilityFileEntrySchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
encoding: z.literal("base64"),
|
||||||
|
data: z.string(),
|
||||||
|
contentType: z.string().min(1).optional().nullable(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
export const portabilityCompanyManifestEntrySchema = z.object({
|
export const portabilityCompanyManifestEntrySchema = z.object({
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
brandColor: z.string().nullable(),
|
brandColor: z.string().nullable(),
|
||||||
|
logoPath: z.string().nullable(),
|
||||||
requireBoardApprovalForNewAgents: z.boolean(),
|
requireBoardApprovalForNewAgents: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,7 +132,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("inline"),
|
type: z.literal("inline"),
|
||||||
rootPath: z.string().min(1).optional().nullable(),
|
rootPath: z.string().min(1).optional().nullable(),
|
||||||
files: z.record(z.string()),
|
files: z.record(portabilityFileEntrySchema),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("github"),
|
type: z.literal("github"),
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ describe("codex execute", () => {
|
||||||
"company-1",
|
"company-1",
|
||||||
"codex-home",
|
"codex-home",
|
||||||
);
|
);
|
||||||
|
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip");
|
||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||||
|
|
@ -124,13 +125,12 @@ describe("codex execute", () => {
|
||||||
|
|
||||||
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
|
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
|
||||||
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
|
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
|
||||||
const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip");
|
|
||||||
|
|
||||||
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
|
||||||
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
||||||
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
||||||
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
||||||
expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true);
|
||||||
expect(logs).toContainEqual(
|
expect(logs).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
stream: "stdout",
|
stream: "stdout",
|
||||||
|
|
@ -217,6 +217,7 @@ describe("codex execute", () => {
|
||||||
|
|
||||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
expect(capture.codexHome).toBe(explicitCodexHome);
|
expect(capture.codexHome).toBe(explicitCodexHome);
|
||||||
|
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome === undefined) delete process.env.HOME;
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,33 @@ describe("codex local adapter skill injection", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves other live Paperclip skill symlinks in the shared workspace skill directory", async () => {
|
||||||
|
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||||
|
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||||
|
cleanupDirs.add(currentRepo);
|
||||||
|
cleanupDirs.add(skillsHome);
|
||||||
|
|
||||||
|
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||||
|
await createPaperclipRepoSkill(currentRepo, "agent-browser");
|
||||||
|
await fs.symlink(
|
||||||
|
path.join(currentRepo, "skills", "agent-browser"),
|
||||||
|
path.join(skillsHome, "agent-browser"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureCodexSkillsInjected(async () => {}, {
|
||||||
|
skillsHome,
|
||||||
|
skillsEntries: [{
|
||||||
|
key: paperclipKey,
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: path.join(currentRepo, "skills", "paperclip"),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
|
expect((await fs.lstat(path.join(skillsHome, "agent-browser"))).isSymbolicLink()).toBe(true);
|
||||||
|
expect(await fs.realpath(path.join(skillsHome, "agent-browser"))).toBe(
|
||||||
|
await fs.realpath(path.join(currentRepo, "skills", "agent-browser")),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,6 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSkillDir(root: string, name: string) {
|
|
||||||
const skillDir = path.join(root, name);
|
|
||||||
await fs.mkdir(skillDir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
|
||||||
return skillDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("codex local skill sync", () => {
|
describe("codex local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
@ -27,7 +20,7 @@ describe("codex local skill sync", () => {
|
||||||
cleanupDirs.clear();
|
cleanupDirs.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports configured Paperclip skills and installs them into the Codex skills home", async () => {
|
it("reports configured Paperclip skills for workspace injection on the next run", async () => {
|
||||||
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
||||||
cleanupDirs.add(codexHome);
|
cleanupDirs.add(codexHome);
|
||||||
|
|
||||||
|
|
@ -46,65 +39,14 @@ describe("codex local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const before = await listCodexSkills(ctx);
|
const before = await listCodexSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("ephemeral");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills");
|
||||||
const after = await syncCodexSkills(ctx, [paperclipKey]);
|
|
||||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
|
||||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isolates default Codex skills by company when CODEX_HOME comes from process env", async () => {
|
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
|
||||||
const sharedCodexHome = await makeTempDir("paperclip-codex-skill-scope-");
|
|
||||||
cleanupDirs.add(sharedCodexHome);
|
|
||||||
const previousCodexHome = process.env.CODEX_HOME;
|
|
||||||
process.env.CODEX_HOME = sharedCodexHome;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const companyAContext = {
|
|
||||||
agentId: "agent-a",
|
|
||||||
companyId: "company-a",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
config: {
|
|
||||||
env: {},
|
|
||||||
paperclipSkillSync: {
|
|
||||||
desiredSkills: [paperclipKey],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const companyBContext = {
|
|
||||||
agentId: "agent-b",
|
|
||||||
companyId: "company-b",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
config: {
|
|
||||||
env: {},
|
|
||||||
paperclipSkillSync: {
|
|
||||||
desiredSkills: [paperclipKey],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
await syncCodexSkills(companyAContext, [paperclipKey]);
|
|
||||||
await syncCodexSkills(companyBContext, [paperclipKey]);
|
|
||||||
|
|
||||||
expect((await fs.lstat(path.join(sharedCodexHome, "companies", "company-a", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
|
||||||
expect((await fs.lstat(path.join(sharedCodexHome, "companies", "company-b", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
|
||||||
await expect(fs.lstat(path.join(sharedCodexHome, "skills", "paperclip"))).rejects.toMatchObject({
|
|
||||||
code: "ENOENT",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (previousCodexHome === undefined) {
|
|
||||||
delete process.env.CODEX_HOME;
|
|
||||||
} else {
|
|
||||||
process.env.CODEX_HOME = previousCodexHome;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
|
||||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||||
cleanupDirs.add(codexHome);
|
cleanupDirs.add(codexHome);
|
||||||
|
|
||||||
|
|
@ -122,10 +64,22 @@ describe("codex local skill sync", () => {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncCodexSkills(configuredCtx, [paperclipKey]);
|
const after = await syncCodexSkills(configuredCtx, [paperclipKey]);
|
||||||
|
expect(after.mode).toBe("ephemeral");
|
||||||
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
|
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const clearedCtx = {
|
it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => {
|
||||||
...configuredCtx,
|
const codexHome = await makeTempDir("paperclip-codex-skill-required-");
|
||||||
|
cleanupDirs.add(codexHome);
|
||||||
|
|
||||||
|
const configuredCtx = {
|
||||||
|
agentId: "agent-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "codex_local",
|
||||||
config: {
|
config: {
|
||||||
env: {
|
env: {
|
||||||
CODEX_HOME: codexHome,
|
CODEX_HOME: codexHome,
|
||||||
|
|
@ -136,13 +90,12 @@ describe("codex local skill sync", () => {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncCodexSkills(clearedCtx, []);
|
const after = await syncCodexSkills(configuredCtx, []);
|
||||||
expect(after.desiredSkills).toContain(paperclipKey);
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy flat Paperclip skill refs before reporting persistent state", async () => {
|
it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => {
|
||||||
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
||||||
cleanupDirs.add(codexHome);
|
cleanupDirs.add(codexHome);
|
||||||
|
|
||||||
|
|
@ -163,38 +116,7 @@ describe("codex local skill sync", () => {
|
||||||
expect(snapshot.warnings).toEqual([]);
|
expect(snapshot.warnings).toEqual([]);
|
||||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||||
expect(snapshot.desiredSkills).not.toContain("paperclip");
|
expect(snapshot.desiredSkills).not.toContain("paperclip");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports unmanaged user-installed Codex skills with provenance metadata", async () => {
|
|
||||||
const codexHome = await makeTempDir("paperclip-codex-user-skills-");
|
|
||||||
cleanupDirs.add(codexHome);
|
|
||||||
|
|
||||||
const externalSkillDir = await createSkillDir(path.join(codexHome, "skills"), "crack-python");
|
|
||||||
expect(externalSkillDir).toContain(path.join(codexHome, "skills"));
|
|
||||||
|
|
||||||
const snapshot = await listCodexSkills({
|
|
||||||
agentId: "agent-4",
|
|
||||||
companyId: "company-1",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
config: {
|
|
||||||
env: {
|
|
||||||
CODEX_HOME: codexHome,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
|
||||||
key: "crack-python",
|
|
||||||
runtimeName: "crack-python",
|
|
||||||
state: "external",
|
|
||||||
managed: false,
|
|
||||||
origin: "user_installed",
|
|
||||||
originLabel: "User-installed",
|
|
||||||
locationLabel: "$CODEX_HOME/skills",
|
|
||||||
readOnly: true,
|
|
||||||
detail: "Installed outside Paperclip management.",
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
CompanyPortabilityCollisionStrategy,
|
CompanyPortabilityCollisionStrategy,
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityExport,
|
CompanyPortabilityExport,
|
||||||
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityExportPreviewResult,
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityImport,
|
CompanyPortabilityImport,
|
||||||
|
|
@ -35,9 +36,11 @@ import {
|
||||||
writePaperclipSkillSyncPreference,
|
writePaperclipSkillSyncPreference,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
|
import type { StorageService } from "../storage/types.js";
|
||||||
import { accessService } from "./access.js";
|
import { accessService } from "./access.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { agentInstructionsService } from "./agent-instructions.js";
|
import { agentInstructionsService } from "./agent-instructions.js";
|
||||||
|
import { assetService } from "./assets.js";
|
||||||
import { generateReadme } from "./company-export-readme.js";
|
import { generateReadme } from "./company-export-readme.js";
|
||||||
import { companySkillService } from "./company-skills.js";
|
import { companySkillService } from "./company-skills.js";
|
||||||
import { companyService } from "./companies.js";
|
import { companyService } from "./companies.js";
|
||||||
|
|
@ -323,7 +326,7 @@ function isSensitiveEnvKey(key: string) {
|
||||||
|
|
||||||
type ResolvedSource = {
|
type ResolvedSource = {
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, string>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -400,6 +403,16 @@ type EnvInputRecord = {
|
||||||
portability?: "portable" | "system_dependent";
|
portability?: "portable" | "system_dependent";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY_LOGO_FILE_NAME = "company-logo";
|
||||||
|
|
||||||
const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [
|
const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [
|
||||||
{ path: ["heartbeat", "cooldownSec"], value: 10 },
|
{ path: ["heartbeat", "cooldownSec"], value: 10 },
|
||||||
{ path: ["heartbeat", "intervalSec"], value: 3600 },
|
{ path: ["heartbeat", "intervalSec"], value: 3600 },
|
||||||
|
|
@ -524,12 +537,83 @@ function resolvePortablePath(fromPath: string, targetPath: string) {
|
||||||
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
|
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortableBinaryFile(
|
||||||
|
value: CompanyPortabilityFileEntry,
|
||||||
|
): value is Extract<CompanyPortabilityFileEntry, { encoding: "base64" }> {
|
||||||
|
return typeof value === "object" && value !== null && value.encoding === "base64" && typeof value.data === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPortableTextFile(
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
|
filePath: string,
|
||||||
|
) {
|
||||||
|
const value = files[filePath];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferContentTypeFromPath(filePath: string) {
|
||||||
|
const extension = path.posix.extname(filePath).toLowerCase();
|
||||||
|
switch (extension) {
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif";
|
||||||
|
case ".jpeg":
|
||||||
|
case ".jpg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case ".png":
|
||||||
|
return "image/png";
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml";
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCompanyLogoExtension(contentType: string | null | undefined, originalFilename: string | null | undefined) {
|
||||||
|
const fromContentType = contentType ? COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType.toLowerCase()] : null;
|
||||||
|
if (fromContentType) return fromContentType;
|
||||||
|
|
||||||
|
const extension = originalFilename ? path.extname(originalFilename).toLowerCase() : "";
|
||||||
|
return extension || ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableBinaryFileToBuffer(entry: Extract<CompanyPortabilityFileEntry, { encoding: "base64" }>) {
|
||||||
|
return Buffer.from(entry.data, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableFileToBuffer(entry: CompanyPortabilityFileEntry, filePath: string) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
return Buffer.from(entry, "utf8");
|
||||||
|
}
|
||||||
|
if (isPortableBinaryFile(entry)) {
|
||||||
|
return portableBinaryFileToBuffer(entry);
|
||||||
|
}
|
||||||
|
throw unprocessable(`Unsupported file entry encoding for ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToPortableBinaryFile(buffer: Buffer, contentType: string | null): CompanyPortabilityFileEntry {
|
||||||
|
return {
|
||||||
|
encoding: "base64",
|
||||||
|
data: buffer.toString("base64"),
|
||||||
|
contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToBuffer(stream: NodeJS.ReadableStream) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFileMap(
|
function normalizeFileMap(
|
||||||
files: Record<string, string>,
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
rootPath?: string | null,
|
rootPath?: string | null,
|
||||||
): Record<string, string> {
|
): Record<string, CompanyPortabilityFileEntry> {
|
||||||
const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null;
|
const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null;
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
for (const [rawPath, content] of Object.entries(files)) {
|
for (const [rawPath, content] of Object.entries(files)) {
|
||||||
let nextPath = normalizePortablePath(rawPath);
|
let nextPath = normalizePortablePath(rawPath);
|
||||||
if (normalizedRoot && nextPath === normalizedRoot) {
|
if (normalizedRoot && nextPath === normalizedRoot) {
|
||||||
|
|
@ -627,7 +711,7 @@ function filterPortableExtensionYaml(yaml: string, selectedFiles: Set<string>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterExportFiles(
|
function filterExportFiles(
|
||||||
files: Record<string, string>,
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
selectedFilesInput: string[] | undefined,
|
selectedFilesInput: string[] | undefined,
|
||||||
paperclipExtensionPath: string,
|
paperclipExtensionPath: string,
|
||||||
) {
|
) {
|
||||||
|
|
@ -640,20 +724,21 @@ function filterExportFiles(
|
||||||
.map((entry) => normalizePortablePath(entry))
|
.map((entry) => normalizePortablePath(entry))
|
||||||
.filter((entry) => entry.length > 0),
|
.filter((entry) => entry.length > 0),
|
||||||
);
|
);
|
||||||
const filtered: Record<string, string> = {};
|
const filtered: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
for (const [filePath, content] of Object.entries(files)) {
|
for (const [filePath, content] of Object.entries(files)) {
|
||||||
if (!selectedFiles.has(filePath)) continue;
|
if (!selectedFiles.has(filePath)) continue;
|
||||||
filtered[filePath] = content;
|
filtered[filePath] = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFiles.has(paperclipExtensionPath) && filtered[paperclipExtensionPath]) {
|
const extensionEntry = filtered[paperclipExtensionPath];
|
||||||
filtered[paperclipExtensionPath] = filterPortableExtensionYaml(filtered[paperclipExtensionPath]!, selectedFiles);
|
if (selectedFiles.has(paperclipExtensionPath) && typeof extensionEntry === "string") {
|
||||||
|
filtered[paperclipExtensionPath] = filterPortableExtensionYaml(extensionEntry, selectedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPaperclipExtensionPath(files: Record<string, string>) {
|
function findPaperclipExtensionPath(files: Record<string, CompanyPortabilityFileEntry>) {
|
||||||
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
||||||
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
||||||
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
||||||
|
|
@ -1332,6 +1417,14 @@ async function fetchOptionalText(url: string) {
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchBinary(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -1425,13 +1518,13 @@ function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildManifestFromPackageFiles(
|
function buildManifestFromPackageFiles(
|
||||||
files: Record<string, string>,
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
||||||
): ResolvedSource {
|
): ResolvedSource {
|
||||||
const normalizedFiles = normalizeFileMap(files);
|
const normalizedFiles = normalizeFileMap(files);
|
||||||
const companyPath =
|
const companyPath = typeof normalizedFiles["COMPANY.md"] === "string"
|
||||||
normalizedFiles["COMPANY.md"]
|
? normalizedFiles["COMPANY.md"]
|
||||||
?? undefined;
|
: undefined;
|
||||||
const resolvedCompanyPath = companyPath !== undefined
|
const resolvedCompanyPath = companyPath !== undefined
|
||||||
? "COMPANY.md"
|
? "COMPANY.md"
|
||||||
: Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md");
|
: Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md");
|
||||||
|
|
@ -1439,11 +1532,15 @@ function buildManifestFromPackageFiles(
|
||||||
throw unprocessable("Company package is missing COMPANY.md");
|
throw unprocessable("Company package is missing COMPANY.md");
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyDoc = parseFrontmatterMarkdown(normalizedFiles[resolvedCompanyPath]!);
|
const companyMarkdown = readPortableTextFile(normalizedFiles, resolvedCompanyPath);
|
||||||
|
if (typeof companyMarkdown !== "string") {
|
||||||
|
throw unprocessable(`Company package file is not readable as text: ${resolvedCompanyPath}`);
|
||||||
|
}
|
||||||
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
||||||
const companyFrontmatter = companyDoc.frontmatter;
|
const companyFrontmatter = companyDoc.frontmatter;
|
||||||
const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles);
|
const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles);
|
||||||
const paperclipExtension = paperclipExtensionPath
|
const paperclipExtension = paperclipExtensionPath
|
||||||
? parseYamlFile(normalizedFiles[paperclipExtensionPath] ?? "")
|
? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "")
|
||||||
: {};
|
: {};
|
||||||
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
||||||
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
||||||
|
|
@ -1503,6 +1600,7 @@ function buildManifestFromPackageFiles(
|
||||||
name: companyName,
|
name: companyName,
|
||||||
description: asString(companyFrontmatter.description),
|
description: asString(companyFrontmatter.description),
|
||||||
brandColor: asString(paperclipCompany.brandColor),
|
brandColor: asString(paperclipCompany.brandColor),
|
||||||
|
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
||||||
requireBoardApprovalForNewAgents:
|
requireBoardApprovalForNewAgents:
|
||||||
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||||
|
|
@ -1516,8 +1614,11 @@ function buildManifestFromPackageFiles(
|
||||||
};
|
};
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
if (manifest.company?.logoPath && !normalizedFiles[manifest.company.logoPath]) {
|
||||||
|
warnings.push(`Referenced company logo file is missing from package: ${manifest.company.logoPath}`);
|
||||||
|
}
|
||||||
for (const agentPath of agentPaths) {
|
for (const agentPath of agentPaths) {
|
||||||
const markdownRaw = normalizedFiles[agentPath];
|
const markdownRaw = readPortableTextFile(normalizedFiles, agentPath);
|
||||||
if (typeof markdownRaw !== "string") {
|
if (typeof markdownRaw !== "string") {
|
||||||
warnings.push(`Referenced agent file is missing from package: ${agentPath}`);
|
warnings.push(`Referenced agent file is missing from package: ${agentPath}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1566,7 +1667,7 @@ function buildManifestFromPackageFiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const skillPath of skillPaths) {
|
for (const skillPath of skillPaths) {
|
||||||
const markdownRaw = normalizedFiles[skillPath];
|
const markdownRaw = readPortableTextFile(normalizedFiles, skillPath);
|
||||||
if (typeof markdownRaw !== "string") {
|
if (typeof markdownRaw !== "string") {
|
||||||
warnings.push(`Referenced skill file is missing from package: ${skillPath}`);
|
warnings.push(`Referenced skill file is missing from package: ${skillPath}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1651,7 +1752,7 @@ function buildManifestFromPackageFiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const projectPath of projectPaths) {
|
for (const projectPath of projectPaths) {
|
||||||
const markdownRaw = normalizedFiles[projectPath];
|
const markdownRaw = readPortableTextFile(normalizedFiles, projectPath);
|
||||||
if (typeof markdownRaw !== "string") {
|
if (typeof markdownRaw !== "string") {
|
||||||
warnings.push(`Referenced project file is missing from package: ${projectPath}`);
|
warnings.push(`Referenced project file is missing from package: ${projectPath}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1685,7 +1786,7 @@ function buildManifestFromPackageFiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const taskPath of taskPaths) {
|
for (const taskPath of taskPaths) {
|
||||||
const markdownRaw = normalizedFiles[taskPath];
|
const markdownRaw = readPortableTextFile(normalizedFiles, taskPath);
|
||||||
if (typeof markdownRaw !== "string") {
|
if (typeof markdownRaw !== "string") {
|
||||||
warnings.push(`Referenced task file is missing from package: ${taskPath}`);
|
warnings.push(`Referenced task file is missing from package: ${taskPath}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1773,9 +1874,10 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath:
|
||||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function companyPortabilityService(db: Db) {
|
export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
const companies = companyService(db);
|
const companies = companyService(db);
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
|
const assetRecords = assetService(db);
|
||||||
const instructions = agentInstructionsService();
|
const instructions = agentInstructionsService();
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
const projects = projectService(db);
|
const projects = projectService(db);
|
||||||
|
|
@ -1818,7 +1920,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
const companyPath = parsed.companyPath === "COMPANY.md"
|
const companyPath = parsed.companyPath === "COMPANY.md"
|
||||||
? "COMPANY.md"
|
? "COMPANY.md"
|
||||||
: normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath));
|
: normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath));
|
||||||
const files: Record<string, string> = {
|
const files: Record<string, CompanyPortabilityFileEntry> = {
|
||||||
[companyPath]: companyMarkdown,
|
[companyPath]: companyMarkdown,
|
||||||
};
|
};
|
||||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
|
|
@ -1859,6 +1961,18 @@ export function companyPortabilityService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = buildManifestFromPackageFiles(files);
|
const resolved = buildManifestFromPackageFiles(files);
|
||||||
|
const companyLogoPath = resolved.manifest.company?.logoPath;
|
||||||
|
if (companyLogoPath && !resolved.files[companyLogoPath]) {
|
||||||
|
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
|
||||||
|
try {
|
||||||
|
const binary = await fetchBinary(
|
||||||
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath),
|
||||||
|
);
|
||||||
|
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
resolved.warnings.unshift(...warnings);
|
resolved.warnings.unshift(...warnings);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ Authorized managers can install company skills independently of hiring, then ass
|
||||||
- Assign skills to existing agents with `POST /api/agents/{agentId}/skills/sync`.
|
- Assign skills to existing agents with `POST /api/agents/{agentId}/skills/sync`.
|
||||||
- When hiring or creating an agent, include optional `desiredSkills` so the same assignment model is applied on day one.
|
- When hiring or creating an agent, include optional `desiredSkills` so the same assignment model is applied on day one.
|
||||||
|
|
||||||
Keep the detailed workflow out of this hot-path file. For concrete commands and examples, read:
|
If you are asked to install a skill for the company or an agent you MUST read:
|
||||||
`skills/paperclip/references/company-skills.md`
|
`skills/paperclip/references/company-skills.md`
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue