mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent pages expose identity files, reporting relationships, and
runtime settings.
> - Several controls had inconsistent spacing, weak navigation, or
misleading save feedback.
> - Empty task-row detail slots also reduced the title width before
truncation.
> - This pull request fixes those behaviors and aligns the reviewed UI
details.
> - The result is clearer agent editing and more readable task and agent
lists.
## Linked Issues or Issue Description
**What happened?**
Agent headers and file controls had inconsistent layout. The Identity
footer appeared without edits and did not provide clear save
confirmation. File names could wrap or omit the Markdown suffix. Task
titles truncated while empty detail slots occupied the row.
**Expected behavior**
Keep headers and actions aligned. Reveal the Identity footer only for
changes or save feedback. Use valid Markdown file names and actual file
dates. Let titles use available space.
**Steps to reproduce**
1. Open an agent overview, then its Identity tab.
2. Edit, undo, save, copy, and add an instruction file.
3. Resize the file panel and inspect file names and date labels.
4. Hover an agent row and inspect the Chat action and tooltip.
5. Open a task list with no recovery or external details and inspect
title width.
Related: #18716 is the prior UI bundle. #16934 and #14483 concern role
editing; this change does not add a role picker.
## What Changed
- Refine the character header, inline title and name editing, crown
position, runtime shortcut, and Chat/New task actions.
- Align profile metadata and manager selection; hide empty direct
reports.
- Refine Identity panels, Markdown filename validation, file-row hover,
truncation, Paper Mono typography, and copy feedback.
- Add a viewport-bound save footer with dirty, saving, and saved states.
Keep undo/discard separate from successful saves.
- Return recorded creation or modification dates for instruction files.
Reuse the active file transaction for metadata reads to avoid exhausting
the small connection pool.
- Reveal the agent-row Chat icon and label on hover, with a standard
delayed tooltip.
- Collapse empty task-row details so titles can use the available width.
## Verification
- Browser checks covered the local overview and Identity flows, narrow
file panels, successful file creation/update, undo/discard, header
navigation, row hover, tooltip delayed-open state, and full task titles.
- Focused instruction, save-footer, date, font-asset, and task-row tests
passed during development. After review, 55 file/instruction tests
passed, including cold restoration with recorded and unknown dates;
server typecheck and 18 immediate-save response tests also passed.
- Full `pnpm -r typecheck` and `pnpm build` passed after rebase onto
current master. The complete `pnpm test:run` attempt hit a 15-second
timeout in the existing chat Retry HTTP integration test; that case
passed in isolation (1 test passed). A second full attempt hit timeouts
in two other chat integration cases (GitHub webhook draining and
delivery retry budget); both passed in isolation without increasing
timeouts. All 55 remote checks passed on f31405a730, including the full
test matrix, build, typecheck, and final verification gate.
- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `git diff
--check` passed on the submitted revision.
## Risks
- File dates are optional. Managed files require recorded dates because
restored projections have synthetic filesystem timestamps. External
files can use modification time; unknown dates are omitted.
- Save-footer timing depends on animation completion; reduced-motion and
failed-save paths have tests.
- Global Paper Mono typography can change text widths. Browser checks
covered the edited surfaces.
- No schema migration or new execution permissions are introduced.
## Model Used
OpenAI Codex, GPT-6, with repository tools, code execution, and browser
verification. The exact serving model ID and context-window size are not
exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes:` / `Closes:`
/ `Refs:` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run the relevant tests locally and they pass; full-run
timeout limitations and successful isolated reruns are documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
288 lines
23 KiB
TypeScript
288 lines
23 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns,
|
|
agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentFileStore } from "../services/agent-file-store.js";
|
|
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
|
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
|
|
import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js";
|
|
import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
|
import type { AuthorizationActor } from "../services/authorization.js";
|
|
import { upsertAgentInstructionsFileSchema } from "@paperclipai/shared";
|
|
|
|
describe("canonical instruction revisions", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let service: ReturnType<typeof agentInstructionRevisionService>;
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
let home: string;
|
|
let companyId: string, agentId: string, userId: string, actorId: string, runId: string, root: string;
|
|
let actor: AuthorizationActor;
|
|
const entryFile = "policy/INSTRUCTIONS.txt";
|
|
const initial = "\uFEFF# Original\r\n☃\0\n";
|
|
beforeAll(async () => {
|
|
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-revisions-")));
|
|
process.env.PAPERCLIP_HOME = home;
|
|
database = await startEmbeddedPostgresTestDatabase("instruction-revisions-db-");
|
|
db = createDb(database.connectionString);
|
|
service = agentInstructionRevisionService(db);
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
vi.restoreAllMocks();
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
await database?.cleanup();
|
|
if (home) await fs.rm(home, { recursive: true, force: true });
|
|
});
|
|
beforeEach(async () => {
|
|
companyId = randomUUID(); agentId = randomUUID(); actorId = randomUUID(); userId = randomUUID(); runId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
|
|
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "Target", adapterConfig: {} });
|
|
await db.insert(agents).values([
|
|
{ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
|
|
{ id: actorId, companyId, name: "Writer" },
|
|
]);
|
|
await db.insert(companyMemberships).values([
|
|
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
|
|
{ companyId, principalType: "agent", principalId: actorId, membershipRole: "member" },
|
|
]);
|
|
await db.insert(principalPermissionGrants).values([
|
|
{ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
|
|
{ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
|
|
]);
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: actorId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
actor = { type: "agent", companyId, agentId: actorId, runId, source: "agent_jwt" };
|
|
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
|
|
await fs.writeFile(path.join(root, entryFile), initial);
|
|
});
|
|
const target = () => ({ companyId, agentId });
|
|
async function base() { return (await service.readCurrent(target(), actor))!; }
|
|
async function save(content: string, baseRevisionId: string | null) {
|
|
return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
|
|
}
|
|
it("exposes recorded creation dates and update dates for root and supporting files", async () => {
|
|
const files = agentFileStore(db);
|
|
const instructions = agentInstructionsService(db);
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
const supporting = "NOTES.md";
|
|
const created = await files.write({ ...target(), path: supporting, bytes: Buffer.from("first"), baseHash: null }, actor);
|
|
const first = await instructions.readFile(agent!, supporting);
|
|
expect(Number.isFinite(Date.parse(first.createdAt!))).toBe(true);
|
|
expect(first.updatedAt).toBeUndefined();
|
|
await files.write({ ...target(), path: supporting, bytes: Buffer.from("second"), baseHash: created.contentHash }, actor);
|
|
// The file-storage pool has two connections. Concurrent locked reads must
|
|
// reuse their transactions for metadata rather than wait on a third lease.
|
|
const [updated] = await Promise.all([
|
|
instructions.readFile(agent!, supporting), instructions.getBundle(agent!),
|
|
instructions.readFile(agent!, supporting), instructions.getBundle(agent!),
|
|
]);
|
|
expect(updated.createdAt).toBeUndefined();
|
|
expect(Date.parse(updated.updatedAt!)).toBeGreaterThanOrEqual(Date.parse(first.createdAt!));
|
|
// The root file uses the revision-compatible API, with the same recorded dates.
|
|
await fs.unlink(path.join(root, entryFile));
|
|
const rootCreated = await save("new root", null);
|
|
expect(await instructions.getFileDates(agent!, entryFile, rootCreated.revision.contentHash)).toHaveProperty("createdAt");
|
|
const rootUpdated = await save("updated root", rootCreated.revision.id);
|
|
expect(await instructions.getFileDates(agent!, entryFile, rootUpdated.revision.contentHash)).toHaveProperty("updatedAt");
|
|
});
|
|
it("reads peer instructions with read-only agent and responsible-user grants but denies saves", async () => {
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.insert(principalPermissionGrants).values([
|
|
{ companyId, principalType: "user", principalId: userId, permissionKey: "agents:read", scope: { agentIds: [agentId] } },
|
|
{ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:read", scope: { agentIds: [agentId] } },
|
|
]);
|
|
const first = await base();
|
|
expect(first.content).toBe(initial);
|
|
await expect(service.history({ ...target(), entryFile }, actor)).resolves.toMatchObject({ revisions: [] });
|
|
await expect(save("read access cannot write", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
|
|
await expect(base()).rejects.toMatchObject({ status: 403 });
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
});
|
|
it.each(["current", "history", "revision", "diff", "candidates"])("denies peer %s reads without a configuration grant or responsible-user target access", async (operation) => {
|
|
const first = await base();
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
|
|
const read = () => {
|
|
if (operation === "current") return service.readCurrent(target(), actor);
|
|
if (operation === "history") return service.history({ ...target(), entryFile }, actor);
|
|
if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
|
|
if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
|
|
return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
|
|
};
|
|
await expect(read()).rejects.toMatchObject({ status: 403 });
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
|
|
await expect(read()).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
it.each(["current", "history", "revision", "diff", "candidates", "write"])("requires the caller's own target-scoped grant for peer %s access", async (operation) => {
|
|
const first = await base();
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
|
|
const access = () => {
|
|
if (operation === "current") return service.readCurrent(target(), actor);
|
|
if (operation === "history") return service.history({ ...target(), entryFile }, actor);
|
|
if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
|
|
if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
|
|
if (operation === "write") return save("authorized peer edit", first.revision.id);
|
|
return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
|
|
};
|
|
// The responsible user retains permission on the target throughout.
|
|
await expect(access()).rejects.toMatchObject({ status: 403 });
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
|
|
await expect(access()).rejects.toMatchObject({ status: 403 });
|
|
await db.update(principalPermissionGrants).set({ scope: { agentIds: [agentId] } }).where(eq(principalPermissionGrants.principalId, actorId));
|
|
// New saves have no historical revision row; authorized history lookups
|
|
// reach the normal not-found response rather than an authorization denial.
|
|
if (operation === "revision" || operation === "diff") await expect(access()).rejects.toMatchObject({ status: 404 });
|
|
else await expect(access()).resolves.toBeDefined();
|
|
});
|
|
it("allows self and board reads without requiring instruction edit permission", async () => {
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
|
|
const selfRoot = resolveManagedInstructionsRoot({ id: actorId, companyId, name: "Writer", adapterConfig: {} });
|
|
await fs.mkdir(selfRoot, { recursive: true });
|
|
await fs.writeFile(path.join(selfRoot, "AGENTS.md"), "Self instructions");
|
|
expect((await service.readCurrent({ companyId, agentId: actorId }, actor))?.content).toBe("Self instructions");
|
|
expect((await service.readCurrent(target(), { type: "board", userId, source: "session" }))?.content).toBe(initial);
|
|
});
|
|
it("reads and replaces current bytes using content tokens without storing revisions", async () => {
|
|
const first = await base();
|
|
expect(first.content).toBe(initial);
|
|
const saved = await save("# Changed\r\n", first.revision.id);
|
|
expect(saved).toMatchObject({ changed: true, materialization: "current" });
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(saved.content);
|
|
expect((await save(saved.content, first.revision.id)).changed).toBe(false);
|
|
expect((await service.history({ ...target(), entryFile }, actor)).revisions).toHaveLength(0);
|
|
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
|
|
});
|
|
it("requires a matching initial content token and serializes competing saves", async () => {
|
|
await expect(save("without reading", null)).rejects.toMatchObject({ status: 409 });
|
|
const first = await base();
|
|
const results = await Promise.allSettled([save("one", first.revision.id), save("two", first.revision.id)]);
|
|
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
|
expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { status: 409 } });
|
|
});
|
|
it("does not claim a save when atomic file replacement fails", async () => {
|
|
const first = await base();
|
|
const rename = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("interrupted replacement"));
|
|
await expect(save("new content", first.revision.id)).rejects.toThrow("interrupted replacement");
|
|
rename.mockRestore();
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
service = agentInstructionRevisionService(db);
|
|
await service.materializeCurrent(target());
|
|
expect((await base()).content).toBe(initial);
|
|
expect((await save("new content", first.revision.id)).content).toBe("new content");
|
|
});
|
|
it("allows authorized peer edits but rejects missing, forged and revoked responsible identity", async () => {
|
|
const first = await base();
|
|
await save("peer save", first.revision.id);
|
|
await expect(service.commit({ ...target(), entryFile, content: "forged", baseRevisionId: first.revision.id, source: "tool" }, { ...actor, onBehalfOfUserId: "forged" })).rejects.toMatchObject({ status: 403 });
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, runId));
|
|
await expect(save("missing", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, runId));
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.principalId, userId), eq(companyMemberships.companyId, companyId)));
|
|
await expect(save("revoked", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
it("rechecks user target scope and agent containment, even with shadow authorization enabled", async () => {
|
|
const first = await base();
|
|
const previousShadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW;
|
|
process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = "true";
|
|
await db.update(principalPermissionGrants).set({ scope: { agentIds: [actorId] } }).where(eq(principalPermissionGrants.principalId, userId));
|
|
await expect(save("denied", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
await db.update(principalPermissionGrants).set({ scope: null }).where(eq(principalPermissionGrants.principalId, userId));
|
|
await db.update(agents).set({ permissions: { trustPreset: "low_trust_review" } }).where(eq(agents.id, actorId));
|
|
await expect(save("restricted", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
if (previousShadow === undefined) delete process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW; else process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = previousShadow;
|
|
});
|
|
it("keeps suggest-only protected-change consent and explicit configure scope restrictions", async () => {
|
|
const first = await base();
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
|
|
await expect(save("needs approval", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
|
|
await expect(save("out of scope", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
it("consumes accepted protected-change approval only with a successful CAS commit", async () => {
|
|
const first = await base();
|
|
const sourceRunId = randomUUID(), issueId = randomUUID(), interactionId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId: actorId, status: "succeeded" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Review instructions" });
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
|
|
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, kind: "request_confirmation", status: "accepted", sourceRunId, createdByAgentId: actorId,
|
|
payload: { version: 1, prompt: "Apply the instruction change?", detailsMarkdown: "```diff\n+approved\n```", target: { type: "custom", key: `agent:${agentId}:instructions`, revisionId: "proposal" } },
|
|
result: { version: 1, outcome: "accepted" }, resolvedByUserId: userId, resolvedAt: new Date(),
|
|
});
|
|
expect((await service.readCurrent(target(), actor))?.revision.id).toBe(first.revision.id);
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
|
|
await expect(save("approved", randomUUID())).rejects.toMatchObject({ status: 409 });
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
|
|
const receipt = await save("approved", first.revision.id);
|
|
expect(receipt.changed).toBe(true);
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).toMatchObject({ consumedByRunId: runId });
|
|
await expect(save("another unapproved change", receipt.revision.id)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
it("shares head/CAS and attribution between board and agent writers", async () => {
|
|
const first = await base();
|
|
const board = { type: "board", userId, source: "session" } as const;
|
|
const saved = await service.commit({ ...target(), entryFile, content: "board edit", baseRevisionId: first.revision.id, source: "board" }, board);
|
|
expect(saved.revision).toMatchObject({ actorUserId: userId, responsibleUserId: userId, actorAgentId: null, sourceRunId: null, source: "api" });
|
|
await expect(save("stale agent edit", first.revision.id)).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
it("seeds a nested legacy managed entry and never imports a different AGENTS.md", async () => {
|
|
await fs.writeFile(path.join(root, "AGENTS.md"), "unrelated");
|
|
await db.update(agents).set({ adapterConfig: { instructionsFilePath: path.join(root, entryFile) } }).where(eq(agents.id, agentId));
|
|
const first = await base();
|
|
expect(first.content).toBe(initial);
|
|
expect(first.revision.entryFile).toBe(entryFile);
|
|
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: "missing.md" } }).where(eq(agents.id, agentId));
|
|
expect(await service.readCurrent(target(), actor)).toBeNull();
|
|
await expect(save("wrong entry", first.revision.id)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
|
|
});
|
|
it("rejects cross-company targets, foreign revisions, and revoked API keys", async () => {
|
|
const first = await base();
|
|
await expect(service.readRevision({ ...target(), entryFile, revisionId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
|
|
await expect(service.readCurrent({ companyId: randomUUID(), agentId }, actor)).rejects.toMatchObject({ status: 404 });
|
|
const keyId = randomUUID();
|
|
await db.insert(agentApiKeys).values({ id: keyId, companyId, agentId: actorId, name: "test", keyHash: randomUUID(), responsibleUserId: userId, revokedAt: new Date() });
|
|
await expect(service.commit({ ...target(), entryFile, content: "key", baseRevisionId: first.revision.id, source: "api" }, { ...actor, keyId })).rejects.toMatchObject({ status: 403 });
|
|
await expect(db.insert(agentInstructionHeads).values({ companyId: randomUUID(), agentId, entryFile, revisionId: first.revision.id })).rejects.toThrow();
|
|
});
|
|
it("rejects invalid content, symlinks, traversal and external bundles without changing canonical bytes", async () => {
|
|
const first = await base();
|
|
expect(() => instructionBytes("\ud800")).toThrow("UTF-8");
|
|
expect(() => instructionBytes(new Uint8Array([0xff]))).toThrow("UTF-8");
|
|
expect(() => instructionBytes("a".repeat(1024 * 1024 + 1))).toThrow("1 MiB");
|
|
for (const value of ["../AGENTS.md", "/etc/passwd", "a/../AGENTS.md", "a\\b", "a//b"]) expect(() => instructionPath(value)).toThrow();
|
|
await fs.unlink(path.join(root, entryFile));
|
|
await fs.symlink(path.join(home, "outside"), path.join(root, entryFile));
|
|
await expect(save("symlink", first.revision.id)).rejects.toMatchObject({ status: 422 });
|
|
await fs.unlink(path.join(root, entryFile));
|
|
await fs.rm(path.dirname(path.join(root, entryFile)), { recursive: true });
|
|
await fs.symlink(home, path.dirname(path.join(root, entryFile)));
|
|
await expect(readInstructionBytes(root, entryFile)).rejects.toMatchObject({ status: 422 });
|
|
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "external", instructionsRootPath: home } }).where(eq(agents.id, agentId));
|
|
await expect(save("external", first.revision.id)).rejects.toMatchObject({ details: { code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" } });
|
|
expect((await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId)))).toHaveLength(0);
|
|
});
|
|
it.skipIf(process.platform !== "darwin")("accepts macOS temporary directory aliases while rejecting links within storage", async () => {
|
|
const aliasRoot = home.replace(/^\/private\/(var|tmp)\//, "/$1/");
|
|
expect(aliasRoot).not.toBe(home);
|
|
await materializeInstructionBytes(aliasRoot, "alias/AGENTS.md", Buffer.from("persisted"));
|
|
expect(await readInstructionBytes(aliasRoot, "alias/AGENTS.md")).toEqual(Buffer.from("persisted"));
|
|
await fs.symlink(path.join(home, "alias"), path.join(home, "linked-agent"));
|
|
await expect(readInstructionBytes(path.join(aliasRoot, "linked-agent"), "AGENTS.md"))
|
|
.rejects.toMatchObject({ status: 422 });
|
|
});
|
|
it("requires the content API for the configured entry and rejects forged fields", async () => {
|
|
await base();
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
await expect(agentInstructionsService(db).writeFile(agent, entryFile, "bypass")).rejects.toMatchObject({ details: { code: "INSTRUCTION_REVISION_REQUIRED" } });
|
|
await expect(agentInstructionsService(db).deleteFile(agent, entryFile)).rejects.toMatchObject({ status: 422 });
|
|
expect(upsertAgentInstructionsFileSchema.safeParse({ path: entryFile, content: "bad", responsibleUserId: userId }).success).toBe(false);
|
|
});
|
|
});
|