diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 46ec46609..04469a94d 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -184,6 +184,54 @@ export const ARCHETYPES = [ { id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" }, ]; +// The catalog's `held` entries — only on GET /api/archetypes?include_held=1 (the New-agent +// picker's "Show preview archetypes" opt-in), each flagged `held: true` → a "Preview" badge. +export const HELD_ARCHETYPES = [ + { + id: "analyst", label: "Analyst", icon: "ChartColumn", + blurb: "Answers questions from your own data files — CSV, Parquet, Excel, SQLite — with SQL and a live chart.", + bundle: "https://github.com/protoLabsAI/analyst-archetype", + soul: "# Identity\n\nI am a data analyst.", + tier: "standard", + requires_tools: ["data_query", "data_chart"], + held: true, + }, +]; + +// GET /api/archetypes/from-url — the "From a bundle URL" peek. Shaped after the real +// analyst-archetype v0.1.0: one fetched plugin pinned at a ref, two built-ins it turns on, +// and a required config_inputs prompt. A non-protoLabsAI URL answers `trusted: false` → the ack. +export function archetypeFromUrl(url, ref) { + const source = url.replace(/^https:\/\//, "").replace(/\.git$/, "").replace(/\/$/, ""); + return { + id: "analyst-archetype", + archetype: { + id: "analyst-archetype", label: "Analyst", icon: "ChartColumn", + blurb: "Answers questions from your own data files with SQL and a live chart.", + bundle: url, soul: "# Identity\n\nI am a data analyst.", tier: "standard", + requires: [], requires_tools: ["data_query", "data_chart"], + ...(ref ? { ref } : {}), + }, + bundle: { + kind: "bundle", id: "analyst-archetype", name: "Analyst", + description: "A read-only data analyst over the folders you allow.", + enabled: ["data", "artifact", "notes"], + members: [ + { id: "data", builtin: false, ref: "v0.1.0", url: "https://github.com/protoLabsAI/data-plugin", name: "Data", version: "0.1.0", description: "SQL over CSV / Parquet / Excel / SQLite, plus charts.", skills: [{ name: "analyze", description: "Answer a question from data" }] }, + { id: "artifact", builtin: true, name: "Artifact", version: "0.4.0", description: "Publish charts and reports as artifacts." }, + { id: "notes", builtin: true, name: "Notes", version: "0.6.0", description: "Markdown notes beside chat." }, + ], + mcp: [], + secrets: [], + config_inputs: [ + { key: "data.data_dirs", label: "Data folders", type: "path", required: true, help: "Only these folders are readable." }, + ], + }, + trusted: source.toLowerCase().startsWith("github.com/protolabsai/"), // official org → no ack + source, + }; +} + // GET /api/archetypes/{id}/preview — the read-only bundle peek (#2041). product-archetype // asks for a GitHub MCP server (needs a token) + a standalone Brave secret, so it exercises // the enriched preview dialog AND the new-agent Configure step's SOFT gate (skip → env). diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index ed2aa3bda..aacc0dcf7 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -204,6 +204,93 @@ test("New agent: every card has its own 'What's included' preview (#2041)", asyn await expect(dialog.getByText("Secrets: Brave API key")).toBeVisible(); }); +// ── New agent: "From a bundle URL" + opt-in preview archetypes ─────────────────────── +// The third source: paste a bundle's git URL (+ ref) → the read-only peek shows what it +// installs and asks for an explicit trust ack on a non-official source → the SAME set-up +// dialog → Create posts `bundle` + `ref`. Held catalog archetypes appear only behind +// Advanced ▸ "Show preview archetypes", badged "Preview", and the choice sticks per console. + +test("New agent → From a bundle URL → preview + trust → set up → create posts bundle + ref", async ({ page }) => { + await openAgents(page); + let posted = null; + await page.route("**/api/fleet", async (route) => { + if (route.request().method() === "POST") posted = route.request().postDataJSON(); + return route.continue(); + }); + await page.getByRole("button", { name: "New agent" }).click(); + await page.getByRole("tab", { name: "From a bundle URL" }).click(); + + // A non-git URL is refused before anything is fetched. + const urlField = page.getByLabel("Repository URL"); + await urlField.fill("not a repo"); + await page.getByRole("button", { name: "Look up" }).click(); + await expect(page.getByRole("alert")).toContainText("isn't a git repository URL"); + + // A GitHub /tree/ page URL folds to repo + ref. + await urlField.fill("https://github.com/acme/analyst-archetype/tree/v0.1.0"); + await page.getByRole("button", { name: "Look up" }).click(); + const preview = page.locator(".bundle-url-preview"); + await expect(preview).toContainText("github.com/acme/analyst-archetype@v0.1.0"); + await expect(page.getByLabel("Ref (optional)")).toHaveValue("v0.1.0"); + await expect(preview).toContainText("What it installs"); + await expect(preview.locator(".archetype-preview-member")).toHaveCount(3); + await expect(preview).toContainText("built-in"); + await expect(preview).toContainText("It will ask for: Data folders"); + await expect(preview).toContainText("isn't an official source"); + + // Next waits for the trust ack. + const next = page.getByRole("button", { name: /^Next/ }); + await expect(next).toBeDisabled(); + await preview.getByText("I trust this repository").click(); + await expect(next).toBeEnabled(); + await next.click(); + + const dialog = setupDialog(page); + await expect(dialog).toContainText("Set up Analyst"); + await expect(dialog).toContainText("installs 3 plugins"); + await expect(dialog.getByLabel("Agent name")).toHaveValue("analyst"); + await dialog.getByLabel("Agent name").fill("databot"); + // The bundle's required config_inputs answer hard-gates Create, exactly like a catalog card. + await expect(createButton(page)).toBeDisabled(); + await dialog.getByLabel("Data folders").fill("/Users/me/data"); + await createButton(page).click(); + await expect(page).toHaveURL(/\/app\/agent\/databot-ab12\//); + expect(posted?.bundle).toBe("https://github.com/acme/analyst-archetype"); + expect(posted?.ref).toBe("v0.1.0"); + expect(posted?.requires_tools).toEqual(["data_query", "data_chart"]); + expect(posted?.config_inputs).toEqual({ "data.data_dirs": "/Users/me/data" }); +}); + +test("New agent → From a bundle URL: an unreadable repo shows the server's reason", async ({ page }) => { + await openAgents(page); + await page.getByRole("button", { name: "New agent" }).click(); + await page.getByRole("tab", { name: "From a bundle URL" }).click(); + await page.getByLabel("Repository URL").fill("https://github.com/acme/missing-archetype"); + await page.getByRole("button", { name: "Look up" }).click(); + await expect(page.getByRole("alert")).toContainText("repository not found"); + await expect(page.getByRole("button", { name: /^Next/ })).toBeDisabled(); +}); + +test("New agent: held archetypes stay hidden until 'Show preview archetypes', then carry a Preview badge", async ({ page }) => { + await openAgents(page); + await page.getByRole("button", { name: "New agent" }).click(); + await expect(page.locator(".pl-radiocard", { hasText: "Analyst" })).toHaveCount(0); + await page.getByRole("button", { name: /^Advanced \(1\)/ }).click(); + await expect(page.locator(".pl-radiocard", { hasText: "Analyst" })).toHaveCount(0); + + await page.getByText("Show preview archetypes").click(); + const held = page.locator(".pl-radiocard", { hasText: "Analyst" }); + await expect(held).toBeVisible(); + await expect(held.locator(".pl-badge")).toHaveText("Preview"); + + // Persisted per console: a reload keeps the opt-in. + await page.reload({ waitUntil: "load" }); + await openFleet(page); + await page.getByRole("button", { name: "New agent" }).click(); + await page.getByRole("button", { name: /^Advanced \(2\)/ }).click(); + await expect(page.locator(".pl-radiocard", { hasText: "Analyst" })).toBeVisible(); +}); + // ── The set-up step's hard gate (#2977/#2979/#2984) ──────────────────────────────── // A required bundle `config_inputs` answer has no env fallback — the server refuses the // create — so Create must not be offered until it's answered. The Project Manager diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index d35e1cf9a..ce02acd7f 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -19,6 +19,8 @@ import { ACTIVITY_HISTORY, ARCHETYPES, ARCHETYPE_PREVIEWS, + archetypeFromUrl, + HELD_ARCHETYPES, ARTIFACT_STORE, buildFrames, buildWatches, @@ -690,7 +692,10 @@ function handleApiGet( { name: "remy", url: "http://192.168.5.50:7871", host: "192.168.5.50", port: 7871 }, ] }; case "/api/archetypes": - return { archetypes: ARCHETYPES }; + // Held (preview) archetypes ride only the explicit opt-in, before Custom (kept last). + return params.get("include_held") === "1" + ? { archetypes: [...ARCHETYPES.slice(0, -1), ...HELD_ARCHETYPES, ARCHETYPES[ARCHETYPES.length - 1]] } + : { archetypes: ARCHETYPES }; case "/api/activity": return ACTIVITY_HISTORY; case "/api/inbox": @@ -1593,6 +1598,17 @@ const server = createServer(async (req, res) => { // Archetype bundle peek (#2041) — the enriched preview (mcp + secrets) the // preview dialog and the new-agent Configure step both read. Unknown/code-free // ids fall back to bundle:null. + // "From a bundle URL" peek — same validation shape as the server: 400 for a non-git + // URL, 502 for a repo that can't be read (`…/missing` in the path). + if (pathname === "/api/archetypes/from-url") { + const u = (url.searchParams.get("url") || "").trim(); + const ref = (url.searchParams.get("ref") || "").trim(); + if (!/^(?:https:\/\/[A-Za-z0-9.-]+(?::\d+)?\/|git@[A-Za-z0-9.-]+:)[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?$/.test(u)) { + return sendJson(res, { detail: `not a git repository URL: '${u}'` }, 400); + } + if (u.includes("/missing")) return sendJson(res, { detail: `could not read bundle ${u}: repository not found` }, 502); + return sendJson(res, archetypeFromUrl(u, ref)); + } const m = pathname.match(/^\/api\/archetypes\/([^/]+)\/preview$/); if (m) { const id = decodeURIComponent(m[1]); diff --git a/apps/web/e2e/new-agent.mobile.spec.ts b/apps/web/e2e/new-agent.mobile.spec.ts index ce13a9f59..793435f9f 100644 --- a/apps/web/e2e/new-agent.mobile.spec.ts +++ b/apps/web/e2e/new-agent.mobile.spec.ts @@ -60,3 +60,21 @@ test("mobile: Setup Wizard — pick step, then the set-up step with the folder p await expect(wizard.getByRole("button", { name: /Browse/ })).toBeVisible(); await noSidewaysScroll(page); }); + +test("mobile: From a bundle URL — the form and the bundle preview fit the phone", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("header-menu").click(); + await page.getByTestId("app-drawer").getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("combobox", { name: "Settings sections" }).selectOption("Fleet"); + await page.getByRole("button", { name: "New agent" }).click(); + await page.getByRole("tab", { name: "From a bundle URL" }).tap(); + await page.getByLabel("Repository URL").fill("https://github.com/acme/analyst-archetype"); + await page.getByLabel("Ref (optional)").fill("v0.1.0"); + await page.getByRole("button", { name: "Look up" }).tap(); + await expect(page.locator(".bundle-url-preview")).toContainText("What it installs"); + await noSidewaysScroll(page); + await page.getByText("I trust this repository").tap(); + await page.getByRole("button", { name: /^Next/ }).tap(); + await expect(page.locator(".archetype-setup-dialog").getByLabel("Agent name")).toHaveValue("analyst"); + await noSidewaysScroll(page); +}); diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index af2a590f7..a4b4d385f 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -1532,6 +1532,19 @@ textarea { gap: var(--pl-space-2_5); margin-top: var(--pl-space-1); } +/* The opt-in "Show preview archetypes" switch at the top of the expanded Advanced section, + with its one-line caveat below; held cards then carry a DS "Preview" Badge in the title. */ +.archetype-preview-toggle { + display: flex; + flex-direction: column; + gap: var(--pl-space-1); +} +.archetype-card-title { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--pl-space-1_5); +} /* Topbar fleet switcher (ADR 0042) — the agent name as a DS Menu trigger (#1078). The dropdown itself is the DS Menu (.pl-menu, portaled to ), so the old diff --git a/apps/web/src/lib/api/fleet.ts b/apps/web/src/lib/api/fleet.ts index 106baf9ab..71cb46aba 100644 --- a/apps/web/src/lib/api/fleet.ts +++ b/apps/web/src/lib/api/fleet.ts @@ -9,6 +9,7 @@ */ import type { Archetype, + ArchetypeFromUrl, ArchetypePreview, DiagnosticsLogs, DiagnosticsTask, @@ -43,15 +44,26 @@ export const fleetApi = { discoverAgents() { return request<{ discovered: DiscoveredAgent[] }>("/api/fleet/discover"); }, - archetypes() { - return request<{ archetypes: Archetype[] }>("/api/archetypes"); + // `includeHeld` asks for the catalog's held (preview) archetypes too — only the New-agent + // picker's opt-in sends it; they're never part of the default list. + archetypes(includeHeld = false) { + return request<{ archetypes: Archetype[] }>(includeHeld ? "/api/archetypes?include_held=1" : "/api/archetypes"); }, archetypePreview(id: string) { return request(`/api/archetypes/${encodeURIComponent(id)}/preview`); }, + /** Peek an uncatalogued bundle by git URL (+ optional ref) — read-only, nothing installs. + * 400 = not a git URL / bad ref; 502 = the repo couldn't be read. */ + archetypeFromUrl(url: string, ref?: string) { + const q = new URLSearchParams({ url }); + if (ref) q.set("ref", ref); + return request(`/api/archetypes/from-url?${q.toString()}`); + }, createAgent(body: { name: string; bundle?: string | null; + // Tag / branch / SHA to install `bundle` at ("From a bundle URL"); omitted = default branch. + ref?: string; soul?: string; port?: number; start?: boolean; diff --git a/apps/web/src/lib/archetypeFlow.test.ts b/apps/web/src/lib/archetypeFlow.test.ts index 2bc45b558..141571efb 100644 --- a/apps/web/src/lib/archetypeFlow.test.ts +++ b/apps/web/src/lib/archetypeFlow.test.ts @@ -177,6 +177,14 @@ describe("createAgentBody — the Create payload", () => { expect(body.soul).toBe("# Engineer"); }); + it("a 'From a bundle URL' archetype pins its ref; catalog rows send none", () => { + const s = run([pickEngineer]); + expect(createAgentBody(s, { ...ENGINEER, ref: "v0.1.0" }, fields).ref).toBe("v0.1.0"); + expect(createAgentBody(s, ENGINEER, fields).ref).toBeUndefined(); + // A ref without a bundle is meaningless — never sent. + expect(createAgentBody(s, { ...BASIC, ref: "v1" }, []).ref).toBeUndefined(); + }); + it("Basic: no bundle, no soul, no contract", () => { const s = run([{ type: "pick", id: "basic", suggestedName: "agent", soul: "" }]); expect(createAgentBody(s, BASIC, [])).toEqual({ diff --git a/apps/web/src/lib/archetypeFlow.ts b/apps/web/src/lib/archetypeFlow.ts index aa2863fab..e656426f5 100644 --- a/apps/web/src/lib/archetypeFlow.ts +++ b/apps/web/src/lib/archetypeFlow.ts @@ -111,6 +111,8 @@ export function createAgentBody( return { name: state.name.trim(), bundle: archetype?.bundle ?? null, + // A "From a bundle URL" archetype pins the ref the operator gave; catalog rows have none. + ref: archetype?.bundle && archetype.ref ? archetype.ref : undefined, soul: soul || undefined, inputs: Object.keys(inputs).length ? inputs : undefined, secrets: secrets.length ? secrets : undefined, diff --git a/apps/web/src/lib/bundleUrl.test.ts b/apps/web/src/lib/bundleUrl.test.ts new file mode 100644 index 000000000..1a9a85c92 --- /dev/null +++ b/apps/web/src/lib/bundleUrl.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { isValidBundleRef, parseBundleUrl } from "./bundleUrl"; + +// The New-agent "From a bundle URL" input check — mirrors the server's rules +// (operator_api/fleet_routes.py `_BUNDLE_URL_RE` + the installer's ref check) so a typo +// never costs a git fetch. The server re-validates; these are the shapes an operator pastes. + +describe("parseBundleUrl", () => { + it.each([ + ["https://github.com/protoLabsAI/analyst-archetype"], + ["https://github.com/protoLabsAI/analyst-archetype.git"], + ["https://github.com/protoLabsAI/analyst-archetype/"], + ["https://gitlab.example.com:8443/group/sub/repo"], + ["git@github.com:protoLabsAI/analyst-archetype.git"], + ])("accepts %s", (url) => { + expect(parseBundleUrl(` ${url} `)).toEqual({ ok: true, url }); + }); + + it.each([ + ["", "Paste the bundle's git URL."], + ["http://github.com/a/b", "https://"], + ["github.com/a/b", "isn't a git repository URL"], + ["https://github.com/onlyowner", "isn't a git repository URL"], + ["https://github.com/a/../b", "isn't a git repository URL"], + ["https://github.com/a/b?tab=readme", "isn't a git repository URL"], + ["file:///etc/passwd", "isn't a git repository URL"], + ["/Users/me/bundle", "isn't a git repository URL"], + ["--upload-pack=x", "isn't a git repository URL"], + ])("rejects %j", (url, msg) => { + const r = parseBundleUrl(url); + expect(r.ok).toBe(false); + expect(!r.ok && r.error).toContain(msg); + }); + + it("carries an explicit ref", () => { + expect(parseBundleUrl("https://github.com/a/b", " v0.1.0 ")).toEqual({ ok: true, url: "https://github.com/a/b", ref: "v0.1.0" }); + }); + + it.each([ + ["https://github.com/a/b/tree/v0.1.0", "v0.1.0"], + ["https://github.com/a/b/tree/feature/x", "feature/x"], + ["https://github.com/a/b/releases/tag/v2.0.0", "v2.0.0"], + ["https://github.com/a/b/commit/0123abc", "0123abc"], + ])("folds a GitHub page URL %s to repo + ref", (url, ref) => { + expect(parseBundleUrl(url)).toEqual({ ok: true, url: "https://github.com/a/b", ref }); + }); + + it("an explicit ref wins over one read from the page URL", () => { + expect(parseBundleUrl("https://github.com/a/b/tree/main", "v1")).toEqual({ ok: true, url: "https://github.com/a/b", ref: "v1" }); + }); + + it("rejects a ref that could reach git as an option or escape the path", () => { + for (const ref of ["-x", "a..b", "v1 two", "../x"]) { + expect(parseBundleUrl("https://github.com/a/b", ref).ok).toBe(false); + expect(isValidBundleRef(ref)).toBe(false); + } + }); +}); diff --git a/apps/web/src/lib/bundleUrl.ts b/apps/web/src/lib/bundleUrl.ts new file mode 100644 index 000000000..61daa54ae --- /dev/null +++ b/apps/web/src/lib/bundleUrl.ts @@ -0,0 +1,46 @@ +// The New-agent "From a bundle URL" source: turn what the operator pasted into the +// `{url, ref}` pair GET /api/archetypes/from-url + POST /api/fleet take — or a readable +// reason it isn't one. The server re-validates with the same rules (operator_api/ +// fleet_routes.py `_BUNDLE_URL_RE` + the installer's ref check); this copy only exists so a +// typo is caught before a git fetch. +// +// Accepted: an https git-host repo (`https://github.com/owner/repo`, optional `.git` / +// trailing slash, nested groups for GitLab-style hosts) or the scp-style SSH form +// (`git@github.com:owner/repo.git`). A GitHub page URL pointing INTO a repo at a ref +// (`…/tree/v0.1.0`, `…/releases/tag/v0.1.0`) is folded to the repo + that ref — it's what a +// browser address bar hands you. An explicit ref field wins over one read from the URL. + +export type ParsedBundleUrl = { ok: true; url: string; ref?: string } | { ok: false; error: string }; + +const BUNDLE_URL_RE = /^(?:https:\/\/[A-Za-z0-9.-]+(?::\d+)?\/|git@[A-Za-z0-9.-]+:)[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?$/; +// The installer's `_REF_RE` (graph/plugins/installer.py) plus its `..` refusal. +const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +// `https://github.com///tree/` · `…/releases/tag/` · `…/commit/`. +const GITHUB_REF_PAGE_RE = /^(https:\/\/github\.com\/[^/]+\/[^/]+?)\/(?:tree|releases\/tag|commit)\/(.+?)\/?$/; + +export function isValidBundleRef(ref: string): boolean { + return REF_RE.test(ref) && !ref.includes(".."); +} + +export function parseBundleUrl(rawUrl: string, rawRef = ""): ParsedBundleUrl { + let url = rawUrl.trim(); + let ref = rawRef.trim(); + if (!url) return { ok: false, error: "Paste the bundle's git URL." }; + if (/^http:\/\//i.test(url)) return { ok: false, error: "Use an https:// URL — plain http isn't accepted." }; + const page = GITHUB_REF_PAGE_RE.exec(url); + if (page) { + url = page[1]; + if (!ref) ref = decodeURIComponent(page[2]); + } + const segments = url.split(/[/:]/); + if (!BUNDLE_URL_RE.test(url) || segments.some((s) => s === "." || s === "..")) { + return { + ok: false, + error: "That isn't a git repository URL — use https://github.com// (or git@host:owner/repo.git).", + }; + } + if (ref && !isValidBundleRef(ref)) { + return { ok: false, error: "That ref isn't valid — use a tag, branch or commit SHA (e.g. v0.1.0)." }; + } + return ref ? { ok: true, url, ref } : { ok: true, url }; +} diff --git a/apps/web/src/lib/previewArchetypesPref.test.ts b/apps/web/src/lib/previewArchetypesPref.test.ts new file mode 100644 index 000000000..c6cc549c9 --- /dev/null +++ b/apps/web/src/lib/previewArchetypesPref.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PREVIEW_ARCHETYPES_KEY, getShowPreviewArchetypes, setShowPreviewArchetypes } from "./previewArchetypesPref"; + +// "Show preview archetypes" — per console, OFF unless the operator turned it on, and the +// console must keep working when storage throws (private mode / blocked site data). + +afterEach(() => { + vi.restoreAllMocks(); + try { + localStorage.removeItem(PREVIEW_ARCHETYPES_KEY); + } catch { + /* ignore */ + } +}); + +describe("previewArchetypesPref", () => { + it("is off by default", () => { + setShowPreviewArchetypes(false); // reset the module's in-memory copy + localStorage.removeItem(PREVIEW_ARCHETYPES_KEY); + expect(getShowPreviewArchetypes()).toBe(false); + }); + + it("persists the opt-in under its own key", () => { + setShowPreviewArchetypes(true); + expect(localStorage.getItem(PREVIEW_ARCHETYPES_KEY)).toBe("1"); + expect(getShowPreviewArchetypes()).toBe(true); + setShowPreviewArchetypes(false); + expect(localStorage.getItem(PREVIEW_ARCHETYPES_KEY)).toBe("0"); + expect(getShowPreviewArchetypes()).toBe(false); + }); + + it("survives storage that throws — the choice holds in memory for the session", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + expect(() => setShowPreviewArchetypes(true)).not.toThrow(); + expect(getShowPreviewArchetypes()).toBe(true); + setShowPreviewArchetypes(false); + expect(getShowPreviewArchetypes()).toBe(false); + }); +}); diff --git a/apps/web/src/lib/previewArchetypesPref.ts b/apps/web/src/lib/previewArchetypesPref.ts new file mode 100644 index 000000000..45c8f9f6e --- /dev/null +++ b/apps/web/src/lib/previewArchetypesPref.ts @@ -0,0 +1,61 @@ +// Settings ▸ New agent ▸ Advanced ▸ "Show preview archetypes" — whether the picker also lists +// the catalog's HELD archetypes (still being tested), badged "Preview". Off unless the operator +// turns it on, and never shown by default. +// +// Per-CONSOLE, like the editor pref (lib/editorPref): one un-suffixed localStorage key, every +// access wrapped — storage can throw (private mode, blocked site data) and the picker must +// render anyway, falling back to OFF. An in-memory copy keeps the choice for this page's +// lifetime when storage is unavailable. + +import { useSyncExternalStore } from "react"; + +export const PREVIEW_ARCHETYPES_KEY = "protoagent.newAgent.showPreviewArchetypes"; + +const listeners = new Set<() => void>(); +let memory: boolean | null = null; + +export function getShowPreviewArchetypes(): boolean { + try { + const raw = globalThis.localStorage?.getItem(PREVIEW_ARCHETYPES_KEY); + if (raw === "1") return true; + if (raw === "0") return false; + } catch { + /* storage unavailable — fall through */ + } + return memory ?? false; +} + +export function setShowPreviewArchetypes(on: boolean): void { + memory = on; + try { + globalThis.localStorage?.setItem(PREVIEW_ARCHETYPES_KEY, on ? "1" : "0"); + } catch { + /* storage unavailable — the in-memory value still applies this session */ + } + listeners.forEach((l) => l()); +} + +function subscribe(cb: () => void): () => void { + listeners.add(cb); + // Another window flipping it (the desktop app can hold several). + const onStorage = (e: StorageEvent) => { + if (e.key === PREVIEW_ARCHETYPES_KEY) cb(); + }; + try { + globalThis.addEventListener?.("storage", onStorage); + } catch { + /* no window (SSR/test) */ + } + return () => { + listeners.delete(cb); + try { + globalThis.removeEventListener?.("storage", onStorage); + } catch { + /* ignore */ + } + }; +} + +export function useShowPreviewArchetypes(): boolean { + return useSyncExternalStore(subscribe, getShowPreviewArchetypes, getShowPreviewArchetypes); +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index f46853e80..c203c3c2f 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -183,10 +183,12 @@ export const publishedLinksQuery = () => }); // Archetypes for the new-agent picker (Basic + installed bundles) — config, not live. -export const archetypesQuery = () => +// `includeHeld` (the picker's "Show preview archetypes" opt-in) adds the catalog's held +// entries under its own key, so the default list is never polluted by the preview one. +export const archetypesQuery = (includeHeld = false) => queryOptions({ - queryKey: queryKeys.archetypes, - queryFn: () => api.archetypes(), + queryKey: includeHeld ? [...queryKeys.archetypes, "held"] : queryKeys.archetypes, + queryFn: () => api.archetypes(includeHeld), }); // Goals the agent works toward (goal mode). Lives in the right sidebar; the panel diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 86be7743b..72e47340a 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1981,6 +1981,22 @@ export type Archetype = { // create so the member persists them to workspace.yaml and warns at boot when its // bound toolset doesn't cover the contract. Optional: absent on older hosts. requires_tools?: string[]; + // A catalog `held` entry — an archetype still being tested. Only served on the opt-in + // `GET /api/archetypes?include_held=1` (Settings ▸ New agent ▸ Advanced ▸ "Show preview + // archetypes"); the picker badges it "Preview". + held?: boolean; + // The tag / branch / SHA a "From a bundle URL" archetype is pinned to — rides the create + // as `ref`. Absent on catalog rows (they install the bundle's default branch). + ref?: string; +}; + +// GET /api/archetypes/from-url — an uncatalogued bundle peeked by URL: its archetype row +// (shaped like an /api/archetypes entry), the same bundle peek /preview serves, and whether +// the source is official/acked (`trusted`, ADR 0071 D3) with its normalized display form. +export type ArchetypeFromUrl = ArchetypePreview & { + archetype: Archetype; + trusted: boolean; + source: string; }; // What an archetype's bundle would set up — the read-only pre-install peek diff --git a/apps/web/src/plugins/TrustAckDialog.tsx b/apps/web/src/plugins/TrustAckDialog.tsx index d902edfbd..478bca59e 100644 --- a/apps/web/src/plugins/TrustAckDialog.tsx +++ b/apps/web/src/plugins/TrustAckDialog.tsx @@ -69,9 +69,7 @@ export function TrustAckDialog({ >

- {source} isn't an official source. Installing enables and runs - its code immediately with the agent's full privileges — there is no sandbox. - Only continue if you trust this repository; for untrusted code, use an MCP server instead. +

Confirming remembers this repository, so you won't be asked for it again.

); } + +// The "this runs code" sentence itself — one copy, shared by this dialog and the New-agent +// "From a bundle URL" trust step (a bundle installs plugins into the new agent, so it's the +// same consent). `doing` names the act: "Installing" here, "Creating this agent" there. +export function RunsCodeWarning({ source, doing = "Installing" }: { source: string; doing?: string }) { + return ( + <> + {source} isn't an official source. {doing} enables and runs + its code immediately with the agent's full privileges — there is no sandbox. + Only continue if you trust this repository; for untrusted code, use an MCP server instead. + + ); +} diff --git a/apps/web/src/settings/BundleUrlSource.tsx b/apps/web/src/settings/BundleUrlSource.tsx new file mode 100644 index 000000000..bf76c3892 --- /dev/null +++ b/apps/web/src/settings/BundleUrlSource.tsx @@ -0,0 +1,202 @@ +import { Spinner } from "@protolabsai/ui/data"; +import { Checkbox, FormField, Input } from "@protolabsai/ui/forms"; +import { Button, Callout } from "@protolabsai/ui/primitives"; +import { useMutation } from "@tanstack/react-query"; +import { ChevronRight, Search } from "lucide-react"; +import { useState, type FormEvent } from "react"; + +import { api } from "../lib/api"; +import { previewMcpSummary, previewSecretsSummary, requiresToolsNotice } from "../lib/archetypeConfig"; +import { parseBundleUrl } from "../lib/bundleUrl"; +import { errMsg } from "../lib/format"; +import { lucideIcon } from "../lib/lucideIcon"; +import type { ArchetypeFromUrl } from "../lib/types"; +import { RunsCodeWarning } from "../plugins/TrustAckDialog"; +import { MemberCard } from "../setup/ArchetypePreviewDialog"; +import "./bundleUrl.css"; + +// The New-agent panel's third source: an archetype bundle that ISN'T in the catalog, by its +// git URL (+ optional ref). Three beats, in order, and nothing installs until the last: +// 1. URL ENTRY — validated here (lib/bundleUrl) and again server-side. +// 2. PREVIEW + TRUST — GET /api/archetypes/from-url peeks the bundle (read-only): its +// archetype card, its description, and what it installs (each plugin + its ref, the +// built-ins it turns on, what it will ask for). Installing third-party code is a trust +// decision, so a source that isn't official/acked needs an explicit "I trust this +// repository" before Next — the same "this runs code" copy as the plugin install ack. +// 3. SET UP — `onNext` hands the result to the panel, which runs the SAME set-up dialog +// the catalog cards use and creates with `bundle` + `ref`. +// Editing the URL or ref after a lookup drops the preview, so Next always means the bundle +// that was actually shown. +export function BundleUrlSource({ onNext }: { onNext: (found: ArchetypeFromUrl) => void }) { + const [url, setUrl] = useState(""); + const [ref, setRef] = useState(""); + const [inputError, setInputError] = useState(null); + const [trustAck, setTrustAck] = useState(false); + const lookup = useMutation({ + mutationFn: (q: { url: string; ref?: string }) => api.archetypeFromUrl(q.url, q.ref), + onMutate: () => setTrustAck(false), + }); + const found = lookup.data; + + function edit(next: { url?: string; ref?: string }) { + if (next.url !== undefined) setUrl(next.url); + if (next.ref !== undefined) setRef(next.ref); + setInputError(null); + if (lookup.data || lookup.error) lookup.reset(); + } + + function submit(e: FormEvent) { + e.preventDefault(); + const parsed = parseBundleUrl(url, ref); + if (!parsed.ok) { + setInputError(parsed.error); + return; + } + // Show the operator what will actually be fetched (a /tree/ page URL folds). + setUrl(parsed.url); + setRef(parsed.ref ?? ""); + lookup.mutate({ url: parsed.url, ref: parsed.ref }); + } + + const canNext = Boolean(found) && (found?.trusted || trustAck); + + return ( +
+

