mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Plugins provide optional views and connect to external runtimes. > - A live runtime view needs a binary browser connection. > - The current plugin API supports JSON routes but cannot proxy that connection. > - Agent detail tabs are declared in the plugin contract but are not mounted on agent pages. > - This PR completes those two host capabilities and keeps upstream credentials on the server. ## Linked Issues or Issue Description **Subsystem affected** Plugin scoped API routes and agent detail views. **Problem or motivation** Plugins cannot expose a binary or text stream through a browser session without exposing an upstream credential or adding a separate browser gateway. Agent-scoped detail contributions also have no mount on the agent page. **Proposed solution** Allow declared board GET routes to select a server-only WSS upstream. Run upgrades through the existing HTTP authentication pipeline and trusted Origin check. Add agent company resolution with the existing `agent:read` decision. Mount contributed detail views in agent navigation. **Alternatives considered** A separately hosted viewer needs its own browser credentials and cross-origin policy. JSON or SSE transport adds encoding and buffering to a binary protocol. **Roadmap alignment** This extends the existing plugin API and detail-tab contract. It adds no desktop provider to Core. I checked ROADMAP.md and searched related open PRs; I found no equivalent transport and agent view change. ## What Changed - Add declared WebSocket routes requiring board authentication, GET, and `http.outbound`. Reject body-based company resolution because browser upgrades cannot send a body. - Keep worker-selected upstream URLs and headers out of browser JSON responses. Strip browser cookies and Origin. Preserve binary frames and socket backpressure. - Close both sockets on disconnect or worker shutdown. Reauthorize on reconnect after a five-minute connection lifetime. Preserve HTTP idle-work accounting for the stream lifetime. - Resolve agent scope through `agent:read` and mount generic agent plugin detail views, with current agent/view breadcrumbs and browser titles. ## Verification - Final head `9d8d269040acf9dc7bb65524876406af522dacb5`: CI passes (including Runner Rust, build, and browser E2E); Greptile 5/5; no unresolved review threads; no merge conflicts. Verified 2026-10-21. - Core server TypeScript check: `node node_modules/typescript/bin/tsc -p server/tsconfig.json --noEmit`. - Focused server suites: 17 passing tests (`server/src/__tests__/plugin-scoped-api-routes.test.ts` and `server/src/http/server.test.ts`), including arbitrary build-output and metrics channels with real local text and binary WebSocket round trips, disconnect cleanup, denied actors and origins, credential exclusion, agent-read denial, and cross-company 404 equivalence. The test replaces TLS with local HTTP while retaining the real upgrade and binary stream. - UI suites: 9 passing tests for independent Insights and Notes contributions, removed contributions, built-in route preservation, sidebar links, direct plugin entry and heading updates after tab navigation. These contributions do not require the WebSocket transport. - Shared validator suite: 42 passing tests, including rejecting body-based WebSocket resolution while retaining ordinary HTTP body resolution and accepting query/entity scopes. - UI token gates pass. - UI TypeScript check: `node ../scripts/storybook-catalog.mjs` then `tsc -b`. - Shared and plugin SDK builds pass. - Full Runner build reached its unrelated Rust step and stopped because cargo is unavailable. Runner TypeScript build was run separately. No live remote VM was used. ## Risks - This adds a browser ingress path for trusted installed plugins. The plugin must select its upstream from a trusted binding. HTTP requests cannot retrieve the upstream descriptor. - Membership or binding changes are rechecked on reconnect. Connections last at most five minutes, and worker shutdown is checked every five seconds. - Initial CI caught a missing cross-company 404 guard and an old test that assumed exactly one upgrade listener. Both were corrected. Greptile's body-resolution and stale-heading findings are fixed with regression coverage; latest-head CI passes and Greptile scored 5/5 with no unresolved findings. - No schema migration. Rollback restores the previous Core image; plugins using this transport must be disabled or rolled back first. ## Model Used OpenAI Codex, based on GPT-6, with reasoning and code/tool execution. The exact runtime model ID and context-window size are not exposed in this session. ## Checklist - [x] Thinking path and issue description included - [x] Model described - [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] No internal issues, private links, or runtime credentials included - [x] Descriptive branch name - [x] Focused tests and relevant typechecks run - [x] Tests and documentation updated - [x] Risks documented - [x] All CI gates green - [x] Greptile 5/5 with no unresolved findings - [x] I will address review findings before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
482 lines
18 KiB
TypeScript
482 lines
18 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("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);
|
|
});
|
|
});
|