diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index ac0726ad..7a0aea51 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -40,7 +40,7 @@ Operational fields: Notes: - 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). - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 2a5c1c55..829cb6a5 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -16,7 +16,6 @@ import { ensurePathInEnv, readPaperclipRuntimeSkillEntries, resolvePaperclipDesiredSkillNames, - removeMaintainerOnlySkillSymlinks, renderTemplate, joinPromptSections, runChildProcess, @@ -136,6 +135,10 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks( } } +function resolveCodexWorkspaceSkillsDir(cwd: string): string { + return path.join(cwd, ".agents", "skills"); +} + type EnsureCodexSkillsInjectedOptions = { skillsHome?: 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)); 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 }); - 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; for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.runtimeName); @@ -279,10 +272,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; -} - -function resolveCodexSkillsHome(config: Record, companyId?: string) { - const env = - typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) - ? (config.env as Record) - : {}; - const configuredCodexHome = asString(env.CODEX_HOME); - const home = configuredCodexHome - ? path.resolve(configuredCodexHome) - : resolveCodexHomeDir(process.env, companyId); - return path.join(home, "skills"); -} - async function buildCodexSkillSnapshot( config: Record, - companyId?: string, ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - const skillsHome = resolveCodexSkillsHome(config, companyId); - const installed = await readInstalledSkillTargets(skillsHome); - return buildPersistentSkillSnapshot({ + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + 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", - availableEntries, + supported: true, + mode: "ephemeral", desiredSkills, - installed, - skillsHome, - 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.", - }); + entries, + warnings, + }; } export async function listCodexSkills(ctx: AdapterSkillContext): Promise { - return buildCodexSkillSnapshot(ctx.config, ctx.companyId); + return buildCodexSkillSnapshot(ctx.config); } export async function syncCodexSkills( ctx: AdapterSkillContext, - desiredSkills: string[], + _desiredSkills: string[], ): Promise { - const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - 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); + return buildCodexSkillSnapshot(ctx.config); } export function resolveCodexDesiredSkillNames( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 51c94d5e..f2c59875 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -231,6 +231,7 @@ export type { InstanceUserRoleGrant, CompanyPortabilityInclude, CompanyPortabilityEnvInput, + CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index cec0021e..811c88a6 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -15,11 +15,20 @@ export interface CompanyPortabilityEnvInput { portability: "portable" | "system_dependent"; } +export type CompanyPortabilityFileEntry = + | string + | { + encoding: "base64"; + data: string; + contentType?: string | null; + }; + export interface CompanyPortabilityCompanyManifestEntry { path: string; name: string; description: string | null; brandColor: string | null; + logoPath: string | null; requireBoardApprovalForNewAgents: boolean; } @@ -110,7 +119,7 @@ export interface CompanyPortabilityManifest { export interface CompanyPortabilityExportResult { rootPath: string; manifest: CompanyPortabilityManifest; - files: Record; + files: Record; warnings: string[]; paperclipExtensionPath: string; } @@ -123,7 +132,7 @@ export interface CompanyPortabilityExportPreviewFile { export interface CompanyPortabilityExportPreviewResult { rootPath: string; manifest: CompanyPortabilityManifest; - files: Record; + files: Record; fileInventory: CompanyPortabilityExportPreviewFile[]; counts: { files: number; @@ -140,7 +149,7 @@ export type CompanyPortabilitySource = | { type: "inline"; rootPath?: string | null; - files: Record; + files: Record; } | { type: "github"; @@ -207,7 +216,7 @@ export interface CompanyPortabilityPreviewResult { issuePlans: CompanyPortabilityPreviewIssuePlan[]; }; manifest: CompanyPortabilityManifest; - files: Record; + files: Record; envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; errors: string[]; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8d4a2832..957e9158 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -129,6 +129,7 @@ export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { CompanyPortabilityInclude, CompanyPortabilityEnvInput, + CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 9cdc6a52..c45eed6a 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -19,11 +19,21 @@ export const portabilityEnvInputSchema = z.object({ 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({ path: z.string().min(1), name: z.string().min(1), description: z.string().nullable(), brandColor: z.string().nullable(), + logoPath: z.string().nullable(), requireBoardApprovalForNewAgents: z.boolean(), }); @@ -122,7 +132,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("inline"), rootPath: z.string().min(1).optional().nullable(), - files: z.record(z.string()), + files: z.record(portabilityFileEntrySchema), }), z.object({ type: z.literal("github"), diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 9291791c..4c9b7c0e 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -56,6 +56,7 @@ describe("codex execute", () => { "company-1", "codex-home", ); + const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip"); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); 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 isolatedConfig = path.join(isolatedCodexHome, "config.toml"); - const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip"); 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.lstat(isolatedConfig)).isFile()).toBe(true); 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.objectContaining({ stream: "stdout", @@ -217,6 +217,7 @@ describe("codex execute", () => { const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; 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(); } finally { if (previousHome === undefined) delete process.env.HOME; diff --git a/server/src/__tests__/codex-local-skill-injection.test.ts b/server/src/__tests__/codex-local-skill-injection.test.ts index 27c1b406..da379ba4 100644 --- a/server/src/__tests__/codex-local-skill-injection.test.ts +++ b/server/src/__tests__/codex-local-skill-injection.test.ts @@ -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")), + ); + }); }); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 93d9cc88..b809ebf8 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -11,13 +11,6 @@ async function makeTempDir(prefix: string): Promise { 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", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; const cleanupDirs = new Set(); @@ -27,7 +20,7 @@ describe("codex local skill sync", () => { 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-"); cleanupDirs.add(codexHome); @@ -46,65 +39,14 @@ describe("codex local skill sync", () => { } as const; const before = await listCodexSkills(ctx); - expect(before.mode).toBe("persistent"); + expect(before.mode).toBe("ephemeral"); expect(before.desiredSkills).toContain(paperclipKey); expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); - - 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); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills"); }); - it("isolates default Codex skills by company when CODEX_HOME comes from process env", 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 () => { + it("does not persist Paperclip skills into CODEX_HOME during sync", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); cleanupDirs.add(codexHome); @@ -122,10 +64,22 @@ describe("codex local skill sync", () => { }, } 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 = { - ...configuredCtx, + it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => { + const codexHome = await makeTempDir("paperclip-codex-skill-required-"); + cleanupDirs.add(codexHome); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "codex_local", config: { env: { CODEX_HOME: codexHome, @@ -136,13 +90,12 @@ describe("codex local skill sync", () => { }, } as const; - const after = await syncCodexSkills(clearedCtx, []); + const after = await syncCodexSkills(configuredCtx, []); expect(after.desiredSkills).toContain(paperclipKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); - expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); }); - 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-"); cleanupDirs.add(codexHome); @@ -163,38 +116,7 @@ describe("codex local skill sync", () => { expect(snapshot.warnings).toEqual([]); expect(snapshot.desiredSkills).toContain(paperclipKey); 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(); }); - - 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.", - })); - }); }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 65e79d73..15ea14b3 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -9,6 +9,7 @@ import type { CompanyPortabilityCollisionStrategy, CompanyPortabilityEnvInput, CompanyPortabilityExport, + CompanyPortabilityFileEntry, CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityImport, @@ -35,9 +36,11 @@ import { writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; import { notFound, unprocessable } from "../errors.js"; +import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; import { agentInstructionsService } from "./agent-instructions.js"; +import { assetService } from "./assets.js"; import { generateReadme } from "./company-export-readme.js"; import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; @@ -323,7 +326,7 @@ function isSensitiveEnvKey(key: string) { type ResolvedSource = { manifest: CompanyPortabilityManifest; - files: Record; + files: Record; warnings: string[]; }; @@ -400,6 +403,16 @@ type EnvInputRecord = { portability?: "portable" | "system_dependent"; }; +const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record = { + "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 }> = [ { path: ["heartbeat", "cooldownSec"], value: 10 }, { path: ["heartbeat", "intervalSec"], value: 3600 }, @@ -524,12 +537,83 @@ function resolvePortablePath(fromPath: string, targetPath: string) { return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/"))); } +function isPortableBinaryFile( + value: CompanyPortabilityFileEntry, +): value is Extract { + return typeof value === "object" && value !== null && value.encoding === "base64" && typeof value.data === "string"; +} + +function readPortableTextFile( + files: Record, + 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) { + 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( - files: Record, + files: Record, rootPath?: string | null, -): Record { +): Record { const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null; - const out: Record = {}; + const out: Record = {}; for (const [rawPath, content] of Object.entries(files)) { let nextPath = normalizePortablePath(rawPath); if (normalizedRoot && nextPath === normalizedRoot) { @@ -627,7 +711,7 @@ function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { } function filterExportFiles( - files: Record, + files: Record, selectedFilesInput: string[] | undefined, paperclipExtensionPath: string, ) { @@ -640,20 +724,21 @@ function filterExportFiles( .map((entry) => normalizePortablePath(entry)) .filter((entry) => entry.length > 0), ); - const filtered: Record = {}; + const filtered: Record = {}; for (const [filePath, content] of Object.entries(files)) { if (!selectedFiles.has(filePath)) continue; filtered[filePath] = content; } - if (selectedFiles.has(paperclipExtensionPath) && filtered[paperclipExtensionPath]) { - filtered[paperclipExtensionPath] = filterPortableExtensionYaml(filtered[paperclipExtensionPath]!, selectedFiles); + const extensionEntry = filtered[paperclipExtensionPath]; + if (selectedFiles.has(paperclipExtensionPath) && typeof extensionEntry === "string") { + filtered[paperclipExtensionPath] = filterPortableExtensionYaml(extensionEntry, selectedFiles); } return filtered; } -function findPaperclipExtensionPath(files: Record) { +function findPaperclipExtensionPath(files: Record) { if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml"; if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml"; 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(); } +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(url: string): Promise { const response = await fetch(url, { headers: { @@ -1425,13 +1518,13 @@ function readAgentSkillRefs(frontmatter: Record) { } function buildManifestFromPackageFiles( - files: Record, + files: Record, opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, ): ResolvedSource { const normalizedFiles = normalizeFileMap(files); - const companyPath = - normalizedFiles["COMPANY.md"] - ?? undefined; + const companyPath = typeof normalizedFiles["COMPANY.md"] === "string" + ? normalizedFiles["COMPANY.md"] + : undefined; const resolvedCompanyPath = companyPath !== undefined ? "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"); } - 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 paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles); const paperclipExtension = paperclipExtensionPath - ? parseYamlFile(normalizedFiles[paperclipExtensionPath] ?? "") + ? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "") : {}; const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {}; const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; @@ -1503,6 +1600,7 @@ function buildManifestFromPackageFiles( name: companyName, description: asString(companyFrontmatter.description), brandColor: asString(paperclipCompany.brandColor), + logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo), requireBoardApprovalForNewAgents: typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean" ? paperclipCompany.requireBoardApprovalForNewAgents @@ -1516,8 +1614,11 @@ function buildManifestFromPackageFiles( }; 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) { - const markdownRaw = normalizedFiles[agentPath]; + const markdownRaw = readPortableTextFile(normalizedFiles, agentPath); if (typeof markdownRaw !== "string") { warnings.push(`Referenced agent file is missing from package: ${agentPath}`); continue; @@ -1566,7 +1667,7 @@ function buildManifestFromPackageFiles( } for (const skillPath of skillPaths) { - const markdownRaw = normalizedFiles[skillPath]; + const markdownRaw = readPortableTextFile(normalizedFiles, skillPath); if (typeof markdownRaw !== "string") { warnings.push(`Referenced skill file is missing from package: ${skillPath}`); continue; @@ -1651,7 +1752,7 @@ function buildManifestFromPackageFiles( } for (const projectPath of projectPaths) { - const markdownRaw = normalizedFiles[projectPath]; + const markdownRaw = readPortableTextFile(normalizedFiles, projectPath); if (typeof markdownRaw !== "string") { warnings.push(`Referenced project file is missing from package: ${projectPath}`); continue; @@ -1685,7 +1786,7 @@ function buildManifestFromPackageFiles( } for (const taskPath of taskPaths) { - const markdownRaw = normalizedFiles[taskPath]; + const markdownRaw = readPortableTextFile(normalizedFiles, taskPath); if (typeof markdownRaw !== "string") { warnings.push(`Referenced task file is missing from package: ${taskPath}`); continue; @@ -1773,9 +1874,10 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: 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 agents = agentService(db); + const assetRecords = assetService(db); const instructions = agentInstructionsService(); const access = accessService(db); const projects = projectService(db); @@ -1818,7 +1920,7 @@ export function companyPortabilityService(db: Db) { const companyPath = parsed.companyPath === "COMPANY.md" ? "COMPANY.md" : normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath)); - const files: Record = { + const files: Record = { [companyPath]: companyMarkdown, }; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( @@ -1859,6 +1961,18 @@ export function companyPortabilityService(db: Db) { } 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); return resolved; } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index ae052dca..05b42cfb 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -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`. - 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` ## Critical Rules