-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: add support for adding agent skills from private github repos #2295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1292,6 +1292,7 @@ export function createExtensionsStore(options: { | |
| owner: repo.owner, | ||
| repo: repo.repo, | ||
| ref: repo.ref, | ||
| token: repo.accessToken, | ||
| }, | ||
| }); | ||
| if (refreshHubSkillsAborted) return; | ||
|
|
@@ -1314,9 +1315,11 @@ export function createExtensionsStore(options: { | |
| return; | ||
| } | ||
|
|
||
| const headers: Record<string, string> = { Accept: "application/vnd.github+json" }; | ||
| if (repo.accessToken) headers["Authorization"] = `Bearer ${repo.accessToken}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also the openwork-server path above ( Fix: preserve |
||
| const listingRes = await fetch( | ||
| `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/contents/skills?ref=${encodeURIComponent(repo.ref)}`, | ||
| { headers: { Accept: "application/vnd.github+json" } }, | ||
| { headers }, | ||
| ); | ||
| if (!listingRes.ok) { | ||
| throw new Error(`Failed to fetch hub catalog (${listingRes.status})`); | ||
|
|
@@ -1667,7 +1670,7 @@ export function createExtensionsStore(options: { | |
| setStateField("skillsStatus", null); | ||
|
|
||
| try { | ||
| const repoOverride: OpenworkHubRepo = { owner: repo.owner, repo: repo.repo, ref: repo.ref }; | ||
| const repoOverride: OpenworkHubRepo = { owner: repo.owner, repo: repo.repo, ref: repo.ref, token: repo.accessToken }; | ||
| if (!openworkClient || !openworkWorkspaceId) return { ok: false, message: "Hub install requires OpenWork server." }; | ||
| const result = await openworkClient.installHubSkill(openworkWorkspaceId, trimmed, { repo: repoOverride }); | ||
| await Promise.all([refreshSkills({ force: true }), refreshHubSkills({ force: true })]); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1880,10 +1880,12 @@ function createRoutes( | |
| const owner = ctx.url.searchParams.get("owner")?.trim(); | ||
| const repo = ctx.url.searchParams.get("repo")?.trim(); | ||
| const ref = ctx.url.searchParams.get("ref")?.trim(); | ||
| const token = ctx.url.searchParams.get("token")?.trim(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Sensitive GitHub PAT is transmitted via URL query parameter on GET /hub/skills, risking exposure in access logs, browser history, and intermediary caches. Prompt for AI agents |
||
| const items = await listHubSkills({ | ||
| owner: owner || "different-ai", | ||
| repo: repo || "openwork-hub", | ||
| ref: ref || "main", | ||
| token, | ||
| }); | ||
| return jsonResponse({ items }); | ||
| }); | ||
|
|
@@ -1911,6 +1913,7 @@ function createRoutes( | |
| owner: typeof repoPayload.owner === "string" ? repoPayload.owner : undefined, | ||
| repo: typeof repoPayload.repo === "string" ? repoPayload.repo : undefined, | ||
| ref: typeof repoPayload.ref === "string" ? repoPayload.ref : undefined, | ||
| token: typeof repoPayload.token === "string" ? repoPayload.token : undefined, | ||
| } | ||
| : undefined; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ import { exists } from "./utils.js"; | |
| import { validateSkillName } from "./validators.js"; | ||
| import { projectSkillsDir } from "./workspace-files.js"; | ||
|
|
||
| type HubRepo = { owner: string; repo: string; ref: string }; | ||
| type HubRepo = { owner: string; repo: string; ref: string; token?: string }; | ||
|
|
||
| const DEFAULT_HUB_REPO: HubRepo = { | ||
| owner: "different-ai", | ||
|
|
@@ -31,27 +31,29 @@ function hubRawBase(repo: HubRepo) { | |
| return `https://raw.githubusercontent.com/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/${encodeURIComponent(repo.ref)}`; | ||
| } | ||
|
|
||
| async function fetchJson(url: string): Promise<any> { | ||
| const res = await fetch(url, { | ||
| headers: { | ||
| Accept: "application/vnd.github+json", | ||
| "User-Agent": "openwork-server", | ||
| }, | ||
| }); | ||
| async function fetchJson(url: string, token?: string): Promise<any> { | ||
| const headers: Record<string, string> = { | ||
| Accept: "application/vnd.github+json", | ||
| "User-Agent": "openwork-server", | ||
| }; | ||
| if (token) headers["Authorization"] = `Bearer ${token}`; | ||
|
|
||
| const res = await fetch(url, { headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text().catch(() => ""); | ||
| throw new ApiError(502, "hub_fetch_failed", `Failed to fetch hub data (${res.status}): ${text || url}`); | ||
| } | ||
| return res.json(); | ||
| } | ||
|
|
||
| async function fetchText(url: string): Promise<string> { | ||
| const res = await fetch(url, { | ||
| headers: { | ||
| Accept: "text/plain", | ||
| "User-Agent": "openwork-server", | ||
| }, | ||
| }); | ||
| async function fetchText(url: string, token?: string): Promise<string> { | ||
| const headers: Record<string, string> = { | ||
| Accept: "text/plain", | ||
| "User-Agent": "openwork-server", | ||
| }; | ||
| if (token) headers["Authorization"] = `Bearer ${token}`; | ||
|
|
||
| const res = await fetch(url, { headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text().catch(() => ""); | ||
| throw new ApiError(502, "hub_fetch_failed", `Failed to fetch hub data (${res.status}): ${text || url}`); | ||
|
|
@@ -108,7 +110,7 @@ export async function listHubSkills(repo: HubRepo = DEFAULT_HUB_REPO): Promise<H | |
| return cachedCatalog.items; | ||
| } | ||
|
|
||
| const listing = await fetchJson(`${hubApiBase(repo)}/contents/skills?ref=${encodeURIComponent(repo.ref)}`); | ||
| const listing = await fetchJson(`${hubApiBase(repo)}/contents/skills?ref=${encodeURIComponent(repo.ref)}`, repo.token); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Catalog cache key omits authentication context, so private repo listings fetched with a PAT can be served to later unauthenticated requests for the same owner/repo/ref. Prompt for AI agents |
||
| const dirs = Array.isArray(listing) | ||
| ? listing | ||
| .filter((entry) => entry && typeof entry === "object" && entry.type === "dir" && typeof entry.name === "string") | ||
|
|
@@ -120,7 +122,7 @@ export async function listHubSkills(repo: HubRepo = DEFAULT_HUB_REPO): Promise<H | |
| try { | ||
| const skillName = dirName.trim(); | ||
| validateSkillName(skillName); | ||
| const skillMd = await fetchText(`${rawBase}/skills/${encodeURIComponent(skillName)}/SKILL.md`); | ||
| const skillMd = await fetchText(`${rawBase}/skills/${encodeURIComponent(skillName)}/SKILL.md`, repo.token); | ||
| const { data, body } = parseFrontmatter(skillMd); | ||
| const name = typeof data.name === "string" ? data.name : skillName; | ||
| const descriptionRaw = typeof data.description === "string" ? data.description : ""; | ||
|
|
@@ -191,6 +193,7 @@ export async function installHubSkill( | |
| owner: input.repo?.owner?.trim() || DEFAULT_HUB_REPO.owner, | ||
| repo: input.repo?.repo?.trim() || DEFAULT_HUB_REPO.repo, | ||
| ref: input.repo?.ref?.trim() || DEFAULT_HUB_REPO.ref, | ||
| token: input.repo?.token?.trim(), | ||
| }; | ||
|
|
||
| const prefix = `skills/${name}/`; | ||
|
|
@@ -200,7 +203,7 @@ export async function installHubSkill( | |
|
|
||
| await mkdir(baseDir, { recursive: true }); | ||
|
|
||
| const tree = await fetchJson(`${hubApiBase(repo)}/git/trees/${encodeURIComponent(repo.ref)}?recursive=1`); | ||
| const tree = await fetchJson(`${hubApiBase(repo)}/git/trees/${encodeURIComponent(repo.ref)}?recursive=1`, repo.token); | ||
| const entries: HubTreeEntry[] = Array.isArray(tree?.tree) ? tree.tree : []; | ||
| const files = entries | ||
| .filter((entry) => entry && entry.type === "blob" && typeof entry.path === "string" && entry.path.startsWith(prefix)) | ||
|
|
@@ -230,9 +233,10 @@ export async function installHubSkill( | |
| } | ||
|
|
||
| await mkdir(dirname(destPath), { recursive: true }); | ||
| const res = await fetch(`${rawBase}/${file.path}`, { | ||
| headers: { "User-Agent": "openwork-server" }, | ||
| }); | ||
| const headers: Record<string, string> = { "User-Agent": "openwork-server" }; | ||
| if (repo.token) headers["Authorization"] = `Bearer ${repo.token}`; | ||
|
|
||
| const res = await fetch(`${rawBase}/${file.path}`, { headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text().catch(() => ""); | ||
| throw new ApiError(502, "hub_fetch_failed", `Failed to fetch hub file (${res.status}): ${text || file.path}`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Putting the PAT in the query string means it lands in server/proxy access logs and history. The install path already sends the repo (and token) in a POST body — better to do the same here, or send the token as a request header, rather than via
?token=.