feat(runner): expose canonical instruction revision tools

Add native read, update, history, and restore operations with server-bound identity and revision compare-and-swap safety. Register the default catalog and API discovery paths; cover real authority authorization and exact revision restore.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 16:18:54 -05:00
1 parent cfaf87150a
commit 5774c98d50
20 files changed
+725 -12

No files matched your search

+25
View File
@@ -162,3 +162,28 @@ conflicts/loss evidence, and arrange bounded recovery. They must not write share
instruction caches or call the old filesystem `writeFile` for an entry.
The next run should read the canonical snapshot and materialize it when disk is
needed. Runtime cleanup parity is not established by these service/editor tests.
## Dedicated native tools
Ordinary native runs expose `read_agent_instructions`,
`update_agent_instructions`, `get_agent_instruction_history`, and
`restore_agent_instructions` by default. `targetAgentId` is optional and defaults
to the calling agent; another target must be in the same company. Read and
history are available in all task modes. Update and restore are available in
standard and planning modes, subject to the canonical service's current
responsible-user permissions, containment, and consent requirements.
Read returns the configured `entryFile`, exact content, and current revision.
Retain that entry filename and revision ID when preparing an update. Both writes
require an explicit `baseRevisionId`; only update accepts null for a genuinely
new entry. Restore requires the existing current revision ID.
Historical reads take both `entryFile` and `revisionId`. History pages contain at
most 100 entries and include a continuation cursor. Restore appends a new
revision; it does not delete history. Tool JSON cannot supply company, caller,
run, responsible user, or source attribution. The native authority binds those
values from the active run, and the canonical service reloads authorization.
A tool commit is immediately durable. If a run-local working copy still contains
older content, cleanup must preserve its conflict candidate rather than replace
the newer tool commit. A 409 response requires explicit conflict resolution;
never silently retry an old candidate against the latest revision.
@@ -7,10 +7,86 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 48,
"actions": 52,
"legacyRequirements": 106
},
"actions": [
{
"id": "read_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:read_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:read_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "update_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:update_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:update_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "get_agent_instruction_history",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:get_agent_instruction_history",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:get_agent_instruction_history"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "restore_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:restore_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:restore_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "create_skill",
"ownership": "optional_agent_tool",
@@ -20,21 +20,25 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(39);
expect(summary.liveCount).toBe(36);
expect(summary.liveCount).toBe(40);
expect(summary.sharedCount).toBe(27);
expect(summary.unionCount).toBe(48);
expect(summary.unionCount).toBe(52);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
"call_api",
"create_project",
"get_agent",
"get_agent_instruction_history",
"get_approval",
"get_approval_context",
"hire_agent",
"list_project_repositories",
"read_agent_instructions",
"restore_agent_instructions",
"schedule_wake",
"search_api",
"update_agent_instructions",
]);
expect(summary.scenarioOnly).toEqual([
"administer_company",
@@ -53,7 +57,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(48);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(52);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -87,7 +91,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(48);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(52);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -113,7 +117,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 35,
live_codex: 39,
scenario_mock: 12,
test_only: 1,
});
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
canonicalOperations: 48,
canonicalOperations: 52,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
expect(report.coverage.operations).toHaveLength(48);
expect(report.coverage.operations).toHaveLength(52);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
"48 operations · 106 capability cases · 12 workflows",
"52 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
@@ -1,3 +1,7 @@
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { answerStatusQuestionAction } from "./answer-status-question.js";
import { blockTaskAction } from "./block-task.js";
import { finishTaskAction } from "./finish-task.js";
@@ -16,6 +20,11 @@ import { deepFreezeProtocolAction } from "./freeze.js";
/** Core actions that are always present in an authorized runner projection. */
export const PAPERCLIP_CORE_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
readAgentInstructionsAction,
updateAgentInstructionsAction,
getAgentInstructionHistoryAction,
restoreAgentInstructionsAction,
answerStatusQuestionAction,
blockTaskAction,
finishTaskAction,
@@ -0,0 +1,99 @@
/** Canonical definition for `get_agent_instruction_history`. */
export const getAgentInstructionHistoryAction = {
"id": "get_agent_instruction_history",
"canonical": {
"operationId": "get_agent_instruction_history",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Get agent instruction history",
"description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
"note": null
},
"examples": {
"call": {
"operationId": "get_agent_instruction_history",
"input": {
"entryFile": "AGENTS.md"
}
},
"success": {
"ok": true,
"operationId": "get_agent_instruction_history",
"result": {}
}
},
"live": {
"order": 42,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "get_agent_instruction_history",
"version": 1,
"title": "Get agent instruction history",
"description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"cursor": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": [
"entryFile"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -1,3 +1,7 @@
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { reassignTaskAction } from "./reassign-task.js";
import { createSkillAction } from "./create-skill.js";
import { createProjectAction } from "./create-project.js";
@@ -49,6 +53,11 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
readAgentInstructionsAction,
updateAgentInstructionsAction,
getAgentInstructionHistoryAction,
restoreAgentInstructionsAction,
createSkillAction,
createProjectAction,
listProjectRepositoriesAction,
@@ -0,0 +1,90 @@
/** Canonical definition for `read_agent_instructions`. */
export const readAgentInstructionsAction = {
"id": "read_agent_instructions",
"canonical": {
"operationId": "read_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Read canonical agent instructions",
"description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
"note": null
},
"examples": {
"call": {
"operationId": "read_agent_instructions",
"input": {}
},
"success": {
"ok": true,
"operationId": "read_agent_instructions",
"result": {}
}
},
"live": {
"order": 40,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "read_agent_instructions",
"version": 1,
"title": "Read canonical agent instructions",
"description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"revisionId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Historical revision to inspect; also provide its entryFile."
}
},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -0,0 +1,99 @@
/** Canonical definition for `restore_agent_instructions`. */
export const restoreAgentInstructionsAction = {
"id": "restore_agent_instructions",
"canonical": {
"operationId": "restore_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"planning"
],
"sideEffectClass": "company_write",
"idempotency": "recommended",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Restore canonical agent instructions",
"description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
"note": null
},
"examples": {
"call": {
"operationId": "restore_agent_instructions",
"input": {
"entryFile": "AGENTS.md",
"revisionId": "00000000-0000-4000-8000-000000000001",
"baseRevisionId": "00000000-0000-4000-8000-000000000002"
}
},
"success": {
"ok": true,
"operationId": "restore_agent_instructions",
"result": {}
}
},
"live": {
"order": 43,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "restore_agent_instructions",
"version": 1,
"title": "Restore canonical agent instructions",
"description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"planning"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"revisionId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Historical revision whose exact content should be restored."
},
"baseRevisionId": {
"type": "string",
"description": "Current revision read before restoring. Never replace a stale base silently.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
},
"required": [
"entryFile",
"revisionId",
"baseRevisionId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -0,0 +1,102 @@
/** Canonical definition for `update_agent_instructions`. */
export const updateAgentInstructionsAction = {
"id": "update_agent_instructions",
"canonical": {
"operationId": "update_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"planning"
],
"sideEffectClass": "company_write",
"idempotency": "recommended",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Update canonical agent instructions",
"description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
"note": null
},
"examples": {
"call": {
"operationId": "update_agent_instructions",
"input": {
"entryFile": "AGENTS.md",
"content": "# Instructions\n",
"baseRevisionId": null
}
},
"success": {
"ok": true,
"operationId": "update_agent_instructions",
"result": {}
}
},
"live": {
"order": 41,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "update_agent_instructions",
"version": 1,
"title": "Update canonical agent instructions",
"description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"planning"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"content": {
"type": "string",
"maxLength": 1048576,
"description": "Complete UTF-8 instruction content, at most 1 MiB; empty content is valid."
},
"baseRevisionId": {
"type": [
"string",
"null"
],
"description": "Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
},
"required": [
"entryFile",
"content",
"baseRevisionId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -27,7 +27,12 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
list_agents: "discovery", get_agent: "discovery",
read_agent_instructions: "agent_instructions",
update_agent_instructions: "agent_instructions",
get_agent_instruction_history: "agent_instructions",
restore_agent_instructions: "agent_instructions",
create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance",
get_approval_context: "governance", request_approval: "governance",
decide_approval: "governance", comment_on_approval: "governance",
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(36);
expect(names).toHaveLength(40);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@@ -33,6 +33,10 @@ export type CapabilitySemanticOperationId =
| "request_review"
| "list_agents"
| "get_agent"
| "read_agent_instructions"
| "update_agent_instructions"
| "get_agent_instruction_history"
| "restore_agent_instructions"
| "search_tasks"
| "list_approvals"
| "get_approval"
@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { agents, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, issues, agentInstructionRevisions, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resolveManagedInstructionsRoot } from "../agent-instructions.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("canonical instruction tools through native authority", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const previousHome = process.env.PAPERCLIP_HOME;
let companyId: string, agentId: string, targetAgentId: string, userId: string, runId: string, issueId: string, root: string;
let authority: PaperclipRunnerToolAuthority;
const entryFile = "policy/ENTRY.md";
const original = "\uFEFF# Original\r\n\0☃\n";
beforeAll(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-tool-home-")));
process.env.PAPERCLIP_HOME = home;
database = await startEmbeddedPostgresTestDatabase("instruction-tool-db-");
db = createDb(database.connectionString);
}, 90_000);
afterAll(async () => {
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, agentId, targetAgentId, userId, runId, issueId] = Array.from({ length: 6 }, () => randomUUID());
await db.insert(companies).values({ id: companyId, name: "Tool scope", 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: targetAgentId, companyId, name: "Target", adapterConfig: {} });
await db.insert(agents).values([
{ id: agentId, companyId, name: "Caller", status: "active" },
{ id: targetAgentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
]);
await db.insert(companyMemberships).values([
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
]);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } });
await db.insert(issues).values({ id: issueId, companyId, title: "Maintain instructions", status: "in_progress", workMode: "standard", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "on_demand", responsibleUserId: userId });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
await fs.writeFile(path.join(root, entryFile), original);
});
const call = (tool: string, args: Record<string, unknown> = {}) => authority.execute({ tool, callId: randomUUID(), arguments: { targetAgentId, ...args } }) as Promise<any>;
it("reads, commits, lists, inspects, and restores exact content with server provenance", async () => {
const first = await call("read_agent_instructions");
expect(first).toMatchObject({ entryFile, content: original });
const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id });
expect(changed.revision).toMatchObject({ source: "tool", responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId });
const history = await call("get_agent_instruction_history", { entryFile, limit: 1 });
expect(history.revisions.map((revision: { id: string }) => revision.id)).toEqual([changed.revision.id]);
expect(history.nextCursor).toEqual(expect.any(String));
expect(await call("read_agent_instructions", { entryFile, revisionId: first.revision.id })).toMatchObject({ content: original });
const restored = await call("restore_agent_instructions", { entryFile, revisionId: first.revision.id, baseRevisionId: changed.revision.id });
expect(restored.revision).toMatchObject({ source: "restore", restoredFromRevisionId: first.revision.id });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original);
await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: changed.revision.id })).rejects.toMatchObject({ status: 409 });
expect((await call("read_agent_instructions")).content).toBe(original);
});
it("rejects supplied identity and missing CAS bases before changing content", async () => {
const first = await call("read_agent_instructions");
for (const identity of ["companyId", "agentId", "runId", "responsibleUserId", "onBehalfOfUserId", "actor"]) {
await expect(call("update_agent_instructions", { entryFile, content: "forged", baseRevisionId: first.revision.id, [identity]: randomUUID() })).rejects.toMatchObject({ status: 400 });
}
await expect(call("update_agent_instructions", { entryFile, content: "missing base" })).rejects.toMatchObject({ status: 400 });
expect((await call("read_agent_instructions")).content).toBe(original);
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, targetAgentId))).toHaveLength(1);
});
it("rechecks responsible-user access and rejects a cross-company target", async () => {
const first = await call("read_agent_instructions");
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await expect(call("update_agent_instructions", { entryFile, content: "revoked", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 403 });
const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID();
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: randomUUID().slice(0, 8) });
await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign" });
await expect(call("read_agent_instructions", { targetAgentId: foreignAgentId })).rejects.toMatchObject({ status: 404 });
await expect(call("update_agent_instructions", { targetAgentId: foreignAgentId, entryFile, content: "foreign", baseRevisionId: null })).rejects.toMatchObject({ status: 404 });
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
await expect(call("read_agent_instructions")).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original);
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
const binding = { companyId: "00000000-0000-4000-8000-000000000001", agentId: "00000000-0000-4000-8000-000000000002", issueId: "00000000-0000-4000-8000-000000000003", runId: "00000000-0000-4000-8000-000000000004" };
describe("dedicated canonical instruction tools", () => {
it("advertises read, CAS update, history, and restore on ordinary native runs", () => {
const authority = new PaperclipRunnerToolAuthority({} as Db, binding);
const tools = authority.definitions();
expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
]));
for (const name of ["update_agent_instructions", "restore_agent_instructions"]) {
const tool = tools.find((candidate) => candidate.name === name)!;
expect(tool.inputSchema).toMatchObject({ required: expect.arrayContaining(["baseRevisionId"]), additionalProperties: false });
expect(tool.inputSchema).not.toHaveProperty("properties.responsibleUserId");
}
});
});
@@ -0,0 +1,63 @@
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { badRequest, notFound } from "../../errors.js";
import { agentInstructionRevisionService } from "../agent-instruction-revisions.js";
import { deriveBundleState } from "../agent-instructions.js";
import { agentService } from "../agents.js";
import type { AuthorizationActor } from "../authorization.js";
const target = { targetAgentId: z.string().uuid().optional() };
const entryFile = z.string().min(1).max(4096);
const baseRevisionId = z.string().uuid().nullable();
const schemas = {
read_agent_instructions: z.object({ ...target, entryFile: entryFile.optional(), revisionId: z.string().uuid().optional() }).strict()
.refine((input) => Boolean(input.entryFile) === Boolean(input.revisionId), "Historical reads require both entryFile and revisionId"),
update_agent_instructions: z.object({ ...target, entryFile, content: z.string().max(1024 * 1024), baseRevisionId }).strict(),
get_agent_instruction_history: z.object({ ...target, entryFile, cursor: z.string().min(1).max(2048).optional(), limit: z.number().int().min(1).max(100).optional() }).strict(),
restore_agent_instructions: z.object({ ...target, entryFile, revisionId: z.string().uuid(), baseRevisionId: z.string().uuid() }).strict(),
};
export type AgentInstructionToolName = keyof typeof schemas;
/** Called only after the native authority validates its server-bound active run. */
export async function executeAgentInstructionTool(input: {
db: Db;
binding: { companyId: string; agentId: string; runId: string };
tool: AgentInstructionToolName;
arguments: unknown;
}) {
// Never accept an actor, responsible user, company, or source run from tool JSON.
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.binding.companyId, agentId: input.binding.agentId, runId: input.binding.runId };
const service = agentInstructionRevisionService(input.db);
const parse = <T>(schema: z.ZodType<T>): T => {
const result = schema.safeParse(input.arguments);
if (!result.success) throw badRequest("Invalid instruction tool arguments", result.error.flatten());
return result.data;
};
const scope = (targetAgentId?: string) => ({ companyId: input.binding.companyId, agentId: targetAgentId ?? input.binding.agentId });
switch (input.tool) {
case "read_agent_instructions": {
const args = parse(schemas.read_agent_instructions);
const target = scope(args.targetAgentId);
if (args.entryFile && args.revisionId) return service.readRevision({ ...target, entryFile: args.entryFile, revisionId: args.revisionId }, actor);
const snapshot = await service.readCurrent(target, actor);
if (snapshot) return { ...snapshot, entryFile: snapshot.revision.entryFile };
// An unseeded empty entry still needs its configured filename for the first CAS.
const agent = await agentService(input.db).getById(target.agentId);
if (!agent || agent.companyId !== target.companyId) throw notFound("Agent not found");
return { entryFile: deriveBundleState(agent).entryFile, revision: null, content: null };
}
case "update_agent_instructions": {
const args = parse(schemas.update_agent_instructions);
return service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor);
}
case "get_agent_instruction_history": {
const args = parse(schemas.get_agent_instruction_history);
return service.history({ ...scope(args.targetAgentId), entryFile: args.entryFile, cursor: args.cursor, limit: args.limit }, actor);
}
case "restore_agent_instructions": {
const args = parse(schemas.restore_agent_instructions);
return service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor);
}
}
}
@@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(30);
expect(authority.definitions()).toHaveLength(34);
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never infer answers");
@@ -1,3 +1,4 @@
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
import { publicChatTaskUrl } from "../chat-task-url.js";
import type { createAssignedMcpTools } from "./assigned-mcp-tools.js";
import { assertAssignableAgent } from "../agent-assignability.js";
@@ -79,6 +80,7 @@ import {
} from "./chat-attachment-read.js";
const IMPLEMENTED_OPERATIONS = new Set([
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
"search_api", "call_api", "hire_agent",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
@@ -364,6 +366,13 @@ export class PaperclipRunnerToolAuthority {
throw new Error("paperclip_runner_tool_mode_denied");
}
switch (call.tool) {
case "read_agent_instructions":
case "update_agent_instructions":
case "get_agent_instruction_history":
case "restore_agent_instructions":
return executeAgentInstructionTool({ db: this.db, binding: {
companyId: this.binding.companyId, agentId: this.binding.agentId, runId: this.binding.runId,
}, tool: call.tool, arguments: call.arguments });
case "create_skill": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
@@ -42,6 +42,10 @@ function words(text: string): string[] {
}
function dedicatedTools(method: string, path: string): string[] {
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"];
if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"];
if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"];
if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : [];
@@ -52,6 +52,10 @@ describe("runner API catalog", () => {
expect(runnerApiOperation(createProject).dedicatedTools).toEqual(["create_project"]);
expect(runnerApiOperation(projects).dedicatedTools).toEqual(["list_projects"]);
expect(runnerApiOperation("GET /api/companies/{companyId}/project-repositories").dedicatedTools).toEqual(["list_project_repositories"]);
expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["read_agent_instructions"]);
expect(runnerApiOperation("PUT /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["update_agent_instructions"]);
expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/history").dedicatedTools).toEqual(["get_agent_instruction_history"]);
expect(runnerApiOperation("POST /api/agents/{id}/instructions-bundle/restore").dedicatedTools).toEqual(["restore_agent_instructions"]);
expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedTools).toEqual(["hire_agent"]);
expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedToolGuidance).toContain("inherits the caller's native runtime");
});