Files
PaperClipAI/server/src/__tests__/agent-instruction-revisions.test.ts
T
DottaandPaperclip 86b365f992 feat(auth): add independent agent configuration read permission (#18766)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need to inspect configuration and instructions when they set
up teammates.
> - Configuration reads currently depend on configure or suggestion
grants.
> - Legacy standard agents did not receive those newer grants. Some
self-read routes also lose the target agent identity.
> - This pull request adds an independent read grant and applies only
that grant to eligible existing agents.
> - The benefit is that agents can inspect and verify configuration
without new write authority.

## Linked Issues or Issue Description

Refs #15283. Related earlier proposals: #8064 and #12050. Those
proposals target older authorization code; this change uses the current
configure/suggest model and adds a read-only rollout for existing
agents. Related bounded peer metadata proposal: #14160.

**What existing behavior does this improve?**

Agent configuration, configured skill, instruction, and revision reads
through the existing APIs and native instruction tools.

**Current behavior**

An agent needs `agents:configure` or `agents:suggest-changes` to inspect
a peer. Older standard agents can hire a teammate but cannot verify the
saved instructions. Configuration and revision routes also reject
self-reads because they authorize a company resource instead of the
target agent.

**Proposed behavior**

Add `agents:read` for redacted configuration and instruction reads. Keep
`agents:configure` as read/write and preserve suggestion-grant
compatibility. Standard new agents receive the read grant. A one-time
migration grants only reads to eligible existing standard agents.
Individual routes authorize the target agent and grant scope.

**Reason and benefit**

Read access can be granted without configuration changes, rollback, or
environment probes. Legacy agents can verify teammates without receiving
new write permissions.

## What Changed

- Add `agents:read` to the shared permission contract and standard-agent
defaults.
- Accept read, configure, or suggestion grants for configuration reads,
with target scope and the responsible-user ceiling.
- Fix target-aware self and scoped configuration, skill, and revision
reads.
- Add an audited data migration. Skip low-trust, built-in, inactive,
pending, terminated, setup, and scoped configuration/suggestion cases.
Preserve existing read scopes and later revocation.
- Update the specifications, default-permission documentation, and agent
access map.

## Verification

- Focused acceptance: 276 tests passed across seven suites on commit
`a37314df88`: authorization, real-key HTTP access, route authorization,
canonical instructions, default grants, migration replay, and lifecycle
configuration.
- The HTTP journey uses a real hashed agent API key and a disposable
PostgreSQL database. It verifies redaction, scoped reads, denied edits
and rollback, cross-company isolation, and both agent and
responsible-user revocation.
- New read-grant regressions failed before the authorization change. A
realistic paused/pausing/resuming migration fixture also failed before
the lifecycle filter correction and passed afterward. Setup-held paused
agents stay excluded.
- `pnpm -r typecheck` and `pnpm build` passed on the rebased branch; the
final lifecycle migration correction also passed the database package
typecheck and build. The latest commit changes only a test assertion.
- `pnpm --filter @paperclipai/db check:migrations`, `node
scripts/check-maps.mjs`, and `git diff --check` passed.
- Full current-head [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41641531426)
passed: typecheck, build, all general and serialized test lanes, Runner
checks, all eight browser shards, canary dry run, policy, and aggregate
gates. The local full-suite attempt was stopped after exposing the
outdated cross-company 403 assertion; interrupted local full-suite
attempts are not counted as passes.

## Risks

The migration deliberately expands redacted configuration and
instruction visibility for eligible existing standard agents in the same
company. An absent older write grant does not deny the new read
capability. It adds no write grant. Scoped configure/suggestion grants
and all existing read-grant scopes stay intact. Administrators can
remove the independent read grant after the one-time rollout; ordinary
updates and startup do not restore it. Configure and suggestion grants
still imply reads within their own scopes. Cross-company
configuration/history reads now return 404 through the common
target-aware helper. No schema change or credential-value disclosure is
added.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The host does not expose a more specific runtime model ID or
context-window size.

## 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 `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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>
2026-11-03 07:26:27 -06:00

262 lines
21 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 { 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("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);
});
});