Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ Recommended backend model for these plugins:
- operational actions go through host-mediated privileged contracts
- process execution uses `system.process.exec` plus a narrow scope such as `system.process.scope.docker-cli`
- filesystem mutations use `system.fs.mutate` plus a narrow scope such as `system.fs.scope.<scope-id>`
- plugins should declare expected capabilities in code via `declareCapabilities()` so host preflight checks can run before rare action paths are triggered

Do not model these plugins around generic unrestricted shell access.

Expand All @@ -229,6 +230,7 @@ Preferred pattern:
- audit every privileged request with plugin identity and correlation id
- use `requestScopedWorkflow(...)` when a plugin needs a typed multi-step preview/apply or inspect/act flow instead of plugin-private orchestration
- expect workflow step results to expose typed process outcome data (`command`, `args`, `exitCode`, `stdout`, `stderr`, `durationMs`) rather than opaque blobs
- implement `declareCapabilities()` for operator plugins so declared vs granted capabilities can be compared during preflight and diagnostics

Examples of suitable process scopes:

Expand Down
2 changes: 2 additions & 0 deletions docs/API_STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ This document defines which SDK surfaces are stable contracts versus internal im
- `renderOnLoad()`
- `serializeRender()`
- `serializeRenderOnLoad()`
- Optional declared capability surface:
- `declareCapabilities()`
- Public plugin contracts:
- `FDOInterface`
- `PluginMetadata`
Expand Down
17 changes: 17 additions & 0 deletions docs/SAFE_PLUGIN_AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ Privileged SDK features are capability-gated. The host should grant capabilities

Without capability grants, these operations throw explicit permission errors by design.

Best practice for operator-style and privileged plugins:

- declare expected capabilities in code via `declareCapabilities()`
- treat that declaration as an early intent manifest for host preflight and diagnostics
- do not treat declared capabilities as actual grants; the host remains authoritative
- keep runtime `requireCapability(...)` and scoped helper enforcement for real authorization

For system-level changes such as `/etc/hosts`, use a host-mediated action contract with strict payload validation and host-side confirmation/auditing.
For Docker-style plugins, prefer host-mediated `system.process.exec` with a narrow scope such as `system.process.scope.docker-cli` and a host allowlist for exact command paths and argument patterns.

Expand Down Expand Up @@ -149,6 +156,16 @@ This lets plugin authors and AI tooling distinguish:

This is the model AI tooling should recommend when a plugin needs to behave like Docker Desktop, a Kubernetes console, or another operational dashboard.

For production-grade UX, operator plugins should also expose:

```ts
declareCapabilities() {
return createOperatorToolCapabilityPreset("kubectl");
}
```

This allows hosts to compare declared capabilities with granted capabilities during preflight, before a user reaches a rare or deep action path.

## Error-Path Safety

- Keep render error fallbacks simple and runtime-safe
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Primary authoring entry points:

Use the operator fixtures for production-oriented DevOps/SRE/plugin authoring work.
Use the non-operator fixtures for lifecycle, error-handling, storage, and UI composition baselines.
For privileged/operator fixtures, prefer declaring expected capabilities in code via `declareCapabilities()` so host preflight checks can compare declared vs granted capabilities early.

## Getting Started

Expand Down
48 changes: 48 additions & 0 deletions examples/fixtures/operator-kubernetes-plugin.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
FDOInterface,
FDO_SDK,
PluginMetadata,
PluginRegistry,
createOperatorToolCapabilityPreset,
getOperatorToolPreset,
createScopedWorkflowRequest,
Expand All @@ -26,12 +27,20 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements
return this._metadata;
}

declareCapabilities() {
return createOperatorToolCapabilityPreset("kubectl");
}

init(): void {
const preset = getOperatorToolPreset("kubectl");
this.info("Kubernetes operator fixture initialized", {
preset,
declaredCapabilities: this.declareCapabilities(),
requestedCapabilities: createOperatorToolCapabilityPreset("kubectl"),
});

PluginRegistry.registerHandler("kubectl.previewClusterObjects", async () => this.previewClusterObjects());
PluginRegistry.registerHandler("kubectl.inspectAndRestartWorkflow", async () => this.inspectAndRestartWorkflow());
}

render(): string {
Expand All @@ -41,10 +50,49 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements
<p>Recommended host capability bundle: ${JSON.stringify(createOperatorToolCapabilityPreset("kubectl"))}</p>
<p>Preferred request helper: <code>requestOperatorTool("kubectl", ...)</code></p>
<p>For inspect/act flows, prefer <code>requestScopedWorkflow(...)</code> over plugin-private orchestration.</p>
<div style={{ display: "flex", gap: "12px", marginTop: "12px", flexWrap: "wrap" }}>
<button id="kubectl-preview-objects">Inspect Objects</button>
<button id="kubectl-inspect-restart-workflow">Inspect+Restart Workflow</button>
</div>
<pre id="kubectl-workflow-result" style={{ marginTop: "16px", whiteSpace: "pre-wrap" }}></pre>
</div>
`;
}

renderOnLoad(): string {
return `
() => {
const previewObjectsButton = document.getElementById("kubectl-preview-objects");
const inspectRestartWorkflowButton = document.getElementById("kubectl-inspect-restart-workflow");
const output = document.getElementById("kubectl-workflow-result");
if (!previewObjectsButton || !inspectRestartWorkflowButton || !output) return;

const runHandler = async (handler) => {
output.textContent = "Running...";
try {
const result = await window.createBackendReq("UI_MESSAGE", {
handler,
content: {},
});
output.textContent = JSON.stringify(result, null, 2);
} catch (error) {
output.textContent = JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}, null, 2);
}
};

