Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1c1be5d
feat(shared): bake execution capabilities into synced plugin manifests
MarioCadenas Sep 30, 2026
e66387a
feat: let the CLI supply user_api_scopes to the app template
MarioCadenas Sep 30, 2026
7a15767
refactor(shared): derive registry add scope warnings from SCOPE_BY_TYPE
MarioCadenas Sep 30, 2026
b2050ef
fix(shared): declare user scopes for plugins that always act as the user
MarioCadenas Sep 30, 2026
51f5417
chore: merge resource-and-execution-base-pr into manifest-scope-capab…
MarioCadenas Oct 1, 2026
da70299
refactor(shared): drop the unused CapabilityScope type export
MarioCadenas Oct 1, 2026
af70267
chore: merge resource-and-execution-base-pr into manifest-scope-capab…
MarioCadenas Oct 1, 2026
4ded489
chore: merge resource-and-execution-base-pr into manifest-scope-capab…
MarioCadenas Oct 1, 2026
d8f7602
docs(appkit): refresh the template user_api_scopes example to short n…
MarioCadenas Oct 1, 2026
7870aa4
test(appkit): align the template fallback pin with the short-name com…
MarioCadenas Oct 1, 2026
4608af5
feat(shared): bake DABs binding spec into synced plugin manifests
MarioCadenas Oct 1, 2026
a2cf53c
refactor(shared): anchor DABs binding yamlKeys with an SDK seam
MarioCadenas Oct 1, 2026
99912c3
refactor(shared): drop the SDK shim, leave a plain table with an accu…
MarioCadenas Oct 1, 2026
984bb2d
fix(shared): correct volume/experiment binding fields and guard the rest
MarioCadenas Oct 1, 2026
e9d8fcb
chore: merge resource-and-execution-base-pr into manifest-scope-capab…
MarioCadenas Oct 5, 2026
6781872
docs(appkit): explain genie's forced scope and the legacy template sc…
MarioCadenas Oct 5, 2026
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
10 changes: 9 additions & 1 deletion docs/docs/api/appkit/Interface.PluginManifest.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion docs/static/schemas/plugin-manifest.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1,417 changes: 1,410 additions & 7 deletions docs/static/schemas/template-plugins.schema.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions packages/appkit/src/plugins/genie/genie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ export class GeniePlugin extends Plugin implements ToolProvider {
return this.config.spaces?.[alias] ?? null;
}