Bundle URL

+

+ Create an agent from an archetype bundle that isn't in the list — paste its git repository URL. You'll + see what it installs before anything runs. +

+
+ + edit({ url: e.target.value })} + aria-invalid={inputError ? true : undefined} + /> + + + edit({ ref: e.target.value })} + /> + +
+ +
+
+

+ Ref is a tag, branch or commit SHA — blank uses the default branch. A GitHub /tree/<ref> link + works too. +

+ {inputError ? ( +

+ {inputError} +

+ ) : null} + {lookup.isError ? ( +

+ Couldn't read that bundle — {errMsg(lookup.error)} +

+ ) : null} + + {found ? : null} + +
+ +
+
+ ); +} + +// Step 2: what this bundle is and what creating from it installs — then the trust decision. +function BundleFoundPreview({ + found, + trustAck, + onTrustAck, +}: { + found: ArchetypeFromUrl; + trustAck: boolean; + onTrustAck: (on: boolean) => void; +}) { + const a = found.archetype; + const bundle = found.bundle; + const members = bundle?.members ?? []; + const configLabels = (bundle?.config_inputs ?? []).map((c) => c.label || c.key); + const contract = requiresToolsNotice(a.label, a.requires_tools); + return ( +
+
+ + {lucideIcon(a.icon, 22)} + +
+ {a.label} +
+ + {found.source} + {a.ref ? `@${a.ref}` : ""} + + {a.ref ? null : " · default branch"} +
+
+
+ {a.blurb ?

{a.blurb}

: null} + {bundle?.description && bundle.description !== a.blurb ? ( +

{bundle.description}

+ ) : null} + +

