mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Merge remote-tracking branch 'public-gh/master' into paperclip-routines
* public-gh/master: (46 commits) chore(lockfile): refresh pnpm-lock.yaml (#1377) fix: manage codex home per company by default Ensure agent home directories exist before use Handle directory entries in imported zip archives Fix portability import and org chart test blockers Fix PR verify failures after merge fix: address greptile follow-up feedback Address remaining Greptile portability feedback docs: clarify quickstart npx usage Add guarded dev restart handling Fix PAP-576 settings toggles and transcript default Add username log censor setting fix: use standard toggle component for permission controls fix: add missing setPrincipalPermission mock in portability tests fix: use fixed 1280x640 dimensions for org chart export image Adjust default CEO onboarding task copy fix: link Agent Company to agentcompanies.io in export README fix: strip agents and projects sections from COMPANY.md export body fix: default company export page to README.md instead of first file Add default agent instructions bundle ... # Conflicts: # packages/adapters/pi-local/src/server/execute.ts # packages/db/src/migrations/meta/0039_snapshot.json # packages/db/src/migrations/meta/_journal.json # server/src/__tests__/agent-permissions-routes.test.ts # server/src/__tests__/agent-skills-routes.test.ts # server/src/services/company-portability.ts # skills/paperclip/references/company-skills.md # ui/src/api/agents.ts
This commit is contained in:
commit
e3c92a20f1
96 changed files with 15366 additions and 1684 deletions
|
|
@ -6,6 +6,9 @@ import {
|
|||
|
||||
describe("normalizeRememberedInstanceSettingsPath", () => {
|
||||
it("keeps known instance settings pages", () => {
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe(
|
||||
"/instance/settings/general",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
|
||||
"/instance/settings/experimental",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
|
||||
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
|
||||
|
||||
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
|
||||
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||
|
|
@ -9,6 +9,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
|
|||
const hash = match?.[3] ?? "";
|
||||
|
||||
if (
|
||||
pathname === "/instance/settings/general" ||
|
||||
pathname === "/instance/settings/heartbeats" ||
|
||||
pathname === "/instance/settings/plugins" ||
|
||||
pathname === "/instance/settings/experimental"
|
||||
|
|
|
|||
40
ui/src/lib/legacy-agent-config.test.ts
Normal file
40
ui/src/lib/legacy-agent-config.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasLegacyWorkingDirectory,
|
||||
shouldShowLegacyWorkingDirectoryField,
|
||||
} from "./legacy-agent-config";
|
||||
|
||||
describe("legacy agent config helpers", () => {
|
||||
it("treats non-empty cwd values as legacy working directories", () => {
|
||||
expect(hasLegacyWorkingDirectory("/tmp/workspace")).toBe(true);
|
||||
expect(hasLegacyWorkingDirectory(" /tmp/workspace ")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores nullish and blank cwd values", () => {
|
||||
expect(hasLegacyWorkingDirectory("")).toBe(false);
|
||||
expect(hasLegacyWorkingDirectory(" ")).toBe(false);
|
||||
expect(hasLegacyWorkingDirectory(null)).toBe(false);
|
||||
expect(hasLegacyWorkingDirectory(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the deprecated field only for edit forms with an existing cwd", () => {
|
||||
expect(
|
||||
shouldShowLegacyWorkingDirectoryField({
|
||||
isCreate: true,
|
||||
adapterConfig: { cwd: "/tmp/workspace" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowLegacyWorkingDirectoryField({
|
||||
isCreate: false,
|
||||
adapterConfig: { cwd: "" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowLegacyWorkingDirectoryField({
|
||||
isCreate: false,
|
||||
adapterConfig: { cwd: "/tmp/workspace" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
17
ui/src/lib/legacy-agent-config.ts
Normal file
17
ui/src/lib/legacy-agent-config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
function asNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function hasLegacyWorkingDirectory(value: unknown): boolean {
|
||||
return asNonEmptyString(value) !== null;
|
||||
}
|
||||
|
||||
export function shouldShowLegacyWorkingDirectoryField(input: {
|
||||
isCreate: boolean;
|
||||
adapterConfig: Record<string, unknown> | null | undefined;
|
||||
}): boolean {
|
||||
if (input.isCreate) return false;
|
||||
return hasLegacyWorkingDirectory(input.adapterConfig?.cwd);
|
||||
}
|
||||
|
|
@ -86,6 +86,7 @@ export const queryKeys = {
|
|||
session: ["auth", "session"] as const,
|
||||
},
|
||||
instance: {
|
||||
generalSettings: ["instance", "general-settings"] as const,
|
||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
experimentalSettings: ["instance", "experimental-settings"] as const,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createZipArchive, readZipArchive } from "./zip";
|
||||
|
||||
|
|
@ -20,6 +21,167 @@ function readString(bytes: Uint8Array, offset: number, length: number) {
|
|||
return new TextDecoder().decode(bytes.slice(offset, offset + length));
|
||||
}
|
||||
|
||||
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
target[offset + 2] = (value >>> 16) & 0xff;
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createDeflatedZipArchive(files: Record<string, string>, rootPath: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
let entryCount = 0;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||
const rawBody = encoder.encode(content);
|
||||
const deflatedBody = new Uint8Array(deflateRawSync(rawBody));
|
||||
const checksum = crc32(rawBody);
|
||||
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, 8);
|
||||
writeUint32(localHeader, 14, checksum);
|
||||
writeUint32(localHeader, 18, deflatedBody.length);
|
||||
writeUint32(localHeader, 22, rawBody.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, 8);
|
||||
writeUint32(centralHeader, 16, checksum);
|
||||
writeUint32(centralHeader, 20, deflatedBody.length);
|
||||
writeUint32(centralHeader, 24, rawBody.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, deflatedBody);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + deflatedBody.length;
|
||||
entryCount += 1;
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(
|
||||
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entryCount);
|
||||
writeUint16(archive, offset + 10, entryCount);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
|
||||
return archive;
|
||||
}
|
||||
|
||||
function createZipArchiveWithDirectoryEntries(rootPath: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const entries = [
|
||||
{ path: `${rootPath}/`, body: new Uint8Array(0), compressionMethod: 0 },
|
||||
{ path: `${rootPath}/agents/`, body: new Uint8Array(0), compressionMethod: 0 },
|
||||
{ path: `${rootPath}/agents/ceo/`, body: new Uint8Array(0), compressionMethod: 0 },
|
||||
{ path: `${rootPath}/COMPANY.md`, body: encoder.encode("# Company\n"), compressionMethod: 8 },
|
||||
{ path: `${rootPath}/agents/ceo/AGENTS.md`, body: encoder.encode("# CEO\n"), compressionMethod: 8 },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
data: entry.compressionMethod === 8 ? new Uint8Array(deflateRawSync(entry.body)) : entry.body,
|
||||
checksum: crc32(entry.body),
|
||||
}));
|
||||
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const fileName = encoder.encode(entry.path);
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, entry.compressionMethod);
|
||||
writeUint32(localHeader, 14, entry.checksum);
|
||||
writeUint32(localHeader, 18, entry.data.length);
|
||||
writeUint32(localHeader, 22, entry.body.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, entry.compressionMethod);
|
||||
writeUint32(centralHeader, 16, entry.checksum);
|
||||
writeUint32(centralHeader, 20, entry.data.length);
|
||||
writeUint32(centralHeader, 24, entry.body.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, entry.data);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + entry.data.length;
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(
|
||||
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entries.length);
|
||||
writeUint16(archive, offset + 10, entries.length);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
|
||||
return archive;
|
||||
}
|
||||
|
||||
describe("createZipArchive", () => {
|
||||
it("writes a zip archive with the export root path prefixed into each entry", () => {
|
||||
const archive = createZipArchive(
|
||||
|
|
@ -51,7 +213,7 @@ describe("createZipArchive", () => {
|
|||
expect(readUint16(archive, endOffset + 10)).toBe(2);
|
||||
});
|
||||
|
||||
it("reads a Paperclip zip archive back into rootPath and file contents", () => {
|
||||
it("reads a Paperclip zip archive back into rootPath and file contents", async () => {
|
||||
const archive = createZipArchive(
|
||||
{
|
||||
"COMPANY.md": "# Company\n",
|
||||
|
|
@ -61,7 +223,7 @@ describe("createZipArchive", () => {
|
|||
"paperclip-demo",
|
||||
);
|
||||
|
||||
expect(readZipArchive(archive)).toEqual({
|
||||
await expect(readZipArchive(archive)).resolves.toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": "# Company\n",
|
||||
|
|
@ -71,7 +233,7 @@ describe("createZipArchive", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("round-trips binary image files without coercing them to text", () => {
|
||||
it("round-trips binary image files without coercing them to text", async () => {
|
||||
const archive = createZipArchive(
|
||||
{
|
||||
"images/company-logo.png": {
|
||||
|
|
@ -83,7 +245,7 @@ describe("createZipArchive", () => {
|
|||
"paperclip-demo",
|
||||
);
|
||||
|
||||
expect(readZipArchive(archive)).toEqual({
|
||||
await expect(readZipArchive(archive)).resolves.toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"images/company-logo.png": {
|
||||
|
|
@ -94,4 +256,34 @@ describe("createZipArchive", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads standard DEFLATE zip archives created outside Paperclip", async () => {
|
||||
const archive = createDeflatedZipArchive(
|
||||
{
|
||||
"COMPANY.md": "# Company\n",
|
||||
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||
},
|
||||
"paperclip-demo",
|
||||
);
|
||||
|
||||
await expect(readZipArchive(archive)).resolves.toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": "# Company\n",
|
||||
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores directory entries from standard zip archives", async () => {
|
||||
const archive = createZipArchiveWithDirectoryEntries("paperclip-demo");
|
||||
|
||||
await expect(readZipArchive(archive)).resolves.toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": "# Company\n",
|
||||
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,10 +136,24 @@ function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Arra
|
|||
return base64ToBytes(entry.data);
|
||||
}
|
||||
|
||||
export function readZipArchive(source: ArrayBuffer | Uint8Array): {
|
||||
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
|
||||
if (compressionMethod === 0) return bytes;
|
||||
if (compressionMethod !== 8) {
|
||||
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
|
||||
}
|
||||
if (typeof DecompressionStream !== "function") {
|
||||
throw new Error("Unsupported zip archive: this browser cannot read compressed zip entries.");
|
||||
}
|
||||
const body = new Uint8Array(bytes.byteLength);
|
||||
body.set(bytes);
|
||||
const stream = new Blob([body]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
|
||||
rootPath: string | null;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
} {
|
||||
}> {
|
||||
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
||||
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
|
||||
let offset = 0;
|
||||
|
|
@ -164,9 +178,6 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
|
|||
if ((generalPurposeFlag & 0x0008) !== 0) {
|
||||
throw new Error("Unsupported zip archive: data descriptors are not supported.");
|
||||
}
|
||||
if (compressionMethod !== 0) {
|
||||
throw new Error("Unsupported zip archive: only uncompressed entries are supported.");
|
||||
}
|
||||
|
||||
const nameOffset = offset + 30;
|
||||
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
|
||||
|
|
@ -175,13 +186,14 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
|
|||
throw new Error("Invalid zip archive: truncated file contents.");
|
||||
}
|
||||
|
||||
const archivePath = normalizeArchivePath(
|
||||
textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)),
|
||||
);
|
||||
if (archivePath && !archivePath.endsWith("/")) {
|
||||
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
|
||||
const archivePath = normalizeArchivePath(rawArchivePath);
|
||||
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
|
||||
if (archivePath && !isDirectoryEntry) {
|
||||
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
|
||||
entries.push({
|
||||
path: archivePath,
|
||||
body: bytesToPortableFileEntry(archivePath, bytes.slice(bodyOffset, bodyEnd)),
|
||||
body: bytesToPortableFileEntry(archivePath, entryBytes),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue