mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - People talk to their agents in chat, and agents answer from the
company's saved settings
> - A board member can save a company trace export (an OTLP collector or
Braintrust) in Company settings
> - The settings route is for board connection managers only, so an
agent cannot read it
> - In a chat eval, the board saved and tested a collector. The agent
checked only its own config and secrets, and then told the person that
nothing was configured
> - Chat guidance also names company pages as `/<prefix>/...`. In
another eval, the agent copied that placeholder into a link, and the
link opened nothing
> - This pull request gives agents a read-only status of the trace
export, and gives chat the real company prefix in page paths
> - The benefit is that agents answer from saved settings, and the links
they write open real pages
## Linked Issues or Issue Description
**What happened?**
1. A board member saved and tested a trace export destination. The chat
agent was asked to acknowledge the saved configuration. The agent said
that no collector endpoint or credentials existed. Agents get 403 from
`GET /api/companies/{companyId}/observability`, and no other route
reports the saved state.
2. An agent reply linked `/<prefix>/company/settings/instance`. The chat
rendered it as `/%3Cprefix%3E/...`, which is not a real page. The
placeholder came from the chat directive, which names pages such as
`/<prefix>/company/settings/instance/profile`.
**Expected behavior**
1. An agent can read whether the company's trace export is configured,
enabled and credentialed, and the result of its latest test. The agent
cannot read the destination or any credential.
2. The chat directive and API search results that an agent reads give
real company paths, such as `/COM/company/settings/instance/profile`.
**Steps to reproduce**
1. As a board member, save and test an agent observability destination
in Company settings.
2. In chat, ask the CEO agent to acknowledge that the test collector
configuration is saved.
3. See that the agent cannot read the saved settings and denies them.
**Paperclip version or commit**
master at 9dfdeaad86.
## What Changed
- Added `GET /api/companies/{companyId}/observability/status`. Agents of
the company and all members can read it. It returns `configured`,
`enabled`, `provider`, `credentialsConfigured`, `updatedAt` and the
latest test status. It never returns the destination or a credential.
- Added the shared `AgentObservabilityStatus` type and
`agentObservabilityStatus()` mapper, and registered the route in the
OpenAPI catalog so that `search_api` finds it.
- The chat task markdown now fills the conversation's own company prefix
(from its identifier, for example `COM-1` gives `COM`) into the chat
directives in place of `/<prefix>/`.
- `search_api` results now get the same substitution for the caller's
company. The shared catalog is not changed.
- Pinned the changed shared source in
`tests/runner-e2e/source-sdk-public-inputs.json`.
## Verification
- `cd server && npx vitest run src/__tests__/agent-observability.test.ts
src/__tests__/agent-observability-status-guidance.test.ts
src/services/heartbeat/task-markdown.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/paperclip-runner-tool-authority.test.ts`
- Red/green: with the product change removed, 10 of the new or updated
tests fail (the agent status read gets 403, search does not find the
route, and the chat markdown still contains `/<prefix>/`). With the
change, all pass.
- `npx tsc -p server/tsconfig.json --noEmit` passes.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
reports no change.
## Risks
- Low risk. The new route is read-only and returns no destination and no
credential. The existing settings route keeps its board-only rule.
- The chat prompt text changes for chats with an identifier. The text is
constant for each company, so prompt caching is not affected inside a
conversation.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.
## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
14 KiB
TypeScript
196 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { z } from "zod";
|
|
import { buildOpenApiDocument } from "../../routes/openapi.js";
|
|
import { badRequest, notFound } from "../../errors.js";
|
|
import { runnerApiReference } from "./runner-api-reference.js";
|
|
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
type Json = Record<string, any>;
|
|
export interface RunnerApiOperation {
|
|
operationId: string;
|
|
method: string;
|
|
path: string;
|
|
summary: string;
|
|
description: string;
|
|
parameters: Json[];
|
|
requestBody?: Json;
|
|
responses: Json;
|
|
authorization: Json;
|
|
transport: "rest" | "protocol";
|
|
dedicatedTools: string[];
|
|
allowedModes: string[];
|
|
skillReference?: { section: string; description?: string; examples?: { body: unknown }[] };
|
|
dedicatedToolGuidance: string;
|
|
dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
|
|
runnerRestrictions?: string[];
|
|
callPolicy: "rest" | "protocol";
|
|
}
|
|
|
|
const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
const SYNONYMS: Record<string, string> = {
|
|
task: "issue", tasks: "issues", employee: "agent", employees: "agents",
|
|
hire: "agent", hiring: "agents", expense: "cost", expenses: "costs",
|
|
cron: "routines", schedule: "routines", recurring: "routines",
|
|
blocker: "dependencies", blockers: "dependencies", files: "attachments",
|
|
};
|
|
|
|
// Function words carry no resource meaning; scoring them let any summary that
|
|
// happens to say "for" or "the" outrank the operation a query names.
|
|
const SEARCH_FUNCTION_WORDS = new Set(["a", "an", "the", "for", "of", "to", "in", "on", "at", "by", "with", "and", "or", "from"]);
|
|
|
|
function words(text: string): string[] {
|
|
return text.toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length > 0 && !SEARCH_FUNCTION_WORDS.has(word))
|
|
.flatMap((word) => [word, ...(SYNONYMS[word] ? [SYNONYMS[word]] : [])])
|
|
.map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
|
|
}
|
|
|
|
function dedicatedTools(method: string, path: string): string[] {
|
|
if (method === "POST" && path === "/api/agents/me/chat/messages") return ["send_chat_message"];
|
|
if (/\/companies\/\{[^}]+\}\/workflows$/.test(path)) return method === "GET" ? ["list_workflows"] : method === "POST" ? ["create_workflow"] : [];
|
|
if (/\/workflows\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_workflow"] : method === "PATCH" ? ["update_workflow"] : [];
|
|
if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
|
|
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\/\{[^}]+\}\/skills\/\{[^}]+\}\/files$/.test(path) && method === "PATCH") return ["update_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"] : [];
|
|
if (/\/project-repositories$/.test(path) && method === "GET") return ["list_project_repositories"];
|
|
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
|
|
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
|
|
if (/\/case-handoffs$/.test(path) && method === "POST") return ["handoff_case", "create_case"];
|
|
if (/\/cases\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_case"] : method === "PATCH" ? ["update_case"] : [];
|
|
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
|
|
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["reassign_task", "set_dependencies", "finish_task", "block_task", "request_review"];
|
|
if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
|
|
if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
|
|
if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
|
|
if (/\/approvals\/\{[^}]+\}/.test(path) && method === "GET") return ["get_approval", "get_approval_context"];
|
|
return [];
|
|
}
|
|
|
|
// Descriptions and schemas are documentation, never authorization. Routes remain
|
|
// authoritative, including conditional role, company and resource checks.
|
|
export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): RunnerApiOperation[] {
|
|
function dereference(value: any, seen = new Set<string>()): any {
|
|
if (Array.isArray(value)) return value.map((entry) => dereference(entry, seen));
|
|
if (!value || typeof value !== "object") return value;
|
|
if (typeof value.$ref === "string" && value.$ref.startsWith("#/")) {
|
|
if (seen.has(value.$ref)) return { description: `Recursive schema: ${value.$ref}` };
|
|
const target = value.$ref.slice(2).split("/").reduce((node: any, key: string) => node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], document);
|
|
if (!target) throw new Error(`Unresolved API schema: ${value.$ref}`);
|
|
return dereference(target, new Set([...seen, value.$ref]));
|
|
}
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, dereference(entry, seen)]));
|
|
}
|
|
const result: RunnerApiOperation[] = [];
|
|
for (const [path, item] of Object.entries<Json>(document.paths)) {
|
|
for (const [verb, operation] of Object.entries<Json>(item)) {
|
|
if (!METHODS.has(verb)) continue;
|
|
const method = verb.toUpperCase();
|
|
const conversationalConfirmation = method === "POST"
|
|
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
|
|
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
|
|
// Computes an install plan without saving it, so read-only modes may call it.
|
|
const readOnlyPreview = method === "POST" && path === "/api/companies/{companyId}/teams/catalog/{catalogId}/preview";
|
|
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
|
|
const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|
|
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|
|
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses))));
|
|
result.push({
|
|
operationId: `${method} ${path}`, method, path,
|
|
summary: operation.summary ?? `${method} ${path}`,
|
|
description: operation.description ?? "",
|
|
parameters: dereference([...(item.parameters ?? []), ...(operation.parameters ?? [])]),
|
|
...(operation.requestBody ? { requestBody: dereference(operation.requestBody) } : {}),
|
|
responses: dereference(operation.responses ?? {}),
|
|
authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
|
|
transport: protocol ? "protocol" : "rest",
|
|
callPolicy: protocol ? "protocol" : "rest",
|
|
dedicatedTools: dedicatedTools(method, path),
|
|
dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
|
|
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
|
|
return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
|
|
}),
|
|
runnerRestrictions: ["Active run and assignment must remain authorized.",
|
|
conversationalConfirmation
|
|
? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user."
|
|
: "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."],
|
|
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
|
|
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
|
|
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
|
|
allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
|
|
: method === "GET" || method === "HEAD" || taskTitleUpdate || readOnlyPreview ? ["standard", "ask", "planning", "skill_test"]
|
|
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
|
|
...(skillReference ? { skillReference } : {}),
|
|
});
|
|
}
|
|
}
|
|
for (const [path, summary, actor] of [
|
|
["/api/companies/{companyId}/events/ws", "Live company events WebSocket; use the existing event client", "board_or_agent"],
|
|
["/api/runner/v1/connect/{runId}", "Authenticated runner PRP WebSocket; runner-owned transport", "runner"],
|
|
["/api/environment-custom-image-setup-sessions/{sessionId}/terminal/ws", "Image setup terminal WebSocket; use the existing terminal client", "board"],
|
|
]) {
|
|
if (result.some(operation => operation.path === path)) continue;
|
|
result.push({ operationId: `GET ${path}`, method: "GET", path, summary,
|
|
description: "WebSocket upgrade. Not callable through call_api.",
|
|
parameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => ({ in: "path", name: match[1], required: true, schema: { type: "string" } })),
|
|
responses: { "101": { description: "Switching Protocols" } }, authorization: { actor },
|
|
transport: "protocol", callPolicy: "protocol", dedicatedTools: [], dedicatedToolGuidance: "Use the existing protocol client.", allowedModes: [],
|
|
});
|
|
}
|
|
return result.sort((a, b) => a.operationId.localeCompare(b.operationId));
|
|
}
|
|
|
|
let cached: RunnerApiOperation[] | undefined;
|
|
let cachedDigest: string | undefined;
|
|
export function runnerApiCatalog(): RunnerApiOperation[] { return cached ??= buildRunnerApiCatalog(); }
|
|
export function runnerApiOperation(id: string): RunnerApiOperation {
|
|
const operation = runnerApiCatalog().find((entry) => entry.operationId === id);
|
|
if (!operation) throw notFound("Unknown API operation; use search_api to discover its exact operationId");
|
|
return operation;
|
|
}
|
|
|
|
/**
|
|
* Catalog text names company pages as /<prefix>/...; a model can copy that
|
|
* placeholder verbatim into a link. When the caller's company prefix is known,
|
|
* hand back real paths. Returns a copy; the shared catalog is never mutated.
|
|
*/
|
|
export function withCompanyPagePaths<T>(value: T, prefix: string | null | undefined): T {
|
|
if (!prefix || !/^[A-Za-z0-9]+$/.test(prefix)) return value;
|
|
return JSON.parse(JSON.stringify(value).replaceAll("/<prefix>/", `/${prefix}/`)) as T;
|
|
}
|
|
|
|
export const runnerApiSearchSchema = z.object({ query: z.string().trim().min(1).max(500), limit: z.number().int().min(1).max(10).default(5), cursor: z.string().max(200).optional() }).strict();
|
|
export function searchRunnerApi(value: unknown) {
|
|
const parsed = runnerApiSearchSchema.safeParse(value);
|
|
if (!parsed.success) throw badRequest("Invalid API search query or limit");
|
|
const input = parsed.data;
|
|
const { query, limit } = input;
|
|
const catalog = runnerApiCatalog();
|
|
cachedDigest ??= createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
|
|
const fingerprint = createHash("sha256").update(cachedDigest).update(query).digest("hex").slice(0, 16);
|
|
let offset = 0;
|
|
if (input.cursor) {
|
|
const match = /^([a-f0-9]{16}):(\d+)$/.exec(input.cursor);
|
|
if (!match || match[1] !== fingerprint) throw badRequest("Search cursor belongs to a different query or catalog");
|
|
offset = Number(match[2]);
|
|
if (!Number.isSafeInteger(offset)) throw badRequest("Invalid search cursor");
|
|
}
|
|
const exact = catalog.find((entry) => entry.operationId.toLowerCase() === query.trim().toLowerCase());
|
|
const terms = [...new Set(words(query))];
|
|
const matches = exact ? [exact] : catalog.map((entry) => {
|
|
const title = new Set(words(`${entry.method} ${entry.path} ${entry.summary}`));
|
|
const description = new Set(words(`${entry.description} ${entry.skillReference?.description ?? ""} ${entry.skillReference?.section ?? ""}`));
|
|
return { entry, score: terms.reduce((sum, word) => sum + (title.has(word) ? 5 : description.has(word) ? 1 : 0), 0) };
|
|
}).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score || a.entry.operationId.localeCompare(b.entry.operationId)).map(({ entry }) => entry);
|
|
const results = matches.slice(offset, offset + limit);
|
|
return {
|
|
results, total: matches.length,
|
|
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
|
|
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
|
|
};
|
|
}
|