Files
PaperClipAI/packages/plugins/sdk/tests/host-client-factory.test.ts
T
Nicky LeachandPaperclip 9f4a956b90 Add durable agent runtime synchronization hooks (#16499)
## Thinking Path

> - Paperclip manages agents and their runtime configuration.
> - Plugins can maintain external agent computers.
> - Configuration and file saves currently miss durable plugin delivery.
> - A transient activity notification cannot recover after interruption.
> - This pull request adds an opt-in agent runtime resource stream.
> - The existing dispatcher delivers and acknowledges its committed
work.
> - Plugins can prepare computers when configuration changes.

## Linked Issues or Issue Description

Related: #15631 and #16203 establish agent lifecycle and native durable
delivery.

**Problem**

Plugins cannot reliably react to agent configuration and instruction
changes without polling or waiting for execution.

**Proposed solution**

Add an opt-in `agent_runtime` stream to the existing resource journal.
Record changes at the database mutation and managed file audit
boundaries. Wake the dispatcher with content-free commit notifications.
Provide scoped runtime inputs to the selected lifecycle plugin.

**Alternatives considered**

Route-only activity events miss service and background writers.
Scheduled scans delay changes and repeatedly query unchanged state.

**Roadmap alignment**

This extends the existing plugin and agent configuration work.

## What Changed

- Add durable update/delete events for agent runtime state, including an
existing-agent baseline.
- Capture configuration changes, instruction edits, deletion, restore,
and managed run writeback.
- Add commit notifications and a reconnect scan. Register one listener
for configured, enabled runtime subscribers. Release it when none
remain.
- Keep HTTP startup independent of queued plugin callbacks.
- Add `ctx.agents.getRuntime`. Require lifecycle capability, invocation
company, enabled plugin, and owning lifecycle selection.
- Keep legacy agent/project subscriptions isolated. Manual inbox reads
retain both legacy streams.
- Resolve recovered instruction paths without reading or hashing bundle
contents. Use the company reporting graph for runtime health.
- Repair missed file events on identical save/deletion retries after a
journal failure; preserve content tokens and conditional-write checks.
- Correlate file audits with runtime revisions to deduplicate recorded
retries without a new table. Bound the lookup with the existing
company/timestamp index.
- Document the contract, migration, access boundaries, and
session-connection requirement.

## Verification

- Rebased the full stack onto origin/master after #16507.
- Final replayed stack: `pnpm -r typecheck` and `pnpm build` passed.
- All 132 affected runtime, skill, connector, publication, instruction,
managed-agent, and delivery tests passed after the latest-master rebase.
Workspace typecheck and build passed.
- Journal failure injection verifies recovery on identical file writes,
deletions, and instruction retries from an acknowledged inbox.
- Deduplication follow-up: 80 runtime/instruction/delivery tests and
server typecheck passed. Repeated retries create no audit/events; old
hashes returning after an intervening commit still repair delivery.
Built-in/native instruction writer regressions passed all 56 tests.
- PR 1 follow-up tests cover valid/cyclic reporting chains, listener
registration failure, multiple subscribers, global/company disable,
re-enable, unload, and startup with blocked callbacks.
- Database coverage includes rollback, journal failure, no-op
suppression, file saves/deletion, ordering/replay, owner/company denial,
independent-client commits, and real listener loss/reconnect.
- SDK/shared suites passed 1,059 tests during implementation.
CLI/delivery regressions also passed.
- Migration safety, map checks, diff whitespace, and local secrets/PII
scan passed.
- Hosted CI and security checks are fully green at `b296405e2`. Standard
Greptile and Apex both returned 5/5 with no unresolved findings.
- The broad local suite encountered an unchanged diagnostic test that
rejects the word "private" from an error stack. This worktree lives
under /private/tmp. The same unchanged diagnostic suite passed all 108
tests in the original checkout. Hosted full-suite checks provide
exact-head verification.

## Risks

- Apply migration 0347 before enabling a runtime subscriber. Runtime
notifications require session-level PostgreSQL LISTEN. Transaction-mode
poolers are unsupported for this opt-in feature.
- Database triggers share the mutation transaction. Journal failure
prevents configuration commits. Trigger work adds a journal insert to
relevant changes.
- Filesystem publication and SQL commit are separate boundaries. Direct
filesystem edits do not emit hooks. External clients must fence stale
revisions and retain conditional-write checks.
- The reader returns only that plugin's own instance secrets. Keep them
out of UI data, logs, plugin state, and agent files.
- No private transport integration or live remote computer qualification
is included.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository inspection, code
execution, and testing. The exact deployment model ID and context window
are not exposed in this session.

## 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 references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run tests locally and they pass (focused suites; broad
local limitation documented above)
- [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>
2026-10-16 07:53:04 -07:00

438 lines
16 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("requires lifecycle capability and the host invocation company before accessing runtime inputs", async () => {
const getRuntime = vi.fn(async () => null);
const services = { agents: { 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.getRuntime"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError);
const allowed = createHostClientHandlers({ pluginId: "runtime", capabilities: ["agents.lifecycle.manage"], services });
await expect(allowed["agents.getRuntime"](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError);
expect(getRuntime).not.toHaveBeenCalled();
await expect(allowed["agents.getRuntime"](params, context)).resolves.toBeNull();
expect(getRuntime).toHaveBeenCalledOnce();
});
});