feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
This commit is contained in:
LukeGus committed 2026-09-21 16:56:15 -05:00
1 parent 225f618337
commit 2d6c1cdecb
29 files changed
+1971 -40

No files matched your search

+143
View File
@@ -18,7 +18,12 @@ const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
// Dotted like a service name, but segments may be camelCase: these name a
// frontend function ("ai.openWithContext"), not a lowercase service contract.
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
const ACTION_CONTRIBUTION_KINDS = ["button"];
function loadSchema() {
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
@@ -145,6 +150,20 @@ function validateManifest(manifest, schema) {
}
}
// Same rule for UI actions: a permission the catalog never sees is one no
// admin can grant, so the action would be invisible rather than denied.
const actions = manifest.contributes?.actions;
if (Array.isArray(actions)) {
for (const action of actions) {
if (!action || !action.permission) continue;
if (!Array.isArray(declared) || !declared.includes(action.permission)) {
errors.push(
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
return errors;
}
@@ -305,6 +324,130 @@ function validateContributes(contributes) {
}
}
if ("actions" in contributes) {
errors.push(...validateActions(contributes.actions));
}
if ("actionSlots" in contributes) {
errors.push(...validateActionSlots(contributes.actionSlots));
}
return errors;
}
function validateActions(actions) {
if (!Array.isArray(actions)) {
return ['Field "contributes.actions" must be an array'];
}
const errors = [];
const seen = new Set();
actions.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actions[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actions[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) {
errors.push(`contributes.actions[${index}].titleKey is required`);
}
if (
typeof entry.handler !== "string" ||
!HANDLER_PATTERN.test(entry.handler)
) {
errors.push(
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`,
);
}
if ("icon" in entry && typeof entry.icon !== "string") {
errors.push(`contributes.actions[${index}].icon must be a string`);
}
if ("permission" in entry && typeof entry.permission !== "string") {
errors.push(`contributes.actions[${index}].permission must be a string`);
}
if (
"slot" in entry &&
(typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot))
) {
errors.push(
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`,
);
}
if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) {
errors.push(
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`,
);
}
});
return errors;
}
function validateActionSlots(slots) {
if (!Array.isArray(slots)) {
return ['Field "contributes.actionSlots" must be an array'];
}
const errors = [];
const seen = new Set();
slots.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actionSlots[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) {
errors.push(
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
);
} else {
for (const kind of entry.accepts) {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) {
errors.push(
`contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
}
}
if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") {
errors.push(
`contributes.actionSlots[${index}].descriptionKey must be a string`,
);
}
});
return errors;
}