mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
[codex] Add built-in Hermes adapters (#8543)
## 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>
This commit is contained in:
1 parent
ef1422c23e
commit
fd2f82ac5b
131 files changed
+10179
-686
No files matched your search
@@ -2,28 +2,6 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
const hermesExecuteMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("hermes-paperclip-adapter/server", () => ({
|
||||
execute: hermesExecuteMock,
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "hermes_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
sessionCodec: null,
|
||||
listSkills: async () => [],
|
||||
syncSkills: async () => ({ entries: [] }),
|
||||
detectModel: async () => null,
|
||||
}));
|
||||
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
@@ -59,17 +37,18 @@ const externalAdapter: ServerAdapterModule = {
|
||||
describe("server adapter registry", () => {
|
||||
beforeEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
unregisterServerAdapter("hermes_local");
|
||||
unregisterServerAdapter("hermes_gateway");
|
||||
unregisterServerAdapter("claude_local");
|
||||
setOverridePaused("claude_local", false);
|
||||
setOverridePaused("hermes_local", true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
unregisterServerAdapter("hermes_local");
|
||||
unregisterServerAdapter("hermes_gateway");
|
||||
unregisterServerAdapter("claude_local");
|
||||
setOverridePaused("claude_local", false);
|
||||
setOverridePaused("hermes_local", false);
|
||||
hermesExecuteMock.mockClear();
|
||||
});
|
||||
|
||||
it("registers external adapters and exposes them through lookup helpers", async () => {
|
||||
@@ -151,6 +130,88 @@ describe("server adapter registry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("ships Hermes adapters as built-ins and still accepts external overrides", () => {
|
||||
const builtInLocal = findServerAdapter("hermes_local");
|
||||
const builtInGateway = findServerAdapter("hermes_gateway");
|
||||
|
||||
expect(builtInLocal).not.toBeNull();
|
||||
expect(builtInLocal?.supportsLocalAgentJwt).toBe(true);
|
||||
expect(builtInLocal?.supportsInstructionsBundle).toBe(true);
|
||||
expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false);
|
||||
expect(builtInLocal?.detectModel).toBeTypeOf("function");
|
||||
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");
|
||||
|
||||
expect(builtInGateway).not.toBeNull();
|
||||
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
|
||||
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
|
||||
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
|
||||
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
|
||||
|
||||
const hermesLocalExternalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_local",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "hermes_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
listSkills: async () => ({
|
||||
adapterType: "hermes_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
}),
|
||||
getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }),
|
||||
detectModel: async () => ({
|
||||
model: "hermes-model",
|
||||
provider: "openrouter",
|
||||
source: "test",
|
||||
}),
|
||||
};
|
||||
|
||||
const hermesGatewayExternalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_gateway",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "hermes_gateway",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
supportsLocalAgentJwt: false,
|
||||
supportsInstructionsBundle: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getConfigSchema: () => ({
|
||||
fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }],
|
||||
}),
|
||||
};
|
||||
|
||||
registerServerAdapter(hermesLocalExternalAdapter);
|
||||
|
||||
expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter);
|
||||
expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true);
|
||||
|
||||
unregisterServerAdapter("hermes_local");
|
||||
|
||||
expect(requireServerAdapter("hermes_local")).toBe(builtInLocal);
|
||||
|
||||
registerServerAdapter(hermesGatewayExternalAdapter);
|
||||
|
||||
expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter);
|
||||
expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false);
|
||||
|
||||
unregisterServerAdapter("hermes_gateway");
|
||||
|
||||
expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway);
|
||||
});
|
||||
|
||||
it("exposes capability flags from registered adapters", () => {
|
||||
const adapterWithCaps: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
@@ -308,279 +369,6 @@ describe("server adapter registry", () => {
|
||||
expect(await detectAdapterModel("claude_local")).toBeNull();
|
||||
expect(detectModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("injects the local agent JWT and Paperclip API auth guidance into Hermes", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
OPENAI_API_KEY: "llm-token",
|
||||
},
|
||||
promptTemplate: "Existing prompt",
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
expect(hermesExecuteMock).toHaveBeenCalledTimes(1);
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.agent.adapterConfig).toMatchObject({
|
||||
env: {
|
||||
OPENAI_API_KEY: "llm-token",
|
||||
PAPERCLIP_API_KEY: "agent-run-jwt",
|
||||
PAPERCLIP_RUN_ID: "run-123",
|
||||
},
|
||||
});
|
||||
expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain(
|
||||
"Authorization: Bearer $PAPERCLIP_API_KEY",
|
||||
);
|
||||
expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain(
|
||||
"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID",
|
||||
);
|
||||
expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain("Existing prompt");
|
||||
});
|
||||
|
||||
it("preserves Hermes command normalization while injecting auth", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
command: "agent-hermes",
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {
|
||||
command: "runtime-hermes",
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
expect(hermesExecuteMock).toHaveBeenCalledTimes(1);
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.config.hermesCommand).toBe("runtime-hermes");
|
||||
expect(patchedCtx.agent.adapterConfig.hermesCommand).toBe("agent-hermes");
|
||||
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
|
||||
});
|
||||
|
||||
it("passes Hermes custom providers through extraArgs while injecting auth", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
provider: "custom:paperclip-openai",
|
||||
extraArgs: ["--source", "tool"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.agent.adapterConfig.provider).toBe("custom:paperclip-openai");
|
||||
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
|
||||
"--source",
|
||||
"tool",
|
||||
"--provider",
|
||||
"custom:paperclip-openai",
|
||||
]);
|
||||
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
|
||||
});
|
||||
|
||||
it("does not duplicate an explicit Hermes provider extraArg", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
provider: "custom:paperclip-openai",
|
||||
extraArgs: ["--provider=custom:paperclip-openai"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
|
||||
"--provider=custom:paperclip-openai",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate spaced Hermes provider extraArgs", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
provider: "custom:paperclip-openai",
|
||||
extraArgs: ["--provider", "custom:paperclip-openai"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
|
||||
"--provider",
|
||||
"custom:paperclip-openai",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes the original Hermes context through when authToken is absent", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
const ctx = {
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
PAPERCLIP_API_KEY: "server-level-key",
|
||||
},
|
||||
promptTemplate: "Existing prompt",
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
};
|
||||
|
||||
await adapter.execute(ctx);
|
||||
|
||||
expect(hermesExecuteMock).toHaveBeenCalledTimes(1);
|
||||
expect(hermesExecuteMock).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("preserves an explicit Hermes Paperclip API key and does not set promptTemplate when none was configured", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
PAPERCLIP_API_KEY: "explicit-agent-key",
|
||||
PAPERCLIP_RUN_ID: "stale-run-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("explicit-agent-key");
|
||||
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_RUN_ID).toBe("run-123");
|
||||
// No custom promptTemplate was set — Hermes must use its built-in default.
|
||||
// Setting promptTemplate here would replace the full default with just the auth guard text,
|
||||
// stripping assigned issue / workflow instructions.
|
||||
expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set promptTemplate when no custom template is configured, preserving Hermes default", async () => {
|
||||
const adapter = requireServerAdapter("hermes_local");
|
||||
|
||||
await adapter.execute({
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "Hermes Agent",
|
||||
role: "engineer",
|
||||
adapterType: "hermes_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {},
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
authToken: "agent-run-jwt",
|
||||
});
|
||||
|
||||
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
|
||||
// promptTemplate must remain unset so Hermes uses its built-in heartbeat/task prompt.
|
||||
expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined();
|
||||
// Auth token is still injected.
|
||||
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExternalAdapterRegistration", () => {
|
||||
|
||||
@@ -115,12 +115,14 @@ describe("adapter routes", () => {
|
||||
adapterRoutes = routes.adapterRoutes;
|
||||
errorHandler = middleware.errorHandler;
|
||||
setOverridePaused("claude_local", false);
|
||||
unregisterServerAdapter("hermes_local");
|
||||
unregisterServerAdapter("claude_local");
|
||||
registerServerAdapter(overridingConfigSchemaAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setOverridePaused("claude_local", false);
|
||||
unregisterServerAdapter("hermes_local");
|
||||
unregisterServerAdapter("claude_local");
|
||||
});
|
||||
|
||||
@@ -185,17 +187,25 @@ describe("adapter routes", () => {
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
});
|
||||
|
||||
// hermes_local currently supports skills + local JWT, but not the managed
|
||||
// instructions bundle flow because the bundled adapter does not consume
|
||||
// instructionsFilePath at runtime.
|
||||
const hermesAdapter = res.body.find((a: any) => a.type === "hermes_local");
|
||||
expect(hermesAdapter).toBeDefined();
|
||||
expect(hermesAdapter.capabilities).toMatchObject({
|
||||
supportsInstructionsBundle: false,
|
||||
const hermesLocal = res.body.find((a: any) => a.type === "hermes_local");
|
||||
expect(hermesLocal).toBeDefined();
|
||||
expect(hermesLocal.source).toBe("builtin");
|
||||
expect(hermesLocal.capabilities).toMatchObject({
|
||||
supportsInstructionsBundle: true,
|
||||
supportsSkills: true,
|
||||
supportsLocalAgentJwt: true,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
});
|
||||
|
||||
const hermesGateway = res.body.find((a: any) => a.type === "hermes_gateway");
|
||||
expect(hermesGateway).toBeDefined();
|
||||
expect(hermesGateway.source).toBe("builtin");
|
||||
expect(hermesGateway.capabilities).toMatchObject({
|
||||
supportsInstructionsBundle: false,
|
||||
supportsSkills: false,
|
||||
supportsLocalAgentJwt: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => {
|
||||
@@ -277,6 +287,28 @@ describe("adapter routes", () => {
|
||||
expect(keys).not.toContain("bootstrapPromptTemplate");
|
||||
});
|
||||
|
||||
it("serves built-in Hermes config schemas", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const local = await request(app).get("/api/adapters/hermes_local/config-schema");
|
||||
expect(local.status, JSON.stringify(local.body)).toBe(200);
|
||||
expect(local.body.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "provider" }),
|
||||
expect.objectContaining({ key: "timeoutSec" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const gateway = await request(app).get("/api/adapters/hermes_gateway/config-schema");
|
||||
expect(gateway.status, JSON.stringify(gateway.body)).toBe(200);
|
||||
expect(gateway.body.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "apiBaseUrl", required: true }),
|
||||
expect.objectContaining({ key: "apiKey", required: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /api/adapters includes ACPX model availability", async () => {
|
||||
const app = createApp();
|
||||
|
||||
|
||||
@@ -709,7 +709,7 @@ describe.sequential("agent permission routes", () => {
|
||||
model: "gpt-5.3-codex-spark",
|
||||
env: expect.any(Object),
|
||||
}),
|
||||
{ strictMode: false },
|
||||
{ strictMode: false, adapterType: "codex_local" },
|
||||
);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
|
||||
@@ -115,6 +115,65 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("converts Hermes gateway apiKey strings into persisted secret refs", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const literalApiKey = `hermes-key-${randomUUID()}`;
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
name: "Hermes Gateway",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "hermes_gateway",
|
||||
adapterConfig: {
|
||||
apiBaseUrl: "https://hermes.example",
|
||||
apiKey: literalApiKey,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
const persistedRows = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, created.id));
|
||||
const persistedConfig = persistedRows[0]?.adapterConfig as Record<string, unknown>;
|
||||
expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey);
|
||||
expect(persistedConfig.apiKey).toMatchObject({
|
||||
type: "secret_ref",
|
||||
version: "latest",
|
||||
});
|
||||
|
||||
const secretId = (persistedConfig.apiKey as { secretId: string }).secretId;
|
||||
const bindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, created.id),
|
||||
));
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]).toMatchObject({
|
||||
secretId,
|
||||
configPath: "apiKey",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const resolved = await secretService(db).resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
persistedConfig,
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: created.id,
|
||||
},
|
||||
{ adapterType: "hermes_gateway" },
|
||||
);
|
||||
expect(resolved.config.apiKey).toBe(literalApiKey);
|
||||
expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey);
|
||||
});
|
||||
|
||||
it("replaces agent secret bindings when adapterConfig env changes", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secrets = secretService(db);
|
||||
|
||||
@@ -73,6 +73,8 @@ async function createIssue(
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
return db
|
||||
@@ -86,6 +88,8 @@ async function createIssue(
|
||||
projectId: input.projectId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
@@ -1187,4 +1191,118 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
grant: { permissionKey: "tasks:assign" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => {
|
||||
const company = await createCompany(db, "TaskBridge");
|
||||
const bridgeAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
const project = await createProject(db, company.id, "Bridge");
|
||||
const parentIssue = await createIssue(db, company.id, { projectId: project.id });
|
||||
const assignedIssue = await createIssue(db, company.id, { assigneeAgentId: bridgeAgent.id });
|
||||
const keyId = randomUUID();
|
||||
const bridgeCreatedIssue = await createIssue(db, company.id, {
|
||||
originKind: "task_bridge",
|
||||
originId: keyId,
|
||||
});
|
||||
const unrelatedIssue = await createIssue(db, company.id);
|
||||
const actor = {
|
||||
type: "agent" as const,
|
||||
agentId: bridgeAgent.id,
|
||||
companyId: company.id,
|
||||
source: "agent_key" as const,
|
||||
keyId,
|
||||
keyScope: {
|
||||
kind: "task_bridge" as const,
|
||||
parentIssueId: parentIssue.id,
|
||||
allowedAssigneeAgentIds: [targetAgent.id],
|
||||
},
|
||||
};
|
||||
const authz = authorizationService(db);
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "company_scope:read",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "tasks:assign",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
parentIssueId: parentIssue.id,
|
||||
assigneeAgentId: targetAgent.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "tasks:assign",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
assigneeUserId: randomUUID(),
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:comment",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: assignedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:mutate",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: bridgeCreatedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:mutate",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: unrelatedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "agent_config:read",
|
||||
resource: {
|
||||
type: "agent",
|
||||
companyId: company.id,
|
||||
agentId: bridgeAgent.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,11 @@ describe("awsSecretsManagerProvider", () => {
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
|
||||
AWS_PROFILE: process.env.AWS_PROFILE,
|
||||
AWS_DEFAULT_PROFILE: process.env.AWS_DEFAULT_PROFILE,
|
||||
AWS_CONFIG_FILE: process.env.AWS_CONFIG_FILE,
|
||||
AWS_SHARED_CREDENTIALS_FILE: process.env.AWS_SHARED_CREDENTIALS_FILE,
|
||||
AWS_SDK_LOAD_CONFIG: process.env.AWS_SDK_LOAD_CONFIG,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -169,6 +174,11 @@ describe("awsSecretsManagerProvider", () => {
|
||||
});
|
||||
|
||||
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
|
||||
delete process.env.AWS_PROFILE;
|
||||
delete process.env.AWS_DEFAULT_PROFILE;
|
||||
delete process.env.AWS_CONFIG_FILE;
|
||||
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
delete process.env.AWS_SDK_LOAD_CONFIG;
|
||||
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
process.env.AWS_SESSION_TOKEN = "test-session-token";
|
||||
|
||||
@@ -1537,7 +1537,7 @@ describe("company portability", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ strictMode: false },
|
||||
{ strictMode: false, adapterType: "codex_local" },
|
||||
);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
@@ -3490,7 +3490,7 @@ describe("company portability", () => {
|
||||
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
|
||||
"company-imported",
|
||||
expect.anything(),
|
||||
{ strictMode: false },
|
||||
{ strictMode: false, adapterType: "claude_local" },
|
||||
);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
adapterType: "claude_local",
|
||||
@@ -3559,7 +3559,7 @@ describe("company portability", () => {
|
||||
model: "gpt-5.4",
|
||||
extraArgs: ["--skip-git-repo-check"],
|
||||
}),
|
||||
{ strictMode: false },
|
||||
{ strictMode: false, adapterType: "codex_local" },
|
||||
);
|
||||
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
|
||||
adapterType: "codex_local",
|
||||
|
||||
@@ -54,6 +54,11 @@ vi.mock("../adapters/index.js", () => ({
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
findActiveServerAdapter: () => ({
|
||||
type: "codex_local",
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
listAdapterModelProfiles: async () => [],
|
||||
runningProcesses: new Map(),
|
||||
}));
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock("../adapters/index.js", () => ({
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
findActiveServerAdapter: () => ({
|
||||
type: "codex_local",
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
listAdapterModelProfiles: async () => [],
|
||||
runningProcesses: new Map(),
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
invites,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
prepareAgentDefaultsPayloadForJoinPersistence,
|
||||
} from "../routes/access.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres invite accept gateway defaults tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
|
||||
it("leaves non-gateway payloads unchanged", () => {
|
||||
@@ -117,3 +145,162 @@ describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentDefaultsForJoin (hermes_gateway)", () => {
|
||||
it("rejects remote plain HTTP by default", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "hermes_gateway",
|
||||
defaultsPayload: {
|
||||
apiBaseUrl: "http://192.168.1.25:8642",
|
||||
apiKey: "hermes-key-1234567890",
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors.join("\n")).toContain("remote plain HTTP");
|
||||
expect(normalized.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "hermes_gateway_plain_http_remote_denied",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows remote plain HTTP only with the explicit unsafe flag", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "hermes_gateway",
|
||||
defaultsPayload: {
|
||||
apiBaseUrl: "http://192.168.1.25:8642",
|
||||
apiKey: "hermes-key-1234567890",
|
||||
dangerouslyAllowInsecureRemoteHttp: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.apiBaseUrl).toBe("http://192.168.1.25:8642/");
|
||||
expect(normalized.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "hermes_gateway_plain_http_remote_unsafe_allowed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("prepareAgentDefaultsPayloadForJoinPersistence (hermes_gateway)", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-hermes-join-defaults-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("hermes-join-defaults");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores a secret ref instead of the literal apiKey in join request defaults", async () => {
|
||||
const companyId = randomUUID();
|
||||
const inviteId = randomUUID();
|
||||
const joinRequestId = randomUUID();
|
||||
const literalApiKey = `hermes-key-${randomUUID()}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: `invite-token-${randomUUID()}`,
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const joinDefaults = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "hermes_gateway",
|
||||
defaultsPayload: {
|
||||
apiBaseUrl: "https://hermes.example",
|
||||
apiKey: literalApiKey,
|
||||
paperclipApiUrl: "https://paperclip.example",
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
expect(joinDefaults.fatalErrors).toEqual([]);
|
||||
|
||||
const persistedDefaults = await prepareAgentDefaultsPayloadForJoinPersistence({
|
||||
db,
|
||||
companyId,
|
||||
adapterType: "hermes_gateway",
|
||||
normalized: joinDefaults.normalized,
|
||||
});
|
||||
|
||||
await db.insert(joinRequests).values({
|
||||
id: joinRequestId,
|
||||
inviteId,
|
||||
companyId,
|
||||
requestType: "agent",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
agentName: "Hermes Gateway",
|
||||
adapterType: "hermes_gateway",
|
||||
capabilities: "Hermes gateway agent",
|
||||
agentDefaultsPayload: persistedDefaults,
|
||||
claimSecretHash: "claim-secret-hash",
|
||||
claimSecretExpiresAt: new Date("2027-03-11T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const persistedJoinRequest = await db
|
||||
.select()
|
||||
.from(joinRequests)
|
||||
.where(eq(joinRequests.id, joinRequestId))
|
||||
.then((rows) => rows[0]);
|
||||
const storedPayload = persistedJoinRequest?.agentDefaultsPayload as Record<string, unknown>;
|
||||
expect(JSON.stringify(storedPayload)).not.toContain(literalApiKey);
|
||||
expect(storedPayload.apiKey).toMatchObject({
|
||||
type: "secret_ref",
|
||||
version: "latest",
|
||||
});
|
||||
|
||||
const storedSecrets = await db.select().from(companySecrets);
|
||||
expect(storedSecrets).toHaveLength(1);
|
||||
expect((storedPayload.apiKey as { secretId: string }).secretId).toBe(storedSecrets[0]?.id);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,21 @@ describe("buildInviteOnboardingTextDocument", () => {
|
||||
expect(text).toContain('"adapterType": "openclaw_gateway"');
|
||||
expect(text).toContain("headers.x-openclaw-token");
|
||||
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
|
||||
expect(text).toContain('adapterType: "hermes_gateway"');
|
||||
expect(text).toContain('"adapterType": "hermes_gateway"');
|
||||
expect(text).toContain("API_SERVER_ENABLED=true");
|
||||
expect(text).toContain("API_SERVER_KEY");
|
||||
expect(text).toContain("hermes gateway run --replace --accept-hooks");
|
||||
expect(text).toContain("Default Hermes API server port: 8642");
|
||||
expect(text).toContain("agentDefaultsPayload.apiBaseUrl");
|
||||
expect(text).toContain("agentDefaultsPayload.paperclipApiUrl");
|
||||
expect(text).toContain("hermes_local");
|
||||
expect(text).toContain("Hermes-originated Paperclip API usage");
|
||||
expect(text).toContain("http://127.0.0.1:8642");
|
||||
expect(text).toContain("http://192.168.1.25:8642");
|
||||
expect(text).toContain("tailnet-name.ts.net:8642");
|
||||
expect(text).toContain("http://host.docker.internal:8642");
|
||||
expect(text).toContain("https://hermes-gateway.example");
|
||||
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
|
||||
expect(text).toContain("PAPERCLIP_API_KEY");
|
||||
expect(text).toContain("Use your runtime's normal skill or instruction installation path.");
|
||||
|
||||
@@ -669,6 +669,22 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
mockStorageService.deleteObject.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("denies company-wide issue list routes for task bridge keys", async () => {
|
||||
const app = await createApp(peerActor({
|
||||
keyId: "99999999-9999-4999-8999-999999999999",
|
||||
keyScope: {
|
||||
kind: "task_bridge",
|
||||
parentIssueId: issueId,
|
||||
},
|
||||
}));
|
||||
|
||||
const res = await request(app).get(`/api/companies/${companyId}/issues`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs");
|
||||
expect(mockIssueService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the company-scope fast path on the issue list route", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => {
|
||||
if (input.action === "company_scope:read") {
|
||||
|
||||
@@ -72,4 +72,39 @@ describe("llm routes", () => {
|
||||
expect(res.text).toContain("Timer heartbeats are opt-in for new hires.");
|
||||
expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false");
|
||||
});
|
||||
|
||||
it("serves static Hermes Gateway configuration docs before the plugin is installed", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const indexRes = await request(app).get("/api/llms/agent-configuration.txt");
|
||||
expect(indexRes.status).toBe(200);
|
||||
expect(indexRes.text).toContain(
|
||||
"- hermes_gateway: /llms/agent-configuration/hermes_gateway.txt",
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/llms/agent-configuration/hermes_gateway.txt");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain("Adapter: hermes_gateway");
|
||||
expect(res.text).toContain('adapterType": "hermes_gateway"');
|
||||
expect(res.text).toContain("API_SERVER_ENABLED=true");
|
||||
expect(res.text).toContain("API_SERVER_KEY");
|
||||
expect(res.text).toContain("hermes gateway run --replace --accept-hooks");
|
||||
expect(res.text).toContain("Default Hermes API server port: 8642");
|
||||
expect(res.text).toContain("agentDefaultsPayload.apiBaseUrl");
|
||||
expect(res.text).toContain("agentDefaultsPayload.paperclipApiUrl");
|
||||
expect(res.text).toContain("hermes_local");
|
||||
expect(res.text).toContain("Hermes-originated Paperclip API usage");
|
||||
expect(res.text).toContain("http://127.0.0.1:8642");
|
||||
expect(res.text).toContain("http://192.168.1.25:8642");
|
||||
expect(res.text).toContain("tailnet-name.ts.net:8642");
|
||||
expect(res.text).toContain("http://host.docker.internal:8642");
|
||||
expect(res.text).toContain("https://hermes-gateway.example");
|
||||
});
|
||||
});
|
||||
@@ -998,6 +998,11 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
version: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const persisted = await svc.getByName(companyId, "Create Rollback");
|
||||
expect(persisted).toBeNull();
|
||||
const versions = await db.select().from(companySecretVersions);
|
||||
expect(versions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a local cleanup handle when create rollback cleanup fails", async () => {
|
||||
|
||||
@@ -9,10 +9,11 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"grok_local",
|
||||
"hermes_gateway",
|
||||
"hermes_local",
|
||||
"openclaw_gateway",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"hermes_local",
|
||||
"process",
|
||||
"http",
|
||||
]);
|
||||
@@ -0,0 +1,56 @@
|
||||
export const hermesGatewayAgentConfigurationDoc = `# hermes_gateway agent configuration
|
||||
|
||||
Adapter: hermes_gateway
|
||||
|
||||
Use when:
|
||||
- Hermes is already running outside Paperclip and exposes its API server.
|
||||
- Paperclip should invoke Hermes through the gateway HTTP API instead of spawning Hermes locally.
|
||||
- The Hermes runtime may live on another host, a private overlay, in Docker, or behind a TLS reverse proxy.
|
||||
|
||||
Don't use when:
|
||||
- Paperclip should start the Hermes CLI directly on the same host. Use the built-in hermes_local adapter for that flow.
|
||||
- Hermes is the process calling Paperclip APIs after claiming its key. That is Hermes-originated Paperclip API usage, not the gateway adapter transport.
|
||||
|
||||
Runtime distinction:
|
||||
- hermes_local: Paperclip starts Hermes on the Paperclip host through the built-in local adapter.
|
||||
- hermes_gateway: Paperclip calls an already-running Hermes API server using agentDefaultsPayload.apiBaseUrl.
|
||||
- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_BRIDGE_API_KEY. Create that Paperclip key with scope.kind = "task_bridge" and an approved parent/project boundary; do not expose a normal claimed agent key to Hermes-facing chat/webhook surfaces. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls.
|
||||
|
||||
Hermes gateway process setup:
|
||||
- Set API_SERVER_ENABLED=true.
|
||||
- Set API_SERVER_KEY to a generated secret value. Do not paste a real key into tickets, docs, screenshots, or tests.
|
||||
- Start Hermes with: hermes gateway run --replace --accept-hooks
|
||||
- Default Hermes API server port: 8642.
|
||||
|
||||
Join request minimum:
|
||||
{
|
||||
"requestType": "agent",
|
||||
"agentName": "My Hermes Gateway Agent",
|
||||
"adapterType": "hermes_gateway",
|
||||
"capabilities": "Hermes gateway agent",
|
||||
"agentDefaultsPayload": {
|
||||
"apiBaseUrl": "http://127.0.0.1:8642",
|
||||
"apiKey": "<same-value-as-API_SERVER_KEY>",
|
||||
"paperclipApiUrl": "http://localhost:3100"
|
||||
}
|
||||
}
|
||||
|
||||
Core fields:
|
||||
- agentDefaultsPayload.apiBaseUrl (string, required): Base URL for the Hermes API server as reachable from the Paperclip server.
|
||||
- agentDefaultsPayload.apiKey (string, required unless the adapter package documents another auth field): Hermes API server key matching API_SERVER_KEY.
|
||||
- agentDefaultsPayload.paperclipApiUrl (string, strongly recommended): Paperclip base URL as reachable from Hermes for invite, claim, skill bootstrap, and later Paperclip API calls.
|
||||
- agentDefaultsPayload.timeoutSec or timeoutMs (number, optional): Runtime request timeout when supported by the installed Hermes gateway adapter.
|
||||
|
||||
Network examples:
|
||||
- Local loopback on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642"; agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100".
|
||||
- LAN/private network: agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642"; agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". Use private IPs or hostnames reachable from both machines.
|
||||
- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm paperclipai allowed-hostname <host> when authenticated/private mode requires it.
|
||||
- Docker: if Hermes runs on the host and Paperclip runs in Docker, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642". If Hermes runs in another container, use the Compose service DNS name such as "http://hermes:8642".
|
||||
- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy.
|
||||
|
||||
Security notes:
|
||||
- Treat API_SERVER_KEY and PAPERCLIP_BRIDGE_API_KEY as secrets.
|
||||
- Never use a normal claimed Paperclip agent API key for internet-facing Hermes-originated task bridge calls; task_bridge keys cannot use company-wide issue list/search/read surfaces and can only mutate bridge-created or assigned issues.
|
||||
- Prefer private network or TLS for non-loopback gateway access.
|
||||
- Use placeholders such as <same-value-as-API_SERVER_KEY> in docs and tests.
|
||||
`;
|
||||
+10
-133
@@ -92,6 +92,10 @@ import {
|
||||
agentConfigurationDoc as grokAgentConfigurationDoc,
|
||||
models as grokModels,
|
||||
} from "@paperclipai/adapter-grok-local";
|
||||
import {
|
||||
createHermesGatewayServerAdapter,
|
||||
createHermesLocalServerAdapter,
|
||||
} from "@paperclipai/hermes-paperclip-adapter";
|
||||
import {
|
||||
execute as openCodeExecute,
|
||||
listOpenCodeSkills,
|
||||
@@ -127,18 +131,6 @@ import {
|
||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||
modelProfiles as piModelProfiles,
|
||||
} from "@paperclipai/adapter-pi-local";
|
||||
import {
|
||||
execute as hermesExecute,
|
||||
testEnvironment as hermesTestEnvironment,
|
||||
sessionCodec as hermesSessionCodec,
|
||||
listSkills as hermesListSkills,
|
||||
syncSkills as hermesSyncSkills,
|
||||
detectModel as detectModelFromHermes,
|
||||
} from "hermes-paperclip-adapter/server";
|
||||
import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
models as hermesModels,
|
||||
} from "hermes-paperclip-adapter";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
import { buildExternalAdapters } from "./plugin-loader.js";
|
||||
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
|
||||
@@ -184,55 +176,6 @@ function buildCursorRuntimeCommandSpec(config: Record<string, unknown>): Adapter
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(ctx: T): T {
|
||||
const config =
|
||||
ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object"
|
||||
? (ctx.config as Record<string, unknown>)
|
||||
: null;
|
||||
const agent =
|
||||
ctx && typeof ctx === "object" && "agent" in ctx && ctx.agent && typeof ctx.agent === "object"
|
||||
? (ctx.agent as Record<string, unknown>)
|
||||
: null;
|
||||
const agentAdapterConfig =
|
||||
agent?.adapterConfig && typeof agent.adapterConfig === "object"
|
||||
? (agent.adapterConfig as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const configCommand =
|
||||
typeof config?.command === "string" && config.command.length > 0 ? config.command : undefined;
|
||||
const agentCommand =
|
||||
typeof agentAdapterConfig?.command === "string" && agentAdapterConfig.command.length > 0
|
||||
? agentAdapterConfig.command
|
||||
: undefined;
|
||||
|
||||
if (config && !config.hermesCommand && configCommand) {
|
||||
config.hermesCommand = configCommand;
|
||||
}
|
||||
if (agentAdapterConfig && !agentAdapterConfig.hermesCommand && agentCommand) {
|
||||
agentAdapterConfig.hermesCommand = agentCommand;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function passHermesCustomProviderThroughExtraArgs(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const provider = typeof config.provider === "string" ? config.provider.trim() : "";
|
||||
if (!provider.startsWith("custom:")) return config;
|
||||
|
||||
const existingExtraArgs = Array.isArray(config.extraArgs)
|
||||
? config.extraArgs.filter((arg): arg is string => typeof arg === "string")
|
||||
: [];
|
||||
const alreadyHasProviderArg = existingExtraArgs.some((arg) =>
|
||||
arg === "--provider" || arg.startsWith("--provider=")
|
||||
);
|
||||
if (alreadyHasProviderArg) return config;
|
||||
|
||||
return {
|
||||
...config,
|
||||
extraArgs: [...existingExtraArgs, "--provider", provider],
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const result: AdapterModel[] = [];
|
||||
@@ -403,6 +346,10 @@ const grokLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: grokAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const hermesGatewayAdapter = createHermesGatewayServerAdapter();
|
||||
|
||||
const hermesLocalAdapter = createHermesLocalServerAdapter();
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
@@ -453,77 +400,6 @@ const piLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is
|
||||
// intentional until hermes ships a matching AdapterExecutionContext type.
|
||||
const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"];
|
||||
// hermes-paperclip-adapter v0.2.0 still depends on the published @paperclipai/adapter-utils
|
||||
// that ships the "paperclip_required" origin; casts bridge until hermes upgrades.
|
||||
const listHermesSkills = hermesListSkills as unknown as ServerAdapterModule["listSkills"];
|
||||
const syncHermesSkills = hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"];
|
||||
|
||||
const hermesLocalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_local",
|
||||
execute: async (ctx) => {
|
||||
const normalizedCtx = normalizeHermesConfig(ctx);
|
||||
if (!normalizedCtx.authToken) return executeHermesLocal(normalizedCtx);
|
||||
|
||||
const existingConfig = (normalizedCtx.agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const existingEnv =
|
||||
typeof existingConfig.env === "object" && existingConfig.env !== null && !Array.isArray(existingConfig.env)
|
||||
? (existingConfig.env as Record<string, string>)
|
||||
: {};
|
||||
const explicitApiKey =
|
||||
typeof existingEnv.PAPERCLIP_API_KEY === "string" && existingEnv.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const promptTemplate =
|
||||
typeof existingConfig.promptTemplate === "string" && existingConfig.promptTemplate.trim().length > 0
|
||||
? existingConfig.promptTemplate
|
||||
: "";
|
||||
const authGuardPrompt = [
|
||||
"Paperclip API safety rule:",
|
||||
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every Paperclip API request.",
|
||||
"Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every Paperclip API request that writes or mutates data, including comments and issue updates.",
|
||||
"Never use a board, browser, or local-board session for Paperclip API writes.",
|
||||
].join("\n");
|
||||
|
||||
const patchedConfig: Record<string, unknown> = {
|
||||
...existingConfig,
|
||||
env: {
|
||||
...existingEnv,
|
||||
...(!explicitApiKey ? { PAPERCLIP_API_KEY: normalizedCtx.authToken } : {}),
|
||||
PAPERCLIP_RUN_ID: normalizedCtx.runId,
|
||||
},
|
||||
};
|
||||
const effectivePatchedConfig = passHermesCustomProviderThroughExtraArgs(patchedConfig);
|
||||
|
||||
// Only inject the auth guard into promptTemplate when a custom template already exists.
|
||||
// When no custom template is set, Hermes uses its built-in default heartbeat/task prompt —
|
||||
// overwriting it with only the auth guard text would strip the assigned issue/workflow instructions.
|
||||
if (promptTemplate) {
|
||||
effectivePatchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`;
|
||||
}
|
||||
|
||||
const patchedCtx = {
|
||||
...normalizedCtx,
|
||||
agent: {
|
||||
...normalizedCtx.agent,
|
||||
adapterConfig: effectivePatchedConfig,
|
||||
},
|
||||
};
|
||||
|
||||
return executeHermesLocal(patchedCtx);
|
||||
},
|
||||
testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never),
|
||||
sessionCodec: hermesSessionCodec,
|
||||
listSkills: listHermesSkills,
|
||||
syncSkills: syncHermesSkills,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||
detectModel: () => detectModelFromHermes(),
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>();
|
||||
|
||||
// For builtin types that are overridden by an external adapter, we keep the
|
||||
@@ -546,8 +422,9 @@ function registerBuiltInAdapters() {
|
||||
cursorLocalAdapter,
|
||||
geminiLocalAdapter,
|
||||
grokLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
hermesGatewayAdapter,
|
||||
hermesLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
]) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { boardAuthService } from "../services/board-auth.js";
|
||||
@@ -192,6 +192,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
agentId: key.agentId,
|
||||
companyId: key.companyId,
|
||||
keyId: key.id,
|
||||
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
|
||||
runId: runIdHeader || undefined,
|
||||
source: "agent_key",
|
||||
};
|
||||
|
||||
+164
-9
@@ -83,6 +83,7 @@ import {
|
||||
} from "../board-claim.js";
|
||||
import { claimFirstInstanceAdmin } from "../first-admin-claim.js";
|
||||
import { getStorageService } from "../storage/index.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -294,8 +295,14 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||
const value = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
value === "localhost" ||
|
||||
value === "::1" ||
|
||||
value === "0:0:0:0:0:0:0:1" ||
|
||||
value === "127.0.0.1" ||
|
||||
/^127(?:\.\d{1,3}){3}$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string | null | undefined): string | null {
|
||||
@@ -474,8 +481,8 @@ function parseBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1") return true;
|
||||
if (normalized === "false" || normalized === "0") return false;
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "off"].includes(normalized)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -676,6 +683,91 @@ export function normalizeAgentDefaultsForJoin(input: {
|
||||
}) {
|
||||
const fatalErrors: string[] = [];
|
||||
const diagnostics: JoinDiagnostic[] = [];
|
||||
if (input.adapterType === "hermes_gateway") {
|
||||
if (!isPlainObject(input.defaultsPayload)) {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_defaults_missing",
|
||||
level: "warn",
|
||||
message: "No Hermes gateway config was provided in agentDefaultsPayload.",
|
||||
hint: "Include agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.apiKey for Hermes gateway joins.",
|
||||
});
|
||||
fatalErrors.push("agentDefaultsPayload is required for adapterType=hermes_gateway");
|
||||
return { normalized: null as Record<string, unknown> | null, diagnostics, fatalErrors };
|
||||
}
|
||||
|
||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||
const normalized = { ...defaults };
|
||||
const rawApiBaseUrl = nonEmptyTrimmedString(defaults.apiBaseUrl ?? defaults.url);
|
||||
if (!rawApiBaseUrl) {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_api_base_url_missing",
|
||||
level: "warn",
|
||||
message: "Hermes gateway apiBaseUrl is missing.",
|
||||
hint: "Set agentDefaultsPayload.apiBaseUrl to the Hermes API server URL.",
|
||||
});
|
||||
fatalErrors.push("agentDefaultsPayload.apiBaseUrl is required");
|
||||
} else {
|
||||
try {
|
||||
const apiBaseUrl = new URL(rawApiBaseUrl);
|
||||
if (apiBaseUrl.protocol !== "http:" && apiBaseUrl.protocol !== "https:") {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_api_base_url_protocol",
|
||||
level: "warn",
|
||||
message: `Hermes gateway apiBaseUrl must use http:// or https:// (got ${apiBaseUrl.protocol}).`,
|
||||
});
|
||||
fatalErrors.push("agentDefaultsPayload.apiBaseUrl must use http:// or https:// for hermes_gateway");
|
||||
} else if (
|
||||
apiBaseUrl.protocol === "http:" &&
|
||||
!isLoopbackHost(apiBaseUrl.hostname) &&
|
||||
parseBooleanLike(defaults.dangerouslyAllowInsecureRemoteHttp) !== true
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_plain_http_remote_denied",
|
||||
level: "warn",
|
||||
message: "Remote plain HTTP Hermes gateway traffic is denied by default.",
|
||||
hint: "Use https:// or set agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp=true only for unsafe local development.",
|
||||
});
|
||||
fatalErrors.push(
|
||||
"agentDefaultsPayload.apiBaseUrl uses remote plain HTTP; use HTTPS or set dangerouslyAllowInsecureRemoteHttp=true for unsafe local development",
|
||||
);
|
||||
} else {
|
||||
normalized.apiBaseUrl = apiBaseUrl.toString();
|
||||
if (apiBaseUrl.protocol === "http:" && !isLoopbackHost(apiBaseUrl.hostname)) {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_plain_http_remote_unsafe_allowed",
|
||||
level: "warn",
|
||||
message: "Unsafe dev escape hatch enabled for non-loopback HTTP Hermes traffic.",
|
||||
});
|
||||
} else {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_api_base_url_configured",
|
||||
level: "info",
|
||||
message: `Hermes gateway endpoint set to ${apiBaseUrl.toString()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_api_base_url_invalid",
|
||||
level: "warn",
|
||||
message: `Invalid Hermes gateway apiBaseUrl: ${rawApiBaseUrl}`,
|
||||
});
|
||||
fatalErrors.push("agentDefaultsPayload.apiBaseUrl is not a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
if (!nonEmptyTrimmedString(defaults.apiKey)) {
|
||||
diagnostics.push({
|
||||
code: "hermes_gateway_api_key_missing",
|
||||
level: "warn",
|
||||
message: "Hermes gateway API key is missing.",
|
||||
hint: "Set agentDefaultsPayload.apiKey to the Hermes API_SERVER_KEY value.",
|
||||
});
|
||||
fatalErrors.push("agentDefaultsPayload.apiKey is required");
|
||||
}
|
||||
|
||||
return { normalized, diagnostics, fatalErrors };
|
||||
}
|
||||
if (input.adapterType !== "openclaw_gateway") {
|
||||
const normalized = isPlainObject(input.defaultsPayload)
|
||||
? (input.defaultsPayload as Record<string, unknown>)
|
||||
@@ -919,6 +1011,24 @@ export function normalizeAgentDefaultsForJoin(input: {
|
||||
return { normalized, diagnostics, fatalErrors };
|
||||
}
|
||||
|
||||
export async function prepareAgentDefaultsPayloadForJoinPersistence(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
adapterType: string | null;
|
||||
normalized: Record<string, unknown> | null;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
if (input.adapterType !== "hermes_gateway" || !input.normalized) {
|
||||
return input.normalized;
|
||||
}
|
||||
|
||||
return secretService(input.db).normalizeAdapterConfigForPersistence(
|
||||
input.companyId,
|
||||
input.normalized,
|
||||
{ adapterType: input.adapterType, actor: input.actor },
|
||||
);
|
||||
}
|
||||
|
||||
function toInviteSummaryResponse(
|
||||
req: Request,
|
||||
token: string,
|
||||
@@ -1585,17 +1695,17 @@ function buildInviteOnboardingManifest(
|
||||
),
|
||||
onboarding: {
|
||||
instructions:
|
||||
"Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token.",
|
||||
"Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token. Hermes Gateway agents must use adapterType='hermes_gateway', start Hermes with API_SERVER_ENABLED=true, API_SERVER_KEY, and `hermes gateway run --replace --accept-hooks`, then set agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.paperclipApiUrl.",
|
||||
inviteMessage: extractInviteMessage(invite),
|
||||
recommendedAdapterType: null,
|
||||
requiredFields: {
|
||||
requestType: "agent",
|
||||
agentName: "Display name for this agent",
|
||||
adapterType:
|
||||
"Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents.",
|
||||
"Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents. Use 'hermes_gateway' only for Hermes Gateway agents.",
|
||||
capabilities: "Optional capability summary",
|
||||
agentDefaultsPayload:
|
||||
"Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Other runtimes should include the config their adapter expects."
|
||||
"Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Hermes Gateway agents must include apiBaseUrl, API_SERVER_KEY-backed auth, and paperclipApiUrl. Other runtimes should include the config their adapter expects."
|
||||
},
|
||||
registrationEndpoint: {
|
||||
method: "POST",
|
||||
@@ -1744,6 +1854,37 @@ export function buildInviteOnboardingTextDocument(
|
||||
|
||||
For OpenClaw Gateway, include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. Do NOT use /v1/responses or /hooks/* in this gateway join flow.
|
||||
|
||||
Hermes Gateway setup:
|
||||
- adapterType: "hermes_gateway"
|
||||
- Start Hermes with API_SERVER_ENABLED=true and API_SERVER_KEY=<random-gateway-key>.
|
||||
- Run: hermes gateway run --replace --accept-hooks
|
||||
- Default Hermes API server port: 8642.
|
||||
- Set agentDefaultsPayload.apiBaseUrl to the Hermes gateway URL Paperclip can reach.
|
||||
- Set agentDefaultsPayload.paperclipApiUrl to the Paperclip base URL Hermes can reach.
|
||||
- Use hermes_local when Paperclip should start Hermes on the Paperclip host.
|
||||
- Use hermes_gateway when Paperclip should call an already-running Hermes API server.
|
||||
- Hermes-originated Paperclip API usage means Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after approval/key claim. Do not confuse that with agentDefaultsPayload.apiBaseUrl, which points Paperclip to Hermes.
|
||||
|
||||
Hermes Gateway payload example:
|
||||
{
|
||||
"requestType": "agent",
|
||||
"agentName": "My Hermes Gateway Agent",
|
||||
"adapterType": "hermes_gateway",
|
||||
"capabilities": "Hermes gateway agent",
|
||||
"agentDefaultsPayload": {
|
||||
"apiBaseUrl": "http://127.0.0.1:8642",
|
||||
"apiKey": "<same-value-as-API_SERVER_KEY>",
|
||||
"paperclipApiUrl": "http://localhost:3100"
|
||||
}
|
||||
}
|
||||
|
||||
Hermes Gateway network examples:
|
||||
- Local loopback: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642" and agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100".
|
||||
- LAN/private network: use reachable private addresses, for example agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642" and agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100".
|
||||
- Private overlay: use overlay DNS names, for example agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642" and agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100".
|
||||
- Docker: if Paperclip runs in Docker and Hermes runs on the host, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642"; if both run in Compose, use the Hermes service name.
|
||||
- Reverse proxy/TLS: use HTTPS origins, for example agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example" and agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example".
|
||||
|
||||
Expected response includes:
|
||||
- request id
|
||||
- one-time claimSecret
|
||||
@@ -3565,6 +3706,20 @@ export function accessRoutes(
|
||||
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
||||
}
|
||||
|
||||
const persistedJoinDefaultsPayload =
|
||||
requestType === "agent"
|
||||
? await prepareAgentDefaultsPayloadForJoinPersistence({
|
||||
db,
|
||||
companyId,
|
||||
adapterType,
|
||||
normalized: joinDefaults.normalized,
|
||||
actor: {
|
||||
userId: req.actor.userId ?? null,
|
||||
agentId: req.actor.agentId ?? null
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||
logger.info(
|
||||
{
|
||||
@@ -3658,7 +3813,7 @@ export function accessRoutes(
|
||||
? req.body.capabilities ?? null
|
||||
: null,
|
||||
agentDefaultsPayload:
|
||||
requestType === "agent" ? joinDefaults.normalized : null,
|
||||
requestType === "agent" ? persistedJoinDefaultsPayload : null,
|
||||
claimSecretHash,
|
||||
claimSecretExpiresAt
|
||||
})
|
||||
@@ -3684,7 +3839,7 @@ export function accessRoutes(
|
||||
: null,
|
||||
adapterType: requestType === "agent" ? adapterType : null,
|
||||
agentDefaultsPayload:
|
||||
requestType === "agent" ? joinDefaults.normalized : null,
|
||||
requestType === "agent" ? persistedJoinDefaultsPayload : null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(joinRequests.id, replayJoinRequestId as string))
|
||||
|
||||
@@ -140,7 +140,6 @@ export function agentRoutes(
|
||||
codex_local: "instructionsFilePath",
|
||||
droid_local: "instructionsFilePath",
|
||||
gemini_local: "instructionsFilePath",
|
||||
hermes_local: "instructionsFilePath",
|
||||
opencode_local: "instructionsFilePath",
|
||||
cursor: "instructionsFilePath",
|
||||
pi_local: "instructionsFilePath",
|
||||
@@ -1079,7 +1078,10 @@ export function agentRoutes(
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
input.companyId,
|
||||
input.adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{
|
||||
strictMode: strictSecretsMode,
|
||||
adapterType: input.adapterType ?? null,
|
||||
},
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
input.adapterType,
|
||||
@@ -1617,11 +1619,13 @@ export function agentRoutes(
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
inputAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{ strictMode: strictSecretsMode, adapterType: type },
|
||||
);
|
||||
const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
normalizedAdapterConfig,
|
||||
undefined,
|
||||
{ adapterType: type },
|
||||
);
|
||||
|
||||
const { executionTarget, environmentName, fallbackChecks, release } =
|
||||
@@ -1943,6 +1947,18 @@ export function agentRoutes(
|
||||
res.json(buildLowTrustSelfView(agent));
|
||||
return;
|
||||
}
|
||||
if (req.actor.keyScope?.kind === "task_bridge") {
|
||||
res.json({
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
status: agent.status,
|
||||
keyScope: req.actor.keyScope,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
||||
@@ -2572,7 +2588,7 @@ export function agentRoutes(
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
syncedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{ strictMode: strictSecretsMode, adapterType: existing.adapterType },
|
||||
);
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(
|
||||
@@ -2643,7 +2659,7 @@ export function agentRoutes(
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{ strictMode: strictSecretsMode, adapterType: existing.adapterType },
|
||||
);
|
||||
await svc.update(
|
||||
id,
|
||||
@@ -2711,7 +2727,7 @@ export function agentRoutes(
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
result.adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{ strictMode: strictSecretsMode, adapterType: existing.adapterType },
|
||||
);
|
||||
await svc.update(
|
||||
id,
|
||||
@@ -3184,7 +3200,7 @@ export function agentRoutes(
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const key = await svc.createApiKey(id, req.body.name);
|
||||
const key = await svc.createApiKey(id, req.body.name, req.body.scope);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -3193,7 +3209,7 @@ export function agentRoutes(
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
details: { keyId: key.id, name: key.name, scope: key.scope },
|
||||
});
|
||||
|
||||
res.status(201).json(key);
|
||||
|
||||
+52
-19
@@ -1889,6 +1889,25 @@ export function issueRoutes(
|
||||
throw forbidden(decision.explanation);
|
||||
}
|
||||
|
||||
function isTaskBridgeKeyActor(req: Request) {
|
||||
return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge";
|
||||
}
|
||||
|
||||
function taskBridgeOriginForActor(req: Request) {
|
||||
return isTaskBridgeKeyActor(req) && req.actor.keyId
|
||||
? { originKind: "task_bridge", originId: req.actor.keyId }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function assertTaskBridgeCreateAllowed(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
assignmentScope: TaskAssignmentAuthorizationScope,
|
||||
) {
|
||||
if (!isTaskBridgeKeyActor(req)) return;
|
||||
await assertCanAssignTasks(req, companyId, assignmentScope);
|
||||
}
|
||||
|
||||
async function decideIssueAccess(
|
||||
req: Request,
|
||||
issue: {
|
||||
@@ -2966,6 +2985,10 @@ export function issueRoutes(
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (isTaskBridgeKeyActor(req)) {
|
||||
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" });
|
||||
return;
|
||||
}
|
||||
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
||||
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
|
||||
const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined;
|
||||
@@ -3126,6 +3149,10 @@ export function issueRoutes(
|
||||
router.get("/companies/:companyId/issues/count", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (isTaskBridgeKeyActor(req)) {
|
||||
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue count APIs" });
|
||||
return;
|
||||
}
|
||||
const attention = req.query.attention as string | undefined;
|
||||
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
|
||||
if (attention !== "blocked") {
|
||||
@@ -5050,7 +5077,7 @@ export function issueRoutes(
|
||||
if (watchdogProductBugFollowUp === false) return;
|
||||
const effectiveParentId = watchdogProductBugFollowUp ? null : rawCreateBody.parentId;
|
||||
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp) {
|
||||
if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp && !isTaskBridgeKeyActor(req)) {
|
||||
const companyScopeDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "company_scope:read",
|
||||
@@ -5067,7 +5094,7 @@ export function issueRoutes(
|
||||
res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertIssueReadAllowed(req, res, createParent))) return;
|
||||
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, createParent))) return;
|
||||
}
|
||||
if (
|
||||
!watchdogProductBugFollowUp &&
|
||||
@@ -5113,17 +5140,19 @@ export function issueRoutes(
|
||||
: {}),
|
||||
};
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
|
||||
const createAssignmentScope = {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
companyId,
|
||||
projectId: createBody.projectId,
|
||||
parentIssueId: createBody.parentId,
|
||||
}),
|
||||
parentIssueId: createBody.parentId ?? null,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
|
||||
};
|
||||
await assertTaskBridgeCreateAllowed(req, companyId, createAssignmentScope);
|
||||
if (rawCreateBody.assigneeAgentId || rawCreateBody.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, companyId, {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
companyId,
|
||||
projectId: createBody.projectId,
|
||||
parentIssueId: createBody.parentId,
|
||||
}),
|
||||
parentIssueId: createBody.parentId ?? null,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
|
||||
});
|
||||
await assertCanAssignTasks(req, companyId, createAssignmentScope);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
@@ -5141,6 +5170,7 @@ export function issueRoutes(
|
||||
}, actor);
|
||||
const issue = await svc.create(companyId, {
|
||||
...createBody,
|
||||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
id: issueId,
|
||||
executionPolicy,
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
@@ -5258,7 +5288,7 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, parent.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
||||
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, parent))) return;
|
||||
if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return;
|
||||
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
@@ -5271,13 +5301,15 @@ export function issueRoutes(
|
||||
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
|
||||
};
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
|
||||
const childAssignmentScope = {
|
||||
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||
};
|
||||
await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope);
|
||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, parent.companyId, {
|
||||
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||
});
|
||||
await assertCanAssignTasks(req, parent.companyId, childAssignmentScope);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
@@ -5300,6 +5332,7 @@ export function issueRoutes(
|
||||
}, actor);
|
||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||
...createBody,
|
||||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
id: issueId,
|
||||
executionPolicy,
|
||||
...(currentSerializedChild
|
||||
|
||||
@@ -3,8 +3,13 @@ import type { Db } from "@paperclipai/db";
|
||||
import { AGENT_ICON_NAMES } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { listServerAdapters } from "../adapters/index.js";
|
||||
import { hermesGatewayAgentConfigurationDoc } from "../adapters/hermes-gateway-doc.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
|
||||
const pluginOnlyAdapterDocs = new Map<string, string>([
|
||||
["hermes_gateway", hermesGatewayAgentConfigurationDoc],
|
||||
]);
|
||||
|
||||
function hasCreatePermission(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
@@ -34,6 +39,15 @@ export function llmRoutes(db: Db) {
|
||||
"Installed adapters:",
|
||||
...adapters.map((adapter) => `- ${adapter.type}: /llms/agent-configuration/${adapter.type}.txt`),
|
||||
"",
|
||||
"Plugin-only adapter docs:",
|
||||
...Array.from(pluginOnlyAdapterDocs.keys())
|
||||
.filter(
|
||||
(adapterType) => !adapters.some((adapter) => adapter.type === adapterType),
|
||||
)
|
||||
.map(
|
||||
(adapterType) => `- ${adapterType}: /llms/agent-configuration/${adapterType}.txt`,
|
||||
),
|
||||
"",
|
||||
"Related API endpoints:",
|
||||
"- GET /api/companies/:companyId/agent-configurations",
|
||||
"- GET /api/agents/:id/configuration",
|
||||
@@ -72,6 +86,11 @@ export function llmRoutes(db: Db) {
|
||||
const adapterType = req.params.adapterType as string;
|
||||
const adapter = listServerAdapters().find((entry) => entry.type === adapterType);
|
||||
if (!adapter) {
|
||||
const pluginOnlyDoc = pluginOnlyAdapterDocs.get(adapterType);
|
||||
if (pluginOnlyDoc) {
|
||||
res.type("text/plain").send(pluginOnlyDoc);
|
||||
return;
|
||||
}
|
||||
res.status(404).type("text/plain").send(`Unknown adapter type: ${adapterType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { envBindingSchema, type SecretVersionSelector } from "@paperclipai/shared";
|
||||
|
||||
interface AgentSecretBindingSyncService {
|
||||
syncSecretRefsForTarget?: (
|
||||
companyId: string,
|
||||
target: { targetType: "agent"; targetId: string },
|
||||
refs: Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
}>,
|
||||
options?: { replaceAll?: boolean },
|
||||
) => Promise<unknown>;
|
||||
syncEnvBindingsForTarget?: (
|
||||
companyId: string,
|
||||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
@@ -11,12 +25,63 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function collectSecretRefs(adapterConfig: unknown): Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
}> {
|
||||
const config = asRecord(adapterConfig);
|
||||
if (!config) return [];
|
||||
const refs: Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
}> = [];
|
||||
|
||||
const envValue = asRecord(config.env);
|
||||
for (const [key, rawBinding] of Object.entries(envValue ?? {})) {
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = parsed.data;
|
||||
if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue;
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: `env.${key}`,
|
||||
versionSelector: binding.version ?? "latest",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(config)) {
|
||||
if (key === "env") continue;
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = parsed.data;
|
||||
if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue;
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: key,
|
||||
versionSelector: binding.version ?? "latest",
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export async function syncAgentAdapterEnvBindings(input: {
|
||||
secretsSvc: AgentSecretBindingSyncService;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
adapterConfig: unknown;
|
||||
}) {
|
||||
if (input.secretsSvc.syncSecretRefsForTarget) {
|
||||
await input.secretsSvc.syncSecretRefsForTarget(
|
||||
input.companyId,
|
||||
{ targetType: "agent", targetId: input.agentId },
|
||||
collectSecretRefs(input.adapterConfig),
|
||||
{ replaceAll: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const envValue = asRecord(asRecord(input.adapterConfig)?.env);
|
||||
await input.secretsSvc.syncEnvBindingsForTarget?.(
|
||||
input.companyId,
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
getAgentWorkEligibility,
|
||||
isUuidLike,
|
||||
normalizeAgentApiKeyScope,
|
||||
normalizeAgentUrlKey,
|
||||
type AgentEligibilityAgent,
|
||||
type AgentApiKeyScope,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
@@ -428,6 +430,16 @@ export function agentService(db: Db) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
|
||||
isPlainRecord(normalizedPatch.adapterConfig)
|
||||
) {
|
||||
normalizedPatch.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
normalizedPatch.adapterConfig,
|
||||
{ adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
}
|
||||
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
@@ -503,11 +515,24 @@ export function agentService(db: Db) {
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
|
||||
const adapterType = data.adapterType ?? "process";
|
||||
const adapterConfig = isPlainRecord(data.adapterConfig)
|
||||
? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
|
||||
: {};
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const created = await tx
|
||||
.insert(agents)
|
||||
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig })
|
||||
.values({
|
||||
...data,
|
||||
name: uniqueName,
|
||||
companyId,
|
||||
role,
|
||||
adapterType,
|
||||
adapterConfig,
|
||||
permissions: normalizedPermissions,
|
||||
runtimeConfig,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
await syncAgentSecretBindings(created, txDb);
|
||||
@@ -733,7 +758,7 @@ export function agentService(db: Db) {
|
||||
});
|
||||
},
|
||||
|
||||
createApiKey: async (id: string, name: string) => {
|
||||
createApiKey: async (id: string, name: string, scope: AgentApiKeyScope = { kind: "standard" }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) throw notFound("Agent not found");
|
||||
if (existing.status === "pending_approval") {
|
||||
@@ -752,6 +777,7 @@ export function agentService(db: Db) {
|
||||
companyId: existing.companyId,
|
||||
name,
|
||||
keyHash,
|
||||
scopeConfig: scope.kind === "standard" ? null : scope,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
@@ -759,6 +785,7 @@ export function agentService(db: Db) {
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
scope: normalizeAgentApiKeyScope(created.scopeConfig),
|
||||
token,
|
||||
createdAt: created.createdAt,
|
||||
};
|
||||
@@ -769,11 +796,19 @@ export function agentService(db: Db) {
|
||||
.select({
|
||||
id: agentApiKeys.id,
|
||||
name: agentApiKeys.name,
|
||||
scopeConfig: agentApiKeys.scopeConfig,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.agentId, id)),
|
||||
.where(eq(agentApiKeys.agentId, id))
|
||||
.then((rows) => rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
scope: normalizeAgentApiKeyScope(row.scopeConfig),
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
}))),
|
||||
|
||||
getKeyById: async (keyId: string) =>
|
||||
db
|
||||
@@ -782,12 +817,21 @@ export function agentService(db: Db) {
|
||||
agentId: agentApiKeys.agentId,
|
||||
companyId: agentApiKeys.companyId,
|
||||
name: agentApiKeys.name,
|
||||
scopeConfig: agentApiKeys.scopeConfig,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.id, keyId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
scope: normalizeAgentApiKeyScope(row.scopeConfig),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
|
||||
revokeKey: async (agentId: string, keyId: string) => {
|
||||
const rows = await db
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import type { AgentApiKeyScope, PermissionKey, PrincipalType, TaskBridgeAgentKeyScope } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared";
|
||||
import {
|
||||
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
|
||||
@@ -29,6 +29,8 @@ export type AuthorizationActor =
|
||||
isInstanceAdmin?: boolean;
|
||||
agentId?: string | null;
|
||||
companyId?: string | null;
|
||||
keyId?: string | null;
|
||||
keyScope?: AgentApiKeyScope | null;
|
||||
runId?: string | null;
|
||||
source?:
|
||||
| "local_implicit"
|
||||
@@ -66,6 +68,8 @@ export type AuthorizationResource =
|
||||
parentIssueId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
@@ -211,6 +215,8 @@ type IssueAuthorizationRow = {
|
||||
assigneeUserId: string | null;
|
||||
status: string;
|
||||
executionPolicy: unknown;
|
||||
originKind: string | null;
|
||||
originId: string | null;
|
||||
};
|
||||
|
||||
function evaluateAuthorizationPolicyForAssignment(
|
||||
@@ -550,6 +556,8 @@ export function authorizationService(db: Db) {
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
status: issues.status,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
@@ -802,6 +810,130 @@ export function authorizationService(db: Db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskBridgeScopeIds(
|
||||
scope: TaskBridgeAgentKeyScope,
|
||||
singularKey: "projectId" | "parentIssueId",
|
||||
pluralKey: "projectIds" | "parentIssueIds",
|
||||
) {
|
||||
return [
|
||||
...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []),
|
||||
...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []),
|
||||
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
async function parentIssueMatchesTaskBridgeBoundary(
|
||||
parentIssueId: string | null | undefined,
|
||||
companyId: string,
|
||||
allowedParentIssueIds: string[],
|
||||
) {
|
||||
if (!parentIssueId || allowedParentIssueIds.length === 0) return false;
|
||||
if (allowedParentIssueIds.includes(parentIssueId)) return true;
|
||||
for (const rootIssueId of allowedParentIssueIds) {
|
||||
if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function issueMatchesTaskBridgeCreateBoundary(
|
||||
scope: TaskBridgeAgentKeyScope,
|
||||
resource: Extract<AuthorizationResource, { type: "issue" }>,
|
||||
) {
|
||||
const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds");
|
||||
const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds");
|
||||
if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true;
|
||||
if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) {
|
||||
return true;
|
||||
}
|
||||
if (resource.parentIssueId && allowedProjectIds.length > 0) {
|
||||
const parent = await loadIssue(resource.parentIssueId);
|
||||
if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function issueMatchesTaskBridgeWriteBoundary(input: {
|
||||
actorAgentId: string;
|
||||
keyId: string;
|
||||
resource: Extract<AuthorizationResource, { type: "issue" }>;
|
||||
}) {
|
||||
const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null;
|
||||
const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null;
|
||||
if (assigneeAgentId === input.actorAgentId) return true;
|
||||
const originKind = issue?.originKind ?? input.resource.originKind ?? null;
|
||||
const originId = issue?.originId ?? input.resource.originId ?? null;
|
||||
return originKind === "task_bridge" && originId === input.keyId;
|
||||
}
|
||||
|
||||
async function decideTaskBridgeAccess(input: {
|
||||
actorAgentId: string;
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
scope: TaskBridgeAgentKeyScope;
|
||||
keyId: string;
|
||||
}): Promise<AuthorizationDecision | null> {
|
||||
const denyBridge = (explanation: string) =>
|
||||
deny({
|
||||
action: input.action,
|
||||
reason: "deny_scope",
|
||||
explanation,
|
||||
});
|
||||
const allowBridge = (explanation: string) =>
|
||||
allow({
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation,
|
||||
});
|
||||
|
||||
if (
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "agent:read" ||
|
||||
input.action === "agent:wake" ||
|
||||
input.action === "project:read" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs.");
|
||||
}
|
||||
|
||||
if (input.action === "tasks:assign") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return denyBridge("Task bridge assignment requires an issue resource.");
|
||||
}
|
||||
if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) {
|
||||
return denyBridge("Task bridge key is outside its approved parent or project boundary.");
|
||||
}
|
||||
if (input.resource.assigneeUserId) {
|
||||
return denyBridge("Task bridge keys cannot assign work to board users.");
|
||||
}
|
||||
const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? [];
|
||||
if (
|
||||
input.resource.assigneeAgentId &&
|
||||
input.resource.assigneeAgentId !== input.actorAgentId &&
|
||||
!allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId)
|
||||
) {
|
||||
return denyBridge("Task bridge key cannot assign work to that agent.");
|
||||
}
|
||||
return allowBridge("Allowed by task bridge create boundary.");
|
||||
}
|
||||
|
||||
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return denyBridge("Task bridge issue access requires an issue resource.");
|
||||
}
|
||||
return await issueMatchesTaskBridgeWriteBoundary({
|
||||
actorAgentId: input.actorAgentId,
|
||||
keyId: input.keyId,
|
||||
resource: input.resource,
|
||||
})
|
||||
? allowBridge("Allowed for bridge-created or assigned issue.")
|
||||
: denyBridge("Task bridge key can only access assigned or bridge-created issues.");
|
||||
}
|
||||
|
||||
return denyBridge("Task bridge key cannot use this API action.");
|
||||
}
|
||||
|
||||
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
||||
if (resource.type !== "issue") return true;
|
||||
if (resource.assigneeAgentId) {
|
||||
@@ -1183,6 +1315,25 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") {
|
||||
const keyId = input.actor.keyId ?? null;
|
||||
if (!keyId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_scope",
|
||||
explanation: "Task bridge key context is missing.",
|
||||
});
|
||||
}
|
||||
const taskBridgeDecision = await decideTaskBridgeAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
scope: input.actor.keyScope,
|
||||
keyId,
|
||||
});
|
||||
if (taskBridgeDecision) return taskBridgeDecision;
|
||||
}
|
||||
|
||||
const lowTrustDecision = await decideLowTrustAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
|
||||
@@ -3050,7 +3050,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
nextAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
{ strictMode: strictSecretsMode, adapterType: effectiveAdapterType },
|
||||
);
|
||||
await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig);
|
||||
return {
|
||||
|
||||
@@ -412,7 +412,10 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64",
|
||||
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings" | "collectMissingRuntimeBindings"
|
||||
| "resolveAdapterConfigForRuntime"
|
||||
| "resolveEnvBindings"
|
||||
| "collectMissingRuntimeBindings"
|
||||
| "collectMissingAdapterConfigRuntimeBindings"
|
||||
>;
|
||||
|
||||
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
|
||||
@@ -494,6 +497,7 @@ function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) {
|
||||
export async function resolveExecutionRunAdapterConfig(input: {
|
||||
companyId: string;
|
||||
agentId?: string | null;
|
||||
adapterType?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
environmentId?: string | null;
|
||||
@@ -574,6 +578,16 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
)),
|
||||
);
|
||||
if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingAdapterConfigRuntimeBindings(
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
input.adapterType ?? null,
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (projectEnv && input.projectId) {
|
||||
missingBindings.push(
|
||||
@@ -666,6 +680,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
}
|
||||
: undefined,
|
||||
{ adapterType: input.adapterType ?? null },
|
||||
);
|
||||
if (Object.keys(environmentEnvResolution.env).length > 0) {
|
||||
resolvedConfig.env = {
|
||||
@@ -3140,6 +3155,13 @@ export function buildPaperclipTaskMarkdown(input: {
|
||||
workMode?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
ancestors?: Array<{
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
priority?: string | null;
|
||||
}> | null;
|
||||
wakeComment?: {
|
||||
id: string;
|
||||
body: string;
|
||||
@@ -3160,6 +3182,7 @@ export function buildPaperclipTaskMarkdown(input: {
|
||||
return [fence + "text", value, fence].join("\n");
|
||||
};
|
||||
const issue = input.issue;
|
||||
const ancestors = (input.ancestors ?? []).slice(0, 6);
|
||||
const wakeComment = input.wakeComment ?? null;
|
||||
const acceptedPlanContinuation =
|
||||
!wakeComment &&
|
||||
@@ -3212,6 +3235,19 @@ export function buildPaperclipTaskMarkdown(input: {
|
||||
lines.push("", "Issue description:", fenceTaskText(description));
|
||||
}
|
||||
}
|
||||
if (ancestors.length > 0) {
|
||||
lines.push("", "Authoritative parent / ancestor context:");
|
||||
for (const [index, ancestor] of ancestors.entries()) {
|
||||
const label = ancestor.identifier || ancestor.id;
|
||||
const status = ancestor.status ? ` (${ancestor.status})` : "";
|
||||
const priority = ancestor.priority ? ` [${ancestor.priority}]` : "";
|
||||
const title = ancestor.title ? ` ${ancestor.title}` : "";
|
||||
lines.push(`- ${index === 0 ? "Parent" : `Ancestor ${index + 1}`}: ${label}${title}${status}${priority}`);
|
||||
}
|
||||
if ((input.ancestors ?? []).length > ancestors.length) {
|
||||
lines.push(`- [ancestor context truncated after ${ancestors.length} entries]`);
|
||||
}
|
||||
}
|
||||
if (wakeComment?.body.trim()) {
|
||||
lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim()));
|
||||
}
|
||||
@@ -8647,6 +8683,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
wakeCommentContext && !exposeLowTrustRaw
|
||||
? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
|
||||
: wakeCommentContext;
|
||||
const issueAncestors = issueRef
|
||||
? await issuesSvc.getAncestors(issueRef.id)
|
||||
: [];
|
||||
if (continuationSummary) {
|
||||
context.paperclipContinuationSummary = {
|
||||
key: safeContinuationSummary!.key,
|
||||
@@ -8692,6 +8731,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
ancestors: issueAncestors,
|
||||
wakeComment: safeWakeCommentContext,
|
||||
interaction: {
|
||||
kind: readNonEmptyString(context.interactionKind),
|
||||
@@ -8918,6 +8958,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
issueId,
|
||||
heartbeatRunId: run.id,
|
||||
environmentId: selectedEnvironmentForConfig?.id ?? null,
|
||||
|
||||
+330
-42
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, inArray, like, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
} from "../secrets/types.js";
|
||||
import { isSecretProviderClientError } from "../secrets/types.js";
|
||||
import { authorizationService } from "./authorization.js";
|
||||
import { findActiveServerAdapter } from "../adapters/index.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
@@ -64,6 +66,9 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
||||
"gcp_secret_manager",
|
||||
"vault",
|
||||
]);
|
||||
const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
hermes_gateway: ["apiKey"],
|
||||
};
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
|
||||
@@ -357,6 +362,11 @@ export function secretService(db: Db) {
|
||||
strictMode?: boolean;
|
||||
fieldPath?: string;
|
||||
};
|
||||
type NormalizeAdapterConfigOptions = {
|
||||
strictMode?: boolean;
|
||||
adapterType?: string | null;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
};
|
||||
|
||||
async function getById(id: string, source: Pick<Db | DbTransaction, "select"> = db) {
|
||||
return source
|
||||
@@ -809,16 +819,210 @@ export function secretService(db: Db) {
|
||||
async function normalizeAdapterConfigForPersistenceInternal(
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
opts?: NormalizeAdapterConfigOptions,
|
||||
) {
|
||||
const normalized = { ...adapterConfig };
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return normalized;
|
||||
if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
|
||||
}
|
||||
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType);
|
||||
for (const key of secretFieldKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, key)) continue;
|
||||
const value = await normalizeSchemaSecretFieldForPersistence(companyId, {
|
||||
adapterType: opts?.adapterType ?? null,
|
||||
key,
|
||||
rawValue: adapterConfig[key],
|
||||
actor: opts?.actor,
|
||||
});
|
||||
if (value === undefined) {
|
||||
delete normalized[key];
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function listAdapterSchemaSecretFieldKeys(adapterType: string | null | undefined): Promise<string[]> {
|
||||
if (!adapterType) return [];
|
||||
const adapter = findActiveServerAdapter(adapterType);
|
||||
const fallback = [...(FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS[adapterType] ?? [])];
|
||||
if (!adapter?.getConfigSchema) return fallback;
|
||||
try {
|
||||
const schema = await adapter.getConfigSchema();
|
||||
return [...new Set([
|
||||
...fallback,
|
||||
...schema.fields
|
||||
.filter((field) => field.meta?.secret === true)
|
||||
.map((field) => field.key),
|
||||
])];
|
||||
} catch (err) {
|
||||
logger.warn({ err, adapterType }, "adapter config schema unavailable while normalizing secret fields");
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeSchemaSecretFieldForPersistence(
|
||||
companyId: string,
|
||||
input: {
|
||||
adapterType: string | null;
|
||||
key: string;
|
||||
rawValue: unknown;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
},
|
||||
): Promise<EnvBinding | undefined> {
|
||||
if (input.rawValue === null || input.rawValue === undefined) return undefined;
|
||||
const parsed = envBindingSchema.safeParse(input.rawValue);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`${input.key} must be a string, plain binding, or secret reference`);
|
||||
}
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "secret_ref") {
|
||||
await assertSecretInCompany(companyId, binding.secretId);
|
||||
return {
|
||||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version,
|
||||
};
|
||||
}
|
||||
const value = binding.value.trim();
|
||||
if (!value) return undefined;
|
||||
if (value === REDACTED_SENTINEL) {
|
||||
throw unprocessable(`Refusing to persist redacted placeholder for key: ${input.key}`);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const adapterPart = normalizeSecretKey(input.adapterType ?? "adapter");
|
||||
const fieldPart = normalizeSecretKey(input.key);
|
||||
const secret = await createManagedLocalSecret(companyId, {
|
||||
name: `${adapterPart}.${fieldPart}.${id}`,
|
||||
key: `${adapterPart}.${fieldPart}.${id}`,
|
||||
value,
|
||||
description: `Adapter config secret for ${input.adapterType ?? "adapter"}.${input.key}`,
|
||||
}, input.actor);
|
||||
return {
|
||||
type: "secret_ref",
|
||||
secretId: secret.id,
|
||||
version: "latest",
|
||||
};
|
||||
}
|
||||
|
||||
async function createManagedLocalSecret(
|
||||
companyId: string,
|
||||
input: {
|
||||
name: string;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) {
|
||||
const existing = await getByName(companyId, input.name);
|
||||
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
||||
const key = normalizeSecretKey(input.key);
|
||||
if (!key) throw unprocessable("Secret key is required");
|
||||
const duplicateKey = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.key, key),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (duplicateKey) throw conflict(`Secret key already exists: ${key}`);
|
||||
|
||||
const provider = getSecretProvider("local_encrypted");
|
||||
const providerConfig = await getSelectableRuntimeProviderConfig({
|
||||
companyId,
|
||||
provider: "local_encrypted",
|
||||
providerConfigId: null,
|
||||
});
|
||||
const providerWriteContext = {
|
||||
companyId,
|
||||
secretKey: key,
|
||||
secretName: input.name,
|
||||
version: 1,
|
||||
};
|
||||
const reservedSecret = await db
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
key,
|
||||
name: input.name,
|
||||
provider: "local_encrypted",
|
||||
providerConfigId: null,
|
||||
status: "archived",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 0,
|
||||
description: input.description ?? null,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
let prepared: PreparedSecretVersion | null = null;
|
||||
try {
|
||||
prepared = await provider.createSecret({
|
||||
value: input.value,
|
||||
externalRef: null,
|
||||
providerConfig,
|
||||
context: providerWriteContext,
|
||||
});
|
||||
const preparedSecret = prepared;
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId: reservedSecret.id,
|
||||
version: 1,
|
||||
material: preparedSecret.material,
|
||||
valueSha256: preparedSecret.valueSha256,
|
||||
fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
|
||||
providerVersionRef: preparedSecret.providerVersionRef ?? null,
|
||||
status: "disabled",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(companySecretVersions)
|
||||
.set({ status: "current" })
|
||||
.where(and(
|
||||
eq(companySecretVersions.secretId, reservedSecret.id),
|
||||
eq(companySecretVersions.version, 1),
|
||||
));
|
||||
const secret = await tx
|
||||
.update(companySecrets)
|
||||
.set({
|
||||
status: "active",
|
||||
externalRef: preparedSecret.externalRef,
|
||||
latestVersion: 1,
|
||||
lastRotatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, reservedSecret.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
return secret;
|
||||
});
|
||||
} catch (error) {
|
||||
if (prepared) {
|
||||
await cleanupPreparedProviderWrite({
|
||||
provider,
|
||||
prepared,
|
||||
providerConfig,
|
||||
context: providerWriteContext,
|
||||
mode: "delete",
|
||||
operation: "adapter_config_secret.create_rollback",
|
||||
}).catch(() => false);
|
||||
}
|
||||
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function collectTargetIds(
|
||||
bindings: Array<typeof companySecretBindings.$inferSelect>,
|
||||
targetType: SecretBindingTargetType,
|
||||
@@ -1817,9 +2021,11 @@ export function secretService(db: Db) {
|
||||
operation: "create.prepare_rollback",
|
||||
});
|
||||
if (cleaned) {
|
||||
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
@@ -1862,9 +2068,11 @@ export function secretService(db: Db) {
|
||||
operation: "create.rollback",
|
||||
});
|
||||
if (cleaned) {
|
||||
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
@@ -2159,6 +2367,7 @@ export function secretService(db: Db) {
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
}>,
|
||||
options?: { replaceAll?: boolean },
|
||||
) => {
|
||||
const normalizedRefs: Array<{
|
||||
secretId: string;
|
||||
@@ -2181,7 +2390,17 @@ export function secretService(db: Db) {
|
||||
const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (pathPrefixes.length > 0) {
|
||||
if (options?.replaceAll) {
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, target.targetType),
|
||||
eq(companySecretBindings.targetId, target.targetId),
|
||||
),
|
||||
);
|
||||
} else if (pathPrefixes.length > 0) {
|
||||
for (const pathPrefix of pathPrefixes) {
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
@@ -2351,7 +2570,7 @@ export function secretService(db: Db) {
|
||||
normalizeAdapterConfigForPersistence: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
opts?: NormalizeAdapterConfigOptions,
|
||||
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
||||
|
||||
normalizeEnvBindingsForPersistence: async (
|
||||
@@ -2363,7 +2582,7 @@ export function secretService(db: Db) {
|
||||
normalizeHireApprovalPayloadForPersistence: async (
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
opts?: NormalizeAdapterConfigOptions,
|
||||
) => {
|
||||
const normalized = { ...payload };
|
||||
const adapterConfig = asRecord(payload.adapterConfig);
|
||||
@@ -2473,52 +2692,121 @@ export function secretService(db: Db) {
|
||||
}));
|
||||
},
|
||||
|
||||
collectMissingAdapterConfigRuntimeBindings: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
adapterType: string | null | undefined,
|
||||
context: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<MissingRuntimeBinding[]> => {
|
||||
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType);
|
||||
const secretRefs = secretFieldKeys.flatMap((key) => {
|
||||
const parsed = envBindingSchema.safeParse(adapterConfig[key]);
|
||||
if (!parsed.success) return [];
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "secret_ref") return [];
|
||||
return [{ key, configPath: key, secretId: binding.secretId }];
|
||||
});
|
||||
if (secretRefs.length === 0) return [];
|
||||
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
|
||||
entry,
|
||||
found: await getBinding({
|
||||
companyId,
|
||||
secretId: entry.secretId,
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
}),
|
||||
})));
|
||||
const missingEntries = bindingChecks
|
||||
.filter((check) => !check.found)
|
||||
.map((check) => check.entry);
|
||||
if (missingEntries.length === 0) return [];
|
||||
|
||||
const secretRows = await Promise.all(
|
||||
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
|
||||
secretId,
|
||||
await getById(secretId).catch(() => null),
|
||||
] as const),
|
||||
);
|
||||
const secretsById = new Map(secretRows);
|
||||
|
||||
return missingEntries.map((entry) => ({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
secretId: entry.secretId,
|
||||
secretName: secretsById.get(entry.secretId)?.name ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
opts?: { adapterType?: string | null },
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
const manifest: RuntimeSecretManifestEntry[] = [];
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
}
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
throw unprocessable(`Invalid environment variable name: ${key}`);
|
||||
}
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
||||
}
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
} else {
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context
|
||||
? {
|
||||
bindingContext: { ...context, configPath: `env.${key}` },
|
||||
accessContext: { ...context, configPath: `env.${key}` },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
env[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
throw unprocessable(`Invalid environment variable name: ${key}`);
|
||||
}
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
||||
}
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
} else {
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context
|
||||
? {
|
||||
bindingContext: { ...context, configPath: `env.${key}` },
|
||||
accessContext: { ...context, configPath: `env.${key}` },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
env[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType);
|
||||
for (const key of secretFieldKeys) {
|
||||
const parsed = envBindingSchema.safeParse(adapterConfig[key]);
|
||||
if (!parsed.success) continue;
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") continue;
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context
|
||||
? {
|
||||
bindingContext: { ...context, configPath: key },
|
||||
accessContext: { ...context, configPath: key },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
resolved[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
},
|
||||
};
|
||||
|
||||
Vendored
+3
@@ -1,5 +1,7 @@
|
||||
export {};
|
||||
|
||||
import type { AgentApiKeyScope } from "@paperclipai/shared";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
@@ -18,6 +20,7 @@ declare global {
|
||||
}>;
|
||||
isInstanceAdmin?: boolean;
|
||||
keyId?: string;
|
||||
keyScope?: AgentApiKeyScope;
|
||||
runId?: string;
|
||||
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user