From 45cba087de0a0e9c51662201d5b63073ac796059 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 4 Nov 2026 12:08:37 +0200 Subject: [PATCH] fix: label catalog-owned skills as gateway-owned Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 ++ FEATURES.md | 4 ++++ TEST-PLAN.md | 7 +++++++ docs/SKILLS.md | 16 +++++++++------- public/admin-skills.js | 13 +++++++------ public/index.html | 2 +- public/skill-assignment-picker.js | 4 ++-- src/gateway/gateway-usage/references/skills.md | 6 ++++-- src/gateway/skills/authoring.js | 2 +- .../bundled/channelgate/references/mcp-skills.md | 2 +- .../skills/bundled/skill-authoring/SKILL.md | 2 +- src/mcp/tools/skills.js | 10 +++++----- src/web/skills-mcp.js | 4 ++-- 13 files changed, 46 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fda95cd..b68a4a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog — ChannelGate +- Rename user-facing “local skills” to “gateway-owned skills” across skill management and guidance; stored ownership values and update behavior are unchanged. + - Clarify catalog-first skill authoring: local skill installs are not durable; external source edits require verified repository write access. - Allow Composio Remote Bash alongside Remote Workbench for authorized remote processing, retaining current-run authority checks and structured membership approvals. diff --git a/FEATURES.md b/FEATURES.md index 3b9ec7dd..78a9aa7a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,9 @@ # ChannelGate — Features +## Skill ownership terminology + +- Skills managed directly in the catalog are labelled **Gateway-owned** in the Skills catalog, owner filter, creation form, assignment picker and MCP guidance. Personal/channel/organization scope remains independent; internal `ownerKind: "local"` values remain compatible. + ## Company email domain auto-approval - Admin UI → Users → Auto-approval accepts exact company email domains, disabled by default. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 4d37f982..8d0fe8a7 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,12 @@ # ChannelGate — Test Plan +## Skill ownership terminology (2026-11-04) + +- Wording-only, engine-independent: open Skills with a gateway-owned fixture (`ownerKind: "local"`) and a Git-source fixture. Require **Gateway-owned** in the catalog owner column/filter and creation form, and **Gateway-owned skills** in the assignment picker. Filter/select/save the gateway-owned fixture; API owner/filter values must remain `local` and the grant must persist. Repository-backed labels must remain distinct. +- MCP: `list_skills` and `get_skill_info` on the same gateway-owned fixture must describe gateway ownership; catalog create/update/publish tool descriptions must use the same vocabulary. Existing skill CRUD, publishing and scope regression suites verify unchanged contracts. +- Passed: 31 focused skill CRUD, publishing, admin UI and picker regression checks; static validation. Disposable Chromium acceptance passed for catalog owner/filter/create labels, distinct Git-source label, raw `owner=local` filter request, picker source label/filter/add and unchanged stored ownership. This wording-only check is engine-independent. +- Existing full conversation/template browser suite: compact layout case passed; conversation navigation case timed out before reaching the skill picker (`#channel-detail .subtab[data-pane="skills"]`). Its unrelated full-flow acceptance remains unresolved. + ## Company domain auto-approval (2026-10-28) - Automated: `node --test test/user-auto-approval.test.js test/user-search.test.js` covers diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 2f544da5..43e0c9f2 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,4 +1,4 @@ -# Skills — the local catalog, channel profiles, templates, authoring and sources +# Skills — the gateway catalog, channel profiles, templates, authoring and sources ChannelGate keeps its own **skill catalog** in the gateway database and materializes real skill folders into every conversation that is granted them. Skills work with no external token and no @@ -42,7 +42,9 @@ Every skill has one **owner**: | bundled | shipped in this checkout (`src/gateway/skills/bundled/`), imported at boot | a new gateway release | | folder | the host's `~/.claude/skills`, `~/.agents/skills` or `GATEWAY_SKILL_SOURCES`, imported at boot and re-read by content | edit the folder; the next boot / re-import picks it up | | git | a GitHub source (below) | the source's next sync; a skill of the gateway's own publish repository also takes `update_skill` (pushed back) | -| local | authored from chat (`create_skill`) or the admin UI | `update_skill` — see *Who may change what* below | +| gateway-owned (`local` internally) | authored from chat (`create_skill`) or the admin UI | `update_skill` — see *Who may change what* below | + +**Gateway-owned** means managed directly in this gateway's catalog, independently of whether the skill is personal, channel-scoped or organization-wide. It does not mean an unmanaged file installed in an engine skill folder. Publishing to the configured Git source adopts the skill into that source; it remains editable through `update_skill` when that source is the gateway's writable publishing repository. Internal API/database ownership values remain `local` for compatibility. A slug is owned by exactly one owner. A sync or an import that finds a slug another owner holds reports a **conflict** and writes nothing. A skill whose source this gateway cannot write to @@ -99,7 +101,7 @@ in every other conversation) and back on with `add_channel_skills`. `set_skill_s `POST /api/skills/catalog/:slug/scope` move a skill either way: **promoting** a customer skill to the library moves its files in the repository and leaves the channel with an explicit grant, so nothing changes there; demoting keeps it for that channel only. Only skills the publish -repository owns (or unpublished local ones) move — a skill synced from another source stays with +repository owns (or unpublished gateway-owned ones) move — a skill synced from another source stays with its source. Names must be unique across the whole repository (the slug comes from the skill's name), so customer skills are prefixed with the customer. @@ -235,7 +237,7 @@ it can never reach the organization tier. above). The bundled `skill-authoring` skill teaches the contract and how to write a description that triggers. - `update_skill` — a new revision. Pass only the files that change; the rest are carried over - (`remove` drops files). A local skill is revised in place; a skill synced from the configured + (`remove` drops files). A gateway-owned skill is revised in place; a skill synced from the configured publish repository is revised and pushed back to its own folder (if the push fails the new revision is pinned, so the next sync cannot revert it). A skill pinned to an older revision is re-pinned to the new one. A non-admin's edit of an organization skill becomes a `change` @@ -248,7 +250,7 @@ it can never reach the organization tier. organization skill gets granted everywhere), a catalog delete (`kind: delete`), or feedback. - Admins review with `list_skill_proposals` / `decide_skill_proposal` in chat, or in the admin UI (Skills → Review). An approved change becomes one revision; when the skill is source-owned the - revision is **pinned** as a local override, so the source keeps flowing into later revisions + revision is **pinned** as a gateway override, so the source keeps flowing into later revisions and the pin holds until an admin unpins. An approved promotion moves a channel skill into the library (the channel keeps it as an explicit grant), makes a personal skill an organization skill, or adds an organization skill to the organization tier; an approved `delete` tombstones @@ -278,7 +280,7 @@ publishes it). Any approved member can also carry organization skills in their o ## Publishing to Git Admin UI → Skills → Sync settings → *Publishing to Git*: a GitHub repository, folder and its own -write-only token. Publishing follows `main`. Every new revision of a local skill — created, +write-only token. Publishing follows `main`. Every new revision of a gateway-owned skill — created, updated, an approved change, an approved promotion — is pushed with the GitHub Contents API, one commit per file under `//` (`.` as the folder = the repository root) — or under `channels///` for a channel-scoped skill, with a README naming the channel — and @@ -344,7 +346,7 @@ with the gateway's environment, uses the Skills Manager tokens the old integrati 3. with `SM_SUPABASE_URL` + `SM_SUPABASE_SERVICE_KEY` (Skills Manager's service role): team favorites become templates (Development; Sales & Marketing → sales and marketing; Management; Admin), admin exclusions become tombstones, and each token user's personal skills become - personal local skills. + personal gateway-owned skills. The script is idempotent and prints what it would do with `--dry-run`. Stub folders the old integration left in channel folders are pruned automatically on each channel's next message. diff --git a/public/admin-skills.js b/public/admin-skills.js index a9f2d540..cbae5e54 100644 --- a/public/admin-skills.js +++ b/public/admin-skills.js @@ -1,4 +1,4 @@ -// The Skills view of the admin UI: the local skill catalog (browse, read, author, pin/rollback, +// The Skills view of the admin UI: the gateway skill catalog (browse, read, author, pin/rollback, // remove), the review queue (staged source revisions + proposals), sources (GitHub / gateway, // sync, settings), templates (edit, preview and apply to a conversation) and usage. Talks to // src/web/routes/skills.js. No framework — one delegated click handler per panel. @@ -51,7 +51,8 @@ const state = { const body = () => document.getElementById("skills-body"); const fmtWhen = (ts) => (ts ? String(ts).replace("T", " ").slice(0, 16) : "—"); -const ownerLabel = (s) => (s.ownerKind === "git" ? `git source #${s.sourceId}` : s.ownerKind); +const ownerKindLabel = (kind) => kind === "local" ? "Gateway-owned" : kind; +const ownerLabel = (s) => (s.ownerKind === "git" ? `git source #${s.sourceId}` : ownerKindLabel(s.ownerKind)); function setMessage(msg, isError = false) { state.message = isError ? "" : msg; @@ -262,7 +263,7 @@ function renderCatalog() { return `
${searchBox("skills-q", { label: "Search the skill catalog", placeholder: "Search slug, name, description, tags…" })} - + @@ -283,7 +284,7 @@ function renderCatalog() { function renderNewSkillForm() { return `
-

New local skill

+

New gateway-owned skill

@@ -357,7 +358,7 @@ function renderReview() { ${esc(r.slug)}${pluginBadge(r)} ${esc(r.skillName || "")}${pluginSummary(r)} #${r.revisionNo}${r.version ? ` v${esc(r.version)}` : ""} · ${r.fileCount} file(s) - ${esc(r.ownerKind === "git" ? `git source #${r.sourceId}` : r.ownerKind)} · ${esc(r.sourceRef.slice(0, 12))} + ${esc(ownerLabel(r))} · ${esc(r.sourceRef.slice(0, 12))} ${fmtWhen(r.createdAt)} `).join(""); @@ -374,7 +375,7 @@ function renderReview() { ${stagedRows || ''}
Skill or pluginRevisionFromReceived
Nothing staged.
${state.fileView?.kind === "revision" ? `

Revision #${state.fileView.revisionNo} files

${state.fileView.files.map((f) => `

${esc(f.path)} ${f.size} B

${f.content != null ? `
${esc(f.content)}
` : ""}`).join("")}
` : ""}

Proposals

-

Changes members proposed from chat. Approving a change publishes a revision (pinned as a local override when the skill comes from a source); approving a promotion grants the skill organization-wide.

+

Changes members proposed from chat. Approving a change publishes a revision (pinned as a gateway override when the skill comes from a source); approving a promotion grants the skill organization-wide.

${proposalRows || ''}
ProposalNote / filesBy
No pending proposals.
${state.fileView?.kind === "proposal" ? `

Proposal #${state.fileView.id} files

${state.fileView.files.map((f) => `

${esc(f.path)}

${esc(f.encoding === "base64" ? "(binary)" : f.content)}
`).join("")}
` : ""}`; } diff --git a/public/index.html b/public/index.html index 8d0dd2ba..af4f9037 100644 --- a/public/index.html +++ b/public/index.html @@ -140,7 +140,7 @@

Users

- +
diff --git a/public/skill-assignment-picker.js b/public/skill-assignment-picker.js index 7aede03b..358f6a83 100644 --- a/public/skill-assignment-picker.js +++ b/public/skill-assignment-picker.js @@ -36,8 +36,8 @@ export function mountSkillAssignmentPicker(root, options) { const source = root.querySelector("[data-picker-source]"); const sourceLabel = (skill) => config.sources.find((s) => String(s.id) === String(skill.sourceId))?.label || config.sources.find((s) => String(s.id) === String(skill.sourceId))?.url - || ({ local: "Local skills", bundled: "Built-in skills", folder: "Host folders" }[skill.ownerKind]) - || (skill.sourceId != null ? `Source #${skill.sourceId}` : "Local skills"); + || ({ local: "Gateway-owned skills", bundled: "Built-in skills", folder: "Host folders" }[skill.ownerKind]) + || (skill.sourceId != null ? `Source #${skill.sourceId}` : "Gateway-owned skills"); function paintSources() { const previous = source.value; const sources = new Map(config.skills.map((skill) => [sourceKey(skill), sourceLabel(skill)])); diff --git a/src/gateway/gateway-usage/references/skills.md b/src/gateway/gateway-usage/references/skills.md index e0632055..9c5941ba 100644 --- a/src/gateway/gateway-usage/references/skills.md +++ b/src/gateway/gateway-usage/references/skills.md @@ -1,10 +1,12 @@ # Skills: what is active here, templates, authoring and proposals Skills are reusable procedures (a `SKILL.md` plus references/scripts) that the gateway keeps in -its own local **catalog** and materializes into this conversation's folder as real files. You +its own gateway **catalog** and materializes into this conversation's folder as real files. You discover them natively — the always-on list is each skill's name + description; the body loads when a skill fires. No token or network call is involved. +**Gateway-owned** skills are managed directly in the catalog (internal owner value `local`). This ownership is independent of personal/channel/organization scope and does not mean unmanaged files installed in an engine skill folder. + ## Where agents must save skills Create and grant skills through the ChannelGate catalog using `create_skill` and the gateway grant tools. Do not install active skills by writing directly into `.claude/skills`, `.agents/skills`, or engine HOME skill folders: gateway reconciliation replaces managed copies and archives unmanaged workspace entries. An on-disk export is only an export, not a durable active channel skill. This rule overrides a generic skill creator's local-folder default. @@ -143,7 +145,7 @@ never-expiring) card; the rest are automatic for an admin (`references/administr - `set_skill_governance` — enable/disable a catalog skill, approve it for organization-wide discovery, or make it mandatory. Mandatory implies enabled and discoverable. - `set_skill_excluded` — hide a synced/bundled skill from the catalog (it stays hidden across syncs) or bring it back. -- `publish_skill` (managers too) — push a local skill to the configured Git repository now. +- `publish_skill` (managers too) — push a gateway-owned or writable publishing-repository skill to the configured Git repository now. - `set_skill_scope` (admins) — move a skill between the shared library and a channel's section (`scope: "library"` promotes a customer skill for everyone; the channel it leaves keeps it as an explicit grant; `scope: "channel"` keeps it for one customer). The files move in the diff --git a/src/gateway/skills/authoring.js b/src/gateway/skills/authoring.js index b2acc20a..3220db70 100644 --- a/src/gateway/skills/authoring.js +++ b/src/gateway/skills/authoring.js @@ -48,7 +48,7 @@ export function describeOwner(skill) { case "bundled": return "bundled with the gateway"; default: - return `${skill?.visibility === "personal" ? "personal, " : ""}authored locally${skill?.createdBy ? ` by ${skill.createdBy}` : ""}`; + return `${skill?.visibility === "personal" ? "personal, " : ""}gateway-owned${skill?.createdBy ? `, created by ${skill.createdBy}` : ""}`; } } diff --git a/src/gateway/skills/bundled/channelgate/references/mcp-skills.md b/src/gateway/skills/bundled/channelgate/references/mcp-skills.md index a1200ef1..6552f879 100644 --- a/src/gateway/skills/bundled/channelgate/references/mcp-skills.md +++ b/src/gateway/skills/bundled/channelgate/references/mcp-skills.md @@ -44,7 +44,7 @@ See [folder and headless configuration](folder-headless.md) for the low-level po ## Local governed skill catalog ChannelGate's SQLite catalog stores exact, content-hashed skill revisions and bundled resources. -Skills may be bundled, locally authored, imported from reviewed host folders, synchronized from Git +Skills may be bundled, gateway-owned, imported from reviewed host folders, synchronized from Git sources, or synchronized from a peer gateway. Review mode stages source changes; auto mode activates them. Excluding a skill is sticky across future syncs; deleting/tombstoning and restoring retain revision history. diff --git a/src/gateway/skills/bundled/skill-authoring/SKILL.md b/src/gateway/skills/bundled/skill-authoring/SKILL.md index b907c323..b6a52c5a 100644 --- a/src/gateway/skills/bundled/skill-authoring/SKILL.md +++ b/src/gateway/skills/bundled/skill-authoring/SKILL.md @@ -15,7 +15,7 @@ tags: # Authoring skills Skills are folders with a `SKILL.md` (YAML frontmatter + Markdown body) plus optional -`references/`, `scripts/` and `assets/` files. The gateway keeps them in its local catalog and +`references/`, `scripts/` and `assets/` files. The gateway keeps them in its gateway catalog and materializes them into every conversation that is granted them — Claude and Codex both discover them natively by their frontmatter `name` and `description`. diff --git a/src/mcp/tools/skills.js b/src/mcp/tools/skills.js index 73f5209d..ababd247 100644 --- a/src/mcp/tools/skills.js +++ b/src/mcp/tools/skills.js @@ -73,7 +73,7 @@ function skillLine(skill, { detail = true } = {}) { const bits = []; if (skill.version) bits.push(`v${skill.version}`); if (skill.category) bits.push(skill.category); - bits.push(skill.ownerKind === "git" ? "synced" : skill.ownerKind); + bits.push(skill.ownerKind === "git" ? "synced" : skill.ownerKind === "local" ? "gateway-owned" : skill.ownerKind); if (skill.visibility === "personal") bits.push("personal"); const desc = skill.description ? ` — ${skill.description.length > 140 ? `${skill.description.slice(0, 137)}…` : skill.description}` : ""; return `• \`${skill.slug}\`${detail ? ` (${bits.join(", ")})` : ""}${desc}`; @@ -505,7 +505,7 @@ export function register(server, ctx) { server.registerTool( "delete_skill", { - description: "Delete a skill from the WHOLE catalog (tombstone; an admin can restore it). Admins: any locally authored skill; anyone: their own personal skill. For anyone else it deactivates the skill in this conversation and files a delete request for an admin. When a member says \"delete this skill\" about a channel skill they usually mean remove_channel_skills (deactivate it here).", + description: "Delete a skill from the WHOLE catalog (tombstone; an admin can restore it). Admins: any gateway-owned skill; anyone: their own personal skill. For anyone else it deactivates the skill in this conversation and files a delete request for an admin. When a member says \"delete this skill\" about a channel skill they usually mean remove_channel_skills (deactivate it here).", inputSchema: { skill: z.string() }, }, async ({ skill: key }) => { @@ -576,7 +576,7 @@ export function register(server, ctx) { server.registerTool( "decide_skill_proposal", { - description: "ADMINS. Approve or reject a skill proposal. Approving a change publishes a new revision (pinned as a local override when the skill comes from a source); approving a promotion makes a personal skill an organization skill or grants an organization skill everywhere; approving feedback closes it.", + description: "ADMINS. Approve or reject a skill proposal. Approving a change publishes a new revision (pinned as a gateway override when the skill comes from a source); approving a promotion makes a personal skill an organization skill or grants an organization skill everywhere; approving feedback closes it.", inputSchema: { id: z.number().int(), decision: z.enum(["approve", "reject"]), note: z.string().optional() }, }, async ({ id, decision, note = "" }) => { @@ -584,7 +584,7 @@ export function register(server, ctx) { try { const r = await decideSkillProposal(id, { decision, decidedBy: createdBy, note }); if (decision === "reject") return text(`Proposal #${id} rejected.`); - return text(`✅ Proposal #${id} approved${r.revision ? ` — \`${r.proposal.slug}\` is now revision ${r.revision.revisionNo}${r.pinned ? " (pinned as a local override of its source; unpin in the admin UI to follow the source again)" : ""}` : ""}${r.promoted ? ` — \`${r.proposal.slug}\` promoted` : ""}${r.deleted ? ` — \`${r.proposal.slug}\` removed from the catalog (restorable in the admin UI)` : ""}${r.templated ? ` — \`${r.proposal.slug}\` added to the ${r.proposal.target} template (every conversation following it gets it on its next message)` : ""}.${publishLine(r.published)}`); + return text(`✅ Proposal #${id} approved${r.revision ? ` — \`${r.proposal.slug}\` is now revision ${r.revision.revisionNo}${r.pinned ? " (pinned as a gateway override of its source; unpin in the admin UI to follow the source again)" : ""}` : ""}${r.promoted ? ` — \`${r.proposal.slug}\` promoted` : ""}${r.deleted ? ` — \`${r.proposal.slug}\` removed from the catalog (restorable in the admin UI)` : ""}${r.templated ? ` — \`${r.proposal.slug}\` added to the ${r.proposal.target} template (every conversation following it gets it on its next message)` : ""}.${publishLine(r.published)}`); } catch (err) { return text(`🚫 ${err?.message || err}`); } @@ -627,7 +627,7 @@ export function register(server, ctx) { server.registerTool( "publish_skill", - { description: "ADMINS / CHANNEL MANAGERS. Push a local skill's current revision to the configured Git repository now (normally automatic on create/update/approve).", inputSchema: { skill: z.string() } }, + { description: "ADMINS / CHANNEL MANAGERS. Push a gateway-owned or writable publishing-repository skill's current revision to the configured Git repository now (normally automatic on create/update/approve).", inputSchema: { skill: z.string() } }, async ({ skill: key }) => { if (!(await requireManage())) return text("Only this channel's managers (or an admin) can publish skills."); const target = publishTarget(); diff --git a/src/web/skills-mcp.js b/src/web/skills-mcp.js index 47bd4332..86f908fa 100644 --- a/src/web/skills-mcp.js +++ b/src/web/skills-mcp.js @@ -143,7 +143,7 @@ export function buildSkillsMcpServer(token, reach = null) { server.registerTool( "library_create_skill", - { description: "Create a new local skill in the catalog from files (SKILL.md required). A catalog token creates a library skill; a user token creates a PERSONAL skill by default, a channel skill when channel_id names a channel that admits you, an organization skill only for an admin (organization: true).", inputSchema: { slug: z.string().optional(), files: z.array(FILE_INPUT).min(1), note: z.string().optional(), channel_id: z.string().optional(), organization: z.boolean().optional() } }, + { description: "Create a new gateway-owned skill in the catalog from files (SKILL.md required). A catalog token creates a library skill; a user token creates a PERSONAL skill by default, a channel skill when channel_id names a channel that admits you, an organization skill only for an admin (organization: true).", inputSchema: { slug: z.string().optional(), files: z.array(FILE_INPUT).min(1), note: z.string().optional(), channel_id: z.string().optional(), organization: z.boolean().optional() } }, async ({ slug = "", files, note = "", channel_id = "", organization = false }) => { const denied = need("manage"); if (denied) return denied; @@ -159,7 +159,7 @@ export function buildSkillsMcpServer(token, reach = null) { server.registerTool( "library_update_skill", - { description: "Publish a new revision of a local skill from partial files (unnamed files are kept).", inputSchema: { name: z.string(), files: z.array(FILE_INPUT).min(1), remove: z.array(z.string()).optional(), note: z.string().optional() } }, + { description: "Publish a new revision of a gateway-owned skill or a skill in the gateway's writable publishing repository from partial files (unnamed files are kept).", inputSchema: { name: z.string(), files: z.array(FILE_INPUT).min(1), remove: z.array(z.string()).optional(), note: z.string().optional() } }, async ({ name, files, remove = [], note = "" }) => { const denied = need("manage"); if (denied) return denied;