mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +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:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user