personas: sharing v1 — export/import bundles, version + consent (OPE-7)

Bundle zip export + import (zip-slip guarded) through the picker's Import door; version+provenance with a replaces-note, re-consent only when capabilities grow.
Consent screen: trust warning first, capability summary with collapsed tool list, recommended connectors.
This commit is contained in:
Rohit C Prasad
2026-08-11 12:12:24 -07:00
committed by Rohit P
parent b5b000eb76
commit 110a8ae8ce
14 changed files with 600 additions and 34 deletions
+32
View File
@@ -956,9 +956,41 @@ export async function mockApi(page: import("@playwright/test").Page) {
const b = req.postDataJSON();
return json({ ok: true, path: b.path });
}
if (/\/v1\/personas\/[^/]+\/export$/.test(p) && m === "POST") {
// Sharing v1: export the bundle zip into the chosen folder.
const id = p.split("/").slice(-2)[0];
const b = req.postDataJSON();
return json({ ok: true, path: `${b.dir}/${id}-coworker-v1.zip` });
}
// must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON();
if (b.zip_b64) {
// Sharing v1: a bundle zip import — consent with version + replaces + recommends.
const imported = {
id: "team-sec", name: "Team Security Coworker", icon: "shield",
tagline: "Our security playbook", needs_workspace: true, builtin: false,
family: "code", workspace: "git", tools: ["code_files", "search", "shell"],
enabled: false, surfaced: false, default: false, version: "2",
};
if (!personas.some((x) => x.id === "team-sec")) personas.push(imported);
return json({
ok: true,
personas,
consent: [{
id: "team-sec", name: "Team Security Coworker",
description: "Reviews code the way our team does.",
tools: ["code_files", "search", "shell"],
risk: ["read", "write_local", "exec"],
connectors: true, mcp: [], messaging: false,
recommended_mode: "interactive", recommended_models: [],
recommends: [{ kind: "connector", ref: "github", reason: "open fix PRs", tier: "core" }],
version: "2",
replaces: { version: "1", installed_at: "2026-08-01", capabilities_grew: true },
source: "/tmp/team-sec.zip", builtin: false,
}],
});
}
if (b.gallery_slug) {
return json(
CLOUD_STATE.signed_in
+68
View File
@@ -0,0 +1,68 @@
import { test, expect } from "./fixtures";
// Sharing v1 (OPE-7): the picker's "Import coworker…" door, the zip-import consent flow
// (trust warning first, capabilities behind a chevron, replaces-note), and per-coworker
// export from Settings ▸ Coworkers.
test("picker's Import door lands on Settings ▸ Coworkers at the Add section", async ({ page }) => {
await page.goto("/");
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await page.getByTestId("import-coworker").click();
// Settings ▸ Coworkers opened, with the Add section (the import surface) present.
await expect(page.getByText("Add coworkers")).toBeVisible();
await expect(page.getByRole("combobox")).toBeVisible();
});
test("zip import: trust warning leads, tools collapse behind a chevron, replaces-note shows", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// Pick the Bundle zip mode and feed a file through the hidden input.
await page.getByRole("combobox").selectOption("zip");
await page.getByTestId("persona-zip-input").setInputFiles({
name: "team-sec.zip",
mimeType: "application/zip",
buffer: Buffer.from("fake-zip-bytes"),
});
const review = page.getByTestId("consent-review");
await expect(review).toBeVisible();
// The trust warning comes FIRST (owner design).
await expect(review.getByText(/Only enable coworkers from someone you trust/)).toBeVisible();
const card = page.getByTestId("consent-team-sec");
await expect(card.getByText("Team Security Coworker").first()).toBeVisible();
await expect(card.getByText(/Can read files, create & edit files and run shell commands/)).toBeVisible();
// Exact tools hidden until the chevron is clicked.
await expect(card.getByText("code_files · search · shell")).toHaveCount(0);
await card.getByTestId("consent-tools-toggle").click();
await expect(card.getByText("code_files · search · shell")).toBeVisible();
// Version + replaces + grew-capabilities re-consent note; recommended connector shown.
await expect(card.getByTestId("replaces-note")).toContainText("Replaces Team Security Coworker v1");
await expect(card.getByTestId("replaces-note")).toContainText("MORE capabilities");
await expect(card.getByText(/github.*(recommended).*open fix PRs/)).toBeVisible();
// Imported coworker landed disabled in the list above, pending consent.
const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" });
await expect(row.getByRole("checkbox", { name: "Enabled" })).not.toBeChecked();
});
test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// The installed (non-builtin) fixture persona carries the Export affordance;
// the native folder pick is server-mocked → /tmp/picked-folder.
await page.getByTestId("persona-export-acme-notes").click();
await expect(page.getByText("Exported to /tmp/picked-folder/acme-notes-coworker-v1.zip")).toBeVisible();
});
+8
View File
@@ -1804,6 +1804,14 @@ export function App() {
onPickCoworker={pickCoworker}
onPickFolder={pickDraftFolder}
onManage={() => openSettings("personas")}
onImport={() => {
openSettings("personas");
// Give the Settings page a beat to mount, then spotlight the Add section.
window.setTimeout(
() => window.dispatchEvent(new CustomEvent("ocw-focus-import")),
250,
);
}}
/>
)}
<Composer
+19 -1
View File
@@ -888,6 +888,8 @@ export interface Persona {
enabled: boolean;
surfaced: boolean;
default: boolean;
version?: string;
installed_at?: string;
}
export interface PersonaConsent {
@@ -901,6 +903,9 @@ export interface PersonaConsent {
messaging: boolean;
recommended_mode: string;
recommended_models: string[];
recommends?: { kind: string; ref: string; reason: string; tier: string }[];
version?: string;
replaces?: { version: string; installed_at: string; capabilities_grew: boolean } | null;
source: string | null;
builtin: boolean;
}
@@ -986,8 +991,21 @@ export async function getCloudGalleryDetail(slug: string): Promise<GalleryDetail
return res.json();
}
/** Sharing v1 (OPE-7): zip a coworker's bundle into `dir`; the zip is the import format. */
export async function exportPersona(
id: string,
dir: string,
): Promise<{ ok: boolean; path?: string; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dir }),
});
return res.json();
}
export async function installPersona(
body: { dir?: string; git_url?: string; gallery_slug?: string },
body: { dir?: string; git_url?: string; gallery_slug?: string; zip_b64?: string; filename?: string },
): Promise<{ ok: boolean; consent?: PersonaConsent[]; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/install`, {
method: "POST",
+167 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
deletePersona,
exportPersona,
getPersonas,
getSessions,
installPersona,
@@ -8,6 +9,7 @@ import {
type Persona,
type PersonaConsent,
} from "../api";
import { chooseFolder } from "../tauri";
import type { SessionInfo } from "../types";
import { Icon } from "./Icon";
@@ -27,7 +29,7 @@ const BTN_BORDERED =
export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) {
const [personas, setPersonas] = useState<Persona[]>([]);
const [mode, setMode] = useState<"git" | "dir">("git");
const [mode, setMode] = useState<"git" | "dir" | "zip">("git");
const [src, setSrc] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
@@ -37,6 +39,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
// arm an inline confirm (same two-step idiom as delete) instead of flipping immediately.
const [confirmOff, setConfirmOff] = useState<string | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
// The picker's "Import coworker…" door lands here and asks us to put the Add section
// front and center (sharing v1).
const addRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const focus = () => addRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
window.addEventListener("ocw-focus-import", focus);
return () => window.removeEventListener("ocw-focus-import", focus);
}, []);
const reload = () => getPersonas().then(setPersonas).catch(() => {});
const reloadSessions = () => getSessions().then(setSessions).catch(() => {});
@@ -75,6 +85,36 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
else reload();
};
const finishInstall = (r: Awaited<ReturnType<typeof installPersona>>) => {
setBusy(false);
if (!r.ok) {
setMsg(r.error || "install failed");
return;
}
setConsent(r.consent || []);
if (r.personas) setPersonas(r.personas);
setMsg(`Installed ${(r.consent || []).length} coworker(s) — review and enable below.`);
setSrc("");
};
const installZip = async (file: File) => {
setBusy(true);
setMsg(null);
setConsent(null);
const buf = new Uint8Array(await file.arrayBuffer());
let bin = "";
for (let i = 0; i < buf.length; i += 0x8000)
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
finishInstall(await installPersona({ zip_b64: btoa(bin), filename: file.name }));
};
const exportOne = async (p: Persona) => {
const dir = await chooseFolder();
if (!dir) return;
const r = await exportPersona(p.id, dir);
setMsg(r.ok ? `Exported to ${r.path}` : r.error || "export failed");
};
const install = async () => {
if (!src.trim()) return;
setBusy(true);
@@ -150,6 +190,16 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
<Icon name="sliders" size={15} />
</button>
)}
{!p.builtin && (
<button
className={BTN_BORDERED}
title="Export this coworker as a shareable bundle"
data-testid={`persona-export-${p.id}`}
onClick={() => void exportOne(p)}
>
Export
</button>
)}
{!p.builtin &&
(confirmDel === p.id ? (
<span className="flex items-center gap-1.5 shrink-0">
@@ -205,50 +255,138 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
))}
</div>
<div className={SEC_H + " mb-1.5"}>Add coworkers</div>
<div ref={addRef} className={SEC_H + " mb-1.5"}>Add coworkers</div>
<p className="text-[12px] text-muted mb-3 leading-relaxed">
Load from a local directory or a public GitHub repo. Files are copied into a managed area (a
snapshot), so the coworker stays stable even if the source changes. No code runs a coworker only
composes vetted tools.
</p>
<div className="flex items-center gap-2">
<select className={SELECT} value={mode} onChange={(e) => setMode(e.target.value as "git" | "dir")}>
<select
className={SELECT}
value={mode}
onChange={(e) => setMode(e.target.value as "git" | "dir" | "zip")}
>
<option value="git">GitHub URL</option>
<option value="dir">Local directory</option>
<option value="zip">Bundle zip</option>
</select>
<input
className={INPUT}
placeholder={mode === "git" ? "https://github.com/acme/ops-coworker" : "/path/to/coworkers"}
value={src}
onChange={(e) => setSrc(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && install()}
/>
<button className={BTN_ACCENT} disabled={busy || !src.trim()} onClick={install}>
{busy ? "Installing…" : "Install"}
</button>
{mode === "zip" ? (
<label className={BTN_BORDERED + " cursor-pointer"}>
{busy ? "Installing…" : "Choose a .zip bundle…"}
<input
type="file"
accept=".zip"
className="hidden"
data-testid="persona-zip-input"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void installZip(f);
e.target.value = "";
}}
/>
</label>
) : (
<>
<input
className={INPUT}
placeholder={mode === "git" ? "https://github.com/acme/ops-coworker" : "/path/to/coworkers"}
value={src}
onChange={(e) => setSrc(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && install()}
/>
<button className={BTN_ACCENT} disabled={busy || !src.trim()} onClick={install}>
{busy ? "Installing…" : "Install"}
</button>
</>
)}
</div>
{msg && <div className="text-[12.5px] text-muted mt-2.5">{msg}</div>}
{consent && consent.length > 0 && (
<div className="mt-4 space-y-2">
<div className="mt-4 space-y-2" data-testid="consent-review">
{/* Trust first (owner design, 2026-08-11): the source warning leads; capabilities
are a one-line summary with the exact tools under a collapsed chevron. A
coworker runs no third-party code, so this list is complete but a prompt
still steers an agent, so who it came from genuinely matters. */}
<div className="flex items-start gap-2.5 rounded-xl border border-warnInk/30 bg-warnSoft px-3.5 py-2.5 text-[12.5px] text-warnInk">
<Icon name="shield" size={15} className="shrink-0 mt-0.5" />
<span>
Only enable coworkers from someone you trust. Nothing here runs third-party
code but its instructions will guide the coworker's behavior.
</span>
</div>
{consent.map((c) => (
<div key={c.id} className={CARD + " p-3.5"}>
<div className="text-[13.5px] font-medium">{c.name}</div>
<div className="text-[12px] text-muted mt-0.5 mb-2">{c.description}</div>
<div className="text-[12px] text-ink">Tools: {c.tools.join(", ") || "—"}</div>
<div className="text-[12px] text-ink">
Risk: {c.risk.join(", ") || "read"}
{c.connectors ? " · connectors" : ""}
{c.messaging ? " · messaging" : ""}
{c.mcp.length ? ` · mcp: ${c.mcp.join(", ")}` : ""}
</div>
<div className="text-[12px] text-faint mt-1">
Recommended mode: {c.recommended_mode}. Enable it above to use it.
</div>
</div>
<ConsentCard key={c.id} c={c} />
))}
</div>
)}
</div>
);
}
// One phrase per risk class — the plain-language capability summary the consent card leads
// with; unknown classes fall back to their raw id so nothing is silently omitted.
const RISK_PHRASE: Record<string, string> = {
read: "read files",
write_local: "create & edit files",
exec: "run shell commands",
network: "access the network",
write_remote: "act on connected services",
};
function ConsentCard({ c }: { c: PersonaConsent }) {
const [showTools, setShowTools] = useState(false);
const phrases = (c.risk.length ? c.risk : ["read"]).map((r) => RISK_PHRASE[r] || r);
const summary = phrases.join(", ").replace(/, ([^,]*)$/, " and $1");
const recommends = c.recommends || [];
return (
<div className={CARD + " p-3.5"} data-testid={`consent-${c.id}`}>
<div className="text-[13.5px] font-medium flex items-center gap-2">
<span>{c.name}</span>
{c.version && <span className="text-[11px] text-faint font-normal">v{c.version}</span>}
</div>
{c.description && <div className="text-[12px] text-muted mt-0.5">{c.description}</div>}
{c.replaces && (
<div className="text-[12px] text-muted mt-1.5" data-testid="replaces-note">
Replaces {c.name}
{c.replaces.version ? ` v${c.replaces.version}` : ""}
{c.replaces.installed_at ? ` (installed ${c.replaces.installed_at})` : ""}.
{c.replaces.capabilities_grew
? " This update asks for MORE capabilities than the copy it replaces — review below before re-enabling."
: " Same capabilities as before — it stays enabled."}
</div>
)}
<div className="text-[12.5px] text-ink mt-2">
Can {summary}
{c.connectors ? " · use your connected services" : ""}
{c.messaging ? " · send messages" : ""}
{c.mcp.length ? ` · use MCP: ${c.mcp.join(", ")}` : ""}
<button
className="ml-2 text-accent text-[12px] hover:underline"
data-testid="consent-tools-toggle"
onClick={() => setShowTools((v) => !v)}
>
{showTools ? "Hide tools" : `Exact tools (${c.tools.length})`}
</button>
</div>
{showTools && (
<div className="text-[12px] text-muted mt-1 font-mono">{c.tools.join(" · ") || "—"}</div>
)}
{recommends.length > 0 && (
<div className="mt-2 space-y-0.5">
{recommends.map((r) => (
<div key={r.kind + r.ref} className="text-[12px] text-muted">
<span className="text-ink">{r.ref}</span>
{r.tier === "core" ? " (recommended)" : " (optional)"} {r.reason}
</div>
))}
</div>
)}
<div className="text-[12px] text-faint mt-2">
Recommended mode: {c.recommended_mode}. Enable it above to use it.
</div>
</div>
);
}
@@ -21,6 +21,9 @@ interface Props {
onPickCoworker: (id: string) => void;
onPickFolder: (path: string, branch?: string | null) => void;
onManage: () => void;
// Sharing v1 (OPE-7): the quick door to the import/browse screen — one row, so the
// picker itself never grows beyond the user's own coworkers.
onImport: () => void;
}
export function SessionSetupRow(props: Props) {
@@ -89,6 +92,16 @@ export function SessionSetupRow(props: Props) {
</button>
))}
<div className="border-t border-line mt-1 pt-1">
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
data-testid="import-coworker"
onClick={() => {
setOpenMenu(null);
props.onImport();
}}
>
Import coworker
</button>
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => {