mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip manages agents and their execution authority. > - A plugin can host Runner tasks on a persistent execution host. > - Admission checks must observe current lifecycle state and credentials. > - The current runtime reader also resolves files, skills, and connections for each check. > - Some providers also repeat the same project authentication check during submit. > - This pull request adds a compact fresh reader and an explicit submit-time validation contract. > - Providers can remove repeated work while Core keeps lease and scope checks. ## Linked Issues or Issue Description **What existing behavior does this improve?** Plugin Runner startup before task submission. **Current behavior** One measured startup made 30 full runtime reads during bootstrap. These reads took 5.759 seconds. Each guard can resolve instructions, skills, and connection assignments even when it only needs current identity and lifecycle state. Providers that already enforce project authentication in submit must also perform a separate Core preflight. **Proposed behavior** Use a lifecycle-owned runtime identity reader for guards. Keep the full runtime reader for synchronization inputs. A trusted provider can declare `projectAuthCheckAtSubmit: true` in its prepared result. Core pins the exact configuration, provider, and project set on the lease. The provider must reject authentication conflicts before credential installation or task start. Providers that omit the flag retain the existing preflight. **Reason and benefit** Estimated savings with a paired consumer: 4–5 seconds from compact runtime checks, plus roughly 0.5–0.9 seconds from removing the duplicate authentication operation. These estimates overlap and are not a measured deployed result. Refs #17367 for the separate run-log HEAD optimization; this PR does not duplicate it. **Breaking changes** No migration. The SDK adds a host method and an optional prepared-result field. Consumers that use them require this Core host version. Older providers retain their existing flow. ## What Changed - Add `agents.getRuntimeIdentity` across SDK, RPC, host service, and test fixtures. - Reuse the existing company, plugin ownership, readiness, and capability checks. Read lifecycle state and protected instance secrets fresh on every call. - Skip instruction, organization, skill, and connection hydration in the compact reader. - Allow explicit submit-time authentication enforcement, with prepared scope pinned and checked again at dispatch. - Emit fixed preparation, project-auth, submit, and connection timing phases. ## Verification - 72 targeted SDK capability, plugin task-owner, and environment-task mediator tests passed. - 33 real embedded-Postgres lifecycle and secret tests passed, including pause, content revision, company isolation, plugin disablement, and secret rotation. - SDK and server TypeScript checks passed. Local Runner/eval TypeScript outputs were built to satisfy server type resolution. - Local Postgres required missing library aliases in ignored dependencies; no product source was changed for that repair. - Full repository suites are delegated to GitHub CI. No live rollout or speedup claim. ## Risks The submit-time flag delegates enforcement to a trusted provider. It is not proof that prepare already checked authentication. The provider must check before VM credential installation or task admission. Core still checks exact prepared scope, active lease, run identity, credential type, and dispatch fencing. Existing providers keep the separate check. Roll back a paired consumer before rolling back this host. ## Model Used OpenAI GPT-6 (Codex), with code execution and repository tools. Exact deployment build and context-window size are not exposed. ## 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 described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { HostServices } from "../src/host-client-factory.js";
|
|
import {
|
|
CapabilityDeniedError,
|
|
createHostClientHandlers,
|
|
InvocationScopeDeniedError,
|
|
} from "../src/host-client-factory.js";
|
|
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
|
|
|
|
describe("createHostClientHandlers invocation company scope", () => {
|
|
it("rejects worker-selected config and secret company ids without a host invocation scope", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKeyRef: "unreachable" }));
|
|
const secretsResolve = vi.fn(async () => "unreachable");
|
|
const services = {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} as unknown as HostServices;
|
|
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["secrets.read-ref"],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
handlers["config.get"]({ companyId: "company-a" }),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
companyId: "company-a",
|
|
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
|
}),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(configGet).not.toHaveBeenCalled();
|
|
expect(secretsResolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows explicit config and secret company ids only when they match the host invocation scope", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKeyRef: "ref" }));
|
|
const secretsResolve = vi.fn(async () => "resolved");
|
|
const services = {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} as unknown as HostServices;
|
|
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["secrets.read-ref"],
|
|
services,
|
|
});
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
|
|
await expect(
|
|
handlers["config.get"]({ companyId: "company-a" }, context),
|
|
).resolves.toEqual({ apiKeyRef: "ref" });
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
companyId: "company-a",
|
|
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
|
}, context),
|
|
).resolves.toBe("resolved");
|
|
|
|
expect(configGet).toHaveBeenCalledWith({ companyId: "company-a" }, context);
|
|
expect(secretsResolve).toHaveBeenCalledWith({
|
|
companyId: "company-a",
|
|
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
|
}, context);
|
|
});
|
|
|
|
it("rejects company-scoped host calls outside the current invocation company", async () => {
|
|
const projectsList = vi.fn(async () => []);
|
|
const services = {
|
|
projects: {
|
|
list: projectsList,
|
|
},
|
|
} as unknown as HostServices;
|
|
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["projects.read"],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
handlers["projects.list"](
|
|
{ companyId: "company-b" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
await expect(
|
|
handlers["projects.list"](
|
|
{ companyId: "company-b" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
expect(projectsList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("filters companies.list to the current invocation company", async () => {
|
|
const services = {
|
|
companies: {
|
|
list: vi.fn(async () => [
|
|
{ id: "company-a", name: "Company A" },
|
|
{ id: "company-b", name: "Company B" },
|
|
]),
|
|
},
|
|
} as unknown as HostServices;
|
|
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["companies.read"],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
handlers["companies.list"](
|
|
{},
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
),
|
|
).resolves.toEqual([{ id: "company-a", name: "Company A" }]);
|
|
});
|
|
|
|
it("rejects company-scope store access for a different company", async () => {
|
|
const stateGet = vi.fn(async () => null);
|
|
const services = {
|
|
state: {
|
|
get: stateGet,
|
|
},
|
|
} as unknown as HostServices;
|
|
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["plugin.state.read"],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
handlers["state.get"](
|
|
{ scopeKind: "company", scopeId: "company-b", stateKey: "settings" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(stateGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"access.members.list",
|
|
"access.members.read",
|
|
{ companyId: "company-a" },
|
|
(services: HostServices) => vi.mocked(services.access.listMembers),
|
|
],
|
|
[
|
|
"access.members.update",
|
|
"access.members.write",
|
|
{ companyId: "company-a", memberId: "member-a", patch: { status: "active" } },
|
|
(services: HostServices) => vi.mocked(services.access.updateMember),
|
|
],
|
|
[
|
|
"authorization.grants.set",
|
|
"authorization.grants.write",
|
|
{ companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] },
|
|
(services: HostServices) => vi.mocked(services.authorization.setGrants),
|
|
],
|
|
[
|
|
"authorization.policies.update",
|
|
"authorization.policies.write",
|
|
{ companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null },
|
|
(services: HostServices) => vi.mocked(services.authorization.updatePolicy),
|
|
],
|
|
[
|
|
"authorization.audit.search",
|
|
"authorization.audit.read",
|
|
{ companyId: "company-a" },
|
|
(services: HostServices) => vi.mocked(services.authorization.searchAudit),
|
|
],
|
|
] as const)(
|
|
"rejects %s when the plugin lacks %s",
|
|
async (method, capability, params, getDelegate) => {
|
|
const services = {
|
|
access: {
|
|
listMembers: vi.fn(async () => []),
|
|
updateMember: vi.fn(async () => ({ id: "member-a" })),
|
|
},
|
|
authorization: {
|
|
setGrants: vi.fn(async () => []),
|
|
updatePolicy: vi.fn(async () => ({ policy: null })),
|
|
searchAudit: vi.fn(async () => []),
|
|
},
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: [],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
|
|
).rejects.toMatchObject({
|
|
name: "CapabilityDeniedError",
|
|
message: expect.stringContaining(capability),
|
|
});
|
|
await expect(
|
|
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
expect(getDelegate(services)).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("checks invocation company scope before exposing authorization data", async () => {
|
|
const searchAudit = vi.fn(async () => []);
|
|
const services = {
|
|
authorization: {
|
|
searchAudit,
|
|
},
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["authorization.audit.read"],
|
|
services,
|
|
});
|
|
|
|
await expect(
|
|
handlers["authorization.audit.search"](
|
|
{ companyId: "company-b" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(searchAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a human-attributed createComment call when only issue.comments.create is granted", async () => {
|
|
const createComment = vi.fn(async () => ({ id: "comment-1" }));
|
|
const services = {
|
|
issues: { createComment },
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["issue.comments.create"],
|
|
services,
|
|
});
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
|
|
await expect(
|
|
handlers["issues.createComment"]({
|
|
issueId: "issue-a",
|
|
body: "hello",
|
|
companyId: "company-a",
|
|
actorUserId: "user-a",
|
|
}, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
expect(createComment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows a human-attributed createComment call once issue.comments.create_human_attributed is also granted", async () => {
|
|
const createComment = vi.fn(async () => ({ id: "comment-1" }));
|
|
const services = {
|
|
issues: { createComment },
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
|
|
services,
|
|
});
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
|
|
await expect(
|
|
handlers["issues.createComment"]({
|
|
issueId: "issue-a",
|
|
body: "hello",
|
|
companyId: "company-a",
|
|
actorUserId: "user-a",
|
|
}, context),
|
|
).resolves.toEqual({ id: "comment-1" });
|
|
expect(createComment).toHaveBeenCalledWith({
|
|
issueId: "issue-a",
|
|
body: "hello",
|
|
companyId: "company-a",
|
|
actorUserId: "user-a",
|
|
});
|
|
});
|
|
|
|
it("still allows a plain agent-attributed createComment call without the human-attribution capability", async () => {
|
|
const createComment = vi.fn(async () => ({ id: "comment-2" }));
|
|
const services = {
|
|
issues: { createComment },
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["issue.comments.create"],
|
|
services,
|
|
});
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
|
|
await expect(
|
|
handlers["issues.createComment"]({
|
|
issueId: "issue-a",
|
|
body: "hello",
|
|
companyId: "company-a",
|
|
authorAgentId: "agent-a",
|
|
}, context),
|
|
).resolves.toEqual({ id: "comment-2" });
|
|
expect(createComment).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createHostClientHandlers capability gating for LOOA-641 methods", () => {
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
|
|
it("denies issues.respondInteraction without issue.interactions.respond", async () => {
|
|
const respondInteraction = vi.fn(async () => ({ interaction: { id: "i" }, applied: true }));
|
|
const services = { issues: { respondInteraction } } as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
// A read grant must not confer the ability to respond.
|
|
capabilities: ["issue.interactions.read"],
|
|
services,
|
|
});
|
|
await expect(
|
|
handlers["issues.respondInteraction"]({
|
|
issueId: "issue-a", interactionId: "int-a", companyId: "company-a", action: "accept", actorUserId: "user-a",
|
|
}, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
expect(respondInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows issues.respondInteraction with issue.interactions.respond", async () => {
|
|
const respondInteraction = vi.fn(async () => ({ interaction: { id: "i" }, applied: true }));
|
|
const services = { issues: { respondInteraction } } as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["issue.interactions.respond"],
|
|
services,
|
|
});
|
|
await expect(
|
|
handlers["issues.respondInteraction"]({
|
|
issueId: "issue-a", interactionId: "int-a", companyId: "company-a", action: "accept", actorUserId: "user-a",
|
|
}, context),
|
|
).resolves.toEqual({ interaction: { id: "i" }, applied: true });
|
|
expect(respondInteraction).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("denies approvals.decide without approvals.respond", async () => {
|
|
const decide = vi.fn(async () => ({ approval: { id: "a" }, applied: true }));
|
|
const services = { approvals: { decide } } as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
// A read grant must not confer the ability to decide.
|
|
capabilities: ["approvals.read"],
|
|
services,
|
|
});
|
|
await expect(
|
|
handlers["approvals.decide"]({
|
|
approvalId: "a", companyId: "company-a", action: "approve", actorUserId: "user-a",
|
|
}, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
expect(decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows approvals.decide with approvals.respond", async () => {
|
|
const decide = vi.fn(async () => ({ approval: { id: "a" }, applied: true }));
|
|
const services = { approvals: { decide } } as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["approvals.respond"],
|
|
services,
|
|
});
|
|
await expect(
|
|
handlers["approvals.decide"]({
|
|
approvalId: "a", companyId: "company-a", action: "approve", actorUserId: "user-a",
|
|
}, context),
|
|
).resolves.toEqual({ approval: { id: "a" }, applied: true });
|
|
expect(decide).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("denies read methods without their read capability", async () => {
|
|
const listInteractions = vi.fn(async () => []);
|
|
const list = vi.fn(async () => []);
|
|
const getAttachmentContent = vi.fn(async () => null);
|
|
const services = {
|
|
issues: { listInteractions, getAttachmentContent },
|
|
approvals: { list },
|
|
} as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: [],
|
|
services,
|
|
});
|
|
await expect(
|
|
handlers["issues.listInteractions"]({ issueId: "i", companyId: "company-a" }, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
await expect(
|
|
handlers["approvals.list"]({ companyId: "company-a" }, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
await expect(
|
|
handlers["issues.getAttachmentContent"]({ attachmentId: "at", companyId: "company-a" }, context),
|
|
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
expect(listInteractions).not.toHaveBeenCalled();
|
|
expect(list).not.toHaveBeenCalled();
|
|
expect(getAttachmentContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("enforces invocation company scope on the new methods", async () => {
|
|
const list = vi.fn(async () => []);
|
|
const services = { approvals: { list } } as unknown as HostServices;
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "paperclip.test",
|
|
capabilities: ["approvals.read"],
|
|
services,
|
|
});
|
|
// Requesting company-b while scoped to company-a must be denied.
|
|
await expect(
|
|
handlers["approvals.list"]({ companyId: "company-b" }, context),
|
|
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(list).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
|
|
describe("agent runtime reader", () => {
|
|
it.each(["getRuntime", "getRuntimeIdentity"] as const)("requires lifecycle capability and invocation company for %s", async method => {
|
|
const getRuntime = vi.fn(async () => null);
|
|
const services = { agents: { [method]: getRuntime } } as unknown as HostServices;
|
|
const params = { companyId: "company-a", agentId: "agent-a" };
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
const denied = createHostClientHandlers({ pluginId: "runtime", capabilities: ["agents.read"], services });
|
|
await expect(denied[`agents.${method}`](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
const allowed = createHostClientHandlers({ pluginId: "runtime", capabilities: ["agents.lifecycle.manage"], services });
|
|
await expect(allowed[`agents.${method}`](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(getRuntime).not.toHaveBeenCalled();
|
|
await expect(allowed[`agents.${method}`](params, context)).resolves.toBeNull();
|
|
expect(getRuntime).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("project instance secrets", () => {
|
|
it("requires the capability and host-authorized company scope", async () => {
|
|
const getInstanceSecrets = vi.fn(async () => ({ mount_grants: "fixture" }));
|
|
const services = { projects: { getInstanceSecrets } } as unknown as HostServices;
|
|
const params = { companyId: "company-a", projectId: "project-a" };
|
|
const context = { invocationScope: { companyId: "company-a" } };
|
|
const denied = createHostClientHandlers({ pluginId: "fixture", capabilities: [], services });
|
|
await expect(denied["projects.getInstanceSecrets"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError);
|
|
const allowed = createHostClientHandlers({ pluginId: "fixture", capabilities: ["projects.instance-secrets"], services });
|
|
await expect(allowed["projects.getInstanceSecrets"](params)).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
await expect(allowed["projects.getInstanceSecrets"](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
|
expect(getInstanceSecrets).not.toHaveBeenCalled();
|
|
await expect(allowed["projects.getInstanceSecrets"](params, context)).resolves.toEqual({ mount_grants: "fixture" });
|
|
expect(getInstanceSecrets).toHaveBeenCalledWith(params);
|
|
});
|
|
});
|