previewObjectsButton.addEventListener("click", () => {
void runHandler("kubectl.previewClusterObjects");
});

inspectRestartWorkflowButton.addEventListener("click", () => {
void runHandler("kubectl.inspectAndRestartWorkflow");
});
}
`;
}

async previewClusterObjects(): Promise<unknown> {
return requestOperatorTool("kubectl", {
command: "/usr/local/bin/kubectl",
Expand Down
48 changes: 48 additions & 0 deletions examples/fixtures/operator-terraform-plugin.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
FDOInterface,
FDO_SDK,
PluginMetadata,
PluginRegistry,
createOperatorToolActionRequest,
createOperatorToolCapabilityPreset,
createScopedWorkflowRequest,
Expand All @@ -26,6 +27,10 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
return this._metadata;
}

declareCapabilities() {
return createOperatorToolCapabilityPreset("terraform");
}

init(): void {
const request = createOperatorToolActionRequest("terraform", {
command: "/usr/local/bin/terraform",
Expand All @@ -36,9 +41,13 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
});

this.info("Terraform operator fixture initialized", {
declaredCapabilities: this.declareCapabilities(),
requestedCapabilities: createOperatorToolCapabilityPreset("terraform"),
request,
});

PluginRegistry.registerHandler("terraform.previewPlan", async () => this.previewPlan());
PluginRegistry.registerHandler("terraform.previewApplyWorkflow", async () => this.previewAndApplyWorkflow());
}

render(): string {
Expand All @@ -47,10 +56,49 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
<h1>${this._metadata.name}</h1>
<p>Use curated capability and request helpers for known tool families.</p>
<p>For multi-step preview/apply flows, prefer <code>requestScopedWorkflow(...)</code> over plugin-private orchestration.</p>
<div style={{ display: "flex", gap: "12px", marginTop: "12px", flexWrap: "wrap" }}>
<button id="terraform-preview-plan">Preview Plan</button>
<button id="terraform-preview-apply-workflow">Preview+Apply Workflow</button>
</div>
<pre id="terraform-workflow-result" style={{ marginTop: "16px", whiteSpace: "pre-wrap" }}></pre>
</div>
`;
}

renderOnLoad(): string {
return `
() => {
const previewPlanButton = document.getElementById("terraform-preview-plan");
const previewApplyWorkflowButton = document.getElementById("terraform-preview-apply-workflow");
const output = document.getElementById("terraform-workflow-result");
if (!previewPlanButton || !previewApplyWorkflowButton || !output) return;

const runHandler = async (handler) => {
output.textContent = "Running...";
try {
const result = await window.createBackendReq("UI_MESSAGE", {
handler,
content: {},
});
output.textContent = JSON.stringify(result, null, 2);
} catch (error) {
output.textContent = JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}, null, 2);
}
};

previewPlanButton.addEventListener("click", () => {
void runHandler("terraform.previewPlan");
});

previewApplyWorkflowButton.addEventListener("click", () => {
void runHandler("terraform.previewApplyWorkflow");
});
}
`;
}

