mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 15:50:02 +00:00
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:
@@ -4,6 +4,7 @@ name: Cloud Posture Coworker
|
||||
icon: sliders
|
||||
tagline: Review Terraform & cloud config — read-only, evidence first
|
||||
family: code
|
||||
version: "1"
|
||||
tools: [code_files, git, search, shell, todo]
|
||||
connectors: true
|
||||
skills: [iac-scan, aws-posture]
|
||||
|
||||
@@ -4,6 +4,7 @@ name: Dependency Audit Coworker
|
||||
icon: audit
|
||||
tagline: Vulnerable dependencies — audit, minimal upgrades, PRs
|
||||
family: code
|
||||
version: "1"
|
||||
tools: [code_files, git, search, shell, todo]
|
||||
connectors: true
|
||||
skills: [dependency-audit, safe-upgrade-pr]
|
||||
|
||||
@@ -4,6 +4,7 @@ name: Security Coworker
|
||||
icon: shield
|
||||
tagline: Find and fix security issues — scan, triage, PR
|
||||
family: code
|
||||
version: "1"
|
||||
tools: [code_files, git, search, shell, todo]
|
||||
connectors: true
|
||||
skills: [semgrep-review, secret-scan, security-fix-pr]
|
||||
|
||||
@@ -31,11 +31,31 @@ def consent_summary(m: PersonaManifest) -> dict:
|
||||
"messaging": m.messaging,
|
||||
"recommended_mode": m.default_permission_mode,
|
||||
"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,
|
||||
"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(
|
||||
url: str, dest: Path
|
||||
) -> None: # pragma: no cover - exercised via injection
|
||||
|
||||
@@ -63,6 +63,10 @@ class PersonaManifest:
|
||||
recommended_models: list[str] = field(default_factory=list)
|
||||
skills: 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)
|
||||
builtin: bool = False
|
||||
source: Optional[str] = (
|
||||
@@ -237,6 +241,7 @@ def parse_manifest(
|
||||
recommended_models=_strlist(meta, "recommended_models"),
|
||||
skills=_strlist(meta, "skills"),
|
||||
mcp=_strlist(meta, "mcp"),
|
||||
version=str(meta.get("version", "") or "").strip(),
|
||||
recommends=_recommends(persona_id, meta),
|
||||
builtin=builtin,
|
||||
source=source,
|
||||
|
||||
@@ -85,6 +85,9 @@ class PersonaRegistry:
|
||||
self._entries: dict[str, PersonaEntry] = {}
|
||||
self._enabled: 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._load_builtin(builtin_dir)
|
||||
for d in extra_dirs or []:
|
||||
@@ -209,6 +212,7 @@ class PersonaRegistry:
|
||||
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
self._enabled = dict(data.get("enabled", {}))
|
||||
self._surfaced = dict(data.get("surfaced", {}))
|
||||
self._installed_meta = dict(data.get("installed_meta", {}))
|
||||
self._default = data.get("default", DEFAULT_PERSONA_ID)
|
||||
|
||||
def save(self) -> None:
|
||||
@@ -220,6 +224,7 @@ class PersonaRegistry:
|
||||
{
|
||||
"enabled": self._enabled,
|
||||
"surfaced": self._surfaced,
|
||||
"installed_meta": self._installed_meta,
|
||||
"default": self._default,
|
||||
},
|
||||
indent=2,
|
||||
@@ -308,6 +313,8 @@ class PersonaRegistry:
|
||||
"enabled": self.is_enabled(e.id),
|
||||
"surfaced": self.is_surfaced(e.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()
|
||||
]
|
||||
@@ -378,15 +385,109 @@ class PersonaRegistry:
|
||||
summaries: list[dict] = []
|
||||
for md in mds:
|
||||
m = load_manifest_file(md, builtin=False) # validate before snapshotting
|
||||
replaces = self._replaces_of(m)
|
||||
snapshot = self._snapshot(md, m.id)
|
||||
installed = load_manifest_file(snapshot, builtin=False) if snapshot else m
|
||||
self._register_manifest(installed, builtin=False)
|
||||
self._enabled[m.id] = False # pending consent — never auto-enabled
|
||||
self._surfaced[m.id] = False
|
||||
summaries.append(consent_summary(installed))
|
||||
# Consent rules (sharing v1): a fresh install always lands disabled pending
|
||||
# consent. An UPDATE keeps the user's enabled state — unless its capability
|
||||
# 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()
|
||||
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]:
|
||||
"""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)."""
|
||||
|
||||
+19
-1
@@ -450,6 +450,16 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
summaries = reg.install_from_git(str(body["git_url"]))
|
||||
elif body.get("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"):
|
||||
# Gallery install = fetch the manifest markdown from the cloud
|
||||
# (sign-in required), verify its hash, then reuse the exact
|
||||
@@ -483,12 +493,20 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
else:
|
||||
return {
|
||||
"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
|
||||
return {"ok": False, "error": str(e)}
|
||||
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}")
|
||||
def cloud_gallery_detail(slug: str) -> dict[str, Any]:
|
||||
"""Solo page for one gallery coworker: publisher pitch + capabilities
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user