mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 15:20:19 +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
|
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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
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
@@ -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",
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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