From 110a8ae8ce365cf0c013a2a13f66d071b7a30123 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 11 Aug 2026 12:12:24 -0700 Subject: [PATCH] =?UTF-8?q?personas:=20sharing=20v1=20=E2=80=94=20export/i?= =?UTF-8?q?mport=20bundles,=20version=20+=20consent=20(OPE-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../builtin/cloud-posture/manifest.md | 1 + .../personas/builtin/dep-audit/manifest.md | 1 + .../personas/builtin/security/manifest.md | 1 + coworker/personas/loading.py | 20 ++ coworker/personas/manifest.py | 5 + coworker/personas/registry.py | 107 +++++++++- coworker/server/app.py | 20 +- surfaces/gui/e2e/fixtures.ts | 32 +++ surfaces/gui/e2e/sharing.spec.ts | 68 ++++++ surfaces/gui/src/App.tsx | 8 + surfaces/gui/src/api.ts | 20 +- surfaces/gui/src/components/PersonasTab.tsx | 196 +++++++++++++++--- .../gui/src/components/SessionSetupRow.tsx | 13 ++ tests/test_sharing_v1.py | 142 +++++++++++++ 14 files changed, 600 insertions(+), 34 deletions(-) create mode 100644 surfaces/gui/e2e/sharing.spec.ts create mode 100644 tests/test_sharing_v1.py diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index 5e941363..552b363e 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -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] diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index e3cc56c7..13c51520 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -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] diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 55c5c906..907670d2 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -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] diff --git a/coworker/personas/loading.py b/coworker/personas/loading.py index 8c55fc9d..25412d4e 100644 --- a/coworker/personas/loading.py +++ b/coworker/personas/loading.py @@ -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 diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 58730591..4715fee9 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -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, diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 617ceb9b..1c19dc8a 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -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).""" diff --git a/coworker/server/app.py b/coworker/server/app.py index 80dd95bc..1eca5ef6 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -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 diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 6c44654a..2651f40b 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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 diff --git a/surfaces/gui/e2e/sharing.spec.ts b/surfaces/gui/e2e/sharing.spec.ts new file mode 100644 index 00000000..85d07d8f --- /dev/null +++ b/surfaces/gui/e2e/sharing.spec.ts @@ -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(); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index fe139ae8..40662266 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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, + ); + }} /> )} { + 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", diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index 1990bbb6..5d7bd917 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -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([]); - 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(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(null); const [sessions, setSessions] = useState([]); + // The picker's "Import coworker…" door lands here and asks us to put the Add section + // front and center (sharing v1). + const addRef = useRef(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>) => { + 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) => )} + {!p.builtin && ( + + )} {!p.builtin && (confirmDel === p.id ? ( @@ -205,50 +255,138 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => ))} -
Add coworkers
+
Add coworkers

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.

- setMode(e.target.value as "git" | "dir" | "zip")} + > + - setSrc(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && install()} - /> - + {mode === "zip" ? ( + + ) : ( + <> + setSrc(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && install()} + /> + + + )}
{msg &&
{msg}
} {consent && consent.length > 0 && ( -
+
+ {/* 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. */} +
+ + + Only enable coworkers from someone you trust. Nothing here runs third-party + code — but its instructions will guide the coworker's behavior. + +
{consent.map((c) => ( -
-
{c.name}
-
{c.description}
-
Tools: {c.tools.join(", ") || "—"}
-
- Risk: {c.risk.join(", ") || "read"} - {c.connectors ? " · connectors" : ""} - {c.messaging ? " · messaging" : ""} - {c.mcp.length ? ` · mcp: ${c.mcp.join(", ")}` : ""} -
-
- Recommended mode: {c.recommended_mode}. Enable it above to use it. -
-
+ ))}
)}
); } + +// 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 = { + 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 ( +
+
+ {c.name} + {c.version && v{c.version}} +
+ {c.description &&
{c.description}
} + {c.replaces && ( +
+ 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."} +
+ )} +
+ Can {summary} + {c.connectors ? " · use your connected services" : ""} + {c.messaging ? " · send messages" : ""} + {c.mcp.length ? ` · use MCP: ${c.mcp.join(", ")}` : ""} + +
+ {showTools && ( +
{c.tools.join(" · ") || "—"}
+ )} + {recommends.length > 0 && ( +
+ {recommends.map((r) => ( +
+ {r.ref} + {r.tier === "core" ? " (recommended)" : " (optional)"} — {r.reason} +
+ ))} +
+ )} +
+ Recommended mode: {c.recommended_mode}. Enable it above to use it. +
+
+ ); +} diff --git a/surfaces/gui/src/components/SessionSetupRow.tsx b/surfaces/gui/src/components/SessionSetupRow.tsx index 5bad4856..bb1e37f4 100644 --- a/surfaces/gui/src/components/SessionSetupRow.tsx +++ b/surfaces/gui/src/components/SessionSetupRow.tsx @@ -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) { ))}
+