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
@@ -4,6 +4,7 @@ name: Cloud Posture Coworker
icon: sliders icon: sliders
tagline: Review Terraform & cloud config — read-only, evidence first tagline: Review Terraform & cloud config — read-only, evidence first
family: code family: code
version: "1"
tools: [code_files, git, search, shell, todo] tools: [code_files, git, search, shell, todo]
connectors: true connectors: true
skills: [iac-scan, aws-posture] skills: [iac-scan, aws-posture]
@@ -4,6 +4,7 @@ name: Dependency Audit Coworker
icon: audit icon: audit
tagline: Vulnerable dependencies — audit, minimal upgrades, PRs tagline: Vulnerable dependencies — audit, minimal upgrades, PRs
family: code family: code
version: "1"
tools: [code_files, git, search, shell, todo] tools: [code_files, git, search, shell, todo]
connectors: true connectors: true
skills: [dependency-audit, safe-upgrade-pr] skills: [dependency-audit, safe-upgrade-pr]
@@ -4,6 +4,7 @@ name: Security Coworker
icon: shield icon: shield
tagline: Find and fix security issues — scan, triage, PR tagline: Find and fix security issues — scan, triage, PR
family: code family: code
version: "1"
tools: [code_files, git, search, shell, todo] tools: [code_files, git, search, shell, todo]
connectors: true connectors: true
skills: [semgrep-review, secret-scan, security-fix-pr] skills: [semgrep-review, secret-scan, security-fix-pr]
+20
View File
@@ -31,11 +31,31 @@ def consent_summary(m: PersonaManifest) -> dict:
"messaging": m.messaging, "messaging": m.messaging,
"recommended_mode": m.default_permission_mode, "recommended_mode": m.default_permission_mode,
"recommended_models": list(m.recommended_models), "recommended_models": list(m.recommended_models),
# Recommended connectors/MCP with reasons + tiers — the consent screen shows
# these so the user knows what the coworker hopes to use (sharing v1).
"recommends": [
{"kind": r.kind, "ref": r.ref, "reason": r.reason, "tier": r.tier}
for r in m.recommends
],
"version": m.version,
"source": m.source, "source": m.source,
"builtin": m.builtin, "builtin": m.builtin,
} }
def capability_set(m: PersonaManifest) -> set[str]:
"""The persona's capability surface as a flat comparable set — used to decide
whether an update GREW capabilities (which requires re-consent; a same-or-smaller
update keeps the user's enabled state)."""
caps = {f"tool:{t}" for t in m.tools}
caps |= {f"mcp:{s}" for s in m.mcp}
if m.connectors:
caps.add("connectors")
if m.messaging:
caps.add("messaging")
return caps
def git_clone( def git_clone(
url: str, dest: Path url: str, dest: Path
) -> None: # pragma: no cover - exercised via injection ) -> None: # pragma: no cover - exercised via injection
+5
View File
@@ -63,6 +63,10 @@ class PersonaManifest:
recommended_models: list[str] = field(default_factory=list) recommended_models: list[str] = field(default_factory=list)
skills: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list)
mcp: list[str] = field(default_factory=list) mcp: list[str] = field(default_factory=list)
# Sharing v1 (OPE-7): the author's version string ("1", "1.2", "2026-08"…). Purely
# informational provenance — with folder/git distribution there is no authoritative
# update channel, so this drives the "replaces vN" note on re-install, nothing more.
version: str = ""
recommends: list[Recommendation] = field(default_factory=list) recommends: list[Recommendation] = field(default_factory=list)
builtin: bool = False builtin: bool = False
source: Optional[str] = ( source: Optional[str] = (
@@ -237,6 +241,7 @@ def parse_manifest(
recommended_models=_strlist(meta, "recommended_models"), recommended_models=_strlist(meta, "recommended_models"),
skills=_strlist(meta, "skills"), skills=_strlist(meta, "skills"),
mcp=_strlist(meta, "mcp"), mcp=_strlist(meta, "mcp"),
version=str(meta.get("version", "") or "").strip(),
recommends=_recommends(persona_id, meta), recommends=_recommends(persona_id, meta),
builtin=builtin, builtin=builtin,
source=source, source=source,
+104 -3
View File
@@ -85,6 +85,9 @@ class PersonaRegistry:
self._entries: dict[str, PersonaEntry] = {} self._entries: dict[str, PersonaEntry] = {}
self._enabled: dict[str, bool] = {} self._enabled: dict[str, bool] = {}
self._surfaced: dict[str, bool] = {} self._surfaced: dict[str, bool] = {}
# Sharing v1 (OPE-7): install provenance per installed persona —
# {version, source, installed_at} — drives the "replaces vN" note on re-install.
self._installed_meta: dict[str, dict] = {}
self._default = DEFAULT_PERSONA_ID self._default = DEFAULT_PERSONA_ID
self._load_builtin(builtin_dir) self._load_builtin(builtin_dir)
for d in extra_dirs or []: for d in extra_dirs or []:
@@ -209,6 +212,7 @@ class PersonaRegistry:
data = json.loads(self.state_path.read_text(encoding="utf-8")) data = json.loads(self.state_path.read_text(encoding="utf-8"))
self._enabled = dict(data.get("enabled", {})) self._enabled = dict(data.get("enabled", {}))
self._surfaced = dict(data.get("surfaced", {})) self._surfaced = dict(data.get("surfaced", {}))
self._installed_meta = dict(data.get("installed_meta", {}))
self._default = data.get("default", DEFAULT_PERSONA_ID) self._default = data.get("default", DEFAULT_PERSONA_ID)
def save(self) -> None: def save(self) -> None:
@@ -220,6 +224,7 @@ class PersonaRegistry:
{ {
"enabled": self._enabled, "enabled": self._enabled,
"surfaced": self._surfaced, "surfaced": self._surfaced,
"installed_meta": self._installed_meta,
"default": self._default, "default": self._default,
}, },
indent=2, indent=2,
@@ -308,6 +313,8 @@ class PersonaRegistry:
"enabled": self.is_enabled(e.id), "enabled": self.is_enabled(e.id),
"surfaced": self.is_surfaced(e.id), "surfaced": self.is_surfaced(e.id),
"default": e.id == self.default_id(), "default": e.id == self.default_id(),
"version": e.manifest.version if e.manifest else "",
"installed_at": self._installed_meta.get(e.id, {}).get("installed_at", ""),
} }
for e in self._entries.values() for e in self._entries.values()
] ]
@@ -378,15 +385,109 @@ class PersonaRegistry:
summaries: list[dict] = [] summaries: list[dict] = []
for md in mds: for md in mds:
m = load_manifest_file(md, builtin=False) # validate before snapshotting m = load_manifest_file(md, builtin=False) # validate before snapshotting
replaces = self._replaces_of(m)
snapshot = self._snapshot(md, m.id) snapshot = self._snapshot(md, m.id)
installed = load_manifest_file(snapshot, builtin=False) if snapshot else m installed = load_manifest_file(snapshot, builtin=False) if snapshot else m
self._register_manifest(installed, builtin=False) self._register_manifest(installed, builtin=False)
self._enabled[m.id] = False # pending consent — never auto-enabled # Consent rules (sharing v1): a fresh install always lands disabled pending
self._surfaced[m.id] = False # consent. An UPDATE keeps the user's enabled state — unless its capability
summaries.append(consent_summary(installed)) # set GREW, which is a new decision, never a silent upgrade.
if replaces is None or replaces.get("capabilities_grew"):
self._enabled[m.id] = False
self._surfaced[m.id] = False
self._installed_meta[m.id] = {
"version": installed.version,
"source": str(md),
"installed_at": self._now_stamp(),
}
summary = consent_summary(installed)
summary["replaces"] = replaces
summaries.append(summary)
self.save() self.save()
return summaries return summaries
@staticmethod
def _now_stamp() -> str:
from datetime import date
return date.today().isoformat()
def _replaces_of(self, incoming) -> Optional[dict]:
"""When re-installing an already-installed persona id: what the new copy
replaces ({version, installed_at, capabilities_grew}), else None."""
from .loading import capability_set
existing = self._entries.get(incoming.id)
if existing is None or existing.builtin or existing.manifest is None:
return None
meta = self._installed_meta.get(incoming.id, {})
grew = bool(capability_set(incoming) - capability_set(existing.manifest))
return {
"version": meta.get("version") or existing.manifest.version or "",
"installed_at": meta.get("installed_at", ""),
"capabilities_grew": grew,
}
def export_persona(self, persona_id: str, dest_dir: str | Path) -> dict:
"""Sharing v1 export: zip the persona's bundle (manifest + skills/) into
``dest_dir``. The zip's contents ARE the import format — extract or point the
installer at it and the round trip is lossless."""
import zipfile
entry = self._entries.get(persona_id)
if entry is None or entry.manifest is None or not entry.manifest.source:
return {"ok": False, "error": "this coworker has no shareable bundle"}
src_md = Path(entry.manifest.source)
if not src_md.is_file():
return {"ok": False, "error": "the coworker's bundle files are missing"}
dest = Path(dest_dir).expanduser()
if not dest.is_dir():
return {"ok": False, "error": "destination folder does not exist"}
version = entry.manifest.version
zip_name = f"{persona_id}-coworker{('-v' + version) if version else ''}.zip"
zip_path = dest / zip_name
skills_dir = src_md.parent / "skills"
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(src_md, "manifest.md")
if skills_dir.is_dir():
for p in sorted(skills_dir.rglob("*")):
if p.is_file():
zf.write(p, str(Path("skills") / p.relative_to(skills_dir)))
except OSError as e:
return {"ok": False, "error": f"could not write the archive: {e}"}
return {"ok": True, "path": str(zip_path)}
def install_from_zip(self, data: bytes, filename: str = "") -> list[dict]:
"""Install persona(s) from a shared bundle zip (the export format). The archive
is extracted to a temp dir with a zip-slip guard, then installed like a local
directory — landing disabled pending consent like every install."""
import io
import tempfile
import zipfile
with tempfile.TemporaryDirectory(prefix="ocw-persona-zip-") as tmp:
root = Path(tmp)
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
name = info.filename
target = (root / name).resolve()
if not str(target).startswith(str(root.resolve())):
raise FileNotFoundError(f"unsafe path in archive: {name}")
zf.extractall(root)
except zipfile.BadZipFile as e:
raise FileNotFoundError(f"not a valid bundle archive: {e}") from e
# Accept both layouts: files at the root, or a single wrapping folder
# (how macOS zips a directory).
candidates = [root, *[p for p in root.iterdir() if p.is_dir()]]
for d in candidates:
if list(d.glob("*.md")) or (d / "manifest.md").is_file():
return self.install_from_dir(d)
raise FileNotFoundError(
f"no persona manifest found in {filename or 'the archive'}"
)
def _snapshot(self, md: Path, persona_id: str) -> Optional[Path]: def _snapshot(self, md: Path, persona_id: str) -> Optional[Path]:
"""Copy a manifest into the managed install area; return the snapshot path (or None if no """Copy a manifest into the managed install area; return the snapshot path (or None if no
managed area is configured, e.g. an ephemeral in-memory registry).""" managed area is configured, e.g. an ephemeral in-memory registry)."""
+19 -1
View File
@@ -450,6 +450,16 @@ def create_app(manager: SessionManager) -> FastAPI:
summaries = reg.install_from_git(str(body["git_url"])) summaries = reg.install_from_git(str(body["git_url"]))
elif body.get("dir"): elif body.get("dir"):
summaries = reg.install_from_dir(str(body["dir"])) summaries = reg.install_from_dir(str(body["dir"]))
elif body.get("zip_b64"):
# Sharing v1 (OPE-7): a bundle zip — the export format — round-trips
# through the same dir installer + consent path.
try:
data = base64.b64decode(str(body["zip_b64"]), validate=True)
except (ValueError, binascii.Error):
return {"ok": False, "error": "Invalid archive encoding."}
summaries = reg.install_from_zip(
data, str(body.get("filename", ""))
)
elif body.get("gallery_slug"): elif body.get("gallery_slug"):
# Gallery install = fetch the manifest markdown from the cloud # Gallery install = fetch the manifest markdown from the cloud
# (sign-in required), verify its hash, then reuse the exact # (sign-in required), verify its hash, then reuse the exact
@@ -483,12 +493,20 @@ def create_app(manager: SessionManager) -> FastAPI:
else: else:
return { return {
"ok": False, "ok": False,
"error": "provide a `dir`, `git_url`, or `gallery_slug`", "error": "provide a `dir`, `git_url`, `zip_b64`, or `gallery_slug`",
} }
except Exception as e: # surface manifest/clone errors to the caller except Exception as e: # surface manifest/clone errors to the caller
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
return {"ok": True, "consent": summaries, "personas": reg.list_all()} return {"ok": True, "consent": summaries, "personas": reg.list_all()}
@app.post("/v1/personas/{persona_id}/export")
def export_persona(persona_id: str, body: dict) -> dict[str, Any]:
# Sharing v1 (OPE-7): zip the persona's bundle into the chosen folder. The zip
# is the import format — send it to a teammate, they import it from the picker.
return manager.personas.export_persona(
persona_id, str((body or {}).get("dir", ""))
)
@app.get("/v1/cloud/gallery/{slug}") @app.get("/v1/cloud/gallery/{slug}")
def cloud_gallery_detail(slug: str) -> dict[str, Any]: def cloud_gallery_detail(slug: str) -> dict[str, Any]:
"""Solo page for one gallery coworker: publisher pitch + capabilities """Solo page for one gallery coworker: publisher pitch + capabilities
+32
View File
@@ -956,9 +956,41 @@ export async function mockApi(page: import("@playwright/test").Page) {
const b = req.postDataJSON(); const b = req.postDataJSON();
return json({ ok: true, path: b.path }); 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) // must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") { if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON(); 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) { if (b.gallery_slug) {
return json( return json(
CLOUD_STATE.signed_in 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} onPickCoworker={pickCoworker}
onPickFolder={pickDraftFolder} onPickFolder={pickDraftFolder}
onManage={() => openSettings("personas")} 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 <Composer
+19 -1
View File
@@ -888,6 +888,8 @@ export interface Persona {
enabled: boolean; enabled: boolean;
surfaced: boolean; surfaced: boolean;
default: boolean; default: boolean;
version?: string;
installed_at?: string;
} }
export interface PersonaConsent { export interface PersonaConsent {
@@ -901,6 +903,9 @@ export interface PersonaConsent {
messaging: boolean; messaging: boolean;
recommended_mode: string; recommended_mode: string;
recommended_models: 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; source: string | null;
builtin: boolean; builtin: boolean;
} }
@@ -986,8 +991,21 @@ export async function getCloudGalleryDetail(slug: string): Promise<GalleryDetail
return res.json(); 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( 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 }> { ): Promise<{ ok: boolean; consent?: PersonaConsent[]; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/install`, { const res = await fetch(`${httpBase()}/v1/personas/install`, {
method: "POST", method: "POST",
+167 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
deletePersona, deletePersona,
exportPersona,
getPersonas, getPersonas,
getSessions, getSessions,
installPersona, installPersona,
@@ -8,6 +9,7 @@ import {
type Persona, type Persona,
type PersonaConsent, type PersonaConsent,
} from "../api"; } from "../api";
import { chooseFolder } from "../tauri";
import type { SessionInfo } from "../types"; import type { SessionInfo } from "../types";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -27,7 +29,7 @@ const BTN_BORDERED =
export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) { export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) {
const [personas, setPersonas] = useState<Persona[]>([]); 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 [src, setSrc] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null); 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. // arm an inline confirm (same two-step idiom as delete) instead of flipping immediately.
const [confirmOff, setConfirmOff] = useState<string | null>(null); const [confirmOff, setConfirmOff] = useState<string | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]); 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 reload = () => getPersonas().then(setPersonas).catch(() => {});
const reloadSessions = () => getSessions().then(setSessions).catch(() => {}); const reloadSessions = () => getSessions().then(setSessions).catch(() => {});
@@ -75,6 +85,36 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
else reload(); 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 () => { const install = async () => {
if (!src.trim()) return; if (!src.trim()) return;
setBusy(true); setBusy(true);
@@ -150,6 +190,16 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
<Icon name="sliders" size={15} /> <Icon name="sliders" size={15} />
</button> </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 && {!p.builtin &&
(confirmDel === p.id ? ( (confirmDel === p.id ? (
<span className="flex items-center gap-1.5 shrink-0"> <span className="flex items-center gap-1.5 shrink-0">
@@ -205,50 +255,138 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
))} ))}
</div> </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"> <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 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 snapshot), so the coworker stays stable even if the source changes. No code runs a coworker only
composes vetted tools. composes vetted tools.
</p> </p>
<div className="flex items-center gap-2"> <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="git">GitHub URL</option>
<option value="dir">Local directory</option> <option value="dir">Local directory</option>
<option value="zip">Bundle zip</option>
</select> </select>
<input {mode === "zip" ? (
className={INPUT} <label className={BTN_BORDERED + " cursor-pointer"}>
placeholder={mode === "git" ? "https://github.com/acme/ops-coworker" : "/path/to/coworkers"} {busy ? "Installing…" : "Choose a .zip bundle…"}
value={src} <input
onChange={(e) => setSrc(e.target.value)} type="file"
onKeyDown={(e) => e.key === "Enter" && install()} accept=".zip"
/> className="hidden"
<button className={BTN_ACCENT} disabled={busy || !src.trim()} onClick={install}> data-testid="persona-zip-input"
{busy ? "Installing…" : "Install"} disabled={busy}
</button> 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> </div>
{msg && <div className="text-[12.5px] text-muted mt-2.5">{msg}</div>} {msg && <div className="text-[12.5px] text-muted mt-2.5">{msg}</div>}
{consent && consent.length > 0 && ( {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) => ( {consent.map((c) => (
<div key={c.id} className={CARD + " p-3.5"}> <ConsentCard key={c.id} c={c} />
<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>
))} ))}
</div> </div>
)} )}
</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; onPickCoworker: (id: string) => void;
onPickFolder: (path: string, branch?: string | null) => void; onPickFolder: (path: string, branch?: string | null) => void;
onManage: () => 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) { export function SessionSetupRow(props: Props) {
@@ -89,6 +92,16 @@ export function SessionSetupRow(props: Props) {
</button> </button>
))} ))}
<div className="border-t border-line mt-1 pt-1"> <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 <button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent" className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => { onClick={() => {
+142
View File
@@ -0,0 +1,142 @@
"""Sharing v1 (OPE-7) — export/import round trip, version provenance, re-consent rules.
A coworker bundle (manifest + skills/) zips losslessly, imports through the same consent
path as every install, records provenance, and on re-install shows a "replaces vN" note
keeping the user's enabled state unless the capability set GREW.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from coworker.personas.registry import PersonaRegistry
from coworker.providers import ModelCapabilities, ProviderClient
from coworker.server import create_app
from coworker.server.manager import SessionManager
MANIFEST_V1 = """---
id: team-sec
name: Team Security Coworker
tagline: Our security playbook
family: code
version: "1"
tools: [code_files, search]
skills: [triage]
---
You review code the way our team does.
"""
MANIFEST_V2_SAME = MANIFEST_V1.replace('version: "1"', 'version: "2"')
MANIFEST_V2_GROWN = MANIFEST_V2_SAME.replace(
"tools: [code_files, search]", "tools: [code_files, search, shell]"
)
def _bundle(tmp_path, name, manifest):
d = tmp_path / name
(d / "skills" / "triage").mkdir(parents=True)
(d / "manifest.md").write_text(manifest, encoding="utf-8")
(d / "skills" / "triage" / "SKILL.md").write_text(
"---\nname: triage\ndescription: our triage playbook\n---\nTriage like we do.\n",
encoding="utf-8",
)
return d
def _reg(tmp_path) -> PersonaRegistry:
return PersonaRegistry(state_path=tmp_path / "state" / "personas.json")
def test_export_import_round_trip(tmp_path):
reg = _reg(tmp_path)
reg.install_from_dir(_bundle(tmp_path, "authored", MANIFEST_V1))
out = tmp_path / "shared"
out.mkdir()
res = reg.export_persona("team-sec", out)
assert res["ok"] is True and res["path"].endswith("team-sec-coworker-v1.zip")
# A second registry (the teammate) imports the zip: same skills, disabled pending
# consent, provenance recorded.
reg2 = PersonaRegistry(state_path=tmp_path / "state2" / "personas.json")
summaries = reg2.install_from_zip(open(res["path"], "rb").read(), "team-sec.zip")
assert [s["id"] for s in summaries] == ["team-sec"]
assert summaries[0]["version"] == "1"
assert summaries[0]["replaces"] is None
assert reg2.is_enabled("team-sec") is False
m = reg2.get("team-sec").manifest
from pathlib import Path
assert (Path(m.source).parent / "skills" / "triage" / "SKILL.md").is_file()
def test_reinstall_same_capabilities_keeps_enabled(tmp_path):
reg = _reg(tmp_path)
reg.install_from_dir(_bundle(tmp_path, "v1", MANIFEST_V1))
reg.set_enabled("team-sec", True)
summaries = reg.install_from_dir(_bundle(tmp_path, "v2", MANIFEST_V2_SAME))
rep = summaries[0]["replaces"]
assert rep and rep["version"] == "1" and rep["capabilities_grew"] is False
# Same-or-smaller capabilities → the user's enabled state survives the update.
assert reg.is_enabled("team-sec") is True
assert reg.get("team-sec").manifest.version == "2"
def test_reinstall_with_grown_capabilities_requires_reconsent(tmp_path):
reg = _reg(tmp_path)
reg.install_from_dir(_bundle(tmp_path, "v1", MANIFEST_V1))
reg.set_enabled("team-sec", True)
summaries = reg.install_from_dir(_bundle(tmp_path, "v2", MANIFEST_V2_GROWN))
rep = summaries[0]["replaces"]
assert rep and rep["capabilities_grew"] is True
# New tools = a new decision — never a silent upgrade.
assert reg.is_enabled("team-sec") is False
def test_zip_slip_and_garbage_are_rejected(tmp_path):
import io
import zipfile
import pytest
reg = _reg(tmp_path)
evil = io.BytesIO()
with zipfile.ZipFile(evil, "w") as zf:
zf.writestr("../../outside.md", "nope")
with pytest.raises(Exception):
reg.install_from_zip(evil.getvalue(), "evil.zip")
with pytest.raises(Exception):
reg.install_from_zip(b"not a zip", "garbage.zip")
def test_endpoints_round_trip(tmp_path, monkeypatch):
import base64
class P(ProviderClient):
def complete(self, **kw):
raise AssertionError
def capabilities(self, model):
return ModelCapabilities()
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
mgr = SessionManager(workspace=tmp_path, provider=P())
client = TestClient(create_app(mgr))
mgr.personas.install_from_dir(_bundle(tmp_path, "authored", MANIFEST_V1))
out = tmp_path / "shared"
out.mkdir()
res = client.post("/v1/personas/team-sec/export", json={"dir": str(out)}).json()
assert res["ok"] is True
zip_b64 = base64.b64encode(open(res["path"], "rb").read()).decode()
res2 = client.post(
"/v1/personas/install", json={"zip_b64": zip_b64, "filename": "team-sec.zip"}
).json()
assert res2["ok"] is True
assert res2["consent"][0]["id"] == "team-sec"
assert res2["consent"][0]["version"] == "1"
# The builtins can't be exported (builders have no bundle) — clean error, not a crash.
res3 = client.post("/v1/personas/cowork/export", json={"dir": str(out)}).json()
assert res3["ok"] is False