What it installs

+ {members.length ? ( +
+ {members.map((m, i) => ( + + ))} +
+ ) : ( +

No plugins — a persona-only bundle.

+ )} + {configLabels.length ? ( +

It will ask for: {configLabels.join(", ")}

+ ) : null} + {bundle?.mcp?.length ? ( +

MCP servers: {previewMcpSummary(bundle.mcp)}

+ ) : null} + {bundle?.secrets?.length ? ( +

Secrets: {previewSecretsSummary(bundle.secrets)}

+ ) : null} + {contract ? ( +

+ {contract} +

+ ) : null} + + {found.trusted ? ( + + Creating this agent installs the plugins above into it and turns them on. + + ) : ( + +

+ +

+ onTrustAck(Boolean(v))} label="I trust this repository" /> +
+ )} +
+ ); +} diff --git a/apps/web/src/settings/NewAgentPanel.tsx b/apps/web/src/settings/NewAgentPanel.tsx index 9d6c3b643..da7f376ed 100644 --- a/apps/web/src/settings/NewAgentPanel.tsx +++ b/apps/web/src/settings/NewAgentPanel.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; @@ -6,6 +6,7 @@ import { Button } from "@protolabsai/ui/primitives"; import { PanelHeader } from "@protolabsai/ui/navigation"; import { Dialog, useToast } from "@protolabsai/ui/overlays"; +import { BundleUrlSource } from "./BundleUrlSource"; import { ImportSnapshotPanel } from "./ImportSnapshotPanel"; import { api } from "../lib/api"; import { ArchetypePicker } from "../setup/ArchetypePicker"; @@ -23,7 +24,8 @@ import { import { errMsg } from "../lib/format"; import { escapeCloseAllowed, isTopmostOverlay } from "../lib/overlayStack"; import { HARD_GATE_HINT } from "../lib/pickerCopy"; -import type { Archetype } from "../lib/types"; +import { setShowPreviewArchetypes, useShowPreviewArchetypes } from "../lib/previewArchetypesPref"; +import type { Archetype, ArchetypeFromUrl } from "../lib/types"; // Onboarding / archetype picker (ADR 0042), in two steps (lib/archetypeFlow): // 1. PICK — the archetype cards only (ArchetypePicker): label, icon, blurb, "What's @@ -36,11 +38,17 @@ import type { Archetype } from "../lib/types"; // Creating from a bundle clones+installs it (a few seconds) — the POST returns once the // agent is up, so Create shows a spinner until then. // -// A new agent has TWO sources (ADR 0091 #2106): an archetype (below) or a SNAPSHOT of an -// existing agent. They share this one entry point rather than living in separate places, -// because "where do new agents come from" should be one question with two answers. The -// snapshot path is its own component: it has to show a plan and take consent before it can -// create anything, which is a different shape from picking a card. +// A new agent has THREE sources: an archetype card (below), a bundle URL that isn't in the +// catalog (BundleUrlSource), or a SNAPSHOT of an existing agent (ADR 0091 #2106). They share +// this one entry point rather than living in separate places, because "where do new agents +// come from" should be one question. The URL source ends in the SAME set-up dialog as a card +// (its own flow state, so switching tabs never mixes answers); it has to show the bundle and +// take a trust decision first. The snapshot path has to show a plan and take consent before it +// can create anything, which is a different shape again. +// +// Held catalog archetypes (still being tested) are opt-in: Advanced ▸ "Show preview +// archetypes" (persisted per console, lib/previewArchetypesPref) refetches the list with +// `?include_held=1` and the picker badges them "Preview". Never shown by default. export function NewAgentPanel({ onDone, onCancel, @@ -53,11 +61,20 @@ export function NewAgentPanel({ }) { const qc = useQueryClient(); const toast = useToast(); - const archetypes = useQuery(archetypesQuery()); - const [flow, dispatch] = useReducer(archetypeFlowReducer, undefined, () => initialArchetypeFlow("basic")); + const showPreview = useShowPreviewArchetypes(); + // keepPreviousData: flipping the preview switch swaps query keys — keep the cards on screen + // while the other list loads instead of flashing an empty picker. + const archetypes = useQuery({ ...archetypesQuery(showPreview), placeholderData: keepPreviousData }); + const [catalogFlow, catalogDispatch] = useReducer(archetypeFlowReducer, undefined, () => initialArchetypeFlow("basic")); + // The "From a bundle URL" source runs the same two-step flow on its own state. + const [urlFlow, urlDispatch] = useReducer(archetypeFlowReducer, undefined, () => initialArchetypeFlow("")); + const [fromUrl, setFromUrl] = useState(null); // Which source this new agent comes from. Archetype is the default because it's the // common case; importing is deliberate and usually starts from a file you already have. - const [source, setSource] = useState<"archetype" | "snapshot">("archetype"); + const [source, setSource] = useState<"archetype" | "url" | "snapshot">("archetype"); + const urlSource = source === "url"; + const flow = urlSource ? urlFlow : catalogFlow; + const dispatch = urlSource ? urlDispatch : catalogDispatch; // Names already on the fleet — the suggested name steps around them (engineer-2, …). const fleet = useQuery({ ...fleetQuery(), refetchInterval: false }); const taken = useMemo(() => (fleet.data?.agents ?? []).map((a) => a.name), [fleet.data]); @@ -65,21 +82,25 @@ export function NewAgentPanel({ // "custom" is a wizard-only persona (write-your-own SOUL) — this picker creates an // agent from a bundle, and its persona editor lives under Advanced, so Custom would // just duplicate Basic. - const list = (archetypes.data?.archetypes ?? []).filter((a) => a.id !== "custom"); - const pickedArchetype = list.find((a) => a.id === flow.picked); - const archetype = pickedArchetype ?? list[0]; + const list = (archetypes.data?.archetypes ?? []).filter((a) => a.id !== "custom" && (showPreview || !a.held)); + const pickedCatalog = list.find((a) => a.id === catalogFlow.picked); + const urlArchetype = fromUrl?.archetype; + const pickedArchetype = urlSource ? urlArchetype : pickedCatalog; + const archetype = urlSource ? urlArchetype : (pickedCatalog ?? list[0]); // The picked archetype's read-only peek — the source of the set-up form's fields (its // bundle's config_inputs, MCP inputs and declared secrets). Shares the preview dialog's // cache key; only fetched for bundle-backed archetypes (Basic has no bundle → no fields). + // The URL source already HAS its peek (the lookup returned it) — no second fetch. const preview = useQuery({ - queryKey: ["archetype-preview", flow.picked], - queryFn: () => api.archetypePreview(flow.picked), - enabled: Boolean(pickedArchetype?.bundle), + queryKey: ["archetype-preview", catalogFlow.picked], + queryFn: () => api.archetypePreview(catalogFlow.picked), + enabled: !urlSource && Boolean(pickedCatalog?.bundle), staleTime: 10 * 60 * 1000, retry: 1, }); - const fields = useMemo(() => archetypeConfigFields(preview.data), [preview.data]); + const peekData = urlSource ? (fromUrl ?? undefined) : preview.data; + const fields = useMemo(() => archetypeConfigFields(peekData), [peekData]); // Runtime requirement at CHOOSE-time (#2186 follow-on): an archetype declaring // `requires: [python_runtime]` (cowork — its document skills route through @@ -90,6 +111,7 @@ export function NewAgentPanel({ // `stale` (provisioned, old doc baseline) still works — no warning for it here. const pyRuntime = pythonRuntimeView(useQuery(pythonRuntimeQuery()).data); const runtimeWarning = + !urlSource && pickedArchetype?.requires?.includes("python_runtime") && pyRuntime.kind === "action" && !pyRuntime.stale ? pyRuntime.installing ? `Python runtime is installing — ${pickedArchetype.label}'s document skills will work when it finishes.` @@ -106,12 +128,20 @@ export function NewAgentPanel({ flow.name.trim() && !nameOk ? "Use only letters, numbers, dashes and underscores." : null; function pick(a: Archetype) { - dispatch({ type: "pick", id: a.id, suggestedName: suggestedAgentName(a, taken), soul: a.soul ?? "" }); + catalogDispatch({ type: "pick", id: a.id, suggestedName: suggestedAgentName(a, taken), soul: a.soul ?? "" }); } function next() { if (!archetype) return; pick(archetype); // same card → only fills a still-empty name/persona - dispatch({ type: "next" }); + catalogDispatch({ type: "next" }); + } + // The URL source's Next: the looked-up bundle becomes the picked archetype of its own flow + // (a different bundle clears the answers, the same one keeps them — the reducer's rule). + function nextFromUrl(found: ArchetypeFromUrl) { + const a = found.archetype; + setFromUrl(found); + urlDispatch({ type: "pick", id: `${a.bundle ?? a.id}@${a.ref ?? ""}`, suggestedName: suggestedAgentName(a, taken), soul: a.soul ?? "" }); + urlDispatch({ type: "next" }); } const create = useMutation({ @@ -136,13 +166,13 @@ export function NewAgentPanel({ // "Landed" means DATA, not "not loading": a failed peek is not loading either, and it // knows the questions no better — it holds too, with an inline Retry. const bundlePicked = Boolean(pickedArchetype?.bundle); - const peekLoading = bundlePicked && preview.isLoading; - const peekMissing = bundlePicked && !preview.data; + const peekLoading = !urlSource && bundlePicked && preview.isLoading; + const peekMissing = bundlePicked && !peekData; const canCreate = nameOk && !missingHard && !peekMissing && !create.isPending; const submit = () => { if (canCreate) create.mutate(); }; - const setupOpen = source === "archetype" && flow.step === "setup" && Boolean(archetype); + const setupOpen = source !== "snapshot" && flow.step === "setup" && Boolean(archetype); // Esc / backdrop on the set-up dialog = Back. The DS Dialog closes on EVERY open // dialog's Escape, so an Escape aimed at a layer above this one (the folder picker's @@ -163,13 +193,13 @@ export function NewAgentPanel({ const notOurs = escapeNotOurs.current; escapeNotOurs.current = false; if (!notOurs) dispatch({ type: "back" }); - }, []); + }, [dispatch]); return (
@@ -189,6 +219,15 @@ export function NewAgentPanel({ > From an archetype + - {advancedOpen ? ( + {advancedOpen && previewArchetypes ? ( +
+ + + Archetypes still being tested — they may change or break. Remembered on this console. + +
+ ) : null} + {advancedOpen && advanced.length ? ( {cards(advanced)} diff --git a/apps/web/src/setup/ArchetypePreviewDialog.tsx b/apps/web/src/setup/ArchetypePreviewDialog.tsx index 067fe4937..50314c3e7 100644 --- a/apps/web/src/setup/ArchetypePreviewDialog.tsx +++ b/apps/web/src/setup/ArchetypePreviewDialog.tsx @@ -8,9 +8,10 @@ import type { Archetype, ArchetypePreviewMember } from "../lib/types"; // "What's included" — the read-only pre-pick preview of an archetype: the full // base SOUL plus, for bundle-backed archetypes, the bundle's members with each // one's skills/pip-deps/capabilities (GET /api/archetypes/{id}/preview — a peek, -// nothing installs). Shared by the setup wizard and the fleet new-agent panel. +// nothing installs). Shared by the setup wizard and the fleet new-agent panel; MemberCard is +// reused by the new-agent "From a bundle URL" step to show what a pasted bundle installs. -function MemberCard({ member }: { member: ArchetypePreviewMember }) { +export function MemberCard({ member }: { member: ArchetypePreviewMember }) { if (member.error) { return (
diff --git a/changelog.d/4030.added.md b/changelog.d/4030.added.md new file mode 100644 index 000000000..030fb602a --- /dev/null +++ b/changelog.d/4030.added.md @@ -0,0 +1,10 @@ +- **New agent can start from any bundle URL, and preview archetypes are opt-in (#4030).** + Settings ▸ New agent has a third source, "From a bundle URL": paste a bundle's git URL + (plus an optional tag, branch or SHA; a GitHub `/tree/` link works too). You then see + what it installs before anything runs: each plugin and its ref, the built-ins it turns on, + and the settings it will ask for. A bundle from a non-official source asks you to confirm + "I trust this repository". The flow then continues into the same set-up dialog as the + catalog cards. `POST /api/fleet` accepts `ref`, and `GET /api/archetypes/from-url` returns + the read-only peek. Archetypes the catalog still holds back for testing now show under + Advanced ▸ "Show preview archetypes", marked with a **Preview** badge. They're off by + default and the setting is remembered per console (`GET /api/archetypes?include_held=1`). diff --git a/docs/reference/operator-api.md b/docs/reference/operator-api.md index 92c2257e4..46639588a 100644 --- a/docs/reference/operator-api.md +++ b/docs/reference/operator-api.md @@ -171,8 +171,9 @@ under every root; a claim removes them all), 120 s TTL, one-shot. | GET | `/api/fleet/discover` | Discover agents (LAN mDNS + tailnet) | | POST · DELETE | `/api/fleet/remotes[/{ident}]` | Register / remove a remote member | | POST | `/api/fleet/remotes/pair` | Pair with a remote by claiming a code minted on it (`{url, code, name?}`); stores the per-device token, adds or re-tokens the member (ADR 0113) | -| GET | `/api/archetypes` | Starter agent types (catalog + installed bundles) | +| GET | `/api/archetypes` | Starter agent types (catalog + installed bundles); `?include_held=1` adds the catalog's `held` preview entries (`held: true`) | | GET | `/api/archetypes/{id}/preview` | Peek a bundle archetype's members/MCP/secrets before install | +| GET | `/api/archetypes/from-url?url=&ref=` | Peek an uncatalogued bundle by git URL (+ optional ref): its archetype row, the bundle peek, and whether the source is `trusted` | ## Plugins & MCP diff --git a/graph/workspaces/manager.py b/graph/workspaces/manager.py index 0179ec1d1..815e3d06e 100644 --- a/graph/workspaces/manager.py +++ b/graph/workspaces/manager.py @@ -460,6 +460,7 @@ def create( from_config: str | None = None, inherit_model: str | None = None, bundle: str | None = None, + bundle_ref: str | None = None, port: int | None = None, shared_skills: bool = False, snapshot_config: Path | None = None, @@ -578,6 +579,10 @@ def create( "created": datetime.now(timezone.utc).isoformat(), "bundle": bundle or "", } + # The pinned ref a bundle was created at ("From a bundle URL", #new-agent) — + # recorded beside the URL so the provenance says WHICH bundle, not just whose. + if bundle and bundle_ref: + rec["bundle_ref"] = bundle_ref # The archetype's capability contract (#2277): the tools its persona commits to # performing. Recorded here because the member's instance root IS this workspace, so # it can read its own contract at boot and check it against what actually got bound — @@ -596,7 +601,7 @@ def create( oauth_warnings: list[str] = [] try: if bundle: - installed = _install_bundle_into(ws, bundle) + installed = _install_bundle_into(ws, bundle, ref=bundle_ref) # Auto-enable the bundle's plugins so a new agent boots WITH its tools live — # matching the console install path (which auto-enables on install, ADR 0027). # The CLI installer deliberately doesn't enable, so without this the agent @@ -1421,10 +1426,11 @@ def _tail(*streams: str | bytes | None, lines: int = 60) -> str: return "\n".join(out[-lines:]) -def _install_bundle_into(ws: Path, bundle: str) -> list[str]: +def _install_bundle_into(ws: Path, bundle: str, *, ref: str | None = None) -> list[str]: """Install a bundle (or plugin) into the workspace via a scoped subprocess — ``PROTOAGENT_HOME=`` makes the workspace the installer's instance root, so - plugins land at ``/plugins`` and the lock at ``/plugins.lock``.""" + plugins land at ``/plugins`` and the lock at ``/plugins.lock``. ``ref`` pins + the bundle to a tag / branch / SHA (the CLI's ``--ref``; default = its default branch).""" env = { **os.environ, "PROTOAGENT_HOME": str(ws), @@ -1437,7 +1443,7 @@ def _install_bundle_into(ws: Path, bundle: str) -> list[str]: # follow-up). No effect on a source/server run, where install never pips (ADR 0027 D4). try: proc = subprocess.run( - [*_server_argv(), "plugin", "install", bundle, "--install-runtime-deps"], + [*_server_argv(), "plugin", "install", bundle, *(["--ref", ref] if ref else []), "--install-runtime-deps"], env=env, capture_output=True, text=True, diff --git a/operator_api/fleet_routes.py b/operator_api/fleet_routes.py index a20680ab5..d98ac7d33 100644 --- a/operator_api/fleet_routes.py +++ b/operator_api/fleet_routes.py @@ -13,6 +13,7 @@ import asyncio import logging +import re from fastapi import Request, WebSocket # module-level so the stringized `request: Request` / # `ws: WebSocket` annotations on the proxy routes resolve @@ -201,7 +202,7 @@ async def _agent_ws_proxy(ws: WebSocket, slug: str, path: str): async def _create_agent(body: dict = Body(...)): """Create an agent (optionally from a bundle archetype) and start it. - Body: ``{name, bundle?: , soul?: str, port?: int, start?: bool=true, + Body: ``{name, bundle?: , ref?: , soul?: str, port?: int, start?: bool=true, shared_skills?: bool, inherit_config?: bool=true, inputs?: {key: value}, secrets?: [{key, value}], config_inputs?: {dotted_key: value}}``. ``soul`` is the archetype's base SOUL.md (persona), written @@ -224,6 +225,19 @@ async def _create_agent(body: dict = Body(...)): """ name = str(body.get("name", "")).strip() bundle = (str(body.get("bundle") or "").strip()) or None + # The tag / branch / SHA to install the bundle at — the new-agent "From a bundle URL" + # source pins one (blank = the default branch). Checked here, before any workspace + # exists, with the installer's own validator; only meaningful with a bundle. + ref = (str(body.get("ref") or "").strip()) or None + if ref and not bundle: + raise HTTPException(400, "`ref` needs a `bundle` to pin") + if ref: + from graph.plugins import installer + + try: + installer._validate_ref(ref) + except installer.InstallError as exc: + raise HTTPException(400, str(exc)) # Operator-supplied bundle-seed values (#2041): `inputs` fill MCP `${input}` placeholders, # `secrets` carry values for the bundle's declared secrets. Coerced to plain str maps/list # here so a malformed field degrades to "not supplied" (env-only fallback) rather than 500. @@ -256,6 +270,7 @@ async def _create_agent(body: dict = Body(...)): out = await fleet_ops.create( name, bundle=bundle, + ref=ref, soul=soul, port=port, start=start, @@ -322,10 +337,40 @@ async def _remove_agent(name: str, purge: bool = False): raise HTTPException(400, str(exc)) @app.get("/api/archetypes") - async def _list_archetypes(): + async def _list_archetypes(include_held: bool = False): """Starter agent types for the new-agent picker: the built-in **Basic** + - every installed bundle's ``archetype:`` metadata.""" - return {"archetypes": _archetypes()} + every installed bundle's ``archetype:`` metadata. ``?include_held=1`` also returns + the catalog's ``held`` entries (archetypes still being tested), each marked + ``held: true`` — only the console's opt-in "Show preview archetypes" asks for them.""" + return {"archetypes": _archetypes(include_held=include_held)} + + @app.get("/api/archetypes/from-url") + async def _archetype_from_url(url: str = "", ref: str = ""): + """An archetype from a bundle's git URL (+ optional ref) that ISN'T in the catalog — + the new-agent picker's "From a bundle URL" source. A read-only peek (nothing + installs): the bundle's ``archetype:`` block shaped like a ``/api/archetypes`` row, + plus the same ``bundle`` peek ``/preview`` serves (members + refs, builtins, + config_inputs) so the console can show what it installs before Create. ``trusted`` + says whether the source is official/acked (ADR 0071 D3) — the console asks for an + explicit "I trust this repository" when it isn't.""" + from graph.plugins import installer + from ops import plugins as plugin_ops + + try: + clean_url = _validate_bundle_url(url) + clean_ref = ref.strip() or None + if clean_ref: + installer._validate_ref(clean_ref) + except (ValueError, installer.InstallError) as exc: + raise HTTPException(400, str(exc)) + try: + peek = await plugin_ops.peek_bundle(clean_url, clean_ref) + except Exception as exc: # noqa: BLE001 — network/git failure → clean 502 + raise HTTPException(502, f"could not read bundle {clean_url}: {exc}") + rec = _peek_archetype_record(clean_url, peek) + if clean_ref: + rec["ref"] = clean_ref + return {"id": rec["id"], "archetype": rec, "bundle": peek, **_source_trust(clean_url)} @app.get("/api/archetypes/{archetype_id}/preview") async def _archetype_preview(archetype_id: str): @@ -334,7 +379,9 @@ async def _archetype_preview(archetype_id: str): pip deps, and capabilities — enumerated WITHOUT installing (read-only peek, TTL-cached). Code-free archetypes return ``bundle: null``; the SOUL text is already in the list payload.""" - record = next((a for a in _archetypes() if a.get("id") == archetype_id), None) + # Held entries resolve too: the picker only shows them when the operator opted in, + # and this is a read-only peek. + record = next((a for a in _archetypes(include_held=True) if a.get("id") == archetype_id), None) if record is None: raise HTTPException(404, f"unknown archetype: {archetype_id}") if not record.get("bundle"): @@ -351,8 +398,6 @@ async def _archetype_preview(archetype_id: str): def _norm_url(u: str | None) -> str: """Canonicalize a git URL for dedupe (drop trailing ``.git`` / ``/``, lowercase) — the same normalization the plugin catalog uses to match install state by URL.""" - import re - return re.sub(r"\.git$", "", (u or "").strip().rstrip("/")).lower() @@ -386,11 +431,11 @@ def _norm_tier(value: object) -> str: ] -def _load_archetype_catalog() -> list[dict]: - """Built-in archetype entries from ``archetype-catalog.json`` — the live config dir - overrides the bundled seed (a fork adds/removes archetypes with NO code change), same - lookup order as the plugin/MCP catalogs. Falls back to Basic + Custom if the file is - absent or malformed, so the new-agent picker + wizard never come up empty-handed.""" +def _read_archetype_catalog_doc() -> dict | None: + """The winning ``archetype-catalog.json`` parsed — the live config dir overrides the + bundled seed (a fork adds/removes archetypes with NO code change), same lookup order as + the plugin/MCP catalogs. None when the file is absent or malformed; the live dir wins + even when broken (never silently falls through to the seed).""" import json from infra.paths import instance_paths @@ -400,16 +445,153 @@ def _load_archetype_catalog() -> list[dict]: f = base / "archetype-catalog.json" if f.exists(): try: - entries = (json.loads(f.read_text(encoding="utf-8")) or {}).get("archetypes") - if isinstance(entries, list) and entries: - return entries + doc = json.loads(f.read_text(encoding="utf-8")) or {} + return doc if isinstance(doc, dict) else None except (json.JSONDecodeError, UnicodeDecodeError, OSError): log.warning("[fleet] archetype-catalog.json unreadable at %s", f) - break # live dir wins even if broken — don't silently fall through to the seed + return None + return None + + +def _load_archetype_catalog() -> list[dict]: + """Built-in archetype entries from ``archetype-catalog.json`` (see + ``_read_archetype_catalog_doc``). Falls back to Basic + Custom if the file is absent or + malformed, so the new-agent picker + wizard never come up empty-handed.""" + entries = (_read_archetype_catalog_doc() or {}).get("archetypes") + if isinstance(entries, list) and entries: + return entries return _FALLBACK_ARCHETYPES -def _archetypes() -> list[dict]: +def _load_held_archetypes() -> list[dict]: + """The catalog's ``held`` entries — archetypes parked out of the picker until they're + tested. Same shape as ``archetypes``; served only on an explicit ``include_held`` ask + (the console's "Show preview archetypes" opt-in), never by default.""" + held = (_read_archetype_catalog_doc() or {}).get("held") + return [e for e in held if isinstance(e, dict)] if isinstance(held, list) else [] + + +def _catalog_record(entry: dict, aid: str) -> dict: + """One catalog (or held) entry shaped as the ``/api/archetypes`` row the console reads.""" + from graph.config_io import read_soul_preset + + soul = entry.get("soul") or (read_soul_preset(str(entry["soul_preset"])) if entry.get("soul_preset") else "") + return { + "id": aid, + "label": entry.get("label", aid), + "icon": entry.get("icon", "Package"), + "bundle": entry.get("bundle") or None, + "blurb": entry.get("blurb", ""), + "soul": soul, + # Picker placement (ADR 0042): "advanced" archetypes collapse under the picker's + # "Advanced (N)" toggle; a missing tag normalizes to "standard" (renders inline). + "tier": _norm_tier(entry.get("tier")), + # Host capabilities this archetype needs to be USEFUL (#2186 follow-on) — + # e.g. "python_runtime": cowork's document skills route through execute_code, + # which on the desktop app needs the managed CPython. The new-agent picker + # warns at choose-time when a requirement isn't provisioned. + "requires": list(entry.get("requires") or []), + # Capability contract (#2277): the tools this archetype's PERSONA commits to + # performing. Recorded on the created workspace so the member can check its own + # doctrine against the tools that actually bound — a preset that says it files + # issues while `github.write` defaults false otherwise narrates the filing. + "requires_tools": list(entry.get("requires_tools") or []), + } + + +def _bundle_archetype_record(bid: str, url: str, arch: dict) -> dict: + """A bundle's ``archetype:`` manifest block shaped as an ``/api/archetypes`` row — shared + by installed-bundle self-registration and the "From a bundle URL" peek.""" + from graph.config_io import read_soul_preset + + # A bundle declares its persona inline (`soul`) or names a host preset + # (`soul_preset`) — the same pair the catalog supports (#2715; before, + # only inline worked here and a preset-naming bundle silently fell back + # to the base persona via the console's personaSoul()). An unknown + # preset name resolves to "" — warn, because the operator sees the + # fallback persona with no other signal. + soul = str(arch.get("soul") or "") + if not soul and arch.get("soul_preset"): + soul = read_soul_preset(str(arch["soul_preset"])) + if not soul: + log.warning( + "[fleet] bundle %s names soul_preset %r — not found on this host; " + "the picker will fall back to the base persona", + bid, + arch["soul_preset"], + ) + return { + "id": bid, + "label": arch.get("label"), + "icon": arch.get("icon", "Package"), + "blurb": arch.get("blurb", ""), + "bundle": url or None, + "soul": soul, + # A bundle can file itself under the picker's "Advanced" toggle too — + # same optional tag as the catalog field, normalized to standard/advanced. + "tier": _norm_tier(arch.get("tier")), + # A bundle's archetype: block can declare host requirements too — + # same shape as the catalog field (#2186 follow-on). + "requires": list(arch.get("requires") or []), + # A bundle's archetype: block declares its capability contract the + # same way (#2277). + "requires_tools": list(arch.get("requires_tools") or []), + } + + +# A bundle URL the "From a bundle URL" source accepts: an https git-host repo +# (`https://github.com/owner/repo`, optional `.git` / trailing slash; nested groups OK for +# GitLab-style hosts) or the scp-style SSH form (`git@github.com:owner/repo.git`). Stricter +# than the installer's scheme check on purpose — this is a pasted URL, not a local path. +_BUNDLE_URL_RE = re.compile( + r"^(?:https://[A-Za-z0-9.-]+(?::\d+)?/|git@[A-Za-z0-9.-]+:)" + r"[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+/?$" +) + + +def _validate_bundle_url(url: str) -> str: + """The trimmed bundle URL, or ``ValueError`` with a readable reason.""" + u = (url or "").strip() + if not u: + raise ValueError("a bundle URL is required") + if not _BUNDLE_URL_RE.match(u) or any(seg in (".", "..") for seg in re.split(r"[/:]", u)): + raise ValueError( + f"not a git repository URL: {u!r} — use https://github.com// (or git@host:owner/repo.git)" + ) + return u + + +def _peek_archetype_record(url: str, peek: dict) -> dict: + """The ``/api/archetypes`` row for a peeked bundle URL: its ``archetype:`` block, with + the label / blurb falling back to the bundle's own name / description (or the repo name) + so a bundle without an archetype block still reads as a card.""" + arch = dict(peek.get("archetype") or {}) + slug = re.sub(r"\.git$", "", url.rstrip("/")).rsplit("/", 1)[-1].rsplit(":", 1)[-1] + members = peek.get("members") or [] + first = members[0] if members and isinstance(members[0], dict) else {} + bid = str(peek.get("id") or first.get("id") or slug) + arch["label"] = arch.get("label") or peek.get("name") or first.get("name") or slug + arch["blurb"] = arch.get("blurb") or peek.get("description") or first.get("description") or "" + return _bundle_archetype_record(bid, url, arch) + + +def _source_trust(url: str) -> dict: + """Whether ``url`` is an official / already-acked plugin source (ADR 0071 D3) — the same + predicate the install route's consent gate uses — plus its normalized display form.""" + from graph.plugins.trust import normalize_source, source_trusted + from runtime.state import STATE + + cfg = STATE.graph_config + trusted = source_trusted( + url, + official=getattr(cfg, "plugins_sources_official", None) if cfg else None, + acked=getattr(cfg, "plugins_sources_acked", None) if cfg else None, + trust_unverified=(getattr(cfg, "plugins_trust_unverified", False) is True) if cfg else False, + ) + return {"trusted": bool(trusted), "source": normalize_source(url)} + + +def _archetypes(*, include_held: bool = False) -> list[dict]: """Starter agent types for the new-agent picker + setup wizard (ADR 0042). Data-driven: the built-in set comes from ``archetype-catalog.json`` (see @@ -419,10 +601,9 @@ def _archetypes() -> list[dict]: names a ``soul_preset`` file under ``config/soul-presets/`` (resolved here) or an inline ``soul``; a bundle declares it inline in its manifest. The whole list is deduped by id + bundle URL (a catalog entry for a stack never doubles up with the same installed bundle), - and ``custom`` is kept LAST. + and ``custom`` is kept LAST. ``include_held`` appends the catalog's ``held`` entries + (``held: true``) before Custom — the picker's opt-in preview archetypes. """ - from graph.config_io import read_soul_preset - out: list[dict] = [] custom: dict | None = None seen_ids: set[str] = set() @@ -432,29 +613,8 @@ def _archetypes() -> list[dict]: aid = str(entry.get("id") or "").strip() if not aid or aid in seen_ids: continue - soul = entry.get("soul") or (read_soul_preset(str(entry["soul_preset"])) if entry.get("soul_preset") else "") - bundle = entry.get("bundle") or None - rec = { - "id": aid, - "label": entry.get("label", aid), - "icon": entry.get("icon", "Package"), - "bundle": bundle, - "blurb": entry.get("blurb", ""), - "soul": soul, - # Picker placement (ADR 0042): "advanced" archetypes collapse under the picker's - # "Advanced (N)" toggle; a missing tag normalizes to "standard" (renders inline). - "tier": _norm_tier(entry.get("tier")), - # Host capabilities this archetype needs to be USEFUL (#2186 follow-on) — - # e.g. "python_runtime": cowork's document skills route through execute_code, - # which on the desktop app needs the managed CPython. The new-agent picker - # warns at choose-time when a requirement isn't provisioned. - "requires": list(entry.get("requires") or []), - # Capability contract (#2277): the tools this archetype's PERSONA commits to - # performing. Recorded on the created workspace so the member can check its own - # doctrine against the tools that actually bound — a preset that says it files - # issues while `github.write` defaults false otherwise narrates the filing. - "requires_tools": list(entry.get("requires_tools") or []), - } + rec = _catalog_record(entry, aid) + bundle = rec["bundle"] seen_ids.add(aid) if bundle: seen_urls.add(_norm_url(bundle)) @@ -486,44 +646,26 @@ def _archetypes() -> list[dict]: seen_ids.add(bid) if url: seen_urls.add(_norm_url(url)) - # A bundle declares its persona inline (`soul`) or names a host preset - # (`soul_preset`) — the same pair the catalog supports (#2715; before, - # only inline worked here and a preset-naming bundle silently fell back - # to the base persona via the console's personaSoul()). An unknown - # preset name resolves to "" — warn, because the operator sees the - # fallback persona with no other signal. - soul = str(arch.get("soul") or "") - if not soul and arch.get("soul_preset"): - soul = read_soul_preset(str(arch["soul_preset"])) - if not soul: - log.warning( - "[fleet] bundle %s names soul_preset %r — not found on this host; " - "the picker will fall back to the base persona", - bid, - arch["soul_preset"], - ) - out.append( - { - "id": bid, - "label": arch.get("label"), - "icon": arch.get("icon", "Package"), - "blurb": arch.get("blurb", ""), - "bundle": url or None, - "soul": soul, - # A bundle can file itself under the picker's "Advanced" toggle too — - # same optional tag as the catalog field, normalized to standard/advanced. - "tier": _norm_tier(arch.get("tier")), - # A bundle's archetype: block can declare host requirements too — - # same shape as the catalog field (#2186 follow-on). - "requires": list(arch.get("requires") or []), - # A bundle's archetype: block declares its capability contract the - # same way (#2277). - "requires_tools": list(arch.get("requires_tools") or []), - } - ) + out.append(_bundle_archetype_record(bid, url, arch)) except Exception: # noqa: BLE001 — archetype discovery is best-effort log.warning("[fleet] archetype discovery failed", exc_info=True) + # Held catalog entries (archetypes still being tested) — only on an explicit ask, each + # flagged so the picker can badge it "Preview". Deduped like the rest: once the bundle is + # installed (or promoted into `archetypes`), the regular row wins and the badge goes. + if include_held: + for entry in _load_held_archetypes(): + aid = str(entry.get("id") or "").strip() + if not aid or aid in seen_ids or aid == "custom": + continue + rec = _catalog_record(entry, aid) + if rec["bundle"] and _norm_url(rec["bundle"]) in seen_urls: + continue + seen_ids.add(aid) + if rec["bundle"]: + seen_urls.add(_norm_url(rec["bundle"])) + out.append({**rec, "held": True}) + if custom is not None: out.append(custom) # the catch-all write-your-own persona, always LAST return out diff --git a/ops/fleet.py b/ops/fleet.py index 596a8f3f3..cee7595a8 100644 --- a/ops/fleet.py +++ b/ops/fleet.py @@ -64,6 +64,7 @@ def create_sync( name: str, *, bundle: str | None = None, + ref: str | None = None, soul: str | None = None, port: int | None = None, start: bool = True, @@ -82,6 +83,7 @@ def create_sync( ws = manager.create( name, bundle=bundle or None, + bundle_ref=ref or None, port=port, shared_skills=shared_skills, inherit_model=_inherit_model_source(inherit_config), @@ -185,6 +187,7 @@ async def create( name: str, *, bundle: str | None = None, + ref: str | None = None, soul: str | None = None, port: int | None = None, start: bool = True, @@ -199,6 +202,7 @@ async def create( create_sync, name, bundle=bundle, + ref=ref, soul=soul, port=port, start=start, diff --git a/tests/test_fleet_routes.py b/tests/test_fleet_routes.py index b5307bfd1..af1c52aad 100644 --- a/tests/test_fleet_routes.py +++ b/tests/test_fleet_routes.py @@ -868,3 +868,200 @@ def test_pair_route_refuses_plain_http_on_a_lan_before_dialling(client, pair_wir r = client.post("/api/fleet/remotes/pair", json={"url": lan, "code": "ABCDE-12345", "allow_insecure": True}) assert r.status_code == 200, r.text assert len(pair_wire["posts"]) == 1 + + +# ── Held (preview) archetypes + "From a bundle URL" (new-agent sources) ───────────── + + +def _with_held(monkeypatch, held): + from operator_api import fleet_routes + + monkeypatch.setattr(fleet_routes, "_load_held_archetypes", lambda: held) + monkeypatch.setattr("graph.plugins.installer._read_lock", lambda: {}) + + +_HELD = [ + { + "_held": "Josh tests first.", + "id": "analyst", + "label": "Analyst", + "icon": "ChartColumn", + "bundle": "https://github.com/protoLabsAI/analyst-archetype", + "blurb": "Answers questions from your data files.", + "soul": "# Analyst", + "requires_tools": ["data_query"], + } +] + + +def test_held_archetypes_hidden_by_default(client, monkeypatch): + _with_held(monkeypatch, _HELD) + arr = client.get("/api/archetypes").json()["archetypes"] + assert "analyst" not in [a["id"] for a in arr] + assert not any(a.get("held") for a in arr) + + +def test_held_archetypes_only_on_explicit_include_held(client, monkeypatch): + _with_held(monkeypatch, _HELD) + arr = client.get("/api/archetypes?include_held=1").json()["archetypes"] + by_id = {a["id"]: a for a in arr} + assert by_id["analyst"]["held"] is True + assert by_id["analyst"]["requires_tools"] == ["data_query"] + assert "_held" not in by_id["analyst"] # the curator's note is never served + assert not any(a.get("held") for a in arr if a["id"] != "analyst") # only held rows are flagged + assert arr[-1]["id"] == "custom" # Custom stays LAST + + +def test_held_archetype_dedupes_against_installed_bundle(client, monkeypatch): + from operator_api import fleet_routes + + monkeypatch.setattr(fleet_routes, "_load_held_archetypes", lambda: _HELD) + monkeypatch.setattr( + "graph.plugins.installer._read_lock", + lambda: { + "bundles": [ + { + "id": "analyst-archetype", + "source_url": "https://github.com/protoLabsAI/analyst-archetype.git", + "archetype": {"label": "Analyst"}, + } + ] + }, + ) + arr = client.get("/api/archetypes?include_held=1").json()["archetypes"] + assert [a["id"] for a in arr if a["label"] == "Analyst"] == ["analyst-archetype"] # installed row wins + assert not any(a.get("held") for a in arr) + + +def test_held_archetype_preview_resolves(client, monkeypatch): + _with_held(monkeypatch, _HELD) + import ops.plugins as plugin_ops + + async def _fake_peek(url, ref=None): + return {"kind": "bundle", "id": "analyst-archetype", "members": []} + + monkeypatch.setattr(plugin_ops, "peek_bundle", _fake_peek) + assert client.get("/api/archetypes/analyst/preview").json()["bundle"]["id"] == "analyst-archetype" + + +@pytest.mark.parametrize( + "url", + [ + "", + "not a url", + "https://github.com/onlyowner", + "http://github.com/a/b", + "file:///etc/passwd", + "/tmp/local/repo", + "https://github.com/a/../b", + "https://github.com/a/b?x=1", + "--upload-pack=evil", + ], +) +def test_from_url_rejects_non_git_urls(client, monkeypatch, url): + import ops.plugins as plugin_ops + + async def _never(*a, **k): + raise AssertionError("must not fetch an invalid URL") + + monkeypatch.setattr(plugin_ops, "peek_bundle", _never) + r = client.get("/api/archetypes/from-url", params={"url": url}) + assert r.status_code == 400 + + +def test_from_url_rejects_bad_ref(client): + r = client.get("/api/archetypes/from-url", params={"url": "https://github.com/a/b", "ref": "-x;rm"}) + assert r.status_code == 400 + + +def test_from_url_peeks_and_shapes_an_archetype(client, monkeypatch): + import ops.plugins as plugin_ops + from runtime.state import STATE + + seen = {} + + async def _fake_peek(url, ref=None): + seen.update(url=url, ref=ref) + return { + "kind": "bundle", + "id": "analyst-archetype", + "name": "Analyst bundle", + "description": "Data analysis.", + "members": [{"id": "data", "builtin": False, "ref": "v0.1.0"}, {"id": "notes", "builtin": True}], + "config_inputs": [{"key": "data.data_dirs", "label": "Data folders", "type": "string"}], + "archetype": {"label": "Analyst", "icon": "ChartColumn", "blurb": "Answers from data.", "soul": "# A"}, + } + + monkeypatch.setattr(plugin_ops, "peek_bundle", _fake_peek) + cfg = type("C", (), {"plugins_sources_official": ["github.com/protoLabsAI/*"], "plugins_sources_acked": []})() + monkeypatch.setattr(STATE, "graph_config", cfg, raising=False) + r = client.get( + "/api/archetypes/from-url", params={"url": " https://github.com/protoLabsAI/analyst-archetype ", "ref": "v0.1.0"} + ) + assert r.status_code == 200, r.text + body = r.json() + assert seen == {"url": "https://github.com/protoLabsAI/analyst-archetype", "ref": "v0.1.0"} + arch = body["archetype"] + assert body["id"] == arch["id"] == "analyst-archetype" + assert arch["label"] == "Analyst" and arch["blurb"] == "Answers from data." and arch["soul"] == "# A" + assert arch["bundle"] == "https://github.com/protoLabsAI/analyst-archetype" + assert arch["ref"] == "v0.1.0" + assert body["bundle"]["members"][0]["ref"] == "v0.1.0" # the full peek rides along + assert body["trusted"] is True and body["source"] == "github.com/protoLabsAI/analyst-archetype" + + +def test_from_url_untrusted_source_and_no_archetype_block(client, monkeypatch): + import ops.plugins as plugin_ops + from runtime.state import STATE + + async def _fake_peek(url, ref=None): + return {"kind": "plugin", "members": [{"id": "thing", "name": "Thing", "description": "A plugin."}]} + + monkeypatch.setattr(plugin_ops, "peek_bundle", _fake_peek) + cfg = type("C", (), {"plugins_sources_official": ["github.com/protoLabsAI/*"], "plugins_sources_acked": []})() + monkeypatch.setattr(STATE, "graph_config", cfg, raising=False) + body = client.get("/api/archetypes/from-url", params={"url": "git@github.com:acme/thing.git"}).json() + assert body["trusted"] is False + assert body["archetype"]["label"] == "Thing" and body["archetype"]["blurb"] == "A plugin." + assert "ref" not in body["archetype"] + + +def test_from_url_fetch_failure_is_502(client, monkeypatch): + import ops.plugins as plugin_ops + + async def _boom(url, ref=None): + raise RuntimeError("repo not found") + + monkeypatch.setattr(plugin_ops, "peek_bundle", _boom) + r = client.get("/api/archetypes/from-url", params={"url": "https://github.com/a/b"}) + assert r.status_code == 502 and "repo not found" in r.json()["detail"] + + +def test_create_forwards_bundle_ref(client, monkeypatch): + from graph.workspaces import manager + + captured: dict = {} + + def fake_create(name, **kwargs): + captured.update(kwargs) + return {"id": f"{name}-0000", "name": name, "port": 7999, "path": "/tmp/x", "installed": []} + + monkeypatch.setattr(manager, "create", fake_create) + r = client.post( + "/api/fleet", + json={"name": "pinned", "start": False, "bundle": "https://github.com/x/stack", "ref": "v0.1.0"}, + ) + assert r.status_code == 200 + assert captured["bundle_ref"] == "v0.1.0" + + captured.clear() + client.post("/api/fleet", json={"name": "unpinned", "start": False, "bundle": "https://github.com/x/stack"}) + assert captured["bundle_ref"] is None + + +@pytest.mark.parametrize("body", [{"ref": "v1"}, {"bundle": "https://github.com/x/stack", "ref": "--evil"}]) +def test_create_rejects_bad_ref(client, monkeypatch, body): + from graph.workspaces import manager + + monkeypatch.setattr(manager, "create", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not create"))) + assert client.post("/api/fleet", json={"name": "x", "start": False, **body}).status_code == 400 diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py index f9665c05d..f1d5ba7fb 100644 --- a/tests/test_workspaces.py +++ b/tests/test_workspaces.py @@ -1448,7 +1448,7 @@ def test_bundle_failure_does_not_transfer_oauth_ownership(root, tmp_path, monkey (host / "langgraph-config.yaml").write_text("model:\n name: chatgpt:gpt-5-codex\n") local_store = host / "codex-oauth.json" local_store.write_text('{"tokens":{"refresh_token":"instance"}}') - monkeypatch.setattr(manager, "_install_bundle_into", lambda ws, bundle: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr(manager, "_install_bundle_into", lambda ws, bundle, ref=None: (_ for _ in ()).throw(RuntimeError("boom"))) with pytest.raises(RuntimeError, match="boom"): manager.create("sister", inherit_model=str(host), bundle="https://example.invalid/bundle") @@ -1467,7 +1467,7 @@ def test_create_from_bundle_refuses_missing_required_input_and_cleans_up(root, t repo.mkdir(parents=True) monkeypatch.setattr(manager, "github_slug_for_checkout", lambda p: "acme/proj") - def fake_install(ws, bundle): + def fake_install(ws, bundle, ref=None): _pm_lock(ws) return ["board"] @@ -1591,7 +1591,7 @@ def test_cli_new_answers_config_inputs_and_copies_the_delegate(root, tmp_path, m monkeypatch.setattr(config_io, "config_yaml_path", lambda: host / "langgraph-config.yaml") monkeypatch.setattr(manager, "github_slug_for_checkout", lambda p: "") - def fake_install(ws, bundle): + def fake_install(ws, bundle, ref=None): _pm_lock(ws) return ["board"] @@ -1869,3 +1869,35 @@ def _stop(): cli.run_plugin_cli(["list"]) assert logging.getLogger("httpx").level == logging.WARNING assert logging.getLogger("httpcore").level == logging.WARNING + + +def test_create_pins_bundle_ref_into_install_and_record(root, tmp_path, monkeypatch): + """A bundle created at a ref ("From a bundle URL") installs AT that ref and records it.""" + host = _host_dir(tmp_path) + seen: dict = {} + + def fake_install(ws, bundle, ref=None): + seen.update(bundle=bundle, ref=ref) + return [] + + monkeypatch.setattr(manager, "_install_bundle_into", fake_install) + rec = manager.create("pinned", bundle="https://example/b", bundle_ref="v0.1.0", inherit_model=str(host)) + assert seen == {"bundle": "https://example/b", "ref": "v0.1.0"} + ws_doc = yaml.safe_load((root / rec["id"] / "workspace.yaml").read_text()) + assert ws_doc["bundle_ref"] == "v0.1.0" + + +def test_install_bundle_into_passes_ref_to_the_cli(monkeypatch, tmp_path): + import subprocess + + seen: list[list[str]] = [] + + def _run(argv, **kw): + seen.append(list(argv)) + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(manager.subprocess, "run", _run) + manager._install_bundle_into(tmp_path, "https://github.com/acme/stack", ref="v2") + manager._install_bundle_into(tmp_path, "https://github.com/acme/stack") + assert seen[0][seen[0].index("--ref") + 1] == "v2" + assert "--ref" not in seen[1]