Files
PaperClipAI/packages/shared/src/validators/plugin.test.ts
T
Nicky LeachandPaperclip 35ab90ad0f Map runtime activity to authorized task plugin panels (#18131)
## Thinking Path

> - Paperclip manages agents and tasks.
> - Plugins contribute views to the task sidebar.
> - A ready view can belong to an earlier task.
> - Runner records activity under its authenticated run.
> - This PR maps that activity to installed task-panel declarations.
> - The sidebar checks readiness after current-run use and restores use
after reconnect.

## Linked Issues or Issue Description

Builds on merged contract #18129 and Runner publisher #18050. This PR
targets
master and is ready for review. Earlier combined verification is
retained in
[the verification
archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676).

**Subsystem affected**

Run-event reads, plugin task contributions and the task sidebar.

**Problem or motivation**

Readiness polling can show a view left ready by another task. Reload and
reconnect must recover current-run use without losing concurrent live
activity.

**Proposed solution**

Map recorded activity to installed task-panel declarations. Merge
authorized
history and live activity, then perform bounded readiness checks for the
running agent. Preserve the existing read-only view.

## What Changed

- Add optional `{ namespace, activityKey }` activation mappings to
task-panel
declarations. Legacy declarations remain valid but contribute no task
panel.
- Add `view=plugin-panels` to the existing run-events route. Preserve
run-read and
telemetry-read authorization, secret redaction and private-run denial.
Map
activity to installed contributions and return at most 128 panel
identities.
- Scope activation caches to current panel versions and activity
declarations.
Preserve unchanged panels during unrelated plugin updates and history
reads.
- Merge history and live hints without losing concurrent activations.
Filter by
  company, task, run and agent. Restore history on mount and reconnect.
- Replace periodic availability checks with up to three readiness
attempts after
activity. Fresh activity restarts settled transient failures. Pending
checks,
  successful readiness and HTTP 4xx failures are left alone.
- Reuse live run updates with the panel cache key. Recover only panel
history
after a pending subscription read. Mark expected not-ready query results
so
  the global error reporter skips them; real failures remain reportable.
- Preserve current-run dismissal and the read-only plugin view. Update
manifest
  comments and producer/consumer documentation.

## Verification

- Current head: `5e04d757c`, based on master `09c05c550`. Range
comparison
confirmed all six feature commits were unchanged by the rebase. The
final
follow-up keys readiness by the plugin installation ID and availability
path.
- `cd ui && node ../node_modules/vitest/vitest.mjs run
src/hooks/useTaskPluginPanels.test.tsx`
— all 45 cases passed. `cd ui && node ../node_modules/typescript/bin/tsc
-b` passed.
- The shared query-client and live-update recovery selection passed 96
cases on
  `49c544cd2`. Token gates passed before the final hook-only changes.
- Earlier focused verification passed 57 manifest cases, 69
database/history cases,
event-registry cases and route authorization cases, including
private-run denial.
- Regression coverage includes subscription/history races, task changes
during
recovery, unfocused run completion, bounded readiness retries, denied
reads,
expected not-ready responses, changed activity mappings, unchanged panel
preservation, old cache scopes, and same-version route or installation
changes.
- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/40942001707)
passed: 54 successful checks, two skips, no failures or pending checks.
- [Standard and final
review](https://github.com/paperclipai/paperclip/pull/18131#issuecomment-6489842168)
both scored 5/5 on `5e04d757c`. The final review used the Apex tier,
added no
comments, and left no unresolved findings. The PR is conflict-free and
ready for review.
- Local checks stay targeted. GitHub CI provides the broad build, test,
typecheck,
browser and Runner coverage. No deployment or live-provider acceptance
is claimed.

<details>
<summary>Retained failures and recovery evidence</summary>

- `ac9329548` twice failed a browser reload assertion while conversation
history
was concealed. Master fixed its readiness wait in #17923. The approve
journey
then passed locally on `fea63a418` with installed Chrome (1 passed, 39
seconds).
All eight browser shards passed in later CI. The browser test is outside
this diff.
- A canary checkout failed with Git HTTP 408; its unchanged-head retry
passed.
- `de07d09fa` failed the mobile Cases card click after a ten-second
stability wait.
The same read-only journey passed locally on that exact head (1 passed,
26 seconds).
Its unchanged-head CI retry passed 22 cases, with one skip. The original
artifact
  is retained; the cause of that click timeout is not established.
- [CI on
`49c544cd2`](https://github.com/paperclipai/paperclip/actions/runs/40903679075)
passed 54 checks with two skips. CI also passed on `de07d09fa` after the
recorded retry.
- The `1e59ab441` merge checkout passed all browser and server suites.
Runner
shard 2 passed 2,252 cases and timed out waiting for a
controller-recovery command.
The test had landed on master in #18161 after the former branch base.
Rebasing
aligned the local checkout with that code; Runner shard 2 then passed on
`fcf7380d3`.
The original timeout cause remains unproven; no unrelated timeout was
changed.
- [CI on
`fcf7380d3`](https://github.com/paperclipai/paperclip/actions/runs/40940235193)
recorded runner shutdown signals in typecheck and a workspace shard. A
chat
recovery test observed a null wake deadline, and one Runner transport
fixture
failed during close. Those separate assertion failures remain
unexplained.
All of those jobs passed on the final readiness-key head. Those later
passes
establish recovery; they do not prove the cause of the earlier assertion
failures.
- Local browser setup first stopped at pnpm version-download
verification, then a
sandbox IPC restriction, then a missing bundled browser. Installed pnpm
9.15.4
  and Chrome completed the journeys. No dependency files were changed.
- Earlier optional-property TypeScript errors were corrected. Embedded
PostgreSQL
needed its install hook run from its package directory. Older CI also
recorded
fixture-cleanup lock timeouts, attention-fixture timeouts, and canceled
server
shards after runner shutdown. The parent fixed its FIFO snapshot-copy
failure
before merge. The linked verification archive retains earlier combined
evidence.

</details>

## Risks

Activity is a hint and grants no access. Missing plugins, denied reads
and
unavailable views contribute no panel. Older runs without activity
records show
no task plugin tab. A used view stays available for the running task;
reconnect
reconciles readiness. Matching producer and consumer builds are
required. No
migration, dependency, image publication or deployment is included.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools and code
execution.
The exact serving snapshot is 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 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] 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-29 13:56:48 -07:00

533 lines
22 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { PLUGIN_CAPABILITIES } from "../constants.js";
import { resolveDeclaredSandboxCapabilities } from "../environment-support.js";
import { pluginApiRouteDeclarationSchema, pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js";
function buildSandboxProviderManifest(driver: Record<string, unknown>) {
return {
id: "paperclip.capability-provider",
apiVersion: 1,
version: "0.1.0",
displayName: "Capability Provider",
description: "Sandbox provider that declares fine-grained capabilities.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "./dist/worker.js" },
environmentDrivers: [
{
driverKey: "capability-provider",
kind: "sandbox_provider",
displayName: "Capability Provider",
configSchema: { type: "object" },
...driver,
},
],
};
}
describe("plugin capability constants", () => {
it("exposes each capability once", () => {
expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length);
});
});
describe("plugin manifest validators", () => {
it("limits environment skills to unique declarations owned by the plugin", () => {
const manifest = { ...buildSandboxProviderManifest({ skillKeys: ["desktop"] }),
capabilities: ["environment.drivers.register", "skills.managed"],
skills: [{ skillKey: "desktop", displayName: "Desktop" }] };
expect(pluginManifestV1Schema.parse(manifest).environmentDrivers![0].skillKeys).toEqual(["desktop"]);
for (const keys of [["missing"], ["desktop", "desktop"]]) {
expect(pluginManifestV1Schema.safeParse({ ...manifest,
environmentDrivers: [{ ...manifest.environmentDrivers[0], skillKeys: keys }] }).success).toBe(false);
}
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["environment.drivers.register"] }).success).toBe(false);
});
it("retains the agent prerequisite on environment drivers", () => {
const manifest = { ...buildSandboxProviderManifest({ requiresAgent: true }), agentLifecycle: true,
capabilities: ["environment.drivers.register", "agents.lifecycle.manage"] };
expect(pluginManifestV1Schema.parse(manifest).environmentDrivers?.[0]?.requiresAgent).toBe(true);
expect(pluginManifestV1Schema.safeParse(buildSandboxProviderManifest({ requiresAgent: "true" })).success).toBe(false);
expect(pluginManifestV1Schema.safeParse({ ...manifest, agentLifecycle: undefined }).success).toBe(false);
});
it("requires authority for required agent lifecycle participants", () => {
const manifest = { id: "example.lifecycle", apiVersion: 1, version: "0.1.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, agentLifecycle: true };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["agents.read"] }).success).toBe(false);
expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["agents.lifecycle.manage"] }).agentLifecycle).toBe(true);
});
it("requires subscription authority and known resource types for durable delivery", () => {
const manifest = { id: "example.lifecycle", apiVersion: 1, version: "1.0.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, resourceLifecycle: ["project"] };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["projects.read"] }).success).toBe(false);
expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["events.subscribe"] }).resourceLifecycle).toEqual(["project"]);
for (const resourceLifecycle of [[], ["issue"]]) {
expect(pluginManifestV1Schema.safeParse({ ...manifest, resourceLifecycle, capabilities: ["events.subscribe"] }).success).toBe(false);
}
});
it("requires routing authority for native pooled connector declarations without a custom UI bundle", () => {
const manifest = { id: "example.pool", apiVersion: 1, version: "0.1.0", displayName: "Pool", description: "Pool", author: "Tests", categories: ["connector"], entrypoints: { worker: "worker.js" }, aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" } };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["ui.page.register"] }).success).toBe(false);
expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["ai.connections.route"] }).aiConnectionRouter).toEqual(manifest.aiConnectionRouter);
});
it("accepts existing-style plugins that do not request access or authorization capabilities", () => {
const parsed = pluginManifestV1Schema.parse({
id: "paperclip.compat-dashboard",
apiVersion: 1,
version: "0.1.0",
displayName: "Compat Dashboard",
description: "Dashboard-only plugin without access or authorization host APIs.",
author: "Paperclip",
categories: ["ui"],
capabilities: ["ui.dashboardWidget.register"],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui.js",
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "compat-dashboard",
displayName: "Compat Dashboard",
exportName: "CompatDashboard",
},
],
},
});
expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]);
});
it("accepts sandbox provider template config bindings", () => {
const parsed = pluginManifestV1Schema.parse({
id: "paperclip.template-provider",
apiVersion: 1,
version: "0.1.0",
displayName: "Template Provider",
description: "Sandbox provider with captured template config binding.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "./dist/worker.js" },
environmentDrivers: [
{
driverKey: "template-provider",
kind: "sandbox_provider",
displayName: "Template Provider",
supportsTemplateCapture: true,
templateRefKind: "provider_template",
templateConfigBinding: {
field: "templateId",
unsetFields: ["image"],
},
configSchema: { type: "object" },
},
],
});
expect(parsed.environmentDrivers?.[0]?.templateConfigBinding).toEqual({
field: "templateId",
unsetFields: ["image"],
});
});
it("rejects template config bindings that replace provider identity", () => {
const parsed = pluginManifestV1Schema.safeParse({
id: "paperclip.bad-template-provider",
apiVersion: 1,
version: "0.1.0",
displayName: "Bad Template Provider",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "./dist/worker.js" },
environmentDrivers: [
{
driverKey: "bad-template-provider",
kind: "sandbox_provider",
displayName: "Bad Template Provider",
templateConfigBinding: {
field: "provider",
},
configSchema: { type: "object" },
},
],
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("provider key"))).toBe(true);
});
});
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
activityGatePolicy: "require_external_activity",
activityGateScope: "project",
issueTemplate: { surfaceVisibility: "default" },
});
expect(parsed.issueTemplate?.surfaceVisibility).toBe("default");
expect(parsed.activityGatePolicy).toBe("require_external_activity");
expect(parsed.activityGateScope).toBe("project");
});
it("rejects non-core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.safeParse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
issueTemplate: { surfaceVisibility: "normal" },
});
expect(parsed.success).toBe(false);
});
});
describe("plugin managed skill validators", () => {
const baseManifest = {
id: "paperclip.test-managed-skills",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Skills",
description: "Managed skills test plugin.",
author: "Paperclip",
categories: ["automation"],
entrypoints: { worker: "./dist/worker.js" },
} as const;
it("requires skills.managed when managed skills are declared", () => {
const parsed = pluginManifestV1Schema.safeParse({
...baseManifest,
capabilities: [],
skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }],
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("skills.managed"))).toBe(true);
});
it("accepts managed skills with the skills.managed capability", () => {
const parsed = pluginManifestV1Schema.parse({
...baseManifest,
capabilities: ["skills.managed"],
skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }],
});
expect(parsed.skills?.[0]?.skillKey).toBe("wiki-maintainer");
});
});
describe("plugin UI slot validators", () => {
it("accepts route-scoped sidebar slots with a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
routePath: "wiki",
});
expect(parsed.routePath).toBe("wiki");
});
it("requires route-scoped sidebar slots to declare a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath");
});
it("keeps reserved company route protection for route-scoped sidebars", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "settings-route-sidebar",
displayName: "Settings Sidebar",
exportName: "SettingsSidebar",
routePath: "settings",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
it("accepts workspace entity types as detailTab targets", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "detailTab",
id: "workspace-diff-viewer",
displayName: "Diff",
exportName: "WorkspaceDiffViewer",
entityTypes: ["execution_workspace", "project_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]);
});
it("accepts execution_workspace as a toolbarButton entityType", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "toolbarButton",
id: "workspace-open-diff",
displayName: "Open diff",
exportName: "OpenWorkspaceDiffButton",
entityTypes: ["execution_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
it("accepts company settings page slots with a non-core settings route", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "companySettingsPage",
id: "permissions-settings",
displayName: "Permissions",
exportName: "PermissionsSettingsPage",
routePath: "permissions",
});
expect(parsed.routePath).toBe("permissions");
});
it("prevents company settings page slots from shadowing core settings routes", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "companySettingsPage",
id: "instance-settings",
displayName: "Instance",
exportName: "InstanceSettingsPage",
routePath: "instance",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});
describe("sandbox provider capability declaration validators", () => {
it("preserves a declared acquisition budget and keeps it optional", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ defaultAcquireTimeoutMs: 300_000 }),
);
expect(parsed.environmentDrivers?.[0]?.defaultAcquireTimeoutMs).toBe(300_000);
const legacy = pluginManifestV1Schema.parse(buildSandboxProviderManifest({}));
expect(legacy.environmentDrivers?.[0]?.defaultAcquireTimeoutMs).toBeUndefined();
});
it.each([0, -1, 1.5, Infinity, NaN, "300000", 86_400_001])(
"rejects an invalid acquisition budget: %s",
(defaultAcquireTimeoutMs) => {
expect(pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ defaultAcquireTimeoutMs }),
).success).toBe(false);
},
);
it("test_manifest_accepts_sandbox_capabilities_and_rejects_unknown_capability_keys", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
sandboxCapabilities: {
reusableLeases: true,
nativeSyncIn: true,
nativeSyncOut: false,
persistentProcessSessions: true,
independentControlCommands: false,
incrementalSessionOutput: true,
},
}),
);
expect(parsed.environmentDrivers?.[0]?.sandboxCapabilities).toEqual({
reusableLeases: true,
nativeSyncIn: true,
nativeSyncOut: false,
persistentProcessSessions: true,
independentControlCommands: false,
incrementalSessionOutput: true,
});
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
sandboxCapabilities: {
reusableLeases: true,
// A typo or unknown capability name must fail validation, not drop
// silently. The nested schema is `.strict()`.
nativeSync: true,
},
}),
);
expect(rejected.success).toBe(false);
});
it("test_manifest_accepts_concurrent_sync_operations_capability", () => {
// A provider opts in to parallel bidirectional file sync with this key. The
// strict schema accepts it and keeps the declared value.
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
sandboxCapabilities: { concurrentSyncOperations: true },
}),
);
expect(parsed.environmentDrivers?.[0]?.sandboxCapabilities).toEqual({
concurrentSyncOperations: true,
});
});
it("test_manifest_rejects_unknown_sync_concurrency_capability_key", () => {
// A neighboring but unknown concurrency key must fail validation, not drop
// silently. The strict schema rejects a capability the host does not honor.
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
sandboxCapabilities: { concurrentSyncAndExec: true },
}),
);
expect(rejected.success).toBe(false);
});
it("test_supports_reusable_leases_compat_maps_to_reusable_leases", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsReusableLeases: true }),
);
const driver = parsed.environmentDrivers?.[0];
expect(driver?.sandboxCapabilities).toBeUndefined();
expect(resolveDeclaredSandboxCapabilities(driver!).reusableLeases).toBe(true);
});
it("test_sandbox_capabilities_reusable_leases_wins_over_compat_field", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
supportsReusableLeases: true,
sandboxCapabilities: { reusableLeases: false },
}),
);
const driver = parsed.environmentDrivers?.[0];
// The nested declaration wins over the legacy compat flag when both exist.
expect(resolveDeclaredSandboxCapabilities(driver!).reusableLeases).toBe(false);
});
});
describe("login pty transport capability and legacy alias", () => {
it("test_login_pty_new_field_parses_and_carries_the_flag", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsLoginPty: true }),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_legacy_alias_only_canonicalizes_onto_login_pty", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsSetupTokenLogin: true }),
);
const driver = parsed.environmentDrivers?.[0];
// The validator maps the deprecated alias onto the canonical field at parse
// time, and it drops the alias so a downstream reader cannot read the old
// name.
expect(driver?.supportsLoginPty).toBe(true);
expect(driver).not.toHaveProperty("supportsSetupTokenLogin");
});
it("test_conflicting_alias_and_new_field_reject", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: false,
}),
);
expect(rejected.success).toBe(false);
});
it("test_matching_alias_and_new_field_pass", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: true,
}),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_unknown_login_field_misspelling_rejects", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPTY: true }),
);
expect(rejected.success).toBe(false);
});
it("test_login_pty_accepts_literal_booleans_only", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPty: "true" }),
);
expect(rejected.success).toBe(false);
});
});
describe("plugin WebSocket company resolution", () => {
const route = { routeKey: "channel.connect", method: "GET", path: "/channels/:channelId", auth: "board", capability: "api.routes.register" };
it("rejects body resolution only for WebSocket routes", () => {
const companyResolution = { from: "body", key: "companyId" };
expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution }).success).toBe(true);
expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution, transport: "websocket" }).success).toBe(false);
});
it.each([
undefined,
{ from: "query", key: "companyId" },
{ from: "issue", param: "issueId" },
{ from: "agent", param: "agentId" },
])("accepts body-free resolution %j", (companyResolution) => {
expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution, transport: "websocket" }).success).toBe(true);
});
});
describe("plugin task side-panel declarations", () => {
const slot = { type: "detailTab", id: "view", displayName: "Live view", exportName: "LiveView", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } };
const route = { routeKey: "view.availability", method: "GET", path: slot.taskSidePanel.availabilityPath, auth: "board", capability: "api.routes.register", companyResolution: { from: "agent", param: "entityId" } };
const manifest = { id: "example.live-view", apiVersion: 1, version: "1.0.0", displayName: "Live view", description: "Live view", author: "Tests", categories: ["ui"], capabilities: ["ui.detailTab.register", "api.routes.register"], entrypoints: { worker: "worker.js", ui: "ui.js" }, ui: { slots: [slot] }, apiRoutes: [route] };
it("accepts an optional panel backed by an authorized plugin-local agent route", () => {
expect(pluginManifestV1Schema.parse(manifest).ui?.slots?.[0]?.taskSidePanel).toEqual(slot.taskSidePanel);
});
it("accepts an explicit UI-independent activity mapping and preserves legacy declarations", () => {
const activation = { namespace: "example.runtime", activityKey: "desktop.used" };
expect(pluginUiSlotDeclarationSchema.parse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).taskSidePanel?.activation).toEqual(activation);
expect(pluginUiSlotDeclarationSchema.parse(slot).taskSidePanel?.activation).toBeUndefined();
for (const value of ["视".repeat(60), "😀".repeat(160)]) {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation: { namespace: value, activityKey: value } } }).success).toBe(true);
}
for (const activation of [{ namespace: "", activityKey: "used" }, { namespace: "runtime", activityKey: "😀".repeat(161) }, { namespace: "runtime", activityKey: "\u007f" }, { namespace: "runtime", activityKey: "used", runId: "other" }]) {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).success).toBe(false);
}
});
it("accepts a GET availability route when a POST at the same path is declared first", () => {
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes: [{ ...route, routeKey: "view.update", method: "POST" }, route] }).success).toBe(true);
});
it.each(["https://example.test/:entityId", "//example/:entityId", "/../:entityId", "/agents/:entityId?redirect=elsewhere", "/agents/:agentId/view"])("rejects unsafe or unsupported path %s", availabilityPath => {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { availabilityPath } }).success).toBe(false);
});
it("rejects non-agent and non-detail contributions", () => {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, entityTypes: ["issue"] }).success).toBe(false);
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, type: "page" }).success).toBe(false);
});
it.each([[], [{ ...route, method: "POST" }], [{ ...route, auth: "agent" }], [{ ...route, companyResolution: { from: "query", key: "companyId" } }]].map(apiRoutes => ({ apiRoutes })))("requires a declared board GET route scoped to the supplied agent", ({ apiRoutes }) => {
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes }).success).toBe(false);
});
});