/**
* Every route runs on behalf of the requesting user, whatever auth mode the
* genie_space resource is bound with. That is why manifest.json declares the
* plugin-level `scopes: ["genie"]`: the user token always needs the genie
* scope, even when the space is bound to the service principal. If these
* routes ever follow the resource's auth mode (as analytics does with
* `.obo.sql`), drop that plugin-level scope and let the resource-level scope
* from SCOPE_BY_TYPE cover it.
*/
injectRoutes(router: IAppRouter) {
this.route(router, {
name: "sendMessage",
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/plugins/genie/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "genie",
"displayName": "Genie Plugin",
"description": "AI/BI Genie space integration for natural language data queries",
"scopes": ["genie"],
"resources": {
"required": [
{
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/plugins/serving/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"displayName": "Model Serving Plugin (deprecated)",
"description": "DEPRECATED: use the agents plugin instead. Authenticated proxy to Databricks Model Serving endpoints",
"deprecated": true,
"scopes": ["model-serving"],
"resources": {
"required": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { scanForPlugins, scanPluginsDir } from "./sync";

const probe = {
name: "probe",
displayName: "Probe",
description: "Execution capability probe",
scopes: ["ai-gateway"],
resources: {
required: [
{
type: "sql_warehouse",
alias: "Warehouse",
resourceKey: "sql-warehouse",
description: "OBO-capable resource",
permission: "CAN_USE",
fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } },
},
],
optional: [
{
type: "secret",
alias: "Secret",
resourceKey: "secret",
description: "App-only resource",
permission: "READ",
fields: { scope: { env: "SECRET_SCOPE" }, key: { env: "SECRET_KEY" } },
},
{
type: "job",
alias: "Job",
resourceKey: "job",
description: "SP-only resource with no scope",
permission: "CAN_MANAGE_RUN",
fields: { id: { env: "DATABRICKS_JOB_ID" } },
},
],
},
};

const plain = { ...probe, name: "plain", scopes: undefined };

function writeManifest(dir: string, manifest: object) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest));
}

describe("sync execution capabilities", () => {
let tmp: string;

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-sync-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

const paths = {
"plugins dir (loadPluginEntry)": async () => {
writeManifest(path.join(tmp, "plugins", "probe"), probe);
writeManifest(path.join(tmp, "plugins", "plain"), plain);
return scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false);
},
"node_modules scan (scanForPlugins)": async () => {
const pluginsDir = path.join(
tmp,
"node_modules",
"@x/pkg",
"dist",
"plugins",
);
writeManifest(path.join(pluginsDir, "probe"), probe);
writeManifest(path.join(pluginsDir, "plain"), plain);
return scanForPlugins(tmp, ["@x/pkg"], false);
},
};

it.each(Object.entries(paths))(
"%s bakes scope, appOnly, and scopes",
async (_, scan) => {
const plugins = await scan();
const [warehouse] = plugins.probe.resources.required;
const [secret, job] = plugins.probe.resources.optional;

expect(warehouse.scope).toBe("sql");
expect(warehouse).not.toHaveProperty("appOnly");
expect(secret.appOnly).toBe(true);
expect(secret).not.toHaveProperty("scope");
expect(job).not.toHaveProperty("scope");
expect(job).not.toHaveProperty("appOnly");
expect(plugins.probe.scopes).toEqual(["ai-gateway"]);
expect(plugins.plain).not.toHaveProperty("scopes");

// DABs binding is baked per resource from DABS_BINDING_BY_TYPE.
expect(warehouse.binding).toEqual({
yamlKey: "sql_warehouse",
varFields: [["id", "id"]],
});
expect(secret.binding).toEqual({
yamlKey: "secret",
varFields: [
["scope", "scope"],
["key", "key"],
],
});
expect(job.binding).toEqual({
yamlKey: "job",
varFields: [["id", "id"]],
});
},
);

it("core plugins declare the scopes they always use on behalf of the user", async () => {
const plugins = await scanPluginsDir(
path.resolve(__dirname, "../../../../../../appkit/src/plugins"),
"@databricks/appkit",
false,
);
// genie and serving routes always run as the user, whatever the resource binding.
expect(plugins.genie.scopes).toEqual(["genie"]);
expect(plugins.serving.scopes).toEqual(["model-serving"]);
// These only act as the user when a resource or config opts in, so they stay unscoped.
for (const name of ["analytics", "files", "aiSearch", "agents"]) {
expect(plugins[name]).not.toHaveProperty("scopes");
}

// DABs binding is baked from the real core manifests, including the
// uc_securable static field for volumes.
const genieSpace = plugins.genie.resources.required.find(
(r: { type: string }) => r.type === "genie_space",
);
expect(genieSpace?.binding).toEqual({
yamlKey: "genie_space",
varFields: [
["name", "name"],
["id", "space_id"],
],
});
const volume = plugins.files.resources.required.find(
(r: { type: string }) => r.type === "volume",
);
expect(volume?.binding).toEqual({
yamlKey: "uc_securable",
// volume declares `path`, not `id`.
varFields: [["path", "securable_full_name"]],
staticFields: [["securable_type", "VOLUME"]],
});
});

it("fails sync when a binding references a field the resource does not declare", async () => {
const bad = {
name: "badbind",
displayName: "Bad binding",
description: "Binding references an undeclared field",
resources: {
required: [
{
type: "sql_warehouse",
alias: "Warehouse",
resourceKey: "sql-warehouse",
description: "sql_warehouse binding expects field `id`",
permission: "CAN_USE",
// Declares `region`, not `id`, so the baked binding varField `id`
// would reference an unset variable.
fields: { region: { env: "DATABRICKS_REGION" } },
},
],
optional: [],
},
};
writeManifest(path.join(tmp, "plugins", "badbind"), bad);
await expect(
scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false),
).rejects.toThrow(/binding references manifest field "id"/);
});
});
Loading