From c0ce70f385153bb953a29cec9cef04e13c22b606 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:51:48 +0000 Subject: [PATCH 1/8] Admins work on a shared workspace's skills in Knowledge, Skills, with the owner's Mac off An Admin of a workspace shared with them now sees its skills in Knowledge, Skills, as Orgo keeps them, and works on them with their own Orgo key: publish a draft, import a skill folder as a new draft, update one from a folder (published once they say so), roll back, delete, give a skill that names its bots to one more or one fewer, and approve or take back a script. None of it waits for the owner's Mac. Labels, Who has what and a bot's proposals stay the owner's. A Member doesn't see Skills there, and is refused before Orgo is asked. The other kinds of Knowledge keep the owner's line, with Open Skills under it for an Admin. The skills tests now count Bops' own app skills from the list itself, so they pass again. --- app/api/skills/route.ts | 9 +- components/app/memory-categories.tsx | 18 +- components/app/skills.tsx | 164 +++++++-- components/app/view-only.ts | 7 +- lib/server/shared-skills.ts | 281 +++++++++++++++ lib/server/shared.ts | 18 + lib/server/workspace-skills.ts | 32 +- lib/shared.ts | 7 + scripts/test-shared-skills.mjs | 496 +++++++++++++++++++++++++++ scripts/test-shared.mjs | 5 +- scripts/test-skills-ui-shared.mjs | 285 +++++++++++++++ scripts/test-skills.mjs | 8 +- 12 files changed, 1279 insertions(+), 51 deletions(-) create mode 100644 lib/server/shared-skills.ts create mode 100644 scripts/test-shared-skills.mjs create mode 100644 scripts/test-skills-ui-shared.mjs diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index 57882e490..852d44300 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -2,6 +2,7 @@ import { chatThreads, draftSkill, saveSkillDraft } from "@/lib/server/procedures import { notReady } from "@/lib/server/ready"; import { bot, chat, getState } from "@/lib/server/store"; import { sharedOpen } from "@/lib/server/shared"; +import { sharedSkillAct, sharedSkillsList } from "@/lib/server/shared-skills"; import { acceptProposal, proposalDiff, rejectProposal } from "@/lib/server/skill-proposals"; import { fromBopsWindow } from "@/lib/server/ui-token"; import { @@ -40,7 +41,9 @@ export const dynamic = "force-dynamic"; * (ui-token.ts): a page a bot's Chrome reached this server with mustn't teach a bot steps or approve a script. * Which labels each bot has (Who has what), a label's name and about line, Suggest labels with AI, and a bot's proposed change * (skill-proposals.ts: accept, reject, or its diff on what the skill is now) are the owner's too: never while a - * workspace shared with the user is open. + * workspace shared with the user is open. While one is open, every call here is about that workspace's skills alone, + * in orgo-web with the user's own Orgo key, for an Admin there (lib/server/shared-skills.ts), never this Mac's folders + * or the user's own workspace of the id a request names. */ const notFromWindow = (request: Request) => (fromBopsWindow(request) ? null : Response.json({ error: "Skills can only be changed in the Bops app." }, { status: 403 })); @@ -62,6 +65,7 @@ function fromOf(v: unknown): SkillRef["from"] { export async function GET(request: Request) { const unready = notReady() ?? notFromWindow(request); if (unready) return unready; + if (sharedOpen()) return sharedSkillsList().then((r) => Response.json(r), failed); const url = new URL(request.url); const wsId = url.searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE; if (!workspaceById(wsId)) return Response.json({ error: "That workspace is gone." }, { status: 404 }); @@ -75,11 +79,10 @@ export async function POST(request: Request) { const unready = notReady() ?? notFromWindow(request); if (unready) return unready; const b = (await request.json().catch(() => ({}))) as Record; + if (sharedOpen()) return sharedSkillAct(b).then((r) => Response.json(r), failed); const wsId = str(b.ws) ?? getState().workspace ?? MAIN_WORKSPACE; const kind = kindOf(b.kind); const slug = str(b.slug) ?? ""; - const ownerOnly = new Set(["bot-label", "bot-labels", "label-rename", "label-about", "label-suggest", "proposal"]); - if (ownerOnly.has(String(b.action)) && sharedOpen()) return Response.json({ error: "In a shared workspace, only its owner changes this." }, { status: 403 }); try { switch (b.action) { case "detail": diff --git a/components/app/memory-categories.tsx b/components/app/memory-categories.tsx index 0fe37d758..2affdbff2 100644 --- a/components/app/memory-categories.tsx +++ b/components/app/memory-categories.tsx @@ -7,6 +7,7 @@ import { Mascot, Spinner } from "./mascot"; import { IssuesIcon, IssuesPage, isOpen, issuesSoon, useIssues } from "./orbit-issues"; import { SkillsPage, skillsIn } from "./skills"; import type { AppState } from "@/lib/types"; +import { sharedSkillsWho } from "@/lib/shared"; import { isFavoriteKind, type FavoriteKind } from "@/lib/favorites"; import { menuPoint, PinIcon, SideMenu, useFavorites, type Favorites } from "./favorites"; import { Fold } from "./fold"; @@ -747,6 +748,8 @@ function KnowledgeView({ }) { // A workspace shared with the user: its Knowledge is kept on the owner's Bops, so none is read here (view-only.ts would refuse it). const owners = state?.shared ? `Knowledge in ${state.shared.name} is kept on the owner's Bops, so it doesn't show here. To find something out, ask a bot in its chat.` : undefined; + // Its skills, for an Admin there: kept in Orgo, so they show and change here with the owner's Mac off (skills.tsx, shared-skills.ts). + const sharedSkills = !!state?.shared && sharedSkillsWho(state.shared); const { snap, act, failed, retry } = useMemoryCategories(ws, user, !owners); const { list } = useIssues(ws, user, !owners); const forgetting = useForget(ws, user, !owners); @@ -777,7 +780,8 @@ function KnowledgeView({ .catch(() => ({ error: "Couldn't reach Bops." }) as Answer & { error?: string }); return r.error ?? r; }, [ws]); - if (owners) return ; + if (owners && sharedSkills && category === "skills" && state) return ; + if (owners) return onCategory("skills") : undefined} act={act} forgetting={forgetting} category="all" onCategory={onCategory} onClose={onClose} />; if (category === "issues") return ( void; /** A workspace shared with the user: what the page says in place of its Knowledge, which is the owner's (no kinds, nothing to add or ask). */ owners?: string; + /** With `owners`: its skills show here for this viewer (an Admin there), opened from under the line. */ + onSkills?: () => void; act: MemoryAct; ask?: (question: string) => Promise; /** Forget, with Undo (useForget). */ @@ -1128,7 +1135,14 @@ export function MemoryPage({ const listView = (
{owners ? ( -

{owners}

+
+

{owners}

+ {onSkills && ( + + )} +
) : !snap && failed ? (
{failed} diff --git a/components/app/skills.tsx b/components/app/skills.tsx index 93c21e226..696d04d5b 100644 --- a/components/app/skills.tsx +++ b/components/app/skills.tsx @@ -24,11 +24,20 @@ import { CloseButton, currentWorkspace, post, teamOf } from "./ui"; * it's for (the line its bots' prompts show). Suggest labels with AI (the owner's) turns off a small model's pick * of a label for a new skill. The lists are the app's state (Workspace.skills, Workspace.apps, * Workspace.skillLabelInfo, the bots), so they're as current as everything else. + * + * In a workspace shared with the user, for an Admin there (lib/shared.ts sharedSkillsWho), the page is that workspace's + * skills as Orgo keeps them (GET /api/skills: lib/server/shared-skills.ts, with the user's own Orgo key), read again after + * each change: Publish on a draft, Import a skill folder (a new draft), Update from folder (its next version, published + * once they say so), Roll back, Delete, a skill's own bots, and its scripts to approve. Its labels, Who has what and + * Suggest labels stay the owner's, and nothing of this Mac's folders shows (no Open in Finder). */ type Problem = { path: string; reason: string; line?: number }; type FileInfo = { path: string; size: number; sha256: string; executable: boolean; script: boolean; approved: boolean; binary?: boolean }; -type Detail = { ref: SkillRef; body: string; frontmatter: Record; files: FileInfo[]; dir: string | null; missing: boolean; changed: boolean }; +type Detail = { ref: SkillRef; body: string; frontmatter: Record; files: FileInfo[]; dir: string | null; missing: boolean; changed: boolean; shared?: true }; +/** What an import or an Update from folder did, said over the page. `published`: in a shared workspace, it's in use at once. */ +type ImportResult = { name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean; published?: boolean }; +type Lists = { skills: SkillRef[]; apps: SkillRef[] }; /** The Mac app's bridge for skills (desktop/preload.cjs): a folder picked in Finder, and a folder shown there. */ type Bridge = { pickFolder?: () => Promise; revealFolder?: (dir: string) => Promise }; @@ -167,7 +176,28 @@ async function call(body: Record): Promise<{ ok: true; json: export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { state: AppState; only?: SkillKind; focus?: string; focusAt?: number; nav?: ReactNode; onClose: () => void }) { const ws = currentWorkspace(state); const team = teamOf(state); - const { skills, apps } = skillsIn(state, ws); + // A workspace shared with the user: its skills as Orgo keeps them, read here (never the state's: the owner's are on their Mac). + const shared = state.shared; + const [remote, setRemote] = useState<{ ws: string; lists: Lists | null; error?: string } | null>(null); + const [reads, setReads] = useState(0); + const reload = useCallback(() => setReads((n) => n + 1), []); + useEffect(() => { + if (!shared) return; + let gone = false; + void fetch("/api/skills", { cache: "no-store" }) + .then(async (r) => ({ ok: r.ok, json: (await r.json().catch(() => ({}))) as Partial & { error?: string } })) + .then(({ ok, json }) => { + if (gone) return; + if (ok) setRemote({ ws, lists: { skills: json.skills ?? [], apps: json.apps ?? [] } }); + else setRemote((was) => ({ ws, lists: was?.ws === ws ? was.lists : null, error: json.error ?? "Couldn't read its skills. Try again." })); + }) + .catch(() => !gone && setRemote((was) => ({ ws, lists: was?.ws === ws ? was.lists : null, error: "Couldn't reach Bops. Try again." }))); + return () => { + gone = true; + }; + }, [shared, ws, reads]); + const here = shared ? (remote?.ws === ws ? remote.lists : null) : null; + const { skills, apps } = shared ? (here ?? { skills: [], apps: [] }) : skillsIn(state, ws); const [open, setOpen] = useState(focus ?? null); const [asked, setAsked] = useState({ focus, focusAt }); if (asked.focus !== focus || asked.focusAt !== focusAt) { @@ -175,10 +205,11 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat if (focus) setOpen(focus); } const [importing, setImporting] = useState(null); - const [imported, setImported] = useState<{ name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean } | null>(null); + const [imported, setImported] = useState(null); const [orgo, setOrgo] = useState(false); // Opened: kept in step with Orgo, where the workspace has its own and Orgo has the routes. useEffect(() => { + if (shared) return; let gone = false; void fetch(`/api/skills?sync=1&ws=${encodeURIComponent(ws)}`, { cache: "no-store" }) .then((r) => r.json()) @@ -187,7 +218,7 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat return () => { gone = true; }; - }, [ws]); + }, [ws, shared]); const picker = bridge()?.pickFolder; const importFolder = async (kind: SkillKind) => { @@ -200,10 +231,13 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat if (r.ok) { setImported({ name: r.json.ref.name, skipped: r.json.skipped, warnings: r.json.warnings }); setOpen(`${kind}:${r.json.ref.slug}`); + reload(); } else setImported({ error: r.error, skipped: r.problems ?? [], warnings: [] }); }; const ref = open ? [...skills, ...apps].find((r) => `${r.kind}:${r.slug}` === open) : undefined; + // Apps aren't offered in a shared workspace (Bops' apps are the owner's there). + const importKinds = (only ? [only] : (["skill", "app"] as SkillKind[])).filter((k) => !shared || k === "skill"); const info = state.workspaces?.find((w) => w.id === ws)?.skillLabelInfo ?? []; // Bops' own Apps and source control skills only where apps are shown (lib/apps-access.ts): never in a shared workspace. const appsHere = appsOwnShown(state); @@ -236,7 +270,7 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat
{only === "app" ? "Apps" : "Skills"} - {about} {orgo ? "Kept in step with Orgo." : "Kept on this Mac."} + {about} {shared ? `Kept in Orgo for everyone in ${shared.name}.` : orgo ? "Kept in step with Orgo." : "Kept on this Mac."}
@@ -244,16 +278,30 @@ export function SkillsPage({ state, only, focus, focusAt, nav, onClose }: { stat {!ref && nav} {imported && setImported(null)} />} + {shared && remote?.error && ( + + {remote.error} + + + )} + {shared && !here && !remote?.error && ( + + + Reading its skills + + )} {ref ? ( - setOpen(null)} onUpdated={setImported} /> + setOpen(null)} onUpdated={setImported} onChanged={reload} /> ) : ours ? ( setOpen(null)} /> ) : ( <> - {picker && ( + {picker && importKinds.length > 0 && (
- {kinds.map((k) => ( + {importKinds.map((k) => ( @@ -561,6 +611,7 @@ function SkillView({ labels, onBack, onUpdated, + onChanged, }: { state: AppState; ws: string; @@ -569,14 +620,19 @@ function SkillView({ labels: string[]; onBack: () => void; /** What Update from folder left out, said over the page as an import's is. */ - onUpdated: (result: { name?: string; skipped: Problem[]; warnings: Problem[]; error?: string; updated?: boolean }) => void; + onUpdated: (result: ImportResult) => void; + /** Something changed it: the page's list is read again (a shared workspace's, which isn't in the state). */ + onChanged?: () => void; }) { + const shared = !!state.shared; const [detail, setDetail] = useState(null); const [version, setVersion] = useState(undefined); const [error, setError] = useState(null); const [problems, setProblems] = useState([]); const [busy, setBusy] = useState(null); const [sure, setSure] = useState(false); + // In a shared workspace, a folder picked for Update from folder, waiting for the user to say publish it. + const [folder, setFolder] = useState(null); // Read again after a change here, and whenever the state says it changed (a publish, a roll back, a sync from Orgo, an approval). const [tick, setTick] = useState(0); const read = useCallback(async () => setTick((n) => n + 1), []); @@ -613,14 +669,18 @@ function SkillView({ return false; } await read(); + onChanged?.(); return true; }; const reveal = bridge()?.revealFolder; const picker = bridge()?.pickFolder; // Update from folder: a folder picked in Finder is its next version (a draft to publish), checked as an import is. - const fromFolder = async () => { - const path = await picker?.().catch(() => null); + const fromFolder = async (picked?: string) => { + const path = picked ?? (await picker?.().catch(() => null)); if (!path) return; + // In a shared workspace the folder is published at once: the user says so first. + if (shared && !picked) return setFolder(path); + setFolder(null); setBusy("folder"); setError(null); setProblems([]); @@ -631,11 +691,14 @@ function SkillView({ setProblems(res.problems ?? []); return; } - onUpdated({ name: res.json.ref.name, skipped: res.json.skipped, warnings: res.json.warnings, updated: true }); + onUpdated({ name: res.json.ref.name, skipped: res.json.skipped, warnings: res.json.warnings, updated: true, published: shared }); await read(); + onChanged?.(); }; - const latest = r.versions?.at(-1)?.version; - const canPublish = !version && (r.status === "draft" || !!detail?.changed); + // A shared workspace's versions are Orgo's (its detail says them); Publish there is a draft's alone. + const versions = shared ? (detail?.ref.versions ?? []) : (r.versions ?? []); + const latest = versions.at(-1)?.version; + const canPublish = !version && (shared ? r.status === "draft" : r.status === "draft" || !!detail?.changed); const word = r.kind === "app" ? "app" : "skill"; return ( @@ -647,7 +710,12 @@ function SkillView({ {r.name} {r.status === "draft" ? Draft : v{r.version}} - {r.kind === "skill" && sameLabel(l, cleanLabel(r.label))) ?? cleanLabel(r.label)} labels={labels} busy={!!busy} auto={suggestedLabel(r)} onPick={(l) => void act("label", labelEdit(l))} />} + {r.kind === "skill" && + (shared ? ( + {cleanLabel(r.label)} + ) : ( + sameLabel(l, cleanLabel(r.label))) ?? cleanLabel(r.label)} labels={labels} busy={!!busy} auto={suggestedLabel(r)} onPick={(l) => void act("label", labelEdit(l))} /> + ))} {r.risk && {RISK[r.risk] ?? r.risk}} {r.runsOn && Runs {r.runsOn === "local" ? "on its computer" : `on ${r.runsOn}`}} @@ -659,16 +727,16 @@ function SkillView({
{canPublish && ( )} - {reveal && detail?.dir && !detail.missing && ( + {!shared && reveal && detail?.dir && !detail.missing && ( )} {picker && !version && ( - @@ -677,9 +745,24 @@ function SkillView({ Delete
+ {folder && ( +
+ + Publish the folder {folder.split("/").filter(Boolean).pop()} as its next version? Its bots use it at once. + + + +
+ )} {sure && (
- Delete this {word}, its folder and every version? Its bots stop using it. + + {shared ? `Delete this ${word} and every version, for everyone here? Its bots stop using it.` : `Delete this ${word}, its folder and every version? Its bots stop using it.`} + @@ -699,11 +782,16 @@ function SkillView({
)} - {team.length > 0 && ( + {team.length > 0 && shared && !r.botIds.length && ( + + Every bot with the label {cleanLabel(r.label)} has it. The owner picks which bots have a label. + + )} + {team.length > 0 && (!shared || r.botIds.length > 0) && (
{team.map((b) => { - const on = botHasSkill(r, b); + const on = shared ? r.botIds.includes(b.id) : botHasSkill(r, b); return ( - {!current && ( + {!current && (!shared || r.status === "published") && ( @@ -756,7 +844,7 @@ function SkillView({ ) ) : detail.missing ? ( - Its folder isn't on this Mac. + {shared ? "Orgo has no version of it yet." : "Its folder isn't on this Mac."} ) : ( <> {detail.files.length > 0 && ( @@ -791,22 +879,24 @@ function FileRow({ ws, skill: r, file: f, version, last, onApproved }: { ws: str }; const approve = async (on: boolean) => { setBusy(true); - const res = await call({ action: "approve", ws, sha256: f.sha256, on }); + const res = await call({ action: "approve", ws, sha256: f.sha256, path: f.path, on }); setBusy(false); if (!res.ok) return setError(res.error); setText(null); await onApproved(); }; const size = f.size < 1024 ? `${f.size} B` : f.size < 1024 * 1024 ? `${Math.round(f.size / 1024)} KB` : `${(f.size / 1024 / 1024).toFixed(1)} MB`; + // Read, its first line can make it a script (skill-bundle.ts isScript), which a shared workspace's list can't say. + const script = f.script || !!text?.startsWith("#!"); return (
{f.path} {f.binary ? `Binary, ${size}` : size} - {f.script && (f.approved ? Approved : Needs approval)} - {(!f.binary || f.script) && ( + {script && (f.approved ? Approved : Needs approval)} + {(!f.binary || script) && ( )}
@@ -817,7 +907,7 @@ function FileRow({ ws, skill: r, file: f, version, last, onApproved }: { ws: str ) : (
{text}
)} - {f.script && ( + {script && ( sha256 {f.sha256.slice(0, 16)} diff --git a/components/app/view-only.ts b/components/app/view-only.ts index 0f932e4f1..98f85789a 100644 --- a/components/app/view-only.ts +++ b/components/app/view-only.ts @@ -12,7 +12,7 @@ import { changeRouteOf } from "@/cloud/change-routes"; * or switches the workspace, signing in and out, notices, usage events, what's on screen and what's the * user's own (their vault, account and name) go through. Nor do the reads the server answers from the * user's own bots or workspaces by id (a shared bot's id is often one of theirs, "boppy"): their memory, - * Knowledge's issues, skills, files, screens. The buttons that would change something are hidden or say so too; this is what + * Knowledge's issues, files, screens. The buttons that would change something are hidden or say so too; this is what * holds whatever's missed. A screen's stream and its pictures (an EventSource, an image) never pass through fetch: what * opens them asks readAllowed first. */ @@ -43,13 +43,16 @@ const ALLOWED = [ // on the owner's computer Bops Cloud names for that bot (lib/server/shared.ts sharedFileTarget), for an Admin, with the // user's own Orgo key; never on the user's own bot of that id. (attachments.tsx sendFile asks sendRefused: XMLHttpRequest.) /^\/api\/bots\/files(\?|$)/, + // Skills (skills.tsx, in Knowledge): while a shared workspace is open, every skills route acts on that workspace's skills + // alone, in orgo-web with the user's own Orgo key, for an Admin there (lib/server/shared-skills.ts, which refuses anyone + // else before Orgo is asked); never this Mac's folders or the user's own workspace of that id. + /^\/api\/skills(\/|\?|$)/, ]; /** The reads that don't go while it's open: answered from the user's own bot or workspace of that id, never the shared one's. */ const OWN_READS = [ /^\/api\/memory(\/|\?|$)/, /^\/api\/orbit(\/|\?|$)/, - /^\/api\/skills(\/|\?|$)/, /^\/api\/routines\/suggestions(\/|\?|$)/, // A routine's runs, by id: the user's own routine of that id, never the owner's (its reports read the shared copy only). /^\/api\/routines\/[^/]+\/runs(\/|\?|$)/, diff --git a/lib/server/shared-skills.ts b/lib/server/shared-skills.ts new file mode 100644 index 000000000..60f923f51 --- /dev/null +++ b/lib/server/shared-skills.ts @@ -0,0 +1,281 @@ +import "server-only"; +import { sharedSkillsWho } from "@/lib/shared"; +import { keptLabel } from "@/lib/skill-labels"; +import type { SkillKind, SkillRef, SkillRisk } from "@/lib/types"; +import { orgoHeaders } from "./app-version"; +import { apiKey } from "./orgo"; +import { orgoOrigin } from "./orgo-auth"; +import { sharedSkillsOpen } from "./shared"; +import { RISKS, type Problem } from "./skill-bundle"; +import { folderPayload, localKind, remoteType, SkillError } from "./workspace-skills"; + +/* + * Skills in a workspace shared with the user (Knowledge, Skills), for an Admin there (lib/shared.ts sharedSkillsWho: a + * Member with Memory and skills next). Its skills are kept in Orgo (orgo-web's /api/projects//skills routes), the copy + * everyone reads while the owner's Mac is off, and orgo-web lets the owner and an Admin list, read, publish, roll back, + * delete and approve with their own Orgo key, so none of it waits for the owner's Mac: this Bops calls orgo-web with the + * user's own key, as Keys does (shared-keys.ts), and the owner's Mac brings the change in on its next sync + * (workspace-skills.ts syncSkills, every few minutes). Never a change act, never the user's own workspaces: `ws` in a + * request is ignored, and a skill is found by its slug in the shared workspace's own list. + * + * What an Admin does here: list and read every skill (its SKILL.md, its files, its versions), publish a draft (orgo-web's + * PATCH status), import a folder from this Mac as a new draft, update one from a folder (its next version, published at + * once, after they said so), roll back, delete, give a skill that names its bots to one more or one fewer, and approve or + * take back a script (orgo-web takes an approval only from a person: this Bops's sign in with Orgo is theirs). Labels, + * which bots have which label (Who has what) and a bot's proposed changes stay the owner's, in their own window. Bodies + * and files are never logged here. + */ + +/** The line for what stays the owner's in a shared workspace (labels, Who has what, a bot's proposals). */ +export const SHARED_OWNER_ONLY = "In a shared workspace, only its owner changes this."; +/** What a Member without the power reads (the Knowledge page shows them nothing to press). */ +export const SHARED_SKILLS_WHO = "Only the owner or an Admin can change this workspace's skills."; +/** What the page says on an orgo-web without the skills routes (a 404 that isn't JSON). */ +export const SKILLS_NOT_ON_ORGO = "Orgo can't keep this workspace's skills yet."; +const NOT_SIGNED_IN = "Sign in to Orgo to work on this workspace's skills."; +/** A skill that names no bots goes to every bot with its label, and which bot has which label is the owner's to say. */ +export const BY_LABEL = "It goes to every bot with its label, and only the owner picks which bots have a label. Ask them, or give it its own bots in their Skills."; + +/** orgo-web's refusals by their code, in the window's words (its own words for any other). */ +const SAID: Record = { + workspace_read_only: { status: 403, words: SHARED_SKILLS_WHO }, + session_required: { status: 403, words: "Orgo takes an approval only from you, signed in to Bops with Orgo. Sign out, then sign in again." }, + secret_found: { status: 422, words: "That looks like a secret. Keep keys in Logins, not in a skill." }, + invalid_bundle: { status: 400, words: "Some of its files can't be kept. Fix the ones listed and try again." }, + unchanged: { status: 409, words: "Nothing changed since the version in use." }, + version_exists: { status: 409, words: "That version is published already." }, + stale_skill: { status: 409, words: "Someone changed it just now. Look at it again, then try again." }, + skill_limit: { status: 409, words: "This workspace holds 500 skills at most. Delete one to add another." }, + storage_quota: { status: 413, words: "That's more than the storage the owner's plan includes." }, + too_large: { status: 413, words: "That's too large to send to Orgo." }, + unknown_sha: { status: 404, words: "Orgo doesn't have that script yet. Try again in a minute." }, + version_not_found: { status: 404, words: "That version isn't there anymore." }, + busy: { status: 503, words: "Orgo is busy. Try again in a minute." }, +}; + +type Json = Record; + +/** + * One call to the shared workspace's skills routes on orgo-web, with the user's own Orgo key. `skill`: the call is about + * one skill, so a 404 says the skill is gone rather than the workspace. Never logs what it sent or got. + */ +async function orgoSkills(projectId: string, path: string, init: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: unknown; skill?: boolean; timeoutMs?: number } = {}): Promise { + let key: string; + try { + key = await apiKey(); + } catch { + throw new SkillError(NOT_SIGNED_IN, 401); + } + let res: Response; + try { + res = await fetch(`${orgoOrigin()}/api/projects/${encodeURIComponent(projectId)}/skills${path}`, { + method: init.method ?? "GET", + headers: { ...orgoHeaders(), Authorization: `Bearer ${key}`, Accept: "application/json", ...(init.body === undefined ? {} : { "Content-Type": "application/json" }) }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + cache: "no-store", + signal: AbortSignal.timeout(init.timeoutMs ?? 20_000), + }); + } catch { + throw new SkillError("Bops couldn't reach Orgo. Check the internet and try again.", 502); + } + const json = (await res.json().catch(() => null)) as (T & { error?: unknown; code?: unknown; problems?: unknown }) | null; + if (res.ok && json) return json; + if (res.status === 401) throw new SkillError(NOT_SIGNED_IN, 401); + const problems = Array.isArray(json?.problems) ? (json.problems as Problem[]).filter((p) => p && typeof p.path === "string" && typeof p.reason === "string").slice(0, 100) : undefined; + const said = typeof json?.code === "string" && Object.hasOwn(SAID, json.code) ? SAID[json.code] : undefined; + if (said) throw new SkillError(said.words, said.status, problems); + // No such route (an orgo-web before it answers a page, not JSON); or the skill, or the user's place in the workspace, gone. + if (res.status === 404) { + if (!json) throw new SkillError(SKILLS_NOT_ON_ORGO, 503); + throw new SkillError(init.skill ? "That skill isn't there anymore." : "This workspace isn't shared with you anymore.", 404); + } + throw new SkillError(json && typeof json.error === "string" && json.error ? json.error : `Orgo answered ${res.status}.`, res.ok ? 502 : res.status, problems); +} + +/** The shared workspace open now, when the user may work on its skills there; else why not (none open: 409; not theirs: 403). */ +function scope() { + const open = sharedSkillsOpen(); + if (!open) throw new SkillError("Open the workspace again.", 409); + if (!sharedSkillsWho(open)) throw new SkillError(SHARED_SKILLS_WHO, 403); + return open; +} + +/** A skill as orgo-web's list says it (GET …/skills): `current_version.label` is what its SKILL.md says of its label. */ +type RemoteSkill = { + id: string; + slug: string; + name: string; + description: string | null; + type: string; + status: string; + bot_ids?: string[]; + label?: string | null; + created_at?: string; + updated_at: string; + current_version?: { version?: string; risk?: string | null; label?: string } | null; +}; + +/** A skill of orgo-web's as the page lists one (SkillRef): published at its current version, else a draft. */ +function refOf(x: RemoteSkill): SkillRef { + const published = x.status === "published"; + // A label column of orgo-web's when it keeps one, else the one its current version's SKILL.md gives it. + const label = keptLabel(typeof x.label === "string" ? x.label : x.current_version?.label); + const risk = x.current_version?.risk; + return { + slug: x.slug, + kind: localKind(x.type), + name: x.name, + description: x.description ?? "", + ...(label ? { label } : {}), + labelBy: "skill", + status: published ? "published" : "draft", + ...(published && x.current_version?.version ? { version: x.current_version.version } : {}), + ...(risk && (RISKS as readonly string[]).includes(risk) ? { risk: risk as SkillRisk } : {}), + botIds: Array.isArray(x.bot_ids) ? x.bot_ids.filter((id): id is string => typeof id === "string") : [], + source: "orgo", + createdAt: Date.parse(x.created_at ?? "") || 0, + updatedAt: Date.parse(x.updated_at) || 0, + remoteId: x.id, + remoteAt: x.updated_at, + }; +} + +async function listed(projectId: string): Promise { + const got = await orgoSkills<{ skills?: unknown }>(projectId, ""); + return Array.isArray(got.skills) ? (got.skills.filter((x) => !!x && typeof x === "object" && typeof (x as RemoteSkill).id === "string" && typeof (x as RemoteSkill).slug === "string") as RemoteSkill[]) : []; +} + +/** + * GET /api/skills in a shared workspace: its skills and apps as orgo-web lists them now (newest first, as the page shows + * them), always kept in Orgo. Nothing of the user's own workspaces. + */ +export async function sharedSkillsList() { + const open = scope(); + const refs = (await listed(open.projectId)).map(refOf).sort((a, b) => b.updatedAt - a.updatedAt); + return { skills: refs.filter((r) => r.kind === "skill"), apps: refs.filter((r) => r.kind === "app"), labels: [] as string[], labelInfo: [], sync: null, orgo: true, shared: true }; +} + +/** One of the shared workspace's skills by its kind and slug, as orgo-web lists it now. */ +async function findRemote(projectId: string, kind: SkillKind, slug: string): Promise { + const x = slug ? (await listed(projectId)).find((s) => s.slug === slug && localKind(s.type) === kind) : undefined; + if (!x) throw new SkillError(`That ${kind} isn't there anymore.`, 404); + return x; +} + +/** A file orgo-web lists for a version (GET …/skills/{id}). */ +type RemoteFile = { path: string; sha256: string; size_bytes: number; executable: boolean; media_type?: string; approved?: boolean }; + +const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|x-yaml|toml|javascript|x-sh|x-python|x-shellscript|sql|graphql))/i; +/** Script kinds by name: what Bops counts as a script (skill-bundle.ts isScript) without its first line to read. */ +const SCRIPT_NAMES = /\.(sh|bash|zsh|fish|py|rb|pl|php|js|mjs|cjs|ts|ps1|command)$/i; +const isTextFile = (f: RemoteFile) => TEXT_TYPES.test(f.media_type ?? "text/plain"); + +/** + * Bops' isScript (executable, under scripts/, or a #! line) as far as orgo-web's list says: its bit, its folder, and a + * script's kind of name or type. One whose first line alone makes it a script shows when it's read (the window looks). + */ +const scriptish = (f: RemoteFile) => f.executable || f.path.startsWith("scripts/") || SCRIPT_NAMES.test(f.path) || /x-(sh|python|shellscript)/i.test(f.media_type ?? ""); + +const str = (v: unknown) => (typeof v === "string" ? v : undefined); +const versionQuery = (v: string | undefined) => (v ? `?version=${encodeURIComponent(v)}` : ""); + +/** + * POST /api/skills in a shared workspace (`{action, kind, slug, ...}`, its `ws` ignored): what the user does to one of its + * skills, done in orgo-web with their own key. Anything else answers SHARED_OWNER_ONLY. + */ +export async function sharedSkillAct(b: Json): Promise { + const open = scope(); + const pid = open.projectId; + const kind: SkillKind = b.kind === "app" ? "app" : "skill"; + const slug = str(b.slug) ?? ""; + switch (b.action) { + case "detail": { + const x = await findRemote(pid, kind, slug); + const version = str(b.version); + const got = await orgoSkills<{ version?: { version?: string; body?: string; frontmatter?: Json; files?: RemoteFile[] } | null; versions?: unknown }>(pid, `/${encodeURIComponent(x.id)}${versionQuery(version)}`, { skill: true }); + const v = got.version ?? null; + const versions = Array.isArray(got.versions) ? got.versions.filter((y): y is string => typeof y === "string") : []; + const ref = refOf(x); + return { + ref: { ...ref, versions: versions.map((y) => ({ version: y, at: 0, sha256: "" })) }, + body: v?.body ?? "", + frontmatter: v?.frontmatter ?? {}, + files: (v?.files ?? []).map((f) => ({ path: f.path, size: f.size_bytes, sha256: f.sha256, executable: f.executable, script: scriptish(f), approved: !!f.approved, binary: !isTextFile(f) })), + dir: null, + missing: !v, + changed: false, + shared: true, + }; + } + case "file": { + const x = await findRemote(pid, kind, slug); + const path = str(b.path) ?? ""; + if (!path || path.split("/").some((p) => p === ".." || p === "")) throw new SkillError("That file isn't in it.", 404); + const f = await orgoSkills<{ path?: string; sha256?: string; size_bytes?: number; content?: string; url?: string }>(pid, `/${encodeURIComponent(x.id)}/files/${path.split("/").map(encodeURIComponent).join("/")}${versionQuery(str(b.version))}`, { skill: true }); + // Text up to 1 MB comes inline; a binary or larger file only as a link, which isn't shown here. + if (typeof f.content === "string") return { path: f.path ?? path, content: f.content, sha256: f.sha256 ?? "" }; + return { path: f.path ?? path, content: null, sha256: f.sha256 ?? "", binary: true, size: f.size_bytes ?? 0 }; + } + case "publish": { + const x = await findRemote(pid, kind, slug); + if (x.status === "published") throw new SkillError("It's published already. To change it, update it from a folder.", 409); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}`, { method: "PATCH", body: { status: "published", if_updated_at: x.updated_at }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}), status: "published" }) }; + } + case "rollback": { + const x = await findRemote(pid, kind, slug); + const version = str(b.version) ?? ""; + if (!/^\d+\.\d+\.\d+/.test(version)) throw new SkillError("A version looks like 1.2.0."); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}/rollback`, { method: "POST", body: { version }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}) }) }; + } + case "delete": { + const x = await findRemote(pid, kind, slug); + await orgoSkills(pid, `/${encodeURIComponent(x.id)}`, { method: "DELETE", skill: true }); + return { ok: true }; + } + case "bot": { + const botId = str(b.botId); + if (!botId || typeof b.on !== "boolean") throw new SkillError("Which bot, and on or off?"); + if (!open.botIds.includes(botId)) throw new SkillError("That bot isn't in this workspace.", 404); + const x = await findRemote(pid, kind, slug); + const now = Array.isArray(x.bot_ids) ? x.bot_ids : []; + // One that names no bots follows its label (lib/skill-labels.ts botHasSkill): whose label is the owner's to say. + if (!now.length) throw new SkillError(BY_LABEL, 403); + const next = b.on ? [...new Set([...now, botId])] : now.filter((id) => id !== botId); + if (!next.length) throw new SkillError("At least one bot keeps it. Delete it instead."); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(x.id)}`, { method: "PATCH", body: { bot_ids: next, if_updated_at: x.updated_at }, skill: true }); + return { ref: refOf({ ...x, ...(got.skill ?? {}), bot_ids: got.skill?.bot_ids ?? next }) }; + } + case "approve": { + const sha256 = str(b.sha256) ?? ""; + if (!/^[0-9a-f]{64}$/.test(sha256)) throw new SkillError("That isn't a script's sha256."); + const path = str(b.path)?.slice(0, 512); + if (b.on === false) await orgoSkills(pid, `/approvals?sha256=${sha256}`, { method: "DELETE" }).catch((e: unknown) => { + // Not approved there (anymore): nothing to take back. + if (!(e instanceof SkillError && e.status === 404)) throw e; + }); + else await orgoSkills(pid, "/approvals", { method: "POST", body: { sha256, ...(path ? { path_hint: path } : {}) } }); + return { ok: true }; + } + case "import": { + const path = str(b.path); + if (!path || !path.startsWith("/")) throw new SkillError("Pick a folder."); + if (slug) { + // Update from folder: the folder is its next version, published in Orgo at once (the window asked first). + const x = await findRemote(pid, kind, slug); + const f = folderPayload(path, slug); + const got = await orgoSkills<{ skill?: RemoteSkill; version?: { version?: string } }>(pid, `/${encodeURIComponent(x.id)}/versions`, { method: "POST", body: { body: f.skillMd, files: f.files }, skill: true, timeoutMs: 120_000 }); + const version = got.version?.version ?? got.skill?.current_version?.version; + return { ref: refOf({ ...x, ...(got.skill ?? {}), status: "published", ...(version ? { current_version: { ...(x.current_version ?? {}), version } } : {}) }), skipped: f.skipped, warnings: f.warnings, drafted: f.drafted, published: true }; + } + // A new one: a draft in Orgo, to read and publish here. + const f = folderPayload(path); + const got = await orgoSkills<{ skill?: RemoteSkill }>(pid, "", { method: "POST", body: { name: f.name, description: f.description, type: remoteType(kind), body: f.skillMd, files: f.files, status: "draft", source: "import" }, timeoutMs: 120_000 }); + if (!got.skill?.id) throw new SkillError("Orgo didn't say what it saved. Look in Skills again.", 502); + return { ref: refOf(got.skill), skipped: f.skipped, warnings: f.warnings, drafted: f.drafted }; + } + default: + throw new SkillError(SHARED_OWNER_ONLY, 403); + } +} diff --git a/lib/server/shared.ts b/lib/server/shared.ts index 10a08d6cd..60ff12c1b 100644 --- a/lib/server/shared.ts +++ b/lib/server/shared.ts @@ -260,6 +260,24 @@ export function sharedKeysOpen(): { }; } +/** + * The shared workspace open now, for its skills (lib/server/shared-skills.ts): its Orgo project (never an id from a + * request), the user's role and what they may change there (SharedWsSlice.can), and its bots. Null when none is open for + * the signed-in user. + */ +export function sharedSkillsOpen(): { projectId: string; name: string; role: "admin" | "member"; can: string[]; botIds: string[] } | null { + const open = openNow(); + if (!open || (open.at.role !== "admin" && open.at.role !== "member")) return null; + const s = open.data?.state; + return { + projectId: open.at.projectId, + name: open.at.name, + role: open.at.role, + can: Array.isArray(s?.can) ? s.can.filter((x): x is string => typeof x === "string") : [], + botIds: (Array.isArray(s?.bots) ? s.bots : []).flatMap((b) => (typeof b?.id === "string" ? [b.id] : [])), + }; +} + /** * The shared workspace open now, for a change made there from this window (change-relay.ts): the id the window has for it, * its bots, what the user may change there (SharedWsSlice.can) and whether the owner's Bops takes it (SharedWsSlice.changes). Null diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index e6770a94e..56340445d 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -1558,6 +1558,32 @@ export function updateFromFolder(wsId: string, folder: string, kind: SkillKind, return { ref: findRef(wsId, kind, slug)!, skipped: checked.skipped, warnings: checked.warnings, drafted: checked.drafted, published }; } +/** A bundle's files as orgo-web takes them: text as content, a binary file (a program) as content_base64. */ +export const orgoFiles = (files: BundleFile[]) => + files.map((f) => (f.content !== null ? { path: f.path, content: f.content, executable: f.executable } : { path: f.path, content_base64: f.data!.toString("base64"), executable: f.executable })); + +/** + * A folder on this Mac, for a workspace whose skills are kept in Orgo alone here (a shared workspace's Skills: + * shared-skills.ts), read and checked as importFolder checks a new one, or with `slug` as updateFromFolder checks an + * existing skill's next version (its SKILL.md name, when it says one, that skill's), then strictly as a save is (no + * secret, the caps): its name, description, SKILL.md text and files as orgo-web takes them. Nothing is kept here. + */ +export function folderPayload(folder: string, slug?: string) { + const checked = readFolder(folder); + const said = checked.frontmatter.name; + if (slug && said !== undefined && said !== slug) throw new SkillError(`That folder is another skill: its SKILL.md says ${JSON.stringify(said)}, and this one is ${JSON.stringify(slug)}.`, 400, [{ path: "SKILL.md", reason: `frontmatter name: must be ${JSON.stringify(slug)}` }]); + const name = cut(oneLine(checked.name), 120); + const description = oneLine(checked.description); + if (!/[\p{L}\p{N}]/u.test(name)) throw new SkillError("A skill needs a name."); + if (!description) throw new SkillError("Say what it does and when to use it."); + const fm = frontmatterOf(slug ?? checked.slugBase, description, { ...checked.frontmatter, name: slug ?? checked.slugBase }); + noSecretsIn(name, description, fm); + const problems = validateFrontmatter(fm); + if (problems.length) throw new SkillError("The frontmatter has a problem.", 400, problems); + const { files } = checkFiles(checked.body, checked.files.map((f) => ({ path: f.path, content: f.content, data: f.data, executable: f.executable }))); + return { name, description, skillMd: renderSkillMd(fm, checked.body), files: orgoFiles(files), skipped: checked.skipped, warnings: checked.warnings, drafted: checked.drafted }; +} + /* ---------------- Procedures from before ---------------- */ /** A procedure as SKILL.md text: when to use it, its steps, its pitfalls. */ @@ -1824,9 +1850,9 @@ type RemoteSkill = { id: string; slug: string; name: string; description: string /** One of Orgo's record of deletions (GET …/skills?since=): a skill deleted after `since`, never one live now. */ type RemoteDeletion = { id: string; slug: string; type: string; deleted_at: string }; -const remoteType = (kind: SkillKind) => (kind === "app" ? "app" : "skill"); +export const remoteType = (kind: SkillKind) => (kind === "app" ? "app" : "skill"); // "software" is what an older orgo-web called an app. -const localKind = (type: string): SkillKind => (type === "software" || type === "app" ? "app" : "skill"); +export const localKind = (type: string): SkillKind => (type === "software" || type === "app" ? "app" : "skill"); /** One call to orgo-web's skills routes for a workspace, with the signed-in key. Null when Orgo didn't answer. */ async function skillsCall(projectId: string, path: string, init: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: unknown } = {}) { @@ -2284,7 +2310,7 @@ function payloadOf(wsId: string, r: SkillRef) { return { shas: b.files.map((f) => f.sha256), body: renderSkillMd(withLabel(frontmatterOf(r.slug, r.description, b.frontmatter), sentLabelOf(r)), b.body), - files: b.files.map((f) => (f.content !== null ? { path: f.path, content: f.content, executable: f.executable } : { path: f.path, content_base64: f.data!.toString("base64"), executable: f.executable })), + files: orgoFiles(b.files), }; } diff --git a/lib/shared.ts b/lib/shared.ts index 6431c10b9..69734ea05 100644 --- a/lib/shared.ts +++ b/lib/shared.ts @@ -184,6 +184,13 @@ export const powerWho = (state: Viewing): PowerWho => ({ ...(state.shared?.changes ? { can: Object.fromEntries((state.shared.can ?? []).map((p) => [p, true])) as Partial> } : {}), }); +/** + * Whether the viewer works on a shared workspace's skills in Knowledge, Skills (lib/server/shared-skills.ts: orgo-web with + * their own Orgo key, which checks again): an Admin, everything but labels. The one rule the window and the server both + * read. A Member with Memory and skills (Power "memory") comes next, once orgo-web lets them write and says so: here. + */ +export const sharedSkillsWho = (s: { role?: SharedRole; can?: readonly string[] } | null | undefined): boolean => s?.role === "admin"; + export const mayChange = (state: Viewing, power: Power, { soon = false } = {}) => !state.shared || (!!state.shared.can?.includes(power) && (soon || state.shared.changes === true)); diff --git a/scripts/test-shared-skills.mjs b/scripts/test-shared-skills.mjs new file mode 100644 index 000000000..457257760 --- /dev/null +++ b/scripts/test-shared-skills.mjs @@ -0,0 +1,496 @@ +// Tests for a shared workspace's skills on the viewer's own Bops (lib/server/shared-skills.ts, app/api/skills): an +// Admin there lists, reads, publishes, imports, updates from a folder, rolls back, deletes, gives a skill to its bots +// and approves scripts in orgo-web with their own Orgo key, with the owner's Mac off (no change act, no Bops Cloud); +// a request's `ws` is ignored and the viewer's own workspaces and folders are never touched; a Member without the +// power is refused before Orgo is asked; labels, Who has what and a bot's proposals stay the owner's; and orgo-web's +// refusals come out in plain words. Every word said is checked for dashes and for "Orbit". +// +// orgo-web is a fake (a local server with the skills routes), the shared workspace open is the test's (the real +// lib/server/shared.ts, with what Skills reads of it swapped for globalThis.__ss), the Keychain is never there, and the +// state and folders live in a throwaway folder: nothing reaches a real service. The keys are made up. +// Usage: node --conditions=react-server scripts/test-shared-skills.mjs +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +for (const k of Object.keys(process.env)) if (/^(BOPS|OPENAI|AGENTPHONE|AGENTMAIL|HONCHO|COMPOSIO|TYPESAFE|TWILIO|ORGO|CODEX|TAILSCALE|TREG)_/.test(k)) delete process.env[k]; +process.env.BOPS_SELF_HOSTED = "1"; +const UI = "ui-token-test-shared-skills"; +process.env.BOPS_UI_TOKEN = UI; +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const scratch = mkdtempSync(join(tmpdir(), "bops-test-shared-skills-")); +const bin = join(scratch, "bin"); +mkdirSync(bin); +writeFileSync(join(bin, "security"), "#!/bin/sh\nexit 44\n", { mode: 0o755 }); +process.env.PATH = `${bin}:${process.env.PATH}`; +process.env.HOME = join(scratch, "home"); +mkdirSync(process.env.HOME); +/** The viewer's own Orgo key (made up): their Bops sign in. */ +const ORGO_KEY = "sk_orgo_test_shared_skills"; +globalThis.bopsOrgoKey = ORGO_KEY; +const work = join(scratch, "work"); +mkdirSync(work); +process.chdir(work); + +const STAND_INS = new Set(["mac", "relay", "desktop", "mirror", "sessions"]); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, + load(url, context, next) { + // The shared workspace open: the real module, but what Skills reads of it is the test's while globalThis.__ss is set. + if (url.endsWith("/lib/server/shared.ts") && !url.includes("?real")) { + const real = JSON.stringify(`${url}?real`); + return { + format: "module", + shortCircuit: true, + source: `export * from ${real};\nimport * as real from ${real};\nexport const sharedOpen = () => (globalThis.__ss ? true : real.sharedOpen());\nexport const sharedSkillsOpen = () => (globalThis.__ss ? globalThis.__ss : real.sharedSkillsOpen());`, + }; + } + const name = url.match(/\/lib\/server\/([a-z-]+)\.ts$/)?.[1]; + if (!name || !STAND_INS.has(name)) return next(url, context); + const names = [...readFileSync(new URL(url), "utf8").matchAll(/^export (?:async )?(?:function\*? |const |let |class )([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]); + return { format: "module", shortCircuit: true, source: names.map((n) => `export const ${n} = function () { return Promise.resolve(); };`).join("\n") }; + }, +}); +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); +const logged = []; +for (const k of ["info", "warn", "error"]) console[k] = (...a) => logged.push(a.map(String).join(" ")); + +/** Made up, in a shape the scanner knows. Nothing here is a real key. */ +const FAKE_STRIPE = ["sk", "live", "0123456789abcdefABCDEF01"].join("_"); + +/* ---------------- A fake orgo-web's skills routes ---------------- */ + +const P = "11111111-2222-4333-8444-555555555555"; +const AT = "2026-11-12T09:00:00.000Z"; +const orgo = { + pages: false, + next: null, + calls: [], + skills: [], + approvals: new Map(), +}; +const reset = () => { + orgo.pages = false; + orgo.next = null; + orgo.calls = []; + orgo.approvals = new Map(); + orgo.skills = [ + // A draft a bot saved on its computer, for one bot. + { id: "a0000000-0000-4000-8000-000000000001", slug: "weekly-report", name: "Weekly report", description: "Sums up the week.", type: "skill", status: "draft", bot_ids: ["max"], updated_at: AT, created_at: AT, versions: ["0.1.0"], current: "0.1.0", label: "Writing", body: "# Weekly report\n\n1. Read the chat.\n", files: [{ path: "scripts/sum.sh", sha256: "d".repeat(64), size_bytes: 20, executable: true, media_type: "text/x-shellscript", approved: false, content: "#!/bin/sh\necho 1\n" }] }, + // Published, under its label, for every bot with it. + { id: "a0000000-0000-4000-8000-000000000002", slug: "triage", name: "Triage", description: "Sorts the inbox.", type: "skill", status: "published", bot_ids: [], updated_at: "2026-11-12T10:00:00.000Z", created_at: AT, versions: ["0.1.0", "0.1.1"], current: "0.1.1", label: "Email", body: "# Triage\n", files: [] }, + // Published, naming its bots. + { id: "a0000000-0000-4000-8000-000000000003", slug: "invoices", name: "Invoices", description: "Sends invoices.", type: "skill", status: "published", bot_ids: ["max", "ivy"], updated_at: AT, created_at: AT, versions: ["1.0.0"], current: "1.0.0", body: "# Invoices\n", files: [] }, + ]; +}; +reset(); +const send = (res, status, obj) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(obj)); +}; +const view = (s) => ({ id: s.id, workspace_id: P, slug: s.slug, name: s.name, description: s.description, type: s.type, status: s.status, bot_ids: s.bot_ids, source: "bops", created_at: s.created_at, updated_at: s.updated_at, current_version: s.current ? { version: s.current, file_count: s.files.length + 1, size_bytes: 100, risk: null, created_at: AT, ...(s.label ? { label: s.label } : {}) } : null }); +const server = createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const text = Buffer.concat(chunks).toString(); + const url = new URL(req.url, "http://fake"); + const body = text ? JSON.parse(text) : undefined; + orgo.calls.push({ method: req.method, path: url.pathname, query: url.search, body, auth: req.headers.authorization }); + if (req.headers.authorization !== `Bearer ${ORGO_KEY}`) return send(res, 401, { error: "Not authenticated" }); + if (orgo.pages) { + res.writeHead(404, { "content-type": "text/html" }); + return res.end("404"); + } + // A canned answer for the next call (of `method`, when it names one: the list read before a change goes on as usual). + if (orgo.next && (!orgo.next.method || orgo.next.method === req.method)) { + const n = orgo.next; + orgo.next = null; + return send(res, n.status, n.body); + } + const m = url.pathname.match(/^\/api\/projects\/([^/]+)\/skills(?:\/([^/]+))?(?:\/(versions|rollback|files)(?:\/(.+))?)?$/); + if (!m || m[1] !== P) return send(res, 404, { error: "Workspace not found.", code: "not_found" }); + const [, , sid, sub, rest] = m; + if (sid === "approvals") { + const sha = url.searchParams.get("sha256"); + if (req.method === "POST") { + orgo.approvals.set(body.sha256, { sha256: body.sha256, path_hint: body.path_hint ?? null, approved_by: "u_admin", approved_at: AT, approved_by_role: "admin" }); + return send(res, 201, { approval: orgo.approvals.get(body.sha256) }); + } + if (req.method === "DELETE") return orgo.approvals.delete(sha) ? send(res, 200, { revoked: true, sha256: sha }) : send(res, 404, { error: "That hash is not approved.", code: "not_found" }); + return send(res, 200, { approvals: [...orgo.approvals.values()] }); + } + if (!sid) { + if (req.method === "GET") return send(res, 200, { skills: orgo.skills.map(view), deleted: [], full: true, at: AT }); + if (req.method === "POST") { + const s = { id: `a0000000-0000-4000-8000-00000000010${orgo.skills.length}`, slug: body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), name: body.name, description: body.description, type: body.type, status: body.status ?? "published", bot_ids: body.bot_ids ?? [], updated_at: AT, created_at: AT, versions: ["0.1.0"], current: "0.1.0", body: body.body, files: [] }; + orgo.skills.push(s); + return send(res, 201, { skill: view(s), version: { version: "0.1.0" }, warnings: [] }); + } + } + const s = orgo.skills.find((x) => x.id === sid); + if (!s) return send(res, 404, { error: "Skill not found.", code: "not_found" }); + if (!sub && req.method === "GET") { + const v = url.searchParams.get("version") ?? s.current; + if (!s.versions.includes(v)) return send(res, 404, { error: "That version does not exist.", code: "version_not_found" }); + return send(res, 200, { skill: { ...view(s), current_version: undefined }, version: { version: v, frontmatter: { name: s.slug, description: s.description }, body: s.body, files: s.files.map(({ content: _c, ...f }) => f) }, versions: s.versions }); + } + if (sub === "files" && req.method === "GET") { + const f = s.files.find((x) => x.path === decodeURIComponent(rest ?? "")); + return f ? send(res, 200, { path: f.path, sha256: f.sha256, size_bytes: f.size_bytes, executable: f.executable, media_type: f.media_type, content: f.content }) : send(res, 404, { error: "File not found.", code: "not_found" }); + } + if (!sub && req.method === "PATCH") { + if (body.if_updated_at && body.if_updated_at !== s.updated_at) return send(res, 409, { error: "The skill changed since you read it.", code: "stale_skill", skill: view(s) }); + Object.assign(s, { ...(body.status ? { status: body.status } : {}), ...(body.bot_ids ? { bot_ids: body.bot_ids } : {}), updated_at: "2026-11-12T11:00:00.000Z" }); + return send(res, 200, { skill: view(s) }); + } + if (!sub && req.method === "DELETE") { + orgo.skills = orgo.skills.filter((x) => x !== s); + return send(res, 200, { deleted: true, id: s.id }); + } + if (sub === "rollback" && req.method === "POST") { + s.current = body.version; + return send(res, 200, { skill: view(s) }); + } + if (sub === "versions" && req.method === "POST") { + const v = `0.1.${s.versions.length}`; + s.versions.push(v); + Object.assign(s, { current: v, status: "published", body: body.body, updated_at: "2026-11-12T12:00:00.000Z" }); + return send(res, 201, { skill: view(s), version: { version: v } }); + } + return send(res, 405, { error: "no" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +process.env.BOPS_ORGO_ORIGIN = `http://127.0.0.1:${server.address().port}`; + +/* ---------------- Helpers ---------------- */ + +let passed = 0; +let failed = 0; +async function check(name, fn) { + try { + await fn(); + passed++; + console.log(`ok ${name}`); + } catch (e) { + failed++; + console.log(`FAIL ${name}\n ${String(e?.stack ?? e).split("\n").slice(0, 8).join("\n ")}`); + } +} +/** Everything a person could read, checked for dashes and Orbit at the end. */ +const said = []; + +const S = await import(`${root}/lib/server/store.ts`); +const W = await import(`${root}/lib/server/workspace-skills.ts`); +const SS = await import(`${root}/lib/server/shared-skills.ts`); +const Route = await import(`${root}/app/api/skills/route.ts`); +const route = async (body, method = "POST", { window = true } = {}) => { + const res = await Route[method]( + new Request(`http://localhost:3210/api/skills${method === "GET" ? (body ?? "") : ""}`, { method, headers: { "content-type": "application/json", ...(window ? { "x-bops-window": UI } : {}) }, ...(method === "GET" ? {} : { body: JSON.stringify(body) }) }), + ); + const json = await res.json(); + said.push(JSON.stringify(json)); + return { status: res.status, body: json }; +}; +/** An Admin's shared workspace open (lib/server/shared.ts sharedSkillsOpen), or a Member's. */ +const open = (role = "admin", can = []) => void (globalThis.__ss = { projectId: P, name: "Acme", role, can, botIds: ["max", "ivy"] }); +const close = () => void (globalThis.__ss = undefined); +const calls = () => orgo.calls.map((c) => `${c.method} ${c.path}${c.query}`); + +// The viewer's own Bops: their own workspace, with a skill of the same slug as the shared one's, in its folder. +S.update((s) => { + s.owner = { name: "Sam" }; + s.account = { user: { id: "u_admin", email: "sam@example.com" }, signedInAt: 0 }; + s.workspaces = [{ id: "ws_main", name: "Mine", createdAt: 0 }]; + s.bots = [{ id: "max", name: "Max", role: "Sales", color: "#5B8CFF", isMain: true, computerStatus: "none" }]; +}); +const mine = W.createSkill("ws_main", { name: "Weekly report", description: "My own weekly report.", body: "# Mine\n" }).ref; +const mineMd = () => readFileSync(join(process.cwd(), ".data", "workspaces", "ws_main", "skills", mine.slug, "SKILL.md"), "utf8"); +const mineBefore = mineMd(); +const folder = (name, files) => { + const dir = join(scratch, "folders", name); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + for (const [p, c] of Object.entries(files)) { + mkdirSync(join(dir, p, ".."), { recursive: true }); + writeFileSync(join(dir, p), c); + } + return dir; +}; + +/* ---------------- An Admin, with the owner's Mac off ---------------- */ + +await check("an Admin's list is orgo-web's, with their own key, under its labels; the viewer's own workspace isn't read or sent", async () => { + reset(); + open(); + try { + const r = await route("?ws=ws_main", "GET"); + assert.equal(r.status, 200); + assert.deepEqual(calls(), [`GET /api/projects/${P}/skills`]); + assert.ok(orgo.calls.every((c) => c.auth === `Bearer ${ORGO_KEY}`), "the user's own Orgo key"); + assert.deepEqual(r.body.skills.map((x) => [x.slug, x.status, x.version, x.label, x.botIds]), [ + ["triage", "published", "0.1.1", "Email", []], + ["weekly-report", "draft", undefined, "Writing", ["max"]], + ["invoices", "published", "1.0.0", undefined, ["max", "ivy"]], + ]); + assert.deepEqual([r.body.orgo, r.body.shared, r.body.apps], [true, true, []]); + assert.ok(!JSON.stringify(r.body).includes("My own weekly report"), "never the viewer's own skill of that slug"); + } finally { + close(); + } +}); + +await check("detail and a file are orgo-web's: SKILL.md, its files (a script marked, not approved), its versions; no folder", async () => { + reset(); + open(); + try { + const d = await route({ action: "detail", ws: "ws_main", kind: "skill", slug: "weekly-report" }); + assert.equal(d.status, 200); + assert.equal(d.body.body, "# Weekly report\n\n1. Read the chat.\n"); + assert.deepEqual(d.body.files, [{ path: "scripts/sum.sh", size: 20, sha256: "d".repeat(64), executable: true, script: true, approved: false, binary: false }]); + assert.deepEqual(d.body.ref.versions.map((v) => v.version), ["0.1.0"]); + assert.deepEqual([d.body.dir, d.body.missing, d.body.changed], [null, false, false]); + const old = await route({ action: "detail", kind: "skill", slug: "triage", version: "0.1.0" }); + assert.equal(old.status, 200); + assert.ok(orgo.calls.some((c) => c.path.endsWith("/a0000000-0000-4000-8000-000000000002") && c.query === "?version=0.1.0")); + const f = await route({ action: "file", kind: "skill", slug: "weekly-report", path: "scripts/sum.sh" }); + assert.deepEqual(f.body, { path: "scripts/sum.sh", content: "#!/bin/sh\necho 1\n", sha256: "d".repeat(64) }); + assert.equal((await route({ action: "file", kind: "skill", slug: "weekly-report", path: "../x" })).status, 404); + assert.equal((await route({ action: "detail", kind: "skill", slug: "nope" })).status, 404); + } finally { + close(); + } +}); + +await check("Publish sends PATCH status published (as read), only for a draft; the viewer's own skill of that slug stays as it was", async () => { + reset(); + open(); + try { + const r = await route({ action: "publish", ws: "ws_main", kind: "skill", slug: "weekly-report" }); + assert.equal(r.status, 200); + assert.equal(r.body.ref.status, "published"); + const patch = orgo.calls.find((c) => c.method === "PATCH"); + assert.deepEqual([patch.path, patch.body], [`/api/projects/${P}/skills/a0000000-0000-4000-8000-000000000001`, { status: "published", if_updated_at: AT }]); + assert.equal(orgo.skills[0].status, "published"); + const n = orgo.calls.length; + const again = await route({ action: "publish", kind: "skill", slug: "triage" }); + assert.equal(again.status, 409); + assert.match(again.body.error, /published already/); + assert.ok(!orgo.calls.slice(n).some((c) => c.method === "PATCH"), "nothing sent for one published"); + // Changed by someone else between Bops' read and its PATCH: orgo-web's 409 stale_skill, said in words. + orgo.skills[0].status = "draft"; + orgo.next = { method: "PATCH", status: 409, body: { error: "The skill changed since you read it.", code: "stale_skill" } }; + const stale = await route({ action: "publish", kind: "skill", slug: "weekly-report" }); + assert.deepEqual([stale.status, stale.body.error], [409, "Someone changed it just now. Look at it again, then try again."]); + // The viewer's own workspace: untouched. + assert.equal(W.findRef("ws_main", "skill", mine.slug).status, "draft"); + assert.equal(mineMd(), mineBefore); + } finally { + close(); + } +}); + +await check("Roll back, Delete and a skill's own bots go to orgo-web; one that follows its label isn't switched here", async () => { + reset(); + open(); + try { + let r = await route({ action: "rollback", kind: "skill", slug: "triage", version: "0.1.0" }); + assert.equal(r.status, 200); + assert.deepEqual(orgo.calls.at(-1).body, { version: "0.1.0" }); + assert.equal(r.body.ref.version, "0.1.0"); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "ivy", on: false }); + assert.equal(r.status, 200); + assert.deepEqual(orgo.calls.at(-1).body, { bot_ids: ["max"], if_updated_at: AT }); + assert.deepEqual(r.body.ref.botIds, ["max"]); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "max", on: false }); + assert.equal(r.status, 400); + assert.match(r.body.error, /At least one bot keeps it/); + const n = orgo.calls.length; + r = await route({ action: "bot", kind: "skill", slug: "triage", botId: "max", on: false }); + assert.equal(r.status, 403); + assert.equal(r.body.error, SS.BY_LABEL); + r = await route({ action: "bot", kind: "skill", slug: "invoices", botId: "stranger", on: true }); + assert.equal(r.status, 404); + assert.ok(!orgo.calls.slice(n).some((c) => c.method === "PATCH")); + r = await route({ action: "delete", kind: "skill", slug: "invoices" }); + assert.deepEqual([r.status, r.body], [200, { ok: true }]); + assert.equal(orgo.calls.at(-1).method, "DELETE"); + assert.ok(!orgo.skills.some((x) => x.slug === "invoices")); + assert.ok(W.findRef("ws_main", "skill", mine.slug), "the viewer's own skill is still there"); + } finally { + close(); + } +}); + +await check("approving a script goes to orgo-web with the user's own sign in; taking it back too; a key orgo-web won't take, in words", async () => { + reset(); + open(); + try { + let r = await route({ action: "approve", kind: "skill", sha256: "d".repeat(64), path: "scripts/sum.sh", on: true }); + assert.deepEqual([r.status, r.body], [200, { ok: true }]); + const post = orgo.calls.at(-1); + assert.deepEqual([post.method, post.path, post.body, post.auth], ["POST", `/api/projects/${P}/skills/approvals`, { sha256: "d".repeat(64), path_hint: "scripts/sum.sh" }, `Bearer ${ORGO_KEY}`]); + r = await route({ action: "approve", kind: "skill", sha256: "d".repeat(64), on: false }); + assert.equal(r.status, 200); + assert.equal(orgo.calls.at(-1).query, `?sha256=${"d".repeat(64)}`); + // Not approved there (anymore): nothing to take back. + assert.equal((await route({ action: "approve", sha256: "d".repeat(64), on: false })).status, 200); + orgo.next = { method: "POST", status: 403, body: { error: "A person approves scripts.", code: "session_required" } }; + r = await route({ action: "approve", sha256: "d".repeat(64), on: true }); + assert.equal(r.status, 403); + assert.match(r.body.error, /^Orgo takes an approval only from you, signed in to Bops with Orgo\./); + assert.equal((await route({ action: "approve", sha256: "nope", on: true })).status, 400); + assert.deepEqual(W.workspaceById("ws_main").approvals ?? [], [], "nothing kept on this Mac"); + } finally { + close(); + } +}); + +await check("Update from folder publishes the folder as its next version (POST versions); a new folder is a draft (POST skills); checked first", async () => { + reset(); + open(); + try { + const dir = folder("weekly-report", { "SKILL.md": "---\nname: weekly-report\ndescription: Sums up the week, better.\n---\n# Weekly report\n\n1. Read every chat.\n", "scripts/sum.sh": "#!/bin/sh\necho 2\n", "notes.log": "junk" }); + let r = await route({ action: "import", ws: "ws_main", kind: "skill", slug: "weekly-report", path: dir }); + assert.equal(r.status, 200); + const sent = orgo.calls.at(-1); + assert.deepEqual([sent.method, sent.path], ["POST", `/api/projects/${P}/skills/a0000000-0000-4000-8000-000000000001/versions`]); + assert.match(sent.body.body, /^---\nname: weekly-report\ndescription: "?Sums up the week, better\."?\n---\n/); + assert.deepEqual(sent.body.files.map((f) => f.path), ["scripts/sum.sh"], "junk left out"); + assert.equal(sent.body.version, undefined, "orgo-web picks the next number"); + assert.deepEqual([r.body.published, r.body.ref.status, r.body.ref.version], [true, "published", "0.1.1"]); + assert.deepEqual(r.body.skipped.map((p) => p.path), ["notes.log"]); + assert.equal(mineMd(), mineBefore, "the viewer's own folder of that slug untouched"); + // Another skill's folder: refused before Orgo is asked. + const n = orgo.calls.length; + r = await route({ action: "import", kind: "skill", slug: "weekly-report", path: folder("other", { "SKILL.md": "---\nname: other\ndescription: Something else.\n---\n# Other\n" }) }); + assert.equal(r.status, 400); + assert.match(r.body.error, /another skill/); + // A secret in SKILL.md: refused too, never its text. + r = await route({ action: "import", kind: "skill", path: folder("leaky", { "SKILL.md": `---\nname: leaky\ndescription: Leaks.\n---\nkey=${FAKE_STRIPE}\n` }) }); + assert.equal(r.status, 422); + assert.ok(!JSON.stringify(r.body).includes(FAKE_STRIPE)); + assert.equal(orgo.calls.length, n + 1, "only the list was read"); + // A new folder: a draft in Orgo. + r = await route({ action: "import", kind: "skill", path: folder("standup", { "SKILL.md": "---\nname: standup\ndescription: Runs the standup.\n---\n# Standup\n" }) }); + assert.equal(r.status, 200); + const made = orgo.calls.at(-1); + assert.deepEqual([made.method, made.path, made.body.status, made.body.type, made.body.name], ["POST", `/api/projects/${P}/skills`, "draft", "skill", "Standup"]); + assert.equal(r.body.ref.status, "draft"); + assert.ok(!W.findRef("ws_main", "skill", "standup"), "not made in the viewer's own workspace"); + } finally { + close(); + } +}); + +await check("labels, Who has what, a bot's proposals and a chat's Save as skill stay the owner's: refused, Orgo never asked", async () => { + reset(); + open(); + try { + for (const action of ["bot-label", "bot-labels", "label-rename", "label-about", "label-suggest", "proposal", "create", "update", "draft", "save-draft", "nonsense"]) { + const r = await route({ action, ws: "ws_main", kind: "skill", slug: "triage", label: "Email", botId: "max", on: true }); + assert.deepEqual([action, r.status, r.body.error], [action, 403, SS.SHARED_OWNER_ONLY]); + } + assert.deepEqual(calls(), []); + assert.equal(W.workspaceById("ws_main").skills.length, 1, "nothing made in the viewer's own workspace"); + } finally { + close(); + } +}); + +await check("a Member (even with Memory and skills, until phase 2) is refused before Orgo is asked; and only from the Bops window", async () => { + reset(); + for (const can of [[], ["memory"]]) { + open("member", can); + try { + for (const r of [await route("", "GET"), await route({ action: "detail", kind: "skill", slug: "triage" }), await route({ action: "publish", kind: "skill", slug: "weekly-report" })]) { + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + } + } finally { + close(); + } + } + open(); + try { + const r = await route({ action: "publish", kind: "skill", slug: "weekly-report" }, "POST", { window: false }); + assert.equal(r.status, 403); + assert.equal((await route("", "GET", { window: false })).status, 403); + } finally { + close(); + } + assert.deepEqual(calls(), []); +}); + +await check("orgo-web's refusals in plain words: view only, no skills routes (a page), signed out, a gone workspace", async () => { + reset(); + open(); + try { + orgo.next = { method: "PATCH", status: 403, body: { error: "This workspace is view only. Ask the owner for edit access.", code: "workspace_read_only" } }; + let r = await route({ action: "publish", kind: "skill", slug: "weekly-report" }); + // (The list read first: the refusal is the PATCH's.) + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + assert.equal(orgo.calls.at(-1).method, "PATCH"); + orgo.next = null; + orgo.skills[0].status = "draft"; + orgo.calls = []; + orgo.pages = true; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [503, SS.SKILLS_NOT_ON_ORGO]); + orgo.pages = false; + orgo.next = { status: 404, body: { error: "Workspace not found.", code: "not_found" } }; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [404, "This workspace isn't shared with you anymore."]); + orgo.next = { status: 403, body: { error: "This workspace is view only. Ask the owner for edit access.", code: "workspace_read_only" } }; + r = await route("", "GET"); + assert.deepEqual([r.status, r.body.error], [403, SS.SHARED_SKILLS_WHO]); + globalThis.bopsOrgoKey = "sk_orgo_someone_else"; + r = await route("", "GET"); + assert.equal(r.status, 401); + assert.match(r.body.error, /^Sign in to Orgo/); + } finally { + globalThis.bopsOrgoKey = ORGO_KEY; + close(); + } +}); + +await check("with no shared workspace open, Skills is the viewer's own, as before", async () => { + reset(); + const r = await route("?ws=ws_main", "GET"); + assert.equal(r.status, 200); + assert.deepEqual(r.body.skills.map((x) => x.slug), [mine.slug]); + assert.deepEqual(calls(), []); +}); + +await check("nothing said has a dash or says Orbit, and no body or key was logged", async () => { + const words = [...said, SS.SHARED_OWNER_ONLY, SS.SHARED_SKILLS_WHO, SS.SKILLS_NOT_ON_ORGO, SS.BY_LABEL]; + for (const w of words) { + const errors = [...w.matchAll(/"error":"([^"]*)"/g)].map((m) => m[1]); + for (const e of errors.length ? errors : [w]) { + assert.ok(!/[‒–—―]| - /.test(e), `a dash in: ${e}`); + assert.ok(!/orbit/i.test(e), `Orbit in: ${e}`); + } + } + const log = logged.join("\n"); + for (const secret of [ORGO_KEY, FAKE_STRIPE, "Read every chat"]) assert.ok(!log.includes(secret), `logged: ${secret}`); +}); + +server.close(); +rmSync(scratch, { recursive: true, force: true }); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/scripts/test-shared.mjs b/scripts/test-shared.mjs index 68816b4d3..bbc88683e 100644 --- a/scripts/test-shared.mjs +++ b/scripts/test-shared.mjs @@ -1375,8 +1375,11 @@ await check("while one is open, every write to the app's server is answered View for (const ok of ["/api/vault", "/api/account/checkout", "/api/owner", "/api/owner-email"]) assert.equal(callAllowed("POST", ok), true, ok); // Keys go: since a shared workspace has keys of its own, its keys routes act on those alone while it's open (below). assert.equal(callAllowed("POST", "/api/keys"), true, "a shared workspace's own keys"); + // Skills go too: while it's open its skills routes act on its skills in Orgo alone, for an Admin (lib/server/shared-skills.ts). + assert.equal(callAllowed("POST", "/api/skills"), true, "a shared workspace's own skills"); + assert.equal(callAllowed("GET", "/api/skills?ws=x"), true, "a shared workspace's own skills"); // Reads the server answers from the user's own bot or workspace of that id: never the shared one's. - for (const no of ["/api/memory?bot=boppy", "/api/memory/categories?ws=x", "/api/orbit/issues?ws=shared_p1", "/api/skills?ws=x", "/api/routines/suggestions?botId=boppy", "/api/vnc?bot=boppy&display=1", "/api/computer?bot=boppy", "/api/reader?bot=boppy", "/api/crm?ws=x", "/api/made-apps/app_x", "/api/hosted-apps?ws=x", "/api/domains?ws=x", "/api/domains/search?ws=x&q=acme", "/api/domains/6a6a6a6a-1111-4222-8333-444455556666?ws=x"]) + for (const no of ["/api/memory?bot=boppy", "/api/memory/categories?ws=x", "/api/orbit/issues?ws=shared_p1", "/api/routines/suggestions?botId=boppy", "/api/vnc?bot=boppy&display=1", "/api/computer?bot=boppy", "/api/reader?bot=boppy", "/api/crm?ws=x", "/api/made-apps/app_x", "/api/hosted-apps?ws=x", "/api/domains?ws=x", "/api/domains/search?ws=x&q=acme", "/api/domains/6a6a6a6a-1111-4222-8333-444455556666?ws=x"]) assert.equal(callAllowed("GET", no), false, no); assert.equal(callAllowed("GET", "/api/vault"), true); wire.length = 0; diff --git a/scripts/test-skills-ui-shared.mjs b/scripts/test-skills-ui-shared.mjs new file mode 100644 index 000000000..5cb8dde1b --- /dev/null +++ b/scripts/test-skills-ui-shared.mjs @@ -0,0 +1,285 @@ +// Tests for Knowledge, Skills in a workspace shared with the user (components/app/skills.tsx, memory-categories.tsx): for +// an Admin there, Skills shows the workspace's skills as Orgo keeps them (GET /api/skills, read again after each change), +// "Kept in Orgo for everyone in" its name; Publish only on a draft; no Import an app folder, no Open in Finder, no label to +// move, no Who has what or Suggest labels; Update from folder asks before it publishes; a skill that follows its label +// says so in place of its bots' switches. Knowledge's other kinds keep the owner's line, with Open Skills for an Admin; a +// Member sees no Skills. Nothing says a dash or Orbit. The .tsx files are compiled with the TypeScript in node_modules as +// they load and rendered into a DOM (linkedom); the server is a stubbed fetch. +// Usage: node scripts/test-skills-ui-shared.mjs (not with --conditions=react-server: these are client components) +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, + load(url, context, next) { + if (url.endsWith(".css")) return { format: "module", source: "export default {};", shortCircuit: true }; + if (!url.endsWith(".tsx")) return next(url, context); + const source = readFileSync(fileURLToPath(url), "utf8"); + const out = ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: false }, fileName: url }); + return { format: "module", source: out.outputText, shortCircuit: true }; + }, +}); +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); + +const { parseHTML } = await import("linkedom"); +const { window: win } = parseHTML(""); +const doc = win.document; +win.HTMLElement.prototype.focus = function () {}; +win.HTMLElement.prototype.scrollTo = function () {}; +win.HTMLElement.prototype.scrollIntoView = function () {}; +globalThis.window = globalThis; +globalThis.location = { origin: "http://localhost:3210" }; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.document = doc; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.sessionStorage = globalThis.localStorage; +globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +globalThis.requestAnimationFrame = (f) => setTimeout(f, 0); +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +// The Mac app's bridge: a folder picked in Finder, and one shown there. +let picked = null; +globalThis.bopsMac = { pickFolder: async () => picked, revealFolder: async () => true }; + +/** The server, stubbed: every call kept, answered from `lists` and `details`. */ +const calls = []; +let lists; +let details; +let answers = {}; +globalThis.fetch = async (url, init = {}) => { + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(init.body) : undefined; + calls.push({ url: String(url), method, body }); + const reply = (status, json) => ({ ok: status < 400, status, json: async () => json }); + if (String(url).startsWith("/api/skills") && method === "GET") return reply(200, { ...lists, orgo: true, shared: true }); + if (String(url) === "/api/skills" && method === "POST") { + if (body.action === "detail") return reply(200, details[body.slug]); + const a = answers[body.action]; + return a ? reply(a.status ?? 200, a.body) : reply(200, { ok: true, ref: lists.skills.find((x) => x.slug === body.slug) }); + } + return reply(404, {}); +}; + +const { createRoot } = await import("react-dom/client"); +const { act, createElement: h } = await import("react"); +const { SkillsPage } = await import(pathToFileURL(`${root}/components/app/skills.tsx`).href); +const { MemoryView } = await import(pathToFileURL(`${root}/components/app/memory-categories.tsx`).href); +const { sharedWsId } = await import(`${root}/lib/shared.ts`); + +let passed = 0; +let failures = 0; +async function check(name, fn) { + try { + await fn(); + passed++; + console.log(`ok ${name}`); + } catch (e) { + failures++; + console.error(`FAIL ${name}\n ${e.stack.split("\n").slice(0, 10).join("\n ")}`); + } +} +const settle = () => new Promise((r) => setTimeout(r, 5)); +async function mount(el) { + const box = doc.createElement("div"); + doc.body.appendChild(box); + const r = createRoot(box); + await act(async () => { + r.render(el); + await settle(); + }); + await act(settle); + return { box, done: async () => (await act(async () => r.unmount()), box.remove()) }; +} +const click = (el) => + act(async () => { + el.dispatchEvent(new win.Event("click", { bubbles: true })); + await settle(); + await settle(); + }); +const buttons = (box) => [...box.querySelectorAll("button")]; +const button = (box, label) => buttons(box).find((b) => b.textContent.trim() === label); +const words = (box) => box.textContent.replace(/\s+/g, " ").trim(); +const seen = []; +const look = (box) => (seen.push(words(box)), words(box)); + +const WS = sharedWsId("p1"); +const ref = (over) => ({ kind: "skill", labelBy: "skill", botIds: [], source: "orgo", createdAt: 0, updatedAt: 1, ...over }); +const reset = () => { + calls.length = 0; + answers = {}; + picked = null; + lists = { + skills: [ + ref({ slug: "weekly-report", name: "Weekly report", description: "Sums up the week.", status: "draft", label: "Writing", botIds: ["max"], updatedAt: 3 }), + ref({ slug: "triage", name: "Triage", description: "Sorts the inbox.", status: "published", version: "0.1.1", label: "Email", updatedAt: 2 }), + ], + apps: [], + }; + const files = [{ path: "scripts/sum.sh", size: 20, sha256: "d".repeat(64), executable: true, script: true, approved: false, binary: false }]; + details = { + "weekly-report": { ref: { ...lists.skills[0], versions: [{ version: "0.1.0", at: 0, sha256: "" }] }, body: "# Weekly report\n", frontmatter: {}, files, dir: null, missing: false, changed: false, shared: true }, + triage: { ref: { ...lists.skills[1], versions: [{ version: "0.1.0", at: 0, sha256: "" }, { version: "0.1.1", at: 0, sha256: "" }] }, body: "# Triage\n", frontmatter: {}, files: [], dir: "/should/not/show", missing: false, changed: true, shared: true }, + }; +}; +/** The shared workspace open, as the window's state has it (AppState.shared): its bots, none of its skills. */ +const state = (role = "admin") => ({ + workspace: WS, + workspaces: [{ id: WS, name: "Acme", createdAt: 0 }], + bots: [ + { id: "max", name: "Max", role: "Sales", color: "#5B8CFF", isMain: true, workspaceId: WS, computerStatus: "none" }, + { id: "ivy", name: "Ivy", role: "Support", color: "#0A0A0A", isMain: false, workspaceId: WS, computerStatus: "none" }, + ], + chats: [], + sessions: [], + messages: [], + routines: [], + account: { user: { id: "u_sam", email: "sam@example.com" }, signedInAt: 0 }, + shared: { projectId: "p1", name: "Acme", owner: "ada@example.com", role, wsId: WS, can: role === "admin" ? ["memory"] : [] }, +}); + +await check("an Admin's Skills is Orgo's list, read once open: no app import, no Who has what or Suggest, and it says where it's kept", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", onClose() {} })); + try { + const t = look(m.box); + assert.ok(calls.some((c) => c.method === "GET" && c.url === "/api/skills"), "the list asked of the server (the shared branch)"); + assert.ok(!calls.some((c) => c.url.includes("sync=1")), "no sync of the viewer's own workspace"); + assert.match(t, /Kept in Orgo for everyone in Acme\./); + assert.match(t, /Weekly report/); + assert.match(t, /Triage/); + assert.ok(button(m.box, "Import a skill folder"), "a skill folder can be imported"); + assert.ok(!button(m.box, "Import an app folder"), "never an app"); + assert.ok(!/Who has what|Suggest labels/.test(t)); + } finally { + await m.done(); + } +}); + +await check("a draft opened: Publish, no label to move, no Open in Finder; pressing it publishes and reads the list again", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + const t = look(m.box); + assert.ok(button(m.box, "Publish"), "Publish on a draft"); + assert.ok(!m.box.querySelector("[aria-haspopup=menu]"), "no label chip menu"); + assert.match(t, /Writing/); + assert.ok(!button(m.box, "Open in Finder")); + assert.ok(button(m.box, "Update from folder")); + const before = calls.filter((c) => c.method === "GET").length; + await click(button(m.box, "Publish")); + assert.deepEqual(calls.filter((c) => c.method === "POST" && c.body.action === "publish").map((c) => [c.body.slug, c.body.kind]), [["weekly-report", "skill"]]); + assert.ok(calls.filter((c) => c.method === "GET").length > before, "the list read again"); + } finally { + await m.done(); + } +}); + +await check("a published one opened: no Publish (its folder's changes aren't Orgo's), its versions to roll back to, and its label's bots said, not switched", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:triage", onClose() {} })); + try { + const t = look(m.box); + assert.ok(!buttons(m.box).some((b) => /^Publish/.test(b.textContent.trim())), "no Publish on a published one"); + assert.ok(!button(m.box, "Open in Finder"), "no folder, whatever the detail says"); + assert.ok(button(m.box, "Roll back to this"), "an older version to roll back to"); + assert.match(t, /Every bot with the label Email has it\. The owner picks which bots have a label\./); + assert.ok(!m.box.querySelector("[aria-pressed]"), "no bot switches for a skill that follows its label"); + } finally { + await m.done(); + } +}); + +await check("Update from folder asks before it publishes, and sends nothing until Publish", async () => { + reset(); + picked = "/Users/sam/skills/weekly-report"; + answers.import = { body: { ref: { ...lists.skills[0], status: "published", version: "0.1.1" }, skipped: [], warnings: [], published: true } }; + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + await click(button(m.box, "Update from folder")); + assert.match(look(m.box), /Publish the folder weekly-report as its next version\? Its bots use it at once\./); + assert.ok(!calls.some((c) => c.body?.action === "import"), "nothing sent yet"); + const bar = m.box.querySelector("[data-folder-confirm]"); + await click([...bar.querySelectorAll("button")].find((b) => b.textContent.trim() === "Publish")); + assert.deepEqual(calls.filter((c) => c.body?.action === "import").map((c) => [c.body.slug, c.body.path]), [["weekly-report", "/Users/sam/skills/weekly-report"]]); + assert.match(look(m.box), /Published Weekly report from the folder as its next version\./); + } finally { + await m.done(); + } +}); + +await check("a skill naming its bots: each a switch, sent as on or off", async () => { + reset(); + const m = await mount(h(SkillsPage, { state: state(), only: "skill", focus: "skill:weekly-report", onClose() {} })); + try { + const sw = [...m.box.querySelectorAll("[aria-pressed]")]; + assert.deepEqual(sw.map((b) => [b.textContent.trim(), b.getAttribute("aria-pressed")]), [["Max", "true"], ["Ivy", "false"]]); + await click(sw[1]); + assert.deepEqual(calls.filter((c) => c.body?.action === "bot").map((c) => [c.body.botId, c.body.on]), [["ivy", true]]); + } finally { + await m.done(); + } +}); + +await check("Knowledge in a shared workspace: Skills for an Admin, Open Skills under the owner's line; a Member sees neither", async () => { + reset(); + let opened = null; + const props = (role, category) => ({ state: state(role), user: "u_sam", ws: WS, category, onCategory: (c) => (opened = c), onClose() {} }); + let m = await mount(h(MemoryView, props("admin", "skills"))); + try { + assert.match(look(m.box), /Kept in Orgo for everyone in Acme\./); + } finally { + await m.done(); + } + m = await mount(h(MemoryView, props("admin", "all"))); + try { + assert.match(look(m.box), /Knowledge in Acme is kept on the owner's Bops/); + await click(button(m.box, "Open Skills")); + assert.equal(opened, "skills"); + } finally { + await m.done(); + } + calls.length = 0; + for (const category of ["skills", "all"]) { + m = await mount(h(MemoryView, props("member", category))); + try { + assert.match(look(m.box), /Knowledge in Acme is kept on the owner's Bops/); + assert.ok(!button(m.box, "Open Skills")); + assert.ok(!/Kept in Orgo/.test(words(m.box))); + } finally { + await m.done(); + } + } + assert.ok(!calls.some((c) => c.url.startsWith("/api/skills")), "a Member's window never asks"); +}); + +await check("nothing on these pages has a dash or says Orbit", async () => { + for (const t of seen) { + assert.ok(!/[‒–—―]| - /.test(t), `a dash in: ${t.slice(0, 200)}`); + assert.ok(!/orbit/i.test(t), `Orbit in: ${t.slice(0, 200)}`); + } +}); + +console.log(`\n${passed} passed, ${failures} failed`); +process.exit(failures ? 1 : 0); diff --git a/scripts/test-skills.mjs b/scripts/test-skills.mjs index fcdff49a0..f2eecf183 100644 --- a/scripts/test-skills.mjs +++ b/scripts/test-skills.mjs @@ -207,6 +207,8 @@ const hear = (...xs) => (said.push(...xs.map((x) => (typeof x === "string" ? x : const S = await import(`${root}/lib/server/store.ts`); const W = await import(`${root}/lib/server/workspace-skills.ts`); const B = await import(`${root}/lib/server/skill-bundle.ts`); +// How many of Bops' own skills are under its apps label (lib/builtin-skills.ts): what a prompt counts there. +const OWN_APPS = (await import(`${root}/lib/builtin-skills.ts`)).builtinSkills(true).filter((x) => x.label === "Apps and source control").length; const Route = await import(`${root}/app/api/skills/route.ts`); const route = async (body, method = "POST") => { const res = await Route[method](new Request(`http://localhost:3210/api/skills${method === "GET" ? body ?? "" : ""}`, { method, headers: { "content-type": "application/json", "x-bops-window": UI }, ...(method === "GET" ? {} : { body: JSON.stringify(body) }) })); @@ -281,7 +283,7 @@ await check("A new skill is a folder and a draft: its bots don't see it until it assert.equal(readFileSync(join(dir, "SKILL.md"), "utf8"), '---\nname: weekly-report\ndescription: "Sums up the week when asked for \\"the weekly report\\""\n---\n\n# Weekly report\n\n1. Read the chat.\n'); assert.notEqual(statSync(join(dir, "scripts", "sum.sh")).mode & 0o111, 0, "its script executable in its folder"); assert.deepEqual(W.skillsFor("boppy"), [], "a draft isn't any bot's yet"); - assert.equal(W.readSkill("boppy", "Weekly report"), 'No skill called "Weekly report". Your labels: Apps and source control (5). Open one with read_skill and label to see its skills.', "Bops' own only"); + assert.equal(W.readSkill("boppy", "Weekly report"), `No skill called "Weekly report". Your labels: Apps and source control (${OWN_APPS}). Open one with read_skill and label to see its skills.`, "Bops' own only"); // The same name again: its own slug. const twin = W.createSkill("ws_main", { name: "Weekly report", description: "Another", body: "# Two\n" }).ref; assert.equal(twin.slug, "weekly-report-2"); @@ -471,10 +473,10 @@ await check("Versions are version shaped (never a path); a key in a description await check("The prompts list skills and apps under their labels: up to 15 of its own by name and description, more as one line per label", async () => { W.publishSkill("ws_main", "app", "orgo-doctor"); const note = hear(W.skillsNote("max")); - assert.match(note, /^Your skills: [^\n]+\nApps and source control \(5\): [^\n]+\nWriting \(1\):\n- Weekly report: [^\n]+\nUnsorted \(1\):\n- Orgo doctor \(app\): Finds what's wrong with a computer and says the fix\.$/); + assert.match(note, new RegExp(`^Your skills: [^\\n]+\\nApps and source control \\(${OWN_APPS}\\): [^\\n]+\\nWriting \\(1\\):\\n- Weekly report: [^\\n]+\\nUnsorted \\(1\\):\\n- Orgo doctor \\(app\\): Finds what's wrong with a computer and says the fix\\.$`)); for (let i = 0; i < 20; i++) W.createSkill("ws_main", { name: `Many ${i}`, description: `Number ${i}`, body: `# ${i}\n`, status: "published" }); const many = W.skillsNote("max"); - assert.deepEqual(many.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(0, l.indexOf(":"))), ["- Apps and source control (5)", "- Writing (1)", "- Unsorted (21)"]); + assert.deepEqual(many.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(0, l.indexOf(":"))), [`- Apps and source control (${OWN_APPS})`, "- Writing (1)", "- Unsorted (21)"]); assert.match(many, /\n- Unsorted \(21\): Orgo doctor \(app\), Many 0, Many 1, /); assert.doesNotMatch(many, /Number \d/, "past 15 of its own, no descriptions: read_skill with the label lists them"); for (let i = 0; i < 20; i++) await W.deleteSkill("ws_main", "skill", `many-${i}`); From 942f1cb9ce37fce2974a33516691ef41272902b5 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:58:16 +0000 Subject: [PATCH 2/8] The owner's Mac brings in what Admins change in Orgo, and keeps its own work - Every couple of minutes the owner's Mac looks at Orgo for changes to a workspace's skills (every poll for a workspace with people in it, every fifth for one alone), so an Admin's publish, roll back, delete or approval made while the Mac was off lands soon after it's on. - A script approved by the owner or an Admin in Orgo now runs on the owner's Mac when Orgo says their role. A Member's never does, and an Admin made a Member again stops counting. An older Orgo that doesn't say roles counts only the owner's, as before. - When an Admin's publish comes in, a skill's folder with work the owner hasn't published yet is kept, and the Admin's version goes beside it. - When the owner and an Admin both published the same version number with different content, the owner's goes to Orgo as the next free number instead of the two staying different under one number. A roll back made on the Mac now reaches Orgo the same way. --- lib/server/workspace-skills.ts | 174 ++++++++++++++++++++++++++++++--- lib/server/workspaces.ts | 4 +- lib/types.ts | 4 +- scripts/test-skills.mjs | 120 ++++++++++++++++++++++- 4 files changed, 285 insertions(+), 17 deletions(-) diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index 56340445d..c82ae0a65 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -129,6 +129,7 @@ const g = globalThis as unknown as { bopsSkillLabelsRoute?: Map; bopsSkillLabelRename?: Map; bopsLabelSuggest?: Map; timer: ReturnType }>; + bopsSkillsPoll?: { timer: ReturnType; ticks: number }; }; export class SkillError extends Error { @@ -2118,8 +2119,24 @@ function renamesLater() { type ApprovalChange = { pid: string; sha256: string; on: boolean; path?: string }; /** Approvals and revokes Orgo hasn't taken yet, kept on disk until it does. */ const approvalsOut = outbox("skill-approvals", 2000); -/** `approved_by`: the Orgo user who approved it. */ -type RemoteApproval = { sha256: string; path_hint?: string | null; approved_by?: string | null; approved_at?: string }; +/** + * `approved_by`: the Orgo user who approved it. `approved_by_role`: their role in the workspace now, as orgo-web reads it + * with the list (owner, admin, member; null once they left it); an older orgo-web doesn't say it. + */ +type RemoteApproval = { sha256: string; path_hint?: string | null; approved_by?: string | null; approved_at?: string; approved_by_role?: string | null }; + +/** + * Whose approval of orgo-web's runs a script here: with its role said, the owner's or an Admin's now (an Admin can do + * everything in a workspace but delete it or pay; one made a Member again, or gone, no longer counts); without it (an + * orgo-web from before the role), only the person signed in here, as before. `owner`: that person's Orgo user id. + * A Member's never, whatever switch they have: theirs would run nothing on this Mac (phase 2 counts one only on the bots' + * computers, never here). + */ +export function approvalCounts(a: RemoteApproval | undefined, owner: string | undefined): "owner" | "admin" | null { + if (!a) return null; + if (a.approved_by_role !== undefined) return a.approved_by_role === "owner" ? "owner" : a.approved_by_role === "admin" ? "admin" : null; + return !!owner && a.approved_by === owner ? "owner" : null; +} const g2 = globalThis as unknown as { bopsApprovalsTimer?: ReturnType; bopsApprovalsTries?: number; bopsApprovalsSaid?: boolean; bopsApprovalsParked?: Set }; /** Outbox entries Orgo couldn't place (unknown_sha), by id: sent again only after a sync sent a file with that content (or once a run of Bops). */ const parked = (g2.bopsApprovalsParked ??= new Set()); @@ -2222,18 +2239,18 @@ function markApproval(wsId: string, sha256: string, on: boolean) { } /** - * Orgo's approvals here (GET …/skills/approvals): one the person signed in here made (on orgo.ai, or in - * Bops on another Mac: approved_by) is taken, another member's never (it doesn't run a script on this - * Mac); one Orgo had and no longer lists was revoked there and goes here too; one made here that Orgo - * never had is sent, while its script is in one of the workspace's skills. What waits to be sent from here - * stands. + * Orgo's approvals here (GET …/skills/approvals): the owner's and an Admin's (approvalCounts: orgo-web says the + * approver's role now; an older one, only the person signed in here) are taken, a Member's never (it doesn't run a + * script on this Mac), and one taken before from someone who is no longer an Admin goes; one Orgo had and no longer + * lists was revoked there and goes here too; one made here that Orgo never had is sent, while its script is in one of + * the workspace's skills. What waits to be sent from here stands. Each taken says whose it was (ScriptApproval.by). */ async function pullApprovals(wsId: string, pid: string) { const got = await skillsCall<{ approvals?: RemoteApproval[] }>(pid, "/approvals"); if (!got || got.status !== 200 || !Array.isArray(got.json.approvals)) return; const owner = getState().account?.user.id; const there = new Map(got.json.approvals.filter((a) => typeof a?.sha256 === "string").map((a) => [a.sha256, a])); - const byOwner = (a: RemoteApproval | undefined) => !!owner && !!a && a.approved_by === owner; + const byOwner = (a: RemoteApproval | undefined) => approvalCounts(a, owner) !== null; const waiting = new Set(approvalsOut.waiting(wsId).map((e) => e.item.sha256)); const mine = workspaceById(wsId)?.approvals ?? []; const send: ScriptApproval[] = []; @@ -2243,7 +2260,10 @@ async function pullApprovals(wsId: string, pid: string) { if (waiting.has(a.sha256)) kept.push(a); else if (there.has(a.sha256)) { // Made here, or taken from Orgo while Orgo says this person made it (one taken before from another member's goes). - if (a.inBops || byOwner(there.get(a.sha256)) || !owner) kept.push(a.synced ? a : { ...a, synced: true }); + if (a.inBops || byOwner(there.get(a.sha256)) || !owner) { + const by = a.inBops ? a.by : (approvalCounts(there.get(a.sha256), owner) ?? undefined); + kept.push(a.synced && a.by === by ? a : { ...a, synced: true, ...(by ? { by } : {}) }); + } } // Orgo had it and doesn't now: revoked there. Never had it: sent, while its script is in a skill here. else if (!a.synced) { @@ -2252,7 +2272,7 @@ async function pullApprovals(wsId: string, pid: string) { } } for (const [sha, a] of there) - if (byOwner(a) && !waiting.has(sha) && !kept.some((x) => x.sha256 === sha)) kept.push({ sha256: sha, path: (a.path_hint ?? "").slice(0, 512), at: Date.parse(a.approved_at ?? "") || Date.now(), synced: true }); + if (byOwner(a) && !waiting.has(sha) && !kept.some((x) => x.sha256 === sha)) kept.push({ sha256: sha, path: (a.path_hint ?? "").slice(0, 512), at: Date.parse(a.approved_at ?? "") || Date.now(), synced: true, by: approvalCounts(a, owner)! }); if (kept.length !== mine.length || kept.some((x, i) => x !== mine[i])) update((s) => { const ws = s.workspaces?.find((w) => w.id === wsId); @@ -2314,6 +2334,49 @@ function payloadOf(wsId: string, r: SkillRef) { }; } +/** + * Whether Orgo's version of a skill under the number this Mac uses (its export) has the same text and files as this + * Mac's (the frontmatter aside, which each side writes in its own words); null when it couldn't be read. + */ +async function sameAsOrgo(wsId: string, pid: string, remoteId: string, r: SkillRef): Promise { + const mine = r.version ? readBundle(versionDir(wsId, r.kind, r.slug, r.version)) : null; + if (!mine || !r.version) return null; + const ex = await skillsCall<{ files?: Record }>(pid, `/${encodeURIComponent(remoteId)}/export?version=${encodeURIComponent(r.version)}`); + if (!ex || ex.status !== 200 || !ex.json.files) return null; + const parsed = parseSkillMd(ex.json.files["SKILL.md"]?.content ?? ""); + if (!parsed.ok) return null; + const theirs = Object.entries(ex.json.files).filter(([p]) => p !== "SKILL.md").map(([p, f]) => `${p}:${f.sha256 ?? ""}`).sort().join(); + return parsed.body.replace(/^\n+/, "") === mine.body.replace(/^\n+/, "") && theirs === mine.files.map((f) => `${f.path}:${f.sha256}`).sort().join(); +} + +/** + * The version a skill here is at, under a number Orgo has already (an Admin published that number there while this Mac + * was off, or this Mac rolled back to a number Orgo moved past): its bots' version here becomes a new one, the next + * number free both here and in Orgo (its versions, GET …/skills/{id}), with the same text and files (its SKILL.md's + * metadata.version the new number), and the ref points at it. Every version kept here stays. Answers the new number, or + * null when Orgo's versions couldn't be read or the version's folder isn't here. + */ +async function freeVersion(wsId: string, pid: string, remoteId: string, r: SkillRef): Promise { + const from = r.version; + if (!from) return null; + const got = await skillsCall<{ versions?: unknown }>(pid, `/${encodeURIComponent(remoteId)}`); + if (got?.status !== 200 || !Array.isArray(got.json.versions)) return null; + const theirs = got.json.versions.filter((x): x is string => typeof x === "string"); + const to = nextVersion([...theirs, ...(r.versions ?? []).map((x) => x.version)]); + const was = readBundle(versionDir(wsId, r.kind, r.slug, from)); + const dir = versionDir(wsId, r.kind, r.slug, to); + if (!was || !dir) return null; + const md = renderSkillMd(withVersion(was.frontmatter, to), was.body); + writeTree(dir, md, was.files); + const sha = bundleSha(md, was.files); + patchRef(wsId, r.kind, r.slug, (x) => { + x.versions = [...(x.versions ?? []), { version: to, at: Date.now(), sha256: sha }]; + x.version = to; + }); + console.warn(`[skills] ${r.slug}: Orgo has ${from} already, so this Mac's goes as ${to}`); + return to; +} + /** `removed`: deleted on Orgo, so deleted here. `retry`: Orgo didn't take all of it for now (tried again after a backoff). */ export type SyncResult = { mode: "local" | "orgo"; pushed: number; pulled: number; failed: number; removed?: number; retry?: boolean }; @@ -2480,7 +2543,11 @@ async function syncOnce(wsId: string): Promise { // Its label too, where Orgo keeps it in a column (labelColumn): a move goes at once, without a version. const withLabelColumn = labelColumn.get(pid) === true; let p = await skillsCall<{ skill?: RemoteSkill }>(pid, `/${encodeURIComponent(there.id)}`, { method: "PATCH", body: { name: r.name, description: r.description, bot_ids: r.botIds, status: r.status, ...(withLabelColumn ? { label: columnLabel(r) } : {}) } }); - const v = r.version && there.current_version?.version !== r.version ? payloadOf(wsId, r) : null; + // Both moved to the same number since they last agreed (an Admin published it in Orgo, the owner here): the same + // number with other content is a clash, sent as the next free number below (freeVersion). + const theirsNow = there.current_version?.version ?? ""; + const clash = !!r.version && theirsNow === r.version && r.remoteVersion !== undefined && r.remoteVersion !== theirsNow && (await sameAsOrgo(wsId, pid, there.id, r)) === false; + const v = r.version && (theirsNow !== r.version || clash) ? payloadOf(wsId, r) : null; // The label Orgo's SKILL.md has from now on: the one a version sent carries. let sentLabel: string | undefined = withLabelColumn && p?.status === 200 ? sentLabelOf(r) : undefined; if (!withLabelColumn && p?.status === 200 && hasColumn(p.json.skill)) { @@ -2500,10 +2567,23 @@ async function syncOnce(wsId: string): Promise { } } if (v) { - const sentV = await skillsCall(pid, `/${encodeURIComponent(there.id)}/versions`, { method: "POST", body: { body: v.body, files: v.files, version: r.version } }); + const versionsOf = `/${encodeURIComponent(there.id)}/versions`; + let sentV = await skillsCall<{ code?: string }>(pid, versionsOf, { method: "POST", body: { body: v.body, files: v.files, version: r.version } }); + // That number is Orgo's already (an Admin published one there while this Mac was off, or a roll back here to a + // number Orgo moved past): this Mac's version goes as the next number free on both sides, here too. + if (sentV?.status === 409 && sentV.json.code === "version_exists") { + const next = await freeVersion(wsId, pid, there.id, findRef(wsId, r.kind, r.slug) ?? r); + const now = next ? findRef(wsId, r.kind, r.slug) : undefined; + const again = now ? payloadOf(wsId, now) : null; + if (next && now && again) sentV = await skillsCall<{ code?: string }>(pid, versionsOf, { method: "POST", body: { body: again.body, files: again.files, version: next } }); + } if (sentV && sentV.status < 300) { for (const sha of v.shas) pushed.add(sha); sentLabel = sentLabelOf(r); + } else if (sentV?.status === 409 && sentV.json.code !== "unchanged") { + // Not taken (still a number Orgo has): not marked as agreed, so it goes again. + res.failed++; + continue; } } if (p?.status === 200) { @@ -2633,7 +2713,28 @@ async function exportedFile(path: string, f: ExportFile): Promise<{ path: string return text !== null ? { path, content: text, executable: !!f.executable } : { path, content: null, data: bytes, executable: !!f.executable }; } -/** Orgo's copy of a skill (its export: SKILL.md and every file) written here, as `into` or as a new one. */ +/** Two bundles' text and files the same (the blank line after the frontmatter aside): what skillDetail calls unchanged. */ +const sameWork = (a: Bundle, b: Bundle) => a.body.replace(/^\n+/, "") === b.body.replace(/^\n+/, "") && a.files.map((f) => `${f.path}:${f.sha256}`).join() === b.files.map((f) => `${f.path}:${f.sha256}`).join(); + +/** + * Whether a skill's working folder here holds work not published yet, which a pull must keep: its text or files + * differ from the version its bots read (skillDetail's "changed"), or, a draft with no version here, it changed here + * since this Mac and Orgo last agreed. Orgo's version then goes beside it (its .versions folder, the ref's pointer), so + * an Admin's publish never wipes the owner's next version in the making. + */ +function workInFolder(wsId: string, r: SkillRef): boolean { + const here = readBundle(skillDir(wsId, r.kind, r.slug)); + if (!here) return false; + const used = r.version ? readBundle(versionDir(wsId, r.kind, r.slug, r.version)) : null; + if (used) return !sameWork(here, used); + return r.status === "draft" && !!r.syncedAt && r.updatedAt > r.syncedAt; +} + +/** + * Orgo's copy of a skill (its export: SKILL.md and every file) written here, as `into` or as a new one. Into one whose + * folder holds work not published yet (workInFolder), only Orgo's version is written (its .versions folder, and the + * ref's pointer to it): the folder stays as it is. + */ async function pull(wsId: string, pid: string, x: RemoteSkill, into?: SkillRef): Promise { const ex = await skillsCall<{ files?: Record; version?: string }>(pid, `/${encodeURIComponent(x.id)}/export`); if (!ex || ex.status !== 200 || !ex.json.files) return false; @@ -2667,7 +2768,9 @@ async function pull(wsId: string, pid: string, x: RemoteSkill, into?: SkillRef): const unmoved = into.remoteLabel !== undefined && sameLabel(pulledAs, into.remoteLabel); const mine = suggested(into) ? undefined : into.label; const keep = unmoved && !!mine && !sameLabel(pulledAs, mine) ? mine : column && !sameLabel(pulledAs, labelOf(parsed.frontmatter) ?? UNSORTED) ? pulledAs : undefined; - writeTree(skillDir(wsId, kind, into.slug)!, keep ? renderSkillMd(withLabel(parsed.frontmatter ?? {}, keep), parsed.body) : md, files); + // Work not published yet in its folder: kept, and Orgo's version goes beside it (with a version to go to). + const keepFolder = !!version && workInFolder(wsId, into); + if (!keepFolder) writeTree(skillDir(wsId, kind, into.slug)!, keep ? renderSkillMd(withLabel(parsed.frontmatter ?? {}, keep), parsed.body) : md, files); if (version) writeTree(versionDir(wsId, kind, into.slug, version)!, md, files); // Moved on Orgo (another Mac, orgo.ai): that label, the skill's own from now on (Unsorted: none). const label = (column || pulledLabel) && !unmoved ? knownLabel(wsId, pulledAs) : undefined; @@ -2751,6 +2854,49 @@ export function queueSync(wsId: string, tries = 0) { /** Whether Orgo has the skills routes for this project, as last seen (undefined: not asked yet). */ export const skillsRoutesSeen = (projectId: string) => routes.get(projectId); +/** How often the owner's Mac looks at Orgo for changes made there (an Admin's publish, roll back, delete or approval). */ +const POLL_MS = 2 * 60_000; +/** A workspace no one else is in is looked at every this many polls (10 minutes): an Admin with no access entry yet still lands. */ +const POLL_ALONE_EVERY = 5; + +/** + * The workspaces one poll syncs (the `ticks`th): each with an Orgo project whose skills routes Orgo has (or that wasn't + * asked yet), on the owner's Mac. A workspace with people in it (Workspace.memberAccess: someone the owner added) + * every poll; any other every POLL_ALONE_EVERY polls. One with a sync queued, backing off or under way is left to it. + */ +export function skillsPollDue(ticks: number): string[] { + if (onRunner() || userDir() === null) return []; + const timers = g.bopsSkillsSync; + const going = g.bopsSkillsInFlight; + return (getState().workspaces ?? []) + .filter((w) => !!w.orgoProjectId && routes.get(w.orgoProjectId) !== false) + .filter((w) => Object.keys(w.memberAccess ?? {}).length > 0 || ticks % POLL_ALONE_EVERY === 0) + .filter((w) => !timers?.has(w.id) && !going?.has(w.id)) + .map((w) => w.id); +} + +/** + * On the owner's Mac, while Bops runs (workspaces.ts startProjectBinding, once a start): every two minutes, what changed + * in Orgo comes here (syncSkills reads only what changed since the last: ?since=), so an Admin's publish, roll back, + * delete or approval made with this Mac off lands here soon after it's on, and bots on this Mac use it. Never on a + * computer's runner, which reads Orgo itself (runner-skills.ts). + */ +export function startSkillsPoll() { + if (onRunner() || g.bopsSkillsPoll) return; + const poll = { ticks: 0, timer: setInterval(() => { + poll.ticks++; + for (const wsId of skillsPollDue(poll.ticks)) queueSync(wsId); + }, POLL_MS) }; + poll.timer.unref?.(); + g.bopsSkillsPoll = poll; +} + +/** Tests: stop the poll. */ +export function stopSkillsPoll() { + if (g.bopsSkillsPoll) clearInterval(g.bopsSkillsPoll.timer); + g.bopsSkillsPoll = undefined; +} + /** * A deleted workspace's skills and apps go from this Mac (remove.ts deleteWorkspace, while it's still * listed, so its folder is found): its folder (workspaces/, with every skill's diff --git a/lib/server/workspaces.ts b/lib/server/workspaces.ts index 691fa8e5b..995b178ca 100644 --- a/lib/server/workspaces.ts +++ b/lib/server/workspaces.ts @@ -813,7 +813,7 @@ function bindLater() { * hadn't taken when Bops last quit goes now, as its retries lived in memory only: each one's skills are * synced (a create, an edit or a bot dropped since its last sync, SkillRef.updatedAt past syncedAt, and * what Orgo has), and its Orbit is sent where its index still holds what Orgo hasn't taken (deletes, - * items or facts: orbitOutbox). + * items or facts: orbitOutbox). Then skills are looked at in Orgo every couple of minutes (startSkillsPoll). */ export function startProjectBinding() { if (g.bopsWsBindStarted || onRunner() || !stateReady() || stateTooNew()) return; @@ -823,6 +823,8 @@ export function startProjectBinding() { // A deleted workspace's memory that memory didn't take before Bops last quit. void import("./memory-deletes").then((m) => m.memoryDeletesOnStart()).catch((e: Error) => console.warn(`[memory] deletes from before: ${e.message}`)); void pushBoundOnStart().catch((e: Error) => console.warn(`[workspaces] sending what waited from before: ${e.message}`)); + // From then on, what an Admin changed in a workspace's skills in Orgo comes here every couple of minutes. + void import("./workspace-skills").then((m) => m.startSkillsPoll()).catch((e: Error) => console.warn(`[skills] poll: ${e.message}`)); } async function pushBoundOnStart() { diff --git a/lib/types.ts b/lib/types.ts index d8f6f2c40..67bba149a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -494,9 +494,11 @@ export type ScriptApproval = { synced?: boolean; /** * Made in a Bops window by the person signed in (approveScript), not taken from Orgo. One taken from - * Orgo counts only while Orgo says that person made it: another member's approval never runs a script here. + * Orgo counts only while Orgo says the owner or an Admin made it: a Member's approval never runs a script here. */ inBops?: boolean; + /** Whose it was, as Orgo said when it was taken from there: the owner's or an Admin's (lib/server/workspace-skills.ts approvalCounts). */ + by?: "owner" | "admin"; }; export type WorkspaceLine = { diff --git a/scripts/test-skills.mjs b/scripts/test-skills.mjs index f2eecf183..41dc8d113 100644 --- a/scripts/test-skills.mjs +++ b/scripts/test-skills.mjs @@ -157,6 +157,7 @@ const server = createServer(async (req, res) => { } if (!sid && req.method === "POST") { const s = { id: `sk_${++remote.seq}`, pid, slug: body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), name: body.name, description: body.description, type: body.type, status: body.status, bot_ids: body.bot_ids, source: body.source, version: body.version ?? "0.1.0", body: body.body, files: body.files, updated_at: now() }; + s.versions = [s.version]; remote.skills.push(s); return send(res, 201, { skill: view(s), version: { version: s.version }, warnings: [] }); } @@ -169,8 +170,14 @@ const server = createServer(async (req, res) => { return [f.path, { ...where, sha256: f.badSha ?? sha(bytes), executable: !!f.executable }]; }; if (sub === "export") return send(res, 200, { slug: s.slug, name: s.name, description: s.description, type: s.type, version: s.version, files: { "SKILL.md": { content: s.body }, ...Object.fromEntries((s.files ?? []).map(exported)) } }); + // Its versions (GET …/skills/{id}): every number it ever had. + const numbers = () => (s.versions ??= [s.version].filter(Boolean)); + if (!sub && req.method === "GET") return send(res, 200, { skill: view(s), version: { version: s.version, body: s.body }, versions: numbers() }); if (sub === "versions" && req.method === "POST") { - Object.assign(s, { body: body.body ?? s.body, files: body.files ?? s.files, version: body.version, updated_at: now() }); + // A number it has already, as orgo-web: versions never change. + if (body.version && numbers().includes(body.version)) return send(res, 409, { error: `Version ${body.version} already exists. Versions never change; pick a new number.`, code: "version_exists" }); + numbers().push(body.version); + Object.assign(s, { body: body.body ?? s.body, files: body.files ?? s.files, version: body.version, status: "published", updated_at: now() }); return send(res, 201, { skill: view(s), version: { version: s.version } }); } if (req.method === "PATCH") { @@ -786,6 +793,93 @@ await check("Several Macs agree: a sync reads only what changed since the last ( clearTimers(); }); +await check("An Admin's publish in Orgo while the owner's Mac was off comes in without wiping the owner's unpublished folder; a version number both took goes as the next free one, nothing kept here lost", async () => { + const clearTimers = () => { + for (const t of globalThis.bopsSkillsSync?.values() ?? []) clearTimeout(t); + globalThis.bopsSkillsSync?.clear(); + }; + const ref = (slug) => W.findRef("ws_two", "skill", slug); + const remoteOf = (slug) => remote.skills.find((x) => x.id === ref(slug).remoteId); + const later = (ms) => new Date(Date.now() + ms).toISOString(); + const md = (slug, text, version) => `---\nname: ${slug}\ndescription: Hands a ticket over.\nmetadata:\n version: "${version}"\n---\n${text}`; + // Published here and in step with Orgo. + const a = W.createSkill("ws_two", { name: "Handoff", description: "Hands a ticket over.", body: "# Handoff\n\n1. Read it.\n", status: "published" }).ref; + const b = W.createSkill("ws_two", { name: "Escalate", description: "Hands a ticket over.", body: "# Escalate\n\n1. Page someone.\n", status: "published" }).ref; + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(remoteOf(a.slug).version, "0.1.0"); + + // Handoff: the owner works on its folder (not published); an Admin publishes 0.1.1 in Orgo, later. + W.updateSkill("ws_two", "skill", a.slug, { body: "# Handoff\n\n1. Read it.\n2. The owner's step, not published.\n" }); + Object.assign(remoteOf(a.slug), { body: md(a.slug, "# Handoff\n\n1. Read it.\n2. The Admin's step.\n", "0.1.1"), version: "0.1.1", updated_at: later(5_000) }); + remoteOf(a.slug).versions.push("0.1.1"); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(ref(a.slug).version, "0.1.1", "Orgo's version is the one in use"); + assert.match(W.readSkill("tess", a.slug), /The Admin's step/, "its bots read the Admin's"); + const d = W.skillDetail("ws_two", "skill", a.slug); + assert.match(d.body, /The owner's step, not published/, "the owner's folder kept"); + assert.equal(d.changed, true, "and said to differ from the version in use"); + assert.ok(existsSync(join(dataDir(), "p_two", ".versions", "skills", a.slug, "0.1.1", "SKILL.md"))); + + // Escalate: an Admin publishes 0.1.1 in Orgo; then, before this Mac read it, the owner publishes their own 0.1.1 here, later. + Object.assign(remoteOf(b.slug), { body: md(b.slug, "# Escalate\n\n1. The Admin's way.\n", "0.1.1"), version: "0.1.1", updated_at: later(-30_000) }); + remoteOf(b.slug).versions.push("0.1.1"); + W.updateSkill("ws_two", "skill", b.slug, { body: "# Escalate\n\n1. The owner's way.\n" }); + assert.equal(W.publishSkill("ws_two", "skill", b.slug).version, "0.1.1"); + clearTimers(); + remote.calls = []; + const r = await W.syncSkills("ws_two"); + const posts = remote.calls.filter((c) => c.method === "POST" && c.path.endsWith("/versions")).map((c) => c.body.version); + assert.deepEqual(posts, ["0.1.1", "0.1.2"], "refused as Orgo's number, then sent as the next free one"); + assert.equal(r.failed, 0, JSON.stringify(r)); + assert.deepEqual([remoteOf(b.slug).version, ref(b.slug).version], ["0.1.2", "0.1.2"]); + assert.match(remoteOf(b.slug).body, /The owner's way/); + // Said in the log, once. + const said = logged.findIndex((l) => l === `[skills] ${b.slug}: Orgo has 0.1.1 already, so this Mac's goes as 0.1.2`); + assert.ok(said >= 0); + logged.splice(said, 1); + assert.ok(existsSync(join(dataDir(), "p_two", ".versions", "skills", b.slug, "0.1.2", "SKILL.md"))); + assert.match(readFileSync(join(dataDir(), "p_two", ".versions", "skills", b.slug, "0.1.2", "SKILL.md"), "utf8"), /version: "0\.1\.2"/, "its SKILL.md here says the new number"); + assert.deepEqual(ref(b.slug).versions.map((v) => v.version), ["0.1.0", "0.1.1", "0.1.2"], "every version kept here stays"); + assert.match(W.readSkill("tess", b.slug), /^Skill: Escalate \(version 0\.1\.2\)[\s\S]*The owner's way/); + // The next sync agrees: nothing more sent. + clearTimers(); + remote.calls = []; + await W.syncSkills("ws_two"); + assert.equal(remote.calls.filter((c) => c.path.endsWith("/versions")).length, 0); + for (const x of [a, b]) await W.deleteSkill("ws_two", "skill", x.slug); + clearTimers(); +}); + +await check("The owner's Mac looks at Orgo every couple of minutes: a workspace with people every time, one alone every fifth, none with a sync already going, never on a runner", async () => { + const due = (n) => W.skillsPollDue(n); + // (One started by a start's binding, earlier in this file: stopped, so this one's is the test's.) + W.stopSkillsPoll(); + S.update((s) => { + s.workspaces.find((w) => w.id === "ws_two").memberAccess = { "admin@example.com": { others: "write", at: 1 } }; + }); + assert.deepEqual(due(1), ["ws_two"], "with people: every poll"); + S.update((s) => void delete s.workspaces.find((w) => w.id === "ws_two").memberAccess); + assert.deepEqual(due(1), [], "alone: not every poll"); + assert.deepEqual(due(5), ["ws_two"], "alone: every fifth"); + W.queueSync("ws_two"); + assert.deepEqual(due(5), [], "a sync queued already"); + for (const t of globalThis.bopsSkillsSync?.values() ?? []) clearTimeout(t); + globalThis.bopsSkillsSync?.clear(); + process.env.BOPS_RUNNER = "1"; + try { + assert.deepEqual(due(5), [], "never on a computer's runner"); + W.startSkillsPoll(); + assert.equal(globalThis.bopsSkillsPoll, undefined, "no poll there either"); + } finally { + delete process.env.BOPS_RUNNER; + } + W.startSkillsPoll(); + assert.ok(globalThis.bopsSkillsPoll, "on the Mac"); + W.stopSkillsPoll(); +}); + await check("Script approvals reach Orgo with the Bops sign-in key: approved and revoked through an outbox kept on disk, one whose skill isn't on Orgo yet sent after its sync, orgo.ai's taken here, its revokes too", async () => { const O = await import(`${root}/lib/server/sync-outbox.ts`); const clearTimers = () => { @@ -865,6 +959,30 @@ await check("Script approvals reach Orgo with the Bops sign-in key: approved and clearTimers(); await W.syncSkills("ws_two"); assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false, "taken before from another member's: gone"); + // Orgo says whose role it is now: an Admin's approval runs it here (an Admin can do everything but delete the workspace + // or pay); a Member's never; an Admin made a Member again, or gone from the workspace, no longer. + const ws2 = () => S.getState().workspaces.find((w) => w.id === "ws_two"); + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by_role: "admin" }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, true, "an Admin's approval runs it here"); + assert.equal(ws2().approvals.find((a) => a.sha256 === shaGo).by, "admin"); + for (const role of ["member", null]) { + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by_role: role }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false, `approved_by_role ${role}: not here`); + } + // The owner's, by role, even made on another account's Bops signed in as the owner elsewhere: runs. + remote.approvals.set(`p_two:${shaGo}`, { ...editors(), approved_by: "u_owner_elsewhere", approved_by_role: "owner" }); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(ws2().approvals.find((a) => a.sha256 === shaGo)?.by, "owner"); + // As it was: another member's, from an orgo-web that doesn't say roles. + remote.approvals.set(`p_two:${shaGo}`, editors()); + clearTimers(); + await W.syncSkills("ws_two"); + assert.equal(W.skillDetail("ws_two", "skill", k.slug).files[0].approved, false); // Approved in this window: it runs, though Orgo's record stays the other member's. W.approveScript("ws_two", shaGo); await wait(() => S.getState().workspaces.find((w) => w.id === "ws_two").approvals.find((a) => a.sha256 === shaGo)?.synced); From 16057b0ca501fa3961cdf58fe5f7d73639b6f230 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:04:30 +0000 Subject: [PATCH 3/8] Bots answered on their own computer use the skills published in Orgo, with the owner's Mac off A computer's runner now reads its workspace's published skills from Orgo with the computer's own key, at most once a minute (at the start of a chat turn, and before skills are copied to the computer), and keeps them on the computer's disk. Its bots get the ones Bops' own rule gives them (named for them, or by their labels), read_skill reads them, and a copy to the computer carries them, with a script runnable only once the owner or an Admin approved it. A skill taken back to a draft or deleted in Orgo leaves the bots there too. When Orgo doesn't answer, the copy kept stays; with an older Orgo, or nothing read yet, everything works as before. --- lib/server/runner-chat.ts | 17 ++- lib/server/runner-skills.ts | 269 +++++++++++++++++++++++++++++++++ lib/server/workspace-skills.ts | 83 +++++++--- scripts/test-skills-v2.mjs | 131 ++++++++++++++++ 4 files changed, 473 insertions(+), 27 deletions(-) create mode 100644 lib/server/runner-skills.ts diff --git a/lib/server/runner-chat.ts b/lib/server/runner-chat.ts index a48ec0beb..5124e77af 100644 --- a/lib/server/runner-chat.ts +++ b/lib/server/runner-chat.ts @@ -2,7 +2,7 @@ import "server-only"; import { mkdir, stat, unlink, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { RUNNER_HOLD, RUNNER_TURNS_DIR, TURN_BEAT_MS, type RunnerClaim, type RunnerResume, type RunnerTurnEvent, type TurnMember } from "@/cloud/protocol"; -import type { AppApproval, MemberTurn } from "@/lib/types"; +import { workspaceOf, type AppApproval, type MemberTurn } from "@/lib/types"; import { cloudFetch } from "./cloud"; import { loopHere, onRunner } from "./persist"; import { getState, pullState, stateReady } from "./store"; @@ -17,7 +17,8 @@ import { runInTurn, type TurnContext } from "./turn-context"; * 1. makes sure it's on its own computer (runner.ts checkIdentity: a copy never answers), then claims it (Bops Cloud gives * it only to this computer's device, one at a time per chat, and checks again that a member still may); * 2. reads the state until it has the message (Bops Cloud wrote it, never this runner); - * 3. runs the same turn the Mac would (chat.ts answerMessage) in the turn's context (turn-context.ts): its rows carry the + * 3. reads the skills published in Orgo (runner-skills.ts, at most once a minute), then runs the same turn the Mac would + * (chat.ts answerMessage) in the turn's context (turn-context.ts): its rows carry the * turn and Bops Cloud's clock, and the first thing it does that can't be done twice is said at once, so a runner that * goes quiet after that never has it run again; * 4. beats every TURN_BEAT_MS while it runs (a 409 means Bops Cloud took it back: it stops and writes nothing more), @@ -292,7 +293,17 @@ async function claimAndAnswer(ev: RunnerTurnEvent) { } const ctx: TurnContext = { id: t.id, chatId: t.chatId, botId: t.botId, offsetMs, humanAt: t.humanAt, acting: false, cancelled: false }; const answer = await answerOf(); - await asTurn(ctx, () => answer(t.chatId, t.messageId, saidBy(t.member)), { messageId: t.messageId }); + await asTurn( + ctx, + async () => { + // The skills the owner or an Admin published in Orgo, read first (runner-skills.ts, a few seconds at most): the bot + // follows them with the owner's Mac off. + const b = getState().bots.find((x) => x.id === t.botId); + if (b) await import("./runner-skills").then((m) => m.refreshRunnerSkills(workspaceOf(b)), () => {}); + return answer(t.chatId, t.messageId, saidBy(t.member)); + }, + { messageId: t.messageId }, + ); } /** diff --git a/lib/server/runner-skills.ts b/lib/server/runner-skills.ts new file mode 100644 index 000000000..03646550e --- /dev/null +++ b/lib/server/runner-skills.ts @@ -0,0 +1,269 @@ +import "server-only"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import type { SkillKind } from "@/lib/types"; +import { keptLabel } from "@/lib/skill-labels"; +import { onRunner } from "./persist"; +import { isVersion, labelOf, normalizePath, parseSkillMd, RISKS, sha256Hex, textOf, type BundleFile } from "./skill-bundle"; +import { getState, userDir } from "./store"; +import type { Bundle } from "./workspace-skills"; + +/* + * A workspace's published skills as Orgo keeps them, read by a computer's runner (BOPS_RUNNER) with its own key, so a + * chat or a task its bots run there follows the skills the owner or an Admin published, with the owner's Mac off. + * orgo-web lets a computer's own key read its computer's workspace's published skills, each at its current version, and + * the approvals with the approver's role (lib/api-keys/runner-scope.ts computerSkillReadScope there); every published + * one of the workspace, not only its bots': which bot has which is Bops' own rule (lib/skill-labels.ts botHasSkill), + * kept by workspace-skills.ts as on the Mac (refsOf lays this list over the state's, skillsFor filters). + * + * Read at most once a minute a workspace (refreshRunnerSkills: at a chat turn's start, runner-chat.ts, and before a + * copy to the computer, copySkillsToComputer), each call held to a few seconds, and kept on the computer's disk under + * userDir()/runner-skills// (a version never changes, so one read is read once): the list (index.json) and + * each version's files. A version no longer listed goes from there. Orgo not answering, an orgo-web from before + * (403 computer_key) or no Orgo project: the last copy kept stays, and with none, everything is as before (the state's + * list, and what the owner's Mac last copied to the computer). Never written to the state: a computer may not write a + * workspace's skills (cloud/computer-scope.ts). Nothing of a skill is logged. + */ + +/** A published skill as the runner keeps it: what its ref says, and its files (the version's folder holds them). */ +export type RunnerSkill = { + id: string; + slug: string; + kind: SkillKind; + name: string; + description: string; + version: string; + botIds: string[]; + label?: string; + risk?: string; + updatedAt: string; + files: { path: string; sha256: string; size: number; executable: boolean; binary: boolean }[]; +}; +/** What a runner keeps of a workspace's: its published skills, and the contents the owner or an Admin approved. */ +type Kept = { pid: string; at: number; skills: RunnerSkill[]; approvals: string[] }; + +const THROTTLE_MS = 60_000; +/** How long one call to Orgo may take, and how long a turn waits for a read (it goes on in the background after). */ +const CALL_MS = 5_000; +const WAIT_MS = 8_000; + +const g = globalThis as unknown as { bopsRunnerSkills?: Map }> }; +const memo = () => (g.bopsRunnerSkills ??= new Map()); +/** A project's entry, made when missing: changed in place, so a read under way and its end see the same one. */ +const entry = (pid: string) => { + let e = memo().get(pid); + if (!e) memo().set(pid, (e = { tried: 0 })); + return e; +}; + +const root = () => { + const dir = userDir(); + return dir === null ? null : join(/*turbopackIgnore: true*/ dir, "runner-skills"); +}; +const safe = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) || "_"; +const pidDir = (pid: string) => { + const r = root(); + return r ? join(/*turbopackIgnore: true*/ r, safe(pid)) : null; +}; +const versionDir = (pid: string, kind: SkillKind, slug: string, version: string) => { + const d = pidDir(pid); + return d && isVersion(version) ? join(/*turbopackIgnore: true*/ d, kind === "app" ? "apps" : "skills", safe(slug), version) : null; +}; + +/** The copy kept for a project: in memory, else from its index on disk; null when there's none. */ +function keptOf(pid: string): Kept | null { + const m = memo().get(pid); + if (m && m.kept !== undefined) return m.kept; + let kept: Kept | null = null; + const d = pidDir(pid); + try { + if (d && existsSync(/*turbopackIgnore: true*/ join(d, "index.json"))) { + const j = JSON.parse(readFileSync(/*turbopackIgnore: true*/ join(d, "index.json"), "utf8")) as Kept; + if (j && j.pid === pid && Array.isArray(j.skills) && Array.isArray(j.approvals)) kept = j; + } + } catch { + kept = null; + } + entry(pid).kept = kept; + return kept; +} + +/** On a runner: a workspace's published skills and the approvals that count, as last read from Orgo; null without one. */ +export function runnerOverlay(pid: string): { skills: RunnerSkill[]; approvals: Set } | null { + if (!onRunner()) return null; + const kept = keptOf(pid); + return kept ? { skills: kept.skills, approvals: new Set(kept.approvals) } : null; +} + +/** On a runner: a published version's folder as a bundle (SKILL.md split, its files), from the copy kept; null when it isn't there. */ +export function runnerBundle(pid: string, kind: SkillKind, slug: string, version: string): Bundle | null { + if (!onRunner()) return null; + const x = keptOf(pid)?.skills.find((s) => s.kind === kind && s.slug === slug && s.version === version); + const dir = versionDir(pid, kind, slug, version); + if (!x || !dir) return null; + try { + const md = readFileSync(/*turbopackIgnore: true*/ join(dir, "SKILL.md"), "utf8"); + const parsed = parseSkillMd(md); + const files: BundleFile[] = x.files.map((f) => { + const bytes = readFileSync(/*turbopackIgnore: true*/ join(dir, f.path)); + return f.binary ? { path: f.path, content: null, data: bytes, sha256: f.sha256, size: bytes.length, executable: f.executable } : { path: f.path, content: bytes.toString("utf8"), sha256: f.sha256, size: bytes.length, executable: f.executable }; + }); + if (files.some((f) => sha256Hex(f.data ?? Buffer.from(f.content ?? "", "utf8")) !== f.sha256)) return null; + return { frontmatter: parsed.ok ? (parsed.frontmatter ?? {}) : {}, body: parsed.ok ? parsed.body : md, skillMd: md, files }; + } catch { + return null; + } +} + +/** One file of Orgo's export (GET …/skills/{id}/export): its text inline, or its bytes at `url`. */ +export type ExportFile = { content?: string; url?: string; sha256?: string; executable?: boolean }; + +/** + * A file of Orgo's export as it was stored: its text inline, or its bytes from `url` (a short presigned GET, no key + * sent: orgo-web gives one for every binary file and for large text). Checked against its sha256; null when it can't + * be fetched or doesn't match, so nothing half there is ever written. + */ +export async function exportedFile(path: string, f: ExportFile, timeoutMs = 120_000): Promise<{ path: string; content: string | null; data?: Buffer; executable: boolean } | null> { + let bytes: Buffer; + if (typeof f.content === "string") bytes = Buffer.from(f.content, "utf8"); + else if (typeof f.url === "string" && /^https?:\/\//.test(f.url)) { + const res = await fetch(f.url, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null); + if (!res?.ok) return null; + bytes = Buffer.from(await res.arrayBuffer()); + } else return null; + if (f.sha256 && sha256Hex(bytes) !== f.sha256) return null; + const text = typeof f.content === "string" ? f.content : textOf(path, bytes); + return text !== null ? { path, content: text, executable: !!f.executable } : { path, content: null, data: bytes, executable: !!f.executable }; +} + +/** A skill of orgo-web's list as a runner reads it. */ +type Listed = { id?: unknown; slug?: unknown; name?: unknown; description?: unknown; type?: unknown; status?: unknown; bot_ids?: unknown; updated_at?: unknown; current_version?: { version?: unknown; risk?: unknown; label?: unknown } | null }; + +const kindOf = (type: unknown): SkillKind => (type === "app" || type === "software" ? "app" : "skill"); + +/** + * Read a workspace's published skills from Orgo with this computer's own key (on a runner only, at most once a minute + * a workspace), and keep them on its disk. A caller waits a few seconds at most; the read goes on after that. + */ +export async function refreshRunnerSkills(wsId: string): Promise { + if (!onRunner()) return; + const pid = getState().workspaces?.find((w) => w.id === wsId)?.orgoProjectId; + if (!pid || root() === null) return; + const m = entry(pid); + if (!m.going) { + if (Date.now() - m.tried < THROTTLE_MS) return; + m.tried = Date.now(); + m.going = readFromOrgo(pid) + .catch((e: Error) => console.warn(`[runner-skills] reading the workspace's skills: ${e.message}`)) + .finally(() => (m.going = undefined)); + } + let timer: ReturnType | undefined; + await Promise.race([m.going, new Promise((done) => (timer = setTimeout(done, WAIT_MS)))]); + clearTimeout(timer); +} + +async function readFromOrgo(pid: string) { + const { apiKey, callOrgo } = await import("./orgo"); + const key = await apiKey().catch(() => ""); + if (!key) return; + const at = `/api/projects/${encodeURIComponent(pid)}/skills`; + const got = await callOrgo<{ skills?: Listed[] }>(key, at, { timeoutMs: CALL_MS }); + // An orgo-web from before (403 computer_key), another workspace's (404), or Orgo away: what was kept stays. + if (!got || got.status !== 200 || !Array.isArray(got.json.skills)) return; + const before = keptOf(pid); + const skills: RunnerSkill[] = []; + for (const x of got.json.skills) { + const version = x?.current_version?.version; + if (!x || x.status !== "published" || typeof x.id !== "string" || typeof x.slug !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(x.slug) || typeof version !== "string" || !isVersion(version)) continue; + const kind = kindOf(x.type); + const had = before?.skills.find((s) => s.id === x.id && s.version === version && s.kind === kind && s.slug === x.slug); + const base = { + id: x.id, + slug: x.slug, + kind, + name: typeof x.name === "string" ? x.name : x.slug, + description: typeof x.description === "string" ? x.description : "", + version, + botIds: Array.isArray(x.bot_ids) ? x.bot_ids.filter((b): b is string => typeof b === "string") : [], + ...(typeof x.current_version?.risk === "string" && (RISKS as readonly string[]).includes(x.current_version.risk) ? { risk: x.current_version.risk } : {}), + updatedAt: typeof x.updated_at === "string" ? x.updated_at : "", + }; + // A version kept here already: its files and label are as they were (a version never changes). + if (had && versionDir(pid, kind, x.slug, version) && existsSync(/*turbopackIgnore: true*/ join(versionDir(pid, kind, x.slug, version)!, "SKILL.md"))) { + skills.push({ ...base, ...(had.label ? { label: had.label } : {}), files: had.files }); + continue; + } + const fetched = await fetchVersion(key, at, pid, x.id, kind, x.slug, version); + if (fetched) skills.push({ ...base, ...(fetched.label ? { label: fetched.label } : {}), files: fetched.files }); + else { + // Not read now: an older version kept here still serves, until one is. + const older = before?.skills.find((s) => s.id === x.id); + if (older) skills.push(older); + } + } + // The approvals that count: the owner's and an Admin's (approved_by_role), never a Member's. None read: as kept. + const ap = await callOrgo<{ approvals?: { sha256?: unknown; approved_by_role?: unknown }[] }>(key, `${at}/approvals`, { timeoutMs: CALL_MS }); + const approvals = + ap?.status === 200 && Array.isArray(ap.json.approvals) + ? ap.json.approvals.filter((a) => typeof a?.sha256 === "string" && /^[0-9a-f]{64}$/.test(a.sha256) && (a.approved_by_role === "owner" || a.approved_by_role === "admin")).map((a) => a.sha256 as string) + : (before?.approvals ?? []); + const kept: Kept = { pid, at: Date.now(), skills, approvals }; + const d = pidDir(pid)!; + mkdirSync(/*turbopackIgnore: true*/ d, { recursive: true }); + writeFileSync(/*turbopackIgnore: true*/ join(d, "index.json.tmp"), JSON.stringify(kept), { mode: 0o600 }); + renameSync(/*turbopackIgnore: true*/ join(d, "index.json.tmp"), join(d, "index.json")); + entry(pid).kept = kept; + // A version no longer listed goes from this computer. + for (const s of before?.skills ?? []) if (!skills.some((x) => x.kind === s.kind && x.slug === s.slug && x.version === s.version)) { + const dir = versionDir(pid, s.kind, s.slug, s.version); + if (!dir) continue; + rmSync(/*turbopackIgnore: true*/ dir, { recursive: true, force: true }); + // Its skill's folder too, once no version is left in it. + if (existsSync(/*turbopackIgnore: true*/ dirname(dir)) && !readdirSync(/*turbopackIgnore: true*/ dirname(dir)).length) rmSync(/*turbopackIgnore: true*/ dirname(dir), { recursive: true, force: true }); + } +} + +/** One version's files from Orgo's export, written under its folder here (fresh); its label as its SKILL.md says it. Null when any of it couldn't be read. */ +async function fetchVersion(key: string, at: string, pid: string, id: string, kind: SkillKind, slug: string, version: string): Promise<{ label?: string; files: RunnerSkill["files"] } | null> { + const { callOrgo } = await import("./orgo"); + const ex = await callOrgo<{ files?: Record; version?: string }>(key, `${at}/${encodeURIComponent(id)}/export`, { timeoutMs: CALL_MS }); + if (!ex || ex.status !== 200 || !ex.json.files || ex.json.version !== version) return null; + const md = ex.json.files["SKILL.md"]?.content; + if (typeof md !== "string") return null; + const parsed = parseSkillMd(md); + const got = await Promise.all( + Object.entries(ex.json.files) + .filter(([p]) => p !== "SKILL.md") + .map(async ([p, f]) => { + const n = normalizePath(p); + return "reason" in n || n.path !== p ? null : exportedFile(p, f, 30_000); + }), + ); + if (got.some((f) => f === null)) return null; + const dir = versionDir(pid, kind, slug, version); + if (!dir) return null; + const tmp = `${dir}.tmp`; + rmSync(/*turbopackIgnore: true*/ tmp, { recursive: true, force: true }); + mkdirSync(/*turbopackIgnore: true*/ tmp, { recursive: true }); + writeFileSync(/*turbopackIgnore: true*/ join(tmp, "SKILL.md"), md); + const files: RunnerSkill["files"] = []; + for (const f of got as NonNullable<(typeof got)[number]>[]) { + const to = join(/*turbopackIgnore: true*/ tmp, f.path); + if (!resolve(to).startsWith(resolve(tmp) + sep)) return null; + const bytes = f.data ?? Buffer.from(f.content ?? "", "utf8"); + mkdirSync(dirname(to), { recursive: true }); + // Kept, never run from here: a script goes to the computer's skills folder runnable only once approved (shipped). + writeFileSync(to, bytes, { mode: 0o644 }); + files.push({ path: f.path, sha256: sha256Hex(bytes), size: bytes.length, executable: f.executable, binary: f.content === null }); + } + rmSync(/*turbopackIgnore: true*/ dir, { recursive: true, force: true }); + mkdirSync(dirname(dir), { recursive: true }); + renameSync(/*turbopackIgnore: true*/ tmp, dir); + const label = parsed.ok ? keptLabel(labelOf(parsed.frontmatter)) : undefined; + return { ...(label ? { label } : {}), files }; +} + +/** Tests: forget what's kept in memory (the disk's copy stays) and when each workspace was last read. */ +export function runnerSkillsForTests() { + memo().clear(); +} diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index c82ae0a65..22217038d 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -43,6 +43,7 @@ import { type Problem, } from "./skill-bundle"; import { onRunner } from "./persist"; +import { exportedFile, refreshRunnerSkills, runnerBundle, runnerOverlay, type ExportFile, type RunnerSkill } from "./runner-skills"; import { botIdsOn } from "@/cloud/computer-scope"; import { bot, getState, ownerName, update, userDir } from "./store"; import { outbox } from "./sync-outbox"; @@ -181,13 +182,57 @@ const versionDir = (wsId: string, kind: SkillKind, slug: string, version: string /* ---------------- What's in the state ---------------- */ -/** A workspace's skills and apps (both, or one kind), oldest first. */ +/** + * A workspace's skills and apps (both, or one kind), oldest first. On a computer's runner, Orgo's published ones as it + * last read them (runner-skills.ts) laid over the state's: Orgo's name, description, version, bots and label for one it + * lists, one only Orgo has added, and one Orgo had (remoteId) but no longer lists as published shown as a draft, so + * with the owner's Mac off its bots use what the owner or an Admin published. Without a read: the state's, as before. + */ export function refsOf(wsId: string, kind?: SkillKind): SkillRef[] { adoptProcedures(); labelRefs(wsId); const w = workspaceById(wsId); if (!w) return []; - return kind ? [...(w[listKey(kind)] ?? [])] : [...(w.skills ?? []), ...(w.apps ?? [])]; + const own = kind ? [...(w[listKey(kind)] ?? [])] : [...(w.skills ?? []), ...(w.apps ?? [])]; + const over = onRunner() && w.orgoProjectId ? runnerOverlay(w.orgoProjectId) : null; + return over ? overlaid(own, over.skills, kind) : own; +} + +/** A published skill Orgo lists, as a ref here: published at its version, for its bots or by its label. */ +const runnerRef = (x: RunnerSkill, r?: SkillRef): SkillRef => { + const { label: _label, risk: _risk, ...rest } = r ?? ({} as Partial); + return { + ...(rest as Partial), + slug: x.slug, + kind: x.kind, + name: x.name, + description: x.description, + ...(x.label ? { label: x.label } : {}), + labelBy: "skill", + status: "published", + version: x.version, + ...(x.risk && (RISKS as readonly string[]).includes(x.risk) ? { risk: x.risk as SkillRisk } : {}), + botIds: x.botIds, + source: r?.source ?? "orgo", + createdAt: r?.createdAt ?? 0, + updatedAt: Date.parse(x.updatedAt) || r?.updatedAt || 0, + remoteId: x.id, + }; +}; + +function overlaid(own: SkillRef[], theirs: RunnerSkill[], kind?: SkillKind): SkillRef[] { + const used = new Set(); + const out = own.map((r) => { + const x = theirs.find((t) => t.kind === r.kind && !used.has(t) && (r.remoteId ? t.id === r.remoteId : t.slug === r.slug)) ?? theirs.find((t) => t.kind === r.kind && !used.has(t) && t.slug === r.slug); + if (x) { + used.add(x); + return runnerRef(x, r); + } + // Orgo had it and doesn't list it as published now (taken back to a draft, or deleted there): not its bots' here. + return r.status === "published" && r.remoteId ? { ...r, status: "draft" as const } : r; + }); + for (const x of theirs) if (!used.has(x) && (!kind || x.kind === kind)) out.push(runnerRef(x)); + return out; } export const findRef = (wsId: string, kind: SkillKind, slug: string) => refsOf(wsId, kind).find((r) => r.slug === slug); @@ -718,6 +763,10 @@ function readBundle(dir: string | null): Bundle | null { * is written again here from the procedure kept in the state. */ function publishedBundle(wsId: string, r: SkillRef): Bundle | null { + // On a computer's runner: the version Orgo serves, as it last read it (runner-skills.ts). + const pid = onRunner() && r.version ? workspaceById(wsId)?.orgoProjectId : undefined; + const fromOrgo = pid ? runnerBundle(pid, r.kind, r.slug, r.version!) : null; + if (fromOrgo) return fromOrgo; if (r.version) { const v = readBundle(versionDir(wsId, r.kind, r.slug, r.version)); if (v) return v; @@ -1219,7 +1268,11 @@ export function dropBotFromSkills(botId: string, wsId: string) { export type SkillFileInfo = { path: string; size: number; sha256: string; executable: boolean; script: boolean; approved: boolean; binary: boolean }; -const approvedIn = (wsId: string) => new Set((workspaceById(wsId)?.approvals ?? []).map((a) => a.sha256)); +/** The contents approved to run in a workspace: the state's, and on a computer's runner, the owner's and Admins' Orgo has (runner-skills.ts). */ +const approvedIn = (wsId: string) => { + const pid = onRunner() ? workspaceById(wsId)?.orgoProjectId : undefined; + return new Set([...(workspaceById(wsId)?.approvals ?? []).map((a) => a.sha256), ...(pid ? (runnerOverlay(pid)?.approvals ?? []) : [])]); +}; /** A skill as the window shows it: its SKILL.md (a version, else what's being worked on), its files, and where its folder is. */ export function skillDetail(wsId: string, kind: SkillKind, slug: string, version?: string) { @@ -1337,7 +1390,7 @@ export function readSkill(botId: string, nameOrSlug: string, file?: string | nul } const wsId = workspaceOf(b); const bundle = publishedBundle(wsId, r); - if (!bundle) return `"${r.name}" isn't on this Mac right now.`; + if (!bundle) return `"${r.name}" isn't on this ${onRunner() ? "computer" : "Mac"} right now.`; if (file?.trim()) { const n = normalizePath(file.trim()); const f = "reason" in n ? undefined : bundle.files.find((x) => x.path === n.path); @@ -1764,6 +1817,8 @@ const PIECE = 96 * 1024; * copied it rather than clear it. */ export async function copySkillsToComputer(botId: string, computerId: string, bash: (computerId: string, command: string, timeout?: number) => Promise<{ output: string; exit_code: number }>) { + // On a computer's runner: what Orgo publishes now, read first (runner-skills.ts), so the copy is whole. + if (onRunner() && bot(botId)) await refreshRunnerSkills(workspaceOf(bot(botId)!)); const team = sharers(botId); if (onRunner() && team.some((id) => skillsFor(id).some((r) => !publishedBundle(workspaceOf(bot(id)!), r)))) return 0; const bundles = bundlesFor(team, { builtins: true }); @@ -2693,26 +2748,6 @@ async function syncLabelInfo(wsId: string, pid: string): Promise { return true; } -type ExportFile = { content?: string; url?: string; sha256?: string; executable?: boolean }; - -/** - * A file of Orgo's export as it was stored: its text inline, or its bytes from `url` (a short presigned GET, - * no key sent: orgo-web gives one for every binary file and for large text). Checked against its sha256; - * null when it can't be fetched or doesn't match, so nothing half there is ever written. - */ -async function exportedFile(path: string, f: ExportFile): Promise<{ path: string; content: string | null; data?: Buffer; executable: boolean } | null> { - let bytes: Buffer; - if (typeof f.content === "string") bytes = Buffer.from(f.content, "utf8"); - else if (typeof f.url === "string" && /^https?:\/\//.test(f.url)) { - const res = await fetch(f.url, { signal: AbortSignal.timeout(120_000) }).catch(() => null); - if (!res?.ok) return null; - bytes = Buffer.from(await res.arrayBuffer()); - } else return null; - if (f.sha256 && sha256Hex(bytes) !== f.sha256) return null; - const text = typeof f.content === "string" ? f.content : textOf(path, bytes); - return text !== null ? { path, content: text, executable: !!f.executable } : { path, content: null, data: bytes, executable: !!f.executable }; -} - /** Two bundles' text and files the same (the blank line after the frontmatter aside): what skillDetail calls unchanged. */ const sameWork = (a: Bundle, b: Bundle) => a.body.replace(/^\n+/, "") === b.body.replace(/^\n+/, "") && a.files.map((f) => `${f.path}:${f.sha256}`).join() === b.files.map((f) => `${f.path}:${f.sha256}`).join(); diff --git a/scripts/test-skills-v2.mjs b/scripts/test-skills-v2.mjs index f1549e284..77e57195f 100644 --- a/scripts/test-skills-v2.mjs +++ b/scripts/test-skills-v2.mjs @@ -18,6 +18,7 @@ // in a throwaway folder: nothing reaches a real service. The keys in it are made up. // Usage: node --conditions=react-server scripts/test-skills-v2.mjs import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { registerHooks } from "node:module"; import { tmpdir } from "node:os"; @@ -105,6 +106,22 @@ async function fakeOrgo(req, url) { orgo.maps.push({ pid: map[1], body }); return json({ ok: true, kinds: [], ...fields }); } + // A computer's runner reading its workspace's published skills (runner-skills.ts): `runnerRead` by project, as + // orgo-web answers a computer's own key (published ones only, each at its version; approvals with the role); `down` + // (no answer) and `refuse` (an orgo-web from before: 403 computer_key). + const rr = url.pathname.match(/^\/api\/projects\/([^/]+)\/skills(?:\/([^/]+))?(?:\/(export))?$/); + if (rr && orgo.runnerRead?.[rr[1]]) { + const o = orgo.runnerRead[rr[1]]; + if (o.down) throw new TypeError("fetch failed"); + if (o.refuse) return json({ error: "A computer’s own key can’t list a workspace’s skills.", code: "computer_key" }, 403); + const [, , sid, sub] = rr; + const sha = (t) => createHash("sha256").update(t).digest("hex"); + if (!sid) return json({ skills: o.skills.map((x) => ({ id: x.id, slug: x.slug, name: x.name, description: x.description, type: "skill", status: x.status ?? "published", bot_ids: x.bot_ids, updated_at: x.updated_at ?? "2026-11-12T09:00:00.000Z", current_version: { version: x.version } })), deleted: [], full: true }); + if (sid === "approvals") return json({ approvals: o.approvals }); + const x = o.skills.find((y) => y.id === sid); + if (sub === "export" && x) return json({ slug: x.slug, name: x.name, version: x.version, files: { "SKILL.md": { content: x.md }, ...Object.fromEntries(Object.entries(x.files ?? {}).map(([p, c]) => [p, { content: c, sha256: sha(c), executable: p.startsWith("scripts/") }])) } }); + return json({ error: "Skill not found.", code: "not_found" }, 404); + } const labels = url.pathname.match(/^\/api\/projects\/([^/]+)\/skill-labels$/); if (labels) { if (!orgo.labelsRoute) return new Response("", { status: 404, headers: { "content-type": "text/html" } }); @@ -753,6 +770,120 @@ await check("Update from folder: the folder is the skill's next version (its lab assert.equal((await route({ action: "import", ws: "ws_main", slug: "nope", path: f })).status, 404); }); +/* ---------------- A computer's runner, with the owner's Mac off ---------------- */ + +/** On a runner, the computer's own key (made up) as its sign in holds it (orgo-auth.ts runnerKey), never the VMM's look; and a runner that would stop, kept. */ +const A = await import(`${root}/lib/server/orgo-auth.ts`); +A.orgoAuthForTests.leave((why) => logged.push(`left: ${why}`)); +const asRunner = () => { + process.env.BOPS_RUNNER = "1"; + globalThis.bopsRunnerAuth = { ...(globalThis.bopsRunnerAuth ?? {}), picking: Promise.resolve(ORGO_KEY) }; +}; +const asMac = () => { + delete process.env.BOPS_RUNNER; + if (globalThis.bopsRunnerAuth) delete globalThis.bopsRunnerAuth.picking; +}; + +await check("On a computer's runner with the owner's Mac off: Orgo's published skills, read with the computer's key, are its bots' by Bops' own rule; read_skill reads them; a copy carries them, runnable only on the owner's or an Admin's approval", async () => { + const R = await import(`${root}/lib/server/runner-skills.ts`); + const md = (slug, label, text) => `---\nname: ${slug}\ndescription: d\nmetadata:\n label: ${label}\n version: "1.0.0"\n---\n${text}`; + const CLOSE = "#!/bin/sh\necho closing\n"; + const OTHER = "#!/bin/sh\necho other\n"; + const sha = (t) => createHash("sha256").update(t).digest("hex"); + orgo.runnerRead = { + p_lab: { + skills: [ + // An Admin published it in Orgo: by its label, Finance (no bots named). + { id: "a0000000-0000-4000-8000-0000000000c1", slug: "close-the-books", name: "Close the books", description: "Closes the month.", version: "1.0.0", bot_ids: [], md: md("close-the-books", "Finance", "# Close the books\n\n1. Run the close.\n"), files: { "scripts/close.sh": CLOSE, "scripts/other.sh": OTHER } }, + // Named for Ana alone. + { id: "a0000000-0000-4000-8000-0000000000c2", slug: "ana-only", name: "Ana only", description: "Ana's own.", version: "0.2.0", bot_ids: ["ana"], md: md("ana-only", "Email", "# Ana only\n"), files: {} }, + ], + approvals: [ + { sha256: sha(CLOSE), approved_at: "2026-11-12T09:00:00.000Z", approved_by_role: "admin" }, + // A Member's approval: never runs a script. + { sha256: sha(OTHER), approved_at: "2026-11-12T09:00:00.000Z", approved_by_role: "member" }, + ], + }, + }; + setLabels("ben", ["Finance"]); + setLabels("ana", ["Email"]); + asRunner(); + try { + R.runnerSkillsForTests(); + orgo.calls = []; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(orgo.calls.map((c) => `${c.method} ${c.path}`), [ + "GET /api/projects/p_lab/skills", + "GET /api/projects/p_lab/skills/a0000000-0000-4000-8000-0000000000c1/export", + "GET /api/projects/p_lab/skills/a0000000-0000-4000-8000-0000000000c2/export", + "GET /api/projects/p_lab/skills/approvals", + ]); + assert.deepEqual(W.skillsFor("ben").map((r) => [r.slug, r.version, r.label]), [["close-the-books", "1.0.0", "Finance"]], "Ben: the Finance one, by his label"); + assert.deepEqual(W.skillsFor("ana").map((r) => r.slug), ["ana-only"], "Ana: hers by name; not Finance"); + assert.match(W.skillsNote("ben"), /Close the books/); + const read = hear(W.readSkill("ben", "Close the books")); + assert.match(read, /^Skill: Close the books \(version 1\.0\.0\)\n\n# Close the books\n\n1\. Run the close\./); + assert.match(read, /- scripts\/close\.sh \(script, approved\)/, "an Admin's approval runs it"); + assert.match(read, /- scripts\/other\.sh \(script, not approved yet/, "a Member's doesn't"); + assert.match(W.readSkill("ben", "Close the books", "scripts/close.sh"), /echo closing/); + assert.match(W.readSkill("ben", "Close the books", "scripts/other.sh"), /a script not approved yet/); + // Read once a minute: a second look at once asks nothing. + orgo.calls = []; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(orgo.calls, []); + // Copied to Ben's computer: the skill, its approved script runnable, the other one waiting (never its content). + const sent = []; + const n = await W.copySkillsToComputer("ben", "c_ben", async (_id, command) => (sent.push(command), { output: "", exit_code: 0 })); + assert.equal(n, 1); + const all = sent.join("\n"); + assert.match(all, /capabilities\/skills\/close-the-books\/SKILL\.md/); + assert.match(all, new RegExp(`${Buffer.from(CLOSE).toString("base64").replace(/[+/=]/g, "\\$&")}[\\s\\S]*chmod 755 '/workspace/capabilities/skills/close-the-books/scripts/close\\.sh'`)); + assert.ok(!all.includes(Buffer.from(OTHER).toString("base64")), "an unapproved script's content never goes"); + assert.doesNotMatch(all, /chmod 755 '[^']*other\.sh'/); + assert.doesNotMatch(all, /ana-only/, "Ana's isn't Ben's"); + // Nothing of it in the state: a computer may not write a workspace's skills. + assert.ok(!(S.getState().workspaces.find((w) => w.id === "ws_lab").skills ?? []).some((r) => r.slug === "close-the-books")); + } finally { + asMac(); + } + // On the Mac: none of it (the Mac's own folders and Orgo sync, as before). + assert.ok(!W.skillsFor("ben").some((r) => r.slug === "close-the-books")); +}); + +await check("On a runner: Orgo away keeps the copy read before (after a restart too); taken back to a draft there, it's off the bots and the computer; an older orgo-web (403) or none read, everything as before", async () => { + const R = await import(`${root}/lib/server/runner-skills.ts`); + asRunner(); + try { + // A restart: memory gone, the disk's copy read; Orgo away: it still serves. + R.runnerSkillsForTests(); + orgo.runnerRead.p_lab.down = true; + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(W.skillsFor("ben").map((r) => r.slug), ["close-the-books"], "the copy kept on disk"); + assert.match(W.readSkill("ben", "Close the books"), /Run the close/); + // Back, with Close the books taken back to a draft: off Ben, and its folder off this computer. + orgo.runnerRead.p_lab.down = false; + orgo.runnerRead.p_lab.skills[0].status = "draft"; + R.runnerSkillsForTests(); + await R.refreshRunnerSkills("ws_lab"); + assert.deepEqual(W.skillsFor("ben"), []); + assert.ok(!existsSync(join(S.userDir(), "runner-skills", "p_lab", "skills", "close-the-books"))); + // An orgo-web from before (403 computer_key), and nothing read before: the state's list, as before. + S.update((s) => s.workspaces.find((w) => w.id === "ws_sync").skills = [...(s.workspaces.find((w) => w.id === "ws_sync").skills ?? []), { slug: "from-the-mac", kind: "skill", name: "From the Mac", description: "Published on the Mac.", status: "published", version: "0.1.0", versions: [{ version: "0.1.0", at: 1, sha256: "0".repeat(64) }], botIds: [], source: "bops", createdAt: 1, updatedAt: 1 }]); + orgo.runnerRead.p_sync = { refuse: true, skills: [], approvals: [] }; + await R.refreshRunnerSkills("ws_sync"); + assert.deepEqual(W.skillsFor("syd").map((r) => r.slug).filter((x) => x === "from-the-mac"), ["from-the-mac"], "the state's, as before"); + assert.equal(hear(W.readSkill("syd", "From the Mac")), "\"From the Mac\" isn't on this computer right now."); + const sent = []; + assert.equal(await W.copySkillsToComputer("syd", "c_syd", async (_id, c) => (sent.push(c), { output: "", exit_code: 0 })), 0, "the Mac's last copy left as it is"); + assert.deepEqual(sent, []); + } finally { + asMac(); + S.update((s) => (s.workspaces.find((w) => w.id === "ws_sync").skills = (s.workspaces.find((w) => w.id === "ws_sync").skills ?? []).filter((r) => r.slug !== "from-the-mac"))); + setLabels("ben", undefined); + setLabels("ana", undefined); + } +}); + /* ---------------- Plain words ---------------- */ await check("Plain words: nothing a person or a bot reads has a dash, says Orbit, or holds the made up secret", async () => { From 7796d274a00f196d0fefcd67b9347cad70e625c5 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:08:43 +0000 Subject: [PATCH 4/8] A skill a bot saves as a draft gets a card that opens it in Skills When a bot saves a new skill as a draft in a chat, on the owner's Mac or on its own computer, its answer now carries a card: Saved as a draft, with Review and publish, which opens the skill in Knowledge, Skills. Publishing still happens there, by a person who read it, with their own sign in. The owner and, in a workspace shared with them, an Admin see the card; a Member doesn't. Bops Cloud passes the card's skill (its kind, slug and name) to the people the message is shown to. The bot now says the owner or an Admin can publish a new draft, since an Admin can publish it from Skills with the owner's Mac off. A change to a skill is still the owner's to publish. --- cloud/test/core-members.test.ts | 13 +++++++++ cloud/ws-slice.ts | 3 +++ components/app/bops-app.tsx | 3 +++ components/app/chat-view.tsx | 3 +++ components/app/skill-drafts.tsx | 44 +++++++++++++++++++++++++++++++ lib/server/chat.ts | 20 ++++++++++---- lib/server/workspace-skills.ts | 7 ++--- lib/types.ts | 9 +++++++ scripts/test-chat-tools.mjs | 15 ++++++++--- scripts/test-skills-ui-shared.mjs | 34 ++++++++++++++++++++++++ 10 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 components/app/skill-drafts.tsx diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts index 3f769c59a..c43f93f66 100644 --- a/cloud/test/core-members.test.ts +++ b/cloud/test/core-members.test.ts @@ -249,6 +249,19 @@ test("a reply a bot drafted for the owner is shown read only: who, where, the wo }); }); +test("a skill a bot saved as a draft comes with its message: which one (kind, slug, name), so an Admin's card opens it in Skills; nothing else of it", () => { + const saved = { kind: "skill", slug: "weekly-recap", name: "Weekly recap", body: "# Weekly recap", botIds: ["max"] }; + assert.deepEqual(sharedMessage({ id: "m1", chatId: "bot:max", role: "bot", botId: "max", text: "Saved it.", at: 3, skillDrafts: [saved] }, new Set(["max"])), { + id: "m1", + chatId: "bot:max", + role: "bot", + botId: "max", + text: "Saved it.", + at: 3, + skillDrafts: [{ kind: "skill", slug: "weekly-recap", name: "Weekly recap" }], + }); +}); + test("a member given a bot sees all of it: the owner's own messages to it, one kept between us, the answers, and the threads that came of it, live too", () => { const blob = { workspaces: [{ id: "ws_acme", name: "Acme", memberAccess: { "sam@example.com": { others: "none", bots: { boppy: "read" } } } }], diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts index 9b6e68773..e362519c3 100644 --- a/cloud/ws-slice.ts +++ b/cloud/ws-slice.ts @@ -205,6 +205,9 @@ const MESSAGE: Spec = { // Replies a bot wrote for the owner to send (lib/types.ts ReplyDraft), read only: who and where, the words, and // whether they went. Never the account, thread or channel ids they go by, nor a Send in progress or why one failed. drafts: [{ id: true, app: true, toName: true, to: true, subject: true, where: true, text: true, sent: { at: true, text: true } }], + // Skills a bot saved as drafts (Message.skillDrafts): which one, so an Admin's card opens it in Skills to read and + // publish there with their own sign in (components/app/skill-drafts.tsx). Its name only: the skill is Orgo's. + skillDrafts: [{ kind: true, slug: true, name: true }], }; /** `asking` is a map (asker → teammate): its entries are each a bot id, and with `bots` (someone else's view) both of them a bot they see. */ diff --git a/components/app/bops-app.tsx b/components/app/bops-app.tsx index 7457759be..2dde1062d 100644 --- a/components/app/bops-app.tsx +++ b/components/app/bops-app.tsx @@ -22,6 +22,7 @@ import { crmOpen } from "@/lib/crm-access"; import { categoriesOpen } from "@/lib/memory-categories"; import { tagsOf } from "@/lib/routine-catalog"; import { CrmView, OpenCrm } from "./crm"; +import { OpenSkill } from "./skill-drafts"; import { BoppyLoading, BotMoodsFrom, CallTalking, Mascot, talkingStore } from "./mascot"; import { MemoryView, type MemoryOpen } from "./memory-categories"; import { BROWSER_TAB, BrowserGoTo, browserTabOf, browsesHere, CHAT_TAB, electron, forgetTab, hostOf, isReading, LocalOnly, memoryAgain, NewTab, OpenBotLocal, OpenInBrowser, OpenLink, OpenPreview, TabBar, WebTab, type BrowserGo, type PanelTab, type PreviewOpened } from "./panel-tabs"; @@ -1034,6 +1035,7 @@ function Bops({ state }: { state: AppState | null }) { openSettings(at)}> + @@ -1472,6 +1474,7 @@ function Bops({ state }: { state: AppState | null }) { + diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 0dbbfd51d..ef22baca7 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -40,6 +40,7 @@ import { AskInPlace, ChatAvatar, chatName, clockTime, memberAnswers, needsYou, p import { ReplyDrafts } from "./reply-drafts"; import { dropCitations, isLocalLink, isTableRow as isRow, isTableRule as isRule, localOnlyLine, plainStep, plainTitle, tableCells as cells } from "@/lib/plain-words"; import { SkillProposals } from "./skill-proposals"; +import { SkillDrafts } from "./skill-drafts"; /** Light markdown for bot text: **bold** and [links](url). */ /** How many messages a chat draws at first, and how many more each "Show earlier messages" adds. */ @@ -2093,6 +2094,8 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO {/* Changes it proposed to a skill, each a card with Accept and Reject (read only in a shared workspace). */} + {/* Skills it saved as drafts, each a card that opens it in Skills to read and publish. */} + {m.signIn && m.botId && } {replies} {m.needed && m.resultOf && } diff --git a/components/app/skill-drafts.tsx b/components/app/skill-drafts.tsx new file mode 100644 index 000000000..72c988203 --- /dev/null +++ b/components/app/skill-drafts.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { sharedSkillsWho } from "@/lib/shared"; +import type { AppState, Message } from "@/lib/types"; + +/* + * Skills a bot saved as drafts in a chat (Message.skillDrafts, chat.ts create_skill), as cards under its message: which + * skill, and Review and publish, which opens it in Skills, in Knowledge (skills.tsx), where the person reads it and + * publishes it with their own sign in. The card itself never publishes anything. Shown to the owner, and in a workspace + * shared with the user to an Admin there (lib/shared.ts sharedSkillsWho); nobody else sees it. + */ + +/** Opens Knowledge, Skills on one skill ("skill:"): bops-app.tsx gives it, as it gives OpenCrm. */ +export const OpenSkill = createContext<((item?: string) => void) | null>(null); + +const btn = "shrink-0 rounded-full bg-ink px-3 py-1.5 text-[12.5px] font-medium leading-4 text-white disabled:opacity-50"; + +export function SkillDrafts({ message: m, state, className = "" }: { message: Message; state: AppState; className?: string }) { + const open = useContext(OpenSkill); + if (!m.skillDrafts?.length) return null; + if (state.shared && !sharedSkillsWho(state.shared)) return null; + // On the owner's own Bops its state says whether it was published since (a shared workspace's skills are Orgo's, read in Skills). + const refs = state.shared ? [] : (state.workspaces ?? []).flatMap((w) => [...(w.skills ?? []), ...(w.apps ?? [])]); + return ( +
+ {m.skillDrafts.map((d) => { + const published = refs.find((r) => r.kind === d.kind && r.slug === d.slug)?.status === "published"; + return ( +
+ + {published ? `Published: ${d.name}` : `Saved as a draft: ${d.name}`} + + {open && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/lib/server/chat.ts b/lib/server/chat.ts index a0557f360..69445beb5 100644 --- a/lib/server/chat.ts +++ b/lib/server/chat.ts @@ -21,7 +21,7 @@ import { contactLine, createBot, namesTheUser } from "./bots"; import { creditsOut, noteOutOfCredit, outOfCreditWords } from "./cloud"; import { noOwnComputer } from "./plan"; import { agentLabel, agentName, agentNamed, aiOf, modelShort, paysLabel, readyAgents } from "@/lib/agents"; -import { botChatId, live, MAX_PERSONA_CHARS, MIN_EVERY_MINUTES, pairChatId, personaOf, routineSent, settingsOf, TAPBACK_EMOJI, TAPBACKS, saidOf, workBot, workspaceOf, type AppApproval, type Bot, type ChannelKind, type Delivery, type MemberTurn, type Message, type ReplyDraft, type Routine, type RoutineRun, type Schedule, type Session, type SkillProposal, type Tapback } from "@/lib/types"; +import { botChatId, live, MAX_PERSONA_CHARS, MIN_EVERY_MINUTES, pairChatId, personaOf, routineSent, settingsOf, TAPBACK_EMOJI, TAPBACKS, saidOf, workBot, workspaceOf, type AppApproval, type Bot, type ChannelKind, type Delivery, type MemberTurn, type Message, type ReplyDraft, type Routine, type RoutineRun, type Schedule, type Session, type SkillDraftNote, type SkillProposal, type Tapback } from "@/lib/types"; import { computerBriefing, teamBriefing } from "./briefing"; import { belowOf, directOf, entryOf, inLine, mayAskIn, ownMemory, reachOf, readsTeam, worksForNow } from "@/lib/chain"; import { asksNow, chartedFor, handOffRefused, namesList, teamNow } from "./chart"; @@ -1222,6 +1222,8 @@ type ChatPause = Omit & { drafts?: ReplyDraft[]; /** The changes to skills it proposed so far (propose_skill_change), and whose words it read from outside Bops. */ proposals?: SkillProposal[]; + /** The skills it saved as drafts so far (create_skill): a card under its answer opens each in Skills. */ + skillDrafts?: SkillDraftNote[]; readFrom?: string[]; }; @@ -1960,7 +1962,7 @@ export async function resumeChat(a: AppApproval): Promise { } /** What a turn came to: its words, why it failed, the thread it started, or that it paused for the user's OK (resumeChat goes on from there). */ -export type TurnOut = { text: string | null; error?: string; sessionId?: string; paused?: boolean; drafts?: ReplyDraft[]; skillProposals?: SkillProposal[] }; +export type TurnOut = { text: string | null; error?: string; sessionId?: string; paused?: boolean; drafts?: ReplyDraft[]; skillProposals?: SkillProposal[]; skillDrafts?: SkillDraftNote[] }; async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Promise { const b = bot(botId); @@ -2217,7 +2219,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom fn( tool, tool === "create_skill" - ? `Write down how you do one kind of job as a new skill of your workspace, so it's done the same way next time. Only when ${owner} asks you to remember how to do something, or says yes to saving one. Put it under one of your labels when one fits, else a new short one. It's saved as a draft: ${owner} publishes it in Skills, in Knowledge, and then you use it.` + ? `Write down how you do one kind of job as a new skill of your workspace, so it's done the same way next time. Only when ${owner} asks you to remember how to do something, or says yes to saving one. Put it under one of your labels when one fits, else a new short one. It's saved as a draft: ${owner} or an Admin reads it and publishes it in Skills, in Knowledge (a card under your answer opens it there), and then you use it.` : `Change one of your skills (by its name), when ${owner} asks you to, or says yes to it. The change is a draft of its next version until ${owner} publishes it in Skills, in Knowledge.`, { name: { type: "string", description: tool === "create_skill" ? "A short name for the kind of job, e.g. \"Weekly invoice summary\"." : "The skill's name, as listed." }, @@ -2541,6 +2543,8 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom const drafts: ReplyDraft[] = [...(kept?.drafts ?? [])]; // The changes to skills it proposed (skill-proposals.ts), and whose words from outside Bops it read: each proposal's card says so. const proposals: SkillProposal[] = [...(kept?.proposals ?? [])]; + // The skills it saved as drafts (create_skill): a card under the answer opens each in Skills, to read and publish. + const skillDrafts: SkillDraftNote[] = [...(kept?.skillDrafts ?? [])]; for (const x of kept?.readFrom ?? []) readFrom.add(x); if (opts.outside) readFrom.add(`This turn came from ${outsideFrom}`); /** Whose words this turn read so far: its own list, and each app account it read with the senders and recipients it saw there. */ @@ -2699,7 +2703,8 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom const saved = await saveSkillFromComputer(workspaceOf(b), { name: title, description: undash(String(p.description ?? "")), body, label, botId }); if (!saved.ok) return `Not saved: ${saved.why}. Nothing was saved. ${offer}`; notes.push(`Saved the skill "${saved.name}" as a draft`); - return `Saved "${saved.name}" as a draft in your workspace's skills. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + if (saved.slug) skillDrafts.push({ kind: "skill", slug: saved.slug, name: saved.name }); + return `Saved "${saved.name}" as a draft in your workspace's skills. ${owner} or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it.`; } try { const ws = workspaceOf(b); @@ -2707,7 +2712,10 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom ? updateSkill(ws, had.kind, had.slug, { name: title, description: undash(String(p.description ?? had.description)), body, label }, { draft: true }) : createSkill(ws, { name: title, description: undash(String(p.description ?? "")), body, label, botIds: [botId], source: "bops", from: { chatId } }).ref; notes.push(`${had ? "Updated" : "Saved"} the skill "${r.name}" as a draft`); - return `${had ? "Updated" : "Saved"} "${r.name}" as a draft. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + // A change to one is the owner's to publish (its folder is on their Mac); a new one Orgo keeps too, for an Admin. + if (had) return `Updated "${r.name}" as a draft. ${owner} can publish it in Skills, in Knowledge; then you'll use it.`; + skillDrafts.push({ kind: r.kind, slug: r.slug, name: r.name }); + return `Saved "${r.name}" as a draft. ${owner} or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it.`; } catch (e) { if (e instanceof SkillError) return `Not saved: ${e.message}${e.problems?.length ? ` (${e.problems.map((x) => `${x.path}${x.line ? ` line ${x.line}` : ""}: ${x.reason}`).join("; ")})` : ""}`; throw e; @@ -2902,6 +2910,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom read: read.keep(), drafts, proposals, + skillDrafts, readFrom: [...readFrom], }; askFor( @@ -3186,6 +3195,7 @@ async function turn(botId: string, chatId: string, opts: TurnOptions = {}): Prom called: called.length ? called : undefined, drafts: drafts.length ? drafts : undefined, skillProposals: skillProposals.length ? skillProposals : undefined, + skillDrafts: skillDrafts.length ? skillDrafts : undefined, }); // Not the reply to something the user wants kept from someone: it would carry the secret into the shared memory. if (reply && !isSecret(opts.answering)) saveToMemory(scopeOf(botId), "chat", chatId, [{ who: botId, text: reply }], { chat: chatName(chatId) }); diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index 22217038d..99095c121 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -1006,7 +1006,7 @@ export function createSkill(wsId: string, input: NewSkill): { ref: SkillRef; war * rest. Nothing is kept on the computer or in its state: a computer may not write a workspace's skills * (cloud/computer-scope.ts), and the owner's Bops keeps their folders. Answers its name, or why it wasn't saved. */ -export async function saveSkillFromComputer(wsId: string, input: { name: string; description: string; body: string; label: string | null; botId: string }): Promise<{ ok: true; name: string } | { ok: false; why: string }> { +export async function saveSkillFromComputer(wsId: string, input: { name: string; description: string; body: string; label: string | null; botId: string }): Promise<{ ok: true; name: string; slug?: string } | { ok: false; why: string }> { const pid = workspaceById(wsId)?.orgoProjectId; if (!pid) return { ok: false, why: "this workspace isn't on Orgo yet, so a skill can be saved only where its folders are kept" }; const who = bot(input.botId); @@ -1032,12 +1032,13 @@ export async function saveSkillFromComputer(wsId: string, input: { name: string; const { apiKey, callOrgo } = await import("./orgo"); const key = await apiKey().catch(() => ""); if (!key) return { ok: false, why: "this computer isn't signed in to Orgo right now" }; - const r = await callOrgo<{ skill?: { name?: string }; error?: string; code?: string }>(key, `/api/projects/${encodeURIComponent(pid)}/skills`, { + const r = await callOrgo<{ skill?: { name?: string; slug?: string }; error?: string; code?: string }>(key, `/api/projects/${encodeURIComponent(pid)}/skills`, { method: "POST", body: { name, description, type: "skill", body: md, files: [], bot_ids: [input.botId], status: "draft" }, timeoutMs: 20_000, }); - if (r?.status === 201) return { ok: true, name: r.json.skill?.name ?? name }; + // (Its slug, Orgo's, so the card under the answer opens it in Skills.) + if (r?.status === 201) return { ok: true, name: r.json.skill?.name ?? name, ...(typeof r.json.skill?.slug === "string" && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(r.json.skill.slug) ? { slug: r.json.skill.slug } : {}) }; if (!r) return { ok: false, why: "Orgo didn't answer" }; const plain = r.status === 403 && typeof r.json.code === "string" ? skillRefusedWords(r.json.code, who.name) : undefined; if (plain) return { ok: false, why: plain }; diff --git a/lib/types.ts b/lib/types.ts index 67bba149a..96fb5085a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -970,8 +970,17 @@ export type Message = { * Nothing changes until the owner presses Accept in the window (lib/server/skill-proposals.ts). */ skillProposals?: SkillProposal[]; + /** + * Set on a bot's message: skills it saved as drafts this turn (chat.ts create_skill), each a card under the message + * that opens it in Skills, in Knowledge, to read and publish (components/app/skill-drafts.tsx). Publishing is a + * person's, there, with their own sign in: the owner, or an Admin of a shared workspace. + */ + skillDrafts?: SkillDraftNote[]; }; +/** A skill a bot saved as a draft (Message.skillDrafts): which one, to open in Skills. */ +export type SkillDraftNote = { kind: SkillKind; slug: string; name: string }; + /** * A reply a bot wrote for the user, to an email, a Slack message or a Discord message it read with the user's * own account (use_app) in the same turn: it goes back in that thread or channel, from that account, when the diff --git a/scripts/test-chat-tools.mjs b/scripts/test-chat-tools.mjs index 6b38b8f5c..6a414406f 100644 --- a/scripts/test-chat-tools.mjs +++ b/scripts/test-chat-tools.mjs @@ -972,14 +972,17 @@ await check("read_skill, create_skill (a draft), update_skill, and start_task fo assert.equal(t.out(1), 'No skill called "Taxes". Your labels: Apps and source control (7), Finance (1). Open one with read_skill and label to see its skills.'); assert.equal(skillNamed("Weekly invoice summary").uses, 1, "a read counts as a use"); const v = await turnWith("remember how you post the standup", [toolCall("create_skill", { name: "Standup post", description: "When Alex asks for \"the standup notes\"", steps: "1. Read yesterday's chat.\n2. Post three lines.", pitfalls: null })]); - assert.equal(v.out(), 'Saved "Standup post" as a draft. Alex can publish it in Skills, in Knowledge; then you\'ll use it.'); + assert.equal(v.out(), 'Saved "Standup post" as a draft. Alex or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you\'ll use it.'); + // Its card under the answer: which one, to open in Skills (never a publish of its own). + assert.deepEqual(messages().findLast((m) => m.role === "bot").skillDrafts, [{ kind: "skill", slug: "standup-post", name: "Standup post" }]); assert.equal(skillNamed("Standup post").from.chatId, chatId); assert.equal(skillNamed("Standup post").status, "draft", "a bot's skill is a draft until Alex publishes it"); assert.deepEqual(skillNamed("Standup post").botIds, ["boppy"]); assert.ok(!W.skillsNote("boppy").includes("Standup post"), "a draft isn't in its list yet"); assert.equal(messages().findLast((m) => m.role === "system").text, 'Saved the skill "Standup post" as a draft'); const w = await turnWith("update that skill: just post three lines", [toolCall("update_skill", { name: "standup post", description: "When Alex asks for standup notes", steps: "1. Post three lines.", pitfalls: null })]); - assert.match(w.out(), /^Updated "/); + assert.match(w.out(), /^Updated "[Ss]tandup post" as a draft\. Alex can publish it in Skills, in Knowledge; then you'll use it\.$/, `a change to one is the owner's to publish: ${w.out()}`); + assert.equal(messages().findLast((m) => m.role === "bot").skillDrafts, undefined, "no card for a change"); const w0 = await turnWith("update that skill: the payroll one", [toolCall("update_skill", { name: "Payroll", description: "x", steps: "1. x", pitfalls: null })]); assert.equal(w0.out(), 'Not changed: you have no skill called "Payroll". Use create_skill for a new one.'); // A secret in it: refused, said by where and what kind, never the text. @@ -2107,6 +2110,8 @@ await check("an Admin's turn (MemberTurn.role) has every bot and Your Apps at Wr await Chat.handleMessage(chatId, "can you change the site?", undefined, undefined, undefined, undefined, undefined, undefined, undefined, { ...ivy, ref, role: "admin" }); assert.ok(asked[n], `answered with ${tag}`); for (const tool of appTools) assert.ok(toolNames(asked[n]).includes(tool), `${tag}: ${tool}`); + // An Admin's turn keeps create_skill and update_skill (an Admin can do everything but delete the workspace or pay). + for (const tool of ["create_skill", "update_skill"]) assert.ok(toolNames(asked[n]).includes(tool), `${tag}: ${tool}`); const kept = messages().find((m) => m.member?.ref === ref); assert.deepEqual(kept.member, { ...ivy, ref }, "the role isn't kept on the message"); // A computer's runner (runner-chat.ts answerMessage): a message Bops Cloud wrote, answered as an Admin's. @@ -2670,7 +2675,7 @@ await check("the owner's turn on the bot's computer: its apps on the web and its const { runInTurn } = await import(`${root}/lib/server/turn-context.ts`); // A stand in for orgo-web's skills route: what each POST asked, answered as the test says. const posts = []; - let answer = { status: 201, json: { skill: { id: "sk_1", name: "Weekly recap" } } }; + let answer = { status: 201, json: { skill: { id: "sk_1", slug: "weekly-recap", name: "Weekly recap" } } }; orgoFake.handle = async (req) => { const u = new URL(req.url); // (Anything else this turn asks of orgo-web, its keys say: not found.) @@ -2699,7 +2704,9 @@ await check("the owner's turn on the bot's computer: its apps on the web and its const names = toolNames(t.first); for (const x of ["work_on_app", "deploy_app", "local_app", "create_skill", "update_skill", "propose_skill_change", "start_task"]) assert.ok(names.includes(x), `has ${x}`); for (const x of ["schedule", "manage_routine", "watch", "make_page", "make_image", "make_app", "change_app", "create_bot"]) assert.ok(!names.includes(x), `no ${x}`); - assert.equal(t.out(), "Saved \"Weekly recap\" as a draft in your workspace's skills. Alex can publish it in Skills, in Knowledge; then you'll use it."); + assert.equal(t.out(), "Saved \"Weekly recap\" as a draft in your workspace's skills. Alex or an Admin can read it and publish it in Skills, in Knowledge (the card under your answer opens it there); then you'll use it."); + // Its card under the answer, by Orgo's slug for it. + assert.deepEqual(messages().findLast((m) => m.role === "bot").skillDrafts, [{ kind: "skill", slug: "weekly-recap", name: "Weekly recap" }]); const noteOf = (req) => req.input.filter((x) => x.role === "developer").map((x) => x.content).join("\n"); assert.match(noteOf(t.first), /not from Alex's Mac\. Your workspace's apps on the web \(Local, Preview and Published\) work from here as always\. So do skills: a new one is saved as a draft/); assert.equal(posts.length, 1); diff --git a/scripts/test-skills-ui-shared.mjs b/scripts/test-skills-ui-shared.mjs index 5cb8dde1b..6d5806d0d 100644 --- a/scripts/test-skills-ui-shared.mjs +++ b/scripts/test-skills-ui-shared.mjs @@ -87,6 +87,7 @@ const { act, createElement: h } = await import("react"); const { SkillsPage } = await import(pathToFileURL(`${root}/components/app/skills.tsx`).href); const { MemoryView } = await import(pathToFileURL(`${root}/components/app/memory-categories.tsx`).href); const { sharedWsId } = await import(`${root}/lib/shared.ts`); +const { SkillDrafts, OpenSkill } = await import(pathToFileURL(`${root}/components/app/skill-drafts.tsx`).href); let passed = 0; let failures = 0; @@ -274,6 +275,39 @@ await check("Knowledge in a shared workspace: Skills for an Admin, Open Skills u assert.ok(!calls.some((c) => c.url.startsWith("/api/skills")), "a Member's window never asks"); }); +await check("a bot's draft in a chat: a card that opens it in Skills (never a publish of its own), for the owner and an Admin; a Member sees none", async () => { + reset(); + const msg = { id: "m1", chatId: "bot:max", role: "bot", botId: "max", text: "Saved it.", at: 1, skillDrafts: [{ kind: "skill", slug: "weekly-report", name: "Weekly report" }] }; + const opened = []; + const card = (st) => h(OpenSkill.Provider, { value: (item) => opened.push(item) }, h(SkillDrafts, { message: msg, state: st })); + // An Admin of a shared workspace. + let m = await mount(card(state("admin"))); + try { + assert.match(look(m.box), /Saved as a draft: Weekly report/); + await click(button(m.box, "Review and publish")); + assert.deepEqual(opened, ["skill:weekly-report"]); + assert.deepEqual(calls, [], "the card itself sends nothing"); + } finally { + await m.done(); + } + // A Member there: nothing. + m = await mount(card(state("member"))); + try { + assert.equal(m.box.querySelector("[data-skill-drafts]"), null); + } finally { + await m.done(); + } + // The owner, in their own workspace: once it's published, it says so. + const own = { ...state(), shared: undefined, workspaces: [{ id: "ws_main", name: "Mine", createdAt: 0, skills: [{ ...lists.skills[0], status: "published", version: "0.1.0" }] }] }; + m = await mount(card(own)); + try { + assert.match(look(m.box), /Published: Weekly report/); + assert.ok(button(m.box, "Open in Skills")); + } finally { + await m.done(); + } +}); + await check("nothing on these pages has a dash or says Orbit", async () => { for (const t of seen) { assert.ok(!/[‒–—―]| - /.test(t), `a dash in: ${t.slice(0, 200)}`); From bd690159d7c0ad10624ef5ab43e6e4f2336ca008 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:14:33 +0000 Subject: [PATCH 5/8] A runner's turn goes on when its skills can't be read from Orgo --- lib/server/runner-chat.ts | 9 +++++++-- lib/server/workspace-skills.ts | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/server/runner-chat.ts b/lib/server/runner-chat.ts index 5124e77af..9490760da 100644 --- a/lib/server/runner-chat.ts +++ b/lib/server/runner-chat.ts @@ -298,8 +298,13 @@ async function claimAndAnswer(ev: RunnerTurnEvent) { async () => { // The skills the owner or an Admin published in Orgo, read first (runner-skills.ts, a few seconds at most): the bot // follows them with the owner's Mac off. - const b = getState().bots.find((x) => x.id === t.botId); - if (b) await import("./runner-skills").then((m) => m.refreshRunnerSkills(workspaceOf(b)), () => {}); + // Never in the way: a read that fails leaves the turn as it would have been. + try { + const b = (getState().bots ?? []).find((x) => x.id === t.botId); + if (b) await (await import("./runner-skills")).refreshRunnerSkills(workspaceOf(b)); + } catch (e) { + console.warn(`[runner-chat] ${t.id}: skills from Orgo: ${(e as Error).message}`); + } return answer(t.chatId, t.messageId, saidBy(t.member)); }, { messageId: t.messageId }, diff --git a/lib/server/workspace-skills.ts b/lib/server/workspace-skills.ts index 99095c121..f9b2739c1 100644 --- a/lib/server/workspace-skills.ts +++ b/lib/server/workspace-skills.ts @@ -200,9 +200,12 @@ export function refsOf(wsId: string, kind?: SkillKind): SkillRef[] { /** A published skill Orgo lists, as a ref here: published at its version, for its bots or by its label. */ const runnerRef = (x: RunnerSkill, r?: SkillRef): SkillRef => { - const { label: _label, risk: _risk, ...rest } = r ?? ({} as Partial); + // The state's own fields (its uses, the id it had as a procedure), never its label or risk: Orgo's say those. + const rest: Partial = { ...r }; + delete rest.label; + delete rest.risk; return { - ...(rest as Partial), + ...rest, slug: x.slug, kind: x.kind, name: x.name, From b3a1fbe64caa281a0fcf6919c12d2d8084f06aa9 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:06:02 +0000 Subject: [PATCH 6/8] The shared Skills test's fake Orgo leaves out a file's content without an unused name --- scripts/test-shared-skills.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-shared-skills.mjs b/scripts/test-shared-skills.mjs index 457257760..b077ba3c5 100644 --- a/scripts/test-shared-skills.mjs +++ b/scripts/test-shared-skills.mjs @@ -149,7 +149,7 @@ const server = createServer(async (req, res) => { if (!sub && req.method === "GET") { const v = url.searchParams.get("version") ?? s.current; if (!s.versions.includes(v)) return send(res, 404, { error: "That version does not exist.", code: "version_not_found" }); - return send(res, 200, { skill: { ...view(s), current_version: undefined }, version: { version: v, frontmatter: { name: s.slug, description: s.description }, body: s.body, files: s.files.map(({ content: _c, ...f }) => f) }, versions: s.versions }); + return send(res, 200, { skill: { ...view(s), current_version: undefined }, version: { version: v, frontmatter: { name: s.slug, description: s.description }, body: s.body, files: s.files.map((f) => Object.fromEntries(Object.entries(f).filter(([k]) => k !== "content"))) }, versions: s.versions }); } if (sub === "files" && req.method === "GET") { const f = s.files.find((x) => x.path === decodeURIComponent(rest ?? "")); From a8206e98dd323878b768f2ab86092f9deb14fc52 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:07:47 -0800 Subject: [PATCH 7/8] Release notes for 0.0.53 --- docs/releases/v0.0.53.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/releases/v0.0.53.md diff --git a/docs/releases/v0.0.53.md b/docs/releases/v0.0.53.md new file mode 100644 index 000000000..907bc1a8d --- /dev/null +++ b/docs/releases/v0.0.53.md @@ -0,0 +1,3 @@ +- Your Apps shows Preview and Live up front, with a password button. +- Review changes before you publish them. +- Admins can publish skills, even with the owner's Mac off. From f717a5d76e4f770854376c5216dfabf14bb27573 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:12:37 -0800 Subject: [PATCH 8/8] Skills test checks the copied script with plain string matches --- scripts/test-skills-v2.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-skills-v2.mjs b/scripts/test-skills-v2.mjs index 77e57195f..5b2f4e582 100644 --- a/scripts/test-skills-v2.mjs +++ b/scripts/test-skills-v2.mjs @@ -837,7 +837,9 @@ await check("On a computer's runner with the owner's Mac off: Orgo's published s assert.equal(n, 1); const all = sent.join("\n"); assert.match(all, /capabilities\/skills\/close-the-books\/SKILL\.md/); - assert.match(all, new RegExp(`${Buffer.from(CLOSE).toString("base64").replace(/[+/=]/g, "\\$&")}[\\s\\S]*chmod 755 '/workspace/capabilities/skills/close-the-books/scripts/close\\.sh'`)); + const closeAt = all.indexOf(Buffer.from(CLOSE).toString("base64")); + assert.ok(closeAt >= 0, "the approved script's content goes"); + assert.ok(all.indexOf("chmod 755 '/workspace/capabilities/skills/close-the-books/scripts/close.sh'", closeAt) > closeAt, "and is made runnable after it is written"); assert.ok(!all.includes(Buffer.from(OTHER).toString("base64")), "an unapproved script's content never goes"); assert.doesNotMatch(all, /chmod 755 '[^']*other\.sh'/); assert.doesNotMatch(all, /ana-only/, "Ana's isn't Ben's");