async previewPlan(): Promise<unknown> {
return requestOperatorTool("terraform", {
command: "/usr/local/bin/terraform",
Expand Down
2 changes: 2 additions & 0 deletions src/FDOInterface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {FDO_SDK} from "./index";
import { PluginCapability } from "./types";

/**
* Public plugin contract for FDO SDK plugins.
Expand All @@ -7,6 +8,7 @@ import {FDO_SDK} from "./index";
* The SDK serializes those strings for host transport separately.
*/
export interface FDOInterface {
declareCapabilities?(): PluginCapability[];
init(): void;
render(): string;
renderOnLoad?(): string;
Expand Down
54 changes: 53 additions & 1 deletion src/PluginRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import { createDefaultStore } from "./StoreDefault";
import { createJsonStore } from "./StoreJson";
import { validatePluginMetadata, validateSerializedRenderPayload } from "./utils/contracts";
import { NotificationManager } from "./utils/NotificationManager";
import { configureCapabilities, getCapabilityDiagnostics, requireCapability } from "./utils/capabilities";
import { configureCapabilities, createCapabilityBundle, getCapabilityDiagnostics, requireCapability } from "./utils/capabilities";
import { emitDeprecationWarning } from "./utils/deprecation";

type PluginWithMetadata = FDO_SDK & { metadata: PluginMetadata };
type PluginWithQuickActions = FDO_SDK & { defineQuickActions: () => QuickAction[] };
type PluginWithSidePanel = FDO_SDK & { defineSidePanel: () => SidePanelConfig };
type PluginWithDeclaredCapabilities = FDO_SDK & { declareCapabilities: () => CapabilityConfiguration["granted"] };

export class PluginRegistry {
public static readonly DIAGNOSTICS_HANDLER = "__sdk.getDiagnostics";
Expand Down Expand Up @@ -151,6 +152,7 @@ export class PluginRegistry {
this.refreshLoggerContext();
this._logger.event("plugin.init.start");
try {
this.logDeclaredCapabilityPreflight();
this.pluginInstance?.init(); // Safe call without `!`
this.refreshLoggerContext();
this.diagnosticsState.initCount += 1;
Expand Down Expand Up @@ -240,6 +242,11 @@ export class PluginRegistry {
const quickActionsCount = this.getQuickActions().length;
const sidePanelConfig = this.getSidePanelConfig();
const permissions = getCapabilityDiagnostics();
const declaredCapabilities = this.getDeclaredCapabilities();
const grantedSet = new Set(permissions.granted);
const declaredSet = new Set(declaredCapabilities);
const missingDeclared = declaredCapabilities.filter((capability) => !grantedSet.has(capability));
const undeclaredGranted = permissions.granted.filter((capability) => !declaredSet.has(capability));

return {
apiVersion: FDO_SDK.API_VERSION,
Expand Down Expand Up @@ -269,6 +276,11 @@ export class PluginRegistry {
capabilities: store.capabilities,
version: store.getVersion?.(),
})),
declaration: {
declared: declaredCapabilities,
missing: missingDeclared,
undeclaredGranted,
},
permissions,
},
notifications: {
Expand Down Expand Up @@ -457,6 +469,46 @@ export class PluginRegistry {
private static hasSidePanel(plugin: FDO_SDK): plugin is PluginWithSidePanel {
return "defineSidePanel" in plugin;
}

private static hasDeclaredCapabilities(plugin: FDO_SDK): plugin is PluginWithDeclaredCapabilities {
return "declareCapabilities" in plugin && typeof (plugin as PluginWithDeclaredCapabilities).declareCapabilities === "function";
}

private static getDeclaredCapabilities(): CapabilityConfiguration["granted"] {
if (!this.pluginInstance || !this.hasDeclaredCapabilities(this.pluginInstance)) {
return [];
}

const declared = this.pluginInstance.declareCapabilities();
if (!Array.isArray(declared) || declared.some((capability) => typeof capability !== "string")) {
throw new Error("Plugin declareCapabilities() must return an array of capability strings.");
}

return createCapabilityBundle([...declared]);
}

private static logDeclaredCapabilityPreflight(): void {
const declaredCapabilities = this.getDeclaredCapabilities();
if (declaredCapabilities.length === 0) {
return;
}

const grantedCapabilities = getCapabilityDiagnostics().granted;
const grantedSet = new Set(grantedCapabilities);
const missingCapabilities = declaredCapabilities.filter((capability) => !grantedSet.has(capability));

this._logger.event("plugin.capabilities.declared", {
declaredCapabilities,
grantedCapabilities,
missingCapabilities,
});

if (missingCapabilities.length > 0) {
this._logger.warn(
`Plugin declared capabilities that are not currently granted: ${missingCapabilities.join(", ")}.`
);
}
}
}

export default PluginRegistry;
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ export type PluginDiagnostics = {
quickActionsCount: number;
hasSidePanel: boolean;
stores: PluginStoreDiagnostic[];
declaration: {
declared: PluginCapability[];
missing: PluginCapability[];
undeclaredGranted: PluginCapability[];
};
permissions: {
granted: PluginCapability[];
usageCount: Record<string, number>;
Expand Down
37 changes: 37 additions & 0 deletions tests/PluginRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ describe("PluginRegistry", () => {
expect(output).toEqual({ render: undefined, onLoad: undefined });
});

test("includes declared capability diagnostics and missing preflight information", () => {
class DeclaredCapabilitiesPlugin extends FDO_SDK {
get metadata() {
return {
id: "declared-capabilities-plugin",
name: "Declared Capabilities Plugin",
version: "1.0.0",
author: "Test",
description: "Declares expected capabilities",
icon: "cog",
};
}

declareCapabilities() {
return ["system.process.exec", "system.process.scope.terraform"] as const;
}

init() {}
render(): any { return "<div>ok</div>"; }
}

const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => {});
PluginRegistry.configureCapabilities({ granted: ["system.process.exec"] });
PluginRegistry.registerPlugin(new DeclaredCapabilitiesPlugin());
PluginRegistry.callInit();

const diagnostics = PluginRegistry.getDiagnostics();
expect(diagnostics.capabilities.declaration).toEqual({
declared: ["system.process.exec", "system.process.scope.terraform"],
missing: ["system.process.scope.terraform"],
undeclaredGranted: [],
});
expect(warnSpy).toHaveBeenCalledWith(
'Plugin declared capabilities that are not currently granted: system.process.scope.terraform.'
);
});

test("callInit and callRenderer with plugin instance", () => {
const mockPlugin = {
init: vi.fn(),
Expand Down