Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/web/e2e/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
87 changes: 87 additions & 0 deletions apps/web/e2e/fleet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ref> 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
Expand Down
18 changes: 17 additions & 1 deletion apps/web/e2e/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
ACTIVITY_HISTORY,
ARCHETYPES,
ARCHETYPE_PREVIEWS,
archetypeFromUrl,
HELD_ARCHETYPES,
ARTIFACT_STORE,
buildFrames,
buildWatches,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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]);
Expand Down
18 changes: 18 additions & 0 deletions apps/web/e2e/new-agent.mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
13 changes: 13 additions & 0 deletions apps/web/src/app/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>), so the old
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/lib/api/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import type {
Archetype,
ArchetypeFromUrl,
ArchetypePreview,
DiagnosticsLogs,
DiagnosticsTask,
Expand Down Expand Up @@ -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<ArchetypePreview>(`/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<ArchetypeFromUrl>(`/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;
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/archetypeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/archetypeFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/lib/bundleUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
46 changes: 46 additions & 0 deletions apps/web/src/lib/bundleUrl.ts
Original file line number Diff line number Diff line change
@@ -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/<owner>/<repo>/tree/<ref>` · `…/releases/tag/<ref>` · `…/commit/<sha>`.
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/<owner>/<repo> (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 };
}
Loading
Loading