mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters are the boundary between the control plane and the runtimes that actually do work. > - Hermes support needs to be available as first-class local and gateway adapters while still preserving the adapter-manager override path for external packages. > - The adapter work touches runtime execution, UI adapter metadata, onboarding prompts, scoped credentials, release packaging, and smoke coverage, so the handoff needs concrete verification rather than only unit tests. > - This pull request adds built-in Hermes local and Hermes gateway support, keeps external adapter overrides compatible, and documents/tests the gateway flow end to end. > - The benefit is that operators can hire Hermes-backed agents without a manual plugin install, while self-hosted installs can still override/shadow the built-ins through Adapter manager packages. ## Linked Issues or Issue Description No public GitHub issue exists for this exact Hermes built-in adapter, gateway onboarding, and release-source work. Problem description: - Hermes local and gateway adapters need a public, reviewable source path in the monorepo so package artifacts and built-in adapter behavior match the application source. - Operators need built-in `hermes_local` and `hermes_gateway` adapter choices without losing the ability to install external Hermes packages as overrides. - Gateway onboarding needs secure defaults for API server URLs, API keys, and generated agent setup text. - Hermes-originated task bridge credentials need narrower API-key scope configuration. - Related public PRs found during duplicate search include #3027, #2363, #7544, #7950, #8095, and #8543. ## What Changed - Added the unified Hermes adapter package with local and gateway server/UI/CLI exports, config schemas, transcript parsing, model detection, and package metadata. - Registered `hermes_local` and `hermes_gateway` as built-in adapters across shared constants, server registries, CLI packaging, and UI adapter registries. - Kept the external adapter override path compatible so installed Hermes packages can shadow built-ins and restore the built-in parser when disabled. - Added Hermes gateway onboarding docs, board-operator docs, Docker smoke assets, and shell smoke harnesses for join/e2e validation. - Added scoped task-bridge API-key support, authorization checks, issue-origin handling, and tests for Hermes-created Paperclip tasks. - Hardened gateway transport and redaction behavior for API keys, headers, session data, and smoke diagnostics. - Updated release packaging/bootstrap checks for the Hermes packages while leaving `pnpm-lock.yaml` out of the PR per repository policy. ## Verification Targeted local verification recorded before PR handoff: - `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run src/gateway/server/execute.test.ts` — 14/14 passed. - `pnpm test:hermes-gateway-smoke` — 6/6 passed. - Hermes package typecheck/build checks passed. - Focused server/UI adapter tests passed — 31/31. - Release helper Node tests passed — 18/18. - `git diff --check origin/master..HEAD` passed. Fresh Docker E2E smoke evidence: - Ran `pnpm smoke:hermes-gateway-e2e` on 2026-06-26 with a fresh state directory and fresh Docker container against a live Paperclip dev server. - Hermes direct execution reached `completed`. - Hermes stop/cancel path reached `cancelled`. - Hermes gateway created a Paperclip task, Paperclip ran the Hermes agent, and the task reached `done` with the expected marker response. - Temporary board auth keys, token files, smoke state, and Docker containers were cleaned up after the run. PR checks on head `b5eae40ce`: - GitHub Actions passed: `policy`, `review`, `Typecheck + Release Registry`, all general test shards, all serialized server shards, `Build`, `Canary Dry Run`, `e2e`, and aggregate `verify`. - External checks passed: Snyk and Socket Project Report. - External Socket Pull Request Alerts remained pending after the first-party CI matrix completed. ## Risks - Medium risk: this spans adapter registration, package publishing, gateway execution, onboarding docs, API-key scoping, and UI adapter metadata. - Migration risk is low: the scope-config migration adds a nullable column and does not rewrite existing keys. - Gateway execution depends on operator-provided Hermes API configuration; the smoke covers the Docker gateway path but real deployments may differ by network/auth setup. - Direct Greptile review on the latest expanded diff is file-count limited, although the commitperclip review gate passed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent, tool use enabled in a local repository workspace. Context window size is not exposed in this environment. ## 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] Commitperclip review gate is green; direct Greptile review is file-count limited on the latest expanded diff - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
244 lines
9.5 KiB
TypeScript
244 lines
9.5 KiB
TypeScript
import type {
|
|
Agent,
|
|
AgentDesiredSkillEntry,
|
|
AgentPermissions,
|
|
AgentDetail,
|
|
AgentInstructionsBundle,
|
|
AgentInstructionsFileDetail,
|
|
AgentSkillSnapshot,
|
|
AdapterEnvironmentTestResult,
|
|
AgentKeyCreated,
|
|
AgentRuntimeState,
|
|
AgentTaskSession,
|
|
AgentWakeupResponse,
|
|
HeartbeatRun,
|
|
Approval,
|
|
AgentConfigRevision,
|
|
ClearAgentErrorResponse,
|
|
AgentApiKeyScope,
|
|
} from "@paperclipai/shared";
|
|
import type {
|
|
AdapterModelProfileDefinition,
|
|
AdapterModelProfileKey,
|
|
} from "@paperclipai/adapter-utils";
|
|
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
|
import { ApiError, api } from "./client";
|
|
|
|
export interface AgentKey {
|
|
id: string;
|
|
name: string;
|
|
scope: AgentApiKeyScope;
|
|
createdAt: Date;
|
|
revokedAt: Date | null;
|
|
}
|
|
|
|
export interface AdapterModel {
|
|
id: string;
|
|
label: string;
|
|
}
|
|
|
|
export type { AdapterModelProfileKey };
|
|
export type AdapterModelProfile = AdapterModelProfileDefinition;
|
|
|
|
export interface DetectedAdapterModel {
|
|
model: string;
|
|
provider: string;
|
|
source: string;
|
|
candidates?: string[];
|
|
}
|
|
|
|
export interface ClaudeLoginResult {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
loginUrl: string | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface OrgNode {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
reports: OrgNode[];
|
|
}
|
|
|
|
export interface AgentHireResponse {
|
|
agent: Agent;
|
|
approval: Approval | null;
|
|
}
|
|
|
|
export interface AgentPermissionUpdate {
|
|
canCreateAgents: boolean;
|
|
canCreateSkills: boolean;
|
|
canAssignTasks: boolean;
|
|
trustPreset?: AgentPermissions["trustPreset"];
|
|
authorizationPolicy?: AgentPermissions["authorizationPolicy"];
|
|
}
|
|
|
|
export interface AgentWakeRequest {
|
|
source?: "timer" | "assignment" | "on_demand" | "automation";
|
|
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
|
reason?: string | null;
|
|
payload?: Record<string, unknown> | null;
|
|
idempotencyKey?: string | null;
|
|
forceFreshSession?: boolean;
|
|
}
|
|
|
|
function withCompanyScope(path: string, companyId?: string) {
|
|
if (!companyId) return path;
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
return `${path}${separator}companyId=${encodeURIComponent(companyId)}`;
|
|
}
|
|
|
|
function agentPath(id: string, companyId?: string, suffix = "") {
|
|
return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId);
|
|
}
|
|
|
|
export const agentsApi = {
|
|
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
|
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
|
|
listConfigurations: (companyId: string) =>
|
|
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
|
get: async (id: string, companyId?: string) => {
|
|
try {
|
|
return await api.get<AgentDetail>(agentPath(id, companyId));
|
|
} catch (error) {
|
|
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
|
|
// resolve using company agent list while ignoring terminated agents.
|
|
if (
|
|
!(error instanceof ApiError) ||
|
|
error.status !== 409 ||
|
|
!companyId ||
|
|
isUuidLike(id)
|
|
) {
|
|
throw error;
|
|
}
|
|
|
|
const urlKey = normalizeAgentUrlKey(id);
|
|
if (!urlKey) throw error;
|
|
|
|
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
|
|
const matches = agents.filter(
|
|
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
|
);
|
|
if (matches.length !== 1) throw error;
|
|
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
|
|
}
|
|
},
|
|
getConfiguration: (id: string, companyId?: string) =>
|
|
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
|
listConfigRevisions: (id: string, companyId?: string) =>
|
|
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
|
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
|
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
|
|
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
|
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
|
|
create: (companyId: string, data: Record<string, unknown>) =>
|
|
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
|
hire: (companyId: string, data: Record<string, unknown>) =>
|
|
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
|
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
|
api.patch<Agent>(agentPath(id, companyId), data),
|
|
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
|
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
|
instructionsBundle: (id: string, companyId?: string) =>
|
|
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
|
|
updateInstructionsBundle: (
|
|
id: string,
|
|
data: {
|
|
mode?: "managed" | "external";
|
|
rootPath?: string | null;
|
|
entryFile?: string;
|
|
clearLegacyPromptTemplate?: boolean;
|
|
},
|
|
companyId?: string,
|
|
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
|
|
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
|
api.get<AgentInstructionsFileDetail>(
|
|
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
|
),
|
|
saveInstructionsFile: (
|
|
id: string,
|
|
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
|
|
companyId?: string,
|
|
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
|
|
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
|
api.delete<AgentInstructionsBundle>(
|
|
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
|
),
|
|
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
|
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
|
clearError: (id: string, companyId?: string) =>
|
|
api.post<ClearAgentErrorResponse>(agentPath(id, companyId, "/clear-error"), {}),
|
|
approve: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
|
|
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
|
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
|
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
|
skills: (id: string, companyId?: string) =>
|
|
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
|
|
syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) =>
|
|
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
|
|
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
|
|
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }),
|
|
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
|
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
|
|
runtimeState: (id: string, companyId?: string) =>
|
|
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
|
|
taskSessions: (id: string, companyId?: string) =>
|
|
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
|
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
|
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
|
adapterModels: (
|
|
companyId: string,
|
|
type: string,
|
|
options?: { refresh?: boolean; environmentId?: string | null },
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.refresh) params.set("refresh", "1");
|
|
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api.get<AdapterModel[]>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
|
|
);
|
|
},
|
|
detectModel: (companyId: string, type: string) =>
|
|
api.get<DetectedAdapterModel | null>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
|
),
|
|
adapterModelProfiles: (companyId: string, type: string) =>
|
|
api.get<AdapterModelProfile[]>(
|
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/model-profiles`,
|
|
),
|
|
testEnvironment: (
|
|
companyId: string,
|
|
type: string,
|
|
data: {
|
|
adapterConfig: Record<string, unknown>;
|
|
environmentId?: string | null;
|
|
},
|
|
) =>
|
|
api.post<AdapterEnvironmentTestResult>(
|
|
`/companies/${companyId}/adapters/${type}/test-environment`,
|
|
data,
|
|
),
|
|
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
|
|
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
|
|
wakeup: (
|
|
id: string,
|
|
data: AgentWakeRequest,
|
|
companyId?: string,
|
|
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
|
loginWithClaude: (id: string, companyId?: string) =>
|
|
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
|
availableSkills: () =>
|
|
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
|
};
|
|
|
|
export interface AvailableSkill {
|
|
name: string;
|
|
description: string;
|
|
isPaperclipManaged: boolean;
|
|
}
|