diff --git a/.gitignore b/.gitignore index fa275b6a..e05700bc 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,10 @@ next-env.d.ts # desktop app /dist-desktop +/dist-runner /desktop/repo.json -# relay agent (scripts/fetch-relay.sh) +# relay agent and ingot (scripts/fetch-relay.sh, scripts/fetch-ingot.sh) /vendor/ # Bops Cloud installs its two dependencies here on its box diff --git a/.gitleaksignore b/.gitleaksignore index 7636c6d4..0ed85f0c 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -2,3 +2,4 @@ 7e52c8555c74565850efe78d41ad4a8fd676cdbf:scripts/test-render.mjs:generic-api-key:406 52451f6cd78a942408602b8c1ca67eb85ba74eea:scripts/test-vm-identity.mjs:generic-api-key:505 52451f6cd78a942408602b8c1ca67eb85ba74eea:scripts/test-vm-identity.mjs:generic-api-key:510 +52a1f2bfe92531162a1b20007509c22786a12b1e:cloud/test/core-members.test.ts:generic-api-key:211 diff --git a/README.md b/README.md index 607f484f..95f4c67f 100644 --- a/README.md +++ b/README.md @@ -242,11 +242,12 @@ The packaged app's server listens on 127.0.0.1 only. To let webhooks reach it ov scripts/release.sh # or: npm run app:release ``` -It fetches the relay agent, builds the server (Next.js standalone), packs it into Bops.app with its own Node, and makes `dist-desktop/Bops--arm64.dmg` and `.zip`. Before it finishes, it starts the bundled server as a fresh install would and checks that it answers. The installed app keeps its local files (and, self-hosted, its state) in `~/Library/Application Support/Bops/server/.data`, and its log in `~/Library/Logs/Bops/server.log`. +It fetches the relay agent and the ingot command, builds the server (Next.js standalone), packs it into Bops.app with its own Node, and makes `dist-desktop/Bops--arm64.dmg` and `.zip`. Before it finishes, it starts the bundled server as a fresh install would and checks that it answers. The installed app keeps its local files (and, self-hosted, its state) in `~/Library/Application Support/Bops/server/.data`, and its log in `~/Library/Logs/Bops/server.log`. A release needs: - **The relay agent** that routes the bots' Chrome through your Mac (Route through this Mac). `scripts/fetch-relay.sh` downloads it from a private Orgo repository, so a release build needs that access. Without it, `release.sh` stops. +- **The ingot command** for the bots' computers (Linux, amd64 and arm64), which they work on an app's Ingot repo with. `scripts/fetch-ingot.sh` downloads it from a private Orgo repository too, checked against the release's SHA256SUMS. Without it, `release.sh` stops. - **A Developer ID Application certificate**, in the keychain or as `CSC_LINK` plus `CSC_KEY_PASSWORD` (`CSC_NAME` picks one when there are several). An Apple Development certificate is not enough. Without one, the script builds an unsigned app for testing on your own Mac. - **Notarization credentials:** an App Store Connect API key (`APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`), or an Apple ID (`APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4b51fcdf..01575d5b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,5 +5,7 @@ Bops includes code from other projects. That code stays under its own license, n | Path | From | License | |---|---|---| | `components/message-ui/` | Adapted from [imessage-ui](https://github.com/theswerd/imessage-ui), Copyright (c) 2026 Ben Swerdlow and contributors | MIT, in [`components/message-ui/LICENSE`](components/message-ui/LICENSE) | +| `public/made-apps/react-19.2.8.js` | [React and ReactDOM](https://github.com/facebook/react) 19.2.8, bundled for apps bots make (`scripts/build-made-app-runtime.sh`), Copyright (c) Meta Platforms, Inc. and affiliates | MIT, in [`public/made-apps/LICENSE-react`](public/made-apps/LICENSE-react) | +| `public/made-apps/tailwind-browser-4.3.3.js` | [@tailwindcss/browser](https://github.com/tailwindlabs/tailwindcss) 4.3.3, as published, Copyright (c) Tailwind Labs, Inc. | MIT, in [`public/made-apps/LICENSE-tailwindcss`](public/made-apps/LICENSE-tailwindcss) | The packages Bops depends on (npm packages, Electron) keep their own licenses, which come with each package in `node_modules/` after `npm install`. diff --git a/app/api/apps/approvals/route.ts b/app/api/apps/approvals/route.ts index 67174caa..cd92b330 100644 --- a/app/api/apps/approvals/route.ts +++ b/app/api/apps/approvals/route.ts @@ -1,11 +1,20 @@ -import { answerApp } from "@/lib/server/composio"; +import type { AskReply } from "@/lib/types"; +import { replyToAsk } from "@/lib/server/permission"; import { notReady } from "@/lib/server/ready"; -/** The user's yes or no to an app action a bot asked about. */ +/** + * The user's reply to a request for their OK, from this Bops (permission.ts replyToAsk): Allow once, Always allow + * (a rule saved for the bot), or Deny with what to do instead (`message`, which the bot is told). Written into the + * shared state, so the Bops whose run asked carries it on from where it paused, wherever it runs. Refused (409) + * when it's already answered, or this is a bot's computer. + */ export async function POST(request: Request) { const unready = notReady(); if (unready) return unready; - const { id, yes } = (await request.json().catch(() => ({}))) as { id?: string; yes?: boolean }; - if (id) answerApp(id, !!yes); + const { id, reply, message } = (await request.json().catch(() => ({}))) as { id?: string; reply?: AskReply; message?: unknown }; + if (!id) return Response.json({ ok: false, error: "Which request?" }, { status: 400 }); + if (reply !== "once" && reply !== "always" && reply !== "reject") return Response.json({ ok: false, error: "reply: once, always or reject" }, { status: 400 }); + const refused = replyToAsk(id, reply, typeof message === "string" ? message : undefined); + if (refused) return Response.json({ ok: false, error: refused }, { status: 409 }); return Response.json({ ok: true }); } diff --git a/app/api/bots/route.ts b/app/api/bots/route.ts index 8221ef17..1aa60166 100644 --- a/app/api/bots/route.ts +++ b/app/api/bots/route.ts @@ -4,6 +4,7 @@ import { requestInbox } from "@/lib/server/mail"; import { MAX_PERSONA_CHARS, type AppAccess, type Effort } from "@/lib/types"; import { bot, update } from "@/lib/server/store"; import { notReady } from "@/lib/server/ready"; +import { routineChanged } from "@/lib/server/routine-sync"; import { fromBopsWindow } from "@/lib/server/ui-token"; /** @@ -68,6 +69,7 @@ export async function PATCH(request: Request) { const r = renameBot(b.id, body.name); if ("error" in r) return Response.json(r, { status: 400 }); } + const runsOnBefore = b.runsOn; update(() => { if (body.access && typeof body.access.account === "string") { const level = body.access.level; @@ -80,6 +82,8 @@ export async function PATCH(request: Request) { if (typeof body.dataOff === "boolean") b.dataOff = body.dataOff || undefined; if (typeof body.persona === "string") b.persona = body.persona.trim().slice(0, MAX_PERSONA_CHARS).trim() || undefined; }); + // Its routines that need the Mac follow where it works (runner.ts pinnedToMac): Orgo hears. + if (body.runsOn && runsOnBefore !== b.runsOn) routineChanged(b.id); return Response.json({ ok: true }); } diff --git a/app/api/chats/[chatId]/messages/route.ts b/app/api/chats/[chatId]/messages/route.ts index 7ff56d61..6bbf0049 100644 --- a/app/api/chats/[chatId]/messages/route.ts +++ b/app/api/chats/[chatId]/messages/route.ts @@ -10,11 +10,17 @@ export async function POST(request: Request, ctx: RouteContext<"/api/chats/[chat const unready = notReady(); if (unready) return unready; const { chatId } = await ctx.params; - const { text, replyTo, images } = (await request.json()) as { text?: string; replyTo?: string; images?: { id: string; type: string; w?: number; h?: number }[] }; + const { text, replyTo, images, lookingAt } = (await request.json()) as { + text?: string; + replyTo?: string; + images?: { id: string; type: string; w?: number; h?: number }[]; + /** The app open beside the chat (lib/server/made-apps.ts), which a change the user asks for is about. */ + lookingAt?: string; + }; if (!chat(chatId)) return Response.json({ error: "no such chat" }, { status: 404 }); const pics = (images ?? []).filter((i) => uploadPath(i.id)).slice(0, 10); if (!text?.trim() && !pics.length) return Response.json({ error: "empty message" }, { status: 400 }); - void handleMessage(chatId, text?.trim() ?? "", replyTo, pics); + void handleMessage(chatId, text?.trim() ?? "", replyTo, pics, undefined, undefined, undefined, undefined, typeof lookingAt === "string" ? lookingAt : undefined); return Response.json({ ok: true }); } diff --git a/app/api/hosted-apps/[name]/commits/route.ts b/app/api/hosted-apps/[name]/commits/route.ts new file mode 100644 index 00000000..de047f3c --- /dev/null +++ b/app/api/hosted-apps/[name]/commits/route.ts @@ -0,0 +1,26 @@ +import { appsOpen } from "@/lib/apps-access"; +import { appCommits, HostedAppError } from "@/lib/server/hosted-apps"; +import { notReady } from "@/lib/server/ready"; +import { getState } from "@/lib/server/store"; +import { MAIN_WORKSPACE } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** + * An app's changesets on main, newest first (AppCommitsAnswer), from Ingot: `?before=` for the page older + * than that one. `offline` when Ingot can't be reached: no computer is asked. + */ +export async function GET(request: Request, ctx: { params: Promise<{ name: string }> }) { + const unready = notReady(); + if (unready) return unready; + // Only an @orgo.ai account has apps on the web for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { name } = await ctx.params; + const url = new URL(request.url); + const wsId = url.searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE; + try { + return Response.json(await appCommits(wsId, name, url.searchParams.get("before"))); + } catch (e) { + return e instanceof HostedAppError ? Response.json({ error: e.message }, { status: e.status }) : Response.json({ error: (e as Error).message }, { status: 500 }); + } +} diff --git a/app/api/hosted-apps/[name]/logs/route.ts b/app/api/hosted-apps/[name]/logs/route.ts new file mode 100644 index 00000000..a73cfd46 --- /dev/null +++ b/app/api/hosted-apps/[name]/logs/route.ts @@ -0,0 +1,27 @@ +import { appsOpen } from "@/lib/apps-access"; +import { appLog, HostedAppError } from "@/lib/server/hosted-apps"; +import { notReady } from "@/lib/server/ready"; +import { getState } from "@/lib/server/store"; +import { MAIN_WORKSPACE } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** + * The newest deploy's build log after `after` ({ state, log, next, more }): `next` is where to read on from + * (it names the deploy, so a newer deploy's log starts from its beginning). From orgo-web's record only: + * reading it never wakes the computer. + */ +export async function GET(request: Request, ctx: { params: Promise<{ name: string }> }) { + const unready = notReady(); + if (unready) return unready; + // Only an @orgo.ai account has apps on the web for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { name } = await ctx.params; + const url = new URL(request.url); + const wsId = url.searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE; + try { + return Response.json(await appLog(wsId, name, url.searchParams.get("after"))); + } catch (e) { + return e instanceof HostedAppError ? Response.json({ error: e.message }, { status: e.status }) : Response.json({ error: (e as Error).message }, { status: 500 }); + } +} diff --git a/app/api/hosted-apps/[name]/route.ts b/app/api/hosted-apps/[name]/route.ts new file mode 100644 index 00000000..4091207d --- /dev/null +++ b/app/api/hosted-apps/[name]/route.ts @@ -0,0 +1,34 @@ +import { appsOpen } from "@/lib/apps-access"; +import { deployApp, HostedAppError } from "@/lib/server/hosted-apps"; +import { notReady } from "@/lib/server/ready"; +import { getState } from "@/lib/server/store"; +import { fromBopsWindow } from "@/lib/server/ui-token"; +import { MAIN_WORKSPACE } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** + * Put an app on the web (HostedAppAction in lib/hosted-apps.ts): "deploy" changeset `at` (the newest on + * main by default) on the computer it's worked on, or on `botId`'s the first time. Answers { app }. A + * deploy makes the site public, so it's done only from the Bops window (ui-token.ts): never from a page a + * bot's browser reached this server with. + */ +const notFromWindow = (request: Request) => (fromBopsWindow(request) ? null : Response.json({ error: "Apps can only be deployed in the Bops app." }, { status: 403 })); + +const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined); + +export async function POST(request: Request, ctx: { params: Promise<{ name: string }> }) { + const unready = notReady() ?? notFromWindow(request); + if (unready) return unready; + // Only an @orgo.ai account has apps on the web for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { name } = await ctx.params; + const b = (await request.json().catch(() => ({}))) as Record; + const wsId = str(b.ws) ?? getState().workspace ?? MAIN_WORKSPACE; + try { + if (b.action === "deploy") return Response.json({ app: (await deployApp(wsId, name, { at: str(b.at), botId: str(b.botId) })).app }); + return Response.json({ error: "What should happen to it?" }, { status: 400 }); + } catch (e) { + return e instanceof HostedAppError ? Response.json({ error: e.message }, { status: e.status }) : Response.json({ error: (e as Error).message }, { status: 500 }); + } +} diff --git a/app/api/hosted-apps/route.ts b/app/api/hosted-apps/route.ts new file mode 100644 index 00000000..1580f9fc --- /dev/null +++ b/app/api/hosted-apps/route.ts @@ -0,0 +1,26 @@ +import { appsOpen } from "@/lib/apps-access"; +import { HostedAppError, listApps } from "@/lib/server/hosted-apps"; +import { notReady } from "@/lib/server/ready"; +import { getState } from "@/lib/server/store"; +import { MAIN_WORKSPACE } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** + * The workspace's apps that run on the web (lib/server/hosted-apps.ts): each Ingot repo it keeps + * (Workspace.hostedApps), with its newest changeset as Ingot says (no computer is asked), its newest + * deploy, and whether deploying can work here (HostedAppsAnswer). `?ws=` names the workspace (the + * current one by default). + */ +export async function GET(request: Request) { + const unready = notReady(); + if (unready) return unready; + // Only an @orgo.ai account has apps on the web for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const wsId = new URL(request.url).searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE; + try { + return Response.json(await listApps(wsId)); + } catch (e) { + return e instanceof HostedAppError ? Response.json({ error: e.message }, { status: e.status }) : Response.json({ error: (e as Error).message }, { status: 500 }); + } +} diff --git a/app/api/made-apps/[appId]/bridge/route.ts b/app/api/made-apps/[appId]/bridge/route.ts new file mode 100644 index 00000000..762a1b04 --- /dev/null +++ b/app/api/made-apps/[appId]/bridge/route.ts @@ -0,0 +1,47 @@ +import { appsOpen } from "@/lib/apps-access"; +import { bridge, NeedsAllow } from "@/lib/server/made-apps"; +import { getState } from "@/lib/server/store"; + +/** The most one call may send: what an app keeps is 5 MB at most (made-apps.ts), and a call carries one value of it. */ +const MAX_BODY = 6_000_000; + +/** The request's body as text, read only up to MAX_BODY (null past it), so a call can't make the server hold more. */ +async function bodyOf(request: Request): Promise { + if (Number(request.headers.get("content-length")) > MAX_BODY) return null; + const reader = request.body?.getReader(); + if (!reader) return ""; + const parts: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BODY) { + void reader.cancel().catch(() => {}); + return null; + } + parts.push(value); + } + return Buffer.concat(parts).toString("utf8"); +} + +/** An app's `window.bops` call, passed on by the window it runs in (components/app/made-apps.tsx). */ +export async function POST(request: Request, ctx: RouteContext<"/api/made-apps/[appId]/bridge">) { + // Only an @orgo.ai account has apps for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { appId } = await ctx.params; + const text = await bodyOf(request); + if (text === null) return Response.json({ error: "That's too much for one call (6 MB at most)." }, { status: 413 }); + let body: { method?: string; args?: Record } = {}; + try { + body = JSON.parse(text || "{}") ?? {}; + } catch {} + const { method, args } = body; + try { + return Response.json({ result: await bridge(appId, String(method ?? ""), args && typeof args === "object" ? args : {}) }); + } catch (e) { + // Not allowed yet: the window asks the user (components/app/made-apps.tsx) and calls again if they say yes. + if (e instanceof NeedsAllow) return Response.json({ needs: e.what }); + return Response.json({ error: (e as Error).message }); + } +} diff --git a/app/api/made-apps/[appId]/route.ts b/app/api/made-apps/[appId]/route.ts new file mode 100644 index 00000000..8437fb7a --- /dev/null +++ b/app/api/made-apps/[appId]/route.ts @@ -0,0 +1,36 @@ +import { appsOpen } from "@/lib/apps-access"; +import { allow, buildApp, codeFor, deleteApp, draftOf, goBack, madeApp } from "@/lib/server/made-apps"; +import { getState } from "@/lib/server/store"; + +/** An app a bot made: its code now (or what a build is writing), for the tab's Code view; fetched from Orgo first when it was made on another Mac. */ +export async function GET(_request: Request, ctx: RouteContext<"/api/made-apps/[appId]">) { + // Only an @orgo.ai account has apps for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { appId } = await ctx.params; + if (!madeApp(appId)) return Response.json({ error: "No such app." }, { status: 404 }); + return Response.json({ code: await codeFor(appId), draft: draftOf(appId) }); +} + +/** Go back to a version (and Undo), ask its bot to fix a problem it hit, let it ask its bot, or delete it. */ +export async function POST(request: Request, ctx: RouteContext<"/api/made-apps/[appId]">) { + // Only an @orgo.ai account has apps for now (lib/apps-access.ts). + if (!appsOpen(getState())) return Response.json({ error: "Not found" }, { status: 404 }); + const { appId } = await ctx.params; + const app = madeApp(appId); + if (!app) return Response.json({ error: "No such app." }, { status: 404 }); + const body = (await request.json().catch(() => ({}))) as { action?: string; version?: number; problem?: string; on?: boolean }; + try { + if (body.action === "back" && typeof body.version === "number") { + // The version gone back to may have been made on another Mac: its code is fetched first. + await codeFor(appId, body.version); + goBack(appId, body.version); + } else if (body.action === "fix" && body.problem) buildApp(app.botId, app.chatId, { appId, change: `It shows this problem when it runs; fix it: ${body.problem.slice(0, 1500)}` }); + else if (body.action === "delete") deleteApp(appId); + // Let it ask its bot (bops.bot), or stop it. + else if (body.action === "allow-bot") allow(appId, "bot", body.on !== false); + else return Response.json({ error: "What should happen?" }, { status: 400 }); + return Response.json({ ok: true }); + } catch (e) { + return Response.json({ error: (e as Error).message }, { status: 400 }); + } +} diff --git a/app/api/made-apps/[appId]/view/route.ts b/app/api/made-apps/[appId]/view/route.ts new file mode 100644 index 00000000..7800458a --- /dev/null +++ b/app/api/made-apps/[appId]/view/route.ts @@ -0,0 +1,33 @@ +import { appsOpen } from "@/lib/apps-access"; +import { viewPage } from "@/lib/server/made-apps"; +import { getState } from "@/lib/server/store"; + +/** + * The page an app a bot made runs in (lib/server/made-apps.ts). A model wrote it, so it runs sandboxed + * like bots' pages: an opaque origin, no fetches, images or fonts from anywhere (Bops' own API + * included), no storage, no forms. The CSP can't stop a page going to another address itself, or + * WebRTC: in the Mac app it runs in a webview on a session where nothing but its page loads and WebRTC + * reaches nothing (desktop/app-frames.cjs). All it can reach is the window, which passes `window.bops` + * calls to the bridge, and only for this first page. + */ +export async function GET(request: Request, ctx: RouteContext<"/api/made-apps/[appId]/view">) { + // Only an @orgo.ai account has apps for now (lib/apps-access.ts). + if (!appsOpen(getState())) return new Response("Not found", { status: 404 }); + const { appId } = await ctx.params; + const v = Number(new URL(request.url).searchParams.get("v")) || undefined; + let html: string | null = null; + try { + html = await viewPage(appId, v); + } catch {} + if (!html) return new Response("Not found", { status: 404 }); + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": + "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; frame-ancestors 'self'", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/members/route.ts b/app/api/members/route.ts index 727ccab0..20f6f270 100644 --- a/app/api/members/route.ts +++ b/app/api/members/route.ts @@ -33,21 +33,24 @@ export async function GET(request: Request) { return answer(new URL(request.url).searchParams.get("count") === "1" ? await membersCount() : await readMembers()); } -/** Invite someone, or send their invite again: {email, role: "viewer" | "admin"}. Answers {email, role, emailSent, link?}. */ +/** + * Invite someone, or send their invite again: {email, role: "viewer" | "admin", access?}, `access` being + * what they may do with this workspace's bots (lib/member-access.ts). Answers {email, role, emailSent, link?}. + */ export async function POST(request: Request) { const stop = notReady() ?? notFromWindow(request); if (stop) return stop; return answer(await invite(await bodyOf(request))); } -/** Change what someone can do: {memberId, role: "viewer" | "admin"}. */ +/** Change what someone can do: {memberId, role?: "viewer" | "admin", access?}, one or both. */ export async function PATCH(request: Request) { const stop = notReady() ?? notFromWindow(request); if (stop) return stop; return answer(await changeAccess(await bodyOf(request))); } -/** Take someone's access away ({memberId}), or cancel an invite ({email}). */ +/** Take someone's access away ({memberId}), or cancel an invite ({email}): their bot access goes too. */ export async function DELETE(request: Request) { const stop = notReady() ?? notFromWindow(request); if (stop) return stop; diff --git a/app/api/runs-on/keys/route.ts b/app/api/runs-on/keys/route.ts new file mode 100644 index 00000000..bac344d0 --- /dev/null +++ b/app/api/runs-on/keys/route.ts @@ -0,0 +1,22 @@ +import { computerKeys, orgoKeysPage } from "@/lib/server/computer-keys"; +import { stateReady } from "@/lib/server/store"; + +export const dynamic = "force-dynamic"; + +/** + * Whether each of the bots' own computers in Settings (Your bots' computers, components/app/bot-computers.tsx) + * holds its own Orgo key (lib/server/computer-keys.ts), asked of Orgo here with the signed-in key, never + * from the window: GET ?ids=,. + * {keys: {: key | null}, manage}: each computer's key as Settings shows it (what names it, + * never the key), null when it has none, left out when Orgo couldn't say; `manage` is where the user + * sees and revokes their keys on Orgo. Each computer's answer is kept a minute. + */ +export async function GET(request: Request) { + const manage = orgoKeysPage(); + if (!stateReady()) return Response.json({ keys: {}, manage }); + const ids = (new URL(request.url).searchParams.get("ids") ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + return Response.json({ keys: ids.length ? await computerKeys(ids) : {}, manage }); +} diff --git a/app/api/runs-on/route.ts b/app/api/runs-on/route.ts new file mode 100644 index 00000000..3aeca411 --- /dev/null +++ b/app/api/runs-on/route.ts @@ -0,0 +1,40 @@ +import { askRunsOn, heardRunsOn, runsOnMissing, setRunsOn } from "@/lib/server/runner"; +import { orgo } from "@/lib/server/orgo"; +import { runnersSoon } from "@/lib/server/runner-install"; +import { notReady } from "@/lib/server/ready"; +import { getState, stateReady } from "@/lib/server/store"; +import { computerAsleep, computerStaysDown } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** + * Where routines run (Settings, lib/server/runner.ts): Cloud or This Mac, as Orgo keeps it (asked again + * now; null when Orgo hasn't answered, so Settings picks neither and says so), whether Orgo can keep it + * yet (`missing`: not, so they run on this Mac), which computers with a runner up are asleep now, and + * which are down and don't come back by themselves (`down`, by computer: stopped, frozen, in error; their + * routines wait for them, and run there once they're up). Orgo's status read never wakes one. Each computer's + * runner is in the state (runners). + */ +export async function GET() { + if (!stateReady()) return Response.json({ runsOn: null, missing: false, asleep: [], down: {} }); + await askRunsOn(); + const up = Object.entries(getState().runners ?? {}) + .filter(([, x]) => x.status === "running") + .map(([id]) => id); + const status = await Promise.all(up.map((id) => orgo.computer(id).then((c) => [id, c.status] as const, () => [id, undefined] as const))); + const asleep = status.filter(([, x]) => computerAsleep(x)).map(([id]) => id); + const down = Object.fromEntries(status.filter(([, x]) => computerStaysDown(x))); + return Response.json({ runsOn: heardRunsOn() ?? null, missing: runsOnMissing(), asleep, down }); +} + +/** Set where routines run from now on, for every Mac and computer of the user's (PUT orgo-web /api/bops/settings): the runners are set up or stopped in a moment. */ +export async function POST(request: Request) { + const unready = notReady(); + if (unready) return unready; + const body = (await request.json().catch(() => ({}))) as { runsOn?: unknown }; + if (body.runsOn !== "cloud" && body.runsOn !== "mac") return Response.json({ error: "runsOn: cloud or mac" }, { status: 400 }); + const res = await setRunsOn(body.runsOn); + if (res.error) return Response.json({ error: res.error, missing: !!res.missing, runsOn: heardRunsOn() ?? null }, { status: res.missing ? 409 : 502 }); + runnersSoon(); + return Response.json({ runsOn: heardRunsOn() ?? body.runsOn, missing: false }); +} diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index 5169b7c9..44b33997 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -77,7 +77,7 @@ export async function POST(request: Request) { } case "update": { const e = (b.edit ?? {}) as Record; - return Response.json({ ref: updateSkill(wsId, kind, slug, { name: str(e.name), description: str(e.description), body: str(e.body) }) }); + return Response.json({ ref: updateSkill(wsId, kind, slug, { name: str(e.name), description: str(e.description), body: str(e.body), label: str(e.label) }) }); } case "publish": return Response.json({ ref: publishSkill(wsId, kind, slug, { version: str(b.version) }) }); diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 13e40296..fc45423e 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -4,6 +4,8 @@ import "@/lib/server/mac"; import { getState, getVersion } from "@/lib/server/store"; import type { AppState } from "@/lib/types"; import { ensureCloud } from "@/lib/server/cloud-tunnel"; +import { liveMoodsForApp } from "@/lib/server/live"; +import { sharedForApp } from "@/lib/server/shared"; import { startMail } from "@/lib/server/mail"; import { startPhone } from "@/lib/server/phone"; import { startChannels } from "@/lib/server/channels"; @@ -12,7 +14,12 @@ import { fullAccessOn } from "@/lib/server/full-access"; import { tregOn } from "@/lib/server/treg"; import { startActivityBeat } from "@/lib/server/free-hours"; import { startComputerChecks } from "@/lib/server/sessions"; -import { retryHeldRenameOnStart } from "@/lib/server/workspaces"; +import { startRunsOn } from "@/lib/server/runner"; +import { startBopsSync } from "@/lib/server/bops-sync"; +import { startRunnerInstalls } from "@/lib/server/runner-install"; +import { retryHeldRenameOnStart, startProjectBinding } from "@/lib/server/workspaces"; +import { startMadeAppsSync } from "@/lib/server/made-apps-sync"; +import { startCrmSync } from "@/lib/server/crm-sync"; export const dynamic = "force-dynamic"; @@ -20,9 +27,14 @@ export const dynamic = "force-dynamic"; * Full app state plus a version counter; the UI polls this. With ?v= (the version the page has), * an unchanged state answers with just the version, not the whole state. Importing routines and * watches starts their loops; the first poll starts mail (bots' inboxes, and listening for email), - * Bops Cloud's tunnel if the server's start didn't (the Keychain gave the key only later), the - * look at the bots' computers on Orgo (one deleted there since is replaced), and once a start the - * default workspace's name held for the apps is tried again (workspaces.ts retryDefaultRename). + * Bops Cloud's tunnel if the server's start didn't (the Keychain gave the key only later), the look + * at the bots' computers on Orgo (one deleted there since is replaced), asking Orgo where the bots + * work (runner.ts), which says which routines are this Mac's, the map of the bots in each Orgo + * workspace (bops-sync.ts), the runners on the bots' computers (runner-install.ts), an Orgo project + * for each workspace that has none yet (workspaces.ts startProjectBinding), and the apps the bots made + * and the CRM's files in step with Orgo's record of them (made-apps-sync.ts, crm-sync.ts), and once a + * start the default workspace's name held for the apps is tried again (workspaces.ts retryDefaultRename). + * With a workspace shared with the user open, it's that workspace's state, read only (lib/server/shared.ts). */ export async function GET(request: Request) { ensureCloud(); @@ -31,11 +43,19 @@ export async function GET(request: Request) { startChannels(); startComputerChecks(); startActivityBeat(); + startRunsOn(); + startBopsSync(); + startRunnerInstalls(); + startProjectBinding(); + startMadeAppsSync(); + startCrmSync(); retryHeldRenameOnStart(); const have = new URL(request.url).searchParams.get("v"); - const version = getVersion(); + // A workspace shared with the user is open: its state in place of their own workspace's, never theirs (lib/server/shared.ts). + const shared = sharedForApp(getState()); + const version = shared?.version ?? getVersion(); if (have !== null && Number(have) === version) return Response.json({ version }); - return Response.json({ version, state: forApp(getState()) }); + return Response.json({ version, state: forApp(shared?.state ?? getState()) }); } /** @@ -44,12 +64,13 @@ export async function GET(request: Request) { * totals on the server (/api/account), and what's kept of each watched web page to compare it (only the * server compares), with when the page was last read even when that wasn't saved (watches.ts * watchesForApp), and each workspace's Orbit secret (memory-categories.ts). It also says whether the other - * screens stream through Orgo. + * screens stream through Orgo, and what the user's bots are doing on their other devices right now + * (live.ts liveMoodsForApp), which is never saved; a shared workspace's bots' come from its own stream. */ function forApp(state: AppState): AppState { // Full access is kept on this Mac, not in the state (full-access.ts): shown as part of it. const mac = state.mac ? { ...state.mac, fullAccess: fullAccessOn() || undefined } : state.mac; const watches = watchesForApp(state.watches); const workspaces = state.workspaces?.map((w) => (w.orbitKey ? { ...w, orbitKey: undefined } : w)); - return { ...state, mac, watches, workspaces, usage: undefined, bots: state.bots.map((b) => ({ ...b, appsKey: undefined })), screenStream: screenStreamWanted(), businessData: tregOn() }; + return { ...state, mac, watches, workspaces, usage: undefined, bots: state.bots.map((b) => ({ ...b, appsKey: undefined })), screenStream: screenStreamWanted(), businessData: tregOn(), liveMoods: state.shared ? state.liveMoods : liveMoodsForApp(state) }; } diff --git a/app/api/takeover/route.ts b/app/api/takeover/route.ts index 257a4185..0f5d4bca 100644 --- a/app/api/takeover/route.ts +++ b/app/api/takeover/route.ts @@ -1,15 +1,18 @@ import { returnControl, takeOver, takeOverMacChrome } from "@/lib/server/sessions"; import { notReady } from "@/lib/server/ready"; -/** Take control of one of a bot's screens, or with `macScreen` of a Chrome its Mac tasks use. The thread running there pauses. */ +/** + * Take control of one of a bot's screens, or with `macScreen` of a Chrome its Mac tasks use. The thread running there pauses. + * `sessionId`: the thread whose card the user came from (its Sign in): handing the screen back carries it on, when it waits there. + */ export async function POST(request: Request) { const unready = notReady(); if (unready) return unready; - const { botId, display, macScreen } = (await request.json()) as { botId?: string; display?: number; macScreen?: number }; + const { botId, display, macScreen, sessionId } = (await request.json()) as { botId?: string; display?: number; macScreen?: number; sessionId?: unknown }; if (!botId || (display === undefined && macScreen === undefined)) return Response.json({ error: "botId and display (or macScreen) required" }, { status: 400 }); if (macScreen !== undefined && ![0, 1, 2].includes(macScreen)) return Response.json({ error: "macScreen: 0, 1 or 2" }, { status: 400 }); try { - await (macScreen !== undefined ? takeOverMacChrome(botId, macScreen) : takeOver(botId, display!)); + await (macScreen !== undefined ? takeOverMacChrome(botId, macScreen) : takeOver(botId, display!, typeof sessionId === "string" && sessionId ? sessionId : undefined)); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 62ef106e..2ae5f695 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -1,6 +1,7 @@ import { deleteWorkspace } from "@/lib/server/remove"; -import { createWorkspace, deletePreview, dismissRenamedNotice, ensureMain, renameWorkspace, revertWorkspaceMigration, switchWorkspace, syncWorkspaceNames, WorkspaceError } from "@/lib/server/workspaces"; +import { createWorkspace, deletePreview, dismissRenamedNotice, ensureMain, renameWorkspace, revertWorkspaceMigration, setFavorites, switchWorkspace, syncWorkspaceNames, WorkspaceError } from "@/lib/server/workspaces"; import { notReady } from "@/lib/server/ready"; +import { closeShared } from "@/lib/server/shared"; /** * `?preview=`: what deleting that workspace takes with it, for the confirm (its computers, keys, skills @@ -31,6 +32,8 @@ export async function POST(request: Request) { ensureMain(); const made = await createWorkspace(typeof name === "string" ? name : ""); if ("error" in made) return Response.json(made, { status: made.code === "name_taken" ? 409 : made.status }); + // The new one is open now, in place of a shared one that was. + closeShared(); return Response.json(made); } @@ -38,12 +41,21 @@ export async function POST(request: Request) { * Switch to a workspace, or rename one (its Orgo workspace too, when it has one; the default's waits for every * app on the account to be new, and says so in `waiting`). `noticeSeen`: the switcher's note about names Orgo * already had was seen. `syncNames`: take renames made on orgo.ai (the sidebar asks every 10 minutes). `revertOrgoMove`: undo the move onto Orgo workspaces - * (revertWorkspaceMigration, DESIGN.md section 8), for a rollback. + * (revertWorkspaceMigration, DESIGN.md section 8), for a rollback. `favorites` (with `id`): the kinds pinned to + * the sidebar, the whole list in order (setFavorites). */ export async function PATCH(request: Request) { const unready = notReady(); if (unready) return unready; - const { id, name, current, noticeSeen, revertOrgoMove, syncNames } = (await request.json().catch(() => ({}))) as { id?: string; name?: string; current?: string; noticeSeen?: boolean; revertOrgoMove?: boolean; syncNames?: boolean }; + const { id, name, current, noticeSeen, revertOrgoMove, syncNames, favorites } = (await request.json().catch(() => ({}))) as { + id?: string; + name?: string; + current?: string; + noticeSeen?: boolean; + revertOrgoMove?: boolean; + syncNames?: boolean; + favorites?: unknown; + }; ensureMain(); if (noticeSeen === true) dismissRenamedNotice(); if (syncNames === true) { @@ -58,7 +70,15 @@ export async function PATCH(request: Request) { } } try { - if (current) switchWorkspace(current); + if (current) { + switchWorkspace(current); + // One of the user's own, in place of a shared one that was open (lib/server/shared.ts). + closeShared(); + } + if (id && favorites !== undefined) { + setFavorites(id, favorites); + return Response.json({ ok: true }); + } if (id && typeof name === "string") { const r = await renameWorkspace(id, name); if ("error" in r) return Response.json(r, { status: r.code === "name_taken" ? 409 : 502 }); diff --git a/app/api/workspaces/shared/route.ts b/app/api/workspaces/shared/route.ts new file mode 100644 index 00000000..9bd403ef --- /dev/null +++ b/app/api/workspaces/shared/route.ts @@ -0,0 +1,37 @@ +import { closeShared, listShared, olderShared, openShared } from "@/lib/server/shared"; +import { notReady } from "@/lib/server/ready"; +import { fromBopsWindow } from "@/lib/server/ui-token"; + +export const dynamic = "force-dynamic"; + +/* + * Workspaces others shared with the user, read only (lib/server/shared.ts): the switcher lists them, + * opens one in place of the user's own, and leaves it. Opening and leaving only from the Bops window. + */ + +/** The workspaces shared with the user: { workspaces }, empty when Orgo couldn't say. */ +export async function GET() { + const unready = notReady(); + if (unready) return unready; + return Response.json({ workspaces: await listShared().catch(() => []) }); +} + +/** + * `{ open: }` opens one (404 when it isn't shared with the user, what Bops Cloud said otherwise); + * `{ leave: true }` leaves it; `{ older: }` reads a page more of that chat's older messages into the + * one open ({ ok, more: whether some came }). + */ +export async function POST(request: Request) { + const unready = notReady(); + if (unready) return unready; + if (!fromBopsWindow(request)) return Response.json({ error: "Only from the Bops window." }, { status: 403 }); + const body = (await request.json().catch(() => ({}))) as { open?: unknown; leave?: unknown; older?: unknown }; + if (body.leave === true) { + closeShared(); + return Response.json({ ok: true }); + } + if (typeof body.older === "string" && body.older) return Response.json({ ok: true, more: await olderShared(body.older) }); + if (typeof body.open !== "string" || !body.open) return Response.json({ error: "Say which workspace ({ open })." }, { status: 400 }); + const r = await openShared(body.open); + return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status || 502 }); +} diff --git a/app/globals.css b/app/globals.css index 8f8c5eff..ddbbe912 100644 --- a/app/globals.css +++ b/app/globals.css @@ -208,8 +208,9 @@ /* * Boppy listening to music while Bops loads (BoppyLoading in components/app/mascot.tsx): it bops to - * the beat, leaning one way then the other, and notes drift out of its headphones. The same scene as - * the window's splash (desktop/splash.html), so one hands over to the other without a jump. + * the beat, leaning one way then the other, notes drift out of its headphones, and it sings along and + * blinks now and then. The same scene as the window's splash (desktop/splash.html), so one hands over + * to the other without a jump. */ .boppy-loading-scene { position: relative; @@ -225,6 +226,85 @@ transform-origin: 50% 90%; animation: boppy-bop 1.1s ease-in-out infinite; } +/* + * It sings along, a phrase over four bars: its mouth, shut between lines, opens on the beats, a held + * note wavering. Its eyes blink now and then, twice sometimes. The eyes and mouth are the awake face's + * (lib/mascot.ts), found by their shapes, since the pictures carry no classes (scripts/test-bot-moods.mjs + * holds them to it). + */ +.boppy-loading-boppy g[transform="translate(.6 -1.4)"] ellipse { + transform-box: fill-box; + transform-origin: center; + animation: boppy-blink 5.3s ease-in-out infinite; +} +.boppy-loading-boppy ellipse[cy="26.6"] { + transform-box: fill-box; + transform-origin: center; + animation: boppy-sing 4.4s ease-in-out infinite; +} +@keyframes boppy-sing { + 0%, + 2%, + 47%, + 52%, + 96%, + 100% { + transform: scale(1.2, 0.3); + } + 6% { + transform: scale(1, 1.15); + } + 12%, + 25% { + transform: scale(1.1, 0.5); + } + 18% { + transform: scale(1.05, 1.35); + } + 31%, + 39% { + transform: scale(0.95, 1.1); + } + 35%, + 43% { + transform: scale(0.95, 0.9); + } + 56% { + transform: scale(1.05, 1.25); + } + 62% { + transform: scale(1.1, 0.45); + } + 68% { + transform: scale(1.1, 1.4); + } + 74% { + transform: scale(1.1, 0.5); + } + 81%, + 91% { + transform: scale(1, 1.2); + } + 86% { + transform: scale(1, 1); + } +} +@keyframes boppy-blink { + 0%, + 38%, + 43%, + 82%, + 86.5%, + 91.5%, + 100% { + transform: scaleY(1); + } + 40%, + 84%, + 88.5% { + transform: scaleY(0.1); + } +} .boppy-loading-shadow { position: absolute; left: 22px; @@ -324,6 +404,7 @@ } @media (prefers-reduced-motion: reduce) { .boppy-loading-boppy, + .boppy-loading-boppy ellipse, .boppy-loading-shadow { animation: none; } @@ -654,6 +735,238 @@ } } +/* + * A bot's mood while it works (lib/mascot.ts draws each pose, lib/bot-mood.ts picks it): thinking + * glances up, its head tilting with it, while its thought bubble's dots pulse; writing scribbles with + * its pencil, nodding with each stroke; speaking moves its mouth, its head with it, with sound off a + * cup; working bops along, leaning one way then the other, with its cups to the beat; shy sways, + * looking away, its cheeks glowing. Slow loops of transform and opacity only, in the mascot's own 40 + * unit box (a px here is a unit there; each head turns about its own chin). With reduced motion each + * holds its pose; while the window is hidden they rest (mascot-paused). + */ +@keyframes mascot-glance { + 0%, + 100% { + transform: translate(0, 0); + } + 30%, + 75% { + transform: translate(0.6px, -1.4px); + } +} +.mascot-glance { + animation: mascot-glance 2s ease-in-out infinite; +} +/* In time with the glance: the head tilts a little further as the eyes go up, "hmm". */ +@keyframes mascot-ponder { + 0%, + 100% { + transform: rotate(0deg); + } + 30%, + 75% { + transform: rotate(-3deg); + } +} +.mascot-thinking { + transform-origin: 20px 30px; + animation: mascot-ponder 2s ease-in-out infinite; +} +@keyframes mascot-thought { + 0%, + 60%, + 100% { + opacity: 0.35; + } + 30% { + opacity: 1; + } +} +.mascot-thought-dot { + animation: mascot-thought 1.4s ease-in-out infinite; +} + +/* Writing a word, a stroke at a time, then back to the start of the next; the line darkens as it goes. */ +@keyframes mascot-scribble { + 0%, + 100% { + transform: translate(0, 0); + } + 20% { + transform: translate(0.7px, -0.6px); + } + 40% { + transform: translate(1.4px, 0); + } + 60% { + transform: translate(2.1px, -0.6px); + } + 80% { + transform: translate(2.8px, 0); + } +} +.mascot-pencil { + animation: mascot-scribble 1.6s ease-in-out infinite; +} +@keyframes mascot-ink { + 0%, + 100% { + opacity: 0.25; + } + 80% { + opacity: 1; + } +} +.mascot-scribble { + animation: mascot-ink 1.6s ease-in-out infinite; +} +/* A small nod into the page, twice a word. */ +@keyframes mascot-jot { + 0%, + 100% { + transform: translateY(0) rotate(0deg); + } + 50% { + transform: translateY(0.5px) rotate(-1.5deg); + } +} +.mascot-writing { + transform-origin: 20px 32px; + animation: mascot-jot 0.8s ease-in-out infinite; +} + +/* Talking: the mouth opens and closes from its top edge, and the sound arcs pulse out, inner first. */ +@keyframes mascot-talk { + 0%, + 100% { + transform: scaleY(0.35); + } + 30% { + transform: scaleY(1); + } + 55% { + transform: scaleY(0.5); + } + 75% { + transform: scaleY(0.9); + } +} +.mascot-mouth { + transform-box: fill-box; + transform-origin: 50% 0; + animation: mascot-talk 0.9s ease-in-out infinite; +} +@keyframes mascot-sound { + 0%, + 100% { + opacity: 0.2; + } + 40% { + opacity: 1; + } +} +.mascot-sound path { + animation: mascot-sound 1.2s ease-out infinite; +} +.mascot-sound path + path { + animation-delay: 0.2s; +} +/* The head lifts a touch as the mouth opens, in time with it. */ +@keyframes mascot-nod { + 0%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-0.5px); + } +} +.mascot-speaking { + animation: mascot-nod 0.9s ease-in-out infinite; +} + +/* At work: bopping along, leaning one way then the other with a lift between, the cups bouncing on each lift, one then the other. */ +@keyframes mascot-sway { + 0%, + 100% { + transform: translateY(0) rotate(-3deg); + } + 25%, + 75% { + transform: translateY(-0.8px) rotate(0deg); + } + 50% { + transform: translateY(0) rotate(3deg); + } +} +.mascot-working { + transform-origin: 20px 34px; + animation: mascot-sway 1.4s ease-in-out infinite; +} +@keyframes mascot-cup { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(0.8px); + } +} +.mascot-cup { + animation: mascot-cup 0.7s ease-in-out infinite; +} +.mascot-cup-r { + animation-delay: -0.35s; +} +/* Shy, for a few seconds after a thanks: a small bashful sway, the cheeks glowing. */ +@keyframes mascot-bashful { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(-3deg); + } +} +.mascot-shy { + transform-origin: 20px 32px; + animation: mascot-bashful 1.6s ease-in-out infinite; +} +@keyframes mascot-blush { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} +.mascot-blush { + animation: mascot-blush 1.6s ease-in-out infinite; +} +/* As specific as the sound arcs' own rule (.mascot-sound path), whose animation shorthand would otherwise run them on. */ +.mascot-paused *, +.mascot-paused .mascot-sound path { + animation-play-state: paused; +} +@media (prefers-reduced-motion: reduce) { + .mascot-glance, + .mascot-thinking, + .mascot-thought-dot, + .mascot-pencil, + .mascot-scribble, + .mascot-writing, + .mascot-mouth, + .mascot-sound path, + .mascot-speaking, + .mascot-working, + .mascot-cup, + .mascot-shy, + .mascot-blush { + animation: none; + } +} + /* Something new in Knowledge (sidebar.tsx NewDot): a soft blue ring swells out of the dot and fades, slowly, over and over. */ @keyframes new-pulse { from { diff --git a/cloud/README.md b/cloud/README.md index 5f94a524..543c2dcf 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -65,7 +65,72 @@ and `BOPS_PHONE_AREA` (the area code a plan's number is bought in, 415 unless se Every request from a Mac carries `Authorization: Bearer `, the key the app got from "Sign in with Orgo". The cloud asks Orgo whose it is (`GET /api/user/profile`) and keeps -the answer for 5 minutes by the key's SHA-256 (`cloud/auth.ts`). It never stores the key. +the answer for 5 minutes by the key's SHA-256 (`cloud/auth.ts`). It never stores the key. A key Orgo +turns down is a 401, with Orgo's `code` when it gave one (an identity token's says why). + +### A computer's runner + +A runner on one of the bots' computers (`docs/cloud-runner.md`) signs in as that computer, not as its +user: with the computer's Orgo identity (an `orgo_vm_` token; its profile has `principal: { kind: +"vm_identity", desktop_id, expires_at }`) or the computer's own key (`api_key.scope: "computer"`, with +`computer_id`). Either is a computer (`CloudUser.computer`), kept 30 seconds at most and never past the +token's end, so a computer deleted, moved, joined or paused reaches the cloud within that. An `orgo_vm_` +token is a computer whatever Orgo says: one whose profile names no computer is refused everywhere, and so +is a computer key Orgo never bound to its computer (`computer_key: true` without the computer scope). + +A computer calls only the routes marked `computer: true` (`cloud/http.ts`): `GET`/`PUT /v1/state`, +`GET /v1/state/head`, `GET`/`POST /v1/messages`, `POST /v1/session`, and the providers' routes. Everything +else is 403 `computer_not_allowed` ("A computer's identity can't do this."): numbers and lines, mail +handles, Slack links, codes, backups, notices, usage, the iPhone's agent, and the tunnel (`/v1/connect`: +one per user, so a computer there would drop the user's Mac). + +And it reaches only what its own bots use (`cloud/computer-scope.ts`, from the user's state, which it +can't rewrite where it matters). The Mac mirrors each bot's keys, app access and computer to orgo-web on +the user's own key, and orgo-web pins a runner's own calls (routines, Orbit, the access log, Logins) to +the bots those rows put on its computer, so a computer that could rewrite them could reach any bot's: + +- **Its state:** a write over the version it read may change only its own threads (`Session.by`), its + bots' routines' runs (the clock, a passed skip going, a pause), how its bots are doing on it + (`computerStatus`, `computerRam`, `computerNeverReady`, `wakeFailed`, `tailnet`, `composio`), an + account's `usedAt`, a brain's sign-in on this computer, news held about its bots (added), its screens' + last threads, `usage` and `credits`, and its own requests for the user's OK (`appApprovals` with + `askedBy` its device, of its bots or a thread of its own): added unanswered, and taken out (cancelled, or + read once answered). Never a reply (`reply`: once, always or reject is the owner's, from a Bops of their + own), so a computer never says yes to itself (`appApprovals.`), nor a change to one once made, nor a + bot's rules (`bots..permissions`). Anything else is 403 `computer_not_allowed` with `paths` (the first + 20: `bots..keys`, `routines..goal`, `workspaces`). It never starts a user's state (403 + `["state"]`), never uploads a whole one (the old builds' `PUT` without `base`), and never reads one with + the messages in it. +- **Its messages:** written, changed and taken out only in its bots' chats (`bot:`, and a chat one of + them is in), and only those read (`GET /v1/messages` skips the rest, its cursor past them). Only its + bots' own words (role `bot`, one of its bots) and Bops' notes (role `system`, naming none of the + others): never the user's, nor another bot's (`computer-scope.ts` computersMessage). +- **Its session:** `agentmail.apiKey` is empty and `proxied` true: the pod key reaches every inbox of the + user's and lives on past the computer. Its mail goes through `/proxy/agentmail/*` (computers only), + sent on with the user's pod key, in its bots' own inboxes only (`messages`, `search`, a message, its + attachments, `send`, `reply`, `reply-all`, a thread). +- **The providers** (`Spec.computers`, `Rule.computer` in `proxy.ts`, deny by default): OpenAI, Typesafe + and treg as the Mac; Honcho in its bots' workspaces' banks only (`-bops`, + `-bops-`, or the bank a workspace shares), the list filtered to them; Composio's + catalog, a session only for one of its bots (`x-bops-bot`) with that bot's apps and accounts, a read-only + app read-only (`readOnlyHint`), connecting accounts and the sandbox off, and actions only in an account + the bot was given (a reading one where it may only read), never connecting, deleting or linking an + account, a sign-in setup, a raw API call or a trigger; AgentPhone only `POST /v1/messages`, from a line + its bots text the user from to one of the user's verified numbers. +- **The user's OK** is the runner's to ask, as Claude Code's harness asks it (`lib/server/permission.ts`): + a bot's computer is trusted for its bots (it reads and writes their things and uses their accounts), so + Bops Cloud doesn't check its calls against the user's answers. What it holds to is who answers: a + computer never writes a reply (above), and someone the user shared a workspace with sees a request, read + only, and never answers it. + +Its device is the cloud's to set (`cloud/device.ts`: the same as the runner's own, from its computer +id), and a state write saying another device is 403. Its open sockets and streamed answers are looked at +again every 30 seconds (`server.ts watchComputer`): Orgo turning its credential down, or naming another +computer for it, ends them; a socket also ends at its token's end; a streamed answer (an agent's events) +outlives its token only while that computer keeps signing in afresh (`computerSeenAt`, under 2 minutes). +It still reads the user's whole state and spends their AI credit (the identity spec's Q4). orgo-web +answers a profile for an identity token only once `VM_IDENTITY_BOPS_CLOUD_READY=1`, which is set right +after this is deployed and before `VM_IDENTITY=admins`. ### Which app @@ -121,6 +186,9 @@ Webhooks are public and proven by the provider's signature instead. | 1 | `GET /v1/usage?from=&to=&tz=` | Mac | the user's metered use in a range, by kind, day and bot (`CloudUsage`; see "What's counted") | | 3 | `PUT /v1/slack/links` | Mac | where the user's bots are in Slack, for routing the Slack app's events | | 3 | `GET /v1/connect` (WebSocket) | Mac | the tunnel | +| 1 | `GET /v1/live?ws=` (Server-Sent Events), `POST /v1/live` | Mac, iPhone | what each bot of a workspace is doing right now, to every device of the user's (see "Live") | +| 1 | `GET /v1/live?project=`, `GET /v1/ws//state` | Mac | a workspace shared with the user, read only (see "Shared workspaces") | +| 1 | `POST /v1/internal/members-changed` | orgo-web | someone's access to a project changed (signed; see "Shared workspaces") | | 3,4 | `POST /hooks/agentphone` | AgentPhone | texts and call turns for any user's number | | 3,4 | `POST /hooks/openai` | OpenAI | incoming GPT-Live calls over a SIP trunk (dormant) | | 3 | `POST /hooks/slack` | Slack | events from Bops' Slack app, for any user's bots | @@ -138,7 +206,8 @@ Idempotent, safe to call at every app start; two at once for one user must not m so it's sealed (`crypto.ts`) in `bops.cloud_accounts` and the same one is handed back every time. The Mac uses AgentMail directly with it (REST and the WebSocket mail comes in on). AgentMail itself keeps that key inside the pod, and it has mail permissions only (inboxes, messages, drafts, - labels): no keys, pods, domains, webhooks, apps or account changes. + labels): no keys, pods, domains, webhooks, apps or account changes. A computer's runner never gets it + (`apiKey` empty, `proxied` true): its mail goes through `/proxy/agentmail` (above). - **AgentPhone:** a sub-account named `bops-` (found by name first, else `POST /v1/sub-accounts`). The Mac points each number's calls at its agent (`PATCH /v1/numbers/{id}/voice-routing {method: "agent"}` through the proxy, agent in voice mode @@ -178,7 +247,8 @@ object is held only until its owner is recorded. Two SDK details: the Honcho SDK its baseURL, so the cloud also answers Honcho at `/v3/*`; and the Composio SDK sends its key as `x-api-key`, so the Mac adds `Authorization: Bearer ` to it (`defaultHeaders`). -What keeps users apart, per service: +What keeps users apart, per service (a computer's runner is held to less, its own bots' use: "A computer's +runner" above): - **AgentPhone:** every request gets `X-Sub-Account-Id: `, whatever the Mac sent. A sub-account only sees its own numbers, agents and messages, so that is the whole wall. @@ -614,6 +684,73 @@ What Bops' front door (`edge/server.mjs`) served, now here, with nothing of any `edge/public`), names of letters, digits and `-` only, `image/png` or `image/jpeg`. Slack shows them as each bot's `icon_url`. +### Live + +What each bot of a workspace is doing right now (thinking, writing, speaking, working, idle), and a +nudge when the workspace changed, streamed to every signed-in device that watches it (`live.ts`, +`LiveEventIn` and `LiveEventOut` in `protocol.ts`). Nothing of it is saved: it's one hub in this +process's memory, keyed by account and workspace, and a restart starts it over. + +- `GET /v1/live?ws=` (Orgo key as Bearer): Server-Sent Events, each one's `event:` its + type and `data:` JSON. First a `snapshot` of the bots that aren't idle, then what the device missed + since its `Last-Event-ID` when that's in the last 2 minutes and from this start (ids are + `-`), else `reset` (it reads the state afresh). Events are kept for that only in a + workspace watched now or within those 2 minutes, and 64 MB of them in all. A comment every 15 + seconds keeps it open through Caddy (`flush_interval -1`). 20 streams per account and 5000 in all; + past that, 429, and the device goes on polling. A client that stops reading (256 KB behind) is cut + off and comes back with its `Last-Event-ID`. +- `POST /v1/live { ws, events }`: up to 16 events in 4 KB, each checked to its exact shape: + `mood { botId, chatId?, mood, ttl? }`, `session { id, botId, status, activity? }`, + `routine { id, change }`, `call { botId, on }`. 120 events every 10 seconds per account, then + 429 `live_busy`. Only into the caller's own account; `X-Bops-Device` + names the publisher (`by`), so a device can leave out its own. A mood lasts its ttl (15 seconds, at + most 60) unless said again: one that runs out leaves the snapshot and an `idle` goes out. +- `state { hash | seq, by }` comes from the cloud itself: after a write of the user's state, to each + workspace watched now or within 2 minutes whose part of it (the workspace, its bots, chats, threads, routines and watched + screens) changed, `hash` the fingerprint of what its members are shown of it (see "Shared + workspaces"); after messages are written, to their chats' workspaces, + `seq` the newest. The usage ledger and each Mac's own settings are no workspace's, so they wake + nobody. + +Who may watch or publish goes through `canWatch` and `canPublish` alone: the account's own user, or +(watching only) a member of the workspace's Orgo project (see "Shared workspaces"). Live is for the +user's devices: a Cloud mode computer's runner never opens or publishes to it (`/v1/live` isn't a +computer route, so `server.ts` refuses its token first, and `lib/server/persist.ts` keeps "live" among +the Mac's loops); what its routines do reaches the devices through the "state" event its state writes +send. Streams are counted by who opened them, so members can't use up the owner's 20. + +### Shared workspaces + +Someone the owner added to a Bops workspace's Orgo project watches it from their own Bops, read only +(`members.ts`, `ws-slice.ts`; `WsAccess` and `SharedWsState` in `protocol.ts`). The owner picks, per +person and per bot, None, Read or Write (`member-access.ts`, kept on the workspace as `memberAccess`; +Write reads as Read until people can act in a shared workspace). For each bot they were given they see +everything of it: all its chats, the owner's own messages there included, its threads with their notes +and results, its routines, its watched screens, what Needs you and its moods as they happen. A group +chat only when they were given every bot in it. Of a bot set to None, nothing. A message the owner +kept "between us" is no different here: that mark only keeps things out of the shared memory, so what +the owner wants kept from someone stays with a bot that person wasn't given. Never the owner's vault or +logins, keys or key names, Macs, usage, channels, connected accounts or settings, who else is in it or +what they may see, or anything of another workspace. Nobody but the owner changes anything: a member's +publish only goes into their own account, and nothing here writes. + +- Who may is orgo-web's to say, with the caller's own Orgo key: `GET /api/projects//bops-role` + answers `{ role: owner | admin | member, ownerUserId, bopsWsId, name }`, else 404. Kept a minute per + caller and project (a no 15 seconds). A project whose `bopsWsId` is null (an Orgo without per + workspace projects, one "bops" project for all of them) is refused, 409 `not_shareable`. +- `GET /v1/live?project=`: the owner's stream of that workspace (see "Live"). While it's open it + asks orgo-web again every minute; once the answer is no, a `revoked` event goes out and it ends. +- `GET /v1/ws//state`: `SharedWsState`, what the member is shown (the bots they were given, an + allow list of fields per kind) and the newest 200 messages of each of its chats, `hash` the same fingerprint the stream's + `state` events carry, `seq` the newest message write in its chats. `?have=.`: only + `{ unchanged: true }` when nothing changed; `?chat=&before=&beforeId=`: one chat's + older page, the messages before the oldest the reader has (its time and id: one in the same millisecond + that comes before it is in the page; without `beforeId`, the ones before that millisecond). +- `POST /v1/internal/members-changed { projectId, userId }`, from orgo-web after a member is removed, + leaves, has their role changed, or the project changes hands: signed as the plan notices are + (`BOPS_CLOUD_PLAN_SECRET`, see "Plans"; 404 unset, 401 signed wrong). What was kept for them goes and + their streams of it end (reconnecting asks orgo-web afresh); 204. + ## The app's state The Mac app keeps no state of its own: signed in, its state is the user's here, and signing in on @@ -633,21 +770,28 @@ user's other Macs hear of it, then swept hourly), and the rest is one blob in `a Every call names its user (`X-Bops-User`, which must be the key's: else 409 `wrong_user`), the protocol (`X-Bops-Protocol: 2`) and the Mac (`X-Bops-Device`). After a write the user's connected Mac -gets a `state` frame and reads what changed; the others look at the head every 30 seconds. Builds +gets a `state` frame and reads what changed, a device watching a workspace whose part changed gets a +live `state` event (see "Live"), and the others look at the head every 30 seconds. Builds from before still `PUT /v1/state { version, state }` their whole state as a backup (its messages become rows) and `GET` it back with the messages in, until a newer build writes for the user (`protocol` 2): then the old upload is refused (426). -Each app also says which state it may be given, `X-Bops-State-Level: 3` from 0.0.29 on (with every -call and the tunnel, `lib/server/app-version.ts`; none counts as 2), and a write keeps the highest in +Each app also says which state it may be given, `X-Bops-State-Level: 3` or more from 0.0.29 on (4 once +routines may run on a cloud runner, a Bops on one of the bots' computers; with every call and the tunnel, +`lib/server/app-version.ts`; none counts as 2), and a write keeps the highest in `protocol`. Once that's above an app's, every one of the calls above from it but the copy before "Start over" is refused (426 `state_too_new`), reads, merges and writes alike, and so is its tunnel (one it's on then is closed as replaced); its notices say to update. The copy is made from what's here and none of it goes back, and that Mac's Start over goes up once it's updated. 0.0.28 runs a routine that runs every so often or before meetings over and over (its `nextRun` throws after the task started), a monthly one daily, and lists a bot with an app it has no access to as able -to use it. What such a Mac changes meanwhile waits on it (`unsent-state.json`) and goes up merged once -it's updated. A user whose Macs are all older is never marked: nothing changes for them. +to use it, and one before level 4 runs every routine itself, a runner's too. What such a Mac changes +meanwhile waits on it (`unsent-state.json`) and goes up merged once it's updated; a newer app that's +still open when it's refused stops its routines until then (`lib/server/persist-cloud.ts` tooNew). One +before level 4 doesn't stop them, so once one at level 4 wrote the user's state it's refused `/proxy/…` +too (426 `state_too_new`, the state's level read again at most every minute: `state.ts` behindState), +and the runs it would make a second time fail. A call says its level, or only its version (0.0.29's +OpenAI and treg calls: below 0.0.30 is before level 4). A user whose Macs are all older is never marked: nothing changes for them. ## The main bot's chat from the phone @@ -801,12 +945,14 @@ bot's chat from the phone"). |---|---| | `server.ts` | puts the routes together, HTTP + upgrades, start and stop | | `config.ts`, `http.ts`, `auth.ts`, `crypto.ts`, `db.ts` | shared pieces | +| `computer-scope.ts` | what a computer's runner may touch of its user's Bops: its bots, their chats, accounts, inboxes, lines and banks, and what of the state it may write (the app imports it too) | | `protocol.ts` | what the cloud and the app say to each other (both import it) | | `session.ts`, `proxy.ts`, `verify.ts`, `usage.ts`, `reconcile.ts` | 1 and 2: setup, the proxies, codes, metering, agent turns read back | | `pricing.ts`, `credit.ts` | what each use costs, and the AI credit it's paid from (the gate) | | `plans.ts`, `provision.ts`, `handles.ts` | each user's plan and what it brings the main bot (a number, an inbox), and each workspace's mail handle | | `ops.ts` | each user's bot number and email for Orgo's staff page (signed, read only) | | `tunnel.ts`, `hooks.ts`, `slack.ts`, `state.ts` | 3: the tunnel, webhooks, Slack, state | +| `live.ts` | what each bot is doing right now, streamed to the user's devices (in memory only) | | `lines.ts`, `voice.ts`, `calls.ts` | 4: who owns each line, a call's turns answered here, GPT-Live calls (dormant) | | `agent.ts`, `agent-prompt.ts`, `honcho.ts` | 5: the main bot's chat from the phone, what the bot is told there, and its memory | | `style.ts` | how every bot writes and asks, and what Bops is: the words the Mac's bots and the cloud's share (the app imports it too) | @@ -828,6 +974,15 @@ links by the envelope's `team_id` or any `authorizations[].team_id`, as the clou in `slack.ts`), so a message in a channel shared with another workspace isn't dropped. Telegram and Discord stay on the Mac (their tokens are in its Keychain). +Live on the Mac (`lib/server/live.ts`, `live-core.ts`; see "Live"): while signed in it publishes what +its own bots are doing (typing or asking in a chat is thinking, a thread this Mac runs is working, the +user's call is speaking) when that changes and every 5 seconds while it holds, idle when it ends, and +its threads, routines and calls; and it keeps one stream open for the workspace the user is in. A +`state` from another device or a `reset` reads the state at once; the other devices' moods go to the +window (`liveMoods` in what `/api/state` answers, never saved). Dropped, the stream comes back after 1, +2, 4… seconds up to a minute with jitter, a cloud without Live is asked every 15 minutes, and the +30 second look at the head goes on as before. A sign-out, another account's sign-in, or quitting closes it. + For calls, the app answers each turn the cloud replays (`lib/server/phone-voice.ts`, as JSON: the cloud speaks the filler) and follows `x-bops-caller` for who's calling or texting, never its own list. It routes each number's calls to its agent when it makes or assigns one, puts back any that diff --git a/cloud/auth.ts b/cloud/auth.ts index 3fb77dc7..ba4721c2 100644 --- a/cloud/auth.ts +++ b/cloud/auth.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { IncomingMessage } from "node:http"; import { config } from "./config.ts"; +import { deviceIdOf } from "./device.ts"; import { HttpError } from "./http.ts"; /** @@ -8,25 +9,78 @@ import { HttpError } from "./http.ts"; * token, and Orgo says whose it is (GET /api/user/profile, the same call the app makes after * sign-in). The cloud never keeps the key itself: only who it belonged to, by its SHA-256, for a * few minutes. + * + * A computer's runner signs in as that computer, not as its user: with the computer's Orgo identity + * (an `orgo_vm_` token, whose profile has `principal: { kind: "vm_identity", desktop_id, expires_at }`) + * or the computer's own key (`api_key: { scope: "computer", computer_id }`). Either is a computer + * (CloudUser.computer), which server.ts lets call only the routes its runner needs (Route.computer), + * and only as that computer's device. An `orgo_vm_` token is a computer whatever Orgo says: one whose + * profile has no principal is refused on every route. A computer's answer is kept 30 seconds at most + * (and never past a token's end), so a computer deleted, moved, joined or paused reaches Bops Cloud + * within that. A computer's key Orgo hasn't bound to its computer (an old one: `computer_key: true` + * without the computer scope) is a computer Orgo named no computer for, refused everywhere too. + * + * What a computer may touch once it's in is computer-scope.ts's (state.ts, proxy.ts); its sockets and + * long answers are looked at again while they're open (server.ts watchComputer, computerSeenAt). */ -export type CloudUser = { id: string; email?: string; name?: string }; +export type CloudComputer = { + kind: "vm_identity" | "computer_key"; + /** Its Orgo computer id, lowercase; empty for an identity token Orgo named no computer for (refused everywhere). */ + desktopId: string; + /** When its token ends (ms); Infinity for a key. */ + expiresAt: number; + /** Its device as the user's state knows it (device.ts deviceIdOf of its computer id): set here, never taken from the caller. */ + device: string; +}; + +export type CloudUser = { id: string; email?: string; name?: string; computer?: CloudComputer }; + +export const VM_TOKEN_PREFIX = "orgo_vm_"; const HIT_MS = 5 * 60_000; +const COMPUTER_HIT_MS = 30_000; const MISS_MS = 30_000; -const cache = new Map(); +/** By the key's SHA-256: who it is (or null: turned down, with Orgo's code when it gave one), until when. */ +const cache = new Map(); +/** By "\n": when Orgo last answered for a credential of that computer (a fresh answer, not one kept). */ +const seen = new Map(); + +/** When Orgo last said a credential of this computer of the user's is good (0: not lately). */ +export const computerSeenAt = (userId: string, desktopId: string) => seen.get(`${userId}\n${desktopId}`) ?? 0; export const bearer = (req: IncomingMessage) => { const h = req.headers.authorization ?? ""; return /^Bearer\s+(.+)$/i.exec(h)?.[1]?.trim() || ""; }; -/** The user an Orgo key belongs to, or null when Orgo turns it down. Throws (503) when Orgo can't be asked. */ -export async function userForKey(key: string, fetchImpl: typeof fetch = fetch): Promise { - if (!key) return null; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const obj = (v: unknown) => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : null); +/** A code Orgo gave for turning a key down, passed on to the caller (a runner's identity token takes its next credential by it). */ +const codeOf = (v: unknown) => (typeof v === "string" && /^[a-z0-9_]{1,40}$/.test(v) ? v : undefined); + +/** The computer a profile names for the key that asked it, if it's a computer's (see above). */ +function computerOf(key: string, body: Record, userId: string, now: number): CloudComputer | undefined { + const p = obj(body.principal); + const k = obj(body.api_key); + const at = (desktopId: string, kind: CloudComputer["kind"], expiresAt: number): CloudComputer => ({ kind, desktopId, expiresAt, device: desktopId ? deviceIdOf(userId, desktopId) : "" }); + if (p?.kind === "vm_identity" && typeof p.desktop_id === "string" && UUID.test(p.desktop_id)) { + const ends = typeof p.expires_at === "string" ? Date.parse(p.expires_at) : NaN; + return at(p.desktop_id.toLowerCase(), "vm_identity", Number.isFinite(ends) ? ends : now + COMPUTER_HIT_MS); + } + if (key.startsWith(VM_TOKEN_PREFIX)) return at("", "vm_identity", now + COMPUTER_HIT_MS); + if (k?.scope === "computer") return at(typeof k.computer_id === "string" && UUID.test(k.computer_id) ? k.computer_id.toLowerCase() : "", "computer_key", Infinity); + // A computer's key Orgo hasn't bound to its computer: no computer to hold it to, so refused everywhere. + if (k?.computer_key === true) return at("", "computer_key", Infinity); + return undefined; +} + +/** Who an Orgo key is, and when it's turned down, Orgo's code for why. Throws (503) when Orgo can't be asked. */ +async function signInOf(key: string, fetchImpl: typeof fetch): Promise<{ user: CloudUser | null; code?: string }> { + if (!key) return { user: null }; const id = createHash("sha256").update(key).digest("hex"); const hit = cache.get(id); - if (hit && Date.now() - hit.at < (hit.user ? HIT_MS : MISS_MS)) return hit.user; + if (hit && Date.now() < hit.until) return hit; let res: Response; try { res = await fetchImpl(`${config.orgoOrigin()}/api/user/profile`, { @@ -37,8 +91,10 @@ export async function userForKey(key: string, fetchImpl: typeof fetch = fetch): throw new HttpError(503, "Can't reach Orgo to check who you are"); } if (res.status === 401 || res.status === 403) { - cache.set(id, { user: null, at: Date.now() }); - return null; + const said = obj(await res.json().catch(() => null)); + const miss = { user: null, code: codeOf(said?.code), until: Date.now() + MISS_MS }; + cache.set(id, miss); + return miss; } if (!res.ok) throw new HttpError(503, `Orgo answered ${res.status} when checking who you are`); const body = (await res.json().catch(() => ({}))) as Record; @@ -46,22 +102,38 @@ export async function userForKey(key: string, fetchImpl: typeof fetch = fetch): const u = (typeof body.user === "object" && body.user ? body.user : body) as Record; const userId = typeof u.id === "string" ? u.id : typeof u.user_id === "string" ? u.user_id : ""; if (!userId) throw new HttpError(503, "Orgo didn't say who you are"); + const now = Date.now(); + const computer = computerOf(key, body, userId, now); const user: CloudUser = { id: userId, ...(typeof u.email === "string" ? { email: u.email } : {}), ...(typeof u.name === "string" ? { name: u.name } : typeof u.full_name === "string" ? { name: u.full_name } : {}), + ...(computer ? { computer } : {}), }; - cache.set(id, { user, at: Date.now() }); - if (cache.size > 10_000) for (const [k, v] of cache) if (Date.now() - v.at > HIT_MS) cache.delete(k); - return user; + const until = computer ? Math.min(now + COMPUTER_HIT_MS, computer.expiresAt) : now + HIT_MS; + if (until > now) cache.set(id, { user, until }); + if (computer?.desktopId && computer.expiresAt > now) { + seen.set(`${userId}\n${computer.desktopId}`, now); + if (seen.size > 10_000) for (const [k, at] of seen) if (now - at > 10 * 60_000) seen.delete(k); + } + if (cache.size > 10_000) for (const [k, v] of cache) if (v.until <= now) cache.delete(k); + return { user }; +} + +/** The user an Orgo key belongs to, or null when Orgo turns it down. Throws (503) when Orgo can't be asked. */ +export async function userForKey(key: string, fetchImpl: typeof fetch = fetch): Promise { + return (await signInOf(key, fetchImpl)).user; } -/** The signed-in user for a request, or a 401. */ +/** The signed-in user for a request, or a 401 (with Orgo's code when it gave one: an identity token's says why). */ export async function requireUser(req: IncomingMessage): Promise { - const user = await userForKey(bearer(req)); - if (!user) throw new HttpError(401, "Sign in with Orgo"); + const { user, code } = await signInOf(bearer(req), fetch); + if (!user) throw new HttpError(401, "Sign in with Orgo", code ? { code } : undefined); return user; } /** For tests: forget every cached answer. */ -export const clearAuthCache = () => cache.clear(); +export const clearAuthCache = () => { + cache.clear(); + seen.clear(); +}; diff --git a/cloud/computer-scope.ts b/cloud/computer-scope.ts new file mode 100644 index 00000000..a2c0e467 --- /dev/null +++ b/cloud/computer-scope.ts @@ -0,0 +1,519 @@ +/** + * What a computer's runner may touch of its user's Bops (a runner signed in as its computer: auth.ts + * CloudUser.computer). The user's state is one blob that every Mac and runner of theirs shares, and the + * Mac mirrors parts of it to orgo-web on the user's own key: each bot's keys and app access (bops_access), + * and which computer each bot works on (bops_bots.desktop_id). orgo-web pins a runner's own calls + * (routines, Orbit, the access log, the vault) to the bots on its computer by exactly those rows. So a + * computer may write only what its runner's own work changes (writeDenials): + * - threads (sessions) its own device runs (Session.by), added, changed or taken out; + * - the run of a routine of a bot on it: its clock (nextRunAt, lastRunAt, last, lateRun, eventsThrough), + * skipped times that passed going (never one added), and a routine paused (never one turned on); + * - what it began stays its own to the end (threadsOf, runsOf, runsBy): a bot can move off the computer (to a + * computer of its own, to another workspace) while its runner runs one of its threads or one of its + * routines' runs (a chat run, RoutineRun.by), and that thread, the clock and record of the routine whose + * run it is (Routine.last.sessionId, or that run's time), the threads that run starts, and its bot's words + * and Bops' notes about them in its chat (Message.routine, also once the run's end is recorded, until + * another run is) still come from that runner, so the run ends once, with its result. Nothing new for + * that bot: no other thread started, no other run recorded, and no thread moved to another bot or chat. So a runner does a run's work only once Bops Cloud has its start + * (lib/server/store.ts startLanded): one refused because the move landed first is taken back there, and + * the Bops that has the bot now runs it; + * - how a bot on it is doing on its computer (computerStatus, computerRam, computerNeverReady, + * wakeFailed, tailnet) and its Composio session (composio); + * - its own requests for the user's OK (appApprovals, AppApproval.askedBy its device) of its bots, or of a thread + * of its own: added unanswered, and taken out (cancelled, or read once answered). Never a reply: that's the + * user's, from a Bops of their own (a person's action), so a computer never says yes to itself, and never + * anything of another Bops's request; + * - when an app account was last used (accounts[].usedAt), a brain's sign-in on this computer + * (brains[].signIns for its computer id), news held for later about a bot on it (digest, added only), + * which thread last held a screen of it (screenLog), the usage ledger (shown on the account page + * only) and the out-of-credit mark (credits). + * Anything else (bots added, moved or given keys or app access, routines added, edited or taken out, + * another device's threads, workspaces, logins, channels, phone numbers, Macs' own settings) is refused + * (403 computer_not_allowed). Its chat messages are only its bots' words and Bops' notes, in its bots' + * chats (computersMessage: never the user's or another bot's), and the services + * it reaches through Bops Cloud are held to its bots too (proxy.ts: their memory banks, app accounts, + * inboxes and texting lines). + * + * Pure, with no imports: Bops Cloud checks with it (state.ts, proxy.ts), a runner keeps to it before it + * sends (keepAllowed in lib/server/persist-cloud.ts, so a runner never sends what would be refused), and + * the Mac holds a computer's write to it again when it reads one (the second guard, persist-cloud.ts). + * Which bot works where follows lib/types.ts workBot exactly (scripts/test-computer-scope.mjs checks). + */ + +export type Blob = Record; +/** The computer (its Orgo id, any case) and its device as the state knows it (cloud/device.ts). */ +export type Scope = { desktopId: string; device: string }; + +const MAIN_WORKSPACE = "ws_main"; +/** Connections that put bots somewhere rather than apps they use (lib/types.ts CHANNEL_APPS): no default access. */ +const CHANNEL_APPS = new Set(["slackbot"]); + +const isObject = (v: unknown): v is Blob => !!v && typeof v === "object" && !Array.isArray(v); +const str = (v: unknown) => (typeof v === "string" ? v : undefined); +const lower = (v: unknown) => (typeof v === "string" ? v.toLowerCase() : ""); + +/** JSON with every object's keys in order, so the same content compares the same (Postgres' JSONB reorders keys). */ +function canonical(v: unknown): string { + return JSON.stringify(v, (_k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, (x as Blob)[k]])) : x)) ?? "undefined"; +} +export const same = (a: unknown, b: unknown) => a === b || canonical(a) === canonical(b); + +const listOf = (v: unknown): Blob[] => (Array.isArray(v) ? v.filter((x): x is Blob => isObject(x) && typeof x.id === "string") : []); +const byId = (v: unknown) => new Map(listOf(v).map((x) => [x.id as string, x])); + +/* ---------------- Which bots work on a computer (lib/types.ts, the same rules) ---------------- */ + +const workspaceOf = (b: Blob | undefined) => (b?.workspaceId as string | undefined) ?? MAIN_WORKSPACE; +const sharesComputer = (b: Blob) => (b.isMain ? b.computer === "shared" && !b.computerId : ((b.computer as string | undefined) ?? (b.computerId ? "own" : "shared")) === "shared"); +const freeComputerBot = (bots: Blob[], except?: unknown) => bots.find((x) => x.isMain && x.freeComputer && x.computerId && x.id !== except); + +/** The bot whose computer `b` works on (lib/types.ts workBot). */ +export function workBotOf(b: Blob, bots: Blob[], depth = 0): Blob { + if (!sharesComputer(b) || depth > 4) return b; + if (b.isMain) return freeComputerBot(bots, b.id) ?? b; + const main = bots.find((x) => x.isMain && workspaceOf(x) === workspaceOf(b)); + return main ? workBotOf(main, bots, depth + 1) : b; +} + +/** The bots that work on the computer (their own, their main bot's, or the free one), as the state says. */ +export function botsOn(state: Blob, desktopId: string): Blob[] { + const id = lower(desktopId); + if (!id) return []; + const bots = listOf(state.bots); + return bots.filter((b) => lower(workBotOf(b, bots).computerId) === id); +} + +export const botIdsOn = (state: Blob, desktopId: string) => new Set(botsOn(state, desktopId).map((b) => b.id as string)); + +/** The chats its runner may write and read in: its bots' own (bot:), and any chat one of them is in. */ +export function chatsOn(state: Blob, desktopId: string): Set { + const ids = botIdsOn(state, desktopId); + const out = new Set([...ids].map((id) => `bot:${id}`)); + for (const c of listOf(state.chats)) if (Array.isArray(c.botIds) && c.botIds.some((b) => typeof b === "string" && ids.has(b))) out.add(c.id as string); + return out; +} + +/** A thread its runner began, by id (threadsOf): its bot, and the chat it's in. */ +export type OwnThread = { botId: string; chatId: string }; + +/** + * The threads its runner already has in `state` (Session.by its device), by id: still its own after their + * bot moved off the computer, to their end (writeDenials, computersMessage). Another Bops runs that bot's + * routines from then on, and waits for such a run to end (lib/server/routines.ts strayRun). + */ +export function threadsOf(state: Blob, device: string): Map { + const out = new Map(); + if (!device) return out; + for (const s of listOf(state.sessions)) if (s.by === device && typeof s.botId === "string") out.set(s.id as string, { botId: s.botId, chatId: str(s.chatId) ?? `bot:${s.botId}` }); + return out; +} + +/** + * The routines whose run its runner began and is still going (Routine.last: running, RoutineRun.by its device), + * by id: their bot, and its chat. Still its own after their bot moved off the computer, as its threads are + * (threadsOf): a chat run, recorded with no thread while the bot's turn runs, ends there with its result. + */ +export function runsOf(state: Blob, device: string): Map { + const out = new Map(); + for (const [id, r] of runsBy(state, device)) if (r.status === "running") out.set(id, { botId: r.botId, chatId: r.chatId }); + return out; +} + +/** A routine's run its runner recorded (runsBy): its bot and chat, when it began (RoutineRun.at), and how it stands. */ +export type OwnRun = OwnThread & { at: number; status?: unknown }; + +/** + * The routines whose last run its runner recorded (Routine.last.by its device), by id, whatever that run's + * status: their bot, its chat, and when that run began. What that run posts in the chat (Message.routine: a + * chat run's result, its notes and chip, a ping or a "Couldn't send" after it) stays its runner's to write + * from when it began, its end recorded or not, until another run is (computersMessage): the end may reach + * Bops Cloud before its words do, and its bot may have moved off the computer meanwhile. + */ +export function runsBy(state: Blob, device: string): Map { + const out = new Map(); + if (!device) return out; + for (const r of listOf(state.routines)) + if (isObject(r.last) && r.last.by === device && typeof r.last.at === "number" && typeof r.botId === "string") + out.set(r.id as string, { botId: r.botId, chatId: `bot:${r.botId}`, at: r.last.at, status: r.last.status }); + return out; +} + +/** Its bot's words or Bops' note, from none other (Message.from), when `bot(id)` says which bots are its. */ +const saidBy = (m: Blob, bot: (id: unknown) => boolean) => (m.from === undefined || bot(m.from)) && (m.role === "bot" ? bot(m.botId) : m.role === "system" && (m.botId === undefined || bot(m.botId))); + +/** + * Whether its runner may write a message like this one, or change or remove one kept like it: in one of + * its bots' chats (chatsOn), and its bots' own words (role bot, one of `bots`) or a note from Bops (role + * system, naming none of the others), never the user's words nor another bot's. Or one about threads of + * its own (`threads`, threadsOf: a run's chip, its result), each of them, in their chat, said by their bot + * or Bops, or one of a routine's run of its own (`runs`, runsBy: Message.routine, a chat run's result, chip + * and notes, posted since that run began, its end recorded or not), in its bot's chat, said by that bot or + * Bops: that bot may have moved off the computer since the run began. A map with no `at` (runsOf, a run + * still going) takes any of that routine's. + */ +export function computersMessage(m: Blob, bots: Set, chats: Set, threads?: Map, runs?: Map): boolean { + if (chats.has(m.chatId as string) && saidBy(m, (id) => typeof id === "string" && bots.has(id))) return true; + const run = isObject(m.routine) && typeof m.routine.id === "string" ? runs?.get(m.routine.id) : undefined; + const ofThatRun = !!run && (run.at === undefined || (typeof m.at === "number" && m.at >= run.at)); + if (run && ofThatRun && m.chatId === run.chatId && saidBy(m, (b) => b === run.botId)) return true; + const named = [m.resultOf, ...(Array.isArray(m.sessionIds) ? m.sessionIds : [])].filter((x): x is string => typeof x === "string"); + return ( + !!threads && + named.length > 0 && + named.every((id) => { + const t = threads.get(id); + return !!t && m.chatId === t.chatId && saidBy(m, (b) => b === t.botId); + }) + ); +} + +/* ---------------- What its bots may use ---------------- */ + +export type Level = "read" | "act"; + +/** What a bot may do in an app account (lib/types.ts levelOf): what the user set, else Read & act for its workspace's bots by default. */ +export function levelOf(b: Blob, a: Blob): Level | null { + const set = isObject(b.access) ? b.access[a.id as string] : undefined; + if (set === "read" || set === "act") return set; + const openToTeam = !!a.workspaceId && !a.onlyGiven; + if (set === "none" || CHANNEL_APPS.has(a.app as string) || !openToTeam || workspaceOf(a) !== workspaceOf(b)) return null; + return "act"; +} + +/** + * The app accounts the computer's bots may use, working ones only (composio.ts accountsOf), by account id + * ("open:" for an app with no sign-in), with its app and the most any of them (or `botId` alone, + * when it's one of them) may do there. + */ +export function grantsOn(state: Blob, desktopId: string, botId?: string): Map { + const bots = botsOn(state, desktopId).filter((b) => botId === undefined || b.id === botId); + const out = new Map(); + for (const a of listOf(state.accounts)) { + if (a.status !== "active" || typeof a.app !== "string") continue; + for (const b of bots) { + const level = levelOf(b, a); + if (!level) continue; + const had = out.get(a.id as string); + out.set(a.id as string, { app: a.app, level: had?.level === "act" || level === "act" ? "act" : "read" }); + } + } + return out; +} + +/** + * Its bots' own inboxes (and the ones each had before: their mail still comes there), and for a bot with + * no inbox of its own its workspace main bot's, which it sends from (lib/server/routines.ts deliver: a + * routine's results by email; chat.ts), as linesOn does for texts. + */ +export function inboxesOn(state: Blob, desktopId: string): Set { + const bots = listOf(state.bots); + const out = new Set(); + for (const b of botsOn(state, desktopId)) { + const m = isObject(b.mail) ? b.mail : null; + if (typeof m?.inboxId === "string") out.add(m.inboxId); + if (Array.isArray(m?.past)) for (const p of m.past) if (typeof p === "string") out.add(p); + if (m) continue; + const main = bots.find((x) => x.isMain && isObject(x.mail) && workspaceOf(x) === workspaceOf(b)); + const inbox = main && isObject(main.mail) ? main.mail.inboxId : undefined; + if (typeof inbox === "string") out.add(inbox); + } + return out; +} + +/** The number a bot texts from (lib/server/phone.ts lineOf): its workspace's, when it's the main bot, else its own. */ +function lineOf(state: Blob, b: Blob): Blob | undefined { + if (b.isMain) { + const w = listOf(state.workspaces).find((x) => x.id === workspaceOf(b)); + if (isObject(w?.line)) return w.line; + } + return isObject(b.phoneLine) && b.phone ? b.phoneLine : undefined; +} + +/** The lines its bots text the user from (phone.ts textingLine: a bot's own, else its workspace's main bot's), by number and agent. */ +export function linesOn(state: Blob, desktopId: string): { numberIds: Set; agentIds: Set } { + const bots = listOf(state.bots); + const numberIds = new Set(); + const agentIds = new Set(); + for (const b of botsOn(state, desktopId)) { + const main = bots.find((x) => x.isMain && workspaceOf(x) === workspaceOf(b)); + const line = lineOf(state, b) ?? (main ? lineOf(state, main) : undefined); + if (typeof line?.numberId === "string") numberIds.add(line.numberId); + if (typeof line?.agentId === "string") agentIds.add(line.agentId); + } + return { numberIds, agentIds }; +} + +/** The user's own numbers they verified (lib/server/phone.ts ownerPhones: this user's), as their last 10 digits. */ +export function ownerNumbers(state: Blob, userId: string): Set { + const out = new Set(); + for (const p of Array.isArray(state.ownerPhones) ? state.ownerPhones : []) { + if (!isObject(p) || typeof p.number !== "string" || !p.verifiedAt || (p.userId !== undefined && p.userId !== userId)) continue; + out.add(p.number.replace(/\D/g, "").slice(-10)); + } + return out; +} + +/** Honcho ids allow letters, digits, _ and - (lib/server/memory.ts clean). */ +const cleanId = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 200); + +/** + * The memory banks its bots' workspaces use (lib/server/memory.ts bindingOf, signed in): a workspace's + * shared one, else its own ("bops" for the default workspace, "bops-" for the others). Bops + * Cloud puts the user's prefix in front (session.ts honchoPrefix). + */ +export function banksOn(state: Blob, desktopId: string): Set { + const out = new Set(); + for (const b of botsOn(state, desktopId)) { + const ws = workspaceOf(b); + const w = listOf(state.workspaces).find((x) => x.id === ws); + const shared = isObject(w?.memory) ? str(w.memory.bank) : undefined; + out.add(shared ?? (ws !== MAIN_WORKSPACE ? `bops-${cleanId(ws)}` : "bops")); + } + return out; +} + +/* ---------------- What it may write ---------------- */ + +/** A routine's run (lib/server/state-merge.ts CLOCK): what the runner that runs it changes. */ +const ROUTINE_RUN = ["nextRunAt", "lastRunAt", "last", "lateRun", "eventsThrough"] as const; +/** How a bot is doing on its computer, and its Composio session (lib/server/sessions.ts, composio.ts). */ +const BOT_RUN = ["computerStatus", "computerRam", "computerNeverReady", "wakeFailed", "tailnet", "composio"] as const; +/** Top-level keys a computer may change as it likes: the usage ledger (the account page's) and the out-of-credit mark. */ +const FREE = new Set(["usage", "credits"]); + +/** `runs`: its routines' runs still going (runsOf) that the write keeps as that run (their record's time). */ +type Ctx = { device: string; desktopId: string; bots: Set; chats: Set; threads: Map; runs: Map; force: Set }; +type Kept = { value: unknown; denied: string[] }; + +/** A field as an object has it: taken from `from` (or gone when it hasn't it). */ +function copyField(to: Blob, from: Blob, k: string) { + if (k in from) to[k] = from[k]; + else delete to[k]; +} + +/** The keys two objects differ in. */ +const differ = (a: Blob, b: Blob) => [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((k) => !same(a[k], b[k])); + +/** A list of things with ids, item by item: `item` says what of a changed one may stay (its allowed copy, and what was refused). Added and removed ones only as `add`/`remove` say. */ +function keepList( + key: string, + stored: unknown, + next: unknown, + ctx: Ctx, + rules: { add: (x: Blob) => boolean; remove: (x: Blob) => boolean; item: (s: Blob, n: Blob) => { value: Blob; denied: string[] } }, +): Kept { + const was = byId(stored); + const now = byId(next); + const out: Blob[] = []; + const denied: string[] = []; + for (const x of listOf(next)) { + const id = x.id as string; + const s = was.get(id); + const forced = ctx.force.has(`${key}.${id}`); + if (!s) { + if (!forced && rules.add(x)) out.push(x); + else denied.push(`${key}.${id}`); + } else if (same(s, x)) out.push(x); + else if (forced) { + denied.push(`${key}.${id}`); + out.push(s); + } else { + const r = rules.item(s, x); + denied.push(...r.denied); + out.push(r.value); + } + } + for (const [id, s] of was) + if (!now.has(id)) { + if (!ctx.force.has(`${key}.${id}`) && rules.remove(s)) continue; + denied.push(`${key}.${id}`); + out.push(s); + } + return { value: out, denied }; +} + +/** A changed item: only `fields` may change (plus what `extra` lets through); anything else goes back to how it was, and is refused. */ +function onlyFields(key: string, s: Blob, n: Blob, fields: readonly string[], extra?: (v: Blob) => string[]): { value: Blob; denied: string[] } { + const v: Blob = { ...s }; + for (const f of fields) copyField(v, n, f); + const denied = (extra?.(v) ?? []).map((f) => `${key}.${s.id}.${f}`); + for (const f of differ(v, n)) if (!denied.includes(`${key}.${s.id}.${f}`)) denied.push(`${key}.${s.id}.${f}`); + return { value: v, denied }; +} + +/** A thread of one of its bots, in one of their chats (chatsOn): one it may start or change. */ +const ofItsBots = (x: Blob, ctx: Ctx) => ctx.bots.has(x.botId as string) && (x.chatId === undefined || ctx.chats.has(x.chatId as string)); +/** A thread a routine's run of its own starts (Session.routineId, ctx.runs), of that routine's bot in its chat: the bot may have moved off the computer since that run began. */ +const ofItsRun = (x: Blob, ctx: Ctx) => { + const run = typeof x.routineId === "string" ? ctx.runs.get(x.routineId) : undefined; + return !!run && x.botId === run.botId && (x.chatId === undefined || x.chatId === run.chatId); +}; + +/** The thread a routine's run is (Routine.last.sessionId). */ +const runOf = (r: Blob) => (isObject(r.last) ? str(r.last.sessionId) : undefined); +/** + * A routine whose run, as it's kept and as it's written, is its own: a thread of its own of that routine's + * bot (threadsOf), or the run its runner began (RoutineRun.by), written as that run (its time); its bot + * moved off the computer while it ran. + */ +const itsRun = (s: Blob, n: Blob, ctx: Ctx) => { + const id = runOf(s); + if (id && id === runOf(n) && ctx.threads.get(id)?.botId === s.botId) return true; + return isObject(s.last) && !!ctx.device && s.last.by === ctx.device && isObject(n.last) && n.last.at === s.last.at; +}; + +const RULES: Record Kept> = { + // Its own device's threads, of its bots in their chats, or of a routine's run of its own still going; + // another's stay as they were. One it has stays its own where it is, its bot moved off the computer or not (threadsOf). + sessions: (stored, next, ctx) => + keepList("sessions", stored, next, ctx, { + add: (x) => x.by === ctx.device && (ofItsBots(x, ctx) || ofItsRun(x, ctx)), + remove: (s) => s.by === ctx.device, + item: (s, n) => + s.by === ctx.device && n.by === ctx.device && ((ofItsBots(s, ctx) && ofItsBots(n, ctx)) || (n.botId === s.botId && n.chatId === s.chatId)) ? { value: n, denied: [] } : { value: s, denied: [`sessions.${s.id}`] }, + }), + // A run of its bots' routines: the clock, a skip that passed going, a pause. Of another bot's, only the + // clock and record of the run that's its own (itsRun). + routines: (stored, next, ctx) => + keepList("routines", stored, next, ctx, { + add: () => false, + remove: () => false, + item: (s, n) => { + if (n.botId !== s.botId) return { value: s, denied: [`routines.${s.id}`] }; + if (!ctx.bots.has(s.botId as string)) return itsRun(s, n, ctx) ? onlyFields("routines", s, n, ROUTINE_RUN) : { value: s, denied: [`routines.${s.id}`] }; + return onlyFields("routines", s, n, ROUTINE_RUN, (v) => { + const refused: string[] = []; + const was = Array.isArray(s.skips) ? s.skips : []; + const skips = n.skips === undefined ? [] : Array.isArray(n.skips) ? n.skips : null; + if (skips && skips.every((x) => was.some((y) => same(x, y)))) copyField(v, n, "skips"); + else if (!same(s.skips, n.skips)) refused.push("skips"); + if (s.enabled === n.enabled || (s.enabled === true && n.enabled === false)) copyField(v, n, "enabled"); + else refused.push("enabled"); + return refused; + }); + }, + }), + // Its own requests for the user's OK: added unanswered (for one of its bots, or a thread of its own), and taken + // out. A reply is never its to write, nor anything of another's request, nor a change to one once made. + appApprovals: (stored, next, ctx) => + keepList("appApprovals", stored, next, ctx, { + add: (x) => + x.askedBy === ctx.device && + x.reply === undefined && + (ctx.bots.has(x.botId as string) || (typeof x.threadId === "string" && ctx.threads.get(x.threadId)?.botId === x.botId)), + remove: (s) => s.askedBy === ctx.device, + item: (s) => ({ value: s, denied: [`appApprovals.${s.id}`] }), + }), + // How its bots are doing on it. + bots: (stored, next, ctx) => + keepList("bots", stored, next, ctx, { + add: () => false, + remove: () => false, + item: (s, n) => (ctx.bots.has(s.id as string) ? onlyFields("bots", s, n, BOT_RUN) : { value: s, denied: [`bots.${s.id}`] }), + }), + // When an account was last used. + accounts: (stored, next, ctx) => + keepList("accounts", stored, next, ctx, { add: () => false, remove: () => false, item: (s, n) => onlyFields("accounts", s, n, ["usedAt"]) }), + // A brain's sign-in on this computer only. + brains: (stored, next, ctx) => + keepList("brains", stored, next, ctx, { + add: () => false, + remove: () => false, + item: (s, n) => { + const r = onlyFields("brains", s, n, []); + const here = (x: unknown) => isObject(x) && lower(x.computerId) === lower(ctx.desktopId); + const others = (v: unknown) => (Array.isArray(v) ? v.filter((x) => !here(x)) : []); + if (!same(others(s.signIns), others(n.signIns))) return r; + copyField(r.value, n, "signIns"); + return { value: r.value, denied: r.denied.filter((d) => d !== `brains.${s.id}.signIns`) }; + }, + }), + // News held for the user about its bots: added only. + digest: (stored, next, ctx) => { + const was = Array.isArray(stored) ? stored : []; + const now = Array.isArray(next) ? next : []; + const left = [...was]; + const added: unknown[] = []; + for (const x of now) { + const i = left.findIndex((y) => same(x, y)); + if (i >= 0) left.splice(i, 1); + else added.push(x); + } + const ok = added.filter((x) => isObject(x) && ctx.bots.has(x.botId as string)); + return { value: [...was, ...ok], denied: left.length || ok.length < added.length ? ["digest"] : [] }; + }, + // Which thread last held each of its bots' screens (":"). + screenLog: (stored, next, ctx) => { + const was = isObject(stored) ? stored : {}; + const now = isObject(next) ? next : {}; + const out: Blob = {}; + const denied: string[] = []; + for (const k of new Set([...Object.keys(was), ...Object.keys(now)])) { + const mine = ctx.bots.has(k.slice(0, k.lastIndexOf(":"))); + const v = same(was[k], now[k]) || mine ? now[k] : was[k]; + if (!same(v, now[k])) denied.push(`screenLog.${k}`); + if (v !== undefined) out[k] = v; + } + return { value: stored === undefined && !Object.keys(out).length ? undefined : out, denied }; + }, +}; + +/** Its routines' runs still going (runsOf) that `next` still has as that run: a write that records another run starts nothing of that one. */ +function runsKept(stored: Blob, next: Blob, device: string): Map { + const now = byId(next.routines); + const was = byId(stored.routines); + const at = (r: Blob | undefined) => (isObject(r?.last) ? r.last.at : undefined); + return new Map([...runsOf(stored, device)].filter(([id]) => at(now.get(id)) !== undefined && at(now.get(id)) === at(was.get(id)))); +} + +function keepAll(stored: Blob, next: Blob, scope: Scope, force: Set): { blob: Blob; denied: string[] } { + const ctx: Ctx = { + device: scope.device, + desktopId: scope.desktopId, + bots: botIdsOn(stored, scope.desktopId), + chats: chatsOn(stored, scope.desktopId), + threads: threadsOf(stored, scope.device), + runs: runsKept(stored, next, scope.device), + force, + }; + const blob: Blob = {}; + const denied: string[] = []; + for (const k of new Set([...Object.keys(stored), ...Object.keys(next)])) { + let value: unknown; + if (same(stored[k], next[k])) value = next[k]; + else if (force.has(k)) { + value = stored[k]; + denied.push(k); + } else if (FREE.has(k)) value = next[k]; + else if (RULES[k]) { + const r = RULES[k](stored[k], next[k], ctx); + value = r.value; + denied.push(...r.denied); + } else { + value = stored[k]; + denied.push(k); + } + if (value !== undefined) blob[k] = value; + } + return { blob, denied }; +} + +/** + * What a computer's write would change that it may not, as paths ("bots..keys", "routines.", + * "workspaces"): none when it may write it. `stored` is the blob it was written over (the same version). + * A user with no state yet can't have one started by a computer: everything is refused. + */ +export function writeDenials(stored: Blob, next: Blob, scope: Scope): string[] { + if (!Object.keys(stored).length) return Object.keys(next).length ? ["state"] : []; + return keepAll(stored, next, scope, new Set()).denied; +} + +/** + * `mine` with what the computer may not write put back as it was in `base`: what a runner sends (so it's + * never refused), and what the Mac takes of a computer's write. `held` says what was put back. `force`: + * paths Bops Cloud refused anyway ("routines.", "workspaces"), put back too. + */ +export function keepAllowed(base: Blob, mine: Blob, scope: Scope, force: Iterable = []): { blob: Blob; held: string[] } { + const r = keepAll(base, mine, scope, new Set([...force].map((p) => p.split(".").slice(0, 2).join(".")))); + return { blob: r.blob, held: r.denied }; +} diff --git a/cloud/device.ts b/cloud/device.ts new file mode 100644 index 00000000..4c4ace59 --- /dev/null +++ b/cloud/device.ts @@ -0,0 +1,10 @@ +import { createHash } from "node:crypto"; + +/** + * A device of a user's, as their state knows it (state.macs, Session.by, the writer Bops Cloud keeps): + * the first 16 hex characters of SHA-256("\n"). A Mac's platform is its hardware id; + * a computer's runner's is its computer id (BOPS_DEVICE_SEED), so Bops Cloud makes the same one for a + * computer that signs in as itself (auth.ts) and never takes another from it. The app's own is + * lib/server/persist-cloud.ts deviceIdFor, which calls this. + */ +export const deviceIdOf = (userId: string, platform: string) => createHash("sha256").update(`${userId}\n${platform}`).digest("hex").slice(0, 16); diff --git a/cloud/http.ts b/cloud/http.ts index b5f9ab30..bdd88301 100644 --- a/cloud/http.ts +++ b/cloud/http.ts @@ -13,12 +13,20 @@ export type Route = { /** Exact path ("/v1/session") or a prefix ending in "/*" ("/proxy/openai/*"). */ path: string; auth: "user" | "public"; + /** + * A computer signed in as itself (CloudUser.computer: its runner, on its identity token or its own + * key) may call it: only what the runner needs (its state, its threads, its session, the models and + * tools its own tasks use). Everything else answers it 403 (server.ts). + */ + computer?: true; handle: (req: IncomingMessage, res: ServerResponse, ctx: { user: CloudUser | null; url: URL }) => Promise; }; /** A WebSocket upgrade handler for a path prefix ("/v1/connect", "/proxy/openai/*"). Always for a signed-in user. */ export type Upgrade = { path: string; + /** A computer signed in as itself may open it (Route.computer). */ + computer?: true; handle: (req: IncomingMessage, socket: Duplex, head: Buffer, ctx: { user: CloudUser; url: URL }) => void; }; diff --git a/cloud/live.ts b/cloud/live.ts new file mode 100644 index 00000000..db2a04bc --- /dev/null +++ b/cloud/live.ts @@ -0,0 +1,685 @@ +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { CloudUser } from "./auth.ts"; +import { HttpError, readJson, sendJson, type Route } from "./http.ts"; +import { watchProject } from "./members.ts"; +import { + BOPS_DEVICE_HEADER, + LIVE_MOOD_TTL, + LIVE_MOODS, + LIVE_ROUTINE_CHANGES, + type LiveEventIn, + type LiveEventOut, + type LiveMood, + type LiveSnapshot, + type WsAccess, +} from "./protocol.ts"; +import { chatIdsOf, chatWsOf, fullHash, list, OWNER_VIEW, sharedHash, sharedSlice, wsOfBot, type SliceViewer } from "./ws-slice.ts"; + +/** + * Live: what each bot of a workspace is doing right now (thinking, speaking, working…), and a nudge + * when the workspace changed, streamed to every signed-in device that watches it (protocol.ts, "Live"). + * A mood is gone a second after it happens, so none of this touches the database: it's one hub in this + * process's memory (Bops Cloud is one process), keyed by account and workspace. + * + * - Watching: GET /v1/live?ws=, Server-Sent Events through Caddy (flush_interval -1), + * with a comment every 15 seconds so nothing on the way thinks it's idle. A device has one stream + * per account at a time in practice; at most STREAMS_PER_ACCOUNT are open for one caller and + * STREAMS_TOTAL in all, past which a new one is refused (429) and the device keeps polling. + * A workspace member watches with GET /v1/live?project= instead (members.ts): the + * same stream as the owner's devices, of the owner's account and the project's workspace, while + * orgo-web says they're a member; it ends with "revoked" once they aren't. Anyone but the owner hears + * only what's of the bots the owner gave them (LiveScope), worked out from the owner's state. + * - Catching up: every event worth replaying (a thread, a routine, a call, the state) gets an id, + * "-", and the last 2 minutes of them are kept for each workspace watched now or within + * those 2 minutes (one nobody watched has nobody to catch up). A device that comes back + * with a Last-Event-ID in them is sent what it missed; any other (too old, or from before the cloud + * restarted, whose ids start over under a new boot) gets "reset" and reads the state afresh. Moods + * aren't replayed: every connection starts with a snapshot of them instead. + * - Moods: each lasts its ttl (about 15 seconds) unless it's said again, so a Mac that crashed mid-reply + * stops showing its bot busy; one that runs out leaves the snapshot and an idle goes out. + * - Publishing: POST /v1/live { ws, events }, small and checked strictly, only into the caller's own + * account, and at most PUBLISH_BUDGET events every 10 seconds for one account. The cloud itself publishes "state" after every write of the user's state or messages + * (state.ts), only to the workspaces whose part of it changed, so the usage ledger's churn doesn't + * wake anyone. + * + * Who may watch or publish what goes through canWatch and canPublish alone: a device of the + * account's own user, or (watching only) a member of the workspace's Orgo project. Members never + * publish: a publish only ever goes into the caller's own account. + */ + +/** How long events are kept for a device that comes back. */ +export const REPLAY_MS = 2 * 60_000; +export const HEARTBEAT_MS = 15_000; +export const STREAMS_PER_ACCOUNT = 20; +export const STREAMS_TOTAL = 5_000; +/** The most workspaces one account has in the hub at once (each with its buffer and moods). */ +const WORKSPACES_PER_ACCOUNT = 50; +/** The most events kept for one workspace, however recent, and the most text all the buffers keep together. */ +const RING_MAX = 500; +const RING_BYTES_TOTAL = 64 * 1024 * 1024; +/** The most events one account may publish in PUBLISH_WINDOW_MS, past which a publish is refused (429) and tried again later. */ +export const PUBLISH_BUDGET = 120; +const PUBLISH_WINDOW_MS = 10_000; +/** The most bots with a mood in one workspace. */ +const MOODS_MAX = 200; +/** The most a publish's body may be, and how many events it may carry. */ +export const MAX_PUBLISH_BYTES = 4096; +const MAX_EVENTS = 16; +/** A stream whose client reads slower than this much is waiting to go to it is dropped (it reconnects and catches up). */ +const MAX_BUFFERED = 256 * 1024; +const MAX_TTL = 60; +const ID = /^[A-Za-z0-9_:.@-]{1,120}$/; +const WS_ID = /^[A-Za-z0-9_-]{1,64}$/; +/** One workspace of one account: whose stream it is. A member watching names the owner's account. */ +export type LiveTarget = { account: string; ws: string }; + +/** + * Whether `caller` may watch `target`'s stream: the account's own user (any of their devices), or someone + * orgo-web just said is the owner, an admin or a member of the Orgo project that is this workspace + * (`access`, members.ts accessFor). + */ +export function canWatch(caller: CloudUser, target: LiveTarget, access?: WsAccess | null): boolean { + if (caller.id === target.account) return true; + return !!access && access.ownerUserId === target.account && access.bopsWsId === target.ws && ["owner", "admin", "member"].includes(access.role); +} + +/** + * Whether `caller` may publish `event` into `target`: only into the user's own account. Live is for the + * user's devices: a computer's runner never opens or publishes to it (server.ts computerCheck refuses a + * computer's token on /v1/live before this is asked, and lib/server/persist.ts keeps "live" among the + * Mac's loops), and its routines' work reaches the devices through the "state" event its state writes + * send (stateWritten). Letting a runner publish would take a computer route here and a check of + * event.botId against its bots. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- each event is checked on its own, though none is looked into yet +export function canPublish(caller: CloudUser, target: LiveTarget, event: LiveEventIn): boolean { + return caller.id === target.account; +} + +/* ---------------- The hub ---------------- */ + +/** + * What a stream of someone other than the owner carries (members.ts watchProject): only what's of the + * bots they see (ws-slice.ts sharedSlice for `viewer`), worked out from the owner's state when it opens + * and again whenever that's written (stateWritten). Anything it can't tie to one of those bots isn't sent. + */ +export type LiveScope = { + viewer: SliceViewer; + /** The owner's whole part of the state it was worked out from (ws-slice.ts fullHash). */ + from: string; + bots: ReadonlySet; + /** The chats they see, and every two of their bots' conversation with each other (ws-slice.ts chatIdsOf): a mood names where it's typing only when it's one of these, and a message written in one wakes them. */ + chats: ReadonlySet; + /** The routines of the bots they see (a routine's event names only the routine). */ + routines: ReadonlySet; + /** The fingerprint of what they're shown (ws-slice.ts sharedHash): what their "state" events carry. */ + hash: string; +}; + +/** What `viewer` may hear of workspace `ws`, from the owner's state (`from`: its part's fullHash, when that's known already). */ +export function liveScope(blob: Record, ws: string, viewer: SliceViewer, from = fullHash(blob, ws)): LiveScope { + const s = sharedSlice(blob, ws, viewer); + const ids = (items: Record[]) => new Set(items.flatMap((x) => (typeof x.id === "string" ? [x.id] : []))); + return { viewer, from, bots: ids(s.bots), chats: new Set(chatIdsOf(s)), routines: ids(s.routines), hash: sharedHash(s) }; +} + +/** + * `event` as a stream held to `scope` hears it, or null when it isn't one of theirs: a mood, thread or + * call of a bot they don't see, a routine that isn't one of their bots' (or not in the owner's state + * yet: the "state" that follows tells them), or a kind of event this doesn't know. Everything of a bot + * they see is theirs to hear, every thread of it included. A "state" carries the fingerprint of their + * own view, so a copy that's still theirs isn't read again for nothing. One that says messages were + * written (`chats`, where: messagesWritten) goes only when one of those chats is theirs, or isn't known + * ("": a removal), so a hidden bot's chats never wake them. + */ +export function scoped(scope: LiveScope, event: LiveEventOut, chats?: readonly string[]): LiveEventOut | null { + switch (event.type) { + case "mood": + if (!scope.bots.has(event.botId)) return null; + return event.chatId === undefined || scope.chats.has(event.chatId) ? event : { ...event, chatId: undefined }; + case "session": + case "call": + return scope.bots.has(event.botId) ? event : null; + case "routine": + return scope.routines.has(event.id) ? event : null; + case "state": + if (event.hash !== undefined) return { ...event, hash: scope.hash }; + return !chats || chats.some((c) => c === "" || scope.chats.has(c)) ? event : null; + default: + return null; + } +} + +/** + * One open stream: where its events go, and how to end it. `caller`: whose stream it counts as (a member's: theirs, not the owner's); unset, the account's. + * `scope`: set for anyone but the owner, and then only what it allows goes to it (unset: everything, the owner's own devices). + */ +export type LiveSub = { write: (chunk: string) => void; end: () => void; caller?: string; scope?: LiveScope }; +type Mood = { mood: LiveMood; chatId?: string; by: string; expiresAt: number; ttl: number }; +type Entry = { + account: string; + ws: string; + subs: Set; + /** Events worth replaying, oldest first, how much of their SSE text each is, and the chats a message write's was about (never sent). */ + ring: { seq: number; at: number; event: LiveEventOut; bytes: number; chats?: readonly string[] }[]; + /** The newest seq gone from the ring (or from before this entry): a device that has seen less missed something. */ + droppedThrough: number; + moods: Map; + touched: number; + /** When a stream of it was last open (-Infinity: never). */ + watchedAt: number; +}; +export type OpenResult = "ok" | "account_full" | "full"; + +/** An event as a stream carries it. */ +export const sse = (type: string, data: unknown, id?: string) => `${id ? `id: ${id}\n` : ""}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + +export class LiveHub { + /** Different every start, so an id from before a restart never passes for one of now. */ + readonly boot: string; + /** Per account: each watched workspace's slice as last written, and which workspace each chat is in (stateWritten). Gone with the account's last workspace here. */ + readonly seen = new Map; chats: Map }>(); + private seq = 0; + /** How many times any account's state has been written (stateWritten): a member's stream worked out over a read one landed during is worked out again (members.ts). */ + written = 0; + /** Each account's workspaces in the hub, by workspace id. */ + private accounts = new Map>(); + private streams = new Map(); + private total = 0; + /** What all the buffers hold, in characters of their text. */ + private ringBytes = 0; + /** Per account: since when it's been publishing, and how many events. */ + private budgets = new Map(); + private now: () => number; + private timer?: ReturnType; + private limits: { perAccount: number; total: number; ringBytes: number }; + + constructor(opts: { now?: () => number; boot?: string; perAccount?: number; total?: number; ringBytes?: number } = {}) { + this.now = opts.now ?? Date.now; + this.boot = opts.boot ?? `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`; + this.limits = { perAccount: opts.perAccount ?? STREAMS_PER_ACCOUNT, total: opts.total ?? STREAMS_TOTAL, ringBytes: opts.ringBytes ?? RING_BYTES_TOTAL }; + } + + private entry(t: LiveTarget, make: boolean): Entry | undefined { + let mine = this.accounts.get(t.account); + let e = mine?.get(t.ws); + if (!e && make) { + if ((mine?.size ?? 0) >= WORKSPACES_PER_ACCOUNT) return undefined; + if (!mine) this.accounts.set(t.account, (mine = new Map())); + e = { account: t.account, ws: t.ws, subs: new Set(), ring: [], droppedThrough: this.seq, moods: new Map(), touched: this.now(), watchedAt: -Infinity }; + mine.set(t.ws, e); + this.startTimer(); + } + return e; + } + + /** Whether someone watches `e`, or did within REPLAY_MS: only then is an event kept for a device that comes back. */ + private listening = (e: Entry, now: number) => e.subs.size > 0 || now - e.watchedAt <= REPLAY_MS; + + /** Whether one more stream for `target` would be refused, and why. Streams are counted by `caller`, so members can't use up the owner's. */ + wouldRefuse(target: LiveTarget, caller = target.account): OpenResult { + if ((this.streams.get(caller) ?? 0) >= this.limits.perAccount) return "account_full"; + if (this.total >= this.limits.total) return "full"; + const mine = this.accounts.get(target.account); + if (!mine?.has(target.ws) && (mine?.size ?? 0) >= WORKSPACES_PER_ACCOUNT) return "account_full"; + return "ok"; + } + + /** + * Start a stream for `target`: the moods as they are now, then what it missed since `lastEventId` when + * that can be told, else "reset" (with the id it goes on from). + */ + open(target: LiveTarget, sub: LiveSub, lastEventId: string | null): OpenResult { + const caller = sub.caller ?? target.account; + const refused = this.wouldRefuse(target, caller); + if (refused !== "ok") return refused; + const e = this.entry(target, true); + if (!e) return "account_full"; + const now = this.now(); + this.expire(e, now); + e.subs.add(sub); + e.touched = e.watchedAt = now; + this.streams.set(caller, (this.streams.get(caller) ?? 0) + 1); + this.total++; + sub.write(sse("snapshot", this.snapshot(e, sub.scope))); + const from = this.seqOf(lastEventId); + if (from !== null && from >= e.droppedThrough) + for (const x of e.ring) { + const seen = x.seq > from && (sub.scope ? scoped(sub.scope, x.event, x.chats) : x.event); + if (seen) sub.write(sse(seen.type, seen, `${this.boot}-${x.seq}`)); + } + if (from === null || from < e.droppedThrough) sub.write(sse("reset", {}, `${this.boot}-${this.seq}`)); + return "ok"; + } + + /** The seq an id from this start stands for, or null (none, malformed, from before a restart, or ahead of now). */ + private seqOf(id: string | null): number | null { + const m = /^([a-z0-9]+)-(\d{1,15})$/.exec(id?.trim() ?? ""); + if (!m || m[1] !== this.boot) return null; + const n = Number(m[2]); + return n <= this.seq ? n : null; + } + + close(target: LiveTarget, sub: LiveSub) { + const e = this.entry(target, false); + if (!e || !e.subs.delete(sub)) return; + e.touched = e.watchedAt = this.now(); + const caller = sub.caller ?? target.account; + const n = (this.streams.get(caller) ?? 1) - 1; + if (n > 0) this.streams.set(caller, n); + else this.streams.delete(caller); + this.total--; + } + + /** The moods as they are now, held to `scope` when there is one: only its bots', and where they type only when it's a chat of theirs. */ + private snapshot(e: Entry, scope?: LiveScope): LiveSnapshot { + const now = this.now(); + return { + moods: [...e.moods] + .filter(([botId]) => !scope || scope.bots.has(botId)) + .map(([botId, m]) => ({ botId, ...(m.chatId && (!scope || scope.chats.has(m.chatId)) ? { chatId: m.chatId } : {}), mood: m.mood, ttl: Math.max(1, Math.round((m.expiresAt - now) / 1000)), by: m.by })), + }; + } + + /** `event` to each of `e`'s streams (its SSE text `chunk`, with `id`), each held to its own scope (`chats`: as scoped takes them). */ + private send(e: Entry, event: LiveEventOut, chunk: string, id?: string, chats?: readonly string[]) { + for (const s of e.subs) { + const seen = s.scope ? scoped(s.scope, event, chats) : event; + if (!seen) continue; + try { + s.write(seen === event ? chunk : sse(seen.type, seen, id)); + } catch { + /* the stream's own close takes it out */ + } + } + } + + /** + * The owner's state was written: each stream of `target` held to a scope worked out from an older one + * (`full`, its part now) is worked out again from `blob`, once for each viewer. + */ + rescope(target: LiveTarget, blob: Record, full: string) { + const e = this.entry(target, false); + if (!e) return; + const done = new Map(); + for (const s of e.subs) { + if (!s.scope || s.scope.from === full) continue; + const v = s.scope.viewer; + const key = `${v.role}\0${v.email ?? ""}\0${v.id}`; + let next = done.get(key); + if (!next) done.set(key, (next = liveScope(blob, target.ws, v, full))); + s.scope = next; + } + } + + /** + * Whether `account` may publish `n` more events now: PUBLISH_BUDGET in each PUBLISH_WINDOW_MS (its Macs + * say each busy bot's mood every 5 seconds, and their threads as they move). Counted when it may. + */ + allow(account: string, n: number): boolean { + const now = this.now(); + let b = this.budgets.get(account); + if (!b || now - b.from >= PUBLISH_WINDOW_MS) this.budgets.set(account, (b = { from: now, n: 0 })); + if (b.n + n > PUBLISH_BUDGET) return false; + b.n += n; + return true; + } + + /** + * Publish an event to `target`'s streams. A mood is kept for the next stream's snapshot; any other is + * kept for replay only while someone watches the workspace or did lately, and goes nowhere otherwise. + * `chats`: the chats a "state" for messages written is about, kept here to hold it to each stream's + * scope and never sent. False when the account has too many workspaces in the hub already. + */ + publish(target: LiveTarget, event: LiveEventOut, chats?: readonly string[]): boolean { + const now = this.now(); + if (event.type !== "mood") { + const e = this.entry(target, false); + if (!e || !this.listening(e, now)) return true; + e.touched = now; + const seq = ++this.seq; + const id = `${this.boot}-${seq}`; + const chunk = sse(event.type, event, id); + e.ring.push({ seq, at: now, event, bytes: chunk.length, ...(chats ? { chats } : {}) }); + this.ringBytes += chunk.length; + let drop = Math.max(0, e.ring.length - RING_MAX); + // Past what the hub holds in all, the buffer being added to gives up its oldest (a device coming back to it is reset). + for (let over = this.ringBytes - this.limits.ringBytes; over > 0 && drop < e.ring.length - 1; drop++) over -= e.ring[drop].bytes; + this.cut(e, drop); + this.send(e, event, chunk, id, chats); + return true; + } + const e = this.entry(target, true); + if (!e) return false; + e.touched = now; + if (event.mood === "idle") { + e.moods.delete(event.botId); + const idle = { ...event, ttl: undefined }; + this.send(e, idle, sse("mood", idle)); + return true; + } + if (!e.moods.has(event.botId) && e.moods.size >= MOODS_MAX) return true; + const ttl = Math.min(MAX_TTL, Math.max(1, Math.round(event.ttl ?? LIVE_MOOD_TTL))); + e.moods.set(event.botId, { mood: event.mood, ...(event.chatId ? { chatId: event.chatId } : {}), by: event.by, ttl, expiresAt: now + ttl * 1000 }); + // A mood isn't replayed (the snapshot has it), so it carries no id. + const said = { ...event, ttl }; + this.send(e, said, sse("mood", said)); + return true; + } + + /** The oldest `n` events leave `e`'s buffer. */ + private cut(e: Entry, n: number) { + if (n <= 0) return; + const gone = e.ring.splice(0, n); + for (const x of gone) this.ringBytes -= x.bytes; + e.droppedThrough = gone.at(-1)!.seq; + } + + /** `e`'s moods past their ttl go, an idle going out for each. */ + private expire(e: Entry, now: number) { + for (const [botId, m] of e.moods) + if (m.expiresAt <= now) { + e.moods.delete(botId); + const idle: LiveEventOut = { type: "mood", botId, ...(m.chatId ? { chatId: m.chatId } : {}), mood: "idle", by: m.by, at: now }; + this.send(e, idle, sse("mood", idle)); + } + } + + /** + * Moods past their ttl go (an idle goes out for each), events older than REPLAY_MS leave the buffer, + * workspaces nobody's used go, and with an account's last one so does what was seen of its state. + */ + sweep() { + const now = this.now(); + for (const [account, mine] of this.accounts) { + for (const [ws, e] of mine) { + this.expire(e, now); + let cut = 0; + while (cut < e.ring.length && now - e.ring[cut].at > REPLAY_MS) cut++; + this.cut(e, cut); + if (!e.subs.size && !e.moods.size && !e.ring.length && now - e.touched > REPLAY_MS) mine.delete(ws); + } + if (!mine.size) { + this.accounts.delete(account); + this.seen.delete(account); + } + } + for (const [account, b] of this.budgets) if (now - b.from >= PUBLISH_WINDOW_MS) this.budgets.delete(account); + if (!this.accounts.size) this.stopTimer(); + } + + /** The workspaces of `account` someone watches now, or did within REPLAY_MS (a device that comes back is sent what changed meanwhile), a member included. */ + watched(account: string): string[] { + const now = this.now(); + return [...(this.accounts.get(account)?.values() ?? [])].filter((e) => this.listening(e, now)).map((e) => e.ws); + } + + /** The streams open as `caller`'s (their own account's, and the workspaces they watch as a member). */ + streamsOf = (caller: string) => this.streams.get(caller) ?? 0; + openCount = () => this.total; + + private startTimer() { + if (this.timer || this.now !== Date.now) return; + this.timer = setInterval(() => this.sweep(), 1000); + this.timer.unref?.(); + } + private stopTimer() { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + /** End every stream (the cloud stopping): each device reconnects to the next start and is reset. */ + closeAll() { + for (const mine of this.accounts.values()) + for (const e of mine.values()) + for (const s of [...e.subs]) { + try { + s.end(); + } catch {} + this.close(e, s); + } + this.stopTimer(); + } +} + +export const hub = new LiveHub(); +export const closeAll = () => hub.closeAll(); + +/* ---------------- Checking what's published ---------------- */ + +const isObject = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); +const idOk = (v: unknown): v is string => typeof v === "string" && ID.test(v); +const only = (o: Record, keys: string[]) => Object.keys(o).every((k) => keys.includes(k)); + +/** One published event, checked: the exact shape of its type, or a 400. */ +export function eventOf(v: unknown): LiveEventIn { + const bad = (why: string): never => { + throw new HttpError(400, `Bad live event: ${why}`); + }; + if (!isObject(v)) return bad("not an object"); + switch (v.type) { + case "mood": + if (!only(v, ["type", "botId", "chatId", "mood", "ttl"])) return bad("unknown field"); + if (!idOk(v.botId)) return bad("botId"); + if (v.chatId !== undefined && !idOk(v.chatId)) return bad("chatId"); + if (!LIVE_MOODS.includes(v.mood as LiveMood)) return bad("mood"); + if (v.ttl !== undefined && !(typeof v.ttl === "number" && Number.isFinite(v.ttl) && v.ttl > 0 && v.ttl <= MAX_TTL)) return bad("ttl"); + return { type: "mood", botId: v.botId, mood: v.mood as LiveMood, ...(v.chatId !== undefined ? { chatId: v.chatId as string } : {}), ...(v.ttl !== undefined ? { ttl: v.ttl as number } : {}) }; + case "session": + if (!only(v, ["type", "id", "botId", "status", "activity"])) return bad("unknown field"); + if (!idOk(v.id) || !idOk(v.botId)) return bad("id or botId"); + if (typeof v.status !== "string" || !/^[a-z]{1,20}$/.test(v.status)) return bad("status"); + if (v.activity !== undefined && !(typeof v.activity === "string" && v.activity.length <= 120)) return bad("activity"); + return { type: "session", id: v.id, botId: v.botId, status: v.status, ...(v.activity !== undefined ? { activity: v.activity as string } : {}) }; + case "routine": + if (!only(v, ["type", "id", "change"])) return bad("unknown field"); + if (!idOk(v.id)) return bad("id"); + if (!LIVE_ROUTINE_CHANGES.includes(v.change as never)) return bad("change"); + return { type: "routine", id: v.id, change: v.change as (typeof LIVE_ROUTINE_CHANGES)[number] }; + case "call": + if (!only(v, ["type", "botId", "on"])) return bad("unknown field"); + if (!idOk(v.botId) || typeof v.on !== "boolean") return bad("botId or on"); + return { type: "call", botId: v.botId, on: v.on }; + default: + return bad("type"); + } +} + +/** A publish's body, checked: a workspace id and 1 to MAX_EVENTS events. */ +export function publishOf(body: unknown): { ws: string; events: LiveEventIn[] } { + if (!isObject(body) || !only(body, ["ws", "events"])) throw new HttpError(400, "Send { ws, events }"); + if (typeof body.ws !== "string" || !WS_ID.test(body.ws)) throw new HttpError(400, "ws must be a workspace id"); + if (!Array.isArray(body.events) || !body.events.length || body.events.length > MAX_EVENTS) throw new HttpError(400, `events must be 1 to ${MAX_EVENTS} events`); + return { ws: body.ws, events: body.events.map(eventOf) }; +} + +/** Which device a call came from (BOPS_DEVICE_HEADER), as an event names its publisher. */ +function byOf(req: IncomingMessage): string { + const v = req.headers[BOPS_DEVICE_HEADER]; + const s = (Array.isArray(v) ? v[0] : v)?.trim() ?? ""; + return /^[A-Za-z0-9_-]{1,64}$/.test(s) ? s : "app"; +} + +/** The stream a call asks for: `ws`, in the caller's own account (a member's names a project instead: members.ts watchProject). */ +function targetOf(user: CloudUser, url: URL): LiveTarget { + const ws = url.searchParams.get("ws") ?? ""; + if (!WS_ID.test(ws)) throw new HttpError(400, "ws must be a workspace id"); + return { account: user.id, ws }; +} + +/* ---------------- What changed in the state (state.ts) ---------------- */ + +/** + * One workspace's fingerprint as "state" events carry it: of what the owner is shown of it through + * GET /v1/ws/:projectId/state (ws-slice.ts). A stream held to a scope carries its viewer's own instead (scoped). + */ +export const sliceHash = (blob: Record, ws: string) => sharedHash(sharedSlice(blob, ws, OWNER_VIEW)); + +/** Which workspace each chat of the blob is in, a pair of bots' own conversation ("bot:") its bot's. */ +function chatsOf(blob: Record): Map { + const chats = new Map(); + const byId = new Map(list(blob.bots).map((b) => [b.id, b])); + const chatWs = chatWsOf(byId); + for (const c of list(blob.chats)) if (typeof c.id === "string") chats.set(c.id, chatWs(c)); + for (const [id, b] of byId) chats.set(`bot:${String(id)}`, wsOfBot(b)); + return chats; +} + +/** + * The user's state was written (`by` the device that wrote it): every watched workspace whose part of it + * changed hears "state", with the fingerprint of what it shows (sliceHash, and each member's stream its + * member's own; one whose copy has it already needn't read it again). Whether it changed is told from the + * whole part, so the owner's other Macs hear of a change members aren't shown too. First, each member's + * stream is held to what the state now gives them (LiveHub.rescope). Nothing is worked out while nobody watches. + */ +export function stateWritten(account: string, blob: Record, by: string | null, h: LiveHub = hub) { + h.written++; + const watched = h.watched(account); + if (!watched.length) return void h.seen.delete(account); + const prev = h.seen.get(account); + const hashes = new Map(); + for (const ws of watched) { + const full = fullHash(blob, ws); + hashes.set(ws, full); + h.rescope({ account, ws }, blob, full); + if (prev?.hashes.get(ws) !== full) h.publish({ account, ws }, { type: "state", hash: sliceHash(blob, ws), by: by ?? "cloud", at: Date.now() }); + } + h.seen.set(account, { hashes, chats: chatsOf(blob) }); +} + +/** + * A member read the owner's state (members.ts): with nothing seen of it since the cloud started, which + * workspace each chat is in is known from here on, so a message in one goes to its workspace alone + * (messagesWritten) and not to every watched one. Nothing is published. + */ +export function seedSeen(account: string, blob: Record, h: LiveHub = hub) { + if (h.seen.has(account) || !h.watched(account).length) return; + h.seen.set(account, { hashes: new Map(h.watched(account).map((ws) => [ws, fullHash(blob, ws)])), chats: chatsOf(blob) }); +} + +/** + * Messages were written into these chats ("" for one not known, a removal): their workspaces hear + * "state" with the newest seq. A chat not known yet (none of the blob seen since the cloud started, or + * two bots' own conversation) is told to every watched workspace of the account. A member's stream hears + * it only when one of the chats is theirs (scoped). + */ +export function messagesWritten(account: string, chatIds: string[], seq: number | null, by: string | null, h: LiveHub = hub) { + const watched = h.watched(account); + if (!watched.length) return; + const chats = h.seen.get(account)?.chats; + const to = new Set(); + for (const c of chatIds) { + const ws = chats?.get(c); + if (ws) to.add(ws); + else for (const w of watched) to.add(w); + } + for (const ws of to) if (watched.includes(ws)) h.publish({ account, ws }, { type: "state", ...(seq !== null ? { seq } : {}), by: by ?? "cloud", at: Date.now() }, chatIds); +} + +/** For tests: forget what was seen of each account's state. */ +export const forgetSeen = () => hub.seen.clear(); + +/* ---------------- Routes ---------------- */ + +/** + * What a member's stream has besides an owner's (members.ts): whose it counts as, a look every `every` ms + * at whether they may still watch (false: "revoked" goes out and it ends; null: Orgo couldn't say, so it + * carries on; "end": it ends without "revoked", and coming back asks again), and where its end is kept so orgo-web's notice can end it (`track`, which gives back its undoing). + * `scope`: what it may carry, for anyone but the owner (LiveSub.scope). + */ +export type StreamOpts = { caller?: string; every?: number; still?: () => Promise; track?: (end: () => void) => () => void; scope?: LiveScope }; + +/** A stream for `target` on `res`, until either end closes it, and its place in the hub (undefined: gone before it opened). Exported for tests (a fake response). */ +export function serveStream(req: IncomingMessage, res: ServerResponse, target: LiveTarget, h: LiveHub = hub, opts: StreamOpts = {}): LiveSub | undefined { + // Gone while the key was being checked: its close has been and gone, so nothing would ever free its place. + if (res.destroyed || res.socket?.destroyed) return; + const refused = h.wouldRefuse(target, opts.caller); + if (refused !== "ok") throw new HttpError(429, refused === "account_full" ? "Too many live streams open for this account" : "Too many live streams open", { code: "live_full" }); + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + // Nothing on the way holds the events back (Caddy flushes at once: cloud/deploy/Caddyfile.template). + "x-accel-buffering": "no", + }); + res.flushHeaders?.(); + let done = false; + const sub: LiveSub = { + write: (chunk) => { + if (done) return; + // A client this far behind is dropped, its connection cut (an end would wait behind what it isn't + // reading, holding the socket and all of that): it reconnects and catches up from its last id. + if (res.writableLength > MAX_BUFFERED) return end(true); + res.write(chunk); + }, + end: () => end(), + ...(opts.caller ? { caller: opts.caller } : {}), + ...(opts.scope ? { scope: opts.scope } : {}), + }; + const beat = setInterval(() => sub.write(": hb\n\n"), HEARTBEAT_MS); + beat.unref?.(); + let looking = false; + const look = opts.still + ? setInterval(() => { + if (looking || done) return; + looking = true; + void opts.still!() + .then((ok) => { + if (done || ok === true || ok === null) return; + if (ok === false) sub.write(sse("revoked", {})); + end(); + }) + .catch(() => {}) + .finally(() => (looking = false)); + }, opts.every ?? 60_000) + : undefined; + look?.unref?.(); + const untrack = opts.track?.(() => end()); + function end(cut = false) { + if (done) return; + done = true; + clearInterval(beat); + if (look) clearInterval(look); + untrack?.(); + h.close(target, sub); + if (cut) res.destroy(); + else if (!res.writableEnded) res.end(); + } + req.on("close", end); + res.on("close", end); + res.on("error", end); + const lastId = req.headers["last-event-id"]; + h.open(target, sub, (Array.isArray(lastId) ? lastId[0] : lastId) ?? null); + return sub; +} + +const watch: Route = { + method: "GET", + path: "/v1/live", + auth: "user", + handle: async (req, res, { user, url }) => { + if (url.searchParams.has("project")) return watchProject(req, res, user!, url); + const target = targetOf(user!, url); + if (!canWatch(user!, target)) throw new HttpError(403, "You can't watch this workspace"); + serveStream(req, res, target); + }, +}; + +const publish: Route = { + method: "POST", + path: "/v1/live", + auth: "user", + handle: async (req, res, { user }) => { + const { ws, events } = publishOf(await readJson(req, MAX_PUBLISH_BYTES)); + const target: LiveTarget = { account: user!.id, ws }; + const by = byOf(req); + for (const ev of events) if (!canPublish(user!, target, ev)) throw new HttpError(403, "You can't publish this here"); + if (!hub.allow(user!.id, events.length)) throw new HttpError(429, "Too many live events from this account; try again in a few seconds", { code: "live_busy" }); + const at = Date.now(); + for (const ev of events) if (!hub.publish(target, { ...ev, by, at })) throw new HttpError(429, "Too many workspaces live for this account", { code: "live_full" }); + sendJson(res, 200, { ok: true }); + }, +}; + +export const routes: Route[] = [watch, publish]; diff --git a/cloud/member-access.ts b/cloud/member-access.ts new file mode 100644 index 00000000..603c49ed --- /dev/null +++ b/cloud/member-access.ts @@ -0,0 +1,138 @@ +/* + * What each person the owner added to a workspace may do with its bots, kept on the workspace + * (Workspace.memberAccess) in the synced state, so Bops Cloud has it when it builds a member's view + * (cloud/ws-slice.ts sharedSlice) and their live stream. The owner always has every bot. + * + * Per bot: none (they don't see it at all), read (everything of it: its chats with the owner's own + * messages there, a message kept "between us" too, its threads with their notes and results, routines, + * watches, moods and screen), or write (chat with it and run it too, once people can act in a shared + * workspace; until then it reads as read). The same levels as orgo-web's API key permissions + * (lib/api-keys/permission-groups.ts KeyLevel): write takes in read. + * + * A person's `bots` says their level for each bot named; `others` covers every bot not named, the ones + * made later included. So a bot made after they joined stays hidden unless `others` lets them see it. + * Someone with no entry at all sees nothing. + * + * Kept by the person's email, lowercased: an invite has only the email, and they sign in with it. A + * guest (joined with a link, no email) is kept as "user:". Imported by Bops Cloud + * and, through lib/member-access.ts, the window and the Mac server, so it imports nothing. + * + * Each entry says when the owner's Mac set it (`at`), and whether it was set with an invite not yet taken + * (`invite`): it's for the membership that began then (currentFor). Someone removed or gone without the + * Mac seeing it (on orgo.ai, from a Mac on v0.0.29, leaving on their own) who comes back later, by any + * way in, never finds the bots they had before: their membership now began after it was set. + */ + +export type AccessLevel = "none" | "read" | "write"; +export const ACCESS_LEVELS: readonly AccessLevel[] = ["none", "read", "write"]; +export const ACCESS_LABELS: Record = { none: "None", read: "Read", write: "Write" }; + +/** One person's access to a workspace's bots, and when it was set (`at`, `invite`: currentFor). */ +export type MemberAccess = { others: AccessLevel; bots: Record; at?: number; invite?: true }; +/** Everyone's, by accessKey. */ +export type MemberAccessMap = Record; + +export const NO_ACCESS: MemberAccess = { others: "none", bots: {} }; +/** The most bots one person's entry names (a workspace has far fewer). */ +export const MAX_ACCESS_BOTS = 200; + +const RANK: Record = { none: 0, read: 1, write: 2 }; +/** Whether `have` allows `need` (write takes in read). */ +export const permits = (have: AccessLevel, need: AccessLevel) => RANK[have] >= RANK[need]; +export const isAccessLevel = (v: unknown): v is AccessLevel => v === "none" || v === "read" || v === "write"; + +/** How long an Orgo invite can be taken: a membership begun later than this after access set with one isn't that invite's. */ +export const INVITE_MS = 7 * 24 * 3_600_000; + +/** + * Whether access kept for someone is for their membership now, begun at `joinedAt` as Orgo says it (when + * it doesn't say, it is, as before): set since it began (`at`), or set with the invite it began from + * (`invite`, taken within INVITE_MS). Access set before they were removed or left and came back is not, + * nor one set before entries said when (no `at`). + */ +export function currentFor(access: { at?: unknown; invite?: unknown }, joinedAt: number | undefined): boolean { + if (joinedAt === undefined) return true; + const at = typeof access.at === "number" && Number.isFinite(access.at) ? access.at : 0; + return at >= joinedAt || (access.invite === true && at > 0 && joinedAt - at <= INVITE_MS); +} + +/** A time Orgo sent (ms, seconds, or an ISO date), or undefined. */ +export function timeOf(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v) && v > 0) return v < 1e12 ? v * 1000 : v; + if (typeof v === "string" && v.trim() && !Number.isNaN(Date.parse(v))) return Date.parse(v); + return undefined; +} + +/** Where a person's access is kept: their email, lowercased, or "user:" for a guest with none. */ +export function accessKey(who: { email?: string | null; id?: string | null }): string | null { + const email = who.email?.trim().toLowerCase(); + if (email) return email; + return who.id ? `user:${who.id}` : null; +} + +/** A person's access as kept: their email's entry, else their user id's, else none. */ +export function accessOf(map: MemberAccessMap | undefined, who: { email?: string | null; id?: string | null }): MemberAccess { + if (!map) return NO_ACCESS; + const email = who.email?.trim().toLowerCase(); + return (email && map[email]) || (who.id && map[`user:${who.id}`]) || NO_ACCESS; +} + +/** + * One person's level for one bot: the level kept for it (anything else kept for it is none), else + * `others`. Only what's kept for it: a bot whose id every object has a field for ("constructor", a bot + * named Constructor) is never read off the object itself. + */ +export const levelOf = (access: MemberAccess, botId: string): AccessLevel => + Object.hasOwn(access.bots, botId) ? (isAccessLevel(access.bots[botId]) ? access.bots[botId] : "none") : access.others; + +/** + * What a client sent, made safe to keep: anything that isn't a level is none, bot ids are short + * strings, at most MAX_ACCESS_BOTS of them, and a bot at the same level as `others` isn't named. A bot + * it can't name (an id too long, or past the most) is left out only when that gives them less (it then + * reads as `others`); one kept below `others` never is, so null then, as when it isn't an access object at all. + */ +export function cleanAccess(v: unknown): MemberAccess | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const o = v as { others?: unknown; bots?: unknown }; + const others: AccessLevel = isAccessLevel(o.others) ? o.others : "none"; + const bots: Record = {}; + if (o.bots && typeof o.bots === "object" && !Array.isArray(o.bots)) { + for (const [id, level] of Object.entries(o.bots as Record)) { + const l: AccessLevel = isAccessLevel(level) ? level : "none"; + if (l === others) continue; + if (!id || id.length > 80 || id === "__proto__" || Object.keys(bots).length >= MAX_ACCESS_BOTS) { + // Left out, it would read as `others`: more than the owner gave them. + if (!permits(l, others)) return null; + continue; + } + bots[id] = l; + } + } + return { others, bots }; +} + +/** A new invite's access before the owner changes it: every bot there now to read; bots made later hidden. */ +export function inviteDefault(botIds: string[]): MemberAccess { + return { others: "none", bots: Object.fromEntries(botIds.map((id) => [id, "read" as AccessLevel])) }; +} + +/** The bots, of `botIds`, a person sees (read or write). */ +export const visibleBots = (access: MemberAccess, botIds: string[]) => botIds.filter((id) => levelOf(access, id) !== "none"); + +/** + * Whether a person sees a chat: every bot in it at read or more (a group chat with a bot hidden from + * them stays hidden, as its messages would show that bot's side). + */ +export const seesChat = (access: MemberAccess, botIds: string[]) => botIds.length > 0 && botIds.every((id) => levelOf(access, id) !== "none"); + +/** A short line for a person's access: "Every bot, read", "2 bots, 1 to write", "No bots". */ +export function accessSummary(access: MemberAccess, botIds: string[]): string { + const seen = visibleBots(access, botIds); + const writes = seen.filter((id) => levelOf(access, id) === "write").length; + const later = access.others !== "none" ? `, new bots ${access.others === "write" ? "to write" : "to read"}` : ""; + if (!seen.length) return access.others === "none" ? "No bots" : `No bots yet${later}`; + const all = seen.length === botIds.length; + const head = all ? "Every bot" : `${seen.length} ${seen.length === 1 ? "bot" : "bots"}`; + const how = writes === 0 ? "read" : writes === seen.length ? "write" : `${writes} to write`; + return `${head}, ${how}${later}`; +} diff --git a/cloud/members.ts b/cloud/members.ts new file mode 100644 index 00000000..b9701d42 --- /dev/null +++ b/cloud/members.ts @@ -0,0 +1,307 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { bearer, type CloudUser } from "./auth.ts"; +import { config } from "./config.ts"; +import { HttpError, readBody, type Route } from "./http.ts"; +import { canWatch, hub, liveScope, seedSeen, serveStream, type LiveTarget } from "./live.ts"; +import { timeOf } from "./member-access.ts"; +import { planSigned } from "./plans.ts"; +import { MESSAGES_PER_CHAT, WS_NOT_SHAREABLE, WS_ROLES, type SharedWsState, type SharedWsUnchanged, type WsAccess, type WsRole } from "./protocol.ts"; +import { chatPage, chatsSeq, loadState, recentMessages, sendZipped, type MessageRow } from "./state.ts"; +import { botsOf, chatIdsOf, handedByOthers, sharedHash, sharedMessages, sharedSlice, type SliceViewer } from "./ws-slice.ts"; + +/** + * Shared workspaces, read only (protocol.ts "Shared workspaces"): someone the owner added to one of their + * Bops workspaces' Orgo projects watches it from their own Bops. They see the bots the owner gave them + * (member-access.ts, kept on the workspace in the owner's state) and everything of each: its chats (the + * owner's 1:1 chats with it, the owner's own messages in them, and a group whose every bot they were + * given), its threads with their notes and results, its routines and watches, what Needs you, the moods + * as they happen. Never another bot's, nor the owner's vault, logins, keys, Macs or settings + * (ws-slice.ts). Nobody but the owner changes anything: members never publish (POST /v1/live only ever + * goes into the caller's own account) and nothing here writes. + * + * - **Who may**: orgo-web says, with the caller's own Orgo key (GET /api/projects//bops-role: the + * owner, an admin or a member; 404 for anyone else; and, from an orgo-web that sends it, when their + * membership began: joinedAt, so bots set for them before they were removed and came back stay hidden). Bops Cloud's login can't read orgo-web's members, + * so it asks. Each answer is kept ROLE_MS for that caller and project (a "no" NO_MS); a member's open + * stream asks again every ROLE_MS and ends with "revoked" once the answer is no (and plainly, to be asked + * afresh as it reconnects, once Orgo couldn't answer UNSURE_LOOKS looks in a row). A project with no Bops + * workspace of its own (an Orgo without per workspace projects: one "bops" project for all of them) + * is refused, since being its member would show every workspace. + * - **orgo-web tells the cloud** when someone's access to a project changed (they were removed, left, had + * their role changed, or the project changed hands): POST /v1/internal/members-changed { projectId, + * userId }, signed as its plan notices are (plans.ts planSigned). What was kept for them goes, and their + * streams of it end; reconnecting asks orgo-web afresh. An answer asked for before the notice and + * come back after it is asked for again, never kept or used. + * - **Watching**: GET /v1/live?project= is the owner's stream of that workspace (live.ts), counted as + * the member's own streams, and carrying only what's of the bots they see (live.ts LiveScope). + * - **Reading**: GET /v1/ws//state is the owner's workspace as this caller is shown it, and its chats' + * newest messages (SharedWsState), with the fingerprint of their own view. + * + * The caller's key is held in memory while their stream is open, to ask again with it; never stored. + */ + +/** How long orgo-web's answer is kept for a caller and project, and how often an open member stream asks again. */ +export const ROLE_MS = 60_000; +/** How long a "no" is kept: someone just added gets in soon after (orgo-web sends no notice for an add). */ +export const NO_MS = 15_000; +/** How many looks in a row Orgo can't answer before a member's open stream ends (it reconnects, asking afresh). */ +export const UNSURE_LOOKS = 2; +const PROJECT_ID = /^[A-Za-z0-9-]{1,64}$/; +const WS_ID = /^[A-Za-z0-9_-]{1,64}$/; +const USER_ID = /^[A-Za-z0-9._-]{1,128}$/; +const CACHE_MAX = 10_000; + +type Kept = { access: WsAccess | null; at: number }; +const kept = new Map(); +const keyOf = (userId: string, projectId: string) => `${userId}\0${projectId}`; +/** orgo-web's notices, counted (`notices`), and the count at each caller and project's latest one: an answer asked for before it is out of date. */ +let notices = 0; +const noticed = new Map(); +let now = () => Date.now(); +let fetchImpl: typeof fetch = (...a) => fetch(...a); +let every = ROLE_MS; +/** The owner's state as the cloud last saved it, without its messages ({} before their first upload). */ +const savedState = async (account: string) => ((await loadState(account))?.state ?? {}) as Record; +let ownerState = savedState; + +/** What orgo-web answered, checked: an access, or null when it doesn't say one. */ +function accessOf(body: unknown): WsAccess | null { + if (!body || typeof body !== "object") return null; + const b = body as Record; + if (!WS_ROLES.includes(b.role as WsRole) || typeof b.ownerUserId !== "string" || !USER_ID.test(b.ownerUserId)) return null; + if (b.bopsWsId !== null && !(typeof b.bopsWsId === "string" && WS_ID.test(b.bopsWsId))) return null; + const joinedAt = timeOf(b.joinedAt); + return { role: b.role as WsRole, ownerUserId: b.ownerUserId, bopsWsId: b.bopsWsId as string | null, name: typeof b.name === "string" ? b.name.slice(0, 200) : "", ...(joinedAt !== undefined ? { joinedAt } : {}) }; +} + +/** + * The caller's access to an Orgo project as orgo-web says it, or null (none, or no such project: orgo-web + * never says which). Kept ROLE_MS (null NO_MS) unless `fresh`. Throws 503 when Orgo can't be asked. + */ +export async function accessFor(userId: string, key: string, projectId: string, { fresh = false } = {}): Promise { + if (!PROJECT_ID.test(projectId)) return null; + const id = keyOf(userId, projectId); + const hit = kept.get(id); + if (!fresh && hit && now() - hit.at < (hit.access ? ROLE_MS : NO_MS)) return hit.access; + // A notice while orgo-web was being asked: its answer may be from before the change, so it's asked again (once). + for (let tries = 0; ; tries++) { + const asked = notices; + const access = await ask(key, projectId); + if ((noticed.get(id)?.n ?? 0) > asked) { + if (tries) throw new HttpError(503, "Orgo is changing who's in this workspace. Try again."); + continue; + } + kept.set(id, { access, at: now() }); + if (kept.size > CACHE_MAX) for (const [k, v] of kept) if (now() - v.at > ROLE_MS) kept.delete(k); + return access; + } +} + +/** One answer from orgo-web's bops-role, checked (null: no access). Throws 503 when Orgo can't be asked. */ +async function ask(key: string, projectId: string): Promise { + let res: Response; + try { + res = await fetchImpl(`${config.orgoOrigin()}/api/projects/${encodeURIComponent(projectId)}/bops-role`, { + headers: { Authorization: `Bearer ${key}`, Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new HttpError(503, "Can't reach Orgo to check this workspace"); + } + let access: WsAccess | null = null; + if (res.ok) { + access = accessOf(await res.json().catch(() => null)); + if (!access) throw new HttpError(503, "Orgo didn't say who you are in this workspace"); + } else { + void res.body?.cancel().catch(() => {}); + if (![401, 403, 404].includes(res.status)) throw new HttpError(503, `Orgo answered ${res.status} when checking this workspace`); + } + return access; +} + +/** + * The stream and the state a caller may watch for a project: the owner's account and the project's + * workspace. 404 without access (never saying whether the project exists), 409 for a project with no + * Bops workspace of its own. + */ +export async function watchable(user: CloudUser, key: string, projectId: string, opts: { fresh?: boolean } = {}): Promise<{ access: WsAccess; target: LiveTarget }> { + const access = await accessFor(user.id, key, projectId, opts); + if (!access) throw new HttpError(404, "No such workspace"); + if (!access.bopsWsId) throw new HttpError(409, "This workspace can't be shared in Bops yet", { code: WS_NOT_SHAREABLE }); + return { access, target: { account: access.ownerUserId, ws: access.bopsWsId } }; +} + +/** + * Who the caller is to the workspace, as ws-slice.ts sharedSlice takes it: orgo-web's role, their email (their + * access is kept by it), user id (a guest's is) and when their membership began (access set before isn't theirs). + */ +const viewerOf = (user: CloudUser, access: WsAccess): SliceViewer => ({ + role: access.role, + id: user.id, + ...(user.email ? { email: user.email } : {}), + ...(access.joinedAt !== undefined ? { joinedAt: access.joinedAt } : {}), +}); + +/* ---------------- Open streams, and orgo-web's notice ---------------- */ + +/** Each caller's open member streams of a project, by how to end them. */ +const open = new Map void>>(); + +function track(userId: string, projectId: string, end: () => void): () => void { + const id = keyOf(userId, projectId); + let set = open.get(id); + if (!set) open.set(id, (set = new Set())); + set.add(end); + return () => { + set.delete(end); + if (!set.size && open.get(id) === set) open.delete(id); + }; +} + +/** Someone's access to a project changed (orgo-web's notice): what was kept for them goes, and their streams of it end. */ +export function membersChanged(projectId: string, userId: string) { + const id = keyOf(userId, projectId); + kept.delete(id); + noticed.set(id, { n: ++notices, at: now() }); + // Only an answer still on its way can be from before a notice, and none takes longer than this. + if (noticed.size > CACHE_MAX) for (const [k, v] of noticed) if (now() - v.at > ROLE_MS) noticed.delete(k); + for (const end of [...(open.get(id) ?? [])]) end(); +} + +/** How many member streams `userId` has open of a project (tests). */ +export const memberStreams = (userId: string, projectId: string) => open.get(keyOf(userId, projectId))?.size ?? 0; + +/** GET /v1/live?project= (live.ts hands it here): the owner's stream of that workspace, while the caller may watch it. */ +export async function watchProject(req: IncomingMessage, res: ServerResponse, user: CloudUser, url: URL) { + const projectId = url.searchParams.get("project") ?? ""; + if (!PROJECT_ID.test(projectId)) throw new HttpError(400, "project must be an Orgo workspace id"); + const key = bearer(req); + const { access, target } = await watchable(user, key, projectId); + if (!canWatch(user, target, access)) throw new HttpError(403, "You can't watch this workspace"); + // Anyone but the owner hears only what's of the bots they see: worked out from the owner's state, read + // now (a read that fails fails the stream: nothing is sent unscoped), then kept up as it's written (live.ts stateWritten). + const viewer = viewerOf(user, access); + const scopeNow = async () => liveScope(await ownerState(target.account), target.ws, viewer); + const written = hub.written; + const scope = viewer.role === "owner" ? undefined : await scopeNow(); + let unsure = 0; + const sub = serveStream(req, res, target, hub, { + caller: user.id, + every, + // Asked afresh: false once they're out or the project is another workspace now. Orgo not saying is + // no answer once (null), and ends the stream UNSURE_LOOKS in a row: coming back asks again, so a + // member orgo-web can't vouch for doesn't watch on. + still: async () => { + const now = await accessFor(user.id, key, projectId, { fresh: true }).catch(() => undefined); + if (now === undefined) { + if (++unsure < UNSURE_LOOKS) return null; + kept.delete(keyOf(user.id, projectId)); + return "end"; + } + unsure = 0; + return !!now && now.ownerUserId === target.account && now.bopsWsId === target.ws; + }, + track: (end) => track(user.id, projectId, end), + ...(scope ? { scope } : {}), + }); + // A state written while it was being read may be the owner's, too late for the stream to hear it: read + // once more, kept unless a write has worked the stream's scope out again meanwhile (which is newer). + if (sub?.scope && hub.written !== written) + void scopeNow() + .then((next) => { + if (sub.scope === scope) sub.scope = next; + }) + .catch(() => {}); +} + +/* ---------------- The workspace's state ---------------- */ + +const STATE_PATH = /^\/v1\/ws\/([A-Za-z0-9-]{1,64})\/state$/; + +/** + * A member's copy of the owner's messages: as they're shown (ws-slice.ts), and for anyone but the owner + * (`bots`, the ones they see) without any other bot's, its hand-off notes too (`handed`: the threads it + * handed over). A removed message's row (json null) is no message. + */ +const shown = (rows: MessageRow[], bots?: ReadonlySet, handed?: ReadonlySet) => sharedMessages(rows.flatMap((r) => (r.json ? [r.json] : [])), bots, handed); + +const readState: Route = { + method: "GET", + path: "/v1/ws/*", + auth: "user", + handle: async (req, res, { user, url }) => { + const projectId = STATE_PATH.exec(url.pathname)?.[1]; + if (!projectId) throw new HttpError(404, "Not found"); + const { access, target } = await watchable(user!, bearer(req), projectId); + const blob = await ownerState(target.account); + // What this caller is shown: all of it for the owner, else only the bots the owner gave them (by + // their email or user id; none when neither is there). Its messages are only of the chats it has. + const viewer = viewerOf(user!, access); + const slice = sharedSlice(blob, target.ws, viewer); + const bots = viewer.role === "owner" ? undefined : botsOf(slice); + const handed = bots && handedByOthers(blob, bots); + // The owner's state hasn't come up yet, or no longer has this workspace: nothing to show of it. + if (!slice.workspace) throw new HttpError(404, "No such workspace"); + // Which workspace each chat is in, for an owner the cloud has seen nothing of since it started (live.ts). + seedSeen(target.account, blob); + // Its chats, and every two of its bots' conversation with each other ("Asked ● Sam" opens it): ws-slice.ts chatIdsOf. + const chatIds = chatIdsOf(slice); + // Their own view's fingerprint: a ?have= from a copy of another view (another member's, or theirs before + // the owner changed what they see) is never answered "unchanged". + const hash = sharedHash(slice); + const seq = await chatsSeq(target.account, chatIds); + const head = { projectId, wsId: target.ws, role: access.role, name: access.name, hash, seq }; + // One chat's older messages: before a time, and (beforeId) the message at that time the reader has oldest. + const chat = url.searchParams.get("chat"); + if (chat !== null) { + const before = Number(url.searchParams.get("before")); + if (!chatIds.includes(chat)) throw new HttpError(404, "No such chat"); + if (!Number.isSafeInteger(before) || before < 0) throw new HttpError(400, "before must be a time (ms)"); + const page = await chatPage(target.account, chat, before, MESSAGES_PER_CHAT, url.searchParams.get("beforeId") ?? undefined); + const body: SharedWsState = { ...head, state: slice, messages: shown(page.rows, bots, handed), more: page.more ? [chat] : [] }; + return sendZipped(req, res, 200, body); + } + if (url.searchParams.get("have") === `${hash}.${seq}`) return sendZipped(req, res, 200, { hash, seq, unchanged: true } satisfies SharedWsUnchanged); + const recent = await recentMessages(target.account, chatIds, MESSAGES_PER_CHAT); + const body: SharedWsState = { ...head, state: slice, messages: shown(recent.rows, bots, handed), more: recent.more }; + await sendZipped(req, res, 200, body); + }, +}; + +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? (v[0] ?? "") : (v ?? "")); + +const changed: Route = { + method: "POST", + path: "/v1/internal/members-changed", + auth: "public", + handle: async (req, res) => { + const secret = config.planSecret(); + if (!secret) throw new HttpError(404, "Not found"); + const body = await readBody(req, 4 * 1024); + if (!planSigned(secret, one(req.headers["x-bops-timestamp"]), body, one(req.headers["x-bops-signature"]))) throw new HttpError(401, "Bad signature"); + let n: { projectId?: unknown; userId?: unknown }; + try { + n = JSON.parse(body.toString("utf8")) as typeof n; + } catch { + throw new HttpError(400, "Body isn't JSON"); + } + if (typeof n?.projectId !== "string" || !PROJECT_ID.test(n.projectId) || typeof n.userId !== "string" || !USER_ID.test(n.userId)) throw new HttpError(400, "Send {projectId, userId}"); + membersChanged(n.projectId, n.userId); + res.writeHead(204).end(); + }, +}; + +export const routes: Route[] = [readState, changed]; + +/** + * For tests: forget every answer kept, and use this clock, this way to reach orgo-web, this wait between + * an open stream's looks and this way to read an owner's state (unset: the real ones). + */ +export function resetMembers(opts: { now?: () => number; fetch?: typeof fetch; every?: number; state?: (account: string) => Promise> } = {}) { + kept.clear(); + noticed.clear(); + now = opts.now ?? (() => Date.now()); + fetchImpl = opts.fetch ?? ((...a) => fetch(...a)); + every = opts.every ?? ROLE_MS; + ownerState = opts.state ?? savedState; +} diff --git a/cloud/protocol.ts b/cloud/protocol.ts index e5378922..099e6111 100644 --- a/cloud/protocol.ts +++ b/cloud/protocol.ts @@ -22,7 +22,10 @@ export type CloudSession = { */ agentmail: { podId: string; + /** Empty for a computer's runner, which gets no key (`proxied`). */ apiKey: string; + /** A computer's runner: its mail goes through /proxy/agentmail on its own credential, in its bots' inboxes only (cloud/proxy.ts). */ + proxied?: true; domain?: string | null; /** * The user's own mail handle, their default workspace's part of the address (tiger in @@ -130,16 +133,27 @@ export const STATE_PROTOCOL = 2; * app's calls for the user's state (but its copy before Start over) and its tunnel are refused (426 * STATE_TOO_NEW) and its notices say to update (cloud/state.ts). 3: routines that run every so often, * before meetings or monthly, and a bot with no access to an app, which 0.0.28 runs over and over, runs - * daily, or lists as able to use it. + * daily, or lists as able to use it. 4: routines may run on a cloud runner (a Bops on one of the bots' + * computers, lib/server/runner.ts); an older app runs them all again. Only an app at level 4 stops its + * routines on that 426 (ROUTINES_STOP_LEVEL): an older one that's still open would go on from its copy in + * memory, so it's refused the proxies too (cloud/state.ts behindState), and its runs fail instead of + * running a second time. */ export const BOPS_STATE_LEVEL_HEADER = "x-bops-state-level"; -export const STATE_LEVEL = 3; +export const STATE_LEVEL = 4; +/** The first level whose app stops its routines when it's refused the user's state (lib/server/routines.ts tick). */ +export const ROUTINES_STOP_LEVEL = 4; /** 426: a Mac of the user's on a newer Bops wrote their state, which this app can't be given (BOPS_STATE_LEVEL_HEADER). */ export const STATE_TOO_NEW = "state_too_new"; /** 409: the call named another user than the key's. */ export const WRONG_USER = "wrong_user"; /** 409 on PUT /v1/state: someone else wrote since `base` (CloudStateConflict). */ export const STATE_CONFLICT = "state_conflict"; +/** + * 403: a computer signed in as itself (its runner, on its Orgo identity or its own key) called what only + * its user may (phone, mail, Slack, numbers, backups, notices, the tunnel...), or sent another device. + */ +export const COMPUTER_NOT_ALLOWED = "computer_not_allowed"; /** GET /v1/state: the blob (no `messages`), its version (the cloud's count, bumped on every write) and the newest message write's seq. 404 when there's none yet. */ export type CloudState = { version: number; seq: number; protocol: number; writer: string | null; state: Record }; @@ -529,3 +543,96 @@ export type CloudUsage = { /** Per bot (null: not tied to one bot): model tokens, call seconds, and cost. */ bots: { botId: string | null; tokens: number; callSeconds: number; costMicros: number }[]; }; + +/** + * Live (cloud/live.ts): what each bot of a workspace is doing right now, and a nudge when the + * workspace changed, streamed to every signed-in device that watches it. Nothing of it is saved. + * + * GET /v1/live?ws= is a stream of Server-Sent Events. Each one's `event:` is its type and + * its `data:` is JSON. A new connection first gets a "snapshot" (every bot that isn't idle), then the + * events it missed since its Last-Event-ID, or a "reset" when they can't be told (too long away, or + * the cloud restarted): it reads the state afresh then. POST /v1/live { ws, events } publishes. + * + * A member of a shared workspace watches GET /v1/live?project= (cloud/members.ts): the + * owner's stream for that workspace, read only, carrying only what's of the bots they see. It ends with a + * "revoked" event (no data) once orgo-web no longer says they're a member: the app drops its copy of the + * workspace and leaves it. + */ +export type LiveMood = "thinking" | "writing" | "speaking" | "working" | "idle"; +export const LIVE_MOODS: readonly LiveMood[] = ["thinking", "writing", "speaking", "working", "idle"]; +/** How a routine changed, for its "routine" event. */ +export type LiveRoutineChange = "added" | "changed" | "removed" | "run-started" | "run-finished"; +export const LIVE_ROUTINE_CHANGES: readonly LiveRoutineChange[] = ["added", "changed", "removed", "run-started", "run-finished"]; +/** Seconds a mood lasts unless it's said again (its publisher says it every 5 seconds while it holds). */ +export const LIVE_MOOD_TTL = 15; + +/** What a device publishes (POST /v1/live). */ +export type LiveEventIn = + /** A bot's mood. `chatId`: where it's typing, when it is. Idle ends it. */ + | { type: "mood"; botId: string; chatId?: string; mood: LiveMood; ttl?: number } + /** A thread this device runs: its status and what it's doing in a word or two. */ + | { type: "session"; id: string; botId: string; status: string; activity?: string } + | { type: "routine"; id: string; change: LiveRoutineChange } + | { type: "call"; botId: string; on: boolean }; +export type LivePublish = { ws: string; events: LiveEventIn[] }; + +/** + * What a stream carries. `by` is the device that published it (BOPS_DEVICE_HEADER; "cloud" for the + * cloud's own), so a device can leave out its own. "state": the workspace's part of the state changed + * (`hash`, a fingerprint of what this stream's viewer is shown of it, SharedWsState.hash: the owner's + * view on the owner's streams, each member's own on theirs) or messages were written (`seq`; on a + * member's stream, only in a chat they see): read it now. + */ +export type LiveEventOut = + | (LiveEventIn & { by: string; at: number }) + | { type: "state"; hash?: string; seq?: number; by: string; at: number }; +export type LiveSnapshot = { moods: { botId: string; chatId?: string; mood: LiveMood; ttl: number; by: string }[] }; + +/** + * Shared workspaces (cloud/members.ts): someone the owner added to a Bops workspace's Orgo project + * watches it from their own Bops, read only. Who may is orgo-web's to say, with the caller's own key: + * GET /api/projects//bops-role answers WsAccess for the owner and each member (404 for anyone + * else). Roles are Orgo's: owner everything, admin Full access, member View only. Of the workspace, anyone + * but the owner sees only the bots the owner gave them (cloud/member-access.ts, kept on the workspace in + * the owner's state as memberAccess, which is never shown to them), and everything of each: its chats + * with the owner's own messages (one kept "between us" too: that mark only keeps things out of the + * shared memory), its threads, routines and watches. A group chat only when every bot in it is theirs. + */ +export type WsRole = "owner" | "admin" | "member"; +export const WS_ROLES: readonly WsRole[] = ["owner", "admin", "member"]; +/** `joinedAt`: when the caller's membership began, from an orgo-web that says it (member-access.ts currentFor). */ +export type WsAccess = { role: WsRole; ownerUserId: string; bopsWsId: string | null; name: string; joinedAt?: number }; +/** The code a project with no Bops workspace of its own is refused with (one "bops" project would show every workspace). */ +export const WS_NOT_SHAREABLE = "not_shareable"; + +/** + * GET /v1/ws//state: what a member is shown of the owner's workspace (cloud/ws-slice.ts: the + * bots they were given, an allow list of fields), and its chats' newest messages (MESSAGES_PER_CHAT + * each, oldest first; `more` names the chats that have older ones, read with ?chat=&before=&beforeId=: + * the page before the oldest message the reader has, its time and id). + * `hash` is the slice's fingerprint, as the stream's "state" events carry it; `seq` the newest message + * write in its chats. With ?have=. and nothing changed since, only { hash, seq, unchanged: true }. + */ +export type SharedWsSlice = { + workspace: Record | null; + bots: Record[]; + chats: Record[]; + sessions: Record[]; + routines: Record[]; + watches: Record[]; + /** What its bots wait on the owner's OK for (lib/types.ts AppApproval), read only; none when nothing waits, or from a Bops Cloud before asks were shared. */ + approvals?: Record[]; +}; +export type SharedWsState = { + projectId: string; + wsId: string; + role: WsRole; + name: string; + hash: string; + seq: number; + state: SharedWsSlice; + messages: Record[]; + more: string[]; +}; +export type SharedWsUnchanged = { hash: string; seq: number; unchanged: true }; +export const MESSAGES_PER_CHAT = 200; diff --git a/cloud/proxy.ts b/cloud/proxy.ts index 8c5c19a0..b528fe96 100644 --- a/cloud/proxy.ts +++ b/cloud/proxy.ts @@ -8,13 +8,15 @@ import { bearer, type CloudUser } from "./auth.ts"; import { config } from "./config.ts"; import { creditLeft, creditsOn, outOfCredit, requireCredit } from "./credit.ts"; import { seal } from "./crypto.ts"; +import { banksOn, botIdsOn, grantsOn, inboxesOn, linesOn, ownerNumbers, type Blob as StateBlob, type Level } from "./computer-scope.ts"; import { objectInfo, objectOwner, ownObject, query, sessionUsed } from "./db.ts"; import { HttpError, readBody, refuseUpgrade, type Route, type Upgrade } from "./http.ts"; import { recordLine } from "./lines.ts"; import { refuseSecondPlanNumber, requireRoomForNumber, sendingStopped } from "./plans.ts"; import { imageModelPriced, numberCost, smsSegments } from "./pricing.ts"; -import { PLAN_REQUIRED, USAGE_APP_HEADER, USAGE_BOT_HEADER, USAGE_SOURCE_HEADER } from "./protocol.ts"; -import { accountFor, composioUserId, honchoPrefix, ownsWorkspace } from "./session.ts"; +import { COMPUTER_NOT_ALLOWED, PLAN_REQUIRED, USAGE_APP_HEADER, USAGE_BOT_HEADER, USAGE_SOURCE_HEADER } from "./protocol.ts"; +import { accountFor, composioUserId, honchoPrefix, ownsWorkspace, podKeyOf } from "./session.ts"; +import { loadState } from "./state.ts"; import { admitTurn, follow, userStopped, verdict, watchSession, type Start } from "./turn-guard.ts"; import { isWebSearch, liveSecondsOf, recordImages, recordJev, recordTokens, recordTranscription, recordTreg, recordUsage, recordUsageFor, recordWebSearch } from "./usage.ts"; @@ -41,11 +43,19 @@ import { isWebSearch, liveSecondsOf, recordImages, recordJev, recordTokens, reco * item, Jev's tokens, Composio's and Honcho's calls. The Mac says which bot and what kind of work a * call is for (x-bops-bot, x-bops-source: read here, never sent on), and an agent session keeps the * bot it was made for. + * - A computer's runner (auth.ts CloudUser.computer) reaches only what its own bots may use, from its + * user's state as Bops Cloud keeps it (computer-scope.ts; a computer can't change who works where or + * what they may use): the models, Typesafe and treg as the Mac does; Honcho in its bots' workspaces' + * memory banks only; Composio's catalog, and its bots' own sessions and actions in the accounts the + * user gave them (read-only ones read only), never connecting, deleting or linking accounts, sign-in + * setups, raw API calls or triggers; AgentPhone only to text the user's verified numbers from its + * bots' lines; and AgentMail (computers only: the Mac uses its pod key) in its bots' own inboxes. Any + * other route is 403 computer_not_allowed (Spec.computers, Rule.computer). */ const MAX_BODY = 25 * 1024 * 1024; -type Provider = "openai" | "agentphone" | "honcho" | "composio" | "typesafe" | "treg"; +type Provider = "openai" | "agentphone" | "honcho" | "composio" | "typesafe" | "treg" | "agentmail"; /** A call on its way through: who's asking and where to, and what the rules may check or change before it goes. */ type Call = { @@ -84,6 +94,8 @@ type Call = { app?: string; /** The Mac's own request headers, for a rule that reads one it doesn't send on (treg's x-treg-route-max-cost). */ asked: IncomingHttpHeaders; + /** A computer's call: what its bots may use, from its user's state (viewOf), once looked up. */ + view?: Promise; }; type Hook = (call: Call, value: T) => Promise | unknown; @@ -122,6 +134,11 @@ type Rule = { headers?: (call: Call, status: number, headers: IncomingHttpHeaders) => void; /** A refusal (status 400 or more), read whole: what the Mac gets instead ({status, body}), or undefined to pass it on as it came. */ refused?: (call: Call, status: number, headers: IncomingHttpHeaders, body: Buffer) => { status: number; body: Record } | undefined; + /** + * A computer's runner may call it (where its provider lets computers call only the rules that say so, + * Spec.computers "rules"): true, or a check of its own for a computer's call (refuse with HttpError). + */ + computer?: true | ((call: Call) => Promise | void); }; /** "GET v1/agents/sessions/:session/events", with what to do on it. "*" as the method is any; "*" at the end of the path is anything below. */ @@ -139,11 +156,75 @@ type Spec = { body: "json" | "json-or-upload" | "pipe"; /** For every call to this provider, before its route's own check. */ check?: (call: Call) => Promise | void; + /** + * What a computer's runner may call: every rule ("all", with `computerCheck` first), only the rules + * marked `computer` ("rules"). A provider without it refuses computers. + */ + computers?: "all" | "rules"; + computerCheck?: (call: Call) => Promise | void; + /** The key for this call, when it's the user's own rather than the cloud's (AgentMail's pod key). */ + keyFor?: (call: Call) => Promise; rules: Rule[]; /** How long to wait for the provider to start answering. */ timeoutMs: number; }; +/* ---------------- A computer's runner: what its bots may use ---------------- */ + +/** What a computer's bots may use, from its user's state as Bops Cloud keeps it (computer-scope.ts). */ +type ComputerView = { + state: StateBlob; + bots: Set; + /** Honcho workspace ids: the user's prefix and its bots' memory banks. */ + workspaces: Set; + inboxes: Set; + lines: { numberIds: Set; agentIds: Set }; + /** The user's verified numbers, last 10 digits. */ + owners: Set; +}; + +/** The calling computer's view, looked up once per call. */ +function viewOf(call: Call): Promise { + return (call.view ??= (async () => { + const c = call.user.computer!; + const state = ((await loadState(call.user.id))?.state ?? {}) as StateBlob; + const prefix = honchoPrefix(call.user.id); + return { + state, + bots: botIdsOn(state, c.desktopId), + workspaces: new Set([...banksOn(state, c.desktopId)].map((bank) => `${prefix}-${bank}`)), + inboxes: inboxesOn(state, c.desktopId), + lines: linesOn(state, c.desktopId), + owners: ownerNumbers(state, call.user.id), + }; + })()); +} + +const notForComputer = (what: string) => new HttpError(403, `A computer's identity can't do this: ${what}`, { code: COMPUTER_NOT_ALLOWED }); + +/** A computer's call is for one of its own bots (x-bops-bot): which one. */ +async function computerBot(call: Call): Promise { + const v = await viewOf(call); + if (!call.bot || !v.bots.has(call.bot)) throw notForComputer("only for a bot that works on this computer."); + return call.bot; +} + +/** What a computer's bot may use of each account (grantsOn), and the most it may do in each app. */ +async function computerGrants(call: Call, botId: string) { + const grants = grantsOn((await viewOf(call)).state, call.user.computer!.desktopId, botId); + const apps = new Map(); + for (const g of grants.values()) apps.set(g.app.toLowerCase(), apps.get(g.app.toLowerCase()) === "act" || g.level === "act" ? "act" : "read"); + return { grants, apps }; +} + +/** Whether a computer's call may go: its provider lets computers make it (Spec.computers, Rule.computer), and its checks pass. */ +async function forComputer(spec: Spec, r: Rule, call: Call) { + const allowed = spec.computers === "all" || (spec.computers === "rules" && r.computer); + if (!allowed) throw new HttpError(403, "A computer's identity can't do this.", { code: COMPUTER_NOT_ALLOWED }); + await spec.computerCheck?.(call); + if (typeof r.computer === "function") await r.computer(call); +} + /* ---------------- Paths, queries and bodies ---------------- */ /** A path segment as the cloud passes it on: no "%", "/", "\" or anything else a server might read differently. */ @@ -445,6 +526,7 @@ const openai: Spec = { auth: (key) => ({ authorization: `Bearer ${key}` }), body: "json", check: openaiRefs, + computers: "all", timeoutMs: 15 * 60_000, rules: [ rule("POST v1/responses", { @@ -689,6 +771,22 @@ async function notFromPausedNumber(call: Call) { throw new HttpError(402, "This number is paused while you're on Free. Upgrade to text from it again.", { code: PLAN_REQUIRED, upgrade: true }); } +/** + * A computer's text (a routine's result, a bot's text_me during a runner's task): only from a line its + * bots text from (computer-scope.ts linesOn: a bot's own, else its workspace's main bot's), and only to + * one of the user's verified numbers. + */ +async function computerTextsOwner(call: Call) { + const v = await viewOf(call); + const body = isObject(call.json) ? call.json : {}; + const id = (x: unknown) => (typeof x === "string" ? x : ""); + const numberId = id(body.number_id ?? body.numberId); + const agentId = id(body.agent_id ?? body.agentId); + const to = id(body.to_number ?? body.toNumber).replace(/\D/g, "").slice(-10); + if (!numberId || !v.lines.numberIds.has(numberId) || (agentId && !v.lines.agentIds.has(agentId))) throw notForComputer("texts only from the number of a bot on this computer."); + if (to.length !== 10 || !v.owners.has(to)) throw notForComputer("texts only to your own verified numbers."); +} + /** A webhook registration goes to the cloud's own address, whatever the Mac asked for. */ function webhookToCloud(call: Call) { if (!config.publicUrl()) throw new HttpError(503, "This cloud has no public address for webhooks yet."); @@ -727,6 +825,7 @@ const agentphone: Spec = { auth: (key) => ({ authorization: `Bearer ${key}` }), body: "json", check: inSubAccount, + computers: "rules", timeoutMs: 2 * 60_000, rules: [ rule("GET v1/numbers", { json: recordNumbers }), @@ -770,7 +869,7 @@ const agentphone: Spec = { rule("DELETE v1/agents/:agent/numbers/:number"), rule("GET v1/agents/:agent/webhook", { json: keepWebhookSecret }), rule("POST v1/agents/:agent/webhook", { check: webhookToCloud, json: keepWebhookSecret }), - rule("POST v1/messages", { check: notFromPausedNumber, spends: true, done: countText }), + rule("POST v1/messages", { check: notFromPausedNumber, spends: true, done: countText, computer: computerTextsOwner }), rule("POST v1/messages/:message/reactions"), rule("POST v1/conversations/:conversation/typing"), rule("GET v1/register/status"), @@ -793,10 +892,20 @@ function workspaceInPath(call: Call) { if (!ownsWorkspace(call.user.id, call.params.workspace)) throw notYours(call); } -/** Only the user's workspaces, whatever else is in the project: the list is filtered after Honcho answers. */ -function ownWorkspacesOnly(call: Call, data: unknown) { +/** A computer's Honcho call: every workspace it names, in its path, body or query, is one of its bots' memory banks (computer-scope.ts banksOn). */ +async function computerBanks(call: Call) { + const v = await viewOf(call); + const named = [...queryValues(call.query), ...keysIn(call.json)].filter((x) => x.key === "workspaceid" || x.key === "workspaceids").flatMap((x) => idsIn(x.value)); + if (call.params.workspace) named.push(call.params.workspace); + if (call.method === "POST" && call.path.join("/") === "v3/workspaces" && isObject(call.json) && typeof call.json.id === "string") named.push(call.json.id); + if (named.some((w) => !v.workspaces.has(w))) throw notForComputer("memory only in the workspaces of the bots on this computer."); +} + +/** Only the user's workspaces, whatever else is in the project (and for a computer, its bots' only): the list is filtered after Honcho answers. */ +async function ownWorkspacesOnly(call: Call, data: unknown) { const items = isObject(data) && Array.isArray(data.items) ? data.items : []; - const mine = items.filter((w) => isObject(w) && typeof w.id === "string" && ownsWorkspace(call.user.id, w.id)); + const theirs = call.user.computer ? (await viewOf(call)).workspaces : null; + const mine = items.filter((w) => isObject(w) && typeof w.id === "string" && ownsWorkspace(call.user.id, w.id) && (!theirs || theirs.has(w.id))); return { items: mine, total: mine.length, page: 1, size: mine.length, pages: 1 }; } @@ -820,6 +929,8 @@ const honcho: Spec = { auth: (key) => ({ authorization: `Bearer ${key}` }), body: "json-or-upload", check: honchoScope, + computers: "all", + computerCheck: computerBanks, timeoutMs: 5 * 60_000, rules: [ // Get-or-create: the workspace is named in the body. @@ -1040,6 +1151,85 @@ const sessionTool = (call: Call) => { return typeof slug === "string" ? slug.slice(0, 120) : "session"; }; +/** An action's app (its toolkit) and whether it only reads, as Composio says (asked with the cloud's key, kept). */ +const actionsSeen = new Map(); +async function actionOf(slug: string) { + if (actionsSeen.has(slug)) return actionsSeen.get(slug)!; + const t = await askComposio(`/api/v3.1/tools/${encodeURIComponent(slug)}`); + const toolkit = isObject(t?.toolkit) && typeof t.toolkit.slug === "string" ? t.toolkit.slug.toLowerCase() : ""; + const info = t && toolkit ? { toolkit, readOnly: Array.isArray(t.tags) && t.tags.includes("readOnlyHint") } : null; + if (actionsSeen.size > 2000) actionsSeen.clear(); + actionsSeen.set(slug, info); + return info; +} + +/** The accounts a Composio call names (connected_account_id and the like). */ +const accountsNamed = (call: Call) => [...queryValues(call.query), ...keysIn(call.json)].filter((x) => ACCOUNT_KEYS.has(x.key)).flatMap((x) => idsIn(x.value)); + +/** + * A computer's action (POST tools/execute/:tool, lib/server/composio.ts runAppAction): for one of its bots, + * in an account the user gave that bot in the action's app (an app with no sign-in: `open:`), and in + * one it may only read, a reading action only. + */ +async function computerAction(call: Call) { + const { grants } = await computerGrants(call, await computerBot(call)); + const action = await actionOf(call.params.tool); + if (!action) throw new HttpError(404, "Composio doesn't know that action."); + const named = accountsNamed(call); + for (const id of named.length ? named : [`open:${action.toolkit}`]) { + const g = grants.get(id); + if (!g || g.app.toLowerCase() !== action.toolkit) throw notForComputer("actions only in the accounts its bot was given."); + if (g.level === "read" && !action.readOnly) throw notForComputer("only reading actions in an account its bot may only read."); + } +} + +/** + * A computer's Composio session (lib/server/composio.ts sessionFor): for one of its bots, with that bot's + * apps and accounts only, a read-only app's actions read-only, and nothing past them (no connecting + * accounts, no sandbox, no sign-in setups or saved configs). + */ +async function computerSession(call: Call) { + const { grants, apps } = await computerGrants(call, await computerBot(call)); + const body = isObject(call.json) ? call.json : {}; + const refuse = () => notForComputer("a session only with its bot's own apps and accounts."); + const enabled = isObject(body.toolkits) && Array.isArray(body.toolkits.enable) ? body.toolkits.enable : null; + if (!enabled || !enabled.every((t) => typeof t === "string" && apps.has(t.toLowerCase()))) throw refuse(); + for (const [toolkit, ids] of Object.entries(isObject(body.connected_accounts) ? body.connected_accounts : {})) + for (const id of Array.isArray(ids) ? ids : [ids]) { + const g = typeof id === "string" ? grants.get(id) : undefined; + if (!g || g.app.toLowerCase() !== toolkit.toLowerCase()) throw refuse(); + } + const tools = isObject(body.tools) ? body.tools : {}; + for (const t of enabled as string[]) { + if (apps.get(t.toLowerCase()) !== "read") continue; + const cfg = Object.entries(tools).find(([k]) => k.toLowerCase() === t.toLowerCase())?.[1]; + const tags = isObject(cfg) && isObject(cfg.tags) && Array.isArray(cfg.tags.enable) ? cfg.tags.enable : null; + if (!tags?.includes("readOnlyHint")) throw refuse(); + } + const off = (v: unknown) => v === false || (isObject(v) && v.enable === false); + const authConfigs = Array.isArray(body.auth_configs) ? body.auth_configs.length : isObject(body.auth_configs) ? Object.keys(body.auth_configs).length : 0; + if (!off(body.manage_connections) || !off(body.workbench) || authConfigs || body.experimental !== undefined) throw refuse(); +} + +/** A computer's use of a Composio session: one made for one of its bots (recorded with it). Its bot, else 404. */ +async function computerSessionBot(call: Call): Promise { + const botId = (await objectInfo("composio", call.params.session)).botId; + if (!botId || !(await viewOf(call)).bots.has(botId)) throw new HttpError(404, "Not found"); + return botId; +} + +/** An action in a computer's session: the account it names, if it names one, is one that session's bot was given. */ +async function computerSessionAction(call: Call) { + const { grants } = await computerGrants(call, await computerSessionBot(call)); + const account = isObject(call.json) ? call.json.account : undefined; + if (account !== undefined && !(typeof account === "string" && grants.has(account))) throw notForComputer("actions only in the accounts its bot was given."); +} + +/** One of the accounts the computer's bots were given. */ +async function computerAccount(call: Call) { + if (!grantsOn((await viewOf(call)).state, call.user.computer!.desktopId).has(call.params.account)) throw new HttpError(404, "Not found"); +} + /** These checks, one after the other. */ const both = (...checks: ((call: Call) => Promise | void)[]) => @@ -1060,11 +1250,12 @@ const composio: Spec = { auth: (key) => ({ "x-api-key": key }), body: "json", check: composioScope, + computers: "rules", timeoutMs: 5 * 60_000, rules: [ - rule("GET api/v3.1/toolkits"), - rule("GET api/v3.1/toolkits/:toolkit"), - rule("GET api/v3.1/tools/:tool"), + rule("GET api/v3.1/toolkits", { computer: true }), + rule("GET api/v3.1/toolkits/:toolkit", { computer: true }), + rule("GET api/v3.1/tools/:tool", { computer: true }), rule("GET api/v3.1/auth_configs", { json: usableAuthConfigsOnly }), rule("POST api/v3.1/auth_configs", { check: signInSetupWithoutSecrets, json: recordSignInSetup }), rule("GET api/v3.1/connected_accounts", { @@ -1075,27 +1266,32 @@ const composio: Spec = { json: async (call, data) => { const me = composioUserId(call.user.id); const items = isObject(data) && Array.isArray(data.items) ? data.items : []; - const mine = items.filter((a) => isObject(a) && a.user_id === me && typeof a.id === "string") as { id: string }[]; + // A computer sees only the accounts its bots were given. + const given = call.user.computer ? grantsOn((await viewOf(call)).state, call.user.computer.desktopId) : null; + const mine = items.filter((a) => isObject(a) && a.user_id === me && typeof a.id === "string" && (!given || given.has(a.id))) as { id: string }[]; for (const a of mine) await own(call, "composio", "connected_account", a.id); return { ...(isObject(data) ? data : {}), items: mine.map((a) => masked(a)) }; }, + computer: true, }), // Connecting an account: only with a sign-in setup a Mac may use, and another account in the same app is fine. rule("POST api/v3.1/connected_accounts", { check: both(asUser(["connection"]), usableSignIns(true)), json: recordAccount("id") }), rule("POST api/v3.1/connected_accounts/link", { check: both(asUser([]), usableSignIns(true)), json: recordAccount("connected_account_id") }), - rule("GET api/v3.1/connected_accounts/:account", { own: ["account"], json: (_call, data) => masked(data) }), + rule("GET api/v3.1/connected_accounts/:account", { own: ["account"], json: (_call, data) => masked(data), computer: computerAccount }), rule("DELETE api/v3.1/connected_accounts/:account", { own: ["account"] }), rule("POST api/v3.1/tool_router/session", { check: both(asUser([]), usableSignIns(false)), + computer: computerSession, json: async (call, data) => { const id = isObject(data) ? data.session_id : undefined; if (typeof id === "string") await own(call, "composio", "tool_router_session", id, undefined, call.bot); }, }), - rule("GET api/v3.1/tool_router/session/:session", { own: ["session"] }), - rule("POST api/v3.1/tool_router/session/:session/search", { own: ["session"] }), + rule("GET api/v3.1/tool_router/session/:session", { own: ["session"], computer: async (call) => void (await computerSessionBot(call)) }), + rule("POST api/v3.1/tool_router/session/:session/search", { own: ["session"], computer: async (call) => void (await computerSessionBot(call)) }), rule("POST api/v3.1/tool_router/session/:session/execute", { own: ["session"], + computer: computerSessionAction, done: countComposio(sessionTool), // Which of the session's accounts to use: only one of the user's. check: async (call) => { @@ -1106,7 +1302,7 @@ const composio: Spec = { // An app's own API through the user's account (Slack's chat.postMessage and auth.test, an app's "who am I"). rule("POST api/v3.1/tools/execute/proxy", { check: namesAccount, done: countComposio(() => "proxy") }), // One action in one of the user's accounts, or in an app that needs no account. - rule("POST api/v3.1/tools/execute/:tool", { check: asUser([]), done: countComposio((call) => call.params.tool) }), + rule("POST api/v3.1/tools/execute/:tool", { check: asUser([]), done: countComposio((call) => call.params.tool), computer: computerAction }), // Triggers (Slack messages for channels.ts): only on the user's own accounts. Their live delivery // (triggers.subscribe, a Pusher channel for the whole project) isn't passed through at all. rule("GET api/v3.1/triggers_types/:trigger"), @@ -1127,6 +1323,52 @@ const composio: Spec = { ], }; +/* ---------------- AgentMail (a computer's runner) ---------------- */ + +/** + * A computer's mail (lib/server/mail.ts on a runner: a routine's email to the user, a bot's check_email, + * read_email, send_email and reply_email): its session has no pod key (session.ts), so it comes here, + * sent on with the user's pod key, and only in the inboxes of the bots on that computer (computer-scope.ts + * inboxesOn), never another's, a new inbox, a domain or a key. A Mac keeps using its pod key. + */ +async function computerInbox(call: Call) { + if (!call.user.computer) throw new HttpError(403, "Your Mac reaches its mail with its own key."); + const v = await viewOf(call); + const named = [call.params.inbox, ...[...queryValues(call.query), ...keysIn(call.json)].filter((x) => x.key === "inboxid" || x.key === "inboxids").flatMap((x) => idsIn(x.value))]; + if (named.some((id) => !v.inboxes.has(id))) throw notForComputer("mail only in the inboxes of the bots on this computer."); +} + +async function podKeyFor(call: Call): Promise { + const key = await podKeyOf(call.user.id); + if (!key) throw new HttpError(409, "Your email isn't set up yet. Restart Bops on your Mac and try again."); + return key; +} + +/** Exactly what lib/server/mail.ts does in a bot's inbox (the agentmail SDK's paths). */ +const agentmail: Spec = { + name: "AgentMail", + key: config.agentmailKey, + keyFor: podKeyFor, + upstream: config.upstream.agentmail, + auth: (key) => ({ authorization: `Bearer ${key}` }), + body: "json", + computers: "all", + check: computerInbox, + timeoutMs: 2 * 60_000, + rules: [ + rule("GET v0/inboxes/:inbox/messages"), + // Before ":message", which it would otherwise be taken for. + rule("GET v0/inboxes/:inbox/messages/search"), + rule("GET v0/inboxes/:inbox/messages/:message"), + rule("GET v0/inboxes/:inbox/messages/:message/attachments/:attachment"), + rule("POST v0/inboxes/:inbox/messages/send"), + rule("POST v0/inboxes/:inbox/messages/:message/reply"), + rule("POST v0/inboxes/:inbox/messages/:message/reply-all"), + rule("GET v0/inboxes/:inbox/threads/:thread"), + rule("GET v0/inboxes/:inbox/threads/:thread/attachments/:attachment"), + ], +}; + /* ---------------- Typesafe ---------------- */ /** @@ -1140,6 +1382,7 @@ const typesafe: Spec = { upstream: config.upstream.typesafe, auth: (key) => ({ authorization: `Bearer ${key}` }), body: "json", + computers: "all", timeoutMs: 60_000, rules: [ rule("POST v1/systemone", { @@ -1320,11 +1563,12 @@ const treg: Spec = { upstream: config.upstream.treg, auth: (key) => ({ "x-treg-token": key }), body: "json", + computers: "all", timeoutMs: 2 * 60_000, rules: [rule("GET call/:endpoint", TREG_CALL), rule("POST call/:endpoint", TREG_CALL)], }; -const SPECS: Record = { openai, agentphone, honcho, composio, typesafe, treg }; +const SPECS: Record = { openai, agentphone, honcho, composio, typesafe, treg, agentmail }; /* ---------------- Sending on ---------------- */ @@ -1626,12 +1870,14 @@ async function handle(provider: Provider, prefix: string, req: IncomingMessage, /** handle's work once the request is read: its checks, the credit, and sending it on and answering. */ async function sendOn(provider: Provider, spec: Spec, key: string, r: Rule, call: Call, req: IncomingMessage, res: ServerResponse, user: CloudUser, body: IncomingMessage | null) { + if (user.computer) await forComputer(spec, r, call); await spec.check?.(call); for (const name of r.own ?? []) await mustOwn(provider, call, call.params[name]); await r.check?.(call); // Last before it's sent: a call that isn't allowed is refused for that, not for the credit. if (r.spends) await requireCredit(user.id, await r.minCost?.(call)); await r.before?.(call); + if (spec.keyFor) key = await spec.keyFor(call); // A turn that doesn't start gives back what was set aside for it (turn-guard.ts admitTurn). let up: IncomingMessage; try { @@ -1883,8 +2129,12 @@ function responsesBridge(client: WebSocket, { ws: upstream, early, keep }: Upstr const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; +/** + * Every provider's routes take a computer's runner as far as the server (Route.computer): what it may + * call there is each provider's (Spec.computers, Rule.computer, forComputer), deny by default. + */ const proxyRoutes = (provider: Provider, prefix: string, path = `${prefix}/*`): Route[] => - METHODS.map((method) => ({ method, path, auth: "user", handle: (req, res, { user }) => handle(provider, prefix, req, res, user!) })); + METHODS.map((method) => ({ method, path, auth: "user", computer: true as const, handle: (req, res, { user }) => handle(provider, prefix, req, res, user!) })); export const routes: Route[] = [ ...(Object.keys(SPECS) as Provider[]).flatMap((p) => proxyRoutes(p, `/proxy/${p}`)), @@ -1895,6 +2145,7 @@ export const routes: Route[] = [ export const upgrades: Upgrade[] = [ { path: "/proxy/openai/*", + computer: true, handle: (req, socket, head, { user }) => { // Node takes its own error handler off an upgrading socket: without one, a Mac dropping off mid-handshake would crash the process. socket.on("error", () => socket.destroy()); diff --git a/cloud/public/brand/bops-1024.png b/cloud/public/brand/bops-1024.png index aa1878a4..44933269 100644 Binary files a/cloud/public/brand/bops-1024.png and b/cloud/public/brand/bops-1024.png differ diff --git a/cloud/public/brand/bops-512.png b/cloud/public/brand/bops-512.png index 5acf70a1..37eab0bb 100644 Binary files a/cloud/public/brand/bops-512.png and b/cloud/public/brand/bops-512.png differ diff --git a/cloud/public/mascot/main-0A0A0A.jpg b/cloud/public/mascot/main-0A0A0A.jpg index 1bf53a8f..521fa4d9 100644 Binary files a/cloud/public/mascot/main-0A0A0A.jpg and b/cloud/public/mascot/main-0A0A0A.jpg differ diff --git a/cloud/public/mascot/main-0A0A0A.png b/cloud/public/mascot/main-0A0A0A.png index 39b7a3fd..b84c99a6 100644 Binary files a/cloud/public/mascot/main-0A0A0A.png and b/cloud/public/mascot/main-0A0A0A.png differ diff --git a/cloud/server.ts b/cloud/server.ts index 9e5c86a3..8ce2a696 100644 --- a/cloud/server.ts +++ b/cloud/server.ts @@ -2,15 +2,18 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import { fileURLToPath } from "node:url"; import * as agent from "./agent.ts"; import { appVersionOf, fromIphone, noteAppVersion, noteIphoneVersion, requireAppVersion, requireIphoneVersion, startAppPolicy } from "./app-version.ts"; -import { requireUser, type CloudUser } from "./auth.ts"; +import { bearer, computerSeenAt, requireUser, userForKey, type CloudUser } from "./auth.ts"; import { captureCloudException, inTelemetryScope, shutdownTelemetry } from "./analytics.ts"; import { config } from "./config.ts"; import { checkCreditAccess } from "./credit.ts"; import { migrate, query } from "./db.ts"; import { HttpError, matches, refuseUpgrade, sendJson, type Route, type Upgrade } from "./http.ts"; +import { BOPS_DEVICE_HEADER, COMPUTER_NOT_ALLOWED } from "./protocol.ts"; import * as handles from "./handles.ts"; import * as hooks from "./hooks.ts"; import * as lines from "./lines.ts"; +import * as live from "./live.ts"; +import * as members from "./members.ts"; import * as notices from "./notices.ts"; import * as ops from "./ops.ts"; import * as pages from "./pages.ts"; @@ -28,7 +31,7 @@ import * as verify from "./verify.ts"; /** * Bops Cloud. One process serves every user: each module owns its routes (session.ts, proxy.ts, * verify.ts, lines.ts, handles.ts, plans.ts, ops.ts, tunnel.ts, hooks.ts, slack.ts, state.ts, pages.ts, - * usage.ts, agent.ts) and this file only puts them together. See README.md. + * usage.ts, agent.ts, live.ts, members.ts) and this file only puts them together. See README.md. */ const health: Route = { @@ -62,6 +65,8 @@ export const routes = (): Route[] => [ ...usage.routes, ...notices.routes, ...agent.routes, + ...live.routes, + ...members.routes, ]; export const upgrades = (): Upgrade[] => [...tunnel.upgrades, ...proxy.upgrades]; @@ -77,15 +82,76 @@ function requestUrl(req: IncomingMessage): URL | null { /** * Which app a signed-in call came from: kept with the user's account, and an app too old for the cloud * is told to update (app-version.ts). Bops for iPhone's version is its own: it never stands in for the Mac's. + * A Mac's app that a newer one locked out of the user's state, and that runs its routines all the same, + * is refused the proxies (state.ts behindState). A computer's runner stops its own on that. */ -function appCheck(req: IncomingMessage, userId: string, path: string) { +async function appCheck(req: IncomingMessage, user: CloudUser, path: string) { const version = appVersionOf(req); if (fromIphone(req)) { - noteIphoneVersion(userId, version); + noteIphoneVersion(user.id, version); return requireIphoneVersion(version); } - noteAppVersion(userId, version); + noteAppVersion(user.id, version); requireAppVersion(path, version); + if (!user.computer && path.startsWith("/proxy/") && (await state.behindState(req, user.id))) throw state.behindRefusal(path); +} + +/** + * A computer signed in as itself (its runner: auth.ts CloudUser.computer) calls only the routes marked + * for it (`allowed`), and only as its own device: Bops Cloud sets that from the computer's id, and a + * device header that says another is refused. That's which device wrote, and a guard against a runner + * set up wrong; it bounds nothing, as anyone with the token can work the same device out. An identity + * token Orgo named no computer for is refused everywhere. + */ +function computerCheck(req: IncomingMessage, user: CloudUser, allowed: boolean) { + const c = user.computer; + if (!c) return; + if (!c.desktopId || !allowed) throw new HttpError(403, "A computer's identity can't do this.", { code: COMPUTER_NOT_ALLOWED }); + const said = req.headers[BOPS_DEVICE_HEADER]; + const device = (Array.isArray(said) ? said[0] : said)?.trim(); + if (device && device !== c.device) throw new HttpError(403, "That device isn't this computer's.", { code: COMPUTER_NOT_ALLOWED }); +} + +/** How often a computer's open socket or long answer is looked at again (its sign-in kept 30 seconds at most: auth.ts). */ +export const computerWatch = { everyMs: 30_000, seenMs: 2 * 60_000 }; + +/** + * A computer's socket or answer still open is looked at again every 30 seconds: Orgo turning its + * credential down, or naming another computer for it (deleted, moved, joined, copied or paused), ends + * it (`lost`). A socket (`stream` false) also ends when its token does: its runner opens another on its + * next token. A long answer still coming (an agent's events) outlives its token only while that computer + * keeps signing in afresh (computerSeenAt, under 2 minutes ago), so a task's turn isn't cut at the hour. + * Returns what stops the looking (when it closes). + */ +export function watchComputer(key: string, user: CloudUser, lost: () => void, stream: boolean): () => void { + const c = user.computer!; + let over = false; + const stop = () => { + over = true; + clearInterval(every); + if (cut) clearTimeout(cut); + }; + const end = () => { + if (over) return; + stop(); + lost(); + }; + const look = async () => { + if (over) return; + if (Date.now() < c.expiresAt) { + // Orgo out of reach says nothing either way: looked at again next time. + const now = await userForKey(key).catch(() => undefined); + if (now === null || (now && now.computer?.desktopId !== c.desktopId)) end(); + return; + } + if (!stream || Date.now() - computerSeenAt(user.id, c.desktopId) > computerWatch.seenMs) end(); + }; + const every = setInterval(() => void look(), computerWatch.everyMs); + every.unref?.(); + const left = c.expiresAt - Date.now(); + const cut = Number.isFinite(left) ? setTimeout(() => void look(), Math.min(Math.max(0, left), 2 ** 31 - 1)) : undefined; + cut?.unref?.(); + return stop; } export function makeServer(): Server { @@ -99,7 +165,10 @@ export function makeServer(): Server { try { if (!route) throw new HttpError(404, "Not found"); user = route.auth === "user" ? await requireUser(req) : null; - if (user) appCheck(req, user.id, url.pathname); + if (user) computerCheck(req, user, !!route.computer); + if (user) await appCheck(req, user, url.pathname); + // A computer's answer that's still coming (a stream) is held to its computer while it lasts. + if (user?.computer) res.on("close", watchComputer(bearer(req), user, () => res.destroy(), true)); const as = user; // What the call says about usage events goes with everything its handler does (analytics.ts). await inTelemetryScope(req, () => route.handle(req, res, { user: as, url })); @@ -124,8 +193,12 @@ export function makeServer(): Server { if (fromIphone(req)) return refuseUpgrade(socket, 403, "Forbidden"); try { const user = await requireUser(req); - appCheck(req, user.id, url.pathname); + // The tunnel is one per user: a computer there would drop the user's Mac. + computerCheck(req, user, !!up.computer); + await appCheck(req, user, url.pathname); up.handle(req, socket, head, { user, url }); + // A computer's socket is held to its computer, and to its token's end, while it's open. + if (user.computer) socket.on("close", watchComputer(bearer(req), user, () => socket.destroy(), false)); } catch (e) { refuseUpgrade(socket, e instanceof HttpError ? e.status : 500, e instanceof HttpError ? e.message.replace(/[^\x20-\x7e]/g, "") : "Error"); } @@ -168,6 +241,8 @@ async function main() { stopStateSweeps(); stopAppPolicy(); tunnel.closeAll(); + // Every live stream ends: each device reconnects to the next start, and is reset (live.ts). + live.closeAll(); // Usage events still queued go out first (up to 2 seconds). void shutdownTelemetry().finally(() => server.close(() => process.exit(0))); setTimeout(() => process.exit(0), 5_000).unref(); diff --git a/cloud/session.ts b/cloud/session.ts index 9036b9a9..2b1879d0 100644 --- a/cloud/session.ts +++ b/cloud/session.ts @@ -170,6 +170,12 @@ async function makePodKey(podId: string, userId: string): Promise { return r.api_key; } +/** The user's pod key as kept, or null before their first session made one (proxy.ts sends a computer's mail on with it). */ +export async function podKeyOf(userId: string): Promise { + const r = await query<{ sealed: string | null }>("SELECT agentmail_key_sealed AS sealed FROM bops.cloud_accounts WHERE user_id = $1", [userId]); + return keptKey(r.rows[0]?.sealed ?? null, userId); +} + /** The pod key kept for this user, or null when there's none or it can't be opened (BOPS_CLOUD_SECRET changed): then a new one is made. */ function keptKey(sealed: string | null, userId: string) { if (!sealed) return null; @@ -262,7 +268,11 @@ function executorKey() { return key || null; } -/** Make whatever the user doesn't have yet. A provider that fails leaves the rest saved, and the call answers 502 so the Mac tries again. */ +/** + * Make whatever the user doesn't have yet. A provider that fails leaves the rest saved, and the call answers 502 so the Mac tries again. + * A computer's runner (auth.ts CloudUser.computer) never gets the pod key, which reaches every inbox of + * the user's and lives on past the computer: its mail goes through /proxy/agentmail, its bots' inboxes only. + */ async function setUp(user: CloudUser): Promise { await ensureUserRow(user.id); await welcome(user.id); @@ -320,7 +330,7 @@ async function setUp(user: CloudUser): Promise { publicUrl, agentmail: row.agentmail_pod_id && mailKey - ? { podId: row.agentmail_pod_id, apiKey: mailKey, domain: await mailDomain(), handle: handles[MAIN_WORKSPACE]?.handle ?? null, handles } + ? { podId: row.agentmail_pod_id, apiKey: user.computer ? "" : mailKey, ...(user.computer ? { proxied: true as const } : {}), domain: await mailDomain(), handle: handles[MAIN_WORKSPACE]?.handle ?? null, handles } : null, agentphone: config.agentphoneKey() && row.agentphone_sub_account ? { subAccountId: row.agentphone_sub_account, hookUrl: publicUrl ? `${publicUrl}/hooks/agentphone` : "" } : null, honcho: config.honchoKey() ? { workspacePrefix: honchoPrefix(user.id) } : null, @@ -345,6 +355,8 @@ export const routes: Route[] = [ method: "POST", path: "/v1/session", auth: "user", + // A computer's runner too: the services its own tasks use (no mail key: setUp), and the executor key it hands its computer's Codex. + computer: true, handle: async (req, res, { user }) => { sendJson(res, 200, await setUp(user!)); // The plan, asked of orgo-web with the user's own key, and what it brings set up: after the answer, never holding it up. diff --git a/cloud/state.ts b/cloud/state.ts index 1505b225..98653bf2 100644 --- a/cloud/state.ts +++ b/cloud/state.ts @@ -6,11 +6,15 @@ import type { CloudUser } from "./auth.ts"; import { query, tx } from "./db.ts"; import { HttpError, readBody, sendJson, type Route } from "./http.ts"; import { afterStateUpload } from "./plans.ts"; +import { appVersionOf, olderThan } from "./app-version.ts"; +import { botIdsOn, chatsOn, computersMessage, runsBy, threadsOf, writeDenials } from "./computer-scope.ts"; import { BOPS_DEVICE_HEADER, + COMPUTER_NOT_ALLOWED, BOPS_PROTOCOL_HEADER, BOPS_STATE_LEVEL_HEADER, BOPS_USER_HEADER, + ROUTINES_STOP_LEVEL, STATE_CONFLICT, STATE_PROTOCOL, STATE_TOO_NEW, @@ -23,6 +27,7 @@ import { type CloudStateHead, } from "./protocol.ts"; import { dropOlderMac, notifyMac } from "./tunnel.ts"; +import { messagesWritten, stateWritten } from "./live.ts"; import { forgetTelemetryChoice } from "./analytics.ts"; /** @@ -33,16 +38,18 @@ import { forgetTelemetryChoice } from "./analytics.ts"; * one (POST /v1/messages); everything else is one blob in bops.app_state (PUT /v1/state), written only * over the version it was read at, so two Macs of one user can't write over each other: the second * gets 409 with what's there now, merges, and writes again. After every write the user's connected Mac - * hears of it (a "state" frame), and the others look now and then (GET /v1/state/head). + * hears of it (a "state" frame), every device watching one of the user's workspaces live hears of it + * when that workspace's part changed (live.ts), and the others look now and then (GET /v1/state/head). * * Builds from before upload the whole state, messages and all, as a backup (CloudStateBody). They're * still taken until a newer build writes for the user (the row's protocol becomes 2); then they're * refused (426), so an old build can't write over the newer one's work. Their GET gets the messages * put back in. * - * Each app also says which state it may be given (BOPS_STATE_LEVEL_HEADER, 3 from 0.0.29 on; none is - * 2), and the row keeps the highest that wrote (its protocol). Once that's above an app's, every call - * of that app's for the user's state is refused (426 STATE_TOO_NEW), reads and merges as well as + * Each app also says which state it may be given (BOPS_STATE_LEVEL_HEADER, 3 or more from 0.0.29 on, 4 + * once routines may run on a cloud runner; none is 2), and the row keeps the highest that wrote (its + * protocol). Once that's above an app's, every call of that app's for the user's state is refused + * (426 STATE_TOO_NEW), reads and merges as well as * writes, but for its copy before Start over (postBackup), and so is its tunnel (tunnel.ts: one it * holds when the row goes above it is closed), and its notices say to update (notices.ts): 0.0.28 runs a * routine that runs every so often or before meetings over and over, a monthly one daily, and lists a @@ -55,6 +62,11 @@ import { forgetTelemetryChoice } from "./analytics.ts"; * * The cloud writes messages of its own too (writeMessages): one sent from the phone and the main * bot's answer to it (cloud/agent.ts), read from here by the user's Macs like any other. + * + * A computer's runner (auth.ts CloudUser.computer) writes only what computer-scope.ts lets it: its own + * threads, its bots' routines' runs and how its bots are doing (a write that changes anything else is 403 + * computer_not_allowed, with what it was), never a state its user hasn't got yet, and chat messages only + * in its bots' chats, the only ones it reads too. */ /** A state upload or a batch of messages, as sent and once unzipped. */ @@ -127,10 +139,16 @@ async function liveMessages(userId: string): Promise[]> return r.rows.map((x) => x.json); } -/** The user's Mac hears that their state changed (it reads what did). Best effort, after the write. */ -function announce(userId: string) { +/** + * The user's Mac hears that their state changed (it reads what did). Best effort, after the write. With + * `messages` (the chats written into, by whom), the workspaces watched live that hold them hear it too. + */ +function announce(userId: string, messages?: { chats: string[]; by: string | null }) { void head(userId) - .then((h) => notifyMac(userId, { t: "state", version: h.version, seq: h.seq })) + .then((h) => { + notifyMac(userId, { t: "state", version: h.version, seq: h.seq }); + if (messages) messagesWritten(userId, messages.chats, h.seq, messages.by); + }) .catch(() => {}); } @@ -160,6 +178,51 @@ export async function stateTooNew(req: IncomingMessage, userId: string): Promise return (r.rows[0]?.protocol ?? 0) > stateLevelOf(req); } +/** Each user's state level (app_state.protocol) as last read for behindState. A level only goes up, so one read under a minute ago does. */ +const levels = new Map(); +const LEVEL_FRESH_MS = 60_000; + +/** The first release at ROUTINES_STOP_LEVEL. */ +const ROUTINES_STOP_VERSION = "0.0.30"; + +/** + * Whether the call is from a Mac app that goes on with its routines when it's refused the user's state + * (below ROUTINES_STOP_LEVEL). Its level when it says one; else its version, as 0.0.29's OpenAI and treg + * calls say only that. A call that says neither isn't taken for one (block_below keeps those out). + */ +function runsOnWhenRefused(req: IncomingMessage): boolean { + if (headerOf(req, BOPS_STATE_LEVEL_HEADER)) return stateLevelOf(req) < ROUTINES_STOP_LEVEL; + const version = appVersionOf(req); + return !!version && olderThan(version, ROUTINES_STOP_VERSION); +} + +/** + * Whether the call is from an app that can't be given the user's state any more because an app at + * ROUTINES_STOP_LEVEL wrote it, and that doesn't stop its routines for that (it goes on from its copy in + * memory, running what the newer app or a computer's runner runs too). Such an app is refused the + * proxies (server.ts appCheck), so its runs fail instead of running a second time; its state's calls and + * its notices still answer, and those say to update. A user whose Macs are all older (state at level 3 + * or below) is never refused here. A failed read refuses nothing. + */ +export async function behindState(req: IncomingMessage, userId: string): Promise { + if (!runsOnWhenRefused(req)) return false; + const marked = (protocol: number) => protocol >= ROUTINES_STOP_LEVEL; + const known = levels.get(userId); + if (known && (marked(known.protocol) || Date.now() - known.at < LEVEL_FRESH_MS)) return marked(known.protocol); + const r = await query<{ protocol: number }>("SELECT protocol FROM bops.app_state WHERE user_id = $1", [userId]).catch(() => null); + if (!r) return false; + const protocol = r.rows[0]?.protocol ?? 0; + if (levels.size > 50_000) levels.clear(); + levels.set(userId, { protocol, at: Date.now() }); + return marked(protocol); +} + +/** What an app behindState refuses is told: the same as its state's calls, as OpenAI's SDK reads it through /proxy/openai. */ +export function behindRefusal(path: string): HttpError { + const extra = path.startsWith("/proxy/openai/") ? { error: { message: TOO_NEW, code: STATE_TOO_NEW } } : {}; + return new HttpError(426, TOO_NEW, { code: STATE_TOO_NEW, ...extra }); +} + /** The same, refused: for the calls whose answer holds none of the blob, where a write landing just after the check doesn't matter. */ async function requireLevelFor(req: IncomingMessage, userId: string) { if (await stateTooNew(req, userId)) throw new HttpError(426, TOO_NEW, { code: STATE_TOO_NEW }); @@ -268,6 +331,22 @@ async function lockUser(c: pg.PoolClient, userId: string) { return { version: Number(r.rows[0].version), protocol: r.rows[0].protocol }; } +/** The blob on the user's locked row, as kept. */ +async function lockedState(c: pg.PoolClient, userId: string): Promise> { + const r = await c.query<{ state: Record | null }>("SELECT state FROM bops.app_state WHERE user_id = $1", [userId]); + return r.rows[0]?.state ?? {}; +} + +/** A computer's write refused: what it would have changed that it may not (the first 20). */ +const notForComputer = (paths: string[]) => + new HttpError(403, "A computer's identity can't change that.", { code: COMPUTER_NOT_ALLOWED, paths: paths.slice(0, 20) }); + +/** The chats a computer reads and writes in (computer-scope.ts chatsOn), from its user's state now. */ +async function computerChats(user: CloudUser): Promise> { + const row = await readRow(user.id); + return chatsOn(row?.state ?? {}, user.computer!.desktopId); +} + /* ---------------- The cloud's own messages, and one chat's (cloud/agent.ts) ---------------- */ /** @@ -283,7 +362,7 @@ export async function writeMessages(userId: string, messages: Record m.chatId), by: "cloud" }); } /** A message's row as kept: its JSON, or null when it was removed (the chat it was in stays). */ @@ -303,20 +382,50 @@ export async function rowsAfter(userId: string, after: number, limit: number): P return { rows, seq: rows.length ? rows[rows.length - 1].seq : after, more: r.rows.length > limit }; } -/** One chat's newest `limit` live messages, or the newest before `beforeAt`, oldest first; `more`: there are older ones. */ -export async function chatPage(userId: string, chatId: string, beforeAt: number | null, limit: number): Promise<{ rows: MessageRow[]; more: boolean }> { +/** + * One chat's newest `limit` live messages, or the newest before `beforeAt`, oldest first; `more`: there are + * older ones. `beforeId`: the message of that chat at `beforeAt` the page comes before, in the order pages + * are cut in (at, then seq), so one in its millisecond that comes before it is in the page; without it (or + * when there's no such message), the ones before that millisecond. + */ +export async function chatPage(userId: string, chatId: string, beforeAt: number | null, limit: number, beforeId?: string): Promise<{ rows: MessageRow[]; more: boolean }> { const r = beforeAt === null ? await query(`SELECT ${ROW} FROM bops.chat_messages WHERE user_id = $1 AND chat_id = $2 AND json IS NOT NULL ORDER BY at DESC, seq DESC LIMIT $3`, [userId, chatId, limit + 1]) - : await query(`SELECT ${ROW} FROM bops.chat_messages WHERE user_id = $1 AND chat_id = $2 AND json IS NOT NULL AND at < $3 ORDER BY at DESC, seq DESC LIMIT $4`, [ - userId, - chatId, - beforeAt, - limit + 1, - ]); + : await query( + `SELECT ${ROW} FROM bops.chat_messages WHERE user_id = $1 AND chat_id = $2 AND json IS NOT NULL + AND (at < $3 OR (at = $3 AND seq < (SELECT seq FROM bops.chat_messages WHERE user_id = $1 AND chat_id = $2 AND id = $5 AND at = $3))) + ORDER BY at DESC, seq DESC LIMIT $4`, + [userId, chatId, beforeAt, limit + 1, beforeId ?? null], + ); return { rows: r.rows.slice(0, limit).map(rowOf).reverse(), more: r.rows.length > limit }; } +/** + * The newest `perChat` live messages of each of these chats, oldest first, and the chats that have older + * ones (a member reading a shared workspace: members.ts). One query, whatever the number of chats. + */ +export async function recentMessages(userId: string, chatIds: string[], perChat: number): Promise<{ rows: MessageRow[]; more: string[] }> { + if (!chatIds.length) return { rows: [], more: [] }; + const r = await query( + `SELECT ${ROW}, n, total FROM ( + SELECT ${ROW}, row_number() OVER w AS n, count(*) OVER (PARTITION BY chat_id) AS total + FROM bops.chat_messages WHERE user_id = $1 AND chat_id = ANY($2::text[]) AND json IS NOT NULL + WINDOW w AS (PARTITION BY chat_id ORDER BY at DESC, seq DESC) + ) t WHERE n <= $3 ORDER BY at, seq`, + [userId, chatIds, perChat], + ); + const more = new Set(r.rows.filter((x) => Number(x.total) > perChat).map((x) => x.chat_id)); + return { rows: r.rows.map(rowOf), more: [...more] }; +} + +/** The newest message write (a removal too) in these chats' rows (0: none): what a member's copy of them is as new as. */ +export async function chatsSeq(userId: string, chatIds: string[]): Promise { + if (!chatIds.length) return 0; + const r = await query<{ seq: string }>("SELECT COALESCE(max(seq), 0) AS seq FROM bops.chat_messages WHERE user_id = $1 AND chat_id = ANY($2::text[])", [userId, chatIds]); + return Number(r.rows[0].seq); +} + /** * What a bot reads of a chat (lib/server/chat.ts history): its messages, without the system lines that * aren't an email, a text or an app's answer. The newest `limit` of them, oldest first, and how many @@ -365,8 +474,11 @@ const get: Route = { method: "GET", path: "/v1/state", auth: "user", + computer: true, handle: async (req, res, { user }) => { const v2 = newBuild(req); + // A build from before gets every message with the state: never a computer. + if (user!.computer && !v2) throw notForComputer(["messages"]); checkUser(req, user!, v2); const row = await readRow(user!.id); // Held to the row as read, so a newer app's write can't slip in between. @@ -386,6 +498,7 @@ const getHead: Route = { method: "GET", path: "/v1/state/head", auth: "user", + computer: true, handle: async (req, res, { user }) => { checkUser(req, user!, true); await requireLevelFor(req, user!.id); @@ -398,7 +511,8 @@ async function putOver(req: IncomingMessage, res: ServerResponse, user: CloudUse checkUser(req, user, true); if (!wholeNumber(base)) throw new HttpError(400, "base must be a whole number, 0 or more"); const json = toJson(blobOf(state)); - const writer = deviceOf(req); + // A computer's runner writes as its own device, which Bops Cloud sets (server.ts computerCheck). + const writer = user.computer?.device || deviceOf(req); const level = stateLevelOf(req); let raised = false; const version = await tx(async (c) => { @@ -406,6 +520,11 @@ async function putOver(req: IncomingMessage, res: ServerResponse, user: CloudUse // Before the version: an older app is never handed the newer one's state to merge (409). requireLevel(req, row.protocol); if (row.version !== base) return null; + // A computer's write: only what its runner's own work changes, over the blob it read (computer-scope.ts). + if (user.computer) { + const denied = writeDenials(await lockedState(c, user.id), JSON.parse(json) as Record, user.computer); + if (denied.length) throw notForComputer(denied); + } const r = await c.query<{ version: string }>( `UPDATE bops.app_state SET state = $2::jsonb, version = version + 1, protocol = GREATEST(protocol, $3), writer = $4, updated_at = now() WHERE user_id = $1 RETURNING version`, [user.id, json, level, writer], @@ -422,6 +541,8 @@ async function putOver(req: IncomingMessage, res: ServerResponse, user: CloudUse // An older Mac of the user's on the tunnel now can't be given this state: off it, before it hears of it. if (raised) dropOlderMac(user.id, level); announce(user.id); + // Devices watching a workspace whose part of it changed read it now (live.ts). + stateWritten(user.id, state, writer); // A paid plan that waited to know the main bot is set up now (plans.ts). afterStateUpload(user.id); forgetTelemetryChoice(user.id); @@ -429,6 +550,8 @@ async function putOver(req: IncomingMessage, res: ServerResponse, user: CloudUse /** A build from before: its whole state, messages and all, the Mac's own `version` kept as given. */ async function putBackup(req: IncomingMessage, res: ServerResponse, user: CloudUser, version: unknown, state: Record) { + // The whole state, messages and all, from a build from before: never a computer. + if (user.computer) throw notForComputer(["state"]); checkUser(req, user, false); if (!wholeNumber(version)) throw new HttpError(400, "version must be a whole number, 0 or more"); const list = Array.isArray(state.messages) ? state.messages.filter((m) => isObject(m) && typeof m.id === "string" && m.id).map(messageOf) : []; @@ -445,7 +568,8 @@ async function putBackup(req: IncomingMessage, res: ServerResponse, user: CloudU ); }); sendJson(res, 200, { ok: true, version }); - announce(user.id); + announce(user.id, { chats: list.map((m) => m.chatId), by: null }); + stateWritten(user.id, state, null); afterStateUpload(user.id); forgetTelemetryChoice(user.id); } @@ -454,6 +578,7 @@ const put: Route = { method: "PUT", path: "/v1/state", auth: "user", + computer: true, handle: async (req, res, { user }) => { const body = jsonOf | null>(await bodyOf(req)); const state = body?.state; @@ -467,6 +592,7 @@ const getMessages: Route = { method: "GET", path: "/v1/messages", auth: "user", + computer: true, handle: async (req, res, { user, url }) => { checkUser(req, user!, true); await requireLevelFor(req, user!.id); @@ -474,14 +600,18 @@ const getMessages: Route = { const limit = Number(url.searchParams.get("limit") ?? PAGE); if (!wholeNumber(after)) throw new HttpError(400, "after must be a whole number, 0 or more"); if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE) throw new HttpError(400, `limit must be 1 to ${MAX_PAGE}`); - const r = await query<{ id: string; seq: string; json: Record | null }>( - "SELECT id, seq, json FROM bops.chat_messages WHERE user_id = $1 AND seq > $2 ORDER BY seq LIMIT $3", + const r = await query<{ id: string; seq: string; chat_id: string; json: Record | null }>( + "SELECT id, seq, chat_id, json FROM bops.chat_messages WHERE user_id = $1 AND seq > $2 ORDER BY seq LIMIT $3", [user!.id, after, limit + 1], ); const more = r.rows.length > limit; const rows = r.rows.slice(0, limit); + // A computer reads its bots' chats only; the cursor still goes past the rest. + const chats = user!.computer ? await computerChats(user!) : null; // From the start, what was removed is of no use; from a seq on, it's how a Mac hears a message went. - const messages: CloudMessageRow[] = rows.filter((x) => after > 0 || x.json !== null).map((x) => ({ id: x.id, seq: Number(x.seq), json: x.json })); + const messages: CloudMessageRow[] = rows + .filter((x) => (after > 0 || x.json !== null) && (!chats || chats.has(x.chat_id))) + .map((x) => ({ id: x.id, seq: Number(x.seq), json: x.json })); const body: CloudMessagesPage = { messages, seq: rows.length ? Number(rows[rows.length - 1].seq) : after, more }; await sendZipped(req, res, 200, body); }, @@ -491,6 +621,7 @@ const postMessages: Route = { method: "POST", path: "/v1/messages", auth: "user", + computer: true, handle: async (req, res, { user }) => { checkUser(req, user!, true); const body = jsonOf | null>(await bodyOf(req)); @@ -500,15 +631,43 @@ const postMessages: Route = { const msgs = upsert.map(messageOf); const seq = await tx(async (c) => { requireLevel(req, (await lockUser(c, user!.id)).protocol); + if (user!.computer) await computerMayWrite(c, user!, msgs, remove as string[]); await upsertMessages(c, user!.id, msgs); await removeMessages(c, user!.id, remove as string[]); return lastSeq(user!.id, c); }); sendJson(res, 200, { seq }); - if (msgs.length || remove.length) announce(user!.id); + // A removal's chat isn't known here (""): every watched workspace of the user's hears it. + if (msgs.length || remove.length) announce(user!.id, { chats: [...msgs.map((m) => m.chatId), ...(remove.length ? [""] : [])], by: deviceOf(req) }); }, }; +/** + * A computer writes and removes only its bots' messages and Bops' notes, in its bots' chats, or about + * threads and routines' runs of its own in theirs (computer-scope.ts computersMessage: their bot may have + * moved off it since the run began, and a run's words may come after its end is recorded): each new one, + * and each it changes or removes as it's kept, must be one. Never the user's words or another bot's, so a + * key read off a computer can't speak for them. Else 403. + */ +async function computerMayWrite(c: pg.PoolClient, user: CloudUser, msgs: Msg[], remove: string[]) { + const state = await lockedState(c, user.id); + const desktopId = user.computer!.desktopId; + const chats = chatsOn(state, desktopId); + const bots = botIdsOn(state, desktopId); + const threads = threadsOf(state, user.computer!.device); + const runs = runsBy(state, user.computer!.device); + const ids = [...new Set([...msgs.map((m) => m.id), ...remove])]; + const kept = ids.length + ? (await c.query<{ id: string; chat_id: string; json: Record | null }>("SELECT id, chat_id, json FROM bops.chat_messages WHERE user_id = $1 AND id = ANY($2::text[])", [user.id, ids])).rows + : []; + const refused = [ + ...msgs.filter((m) => !computersMessage({ ...(JSON.parse(m.json) as Record), chatId: m.chatId }, bots, chats, threads, runs)).map((m) => m.id), + // One already taken out holds only its chat. + ...kept.filter((k) => (k.json ? !computersMessage({ ...k.json, chatId: k.chat_id }, bots, chats, threads, runs) : !chats.has(k.chat_id))).map((k) => k.id), + ]; + if (refused.length) throw notForComputer([...new Set(refused)].map((id) => `messages.${id}`)); +} + /** * A copy of the whole state, messages and all, before the user starts over (Settings). A Mac kept out of * the state (stateTooNew) makes one too: the copy stays here and none of it goes back to it, and its diff --git a/cloud/style.ts b/cloud/style.ts index 0e2ed42c..86df7304 100644 --- a/cloud/style.ts +++ b/cloud/style.ts @@ -33,8 +33,11 @@ export const SPEAKING = [ /** * What Bops is and how it works, so bots explain their own app correctly. Written in the same * standard. Keep it true to what's built; the last part says what isn't, so bots never overpromise. + * `apps`: the account has Apps (only @orgo.ai ones for now: lib/apps-access.ts), so they're in it, their + * line and their tab; ABOUT_BOPS is without them. */ -export const ABOUT_BOPS = `How Bops works (use this when the user asks about Bops or one of its parts; don't recite it otherwise): +export function aboutBops(apps: boolean): string { + return `How Bops works (use this when the user asks about Bops or one of its parts; don't recite it otherwise): - Bops is the user's app for a team of bots. Each workspace's main bot (Boppy unless the user renamed it) is the chief of staff and runs the team. Each other bot has one role. - Chats: each bot has its own chat. In a group chat, Bops decides which bot answers each message, and whether anyone needs to answer at all. The user can reply to one message (an inline reply) and tapback a message. Bots can tapback too. - Threads: a long task runs as a thread. The user opens a thread to see its steps, reply in it, or stop it. Finished threads collect in the bot's Library. @@ -44,16 +47,19 @@ export const ABOUT_BOPS = `How Bops works (use this when the user asks about Bop - Autopilot, in the sidebar, lists what bots do on their own: Schedules and Monitors. The user asks a bot for one in chat, and runs, pauses, changes or deletes it there. - Schedules: a bot does a job at set times (daily, weekdays, every 2 hours from 8 AM to 10 PM, or once): a reminder, a quick check with its tools and no computer, or a task on a computer. A quiet one (a check-in) posts only when something needs the user. Results can also go by text, email, Slack, Telegram or Discord. - Monitors: the bot gives a heads-up when something needs the user on a public web page (read every few minutes, no screen: prices, news, status pages), on a signed-in site on a screen of its computer (an X inbox), or in a window on the user's Mac (a Messages conversation). A heads-up has "Show me" and, for a screen, "Draft a reply". A draft is never sent by itself. -- Skills, in Knowledge: how the workspace's bots do one kind of job (a skill) and tools Bops built that they run (an app), each a folder on the user's Mac with a SKILL.md and its files. A skill is saved when the user asks a bot to remember how, picks "Save as skill" in the chat's menu, or imports a folder. A new one is a draft until the user publishes it there, where they also pick which bots have it, roll back a version, approve a script before it runs, or open its folder in Finder. -- How I work, on a bot's profile: how the user wants that bot to work, in its chat, tasks and calls. -- Vault: the Vault tab holds Connected Apps, API keys and logins. Connected Apps connect the user's real accounts (Gmail, Notion, HubSpot and many more, with "Add an app"), more than one per app. API keys are for this workspace or all workspaces, as in Orgo's Settings, Secrets. Each bot gets read only or read and act access per account. Changes wait for the user's OK on a card, unless the bot is set to "Just do it" (Approvals, on its profile). Logins keep passwords and 2FA keys in the Mac's Keychain; Bops types them into the sign-in page, so no AI sees them. +- Skills, in Knowledge: how the workspace's bots do one kind of job (a skill) and tools Bops built that the bots run on their computers (a skill's app${apps ? ", not one of the user's Apps" : ""}), each a folder on the user's Mac with a SKILL.md and its files. A skill is saved when the user asks a bot to remember how, picks "Save as skill" in the chat's menu, or imports a folder. A new one is a draft until the user publishes it there, where they also pick which bots have it, roll back a version, approve a script before it runs, or open its folder in Finder. +${apps ? "- Apps: small tools a bot makes for the user when they ask (a tracker, a planner, a dashboard), kept on the user's Mac with their own data. The user opens them from Apps in the sidebar's footer or on a New Tab; each opens in its own tab on the right, and changes when the user asks the bot in the chat beside it.\n" : ""}- How I work, on a bot's profile: how the user wants that bot to work, in its chat, tasks and calls. +- Logins: the Logins tab holds Connected Apps, API keys and saved logins. Connected Apps connect the user's real accounts (Gmail, Notion, HubSpot and many more, with "Add an app"), more than one per app. API keys are for this workspace or all workspaces, as in Orgo's Settings, Secrets. Each bot gets read only or read and act access per account. Changes wait for the user's OK on a card, unless the bot is set to "Just do it" (Approvals, on its profile). Saved logins keep passwords and 2FA keys in the Mac's Keychain; Bops types them into the sign-in page, so no AI sees them. - Email and phone: each bot has its own email address. Each workspace's main bot has a phone number the user can text, iMessage and call; the other bots are reached through it. - Calls: the user can call a bot, in the app or on its number. The call rings first. During a call, the bot can start tasks and says the results when they finish. Video calls with bots aren't built yet: the video button on a bot's card books a 20-minute video call with the Bops team. - Slack, Telegram and Discord: the user can add a bot there like a teammate (its profile, "Where to find") and pairs once by sending the bot its code. Then it takes requests only from the user there, answers in a group or channel when mentioned or replied to, and sends results back where the user asked. -- The right side has tabs: the chat's bot's computer, bot profiles, the Vault, Your Mac, Autopilot, Knowledge (Skills is in it) and web pages. Links in chat open there, in a small browser with its own sign-ins; "Open in browser" opens the page in the user's own browser. +- The right side has tabs: the chat's bot's computer, bot profiles, Logins, Your Mac, Autopilot, Knowledge (Skills is in it)${apps ? ", Apps" : ""} and web pages. Links in chat open there, in a small browser with its own sign-ins; "Open in browser" opens the page in the user's own browser. - You can't see the Bops app itself. When the user asks about a button or part of Bops, answer from this note; don't go looking for it on a screen. - The user can delete messages, threads and bots (not the main bot). Not built yet: app events like "new email", video calls with bots, a real phone for each bot, WhatsApp, a store to share skills, and answering people other than the user in Slack, Telegram or Discord.`; +} +/** How Bops works without Apps: for every account Apps aren't open to, and Bops Cloud's phone answers. */ +export const ABOUT_BOPS = aboutBops(false); /** How a bot asks the user something so they can answer with one tap (Bops turns the line into buttons). */ export const ASKING = diff --git a/cloud/test/core-agent.test.ts b/cloud/test/core-agent.test.ts index 8892e4ef..d0cb57f2 100644 --- a/cloud/test/core-agent.test.ts +++ b/cloud/test/core-agent.test.ts @@ -152,10 +152,11 @@ async function prepareLedger() { before(async () => { await prepareDb(); - await prepareLedger(); + // Held from before the ledger is made again: its grants would otherwise give back the access core-credit's test takes away for a moment. ledger = new pg.Client({ connectionString: TEST_DATABASE_URL }); await ledger.connect(); await ledger.query("SELECT pg_advisory_lock_shared(hashtext($1))", [LEDGER_IN_USE]); + await prepareLedger(); orgo = await fakeProvider((g) => { const id = /^Bearer key-(.+)$/.exec(String(g.headers.authorization ?? ""))?.[1]; if (g.path !== "/api/user/profile" || !id) return { status: 401, json: { error: "no" } }; diff --git a/cloud/test/core-app-version.test.ts b/cloud/test/core-app-version.test.ts index 3be82cc0..d3787741 100644 --- a/cloud/test/core-app-version.test.ts +++ b/cloud/test/core-app-version.test.ts @@ -14,6 +14,8 @@ import { call, dropUsers, fakeOrgo, fakeProvider, keyOf, newUserId, prepareDb, s */ const alice = newUserId("version"); +/** Two Macs: one on this app wrote her state, the other is still on 0.0.29 (state level 3). */ +const bea = newUserId("version-behind"); let orgo: Listening, cloud: Listening, openai: Awaited>; const seen = async (userId: string) => @@ -35,6 +37,7 @@ const socketStatus = (version: string | undefined) => before(async () => { await prepareDb(); await seedUser(alice); + await seedUser(bea); orgo = await fakeOrgo(); openai = await fakeProvider((g) => (g.method === "POST" && g.path === "/v1/responses" ? { json: { id: `resp_${alice}_${Date.now()}`, object: "response", output: [], usage: null } } : { status: 418, json: {} })); Object.assign(process.env, { OPENAI_API_KEY: "sk-test-main", BOPS_UPSTREAM_OPENAI: openai.url }); @@ -54,7 +57,7 @@ beforeEach(async () => { after(async () => { await block(null); - await dropUsers([alice]); + await dropUsers([alice, bea]); await Promise.all([cloud?.close(), orgo?.close(), openai?.close()]); await closeDb(); }); @@ -135,3 +138,40 @@ test("a block that isn't a version turns nobody away, and lifting it serves ever const r = await as(undefined, "POST", "/proxy/openai/v1/responses", { model: "gpt-6.1-sol", input: "hi" }); assert.equal(r.status, 200, r.text); }); + +test("a Mac whose state a newer app wrote, on an app that doesn't stop its routines for that (0.0.29), is refused the proxies too; its state's calls and notices answer", async () => { + /** A call with these headers on top of Bea's key: the ones each app really sends. */ + const at = (headers: Record, method: string, path: string, json?: unknown) => call(cloud.url, method, path, { key: keyOf(bea), json, headers }); + const ask = (headers: Record) => at(headers, "POST", "/proxy/openai/v1/responses", { model: "gpt-6.1-sol", input: "hi" }); + // What OpenAI's SDK client says (lib/server/openai-client.ts): 0.0.29's its version only, this app's its state level too. + const sdk29 = { "x-bops-version": "0.0.29", "x-bops-source": "chat" }; + const sdk30 = { "x-bops-version": "0.0.30", "x-bops-state-level": "4", "x-bops-source": "chat" }; + // What every other call says (appHeaders): 0.0.29's level is 3. + const app29 = { "x-bops-version": "0.0.29", "x-bops-state-level": "3", "x-bops-user": bea, "x-bops-protocol": "2" }; + // The Mac on this app wrote her state. + await query("UPDATE bops.app_state SET protocol = 4 WHERE user_id = $1", [bea]); + const asked = openai.got.length; + assert.equal((await ask(sdk30)).status, 200, "the Mac on this app"); + assert.equal((await ask({ "x-bops-version": "0.0.30" })).status, 200, "this app's version alone says it stops its routines"); + // The one still on 0.0.29, as its SDK calls and as its other calls: told to update, as OpenAI's SDK reads it, and nothing reaches OpenAI. + for (const headers of [sdk29, app29]) { + const r = await ask(headers); + assert.equal(r.status, 426, `${JSON.stringify(headers)}: ${r.text}`); + assert.deepEqual(r.json, { error: { message: r.json.error.message, code: "state_too_new" }, code: "state_too_new" }); + assert.match(r.json.error.message, /^Another of your Macs is on a newer Bops\. Update Bops on this Mac/); + } + assert.equal(openai.got.length, asked + 2, "only the new Mac's reached OpenAI"); + // A call that says neither isn't taken for 0.0.29 (block_below keeps such apps out). + assert.equal((await ask({})).status, 200, "no version and no level"); + // Its state's calls are refused as before, and its notices say to update. + assert.equal((await at(app29, "GET", "/v1/state/head")).status, 426); + const notices = await at(app29, "GET", "/v1/notices"); + assert.equal(notices.status, 200, notices.text); + assert.equal(notices.json.notices[0]?.title, "Update Bops on this Mac"); + // Alice's Macs are all on 0.0.29, which wrote her state at level 3: every call of hers is served, its SDK's included. + await query("UPDATE bops.app_state SET protocol = 3 WHERE user_id = $1", [alice]); + for (const headers of [sdk29, { ...app29, "x-bops-user": alice }]) { + const r = await call(cloud.url, "POST", "/proxy/openai/v1/responses", { key: keyOf(alice), json: { model: "gpt-6.1-sol", input: "hi" }, headers }); + assert.equal(r.status, 200, `${JSON.stringify(headers)}: ${r.text}`); + } +}); diff --git a/cloud/test/core-computer-scope.test.ts b/cloud/test/core-computer-scope.test.ts new file mode 100644 index 00000000..fa2c3b7f --- /dev/null +++ b/cloud/test/core-computer-scope.test.ts @@ -0,0 +1,653 @@ +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { after, before, test } from "node:test"; +import { clearAuthCache, userForKey } from "../auth.ts"; +import { seal } from "../crypto.ts"; +import { closeDb, query } from "../db.ts"; +import { deviceIdOf } from "../device.ts"; +import { COMPUTER_NOT_ALLOWED } from "../protocol.ts"; +import { computerWatch, watchComputer } from "../server.ts"; +import { honchoPrefix } from "../session.ts"; +import { call, dropUsers, fakeProvider, listen, newUserId, prepareDb, startCloud, type Got, type Listening } from "./core-fakes.ts"; + +/** + * What a computer's runner may touch of its user's Bops (computer-scope.ts, state.ts, proxy.ts, session.ts, + * server.ts): a state write only of its runner's own work (its threads, its bots' routines' runs and how + * they're doing), never a state its user hasn't got or a whole one; messages only in its bots' chats, + * and only those read; no mail key in its session, its mail through Bops Cloud in its bots' inboxes + * only; texts only from its bots' lines to the user's verified numbers; Composio only in its bots' + * accounts (read-only ones read only), never connecting or deleting one; Honcho only in its bots' + * memory banks; its sockets and long answers held to it while they're open. + */ + +const D1 = "c1c1c1c1-0000-4000-8000-0000000000aa"; +const D2 = "c2c2c2c2-0000-4000-8000-0000000000bb"; +const POD_KEY = "am_pod_key_not_real"; +const OWNER = "+14155550199"; + +let orgo: Listening; +let cloud: Listening; +let mail: Awaited>; +let phone: Awaited>; +let composio: Awaited>; +let honcho: Awaited>; +const users: string[] = []; +/** Keys Orgo turns down from now on (a computer deleted, its token stale). */ +const turnedDown = new Set(); + +const json = (res: ServerResponse, status: number, body: unknown) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); + +/** + * Orgo's profile: `key-` the user's key, `orgo_vm_~` (or `orgo_vm_soon-…`, ending in 1.5 s) a + * computer's token, `ck-~` its key, `ck-unbound-` a computer key Orgo never bound. `#` after + * any of them is the same one again, which Bops Cloud kept no answer for: a sign-in afresh. + */ +function profile(req: IncomingMessage, res: ServerResponse) { + const sent = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1] ?? ""; + if (req.url !== "/api/user/profile" || turnedDown.has(sent)) return json(res, 401, { error: "no" }); + const key = sent.replace(/#\d+$/, ""); + let m: RegExpExecArray | null; + if ((m = /^key-(.+)$/.exec(key))) return json(res, 200, { id: m[1], email: `${m[1]}@example.com`, api_key: { id: "k0", scope: "account" } }); + if ((m = /^orgo_vm_(soon-)?([^~]+)~([^~]+)$/.exec(key))) + return json(res, 200, { id: m[2], api_key: null, principal: { kind: "vm_identity", desktop_id: m[3], expires_at: new Date(Date.now() + (m[1] ? 1500 : 3600_000)).toISOString() } }); + if ((m = /^ck-unbound-(.+)$/.exec(key))) return json(res, 200, { id: m[1], api_key: { id: "k2", scope: "account", computer_key: true } }); + if ((m = /^ck-([^~]+)~([^~]+)$/.exec(key))) return json(res, 200, { id: m[1], api_key: { id: "k1", scope: "computer", computer_id: m[2], computer_key: true } }); + json(res, 401, { error: "no" }); +} + +before(async () => { + await prepareDb(); + orgo = await listen(createServer(profile)); + process.env.BOPS_ORGO_ORIGIN = orgo.url; + mail = await fakeProvider((g) => (g.path === "/v0/domains" ? { json: { domains: [] } } : { json: { ok: true, path: g.path } })); + phone = await fakeProvider((g) => (g.method === "POST" && g.path === "/v1/messages" ? { json: { id: `ap_${phone.got.length}`, status: "queued" } } : { json: { data: [] } })); + composio = await fakeProvider((g) => { + const tool = /^\/api\/v3\.1\/tools\/([A-Z_]+)$/.exec(g.path)?.[1]; + if (g.method === "GET" && tool) return { json: { slug: tool, toolkit: { slug: tool.split("_")[0].toLowerCase() }, tags: tool.endsWith("_SEARCH") ? ["readOnlyHint"] : [] } }; + if (g.path === "/api/v3.1/tool_router/session") return { json: { session_id: `trs_${composio.got.length}` } }; + if (g.path === "/api/v3.1/connected_accounts") return { json: { items: ["acct_gmail", "acct_notion", "acct_slack"].map((id) => ({ id, user_id: `bops-${g.query.get("user_ids")?.slice(5)}`, status: "ACTIVE" })) } }; + return { json: { data: { ok: true }, successful: true } }; + }); + honcho = await fakeProvider((g) => (g.path === "/v3/workspaces/list" ? { json: { items: [{ id: g.json?.mine }, { id: g.json?.theirs }] } } : { json: {} })); + Object.assign(process.env, { + AGENTMAIL_API_KEY: "am_cloud_key_not_real", + BOPS_UPSTREAM_AGENTMAIL: mail.url, + AGENTPHONE_API_KEY: "ap_cloud_key_not_real", + BOPS_UPSTREAM_AGENTPHONE: phone.url, + COMPOSIO_API_KEY: "composio_cloud_key_not_real", + BOPS_UPSTREAM_COMPOSIO: composio.url, + HONCHO_API_KEY: "honcho_cloud_key_not_real", + BOPS_UPSTREAM_HONCHO: honcho.url, + }); + cloud = await startCloud(); +}); + +after(async () => { + await cloud.close(); + for (const f of [orgo, mail, phone, composio, honcho]) await f.close(); + await query("DELETE FROM bops.cloud_accounts WHERE user_id = ANY($1::text[])", [users]); + await query("DELETE FROM bops.chat_messages WHERE user_id = ANY($1::text[])", [users]); + await dropUsers(users); + await closeDb(); +}); + +/** Each test's longest: one that hangs fails instead of holding up node --test. */ +const T = { timeout: 60_000 }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** Whether `ok()` comes true within `ms`, looked at every 20 ms. */ +async function within(ms: number, ok: () => boolean) { + for (const end = Date.now() + ms; !ok() && Date.now() < end; ) await sleep(20); + return ok(); +} + +const vm = (u: string, d = D1) => `orgo_vm_${u}~${d}`; +const ck = (u: string, d = D1) => `ck-${u}~${d}`; +const asNew = (u: string) => ({ "x-bops-user": u, "x-bops-protocol": "2" }); + +/** + * The user's state: Boppy (main) and Max (sharing Boppy's computer, D1) in the default workspace, Iris on + * a computer of her own (D2) in a second workspace; Gmail open to the default workspace's bots, Notion + * given to Max to read only, Slack to nobody. + */ +function stateOf() { + return { + owner: { name: "Ann" }, + bots: [ + { id: "main", name: "Boppy", isMain: true, computerId: D1, computerStatus: "ready", keys: ["OPENAI_KEY"], mail: { inboxId: "inbox-main", podId: "pod1" } }, + { id: "helper", name: "Max", isMain: false, computerStatus: "ready", access: { acct_notion: "read" } }, + { id: "iris", name: "Iris", isMain: false, computer: "own", computerId: D2, computerStatus: "ready", workspaceId: "ws_two", mail: { inboxId: "inbox-iris", podId: "pod1" }, phone: "+14155550111", phoneLine: { numberId: "num-iris", agentId: "agent-iris" } }, + ], + chats: [ + { id: "bot:main", kind: "bot", botIds: ["main"], createdAt: 1, typing: [] }, + { id: "bot:iris", kind: "bot", botIds: ["iris"], createdAt: 1, typing: [] }, + { id: "group1", kind: "group", botIds: ["helper", "iris"], createdAt: 1, typing: [] }, + ], + sessions: [{ id: "s-mac", botId: "main", by: "mac-device", goal: "x", status: "done" }] as { id: string; botId: string; chatId?: string; by: string; goal: string; status: string; routineId?: string }[], + routines: [ + { id: "r-main", botId: "main", title: "Daily", goal: "Do it", schedule: { kind: "daily", times: ["09:00"] }, enabled: true, nextRunAt: 100, skips: [50, 150] }, + { id: "r-iris", botId: "iris", title: "Iris's", goal: "Hers", schedule: { kind: "daily", times: ["10:00"] }, enabled: true, nextRunAt: 100 }, + ], + accounts: [ + { id: "acct_gmail", app: "gmail", appName: "Gmail", status: "active", at: 1, workspaceId: "ws_main" }, + { id: "acct_notion", app: "notion", appName: "Notion", status: "active", at: 1, workspaceId: "ws_main", onlyGiven: true }, + { id: "acct_slack", app: "slack", appName: "Slack", status: "active", at: 1, onlyGiven: true }, + ], + workspaces: [ + { id: "ws_main", name: "Acme", createdAt: 1, line: { numberId: "num-main", agentId: "agent-main", phone: "+14155550100", type: "sms", scope: "sub" } }, + { id: "ws_two", name: "Side", createdAt: 2 }, + ], + ownerPhones: [{ number: OWNER, consentAt: 1, verifiedAt: 2 }], + usage: [{ kind: "model.tokens", at: 1 }], + }; +} + +/** A user whose Mac wrote their state (version 1), with their cloud account and Composio accounts as the cloud saw them made. */ +async function seeded(what: string) { + const u = newUserId(what); + users.push(u); + const r = await call(cloud.url, "PUT", "/v1/state", { key: `key-${u}`, headers: asNew(u), json: { base: 0, state: stateOf() } }); + assert.equal(r.status, 200, r.text); + await query("INSERT INTO bops.cloud_accounts (user_id, agentmail_pod_id, agentmail_key_sealed, agentphone_sub_account) VALUES ($1, 'pod1', $2, $3) ON CONFLICT (user_id) DO NOTHING", [u, seal(POD_KEY), `sub-${u}`]); + for (const id of ["acct_gmail", "acct_notion", "acct_slack"]) + // The same ids for every test user: this one's now. + await query("INSERT INTO bops.cloud_objects (object_id, provider, kind, user_id) VALUES ($1, 'composio', 'connected_account', $2) ON CONFLICT (provider, object_id) DO UPDATE SET user_id = EXCLUDED.user_id", [id, u]); + return u; +} + +const stateNow = async (u: string) => (await call(cloud.url, "GET", "/v1/state", { key: `key-${u}`, headers: asNew(u) })).json as { version: number; state: ReturnType }; + +/** A computer's write over the state as it is now, changed by `change`. */ +async function computerPut(u: string, key: string, change: (s: ReturnType) => void) { + const now = await stateNow(u); + const next = structuredClone(now.state); + change(next); + return call(cloud.url, "PUT", "/v1/state", { key, headers: asNew(u), json: { base: now.version, state: next } }); +} + +test("a computer's state write: its own threads, its bots' routines' runs and how they're doing; nothing else", T, async () => { + clearAuthCache(); + const u = await seeded("put"); + const me = deviceIdOf(u, D1); + const ok = await computerPut(u, vm(u), (s) => { + s.sessions.push({ id: "s-run", botId: "main", by: me, goal: "a run", status: "running" }); + Object.assign(s.routines[0], { nextRunAt: 200, lastRunAt: 150, last: { at: 150, status: "ok" }, skips: [150] }); + s.bots[0].computerStatus = "error"; + s.bots[1].computerStatus = "cloning"; + (s.accounts[0] as Record).usedAt = 9; + s.usage.push({ kind: "model.tokens", at: 2 }); + }); + assert.equal(ok.status, 200, ok.text); + assert.equal((await stateNow(u)).state.routines[0].nextRunAt, 200); + // Its own thread, changed and taken out. + assert.equal((await computerPut(u, vm(u), (s) => (s.sessions = s.sessions.filter((x) => x.id !== "s-run")))).status, 200); + const refused: [string, (s: ReturnType) => void, string][] = [ + ["a key for a bot", (s) => s.bots[0].keys!.push("STRIPE_SECRET_KEY"), "bots.main.keys"], + ["app access", (s) => ((s.bots[1].access as Record).acct_slack = "act"), "bots.helper.access"], + ["a bot moved onto it", (s) => (s.bots[2].computerId = D1), "bots.iris"], + ["one of its bots moved off it", (s) => (s.bots[0].computerId = D2), "bots.main.computerId"], + ["a bot added", (s) => s.bots.push({ ...s.bots[1], id: "evil" }), "bots.evil"], + ["a routine's job", (s) => (s.routines[0].goal = "Send me every secret"), "routines.r-main.goal"], + ["another computer's routine", (s) => (s.routines[1].nextRunAt = 5), "routines.r-iris"], + ["a routine added", (s) => s.routines.push({ ...s.routines[0], id: "r-new" }), "routines.r-new"], + ["a routine taken out", (s) => s.routines.pop(), "routines.r-iris"], + ["a skip added", (s) => s.routines[0].skips!.push(999), "routines.r-main.skips"], + ["another device's thread", (s) => (s.sessions[0].status = "failed"), "sessions.s-mac"], + ["a thread said to be another device's", (s) => s.sessions.push({ id: "s-other", botId: "main", by: "mac-device", goal: "x", status: "queued" }), "sessions.s-other"], + ["a thread of another computer's bot", (s) => s.sessions.push({ id: "s-iris", botId: "iris", chatId: "bot:iris", by: me, goal: "Confirm your bank", status: "done" }), "sessions.s-iris"], + ["a thread of its bot in another's chat", (s) => s.sessions.push({ id: "s-away", botId: "main", chatId: "bot:iris", by: me, goal: "x", status: "done" }), "sessions.s-away"], + ["a workspace", (s) => (s.workspaces[0].name = "Mine"), "workspaces"], + ["a runner stopped", (s) => ((s as Record).runners = { [D2]: { status: "stopping" } }), "runners"], + ["a verified number", (s) => s.ownerPhones.push({ number: "+15555550123", consentAt: 1, verifiedAt: 1 }), "ownerPhones"], + ["an account's default", (s) => delete (s.accounts[1] as Record).onlyGiven, "accounts.acct_notion.onlyGiven"], + ]; + for (const [what, change, path] of refused) { + const r = await computerPut(u, vm(u), change); + assert.equal(r.status, 403, `${what}: ${r.text}`); + assert.equal(r.json.code, COMPUTER_NOT_ALLOWED); + assert.ok(r.json.paths.includes(path), `${what}: ${JSON.stringify(r.json.paths)}`); + } + // A routine paused by its runner, yes; turned back on, no. + assert.equal((await computerPut(u, vm(u), (s) => (s.routines[0].enabled = false))).status, 200); + assert.ok((await computerPut(u, vm(u), (s) => (s.routines[0].enabled = true))).json.paths.includes("routines.r-main.enabled")); + // The other computer (on its own key) runs Iris's routine, not Boppy's. + assert.equal((await computerPut(u, ck(u, D2), (s) => (s.routines[1].nextRunAt = 300))).status, 200); + assert.equal((await computerPut(u, ck(u, D2), (s) => (s.routines[0].nextRunAt = 300))).status, 403); + // The user's own key: anything, as before. + assert.equal((await computerPut(u, `key-${u}`, (s) => s.bots[0].keys!.push("STRIPE_SECRET_KEY"))).status, 200); +}); + +test("a computer's requests for the user's OK: its own bots', added unanswered and taken out; never a reply (its own included), never another's; the user's own Bops replies", T, async () => { + clearAuthCache(); + const u = await seeded("asks"); + const me = deviceIdOf(u, D1); + type Reply = { reply: string; by?: string; at: number; reason?: string }; + type Ask = { id: string; botId: string; askedBy: string; action: string; resources: string[]; preview: string; at: number; threadId?: string; reply?: Reply }; + const asks = (s: ReturnType) => ((s as Record).appApprovals ??= []) as Ask[]; + const ask = (o: Partial & { id: string }): Ask => ({ botId: "main", askedBy: me, action: "Send an email", resources: ["dana@acme.example"], preview: "to: dana@acme.example", at: 1, ...o }); + // Its own bot's, unanswered: taken. + assert.equal((await computerPut(u, vm(u), (s) => asks(s).push(ask({ id: "apr-1", threadId: "s-run" })))).status, 200); + // The Mac's request is there too (the user's own key). + assert.equal((await computerPut(u, `key-${u}`, (s) => asks(s).push(ask({ id: "apr-mac", askedBy: "mac-device" })))).status, 200); + const refused: [string, (s: ReturnType) => void, string][] = [ + ["its own request answered once", (s) => (asks(s)[0].reply = { reply: "once", by: me, at: 2 }), "appApprovals.apr-1"], + ["its own request answered always", (s) => (asks(s)[0].reply = { reply: "always", by: me, at: 2 }), "appApprovals.apr-1"], + ["its own request rejected", (s) => (asks(s)[0].reply = { reply: "reject", by: me, at: 2 }), "appApprovals.apr-1"], + ["what it asks changed", (s) => (asks(s)[0].preview = "to: everyone@acme.example"), "appApprovals.apr-1"], + ["one added answered", (s) => asks(s).push(ask({ id: "apr-yes", reply: { reply: "once", by: me, at: 2 } })), "appApprovals.apr-yes"], + ["another Bops's request stopped", (s) => (asks(s)[1].reply = { reply: "reject", reason: "stopped", by: me, at: 2 }), "appApprovals.apr-mac"], + ["another Bops's request taken out", (s) => asks(s).splice(1, 1), "appApprovals.apr-mac"], + ["a request as another Bops", (s) => asks(s).push(ask({ id: "apr-as-mac", askedBy: "mac-device" })), "appApprovals.apr-as-mac"], + ["a request for another computer's bot", (s) => asks(s).push(ask({ id: "apr-iris", botId: "iris" })), "appApprovals.apr-iris"], + ]; + for (const [what, change, path] of refused) { + const r = await computerPut(u, vm(u), change); + assert.equal(r.status, 403, `${what}: ${r.text}`); + assert.equal(r.json.code, COMPUTER_NOT_ALLOWED); + assert.ok(r.json.paths.includes(path), `${what}: ${JSON.stringify(r.json.paths)}`); + } + assert.equal(asks((await stateNow(u)).state)[0].reply, undefined, "still waiting on the owner"); + // The owner replies from their own Bops (the user's own key): taken; and the computer takes out its own request once it read it. + assert.equal((await computerPut(u, `key-${u}`, (s) => (asks(s)[0].reply = { reply: "once", by: "mac-device", at: 3 }))).status, 200); + assert.equal((await computerPut(u, vm(u), (s) => ((s as Record).appApprovals = asks(s).filter((a) => a.id !== "apr-1")))).status, 200); + // Cancelled by the computer that asked (taken out unanswered): taken. + assert.equal((await computerPut(u, vm(u), (s) => asks(s).push(ask({ id: "apr-2" })))).status, 200); + assert.equal((await computerPut(u, vm(u), (s) => ((s as Record).appApprovals = asks(s).filter((a) => a.id !== "apr-2")))).status, 200); + assert.deepEqual(asks((await stateNow(u)).state).map((a) => a.id), ["apr-mac"]); +}); + +test("a computer ends the run it began of a bot moved off it meanwhile: its thread, the routine's record, its chip and result; nothing new of that bot's", T, async () => { + clearAuthCache(); + const u = await seeded("moved"); + const me = deviceIdOf(u, D1); + // Max (on D1, Boppy's computer) has a routine; D1's runner begins its 9:00 run. + assert.equal((await computerPut(u, `key-${u}`, (s) => s.routines.push({ id: "r-max", botId: "helper", title: "Max's", goal: "His", schedule: { kind: "daily", times: ["09:00"] }, enabled: true, nextRunAt: 100 }))).status, 200); + const began = await computerPut(u, vm(u), (s) => { + s.sessions.push({ id: "s-max", botId: "helper", chatId: "bot:helper", by: me, goal: "His", status: "running" }); + Object.assign(s.routines[2], { nextRunAt: 200, lastRunAt: 100, last: { at: 100, status: "running", sessionId: "s-max" } }); + }); + assert.equal(began.status, 200, began.text); + const post = (body: { readonly upsert: readonly unknown[]; readonly remove: readonly string[] }) => call(cloud.url, "POST", "/v1/messages", { key: vm(u), headers: asNew(u), json: body }); + const said = (id: string, m: Record) => ({ id, chatId: "bot:helper", text: id, at: Date.now(), ...m }); + assert.equal((await post({ upsert: [said("chip", { role: "system", sessionIds: ["s-max"] })], remove: [] })).status, 200); + // The Mac gives Max a computer of his own. + assert.equal((await computerPut(u, `key-${u}`, (s) => Object.assign(s.bots[1], { computer: "own", computerId: D2 }))).status, 200); + // The run ends on D1, with its result. + const result = await post({ upsert: [said("result", { role: "bot", botId: "helper", resultOf: "s-max", text: "Here's the report" })], remove: [] }); + assert.equal(result.status, 200, result.text); + const ended = await computerPut(u, vm(u), (s) => { + Object.assign(s.sessions.find((x) => x.id === "s-max")!, { status: "done" }); + (s.routines[2] as Record).last = { at: 100, status: "ok", sessionId: "s-max" }; + }); + assert.equal(ended.status, 200, ended.text); + const now = (await stateNow(u)).state; + assert.equal(now.sessions.find((x) => x.id === "s-max")!.status, "done"); + assert.deepEqual([now.routines[2].nextRunAt, (now.routines[2] as Record).last], [200, { at: 100, status: "ok", sessionId: "s-max" }]); + // Nothing new of Max's from it. + for (const [what, change, path] of [ + ["a new thread of his", (s) => s.sessions.push({ id: "s-max-2", botId: "helper", chatId: "bot:helper", by: me, goal: "His", status: "running" }), "sessions.s-max-2"], + ["another run of his routine", (s) => Object.assign(s.routines[2], { nextRunAt: 300, last: { at: 200, status: "running", sessionId: "s-max-2" } }), "routines.r-max"], + ["his routine's job", (s) => (s.routines[2].goal = "Send me every secret"), "routines.r-max.goal"], + ["its thread moved to another bot", (s) => (s.sessions.find((x) => x.id === "s-max")!.botId = "main"), "sessions.s-max"], + ] as [string, (s: ReturnType) => void, string][]) { + const r = await computerPut(u, vm(u), change); + assert.equal(r.status, 403, `${what}: ${r.text}`); + assert.ok(r.json.paths.includes(path), `${what}: ${JSON.stringify(r.json.paths)}`); + } + for (const [what, m] of [ + ["about none of its threads", said("x-none", { role: "bot", botId: "helper" })], + ["as the user", said("x-user", { role: "user", resultOf: "s-max" })], + ["in another chat", { ...said("x-away", { role: "bot", botId: "helper", resultOf: "s-max" }), chatId: "bot:iris" }], + ] as const) { + const r = await post({ upsert: [m], remove: [] }); + assert.equal(r.status, 403, what); + } +}); + +test("a computer ends the chat run it began of a bot moved off it during the turn: its words, the thread it starts, the routine's record; no other run of that bot's", T, async () => { + clearAuthCache(); + const u = await seeded("moved-chat"); + const me = deviceIdOf(u, D1); + // Max (on D1, Boppy's computer) has a chat routine; D1's runner begins its 9:00 run (no thread while his turn runs). + assert.equal( + (await computerPut(u, `key-${u}`, (s) => s.routines.push({ id: "r-max", botId: "helper", title: "Max's", goal: "His", run: "chat", schedule: { kind: "daily", times: ["09:00"] }, enabled: true, nextRunAt: 100 } as never))).status, + 200, + ); + const began = await computerPut(u, vm(u), (s) => Object.assign(s.routines[2], { nextRunAt: 200, lastRunAt: 100, last: { at: 100, status: "running", by: me } })); + assert.equal(began.status, 200, began.text); + // The Mac gives Max a computer of his own while the turn runs. + assert.equal((await computerPut(u, `key-${u}`, (s) => Object.assign(s.bots[1], { computer: "own", computerId: D2 }))).status, 200); + const post = (body: { readonly upsert: readonly unknown[]; readonly remove: readonly string[] }) => call(cloud.url, "POST", "/v1/messages", { key: vm(u), headers: asNew(u), json: body }); + const said = (id: string, m: Record) => ({ id, chatId: "bot:helper", text: id, at: Date.now(), routine: { id: "r-max" }, ...m }); + // The turn's words and its chip, then the thread it started and the run's record of it. + const words = await post({ upsert: [said("chip", { role: "system", sessionIds: ["s-max"] }), said("note", { role: "system", text: "Read example.com" }), said("answer", { role: "bot", botId: "helper" })], remove: [] }); + assert.equal(words.status, 200, words.text); + const thread = await computerPut(u, vm(u), (s) => { + s.sessions.push({ id: "s-max", botId: "helper", chatId: "bot:helper", by: me, goal: "His", status: "queued", routineId: "r-max" }); + (s.routines[2] as Record).last = { at: 100, status: "running", sessionId: "s-max", by: me }; + }); + assert.equal(thread.status, 200, thread.text); + // It ends there, with its result. + assert.equal((await post({ upsert: [said("result", { role: "bot", botId: "helper", resultOf: "s-max", routine: undefined })], remove: [] })).status, 200); + const ended = await computerPut(u, vm(u), (s) => { + Object.assign(s.sessions.find((x) => x.id === "s-max")!, { status: "done" }); + (s.routines[2] as Record).last = { at: 100, status: "ok", sessionId: "s-max", by: me, delivery: { status: "sent", to: "email", at: 101 } }; + }); + assert.equal(ended.status, 200, ended.text); + const now = (await stateNow(u)).state; + assert.equal(now.sessions.find((x) => x.id === "s-max")!.status, "done"); + assert.deepEqual([now.routines[2].nextRunAt, (now.routines[2] as Record).last], [200, { at: 100, status: "ok", sessionId: "s-max", by: me, delivery: { status: "sent", to: "email", at: 101 } }]); + // Nothing new of Max's from it: no other run, no thread of none, nothing more said once the run ended. + for (const [what, change, path] of [ + ["another run of his routine", (s) => Object.assign(s.routines[2], { nextRunAt: 300, last: { at: 200, status: "running", by: me } }), "routines.r-max"], + ["a thread of his", (s) => s.sessions.push({ id: "s-max-2", botId: "helper", chatId: "bot:helper", by: me, goal: "His", status: "queued", routineId: "r-max" }), "sessions.s-max-2"], + ["his routine's job", (s) => (s.routines[2].goal = "Send me every secret"), "routines.r-max.goal"], + ] as [string, (s: ReturnType) => void, string][]) { + const r = await computerPut(u, vm(u), change); + assert.equal(r.status, 403, `${what}: ${r.text}`); + assert.ok(r.json.paths.includes(path), `${what}: ${JSON.stringify(r.json.paths)}`); + } + // What the run says after its end landed (a save that was already sending, a ping on its result, a "Couldn't + // send") is still its own: posted since that run began, while the routine's last run is that runner's. + const late = await post({ upsert: [said("answer", { role: "bot", botId: "helper", ping: true }), said("late", { role: "system", text: "Couldn't send it by text" })], remove: [] }); + assert.equal(late.status, 200, late.text); + assert.equal((await post({ upsert: [said("before", { role: "bot", botId: "helper", at: 50 })], remove: [] })).status, 403, "never one from before that run began"); + assert.equal((await post({ upsert: [said("as-boppy", { role: "bot", botId: "main" })], remove: [] })).status, 403, "nor as another bot"); + // The Mac runs Max's routine next: nothing more of that routine's from the computer. + assert.equal((await computerPut(u, `key-${u}`, (s) => Object.assign(s.routines[2], { nextRunAt: 300, lastRunAt: 200, last: { at: 200, status: "ok" } }))).status, 200); + assert.equal((await post({ upsert: [said("after-mac", { role: "bot", botId: "helper" })], remove: [] })).status, 403, "another Bops' run: nothing of its"); + assert.equal((await post({ upsert: [said("answer", { role: "bot", botId: "helper", ping: false })], remove: [] })).status, 403, "nor a change to what it said before"); +}); + +test("a computer never starts its user's state, never uploads a whole one, and never reads one with every message", T, async () => { + clearAuthCache(); + const u = newUserId("fresh"); + users.push(u); + const r = await call(cloud.url, "PUT", "/v1/state", { key: vm(u), headers: asNew(u), json: { base: 0, state: { bots: [] } } }); + assert.deepEqual([r.status, r.json.paths], [403, ["state"]]); + const v = await seeded("backup"); + const backup = await call(cloud.url, "PUT", "/v1/state", { key: vm(v), json: { version: 9, state: { bots: [] } } }); + assert.equal(backup.status, 403, "a build from before's whole state"); + const old = await call(cloud.url, "GET", "/v1/state", { key: vm(v) }); + assert.equal(old.status, 403, "a read with every message in it"); + assert.equal((await call(cloud.url, "GET", "/v1/state", { key: vm(v), headers: asNew(v) })).status, 200); +}); + +test("a computer's messages: written and read in its bots' chats only", T, async () => { + clearAuthCache(); + const u = await seeded("msgs"); + const msg = (id: string, chatId: string) => ({ id, chatId, role: "system", text: id, at: Date.now() }); + const mac = await call(cloud.url, "POST", "/v1/messages", { key: `key-${u}`, headers: asNew(u), json: { upsert: [msg("m-iris", "bot:iris"), msg("m-main", "bot:main")], remove: [] } }); + assert.equal(mac.status, 200); + const ok = await call(cloud.url, "POST", "/v1/messages", { key: vm(u), headers: asNew(u), json: { upsert: [msg("r-1", "bot:main"), msg("r-2", "group1")], remove: ["m-main"] } }); + assert.equal(ok.status, 200, ok.text); + for (const [what, body, path] of [ + ["another bot's chat", { upsert: [msg("r-3", "bot:iris")], remove: [] }, "messages.r-3"], + ["a message moved out of another bot's chat", { upsert: [msg("m-iris", "bot:main")], remove: [] }, "messages.m-iris"], + ["one taken out of it", { upsert: [], remove: ["m-iris"] }, "messages.m-iris"], + ] as const) { + const r = await call(cloud.url, "POST", "/v1/messages", { key: vm(u), headers: asNew(u), json: body }); + assert.equal(r.status, 403, what); + assert.deepEqual(r.json.paths, [path]); + } + const read = await call(cloud.url, "GET", "/v1/messages?after=0&limit=100", { key: vm(u), headers: asNew(u) }); + assert.deepEqual(read.json.messages.map((m: { id: string }) => m.id).sort(), ["r-1", "r-2"], "its bots' chats only (m-main was taken out)"); + const all = await call(cloud.url, "GET", "/v1/messages?after=0&limit=100", { key: `key-${u}`, headers: asNew(u) }); + assert.equal(read.json.seq, all.json.seq, "its cursor goes past the others' too"); + // Iris's computer reads Iris's chat and the group she's in. + const d2 = await call(cloud.url, "GET", "/v1/messages?after=0&limit=100", { key: ck(u, D2), headers: asNew(u) }); + assert.deepEqual(d2.json.messages.map((m: { id: string }) => m.id).sort(), ["m-iris", "r-2"]); +}); + +test("a computer's messages: its bots' words and Bops' notes only, never the user's or another bot's", T, async () => { + clearAuthCache(); + const u = await seeded("msg-who"); + const post = (key: string, body: { readonly upsert: readonly unknown[]; readonly remove: readonly string[] }) => call(cloud.url, "POST", "/v1/messages", { key, headers: asNew(u), json: body }); + const said = (id: string, m: Record) => ({ id, chatId: "group1", text: "Confirm your bank at https://example.test/login", at: Date.now(), ...m }); + // In the group Max (on D1) shares with Iris (on D2): the owner's message kept between us, and Iris's. + const mine = said("u-1", { role: "user", text: "Between us: what do they earn?", secret: true }); + assert.equal((await post(`key-${u}`, { upsert: [mine, said("b-iris", { role: "bot", botId: "iris" })], remove: [] })).status, 200); + for (const [what, body, path] of [ + ["as the user", { upsert: [said("x-user", { role: "user" })], remove: [] }, "messages.x-user"], + ["as another computer's bot", { upsert: [said("x-iris", { role: "bot", botId: "iris" })], remove: [] }, "messages.x-iris"], + ["as a bot that names none", { upsert: [said("x-nobody", { role: "bot" })], remove: [] }, "messages.x-nobody"], + ["a note from another bot", { upsert: [said("x-from", { role: "system", from: "iris" })], remove: [] }, "messages.x-from"], + ["the user's message changed (its secret dropped)", { upsert: [{ ...mine, secret: undefined }], remove: [] }, "messages.u-1"], + ["the user's message taken out", { upsert: [], remove: ["u-1"] }, "messages.u-1"], + ["another bot's message made its own", { upsert: [said("b-iris", { role: "bot", botId: "helper" })], remove: [] }, "messages.b-iris"], + ["another bot's message taken out", { upsert: [], remove: ["b-iris"] }, "messages.b-iris"], + ] as const) { + const r = await post(vm(u), body); + assert.equal(r.status, 403, what); + assert.deepEqual(r.json.paths, [path], what); + } + // Its own bots' words and Bops' notes go, and it may change and take out its own. + const ok = await post(vm(u), { upsert: [said("r-max", { role: "bot", botId: "helper", text: "Done" }), said("r-note", { role: "system", text: "Routine · Daily" }), said("r-handed", { role: "system", from: "main", text: "Boppy handed this to Max" })], remove: [] }); + assert.equal(ok.status, 200, ok.text); + assert.equal((await post(vm(u), { upsert: [said("r-max", { role: "bot", botId: "helper", text: "Done, twice" })], remove: ["r-note"] })).status, 200); + const all = await call(cloud.url, "GET", "/v1/messages?after=0&limit=100", { key: `key-${u}`, headers: asNew(u) }); + const kept = new Map(all.json.messages.filter((m: { json: unknown }) => m.json).map((m: { id: string; json: Record }) => [m.id, m.json])); + assert.deepEqual([...kept.keys()].sort(), ["b-iris", "r-handed", "r-max", "u-1"]); + assert.equal((kept.get("u-1") as Record).secret, true, "the user's message as they wrote it"); + assert.equal((kept.get("b-iris") as Record).botId, "iris"); +}); + +test("a computer's session holds no mail key; its mail goes through Bops Cloud, its bots' inboxes only, on the pod key", T, async () => { + clearAuthCache(); + const u = await seeded("mail"); + const mine = await call(cloud.url, "POST", "/v1/session", { key: `key-${u}` }); + assert.equal(mine.status, 200, mine.text); + assert.equal(mine.json.agentmail.apiKey, POD_KEY, "the Mac's"); + assert.equal(mine.json.agentmail.proxied, undefined); + const its = await call(cloud.url, "POST", "/v1/session", { key: vm(u) }); + assert.deepEqual([its.json.agentmail.apiKey, its.json.agentmail.proxied, its.json.agentmail.podId], ["", true, "pod1"]); + assert.ok(!its.text.includes(POD_KEY), "not anywhere in it"); + mail.got.length = 0; + for (const [method, path] of [ + ["GET", "/proxy/agentmail/v0/inboxes/inbox-main/messages?limit=10"], + ["GET", "/proxy/agentmail/v0/inboxes/inbox-main/messages/search?q=invoice"], + ["GET", "/proxy/agentmail/v0/inboxes/inbox-main/messages/msg1"], + ["POST", "/proxy/agentmail/v0/inboxes/inbox-main/messages/send"], + ["POST", "/proxy/agentmail/v0/inboxes/inbox-main/messages/msg1/reply-all"], + ["GET", "/proxy/agentmail/v0/inboxes/inbox-main/threads/t1"], + ]) { + const r = await call(cloud.url, method, path, { key: vm(u), json: method === "POST" ? { to: ["a@example.com"], text: "hi" } : undefined }); + assert.equal(r.status, 200, `${method} ${path}: ${r.text}`); + } + assert.ok(mail.got.every((g) => g.headers.authorization === `Bearer ${POD_KEY}`), "sent on with the user's pod key"); + for (const [key, method, path] of [ + [vm(u), "GET", "/proxy/agentmail/v0/inboxes/inbox-iris/messages"], + [vm(u), "DELETE", "/proxy/agentmail/v0/inboxes/inbox-main/messages/msg1"], + [vm(u), "POST", "/proxy/agentmail/v0/pods/pod1/inboxes"], + [`key-${u}`, "GET", "/proxy/agentmail/v0/inboxes/inbox-main/messages"], + ]) { + const r = await call(cloud.url, method, path, { key, json: method === "POST" ? {} : undefined }); + assert.equal(r.status, 403, `${key.slice(0, 8)} ${method} ${path}`); + } + assert.equal((await call(cloud.url, "GET", "/proxy/agentmail/v0/inboxes/inbox-iris/messages", { key: ck(u, D2) })).status, 200, "Iris's computer, Iris's inbox"); + assert.equal((await call(cloud.url, "GET", "/proxy/agentmail/v0/inboxes/inbox-main/messages", { key: ck(u, D2) })).status, 403, "Iris has her own (and works in another workspace): never the main bot's"); +}); + +test("a computer texts only the user's verified numbers, from a line its bots text from", T, async () => { + clearAuthCache(); + const u = await seeded("text"); + const text = (key: string, b: Record) => call(cloud.url, "POST", "/proxy/agentphone/v1/messages", { key, json: { body: "Done", ...b } }); + phone.got.length = 0; + const r = await text(vm(u), { agent_id: "agent-main", number_id: "num-main", to_number: OWNER }); + assert.equal(r.status, 200, r.text); + assert.equal(phone.got.at(-1)?.headers["x-sub-account-id"], `sub-${u}`); + assert.equal((await text(ck(u, D2), { agent_id: "agent-iris", number_id: "num-iris", to_number: OWNER })).status, 200, "Iris's own line"); + for (const [what, key, b] of [ + ["to someone else", vm(u), { agent_id: "agent-main", number_id: "num-main", to_number: "+14155550123" }], + ["from another computer's bot's line", vm(u), { agent_id: "agent-iris", number_id: "num-iris", to_number: OWNER }], + ["the line's number with another agent", vm(u), { agent_id: "agent-iris", number_id: "num-main", to_number: OWNER }], + ] as const) { + const x = await text(key, b); + assert.deepEqual([x.status, x.json.code], [403, COMPUTER_NOT_ALLOWED], what); + } + for (const path of ["/proxy/agentphone/v1/numbers", "/proxy/agentphone/v1/numbers/num-main/messages"]) + assert.equal((await call(cloud.url, "GET", path, { key: vm(u) })).status, 403, path); + assert.equal((await text(`key-${u}`, { agent_id: "agent-main", number_id: "num-main", to_number: "+14155550123" })).status, 200, "the Mac, as before"); +}); + +test("Composio for a computer: its bots' own accounts and sessions, read-only ones read only, nothing that connects or deletes", T, async () => { + clearAuthCache(); + const u = await seeded("composio"); + const me = `bops-${u}`; + const run = (key: string, tool: string, bot: string | null, account?: string) => + call(cloud.url, "POST", `/proxy/composio/api/v3.1/tools/execute/${tool}`, { key, headers: bot ? { "x-bops-bot": bot } : {}, json: { user_id: me, ...(account ? { connected_account_id: account } : {}), arguments: {} } }); + const acts = await run(vm(u), "GMAIL_SEND_EMAIL", "main", "acct_gmail"); + assert.equal(acts.status, 200, `Boppy acts in Gmail: ${acts.text}`); + assert.equal((await run(vm(u), "NOTION_SEARCH", "helper", "acct_notion")).status, 200, "Max reads Notion"); + for (const [what, tool, bot, account] of [ + ["Max may only read Notion", "NOTION_CREATE_PAGE", "helper", "acct_notion"], + ["nobody was given Slack", "SLACK_SEND_MESSAGE", "main", "acct_slack"], + ["Notion isn't Boppy's", "NOTION_SEARCH", "main", "acct_notion"], + ["an account in another app", "NOTION_SEARCH", "helper", "acct_gmail"], + ["Iris isn't on this computer", "GMAIL_SEND_EMAIL", "iris", "acct_gmail"], + ["no bot said", "GMAIL_SEND_EMAIL", null, "acct_gmail"], + ] as const) { + const r = await run(vm(u), tool, bot, account); + assert.equal(r.status, 403, `${what}: ${r.text}`); + } + for (const [method, path, body] of [ + ["POST", "/api/v3.1/connected_accounts/link", { user_id: me, auth_config_id: "ac_1" }], + ["POST", "/api/v3.1/connected_accounts", { user_id: me, auth_config: { id: "ac_1" } }], + ["DELETE", "/api/v3.1/connected_accounts/acct_gmail", undefined], + ["POST", "/api/v3.1/tools/execute/proxy", { connected_account_id: "acct_gmail", endpoint: "/x", method: "GET" }], + ["POST", "/api/v3.1/auth_configs", { toolkit: { slug: "gmail" } }], + ["GET", "/api/v3.1/trigger_instances/active?connected_account_ids=acct_gmail", undefined], + ] as const) { + const r = await call(cloud.url, method, `/proxy/composio${path}`, { key: vm(u), headers: { "x-bops-bot": "main" }, json: body }); + assert.equal(r.status, 403, `${method} ${path}`); + } + const session = (bot: string, body: Record) => + call(cloud.url, "POST", "/proxy/composio/api/v3.1/tool_router/session", { + key: vm(u), + headers: { "x-bops-bot": bot }, + json: { user_id: me, manage_connections: { enable: false }, workbench: { enable: false }, ...body }, + }); + assert.equal((await session("main", { toolkits: { enable: ["gmail"] }, connected_accounts: { gmail: ["acct_gmail"] } })).status, 200); + assert.equal((await session("helper", { toolkits: { enable: ["notion"] }, connected_accounts: { notion: ["acct_notion"] }, tools: { notion: { tags: { enable: ["readOnlyHint"] } } } })).status, 200); + for (const [what, bot, body] of [ + ["Max's read-only Notion with every action", "helper", { toolkits: { enable: ["notion"] }, connected_accounts: { notion: ["acct_notion"] } }], + ["an app Boppy wasn't given", "main", { toolkits: { enable: ["gmail", "slack"] } }], + ["another app's account", "main", { toolkits: { enable: ["gmail"] }, connected_accounts: { gmail: ["acct_slack"] } }], + ["connecting accounts on", "main", { toolkits: { enable: ["gmail"] }, manage_connections: { enable: true } }], + ] as const) { + const r = await session(bot, body); + assert.equal(r.status, 403, what); + } + const listed = await call(cloud.url, "GET", `/proxy/composio/api/v3.1/connected_accounts?user_ids=${me}`, { key: vm(u) }); + assert.deepEqual(listed.json.items.map((a: { id: string }) => a.id).sort(), ["acct_gmail", "acct_notion"], "only what its bots were given"); + assert.equal((await call(cloud.url, "GET", "/proxy/composio/api/v3.1/connected_accounts/acct_slack", { key: vm(u) })).status, 404); + assert.equal((await call(cloud.url, "GET", "/proxy/composio/api/v3.1/toolkits", { key: vm(u) })).status, 200, "the catalog"); + // The Mac, as before: any account of the user's, no bot said. + assert.equal((await run(`key-${u}`, "SLACK_SEND_MESSAGE", null, "acct_slack")).status, 200); +}); + +test("a bot's computer is trusted with what its run sends: an action the user allowed goes as the runner sends it, an empty file input Composio dropped included, with nothing of their answer checked again here", T, async () => { + clearAuthCache(); + const u = await seeded("trusted"); + const send = (args: Record) => + call(cloud.url, "POST", "/proxy/composio/api/v3.1/tools/execute/GMAIL_SEND_EMAIL", { key: vm(u), headers: { "x-bops-bot": "main" }, json: { user_id: `bops-${u}`, connected_account_id: "acct_gmail", arguments: args } }); + const sent = () => composio.got.filter((g) => g.method === "POST" && g.path === "/api/v3.1/tools/execute/GMAIL_SEND_EMAIL").at(-1)?.json?.arguments; + // The runner asked with attachment "" in its inputs; Composio's SDK drops an empty file input before it sends. + const email = { recipient_email: "dana@acme.example", subject: "Report", body: "Here it is" }; + for (const args of [email, { ...email, attachment: "" }]) { + const r = await send(args); + assert.equal(r.status, 200, `${JSON.stringify(args)}: ${r.text}`); + assert.deepEqual(sent(), args, "sent on as it came"); + } + // An email to someone else from the bot's own inbox, as its run sends it once the user allowed it. + const mailed = await call(cloud.url, "POST", "/proxy/agentmail/v0/inboxes/inbox-main/messages/send", { key: vm(u), json: { to: ["dana@acme.example"], subject: "Report", text: "Here it is" } }); + assert.equal(mailed.status, 200, mailed.text); +}); + +test("Honcho for a computer: its bots' workspaces' memory banks only", T, async () => { + clearAuthCache(); + const u = await seeded("honcho"); + const p = honchoPrefix(u); + for (const base of ["/proxy/honcho", ""]) { + assert.equal((await call(cloud.url, "POST", `${base}/v3/workspaces/${p}-bops/peers/list`, { key: vm(u), json: {} })).status, 200, "the default workspace's bank"); + const r = await call(cloud.url, "POST", `${base}/v3/workspaces/${p}-bops-ws_two/peers/list`, { key: vm(u), json: {} }); + assert.deepEqual([r.status, r.json.code], [403, COMPUTER_NOT_ALLOWED], "Iris's workspace's"); + } + assert.equal((await call(cloud.url, "POST", "/v3/workspaces", { key: vm(u), json: { id: `${p}-bops-ws_two` } })).status, 403); + assert.equal((await call(cloud.url, "POST", `/v3/workspaces/${p}-bops-ws_two/peers/list`, { key: ck(u, D2), json: {} })).status, 200, "Iris's computer, hers"); + const listed = await call(cloud.url, "POST", "/v3/workspaces/list", { key: vm(u), json: { mine: `${p}-bops`, theirs: `${p}-bops-ws_two` } }); + assert.deepEqual(listed.json.items.map((w: { id: string }) => w.id), [`${p}-bops`]); + assert.equal((await call(cloud.url, "POST", `/v3/workspaces/${p}-bops-ws_two/peers/list`, { key: `key-${u}`, json: {} })).status, 200, "the Mac, any of the user's"); +}); + +test("a computer key Orgo never bound to its computer is refused everywhere", T, async () => { + clearAuthCache(); + const u = await seeded("unbound"); + const who = await userForKey(`ck-unbound-${u}`); + assert.deepEqual([who?.computer?.kind, who?.computer?.desktopId], ["computer_key", ""]); + const r = await call(cloud.url, "GET", "/v1/state/head", { key: `ck-unbound-${u}`, headers: asNew(u) }); + assert.deepEqual([r.status, r.json.code], [403, COMPUTER_NOT_ALLOWED]); +}); + +test("a computer's socket ends with its token, and its socket or long answer as soon as Orgo turns it down", T, async () => { + clearAuthCache(); + const u = newUserId("watch"); + users.push(u); + const was = { ...computerWatch }; + computerWatch.everyMs = 50; + // Eight of the sign-ins below in it: a moment the process is held up (a busy machine) isn't taken for the computer gone. + computerWatch.seenMs = 800; + const key = vm(u); + const stops: (() => void)[] = []; + let fresh: ReturnType | undefined; + /** Watch `key` as server.ts does a socket (`stream` false) or a long answer: when it ended, 0 while it hasn't. */ + const watch = async (key: string, stream: boolean) => { + const user = (await userForKey(key))!; + const w = { user, endedAt: 0 }; + stops.push(watchComputer(key, user, () => (w.endedAt = Date.now()), stream)); + return w; + }; + try { + // A socket: at its token's end (1.5 s), never before it. + const socket = await watch(`orgo_vm_soon-${u}~${D1}`, false); + const ends = socket.user.computer!.expiresAt; + assert.ok(await within(ends - Date.now() + 2000, () => socket.endedAt > 0), "closed at its token's end"); + assert.ok(socket.endedAt >= ends, `closed ${ends - socket.endedAt} ms before its token's end`); + // Turned down (deleted, moved, paused): within a look. + clearAuthCache(); + const answer = await watch(key, true); + await sleep(200); + // (Orgo's answer is kept 30 seconds: auth.ts. The test forgets it as it turns the key down.) + turnedDown.add(key); + clearAuthCache(); + const downAt = Date.now(); + assert.ok(await within(3000, () => answer.endedAt > 0), "ended once Orgo turned it down"); + assert.ok(answer.endedAt >= downAt && answer.endedAt - downAt < 1000, `ended ${answer.endedAt - downAt} ms after`); + // A long answer outlives its token while the same computer keeps signing in afresh (its runner on + // its own key, every 100 ms here), and not past that. Each sign-in is one Bops Cloud kept no answer + // for (`#`), so when Orgo last said it's good never goes back to nothing in between. + clearAuthCache(); + const long = await watch(`orgo_vm_soon-${u}~${D2}`, true); + const expires = long.user.computer!.expiresAt; + let n = 0; + let signedInAt = 0; + fresh = setInterval(() => void userForKey(`${ck(u, D2)}#${++n}`).then((x) => x?.computer?.desktopId === D2 && (signedInAt = Date.now())), 100); + // Past its token's end by more than seenMs: it would have ended by now, signed in only at its start. + await sleep(Math.max(0, expires - Date.now()) + computerWatch.seenMs + 400); + assert.equal(long.endedAt, 0, "its computer still signs in"); + clearInterval(fresh); + fresh = undefined; + assert.ok(await within(computerWatch.seenMs + 3000, () => long.endedAt > 0), "and then it doesn't"); + assert.ok(long.endedAt - signedInAt >= computerWatch.seenMs - 50, `ended ${long.endedAt - signedInAt} ms after its last sign-in`); + } finally { + if (fresh) clearInterval(fresh); + for (const stop of stops) stop(); + turnedDown.delete(key); + Object.assign(computerWatch, was); + } +}); + +test("what was sent on never carries a computer's credential", T, () => { + const all: Got[] = [...mail.got, ...phone.got, ...composio.got, ...honcho.got]; + assert.ok(all.length > 10); + assert.ok(all.every((g) => !JSON.stringify(g.headers).includes("orgo_vm_") && !JSON.stringify(g.headers).includes("ck-"))); +}); diff --git a/cloud/test/core-live.test.ts b/cloud/test/core-live.test.ts new file mode 100644 index 00000000..494512d3 --- /dev/null +++ b/cloud/test/core-live.test.ts @@ -0,0 +1,489 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, test } from "node:test"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { canWatch, eventOf, forgetSeen, LiveHub, messagesWritten, PUBLISH_BUDGET, publishOf, REPLAY_MS, serveStream, sliceHash, stateWritten, type LiveSub, type LiveTarget } from "../live.ts"; + +/** + * Live (live.ts): the hub fans each event out to every stream of its workspace and no other, a device + * that comes back is sent what it missed or told to start over, moods run out, publishes are checked + * and capped, streams are capped, and "state" goes out only when a workspace's part of the state + * changed. Then the same through the router, over HTTP: Orgo is a fake on localhost, and no database + * is needed (nothing here touches one). + */ + +type Got = { type: string; id?: string; data: Record }; + +/** A stream that keeps what it's sent, parsed. */ +function fakeSub(): LiveSub & { got: Got[]; ended: boolean; text: string } { + const s = { + got: [] as Got[], + ended: false, + text: "", + write(chunk: string) { + s.text += chunk; + for (const block of chunk.split("\n\n").filter((b) => b && !b.startsWith(":"))) { + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + s.got.push({ type: field("event") ?? "", id: field("id"), data: JSON.parse(field("data") ?? "{}") }); + } + }, + end() { + s.ended = true; + }, + }; + return s; +} + +const A: LiveTarget = { account: "user-a", ws: "ws_main" }; +const types = (s: { got: Got[] }) => s.got.map((g) => g.type); +const mood = (botId: string, m: "thinking" | "working" | "idle", ttl?: number) => ({ type: "mood" as const, botId, mood: m, ...(ttl ? { ttl } : {}), by: "mac-1", at: 0 }); + +test("an event reaches every stream of its workspace, and none of another workspace or account", () => { + const hub = new LiveHub({ now: () => 1000 }); + const [one, two, otherWs, otherAccount] = [fakeSub(), fakeSub(), fakeSub(), fakeSub()]; + hub.open(A, one, null); + hub.open(A, two, null); + hub.open({ ...A, ws: "ws_other" }, otherWs, null); + hub.open({ account: "user-b", ws: "ws_main" }, otherAccount, null); + hub.publish(A, mood("boppy", "thinking")); + hub.publish(A, { type: "routine", id: "rt_1", change: "added", by: "mac-1", at: 0 }); + for (const s of [one, two]) assert.deepEqual(types(s), ["snapshot", "reset", "mood", "routine"]); + for (const s of [otherWs, otherAccount]) assert.deepEqual(types(s), ["snapshot", "reset"]); + assert.equal(one.got[2].data.mood, "thinking"); + assert.equal(one.got[2].data.ttl, 15, "the default ttl goes with it"); +}); + +test("only the account's own user may watch its workspaces", () => { + assert.equal(canWatch({ id: "user-a" }, A), true); + assert.equal(canWatch({ id: "user-b" }, A), false); +}); + +test("a new stream starts with the moods there are now", () => { + const hub = new LiveHub({ now: () => 0 }); + hub.publish(A, { ...mood("boppy", "working"), chatId: "bot:boppy" }); + hub.publish(A, mood("otto", "thinking")); + hub.publish(A, mood("otto", "idle")); + const s = fakeSub(); + hub.open(A, s, null); + assert.equal(s.got[0].type, "snapshot"); + assert.deepEqual(s.got[0].data, { moods: [{ botId: "boppy", chatId: "bot:boppy", mood: "working", ttl: 15, by: "mac-1" }] }); +}); + +test("a device that comes back within the buffer is sent what it missed; the moods aren't replayed", () => { + let now = 0; + const hub = new LiveHub({ now: () => now }); + const first = fakeSub(); + hub.open(A, first, null); + hub.publish(A, { type: "session", id: "s1", botId: "boppy", status: "running", by: "mac-1", at: 0 }); + const lastId = first.got.at(-1)!.id!; + assert.match(lastId, new RegExp(`^${hub.boot}-\\d+$`)); + hub.close(A, first); + now = 30_000; + hub.publish(A, { type: "session", id: "s1", botId: "boppy", status: "done", by: "mac-1", at: 0 }); + hub.publish(A, mood("boppy", "thinking")); + hub.publish(A, { type: "call", botId: "boppy", on: true, by: "mac-1", at: 0 }); + const back = fakeSub(); + hub.open(A, back, lastId); + assert.deepEqual(types(back), ["snapshot", "session", "call"]); + assert.equal(back.got[1].data.status, "done"); + // The snapshot has the mood instead. + assert.equal((back.got[0].data.moods as unknown[]).length, 1); +}); + +test("an id older than the buffer, from before a restart, or made up gets reset", () => { + let now = 0; + const hub = new LiveHub({ now: () => now }); + const s = fakeSub(); + hub.open(A, s, null); + hub.publish(A, { type: "routine", id: "rt_1", change: "changed", by: "mac-1", at: 0 }); + const old = s.got.at(-1)!.id!; + hub.publish(A, { type: "routine", id: "rt_1", change: "run-started", by: "mac-1", at: 0 }); + now = REPLAY_MS + 5_000; + hub.sweep(); + const late = fakeSub(); + hub.open(A, late, old); + assert.deepEqual(types(late), ["snapshot", "reset"]); + // The reset says where it goes on from: back with that id, nothing is missed. + const resetId = late.got[1].id!; + const again = fakeSub(); + hub.open(A, again, resetId); + assert.deepEqual(types(again), ["snapshot"]); + + const restarted = new LiveHub({ now: () => now }); + restarted.publish(A, { type: "routine", id: "rt_1", change: "added", by: "mac-1", at: 0 }); + restarted.publish(A, { type: "routine", id: "rt_2", change: "added", by: "mac-1", at: 0 }); + const fromBefore = fakeSub(); + // The same seq under the old boot: never taken for one of now. + restarted.open(A, fromBefore, `${hub.boot}-1`); + assert.deepEqual(types(fromBefore), ["snapshot", "reset"]); + for (const bogus of ["", "nonsense", `${restarted.boot}-999`, `${restarted.boot}--1`]) { + const b = fakeSub(); + restarted.open(A, b, bogus); + assert.deepEqual(types(b), ["snapshot", "reset"], bogus); + } +}); + +test("a mood not said again runs out: it leaves the snapshot and an idle goes out", () => { + let now = 0; + const hub = new LiveHub({ now: () => now }); + const s = fakeSub(); + hub.open(A, s, null); + hub.publish(A, mood("boppy", "working", 15)); + now = 10_000; + hub.sweep(); + assert.equal(types(s).filter((t) => t === "mood").length, 1, "not yet"); + // Said again: it lasts another 15 seconds. + hub.publish(A, mood("boppy", "working", 15)); + now = 24_000; + hub.sweep(); + assert.equal(s.got.at(-1)!.data.mood, "working"); + now = 25_001; + hub.sweep(); + assert.deepEqual(s.got.at(-1)!.data, { type: "mood", botId: "boppy", mood: "idle", by: "mac-1", at: 25_001 }); + const late = fakeSub(); + hub.open(A, late, null); + assert.deepEqual(late.got[0].data, { moods: [] }); +}); + +test("a publish is checked strictly", () => { + assert.deepEqual(eventOf({ type: "mood", botId: "boppy", mood: "thinking", chatId: "bot:boppy", ttl: 15 }), { type: "mood", botId: "boppy", mood: "thinking", chatId: "bot:boppy", ttl: 15 }); + assert.deepEqual(eventOf({ type: "call", botId: "boppy", on: false }), { type: "call", botId: "boppy", on: false }); + assert.deepEqual(eventOf({ type: "routine", id: "rt_1", change: "run-finished" }), { type: "routine", id: "rt_1", change: "run-finished" }); + assert.deepEqual(eventOf({ type: "session", id: "s_1", botId: "boppy", status: "running", activity: "searching" }), { type: "session", id: "s_1", botId: "boppy", status: "running", activity: "searching" }); + const bad = [ + null, + [], + { type: "state", hash: "x" }, + { type: "mood", botId: "boppy", mood: "sleeping" }, + { type: "mood", botId: "boppy", mood: "thinking", extra: 1 }, + { type: "mood", botId: "bad id with spaces", mood: "thinking" }, + { type: "mood", botId: "boppy", mood: "thinking", ttl: 3600 }, + { type: "mood", botId: "boppy", mood: "thinking", ttl: "15" }, + { type: "session", id: "s_1", botId: "boppy", status: "RUNNING!" }, + { type: "session", id: "s_1", botId: "boppy", status: "running", activity: "x".repeat(121) }, + { type: "routine", id: "rt_1", change: "exploded" }, + { type: "call", botId: "boppy", on: "yes" }, + ]; + for (const b of bad) assert.throws(() => eventOf(b), /Bad live event/, JSON.stringify(b)); + assert.throws(() => publishOf({ ws: "ws_main", events: [] }), /events must be/); + assert.throws(() => publishOf({ ws: "ws_main", events: Array(17).fill({ type: "call", botId: "b", on: true }) }), /events must be/); + assert.throws(() => publishOf({ ws: "../etc", events: [{ type: "call", botId: "b", on: true }] }), /ws must be/); + assert.throws(() => publishOf({ ws: "ws_main", events: [{ type: "call", botId: "b", on: true }], account: "user-b" }), /Send \{ ws, events \}/); +}); + +test("streams are capped per account and in all", () => { + const hub = new LiveHub({ now: () => 0, perAccount: 2, total: 3 }); + assert.equal(hub.open(A, fakeSub(), null), "ok"); + const second = fakeSub(); + assert.equal(hub.open(A, second, null), "ok"); + assert.equal(hub.open(A, fakeSub(), null), "account_full"); + assert.equal(hub.open({ account: "user-b", ws: "ws_main" }, fakeSub(), null), "ok"); + assert.equal(hub.open({ account: "user-c", ws: "ws_main" }, fakeSub(), null), "full"); + // One closing makes room. + hub.close(A, second); + assert.equal(hub.streamsOf(A.account), 1); + assert.equal(hub.open(A, fakeSub(), null), "ok"); + assert.equal(hub.openCount(), 3); +}); + +test("a write of the state wakes only the workspaces whose part changed", () => { + forgetSeen(); + const hub = new LiveHub({ now: () => 0 }); + const main = fakeSub(); + const acme = fakeSub(); + hub.open(A, main, null); + hub.open({ ...A, ws: "ws_acme" }, acme, null); + const blob = { + workspaces: [ + { id: "ws_main", name: "Main" }, + { id: "ws_acme", name: "Acme" }, + ], + bots: [{ id: "boppy", name: "Boppy" }, { id: "max", name: "Max", workspaceId: "ws_acme" }], + chats: [{ id: "bot:boppy", botIds: ["boppy"] }, { id: "bot:max", botIds: ["max"], workspaceId: "ws_acme" }, { id: "grp", botIds: ["max"] }], + sessions: [{ id: "s1", botId: "max", status: "running" }], + routines: [], + usage: { events: [1] }, + }; + const states = (s: { got: Got[] }) => s.got.filter((g) => g.type === "state"); + // The first write after a device started watching: each workspace hears it once. + stateWritten(A.account, blob, "mac-1", hub); + assert.equal(states(main).length, 1); + assert.equal(states(acme).length, 1); + assert.equal(states(main)[0].data.by, "mac-1"); + // The same again, and the usage ledger changing: nobody hears it. + stateWritten(A.account, { ...blob, usage: { events: [1, 2, 3] } }, "mac-1", hub); + assert.equal(states(main).length, 1); + assert.equal(states(acme).length, 1); + // The same content with its keys in another order (another Mac): still nothing. + stateWritten(A.account, { ...blob, bots: [{ name: "Boppy", id: "boppy" }, { workspaceId: "ws_acme", name: "Max", id: "max" }] }, "mac-2", hub); + assert.equal(states(main).length + states(acme).length, 2); + // Acme's thread moves on: only Acme hears it, with its new fingerprint. + const next = { ...blob, sessions: [{ id: "s1", botId: "max", status: "done" }] }; + stateWritten(A.account, next, "mac-2", hub); + assert.equal(states(main).length, 1); + assert.equal(states(acme).length, 2); + assert.equal(states(acme)[1].data.hash, sliceHash(next, "ws_acme")); + // Messages: the chat's workspace hears it with the seq; one not known goes to every watched workspace. + messagesWritten(A.account, ["bot:max"], 42, "mac-1", hub); + assert.equal(states(acme).at(-1)!.data.seq, 42); + assert.equal(states(main).length, 1); + messagesWritten(A.account, ["grp"], 43, "mac-1", hub); + assert.equal(states(acme).at(-1)!.data.seq, 43, "a chat with no workspace of its own follows its first bot"); + messagesWritten(A.account, [""], 44, "mac-1", hub); + assert.equal(states(main).at(-1)!.data.seq, 44); + assert.equal(states(acme).at(-1)!.data.seq, 44); +}); + +test("nothing is worked out for an account nobody watches", () => { + forgetSeen(); + const hub = new LiveHub({ now: () => 0 }); + stateWritten("user-z", { bots: [] }, "mac-1", hub); + messagesWritten("user-z", ["bot:boppy"], 1, "mac-1", hub); + assert.deepEqual(hub.watched("user-z"), []); +}); + +test("a state written while a device's stream is briefly down is sent when it comes back, and not once it's been away too long", () => { + forgetSeen(); + let now = 0; + const hub = new LiveHub({ now: () => now }); + const blob = (status: string) => ({ bots: [{ id: "boppy" }], chats: [], sessions: [{ id: "s1", botId: "boppy", status }], routines: [] }); + const first = fakeSub(); + hub.open(A, first, null); + stateWritten(A.account, blob("running"), "mac-1", hub); + const lastId = first.got.at(-1)!.id!; + hub.close(A, first); + // Another Mac finishes the thread while this one is reconnecting. + now = 10_000; + stateWritten(A.account, blob("done"), "mac-2", hub); + const back = fakeSub(); + hub.open(A, back, lastId); + assert.deepEqual(types(back), ["snapshot", "state"]); + assert.equal(back.got[1].data.hash, sliceHash(blob("done"), "ws_main")); + // Away longer than the buffer: nothing is kept for it, and it's reset when it comes back. + const id = back.got.at(-1)!.id!; + hub.close(A, back); + now += REPLAY_MS + 1; + stateWritten(A.account, blob("running"), "mac-2", hub); + assert.deepEqual(hub.watched(A.account), []); + hub.sweep(); + const late = fakeSub(); + hub.open(A, late, id); + assert.deepEqual(types(late), ["snapshot"], "nothing happened in its workspace since, so nothing to catch up"); +}); + +test("an account's workspaces and what was seen of its state go once nobody's used them", () => { + forgetSeen(); + let now = 0; + const hub = new LiveHub({ now: () => now }); + const s = fakeSub(); + hub.open(A, s, null); + stateWritten(A.account, { bots: [{ id: "boppy" }], chats: [{ id: "bot:boppy", botIds: ["boppy"] }] }, "mac-1", hub); + assert.ok(hub.seen.has(A.account)); + hub.close(A, s); + // The laptop closes for days: no write comes to clear it. + now = REPLAY_MS + 1_000; + hub.sweep(); + assert.equal(hub.seen.has(A.account), false); + assert.deepEqual(hub.watched(A.account), []); +}); + +test("a publish into a workspace nobody watches keeps no events, and an account's publishes are budgeted", () => { + let now = 0; + const hub = new LiveHub({ now: () => now }); + for (let i = 0; i < 50; i++) hub.publish({ ...A, ws: `ws_made_up_${i}` }, { type: "session", id: "s1", botId: "boppy", status: "running", by: "mac-1", at: 0 }); + assert.deepEqual(hub.watched(A.account), []); + // So a workspace opened afterwards isn't crowded out by them. + assert.equal(hub.wouldRefuse({ ...A, ws: "ws_real" }), "ok"); + assert.equal(hub.allow(A.account, PUBLISH_BUDGET), true); + assert.equal(hub.allow(A.account, 1), false, "past the budget"); + assert.equal(hub.allow("user-b", 1), true, "another account has its own"); + now = 10_000; + assert.equal(hub.allow(A.account, 1), true, "the next 10 seconds start over"); +}); + +test("past what the hub holds in all, the buffer being added to gives up its oldest", () => { + const hub = new LiveHub({ now: () => 0, ringBytes: 1_000 }); + const s = fakeSub(); + hub.open(A, s, null); + const ev = (i: number) => ({ type: "session" as const, id: `s${i}`, botId: "boppy", status: "running", activity: "x".repeat(100), by: "mac-1", at: 0 }); + hub.publish(A, ev(0)); + const firstId = s.got.at(-1)!.id!; + for (let i = 1; i < 20; i++) hub.publish(A, ev(i)); + const back = fakeSub(); + hub.open(A, back, firstId); + assert.equal(types(back).at(-1), "reset", "what it missed is gone: it reads the state afresh"); + const recent = fakeSub(); + hub.open(A, recent, s.got.at(-2)!.id!); + assert.deepEqual(types(recent), ["snapshot", "session"], "the newest is still kept"); +}); + +/** A response and request as serveStream uses them, with what was done to them. */ +function fakeHttp(opts: { gone?: boolean } = {}) { + const on = new Map void>(); + const res = { + destroyed: !!opts.gone, + writableEnded: false, + writableLength: 0, + written: [] as string[], + cut: false, + writeHead: () => res, + flushHeaders: () => {}, + write: (chunk: string) => (res.written.push(chunk), true), + end: () => void (res.writableEnded = true), + destroy: () => void ((res.cut = true), (res.destroyed = true)), + on: (name: string, fn: () => void) => (on.set(`res:${name}`, fn), res), + }; + const req = { headers: {}, on: (name: string, fn: () => void) => (on.set(`req:${name}`, fn), req) }; + return { req: req as unknown as IncomingMessage, res: res as typeof res & ServerResponse, fake: res }; +} + +test("a stream whose client left while its key was checked takes no place", () => { + const hub = new LiveHub({ now: () => 0 }); + const { req, res, fake } = fakeHttp({ gone: true }); + serveStream(req, res, A, hub); + assert.equal(hub.streamsOf(A.account), 0); + assert.equal(hub.openCount(), 0); + assert.deepEqual(fake.written, []); +}); + +test("a client too far behind is cut off, not just ended, and its place freed", () => { + const hub = new LiveHub({ now: () => 0 }); + const { req, res, fake } = fakeHttp(); + serveStream(req, res, A, hub); + assert.equal(hub.streamsOf(A.account), 1); + fake.writableLength = 300 * 1024; + hub.publish(A, { type: "call", botId: "boppy", on: true, by: "mac-1", at: 0 }); + assert.equal(fake.cut, true, "the socket is destroyed"); + assert.equal(fake.writableEnded, false); + assert.equal(hub.streamsOf(A.account), 0); +}); + +/* ---------------- Over HTTP ---------------- */ + +let orgo: Server; +let cloud: Server; +let base = ""; + +before(async () => { + // Orgo's profile: the key "key-" is 's. + orgo = createServer((req, res) => { + const id = /^Bearer key-(.+)$/.exec(req.headers.authorization ?? "")?.[1]; + if (req.url !== "/api/user/profile" || !id) return void res.writeHead(401).end(); + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id })); + }); + await new Promise((r) => orgo.listen(0, "127.0.0.1", r)); + process.env.BOPS_ORGO_ORIGIN = `http://127.0.0.1:${(orgo.address() as AddressInfo).port}`; + const { makeServer } = await import("../server.ts"); + cloud = makeServer(); + await new Promise((r) => cloud.listen(0, "127.0.0.1", r)); + base = `http://127.0.0.1:${(cloud.address() as AddressInfo).port}`; +}); + +after(async () => { + const { closeAll } = await import("../live.ts"); + closeAll(); + cloud.closeAllConnections(); + await new Promise((r) => cloud.close(r)); + await new Promise((r) => orgo.close(r)); +}); + +/** Open a stream and read its events as they come. */ +async function stream(user: string, ws: string, lastId?: string) { + const ctl = new AbortController(); + const res = await fetch(`${base}/v1/live?ws=${ws}`, { headers: { authorization: `Bearer key-${user}`, ...(lastId ? { "last-event-id": lastId } : {}) }, signal: ctl.signal }); + const got: Got[] = []; + let text = ""; + if (res.ok) + void (async () => { + const dec = new TextDecoder(); + try { + for await (const chunk of res.body!) { + text += dec.decode(chunk as Uint8Array, { stream: true }); + let i: number; + while ((i = text.indexOf("\n\n")) >= 0) { + const block = text.slice(0, i); + text = text.slice(i + 2); + if (block.startsWith(":")) continue; + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + got.push({ type: field("event") ?? "", id: field("id"), data: JSON.parse(field("data") ?? "{}") }); + } + } + } catch {} + })(); + return { res, got, close: () => ctl.abort() }; +} + +const post = (user: string, body: unknown, raw?: string) => + fetch(`${base}/v1/live`, { method: "POST", headers: { authorization: `Bearer key-${user}`, "content-type": "application/json", "x-bops-device": "mac-1" }, body: raw ?? JSON.stringify(body) }); + +async function until(check: () => T | undefined | false, what: string): Promise { + for (const end = Date.now() + 3000; Date.now() < end; await new Promise((r) => setTimeout(r, 20))) { + const v = check(); + if (v) return v; + } + assert.fail(`timed out waiting for ${what}`); +} + +test("over HTTP: a stream with the right headers, events from the user's own devices only", async () => { + const mine = await stream("http-a", "ws_main"); + assert.equal(mine.res.status, 200); + assert.match(mine.res.headers.get("content-type") ?? "", /^text\/event-stream/); + assert.equal(mine.res.headers.get("x-accel-buffering"), "no"); + assert.match(mine.res.headers.get("cache-control") ?? "", /no-transform/); + await until(() => mine.got.length >= 2, "the snapshot and reset"); + // Another account's publish into a workspace of the same id stays in that account. + assert.equal((await post("http-b", { ws: "ws_main", events: [{ type: "mood", botId: "boppy", mood: "thinking" }] })).status, 200); + assert.equal((await post("http-a", { ws: "ws_main", events: [{ type: "mood", botId: "boppy", mood: "working" }] })).status, 200); + const m = await until(() => mine.got.find((g) => g.type === "mood"), "the mood"); + assert.equal(m.data.mood, "working"); + assert.equal(m.data.by, "mac-1"); + assert.equal(mine.got.filter((g) => g.type === "mood").length, 1); + mine.close(); +}); + +test("over HTTP: a bad, too big or unsigned publish is refused, and so is a bad workspace id", async () => { + assert.equal((await post("http-a", { ws: "ws_main", events: [{ type: "state", hash: "x" }] })).status, 400); + assert.equal((await post("http-a", null, "{not json")).status, 400); + assert.equal((await post("http-a", null, JSON.stringify({ ws: "ws_main", events: [{ type: "call", botId: "b", on: true }], pad: "x".repeat(5000) }))).status, 413); + const unsigned = await fetch(`${base}/v1/live`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ws: "ws_main", events: [{ type: "call", botId: "b", on: true }] }) }); + assert.equal(unsigned.status, 401); + assert.equal((await fetch(`${base}/v1/live?ws=a%20b`, { headers: { authorization: "Bearer key-http-a" } })).status, 400); + assert.equal((await fetch(`${base}/v1/live?ws=ws_main`)).status, 401); +}); + +test("over HTTP: a client that leaves before its stream starts holds no place", async () => { + const hub = new LiveHub(); + const slow = createServer((req, res) => void setTimeout(() => serveStream(req, res, A, hub), 300)); + await new Promise((r) => slow.listen(0, "127.0.0.1", r)); + for (let i = 0; i < 3; i++) { + const ctl = new AbortController(); + void fetch(`http://127.0.0.1:${(slow.address() as AddressInfo).port}/`, { signal: ctl.signal }).catch(() => {}); + setTimeout(() => ctl.abort(), 50); + } + await new Promise((r) => setTimeout(r, 500)); + assert.equal(hub.openCount(), 0); + assert.equal(hub.streamsOf(A.account), 0); + hub.closeAll(); + slow.closeAllConnections(); + await new Promise((r) => slow.close(r)); +}); + +test("over HTTP: past the cap a stream is refused with 429, and a closed one makes room", async () => { + const open = []; + for (let i = 0; i < 20; i++) open.push(await stream("http-cap", "ws_main")); + assert.ok(open.every((s) => s.res.status === 200)); + const over = await stream("http-cap", "ws_main"); + assert.equal(over.res.status, 429); + open.pop()!.close(); + // The cloud hears the close a moment later. + let back: Awaited> | null = null; + for (let i = 0; i < 50 && !back; i++) { + const s = await stream("http-cap", "ws_main"); + if (s.res.status === 200) back = s; + else await new Promise((r) => setTimeout(r, 20)); + } + assert.ok(back, "a closed stream makes room for another"); + back.close(); + for (const s of open) s.close(); +}); diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts new file mode 100644 index 00000000..d4a673fe --- /dev/null +++ b/cloud/test/core-members.test.ts @@ -0,0 +1,931 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, beforeEach, test } from "node:test"; +import { canWatch, hub, LiveHub, liveScope, messagesWritten, scoped, stateWritten, type LiveSub } from "../live.ts"; +import { accessSummary, cleanAccess, levelOf, MAX_ACCESS_BOTS, type MemberAccess, type MemberAccessMap } from "../member-access.ts"; +import { accessFor, memberStreams, membersChanged, NO_MS, resetMembers, ROLE_MS, UNSURE_LOOKS, watchable } from "../members.ts"; +import type { WsAccess } from "../protocol.ts"; +import { botsOf, chatIdsOf, handedByOthers, OWNER_VIEW, pairChatsOf, sharedHash, sharedMessage, sharedMessages, sharedSlice, type SliceViewer } from "../ws-slice.ts"; + +/** + * Shared workspaces, read only (members.ts, ws-slice.ts): who may watch is what orgo-web's bops-role + * says for the caller (owner, admin, member; nobody else), kept a minute and asked again while a stream + * is open, dropped on orgo-web's notice; a project with no Bops workspace of its own is refused; what a + * member is shown of the owner's state is an allow list of one workspace, and of it only the bots the + * owner gave them (member-access.ts); and a member's stream is the owner's, counted as the member's, + * carrying only what's of their bots, which a member can't publish into. orgo-web is a fake (a function, + * then a server on localhost), and so is the owner's saved state; nothing here needs a database. + */ + +const OWNER = "user-owner"; +const access = (role: WsAccess["role"], bopsWsId: string | null = "ws_acme"): WsAccess => ({ role, ownerUserId: OWNER, bopsWsId, name: "Acme" }); + +/** orgo-web's bops-role as a fake fetch: `answers` by " " (anything else 404), and every call it got. */ +function fakeRoles(answers: Record) { + const calls: string[] = []; + const f = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + const key = /^Bearer (.+)$/.exec(new Headers(init?.headers).get("authorization") ?? "")?.[1] ?? ""; + const project = /^\/api\/projects\/([^/]+)\/bops-role$/.exec(url.pathname)?.[1] ?? ""; + calls.push(`${key} ${project}`); + const a = answers[`${key} ${project}`] ?? 404; + return typeof a === "number" ? new Response(JSON.stringify({ error: "x" }), { status: a }) : Response.json(a); + }) as typeof fetch; + return { fetch: f, calls }; +} + +beforeEach(() => resetMembers()); + +test("the owner, an admin and a member may watch; anyone else, or a project orgo-web doesn't know, may not", async () => { + const { fetch } = fakeRoles({ "k-owner p1": access("owner"), "k-admin p1": access("admin"), "k-member p1": access("member"), "k-other p1": 403 }); + resetMembers({ fetch }); + assert.equal((await accessFor(OWNER, "k-owner", "p1"))?.role, "owner"); + assert.equal((await accessFor("u-admin", "k-admin", "p1"))?.role, "admin"); + assert.equal((await accessFor("u-member", "k-member", "p1"))?.role, "member"); + assert.equal(await accessFor("u-other", "k-other", "p1"), null); + assert.equal(await accessFor("u-member", "k-member", "p2"), null, "no such project: no different from no access"); + for (const role of ["owner", "admin", "member"] as const) { + const a = access(role); + assert.equal(canWatch({ id: "u-member" }, { account: OWNER, ws: "ws_acme" }, a), true); + assert.equal(canWatch({ id: "u-member" }, { account: OWNER, ws: "ws_main" }, a), false, "only the project's own workspace"); + assert.equal(canWatch({ id: "u-member" }, { account: "user-else", ws: "ws_acme" }, a), false, "only the owner's account"); + } + assert.equal(canWatch({ id: "u-member" }, { account: OWNER, ws: "ws_acme" }, null), false); + assert.equal(canWatch({ id: OWNER }, { account: OWNER, ws: "ws_acme" }), true, "the owner's own devices, as before"); + await assert.rejects(watchable({ id: "u-other" }, "k-other", "p1"), (e: { status: number }) => e.status === 404); +}); + +test("an answer that isn't one, or Orgo failing, is a 503, never access", async () => { + const { fetch } = fakeRoles({ "k p1": { role: "superuser" } as unknown as WsAccess, "k p2": 500 }); + resetMembers({ fetch }); + await assert.rejects(accessFor("u", "k", "p1"), (e: { status: number }) => e.status === 503); + await assert.rejects(accessFor("u", "k", "p2"), (e: { status: number }) => e.status === 503); + assert.equal(await accessFor("u", "k", "../x"), null, "a malformed project id never reaches Orgo"); +}); + +test("a project with no Bops workspace of its own is refused", async () => { + const { fetch } = fakeRoles({ "k p1": access("member", null) }); + resetMembers({ fetch }); + await assert.rejects(watchable({ id: "u" }, "k", "p1"), (e: { status: number; extra?: { code?: string } }) => e.status === 409 && e.extra?.code === "not_shareable"); +}); + +test("an answer is kept a minute per caller and project (a no for less), then asked again; orgo-web's notice drops it", async () => { + let t = 0; + const answers: Record = { "k p1": access("member") }; + const { fetch, calls } = fakeRoles(answers); + resetMembers({ fetch, now: () => t }); + await accessFor("u", "k", "p1"); + await accessFor("u", "k", "p1"); + assert.equal(calls.length, 1); + await accessFor("u2", "k", "p1"); + assert.equal(calls.length, 2, "another caller is asked for"); + t = ROLE_MS - 1; + await accessFor("u", "k", "p1"); + assert.equal(calls.length, 2); + t = ROLE_MS; + answers["k p1"] = 404; + assert.equal(await accessFor("u", "k", "p1"), null, "asked again once the minute is up"); + assert.equal(calls.length, 3); + t += NO_MS - 1; + answers["k p1"] = access("member"); + assert.equal(await accessFor("u", "k", "p1"), null, "a no is kept for a moment"); + t += 1; + assert.equal((await accessFor("u", "k", "p1"))?.role, "member", "then someone just added gets in"); + answers["k p1"] = 404; + membersChanged("p1", "u"); + assert.equal(await accessFor("u", "k", "p1"), null, "the notice drops what was kept"); +}); + +test("an answer asked for before orgo-web's notice and come back after it is asked for again, never kept", async () => { + const answers: Record = { "k p1": access("member") }; + let hold: (() => void) | undefined; + const { fetch: plain, calls } = fakeRoles(answers); + // The first ask waits until let go: the member is removed while it's on its way. + const f = (async (input: string | URL | Request, init?: RequestInit) => { + if (!hold && !calls.length) await new Promise((r) => (hold = r)); + return plain(input, init); + }) as typeof fetch; + resetMembers({ fetch: f }); + const pending = accessFor("u", "k", "p1"); + await until(() => hold, "the ask to start"); + answers["k p1"] = 404; + membersChanged("p1", "u"); + hold!(); + assert.equal(await pending, null, "asked again after the notice: no longer a member"); + assert.equal(calls.length, 2); + assert.equal(await accessFor("u", "k", "p1"), null, "and the stale yes was never kept"); + assert.equal(calls.length, 2); + // A notice from before an ask doesn't hold up that ask. + membersChanged("p1", "u2"); + answers["k p1"] = access("member"); + assert.equal((await accessFor("u2", "k", "p1"))?.role, "member"); +}); + +test("what a member is shown is one workspace, and only the fields named", () => { + const blob = { + workspaces: [ + { id: "ws_main", name: "Main", orbitKey: "orbit-secret", line: { numberId: "n1" } }, + { id: "ws_acme", name: "Acme", createdAt: 5, orbitKey: "acme-orbit-secret", memory: { bank: "b" }, mailSlug: "acme", skills: [{ slug: "s" }], approvals: [{ sha: "x" }], memberAccess: { "kim@acme.com": { others: "read", bots: {} } } }, + ], + bots: [ + { id: "boppy", name: "Boppy", isMain: true, appsKey: "main-apps-key" }, + { + id: "max", + name: "Max", + role: "Sales", + color: "#f00", + isMain: true, + workspaceId: "ws_acme", + computerStatus: "ready", + appsKey: "max-apps-key", + keys: ["STRIPE_SECRET_KEY"], + access: { acct1: "act" }, + composio: { sessionId: "cs", access: "x" }, + tailnet: { ip: "100.1.1.1", name: "t" }, + computerId: "comp-1", + brain: "br1", + persona: "be nice", + mail: { inboxId: "i", podId: "p" }, + phoneLine: { numberId: "n", agentId: "a" }, + }, + ], + chats: [ + { id: "bot:boppy", kind: "bot", botIds: ["boppy"] }, + { id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme", readAt: 9, typing: ["max"], asking: { max: "ana", bad: { x: 1 } } }, + { id: "grp", kind: "group", botIds: ["max"], title: "Deals" }, + ], + sessions: [ + { id: "s1", botId: "max", title: "Find leads", status: "done", answer: "Which list?", replies: [{ id: "r", role: "bot", text: "hi", at: 1, secret: "x" }], env: { remoteUrl: "https://x", sockets: "tok" }, owed: [{ id: "o", type: "computer", output: "screen text", checks: [{ id: "c", message: "Confirm?" }] }], brainRun: { kind: "codex", waits: "sign_in", computerId: "c", cli: "codex --key" }, responseId: "resp_1", emailBack: { inboxId: "i", messageId: "m" } }, + { id: "s2", botId: "boppy", title: "Main's", status: "running" }, + ], + routines: [{ id: "r1", botId: "max", title: "Daily", goal: "g", schedule: { kind: "daily", time: "09:00", secret: "x" }, enabled: true, textTo: { botId: "max", to: "+15555550100" }, deliverTo: { kind: "channel", linkId: "l" }, last: { at: 1, status: "ok", delivery: { to: "x@y" } } }], + watches: [{ id: "w1", botId: "max", site: "X", lookFor: "DMs", since: 1, display: 100, page: { url: "https://x.com", everyMin: 30, lines: ["kept text"], digests: ["d"], hash: "h" }, told: ["said"] }], + usage: { events: [{ cost: 1 }] }, + macs: { mac1: { relay: { on: true } } }, + vault: [{ id: "v1", site: "gmail", username: "owner@x.com" }], + channels: [{ id: "ch1", pairCode: "123456" }], + accounts: [{ id: "acct1", app: "gmail" }], + owner: { name: "Owner", timeZone: "UTC" }, + ownerPhones: [{ number: "+15555550101" }], + account: { user: { id: OWNER } }, + }; + const s = sharedSlice(blob, "ws_acme", OWNER_VIEW); + assert.deepEqual(s, { + workspace: { id: "ws_acme", name: "Acme", createdAt: 5 }, + bots: [{ id: "max", name: "Max", role: "Sales", color: "#f00", isMain: true, workspaceId: "ws_acme", computerStatus: "ready" }], + chats: [ + { id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme", asking: { max: "ana" } }, + { id: "grp", kind: "group", botIds: ["max"], title: "Deals" }, + ], + sessions: [ + { + id: "s1", + botId: "max", + title: "Find leads", + status: "done", + answer: "Which list?", + replies: [{ id: "r", role: "bot", text: "hi", at: 1 }], + owed: [{ id: "o", type: "computer", checks: [{ id: "c", message: "Confirm?" }] }], + brainRun: { kind: "codex", waits: "sign_in" }, + }, + ], + routines: [{ id: "r1", botId: "max", title: "Daily", goal: "g", schedule: { kind: "daily", time: "09:00" }, enabled: true, last: { at: 1, status: "ok" } }], + watches: [{ id: "w1", botId: "max", site: "X", lookFor: "DMs", since: 1, display: 100, page: { url: "https://x.com", everyMin: 30 } }], + }); + // Nothing of any secret, the other workspace or the account is anywhere in it. + const text = JSON.stringify(s); + for (const secret of ["orbit", "apps-key", "STRIPE", "100.1.1.1", "comp-1", "remoteUrl", "tok", "screen text", "codex --key", "resp_1", "+1555", "pairCode", "123456", "owner@x.com", "Boppy", "Main's", "kept text", "said", "x@y", "persona", "be nice", "cost", "memberAccess", "kim@acme.com"]) + assert.ok(!text.includes(secret), `"${secret}" isn't shown`); + // The member the owner gave every bot is held to the same allow list: what the owner is shown, the + // teammate it's asking ("ana", not one of the workspace's bots) left out. + const kim = sharedSlice(blob, "ws_acme", { role: "member", id: "u-kim", email: "kim@acme.com" }); + assert.deepEqual(kim, { ...s, chats: [{ ...s.chats[0], asking: {} }, s.chats[1]] }); + assert.ok(!JSON.stringify(kim).includes("kim@acme.com")); + assert.equal(sharedHash(s), sharedHash(sharedSlice({ ...blob, usage: { events: [] }, vault: [] }, "ws_acme", OWNER_VIEW)), "the account's own parts don't move the fingerprint"); + assert.notEqual(sharedHash(s), sharedHash(sharedSlice({ ...blob, sessions: [] }, "ws_acme", OWNER_VIEW))); + assert.deepEqual(sharedSlice(blob, "ws_nobody", OWNER_VIEW), { workspace: null, bots: [], chats: [], sessions: [], routines: [], watches: [] }); +}); + +test("a message is shown as its chat shows it, one kept between us too", () => { + assert.deepEqual( + sharedMessage({ + id: "m1", + chatId: "bot:max", + role: "bot", + text: "Sent", + at: 3, + email: { dir: "out", inboxId: "inbox_1", messageId: "mid", threadId: "t", from: "max@acme.bops.bot", to: ["a@b.c"], subject: "Hi" }, + channel: { linkId: "l", chat: "C1" }, + phone: { apId: "ap" }, + crm: { ws: "ws_acme", file: "Leads", added: 1, changed: 0, snapshot: "whole file", after: "v2" }, + appResult: { action: "SEND", ok: true, output: "raw output" }, + }), + { id: "m1", chatId: "bot:max", role: "bot", text: "Sent", at: 3, appResult: { action: "SEND", ok: true }, email: { dir: "out", from: "max@acme.bops.bot", to: ["a@b.c"], subject: "Hi" }, crm: { file: "Leads", added: 1, changed: 0 } }, + ); + // "Between us" only keeps it out of the shared memory: a member given the bot reads it as the owner wrote it. + assert.deepEqual(sharedMessage({ id: "m2", chatId: "bot:max", role: "user", text: "between us", at: 4, secret: true }, new Set(["max"])), { id: "m2", chatId: "bot:max", role: "user", text: "between us", at: 4 }); +}); + +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" } } } }], + bots: [ + { id: "boppy", name: "Boppy", isMain: true, workspaceId: "ws_acme" }, + { id: "max", name: "Max", workspaceId: "ws_acme" }, + ], + chats: [ + { id: "bot:boppy", kind: "bot", botIds: ["boppy"], workspaceId: "ws_acme" }, + { id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme" }, + ], + sessions: [ + { id: "s1", botId: "boppy", chatId: "bot:boppy", title: "Austin pay", goal: "Find what senior engineers earn in Austin", status: "done", answer: "About $180k", replies: [{ id: "r1", role: "bot", text: "Reading salaries", at: 2, note: "Looked at three sites" }], secret: true }, + { id: "s2", botId: "boppy", chatId: "bot:boppy", title: "Lunch", goal: "Book lunch Friday", status: "running" }, + { id: "s3", botId: "max", chatId: "bot:max", title: "Max's plan", goal: "Plan the offsite", status: "running" }, + ], + routines: [{ id: "r-boppy", botId: "boppy", title: "Morning brief" }, { id: "r-max", botId: "max", title: "Max daily" }], + watches: [{ id: "w-boppy", botId: "boppy", site: "X" }, { id: "w-max", botId: "max", site: "Y" }], + }; + const sam = member({ id: "u-sam", email: "sam@example.com" }); + const slice = sharedSlice(blob, "ws_acme", sam); + assert.deepEqual(view(blob, sam), { bots: ["boppy"], chats: ["bot:boppy"], sessions: ["s1", "s2"], routines: ["r-boppy"], watches: ["w-boppy"] }); + assert.deepEqual(slice.sessions[0], { id: "s1", botId: "boppy", chatId: "bot:boppy", title: "Austin pay", goal: "Find what senior engineers earn in Austin", status: "done", answer: "About $180k", replies: [{ id: "r1", role: "bot", text: "Reading salaries", at: 2, note: "Looked at three sites" }] }); + assert.deepEqual(ids(sharedSlice(blob, "ws_acme", OWNER_VIEW).sessions), ["s1", "s2", "s3"], "the owner's copy has every thread"); + for (const hidden of ["Max", "max", "offsite", "r-max", "w-max"]) assert.ok(!JSON.stringify(slice).includes(hidden), `"${hidden}" isn't shown`); + // The chat: the owner's request kept between us, the answer to it, the owner's next, the thread's result, and Max's chat. + const rows = [ + { id: "u1", chatId: "bot:boppy", role: "user", text: "Between us, find what senior engineers earn in Austin", at: 1, secret: true }, + { id: "b1", chatId: "bot:boppy", role: "bot", botId: "boppy", text: "On it.", at: 2, sessionIds: ["s1"] }, + { id: "u2", chatId: "bot:boppy", role: "user", text: "Also book lunch Friday", at: 3 }, + { id: "b2", chatId: "bot:boppy", role: "bot", botId: "boppy", text: "Senior engineers in Austin earn about $180k", at: 4, resultOf: "s1", sessionIds: ["s1"], secret: true }, + { id: "b3", chatId: "bot:boppy", role: "bot", botId: "boppy", text: "Booked", at: 5, sessionIds: ["s2"] }, + { id: "x1", chatId: "bot:max", role: "bot", botId: "max", text: "Offsite planned", at: 6 }, + ]; + const bots = botsOf(slice); + const shown = sharedMessages(rows, bots, handedByOthers(blob, bots)); + assert.deepEqual(ids(shown), ["u1", "b1", "u2", "b2", "b3"], "everything of Boppy's chat; a message Max wrote isn't Sam's"); + assert.equal(shown[0].text, "Between us, find what senior engineers earn in Austin"); + assert.deepEqual(ids(sharedMessages(rows)), ["u1", "b1", "u2", "b2", "b3", "x1"], "the owner's copy: all of it"); + // What the threads are doing, live: every one of Boppy's to Sam's stream, none of Max's. + const scope = liveScope(blob, "ws_acme", sam); + assert.ok(scoped(scope, { type: "session", id: "s1", botId: "boppy", status: "running", activity: "Reading salaries", by: "mac-o", at: 1 })); + assert.ok(scoped(scope, { type: "session", id: "s2", botId: "boppy", status: "running", by: "mac-o", at: 1 })); + assert.equal(scoped(scope, { type: "session", id: "s3", botId: "max", status: "running", by: "mac-o", at: 1 }), null); + assert.ok(scoped(scope, { type: "routine", id: "r-boppy", change: "run-started", by: "mac-o", at: 1 })); + assert.equal(scoped(scope, { type: "routine", id: "r-max", change: "run-started", by: "mac-o", at: 1 }), null); +}); + +/* ---------------- Who sees which bots ---------------- */ + +/** A workspace of bots a and b: their 1:1 chats, a group of both, a group of a alone, and a thread, routine and watch each. */ +const team = (memberAccess: MemberAccessMap, more: { bots?: Record[] } = {}) => ({ + workspaces: [{ id: "ws_acme", name: "Acme", memberAccess }], + bots: [{ id: "a", name: "Ada", workspaceId: "ws_acme" }, { id: "b", name: "Bo", workspaceId: "ws_acme" }, { id: "boppy", name: "Boppy" }, ...(more.bots ?? [])], + chats: [ + { id: "bot:a", kind: "bot", botIds: ["a"], workspaceId: "ws_acme", asking: { a: "b" } }, + { id: "bot:b", kind: "bot", botIds: ["b"], workspaceId: "ws_acme" }, + { id: "both", kind: "group", botIds: ["a", "b"], workspaceId: "ws_acme", title: "Both" }, + { id: "just-a", kind: "group", botIds: ["a"], workspaceId: "ws_acme", title: "A's" }, + { id: "odd", kind: "group", botIds: ["a", 7], workspaceId: "ws_acme" }, + ], + sessions: [{ id: "s-a", botId: "a", title: "A's thread" }, { id: "s-b", botId: "b", title: "B's thread" }], + routines: [{ id: "r-a", botId: "a", title: "A daily" }, { id: "r-b", botId: "b", title: "B daily" }], + watches: [{ id: "w-a", botId: "a", site: "X" }, { id: "w-b", botId: "b", site: "Y" }], +}); +const ids = (items: Record[]) => items.map((x) => x.id); +const member = (who: Partial = {}): SliceViewer => ({ role: "member", id: "u-ann", email: "ann@acme.com", ...who }); +/** What a viewer is shown, as the ids of each part. */ +const view = (blob: Record, viewer: SliceViewer) => { + const s = sharedSlice(blob, "ws_acme", viewer); + return { bots: ids(s.bots), chats: ids(s.chats), sessions: ids(s.sessions), routines: ids(s.routines), watches: ids(s.watches) }; +}; +const NOTHING = { bots: [], chats: [], sessions: [], routines: [], watches: [] }; + +test("the owner sees every bot of the workspace; anyone else only the bots the owner gave them, and what's theirs", () => { + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }); + assert.deepEqual(view(blob, OWNER_VIEW), { bots: ["a", "b"], chats: ["bot:a", "bot:b", "both", "just-a", "odd"], sessions: ["s-a", "s-b"], routines: ["r-a", "r-b"], watches: ["w-a", "w-b"] }); + const ann = { bots: ["a"], chats: ["bot:a", "just-a"], sessions: ["s-a"], routines: ["r-a"], watches: ["w-a"] }; + assert.deepEqual(view(blob, member()), ann, "a's chats and its things; not the group with b, nor anything of b's"); + assert.deepEqual(view(blob, member({ role: "admin" })), ann, "an admin is held to what they were given too"); + assert.deepEqual(view(blob, { role: "owner", id: "u-ann", email: "ann@acme.com" }), view(blob, OWNER_VIEW), "the owner is never held to an entry"); + // The teammate a's chat is asking is b, hidden: not named either. + assert.deepEqual(sharedSlice(blob, "ws_acme", member()).chats[0], { id: "bot:a", kind: "bot", botIds: ["a"], workspaceId: "ws_acme", asking: {} }); + assert.deepEqual(sharedSlice(blob, "ws_acme", OWNER_VIEW).chats[0].asking, { a: "b" }); + // Write is kept and shown, and reads as read for now. + assert.deepEqual(view(team({ "ann@acme.com": { others: "none", bots: { a: "write" } } }), member()), ann); + // The workspace itself is shown to anyone orgo-web says is in it, who else is in it and what they may see never. + const text = JSON.stringify(sharedSlice(blob, "ws_acme", member())); + assert.ok(text.includes('"name":"Acme"')); + for (const hidden of ["memberAccess", "ann@acme.com", "Bo", "B's", "w-b", "both"]) assert.ok(!text.includes(hidden), `"${hidden}" isn't shown`); +}); + +test("a bot made later is hidden while others is none, and shown when others lets them read", () => { + const later = { bots: [{ id: "c", name: "Cy", workspaceId: "ws_acme" }] }; + assert.deepEqual(view(team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }, later), member()).bots, ["a"]); + assert.deepEqual(view(team({ "ann@acme.com": { others: "read", bots: { b: "none" } } }, later), member()), { + bots: ["a", "c"], + chats: ["bot:a", "just-a"], + sessions: ["s-a"], + routines: ["r-a"], + watches: ["w-a"], + }); +}); + +test("someone removed and back sees nothing set before they came back; what was set since, or with the invite they came by, they do", async () => { + const DAY = 86_400_000; + const joinedAt = Date.parse("2026-10-20T12:00:00Z"); + const set = (at: number | undefined, invite = false) => team({ "ann@acme.com": { others: "none", bots: { a: "read" }, ...(at !== undefined ? { at } : {}), ...(invite ? { invite: true as const } : {}) } }); + const ann = member({ joinedAt }); + assert.deepEqual(view(set(joinedAt + 1), ann).bots, ["a"], "set since they joined"); + assert.deepEqual(view(set(joinedAt - DAY), ann), NOTHING, "set before they were removed and came back"); + assert.deepEqual(view(set(joinedAt - DAY, true), ann).bots, ["a"], "set with the invite they came by"); + assert.deepEqual(view(set(joinedAt - 8 * DAY, true), ann), NOTHING, "an invite that long before isn't the one they came by"); + assert.deepEqual(view(set(undefined), ann), NOTHING, "set before entries said when"); + assert.deepEqual(view(set(joinedAt - DAY), member()).bots, ["a"], "orgo-web not saying when they joined: as before"); + assert.deepEqual(view(set(joinedAt - DAY), { role: "owner", id: "u-ann", email: "ann@acme.com", joinedAt }), view(set(0), OWNER_VIEW), "the owner, never held to an entry"); + assert.deepEqual([...liveScope(set(joinedAt - DAY), "ws_acme", ann).bots], [], "nor does their stream hear it"); + // orgo-web's answer says when, as a date or ms; one that doesn't, nothing. + resetMembers({ fetch: fakeRoles({ "k p1": { ...access("member"), joinedAt: "2026-10-20T12:00:00Z" } as unknown as WsAccess, "k p2": { ...access("member"), joinedAt }, "k p3": access("member") }).fetch }); + assert.equal((await accessFor("u", "k", "p1"))?.joinedAt, joinedAt); + assert.equal((await accessFor("u", "k", "p2"))?.joinedAt, joinedAt); + assert.equal((await accessFor("u", "k", "p3"))?.joinedAt, undefined); +}); + +test("a person is found by their email in any case, a guest by their user id, and someone with no entry sees no bot", () => { + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read" } }, "user:u-guest": { others: "none", bots: { b: "write" } } }); + assert.deepEqual(view(blob, member({ email: " Ann@ACME.com " })).bots, ["a"], "the email's case and spaces don't matter"); + assert.deepEqual(view(blob, member({ id: "u-guest", email: undefined })).bots, ["b"], "a guest, by their user id"); + assert.deepEqual(view(blob, member({ id: "u-guest", email: "ann@acme.com" })).bots, ["a"], "an email's entry first"); + assert.deepEqual(view(blob, member({ id: "u-nobody", email: "nobody@acme.com" })), NOTHING, "no entry: nothing"); + assert.deepEqual(view(blob, member({ id: "u-nobody", email: undefined })), NOTHING, "no email and no entry by id: nothing"); + assert.deepEqual(view(team({}), member()), NOTHING); + assert.deepEqual(view({ ...blob, workspaces: [{ id: "ws_acme", name: "Acme" }] }, member()), NOTHING, "a workspace nobody was given anything of"); + assert.ok(sharedSlice(team({}), "ws_acme", member()).workspace, "still the workspace, only with no bots"); + // What isn't an access is none, and a bot whose id every object has a field for is never shown for it. + const odd = team({ "ann@acme.com": { others: "admin", bots: { a: "everything", b: 1 } } as unknown as MemberAccessMap["x"] }, { bots: [{ id: "constructor", name: "Constructor", workspaceId: "ws_acme" }] }); + assert.deepEqual(view(odd, member()), NOTHING); + assert.deepEqual(view({ ...odd, workspaces: [{ id: "ws_acme", memberAccess: "everyone" }] }, member()), NOTHING); + assert.deepEqual(view(team({ "ann@acme.com": { others: "none", bots: {} } }, { bots: [{ id: "constructor", workspaceId: "ws_acme" }] }), member()).bots, []); +}); + +test("each viewer's fingerprint is of their own view: one member's copy is never another's, and a hidden bot's change doesn't move theirs", () => { + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read" } }, "cal@acme.com": { others: "none", bots: { b: "read" } } }); + const hash = (b: Record, v: SliceViewer) => sharedHash(sharedSlice(b, "ws_acme", v)); + const ann = member(); + const cal = member({ id: "u-cal", email: "cal@acme.com" }); + assert.notEqual(hash(blob, ann), hash(blob, cal)); + assert.notEqual(hash(blob, ann), hash(blob, OWNER_VIEW)); + const bMoved = { ...blob, sessions: [blob.sessions[0], { ...blob.sessions[1], title: "B's, renamed" }] }; + assert.equal(hash(bMoved, ann), hash(blob, ann), "b's thread isn't Ann's to see, so her copy is still current"); + assert.notEqual(hash(bMoved, cal), hash(blob, cal)); + assert.notEqual(hash(bMoved, OWNER_VIEW), hash(blob, OWNER_VIEW)); + // The owner taking a bot away moves it, so a copy from before is read again. + assert.notEqual(hash(team({ "ann@acme.com": { others: "none", bots: {} } }), ann), hash(blob, ann)); +}); + +test("for anyone but the owner, a message a hidden bot wrote isn't shown, nor what a hidden bot said when asked", () => { + const rows = [ + { id: "m1", chatId: "bot:a", role: "user", text: "hi", at: 1 }, + { id: "m2", chatId: "bot:a", role: "bot", botId: "a", text: "hello", at: 2, asked: [{ botId: "b", question: "q", answer: "b's answer" }, { botId: "a", question: "q2", answer: "a's own" }] }, + { id: "m3", chatId: "bot:a", role: "bot", botId: "b", text: "b chimes in", at: 3 }, + { id: "m4", chatId: "bot:a", role: "bot", botId: "a", text: "asked b only", at: 4, asked: [{ botId: "b", question: "q", answer: "secret plan" }] }, + ]; + assert.deepEqual(sharedMessages(rows).map((m) => m.id), ["m1", "m2", "m3", "m4"], "the owner, as before"); + const seen = sharedMessages(rows, new Set(["a"])); + assert.deepEqual(seen.map((m) => m.id), ["m1", "m2", "m4"]); + assert.deepEqual(seen[1].asked, [{ botId: "a", question: "q2", answer: "a's own" }]); + assert.equal(seen[2].asked, undefined); + assert.ok(!JSON.stringify(seen).includes("secret plan")); +}); + +test("a thread a hidden bot handed over reads as its own bot's, in its chat, and the note the hand-off left isn't shown", () => { + const blob = { + ...team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }), + sessions: [ + { id: "s-handed", botId: "a", chatId: "bot:b", sentVia: "b", title: "From Bo", goal: "Bo's plan" }, + { id: "s-own", botId: "a", chatId: "bot:a", sentVia: "you", title: "Mine" }, + { id: "s-group", botId: "a", chatId: "both", sentVia: "routine", title: "In the group" }, + { id: "s-b", botId: "b", chatId: "bot:b", sentVia: "you", title: "B's thread" }, + ], + }; + const where = (v: SliceViewer) => sharedSlice(blob, "ws_acme", v).sessions.map((x) => `${x.id} ${x.chatId} ${x.sentVia}`); + assert.deepEqual(where(member()), ["s-handed bot:a a", "s-own bot:a you", "s-group bot:a routine"], "never b, nor a chat they don't have"); + assert.deepEqual(where(OWNER_VIEW), ["s-handed bot:b b", "s-own bot:a you", "s-group both routine", "s-b bot:b you"], "the owner, as it is"); + assert.equal(sharedSlice(blob, "ws_acme", member()).sessions[0].goal, "Bo's plan", "the job a was given is a's to read"); + assert.ok(!JSON.stringify(sharedSlice(blob, "ws_acme", member())).includes("bot:b")); + // The note in a's chat: by the bot it came from, and one from before that was kept by its thread. + const rows = [ + { id: "n1", chatId: "bot:a", role: "system", from: "b", text: "Bo handed this to Ada", sessionIds: ["s-handed"], at: 1 }, + { id: "n2", chatId: "bot:a", role: "system", text: "Bo handed this to Ada", sessionIds: ["s-handed"], at: 2 }, + { id: "n3", chatId: "bot:a", role: "system", from: "a", text: "Ada took it on", sessionIds: ["s-own"], at: 3 }, + { id: "n4", chatId: "bot:a", role: "system", text: "Two threads", sessionIds: ["s-handed", "s-own"], at: 4 }, + // Its thread deleted since: still known by who it came from. + { id: "n5", chatId: "bot:a", role: "system", from: "b", text: "Bo handed this to Ada", sessionIds: [], at: 5 }, + ]; + const bots = new Set(["a"]); + const handed = handedByOthers(blob, bots); + assert.deepEqual([...handed], ["s-handed"]); + assert.deepEqual(sharedMessages(rows, bots, handed).map((m) => m.id), ["n3", "n4"]); + assert.deepEqual(sharedMessages(rows).map((m) => m.id), ["n1", "n2", "n3", "n4", "n5"], "the owner sees every note"); + assert.equal(sharedMessages(rows)[0].from, undefined, "who it came from is never shown, even to the owner's copy"); +}); + +test("the main bot's digest of what every bot's watches found tells someone only the lines of bots they have", () => { + // Boppy posts it for Iris's watch on the bank and its own on the inbox (lib/server/attention.ts flush). + const digest = { + id: "d1", + chatId: "bot:boppy", + role: "bot", + botId: "boppy", + text: "While you were busy:\n• Bank: “Withdrawal of $4,800, balance $212”\n• Inbox: “Dana wrote back”", + held: [ + { botId: "iris", text: "Bank: “Withdrawal of $4,800, balance $212”" }, + { botId: "boppy", text: "Inbox: “Dana wrote back”" }, + ], + at: 1, + }; + const onlyIris = { ...digest, id: "d2", text: "While you were busy:\n• Bank: “Card declined”", held: [{ botId: "iris", text: "Bank: “Card declined”" }] }; + // One from before its lines said whose they were: nobody but the owner can tell. + const before = { id: "d3", chatId: "bot:boppy", role: "bot", botId: "boppy", text: "While you were busy:\n• Bank: “Overdrawn”", at: 2 }; + const rows = [digest, onlyIris, before]; + const ava = sharedMessages(rows, new Set(["boppy"])); + assert.deepEqual(ava.map((m) => m.id), ["d1"], "a member given Boppy alone"); + assert.equal(ava[0].text, "While you were busy:\n• Inbox: “Dana wrote back”"); + assert.ok(!JSON.stringify(ava).includes("Withdrawal") && !JSON.stringify(ava).includes("iris"), "nothing of Iris's watch, nor whose lines there were"); + assert.deepEqual( + sharedMessages(rows, new Set(["boppy", "iris"])).map((m) => m.text), + [digest.text, onlyIris.text], + "given both: every line, as posted (the one from before stays the owner's)", + ); + assert.deepEqual(sharedMessages(rows).map((m) => m.text), [digest.text, onlyIris.text, before.text], "the owner: all of it"); + assert.equal(sharedMessages(rows)[0].held, undefined, "whose lines they are is never shown, even to the owner's copy"); + // A bot's own reply that happens to read like it is a digest only when it says so. + assert.equal(sharedMessage({ id: "r1", chatId: "bot:boppy", role: "user", text: "While you were busy:\nI fed the cat", at: 3 }, new Set(["boppy"]))?.id, "r1"); +}); + +test("bot access as kept: a level is read only off what's kept for that bot, and nothing kept is more than was given", () => { + const none: MemberAccess = { others: "none", bots: {} }; + assert.equal(levelOf(none, "constructor"), "none", "a bot named Constructor isn't read off the object"); + assert.equal(levelOf({ others: "read", bots: {} }, "toString"), "read"); + assert.equal(levelOf({ others: "read", bots: { a: "admin" } } as unknown as MemberAccess, "a"), "none", "what isn't a level is none"); + assert.equal(accessSummary(none, ["boppy", "constructor"]), "No bots"); + const long = "x".repeat(81); + assert.equal(cleanAccess({ others: "read", bots: { [long]: "none" } }), null, "a bot it can't name, hidden: turned down, never shown"); + assert.deepEqual(cleanAccess({ others: "none", bots: { [long]: "read" } }), none, "left out when that gives them less"); + const ids = Array.from({ length: MAX_ACCESS_BOTS + 1 }, (_, i) => `b${i}`); + assert.equal(cleanAccess({ others: "write", bots: Object.fromEntries(ids.map((id) => [id, "none"])) }), null); + assert.equal(Object.keys(cleanAccess({ others: "none", bots: Object.fromEntries(ids.map((id) => [id, "read"])) })!.bots).length, MAX_ACCESS_BOTS); +}); + +test("a message written in a chat a member doesn't see never wakes their stream; one in theirs does", () => { + const h = new LiveHub({ now: () => 1000 }); + const target = { account: OWNER, ws: "ws_acme" }; + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }); + const ann = heard(liveScope(blob, "ws_acme", member())); + const own = heard(); + h.open(target, ann, null); + h.open(target, own, null); + stateWritten(OWNER, blob, "mac-o", h); + for (const [chat, seq] of [["bot:b", 41], ["both", 42], ["bot:a", 43], ["", 44], ["just-a", 45]] as const) messagesWritten(OWNER, [chat], seq, "mac-o", h); + const seqs = (s: { got: Heard[] }) => s.got.filter((g) => g.type === "state" && g.data.seq !== undefined).map((g) => g.data.seq); + assert.deepEqual(seqs(own), [41, 42, 43, 44, 45]); + assert.deepEqual(seqs(ann), [43, 44, 45], "b's chat and the group with b stay quiet; a removal, whose chat isn't known, still goes"); + // Coming back from before them: replayed the same way. + const back = heard(liveScope(blob, "ws_acme", member())); + h.open(target, back, own.got.find((g) => g.type === "state")!.id!); + assert.deepEqual(seqs(back), [43, 44, 45]); + h.closeAll(); +}); + +test("two bots' conversation with each other (Asked ● Bo) is read and wakes the stream of someone given both, never of someone given one", () => { + const h = new LiveHub({ now: () => 1000 }); + const target = { account: OWNER, ws: "ws_acme" }; + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read", b: "read" } }, "cy@acme.com": { others: "none", bots: { a: "read" } } }); + const both = sharedSlice(blob, "ws_acme", member()); + assert.deepEqual(chatIdsOf(both), ["bot:a", "bot:b", "both", "just-a", "pair:a:b"]); + assert.deepEqual(chatIdsOf(sharedSlice(blob, "ws_acme", member({ id: "u-cy", email: "cy@acme.com" }))), ["bot:a", "just-a"], "Bo hidden: no conversation with Bo"); + assert.deepEqual(pairChatsOf(["sam", "max", "dana"]), ["pair:dana:max", "pair:dana:sam", "pair:max:sam"], "as lib/types.ts pairChatId names them"); + const ann = heard(liveScope(blob, "ws_acme", member())); + const cy = heard(liveScope(blob, "ws_acme", member({ id: "u-cy", email: "cy@acme.com" }))); + h.open(target, ann, null); + h.open(target, cy, null); + stateWritten(OWNER, blob, "mac-o", h); + messagesWritten(OWNER, ["pair:a:b"], 51, "mac-o", h); + const seqs = (s: { got: Heard[] }) => s.got.filter((g) => g.type === "state" && g.data.seq !== undefined).map((g) => g.data.seq); + assert.deepEqual(seqs(ann), [51]); + assert.deepEqual(seqs(cy), []); + h.closeAll(); + // Ada's answer in her chat says where in that conversation she asked Bo: kept, so the chip opens it there. + const said = { id: "m1", chatId: "bot:a", role: "bot", botId: "a", text: "It's ready", at: 2, asked: [{ botId: "b", question: "Is the deck ready?", answer: "Yes", questionId: "q1" }] }; + assert.deepEqual(sharedMessage(said, botsOf(both))?.asked, [{ botId: "b", question: "Is the deck ready?", answer: "Yes", questionId: "q1" }]); +}); + +test("a member's stream counts as theirs, so members can't use up the owner's", () => { + const hub = new LiveHub({ now: () => 0, perAccount: 1 }); + const target = { account: OWNER, ws: "ws_acme" }; + const sub = (caller?: string): LiveSub => ({ write: () => {}, end: () => {}, ...(caller ? { caller } : {}) }); + assert.equal(hub.open(target, sub("u-member"), null), "ok"); + assert.equal(hub.open(target, sub("u-member"), null), "account_full"); + assert.equal(hub.open(target, sub(), null), "ok", "the owner's own device still gets one"); + assert.equal(hub.streamsOf("u-member"), 1); + assert.equal(hub.streamsOf(OWNER), 1); +}); + +type Heard = { type: string; id?: string; data: Record }; +/** A stream that keeps what it's sent, parsed (held to `scope` when one's given). */ +function heard(scope?: LiveSub["scope"]): LiveSub & { got: Heard[] } { + const got: Heard[] = []; + return { + got, + write(chunk: string) { + for (const block of chunk.split("\n\n").filter((b) => b && !b.startsWith(":"))) { + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + got.push({ type: field("event") ?? "", id: field("id"), data: JSON.parse(field("data") ?? "{}") }); + } + }, + end() {}, + caller: "u-ann", + ...(scope ? { scope } : {}), + }; +} +const said = (s: { got: Heard[] }) => s.got.filter((g) => !["snapshot", "reset"].includes(g.type)).map((g) => `${g.type}:${String(g.data.botId ?? g.data.id ?? "")}`); + +test("a member's stream carries only what's of the bots they see; the owner's carries everything", () => { + let t = 1000; + const h = new LiveHub({ now: () => t }); + const target = { account: OWNER, ws: "ws_acme" }; + const blob = team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }); + const own = heard(); + const ann = heard(liveScope(blob, "ws_acme", member())); + h.open(target, own, null); + h.open(target, ann, null); + const ev = { by: "mac-o", at: 0 }; + h.publish(target, { type: "mood", botId: "a", chatId: "both", mood: "thinking", ...ev }); + h.publish(target, { type: "mood", botId: "b", mood: "thinking", ...ev }); + h.publish(target, { type: "session", id: "s-a", botId: "a", status: "running", ...ev }); + h.publish(target, { type: "session", id: "s-b", botId: "b", status: "running", ...ev }); + h.publish(target, { type: "routine", id: "r-a", change: "run-started", ...ev }); + h.publish(target, { type: "routine", id: "r-b", change: "run-started", ...ev }); + h.publish(target, { type: "routine", id: "r-new", change: "added", ...ev }); + h.publish(target, { type: "call", botId: "b", on: true, ...ev }); + h.publish(target, { type: "call", botId: "a", on: true, ...ev }); + h.publish(target, { type: "state", hash: "owners-hash", ...ev }); + h.publish(target, { type: "state", seq: 9, ...ev }); + assert.deepEqual(said(own), ["mood:a", "mood:b", "session:a", "session:b", "routine:r-a", "routine:r-b", "routine:r-new", "call:b", "call:a", "state:", "state:"]); + assert.deepEqual(said(ann), ["mood:a", "session:a", "routine:r-a", "call:a", "state:", "state:"], "a routine not in the owner's state yet is held back: the state that follows tells them"); + const mood = ann.got.find((g) => g.type === "mood")!; + assert.equal(mood.data.chatId, undefined, "a's mood, without the group chat they don't see"); + assert.equal(own.got.find((g) => g.type === "mood")!.data.chatId, "both"); + const [hashed, seq] = ann.got.filter((g) => g.type === "state"); + assert.equal(hashed.data.hash, sharedHash(sharedSlice(blob, "ws_acme", member())), "their own view's fingerprint, never the owner's"); + assert.equal(seq.data.hash, undefined, "a message write's carries none: always read"); + assert.equal(seq.data.seq, 9); + // Coming back: the snapshot has a's mood alone, and what's replayed is theirs alone. + const firstId = own.got.find((g) => g.type === "session")!.id!; + const back = heard(liveScope(blob, "ws_acme", member())); + h.open(target, back, firstId); + assert.deepEqual((back.got[0].data.moods as { botId: string }[]).map((m) => m.botId), ["a"]); + assert.equal((back.got[0].data.moods as { chatId?: string }[])[0].chatId, undefined); + assert.deepEqual(said(back), ["routine:r-a", "call:a", "state:", "state:"]); + // Moods running out: b's idle isn't theirs either. + t += 61_000; + h.sweep(); + assert.deepEqual(said(ann).slice(-1), ["mood:a"]); + assert.equal(said(own).filter((x) => x === "mood:b").length, 2); + // Someone the owner gave nothing hears no bot at all. + const nobody = heard(liveScope(blob, "ws_acme", member({ id: "u-x", email: "x@acme.com" }))); + h.open(target, nobody, null); + h.publish(target, { type: "mood", botId: "a", mood: "working", ...ev }); + h.publish(target, { type: "session", id: "s-a", botId: "a", status: "done", ...ev }); + assert.deepEqual(said(nobody), []); + h.closeAll(); +}); + +test("when the owner's state is written, a member's stream is held to what it gives them now", () => { + const h = new LiveHub({ now: () => 1000 }); + const target = { account: OWNER, ws: "ws_acme" }; + const before = team({ "ann@acme.com": { others: "none", bots: { a: "read" } } }); + const ann = heard(liveScope(before, "ws_acme", member())); + const own = heard(); + h.open(target, ann, null); + h.open(target, own, null); + const ev = { by: "mac-o", at: 0 }; + h.publish(target, { type: "mood", botId: "b", mood: "thinking", ...ev }); + // The owner gives Ann b, and takes a away. + const after = team({ "ann@acme.com": { others: "none", bots: { b: "read" } } }); + stateWritten(OWNER, after, "mac-o", h); + h.publish(target, { type: "mood", botId: "b", mood: "working", ...ev }); + h.publish(target, { type: "mood", botId: "a", mood: "working", ...ev }); + assert.deepEqual(said(ann), ["state:", "mood:b"]); + assert.equal(ann.got.find((g) => g.type === "state")!.data.hash, sharedHash(sharedSlice(after, "ws_acme", member()))); + assert.equal(own.got.find((g) => g.type === "state")!.data.hash, sharedHash(sharedSlice(after, "ws_acme", OWNER_VIEW)), "the owner's devices: the owner's"); + // A write that changes nothing of theirs leaves the scope as it was. + const scope = ann.scope; + stateWritten(OWNER, { ...after, usage: { events: [1] } }, "mac-o", h); + assert.equal(ann.scope, scope); + h.closeAll(); +}); + +/* ---------------- Over HTTP ---------------- */ + +const SECRET = "test-plan-secret-0123456789abcdef0123456789"; // gitleaks:allow (made up for this test) +let orgo: Server; +let cloud: Server; +let base = ""; +/** bops-role's answers by " ", as the fake orgo-web gives them (anything else 404). */ +const roles: Record = {}; +/** " "s bops-role answers 503 for, as orgo-web in trouble. */ +const failing = new Set(); +/** The email Orgo's profile gives each user who has one. */ +const emails: Record = { "http-mia": "Mia@Acme.com" }; +/** Each owner's saved state, as the cloud would read it (members.ts resetMembers `state`). */ +const owners: Record> = { + "http-owner": { + workspaces: [ + { + id: "ws_acme", + name: "Acme", + memberAccess: { "user:http-member": { others: "none", bots: { max: "read" } }, "mia@acme.com": { others: "none", bots: { max: "read" } }, "user:http-a": { others: "read", bots: {} }, "user:http-b": { others: "read", bots: {} } }, + }, + ], + bots: [{ id: "max", name: "Max", workspaceId: "ws_acme" }, { id: "sam", name: "Sam", workspaceId: "ws_acme" }], + chats: [{ id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme" }, { id: "bot:sam", kind: "bot", botIds: ["sam"], workspaceId: "ws_acme" }], + }, +}; +const savedState = async (account: string) => owners[account] ?? {}; + +before(async () => { + process.env.BOPS_CLOUD_PLAN_SECRET = SECRET; + // Orgo: the key "key-" is 's; its bops-role answers from `roles`. + orgo = createServer((req, res) => { + const id = /^Bearer key-(.+)$/.exec(req.headers.authorization ?? "")?.[1]; + if (!id) return void res.writeHead(401).end(); + if (req.url === "/api/user/profile") return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id, ...(emails[id] ? { email: emails[id] } : {}) })); + const project = /^\/api\/projects\/([^/]+)\/bops-role$/.exec(req.url ?? "")?.[1]; + if (failing.has(`${id} ${project}`)) return void res.writeHead(503).end(); + const a = project && roles[`${id} ${project}`]; + if (!a) return void res.writeHead(404, { "content-type": "application/json" }).end("{}"); + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(a)); + }); + await new Promise((r) => orgo.listen(0, "127.0.0.1", r)); + process.env.BOPS_ORGO_ORIGIN = `http://127.0.0.1:${(orgo.address() as AddressInfo).port}`; + const { makeServer } = await import("../server.ts"); + cloud = makeServer(); + await new Promise((r) => cloud.listen(0, "127.0.0.1", r)); + base = `http://127.0.0.1:${(cloud.address() as AddressInfo).port}`; +}); + +after(async () => { + hub.closeAll(); + cloud.closeAllConnections(); + await new Promise((r) => cloud.close(r)); + await new Promise((r) => orgo.close(r)); +}); + +type Got = { type: string; data: Record }; +/** Open a stream (`?ws=` or `?project=`) and read its events as they come; `ended` once the cloud ends it. */ +async function stream(user: string, query: string) { + const ctl = new AbortController(); + const res = await fetch(`${base}/v1/live?${query}`, { headers: { authorization: `Bearer key-${user}` }, signal: ctl.signal }); + const got: Got[] = []; + const state = { ended: false }; + if (res.ok) + void (async () => { + const dec = new TextDecoder(); + let text = ""; + try { + for await (const chunk of res.body!) { + text += dec.decode(chunk as Uint8Array, { stream: true }); + for (let i = text.indexOf("\n\n"); i >= 0; i = text.indexOf("\n\n")) { + const block = text.slice(0, i); + text = text.slice(i + 2); + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + if (field("event")) got.push({ type: field("event")!, data: JSON.parse(field("data") ?? "{}") }); + } + } + } catch {} + state.ended = true; + })(); + return { res, got, state, close: () => ctl.abort() }; +} + +const publish = (user: string, ws: string, events: unknown[]) => + fetch(`${base}/v1/live`, { method: "POST", headers: { authorization: `Bearer key-${user}`, "content-type": "application/json", "x-bops-device": `${user}-mac` }, body: JSON.stringify({ ws, events }) }); + +async function until(check: () => T | undefined | false, what: string): Promise { + for (const end = Date.now() + 3000; Date.now() < end; await new Promise((r) => setTimeout(r, 20))) { + const v = check(); + if (v) return v; + } + assert.fail(`timed out waiting for ${what}`); +} + +const signed = (body: string, secret = SECRET) => { + const ts = String(Math.floor(Date.now() / 1000)); + return { "content-type": "application/json", "x-bops-timestamp": ts, "x-bops-signature": `sha256=${createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex")}` }; +}; +const notice = (body: unknown, secret?: string) => { + const text = JSON.stringify(body); + return fetch(`${base}/v1/internal/members-changed`, { method: "POST", headers: signed(text, secret), body: text }); +}; + +test("over HTTP: a member's stream is the owner's workspace stream, and what a member publishes stays in their own account", async () => { + resetMembers({ state: savedState }); + roles["http-member proj-1"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + const owner = await stream("http-owner", "ws=ws_acme"); + const member = await stream("http-member", "project=proj-1"); + assert.equal(member.res.status, 200); + await until(() => member.got.some((g) => g.type === "reset"), "the member's stream to start"); + // The owner's Mac says Max is thinking: the member's stream hears it. + assert.equal((await publish("http-owner", "ws_acme", [{ type: "mood", botId: "max", mood: "thinking" }])).status, 200); + const m = await until(() => member.got.find((g) => g.type === "mood"), "the owner's mood"); + assert.equal(m.data.by, "http-owner-mac"); + // The member publishes into "ws_acme": it lands in the member's own account, never the owner's. + assert.equal((await publish("http-member", "ws_acme", [{ type: "mood", botId: "max", mood: "working" }])).status, 200); + await new Promise((r) => setTimeout(r, 100)); + assert.deepEqual( + owner.got.filter((g) => g.type === "mood").map((g) => g.data.mood), + ["thinking"], + ); + assert.equal(member.got.filter((g) => g.type === "mood").length, 1); + assert.equal(hub.streamsOf("http-member"), 1, "counted as the member's"); + owner.close(); + member.close(); +}); + +test("over HTTP: someone orgo-web doesn't name, or a project with no workspace of its own, gets no stream", async () => { + resetMembers(); + roles["http-legacy proj-legacy"] = { role: "member", ownerUserId: "http-owner", bopsWsId: null, name: "bops" }; + assert.equal((await stream("http-stranger", "project=proj-1")).res.status, 404); + assert.equal((await stream("http-legacy", "project=proj-legacy")).res.status, 409); + assert.equal((await stream("http-member", "project=..%2F")).res.status, 400); + assert.equal((await fetch(`${base}/v1/ws/proj-1/state`, { headers: { authorization: "Bearer key-http-stranger" } })).status, 404); + assert.equal((await fetch(`${base}/v1/ws/proj-legacy/state`, { headers: { authorization: "Bearer key-http-legacy" } })).status, 409); +}); + +test("over HTTP: a member removed while watching hears revoked and the stream ends", async () => { + resetMembers({ every: 50, state: savedState }); + roles["http-gone proj-2"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + const s = await stream("http-gone", "project=proj-2"); + assert.equal(s.res.status, 200); + await until(() => s.got.some((g) => g.type === "reset"), "the stream to start"); + await new Promise((r) => setTimeout(r, 150)); + assert.equal(s.state.ended, false, "still a member: it carries on"); + delete roles["http-gone proj-2"]; + await until(() => s.got.some((g) => g.type === "revoked"), "revoked"); + await until(() => s.state.ended, "the stream to end"); + assert.equal(memberStreams("http-gone", "proj-2"), 0); + assert.equal(hub.streamsOf("http-gone"), 0); + resetMembers(); +}); + +test("over HTTP: a member's stream carries on through a look Orgo can't answer, and ends (without revoked) after UNSURE_LOOKS in a row", async () => { + resetMembers({ every: 50, state: savedState }); + roles["http-flaky proj-4"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + const s = await stream("http-flaky", "project=proj-4"); + assert.equal(s.res.status, 200); + await until(() => s.got.some((g) => g.type === "reset"), "the stream to start"); + // Orgo answers 503 from now on: a removed member's notice may have been lost, so the stream can't watch on. + failing.add("http-flaky proj-4"); + await until(() => s.state.ended, "the stream to end"); + assert.equal(s.got.some((g) => g.type === "revoked"), false, "not revoked: the member may still be one"); + assert.equal(memberStreams("http-flaky", "proj-4"), 0); + assert.ok(UNSURE_LOOKS > 1, "one look Orgo couldn't answer doesn't end it"); + // Coming back asks Orgo again: while it can't say, no stream. + assert.equal((await stream("http-flaky", "project=proj-4")).res.status, 503); + failing.delete("http-flaky proj-4"); + resetMembers(); +}); + +test("over HTTP: orgo-web's signed notice ends the member's streams of that project, and nobody else's", async () => { + resetMembers({ state: savedState }); + roles["http-a proj-3"] = { role: "admin", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + roles["http-b proj-3"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + const a = await stream("http-a", "project=proj-3"); + const b = await stream("http-b", "project=proj-3"); + await until(() => a.got.some((g) => g.type === "reset") && b.got.some((g) => g.type === "reset"), "both streams to start"); + assert.equal(memberStreams("http-a", "proj-3"), 1); + // Unsigned, signed with another secret, or not the right shape: refused, and nothing ends. + assert.equal((await fetch(`${base}/v1/internal/members-changed`, { method: "POST", body: JSON.stringify({ projectId: "proj-3", userId: "http-a" }) })).status, 401); + assert.equal((await notice({ projectId: "proj-3", userId: "http-a" }, "another-secret-0123456789abcdef0123")).status, 401); + assert.equal((await notice({ projectId: "proj-3" })).status, 400); + assert.equal(memberStreams("http-a", "proj-3"), 1); + delete roles["http-a proj-3"]; + assert.equal((await notice({ projectId: "proj-3", userId: "http-a" })).status, 204); + await until(() => a.state.ended, "a's stream to end"); + assert.equal(memberStreams("http-a", "proj-3"), 0); + assert.equal(b.state.ended, false); + // Coming back asks orgo-web afresh: no longer a member. + assert.equal((await stream("http-a", "project=proj-3")).res.status, 404); + b.close(); +}); + +test("over HTTP: a member's stream hears only the bots they see, found by their email in any case; one the state can't be read for doesn't open", async () => { + resetMembers({ state: savedState }); + roles["http-mia proj-5"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + roles["http-stray proj-5"] = { role: "member", ownerUserId: "http-owner", bopsWsId: "ws_acme", name: "Acme" }; + const owner = await stream("http-owner", "ws=ws_acme"); + const mia = await stream("http-mia", "project=proj-5"); + const stray = await stream("http-stray", "project=proj-5"); + assert.equal(mia.res.status, 200); + await until(() => [owner, mia, stray].every((s) => s.got.some((g) => g.type === "reset")), "the streams to start"); + assert.equal( + ( + await publish("http-owner", "ws_acme", [ + { type: "mood", botId: "sam", mood: "thinking" }, + { type: "call", botId: "sam", on: true }, + { type: "mood", botId: "max", mood: "working" }, + ]) + ).status, + 200, + ); + await until(() => mia.got.some((g) => g.type === "mood") && owner.got.filter((g) => g.type === "mood").length === 2, "the moods"); + await new Promise((r) => setTimeout(r, 100)); + assert.deepEqual( + mia.got.filter((g) => !["snapshot", "reset"].includes(g.type)).map((g) => `${g.type}:${String(g.data.botId)}`), + ["mood:max"], + ); + assert.deepEqual(stray.got.filter((g) => !["snapshot", "reset"].includes(g.type)), [], "no entry: no bot"); + // The owner's devices hear all of it, as before. + assert.deepEqual( + owner.got.filter((g) => !["snapshot", "reset"].includes(g.type)).map((g) => `${g.type}:${String(g.data.botId)}`), + ["mood:sam", "call:sam", "mood:max"], + ); + for (const s of [owner, mia, stray]) s.close(); + // The owner's state can't be read: no stream, rather than one that isn't held to what they see. + resetMembers({ state: () => Promise.reject(new Error("db down")) }); + assert.notEqual((await stream("http-mia", "project=proj-5")).res.status, 200); + resetMembers(); +}); + +test("over HTTP: a member's stream hears every thread of a bot they were given, one kept between us too, and nothing of a bot they weren't", async () => { + owners["http-dana"] = { + workspaces: [{ id: "ws_acme", name: "Acme", memberAccess: { "user:http-ava": { others: "none", bots: { max: "read" } } } }], + bots: [{ id: "max", name: "Max", workspaceId: "ws_acme" }, { id: "sam", name: "Sam", workspaceId: "ws_acme" }], + chats: [{ id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme" }, { id: "bot:sam", kind: "bot", botIds: ["sam"], workspaceId: "ws_acme" }], + sessions: [ + { id: "s-kept", botId: "max", chatId: "bot:max", title: "Raises", status: "running", secret: true }, + { id: "s-sam", botId: "sam", chatId: "bot:sam", title: "Sam's", status: "running" }, + ], + routines: [{ id: "r-max", botId: "max", title: "Max daily" }, { id: "r-sam", botId: "sam", title: "Sam daily" }], + }; + resetMembers({ state: savedState }); + roles["http-ava proj-7"] = { role: "member", ownerUserId: "http-dana", bopsWsId: "ws_acme", name: "Acme" }; + const ava = await stream("http-ava", "project=proj-7"); + assert.equal(ava.res.status, 200); + await until(() => ava.got.some((g) => g.type === "reset"), "the stream to start"); + const sent = await publish("http-dana", "ws_acme", [ + { type: "session", id: "s-kept", botId: "max", status: "running", activity: "Reading payroll" }, + { type: "session", id: "s-sam", botId: "sam", status: "running" }, + { type: "routine", id: "r-sam", change: "run-started" }, + { type: "call", botId: "sam", on: true }, + { type: "routine", id: "r-max", change: "run-started" }, + ]); + assert.equal(sent.status, 200); + await until(() => ava.got.some((g) => g.type === "routine"), "Max's routine"); + await new Promise((r) => setTimeout(r, 100)); + const heardNow = ava.got.filter((g) => !["snapshot", "reset"].includes(g.type)); + assert.deepEqual(heardNow.map((g) => `${g.type}:${String(g.data.id ?? g.data.botId)}`), ["session:s-kept", "routine:r-max"]); + assert.equal(heardNow[0].data.activity, "Reading payroll"); + ava.close(); + delete owners["http-dana"]; + resetMembers(); +}); + +test("over HTTP: an owner's write that lands while a member's stream reads their state is still what the stream is held to", async () => { + roles["http-mia proj-6"] = { role: "member", ownerUserId: "http-race", bopsWsId: "ws_acme", name: "Acme" }; + const access = (bots: Record) => ({ + workspaces: [{ id: "ws_acme", name: "Acme", memberAccess: { "mia@acme.com": { others: "none", bots } } }], + bots: [{ id: "max", workspaceId: "ws_acme" }, { id: "sam", workspaceId: "ws_acme" }], + }); + owners["http-race"] = access({ max: "read" }); + // The first read is slow: the owner gives Mia sam (and takes max) while it's on its way, and it comes back with the state from before. + let hold: (() => void) | undefined; + let reads = 0; + resetMembers({ + state: async (account) => { + const was = owners[account] ?? {}; + if (reads++ === 0) await new Promise((r) => (hold = r)); + return was; + }, + }); + const opening = stream("http-mia", "project=proj-6"); + await until(() => hold, "the state read to start"); + owners["http-race"] = access({ sam: "read" }); + stateWritten("http-race", owners["http-race"], "mac-r", hub); + hold!(); + const mia = await opening; + assert.equal(mia.res.status, 200); + await until(() => mia.got.some((g) => g.type === "reset") && reads === 2, "the stream to start and read again"); + await new Promise((r) => setTimeout(r, 50)); + assert.equal((await publish("http-race", "ws_acme", [{ type: "mood", botId: "max", mood: "working" }, { type: "mood", botId: "sam", mood: "thinking" }])).status, 200); + await until(() => mia.got.some((g) => g.type === "mood"), "sam's mood"); + await new Promise((r) => setTimeout(r, 100)); + assert.deepEqual( + mia.got.filter((g) => g.type === "mood").map((g) => g.data.botId), + ["sam"], + ); + mia.close(); + resetMembers(); +}); diff --git a/cloud/test/core-turn-guard.test.ts b/cloud/test/core-turn-guard.test.ts index b9752fc7..1e030a5b 100644 --- a/cloud/test/core-turn-guard.test.ts +++ b/cloud/test/core-turn-guard.test.ts @@ -155,10 +155,11 @@ async function prepareLedger() { before(async () => { await prepareDb(); - await prepareLedger(); + // Held from before the ledger is made again: its grants would otherwise give back the access core-credit's test takes away for a moment. ledger = new pg.Client({ connectionString: TEST_DATABASE_URL }); await ledger.connect(); await ledger.query("SELECT pg_advisory_lock_shared(hashtext($1))", [LEDGER_IN_USE]); + await prepareLedger(); process.env.BOPS_AI_CREDITS = "1"; // Sweeps by hand; a stopped turn's use waited on for 3 seconds; a waiting turn read at every sweep. Object.assign(guardTiming, { everyMs: 40, settleMs: 3_000, waitingMs: 0 }); diff --git a/cloud/test/edge-computer.test.ts b/cloud/test/edge-computer.test.ts new file mode 100644 index 00000000..1d45aba6 --- /dev/null +++ b/cloud/test/edge-computer.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { after, before, test } from "node:test"; +import { WebSocket } from "ws"; +import { clearAuthCache, userForKey } from "../auth.ts"; +import { closeDb } from "../db.ts"; +import { deviceIdOf } from "../device.ts"; +import { COMPUTER_NOT_ALLOWED, type CloudStateHead } from "../protocol.ts"; +import { routes, upgrades } from "../server.ts"; +import { dropUsers, keyOf, listen, newUserId, prepareDb, startCloud, type Listening } from "./edge-fakes.ts"; + +/** + * A computer's runner signing in as its computer (cloud/auth.ts, server.ts): an `orgo_vm_` token is a + * computer whatever Orgo says (and refused everywhere without the principal Orgo names it by), a + * computer's own key is one too, each kept 30 seconds at most and never past the token's end; a computer + * calls only the routes its runner needs (its state, its threads, its session, the providers' routes, + * where what it may call is each provider's: core-computer-scope.test.ts) and is refused on phone lines, + * mail handles, Slack, numbers, backups, notices, usage, the iPhone's agent and the tunnel; its device is + * Bops Cloud's to set, and another one it sends is refused; Orgo's code for turning a token down is + * passed on. + */ + +let orgo: Listening; +let cloud: Listening; +const users: string[] = []; +const user = (what: string) => { + const id = newUserId(what); + users.push(id); + return id; +}; +const D1 = "c1c1c1c1-0000-4000-8000-0000000000aa"; +const D2 = "c2c2c2c2-0000-4000-8000-0000000000bb"; +/** Profile asks, by key. */ +const asked = new Map(); + +const json = (res: ServerResponse, status: number, body: unknown) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); + +/** + * Orgo's GET /api/user/profile, faked: `key-` is the user's own key; `orgo_vm_~` a + * computer's identity token (its principal ends in an hour, or `orgo_vm_soon-…` in 1.5 seconds); + * `orgo_vm_bare-` a token whose profile names no computer; `ck-~` a computer's own + * key; `orgo_vm_stale-…` and `orgo_vm_notready-…` turned down with Orgo's code. + */ +function profile(req: IncomingMessage, res: ServerResponse) { + const key = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1] ?? ""; + asked.set(key, (asked.get(key) ?? 0) + 1); + if (req.url !== "/api/user/profile") return json(res, 404, {}); + let m: RegExpExecArray | null; + if ((m = /^key-(.+)$/.exec(key))) return json(res, 200, { id: m[1], email: `${m[1]}@example.com` }); + if (key.startsWith("orgo_vm_stale-")) return json(res, 401, { error: "stale", code: "identity_stale" }); + if (key.startsWith("orgo_vm_notready-")) return json(res, 403, { error: "not yet", code: "bops_cloud_not_ready" }); + if ((m = /^orgo_vm_bare-(.+)$/.exec(key))) return json(res, 200, { id: m[1] }); + if ((m = /^orgo_vm_(soon-)?([^~]+)~([^~]+)$/.exec(key))) + return json(res, 200, { id: m[2], api_key: null, principal: { kind: "vm_identity", desktop_id: m[3], expires_at: new Date(Date.now() + (m[1] ? 1500 : 3600_000)).toISOString() } }); + if ((m = /^ck-([^~]+)~([^~]+)$/.exec(key))) return json(res, 200, { id: m[1], api_key: { id: "k1", scope: "computer", computer_id: m[2], computer_key: true } }); + json(res, 401, { error: "no" }); +} + +before(async () => { + await prepareDb(); + orgo = await listen(createServer(profile)); + process.env.BOPS_ORGO_ORIGIN = orgo.url; + cloud = await startCloud(); +}); + +after(async () => { + await cloud.close(); + await orgo.close(); + await dropUsers(users); + await closeDb(); +}); + +const vm = (u: string, d = D1) => `orgo_vm_${u}~${d}`; +const ck = (u: string, d = D1) => `ck-${u}~${d}`; + +/** A call to the cloud with `key`, as a newer build makes it (its user named, protocol 2). */ +function call(key: string, userId: string, path: string, init: { method?: string; body?: unknown; device?: string } = {}) { + const headers: Record = { Authorization: `Bearer ${key}`, "x-bops-user": userId, "x-bops-protocol": "2" }; + if (init.device) headers["x-bops-device"] = init.device; + if (init.body !== undefined) headers["content-type"] = "application/json"; + return fetch(`${cloud.url}${path}`, { method: init.method ?? "GET", headers, body: init.body === undefined ? undefined : JSON.stringify(init.body) }); +} + +test("an identity token is a computer by Orgo's principal, a computer's key by its scope, and an account key is its user", async () => { + clearAuthCache(); + const u = user("who"); + const a = await userForKey(vm(u, D1.toUpperCase())); + assert.equal(a?.id, u); + assert.deepEqual([a?.computer?.kind, a?.computer?.desktopId, a?.computer?.device], ["vm_identity", D1, deviceIdOf(u, D1)], "lowercased, and its device is the runner's (persist-cloud.ts deviceIdFor of its computer id)"); + assert.ok(a!.computer!.expiresAt > Date.now() + 3500_000); + const k = await userForKey(ck(u)); + assert.deepEqual([k?.computer?.kind, k?.computer?.desktopId, k?.computer?.expiresAt], ["computer_key", D1, Infinity]); + const bare = await userForKey(`orgo_vm_bare-${u}`); + assert.deepEqual([bare?.id, bare?.computer?.kind, bare?.computer?.desktopId], [u, "vm_identity", ""], "an orgo_vm_ token is a computer whatever Orgo says"); + assert.equal((await userForKey(keyOf(u)))?.computer, undefined, "the user's own key: the user"); +}); + +test("a computer's answer is kept 30 seconds at most, and never past its token's end; the user's, 5 minutes", async () => { + clearAuthCache(); + const u = user("cache"); + const token = vm(u); + await userForKey(token); + await userForKey(token); + assert.equal(asked.get(token), 1, "kept"); + const real = Date.now; + try { + Date.now = () => real() + 31_000; + await userForKey(token); + assert.equal(asked.get(token), 2, "asked again after 30 seconds"); + await userForKey(keyOf(u)); + Date.now = () => real() + 31_000 + 60_000; + await userForKey(keyOf(u)); + assert.equal(asked.get(keyOf(u)), 1, "a user's key: still kept"); + } finally { + Date.now = real; + } + const soon = `orgo_vm_soon-${u}~${D1}`; + await userForKey(soon); + await userForKey(soon); + assert.equal(asked.get(soon), 1); + await new Promise((r) => setTimeout(r, 1600)); + await userForKey(soon); + assert.equal(asked.get(soon), 2, "its token ended: asked again"); +}); + +test("a computer calls its state, its threads and its session; everything else of its user's is 403", async () => { + clearAuthCache(); + const u = user("routes"); + for (const key of [vm(u), ck(u)]) { + const head = await call(key, u, "/v1/state/head"); + assert.equal(head.status, 200, `${key}: its state`); + assert.equal((await call(key, u, "/v1/messages?after=0&limit=10")).status, 200, "its threads"); + for (const [method, path] of [ + ["GET", "/v1/notices"], + ["POST", "/v1/notices/dismiss"], + ["POST", "/v1/state/backups"], + ["GET", "/v1/phone/lines"], + ["PUT", "/v1/phone/lines"], + ["POST", "/v1/phone/lines/unlink"], + ["GET", "/v1/mail/handles"], + ["POST", "/v1/mail/handle"], + ["PUT", "/v1/slack/links"], + ["POST", "/v1/verify/start"], + ["GET", "/v1/usage"], + ["GET", "/v1/agent"], + ["GET", "/proxy/agentphone/v1/numbers"], + ]) { + const r = await call(key, u, path, { method, body: method === "GET" ? undefined : {} }); + assert.equal(r.status, 403, `${key}: ${method} ${path}`); + const body = (await r.json()) as { error?: string; code?: string }; + assert.deepEqual([body.code, body.error], [COMPUTER_NOT_ALLOWED, "A computer's identity can't do this."]); + } + } + // Its user, on their own key: not refused for being a computer. + const n = await call(keyOf(u), u, "/v1/notices"); + assert.notEqual(n.status, 403); +}); + +test("a token Orgo named no computer for is refused on every route, its state too", async () => { + clearAuthCache(); + const u = user("bare"); + const r = await call(`orgo_vm_bare-${u}`, u, "/v1/state/head"); + assert.equal(r.status, 403); + assert.equal(((await r.json()) as { code?: string }).code, COMPUTER_NOT_ALLOWED); +}); + +test("the route table: computers on the runner's routes only (the providers' routes say what of each it may call), the models' socket yes, the tunnel never", () => { + const forComputers = routes() + .filter((r) => r.computer) + .map((r) => `${r.method} ${r.path}`); + for (const want of ["GET /v1/state", "PUT /v1/state", "GET /v1/state/head", "GET /v1/messages", "POST /v1/messages", "POST /v1/session", "POST /proxy/openai/*", "GET /proxy/honcho/*", "POST /v3/*", "POST /proxy/composio/*", "POST /proxy/typesafe/*", "POST /proxy/treg/*", "POST /proxy/agentphone/*", "GET /proxy/agentmail/*"]) + assert.ok(forComputers.includes(want), want); + for (const p of forComputers) assert.ok(/^(GET|PUT|POST|PATCH|DELETE) (\/v1\/(state|state\/head|messages|session)|\/proxy\/(openai|honcho|composio|typesafe|treg|agentphone|agentmail)\/\*|\/v3\/\*)$/.test(p), `only the runner's: ${p}`); + const ups = upgrades(); + assert.equal(ups.find((x) => x.path === "/proxy/openai/*")?.computer, true); + assert.ok(!ups.find((x) => x.path === "/v1/connect")?.computer); +}); + +test("the tunnel (one per user) refuses a computer: its upgrade is 403", async () => { + clearAuthCache(); + const u = user("tunnel"); + const status = await new Promise((resolve) => { + const ws = new WebSocket(`${cloud.url.replace(/^http/, "ws")}/v1/connect`, { headers: { Authorization: `Bearer ${vm(u)}` } }); + ws.on("unexpected-response", (_req, res) => resolve(res.statusCode ?? 0)); + ws.on("open", () => { + ws.close(); + resolve(101); + }); + ws.on("error", () => resolve(-1)); + }); + assert.equal(status, 403); +}); + +test("a computer writes as its own device, which Bops Cloud sets: another device header is 403", async () => { + clearAuthCache(); + const u = user("device"); + const token = vm(u); + const mine = deviceIdOf(u, D1); + // The user's Mac wrote their state first, with the computer's bot: a computer never starts one, and writes only its bots' threads (core-computer-scope.test.ts). + const bots = [{ id: "b1", name: "B", isMain: true, computerId: D1 }]; + assert.equal((await call(keyOf(u), u, "/v1/state", { method: "PUT", body: { base: 0, state: { bots, sessions: [] } } })).status, 200); + const thread = { id: "s1", botId: "b1", by: mine, status: "running" }; + const other = await call(token, u, "/v1/state", { method: "PUT", body: { base: 1, state: { bots, sessions: [thread] } }, device: deviceIdOf(u, D2) }); + assert.equal(other.status, 403, "another computer's device"); + assert.equal((await call(token, u, "/v1/state/head", { device: "mac-a" })).status, 403, "a Mac's"); + const put = await call(token, u, "/v1/state", { method: "PUT", body: { base: 1, state: { bots, sessions: [thread] } } }); + assert.equal(put.status, 200, "no header: its own"); + let head = (await (await call(token, u, "/v1/state/head")).json()) as CloudStateHead; + assert.equal(head.writer, mine, "written as the computer's device, set here"); + const again = await call(token, u, "/v1/state", { method: "PUT", body: { base: head.version, state: { bots, sessions: [{ ...thread, status: "done" }] } }, device: mine }); + assert.equal(again.status, 200, "its own, said: taken"); + head = (await (await call(token, u, "/v1/state/head")).json()) as CloudStateHead; + assert.equal(head.writer, mine); +}); + +test("Orgo's code for turning a token down is passed on with the 401", async () => { + clearAuthCache(); + const u = user("codes"); + const stale = await call(`orgo_vm_stale-${u}`, u, "/v1/state/head"); + assert.equal(stale.status, 401); + assert.equal(((await stale.json()) as { code?: string }).code, "identity_stale"); + const notReady = await call(`orgo_vm_notready-${u}`, u, "/v1/state/head"); + assert.equal(notReady.status, 401); + assert.equal(((await notReady.json()) as { code?: string }).code, "bops_cloud_not_ready"); + const plain = await call("nobody", u, "/v1/state/head"); + assert.equal(plain.status, 401); + assert.equal(((await plain.json()) as { code?: string }).code, undefined); +}); diff --git a/cloud/test/edge-fakes.ts b/cloud/test/edge-fakes.ts index 6cf224b2..1b61ea3b 100644 --- a/cloud/test/edge-fakes.ts +++ b/cloud/test/edge-fakes.ts @@ -130,11 +130,16 @@ const json = (res: ServerResponse, status: number, body: unknown) => res.writeHe /** The cloud itself, on a free port. Its auth asks BOPS_ORGO_ORIGIN, so start fakeOrgo() first. */ export const startCloud = () => listen(makeServer()); -/** Orgo's GET /api/user/profile: the key "key-" is 's; anything else is turned down. */ -export async function fakeOrgo(): Promise { +/** + * Orgo's GET /api/user/profile: the key "key-" is 's; anything else is turned down. With + * `roles`, its GET /api/projects//bops-role too: what's there for " ", else 404. + */ +export async function fakeOrgo(roles?: Record): Promise { const orgo = await listen( createServer((req, res) => { const userId = /^Bearer key-(.+)$/.exec(req.headers.authorization ?? "")?.[1]; + const project = /^\/api\/projects\/([^/]+)\/bops-role$/.exec(req.url ?? "")?.[1]; + if (roles && project && userId) return void (roles[`${userId} ${project}`] ? json(res, 200, roles[`${userId} ${project}`]) : json(res, 404, { error: "Not found" })); if (req.url !== "/api/user/profile" || !userId) return void json(res, 401, { error: "no" }); json(res, 200, { id: userId, email: `${userId}@example.com` }); }), diff --git a/cloud/test/edge-live.test.ts b/cloud/test/edge-live.test.ts new file mode 100644 index 00000000..b8534145 --- /dev/null +++ b/cloud/test/edge-live.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { closeDb } from "../db.ts"; +import { closeAll } from "../live.ts"; +import { dropUsers, fakeOrgo, keyOf, newUserId, prepareDb, startCloud, until, type Listening } from "./edge-fakes.ts"; + +/** + * Live and the state (cloud/live.ts, state.ts): a device watching a workspace hears "state" when a + * write changed that workspace's part, and not for a write that changed only another workspace's or the + * account's own (the usage ledger); messages written into a workspace's chat wake it with their seq. + */ + +let orgo: Listening; +let cloud: Listening; +const users: string[] = []; + +before(async () => { + await prepareDb(); + orgo = await fakeOrgo(); + cloud = await startCloud(); +}); + +after(async () => { + closeAll(); + await cloud.close(); + await orgo.close(); + await dropUsers(users); + await closeDb(); +}); + +const headers = (userId: string, device = "mac-a") => ({ Authorization: `Bearer ${keyOf(userId)}`, "x-bops-user": userId, "x-bops-protocol": "2", "x-bops-device": device, "content-type": "application/json" }); +const putState = (userId: string, base: number, state: Record, device?: string) => + fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: headers(userId, device), body: JSON.stringify({ base, state }) }); + +/** A live stream's "state" events, as they come. */ +async function watch(userId: string, ws: string) { + const ctl = new AbortController(); + const res = await fetch(`${cloud.url}/v1/live?ws=${ws}`, { headers: { Authorization: `Bearer ${keyOf(userId)}` }, signal: ctl.signal }); + assert.equal(res.status, 200); + const got: { type: string; data: Record }[] = []; + void (async () => { + let text = ""; + const dec = new TextDecoder(); + try { + for await (const chunk of res.body!) { + text += dec.decode(chunk as Uint8Array, { stream: true }); + for (let i = text.indexOf("\n\n"); i >= 0; i = text.indexOf("\n\n")) { + const block = text.slice(0, i); + text = text.slice(i + 2); + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + if (field("event")) got.push({ type: field("event")!, data: JSON.parse(field("data") ?? "{}") }); + } + } + } catch {} + })(); + await until(() => got.some((g) => g.type === "reset"), "the stream to start"); + return { states: () => got.filter((g) => g.type === "state"), close: () => ctl.abort() }; +} + +const blob = (acmeStatus: string, usage: number[]) => ({ + workspaces: [ + { id: "ws_main", name: "Main" }, + { id: "ws_acme", name: "Acme" }, + ], + bots: [{ id: "boppy", isMain: true }, { id: "max", isMain: true, workspaceId: "ws_acme" }], + chats: [{ id: "bot:boppy", botIds: ["boppy"] }, { id: "bot:max", botIds: ["max"], workspaceId: "ws_acme" }], + sessions: [{ id: "s1", botId: "max", status: acmeStatus }], + routines: [], + usage: { events: usage }, +}); + +test("a write wakes the watched workspace whose part changed, and only it", async () => { + const id = newUserId("live"); + users.push(id); + const main = await watch(id, "ws_main"); + const acme = await watch(id, "ws_acme"); + assert.equal((await putState(id, 0, blob("running", [1]))).status, 200); + await until(() => main.states().length === 1 && acme.states().length === 1, "the first write to reach both"); + assert.equal(main.states()[0].data.by, "mac-a"); + + // Only the usage ledger changed: nobody hears of it. + assert.equal((await putState(id, 1, blob("running", [1, 2]), "mac-b")).status, 200); + // Acme's thread finished: Acme hears of it. + assert.equal((await putState(id, 2, blob("done", [1, 2, 3]), "mac-b")).status, 200); + await until(() => acme.states().length === 2, "Acme to hear of its thread"); + assert.equal(acme.states()[1].data.by, "mac-b"); + assert.equal(main.states().length, 1, "Main's part didn't change"); + + // A message in Acme's main bot's chat: Acme hears it with the seq. + const res = await fetch(`${cloud.url}/v1/messages`, { + method: "POST", + headers: headers(id, "mac-b"), + body: JSON.stringify({ upsert: [{ id: "msg_1", chatId: "bot:max", role: "user", text: "hi", at: 1 }], remove: [] }), + }); + assert.equal(res.status, 200); + const { seq } = (await res.json()) as { seq: number }; + await until(() => acme.states().length === 3, "Acme to hear of the message"); + assert.equal(acme.states()[2].data.seq, seq); + assert.equal(main.states().length, 1); + main.close(); + acme.close(); +}); diff --git a/cloud/test/edge-members.test.ts b/cloud/test/edge-members.test.ts new file mode 100644 index 00000000..492f42ab --- /dev/null +++ b/cloud/test/edge-members.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { closeDb, query } from "../db.ts"; +import { closeAll } from "../live.ts"; +import { resetMembers } from "../members.ts"; +import type { SharedWsState, SharedWsUnchanged } from "../protocol.ts"; +import { dropUsers, fakeOrgo, keyOf, newUserId, prepareDb, startCloud, until, type Listening } from "./edge-fakes.ts"; + +/** + * A shared workspace's state, read by a member (members.ts GET /v1/ws/:projectId/state): the owner's + * workspace as the allow list shows it, with the bots the owner gave them (found by the email Orgo's + * profile gives them; someone given none, no bot) and everything of each, its chats' newest messages + * (the owner's own and one kept "between us" too) and none of another workspace's or of a bot they + * weren't given, a copy that's still current answered in a line, and the stream's "state" events carrying + * the same fingerprint the read does. orgo-web is a fake on localhost. + */ + +let orgo: Listening; +let cloud: Listening; +const owner = newUserId("owner"); +const member = newUserId("member"); +const outsider = newUserId("outsider"); +const both = newUserId("both"); +const PROJECT = "11111111-2222-3333-4444-555555555555"; +const roles: Record = {}; + +before(async () => { + await prepareDb(); + roles[`${member} ${PROJECT}`] = { role: "member", ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme" }; + orgo = await fakeOrgo(roles); + cloud = await startCloud(); + resetMembers(); +}); + +after(async () => { + closeAll(); + await cloud.close(); + await orgo.close(); + await query("DELETE FROM bops.chat_messages WHERE user_id = ANY($1::text[])", [[owner, member]]); + await dropUsers([owner, member, outsider, both]); + await closeDb(); +}); + +const headers = (userId: string) => ({ Authorization: `Bearer ${keyOf(userId)}`, "x-bops-user": userId, "x-bops-protocol": "2", "x-bops-device": "mac-o", "content-type": "application/json" }); +const blob = (acmeStatus: string) => ({ + workspaces: [ + { id: "ws_main", name: "Main", orbitKey: "main-orbit" }, + { id: "ws_acme", name: "Acme", createdAt: 1, orbitKey: "acme-orbit", memberAccess: { [`${member}@example.com`]: { others: "none", bots: { max: "read" } } } }, + ], + bots: [ + { id: "boppy", name: "Boppy", isMain: true }, + { id: "max", name: "Max", isMain: true, workspaceId: "ws_acme", appsKey: "apps-key-max", keys: ["STRIPE_SECRET_KEY"] }, + { id: "dana", name: "Dana", workspaceId: "ws_acme" }, + ], + chats: [ + { id: "bot:boppy", kind: "bot", botIds: ["boppy"] }, + { id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme", readAt: 7 }, + { id: "bot:dana", kind: "bot", botIds: ["dana"], workspaceId: "ws_acme" }, + { id: "pair", kind: "group", botIds: ["max", "dana"], workspaceId: "ws_acme", title: "Hiring" }, + ], + sessions: [ + { id: "s1", botId: "max", title: "Leads", status: acmeStatus, env: { remoteUrl: "https://secret" } }, + { id: "s2", botId: "max", chatId: "bot:max", title: "Pricing", status: "done", answer: "Raise it 10%", secret: true }, + { id: "s3", botId: "dana", chatId: "bot:dana", title: "Shortlist", status: "running" }, + ], + routines: [{ id: "r1", botId: "dana", title: "Dana's daily" }], + vault: [{ id: "v1", username: "owner@example.com" }], + usage: { events: [1, 2] }, +}); +const read = async (userId: string, qs = "") => { + const res = await fetch(`${cloud.url}/v1/ws/${PROJECT}/state${qs}`, { headers: { Authorization: `Bearer ${keyOf(userId)}` } }); + return { status: res.status, body: (await res.json()) as SharedWsState & Partial }; +}; + +test("a member reads all of the bot they were given as it's shown, the owner's own messages too, and nothing of another bot", async () => { + assert.equal((await fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: headers(owner), body: JSON.stringify({ base: 0, state: blob("running") }) })).status, 200); + const msgs = [ + { id: "m1", chatId: "bot:max", role: "user", text: "find leads", at: 10 }, + { id: "m2", chatId: "bot:max", role: "bot", botId: "max", text: "on it", at: 11, channel: { linkId: "slack-1", chat: "C1" } }, + { id: "m3", chatId: "bot:max", role: "user", text: "between us", at: 12, secret: true }, + { id: "m3b", chatId: "bot:max", role: "bot", botId: "max", text: "I won't mention it", at: 13 }, + { id: "m4", chatId: "bot:boppy", role: "user", text: "main workspace only", at: 13 }, + { id: "d1", chatId: "bot:dana", role: "user", text: "only for the shortlist", at: 14 }, + { id: "g1", chatId: "pair", role: "user", text: "the hiring plan", at: 15 }, + ]; + assert.equal((await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: msgs, remove: [] }) })).status, 200); + + const { status, body } = await read(member); + assert.equal(status, 200); + assert.equal(body.wsId, "ws_acme"); + assert.equal(body.role, "member"); + assert.deepEqual(body.state.workspace, { id: "ws_acme", name: "Acme", createdAt: 1 }); + assert.deepEqual(body.state.bots, [{ id: "max", name: "Max", isMain: true, workspaceId: "ws_acme" }]); + assert.deepEqual(body.state.chats, [{ id: "bot:max", kind: "bot", botIds: ["max"], workspaceId: "ws_acme" }]); + assert.deepEqual(body.state.sessions, [ + { id: "s1", botId: "max", title: "Leads", status: "running" }, + { id: "s2", botId: "max", chatId: "bot:max", title: "Pricing", status: "done", answer: "Raise it 10%" }, + ]); + assert.deepEqual(body.state.routines, [], "Dana's routine isn't theirs"); + assert.deepEqual( + body.messages.map((m) => m.id), + ["m1", "m2", "m3", "m3b"], + "all of Max's chat, the owner's own messages and the one kept between us with its answer; nothing of another workspace or of Dana", + ); + assert.deepEqual(body.messages[1], { id: "m2", chatId: "bot:max", role: "bot", botId: "max", text: "on it", at: 11 }); + assert.deepEqual(body.messages[2], { id: "m3", chatId: "bot:max", role: "user", text: "between us", at: 12 }); + assert.deepEqual(body.more, []); + const text = JSON.stringify(body); + for (const secret of ["orbit", "apps-key", "STRIPE", "https://secret", "owner@example.com", "Boppy", "main workspace only", "slack-1", "usage", "memberAccess", `${member}@example.com`, "Dana", "dana", "shortlist", "Shortlist", "pair", "hiring", "Hiring"]) + assert.ok(!text.includes(secret), `"${secret}" isn't shown`); + + // Still current: answered in a line. One chat's older page: only a chat of the workspace. + const again = await read(member, `?have=${body.hash}.${body.seq}`); + assert.deepEqual(again.body, { hash: body.hash, seq: body.seq, unchanged: true }); + assert.equal((await read(member, "?chat=bot:max&before=11")).body.messages.length, 1); + assert.equal((await read(member, "?chat=bot:boppy&before=99")).status, 404); + assert.equal((await read(member, "?chat=bot:dana&before=99")).status, 404, "nor a page of Dana's chat"); + // The owner reading their own project's state: every bot and chat of it; anyone else gets 404. + roles[`${owner} ${PROJECT}`] = { role: "owner", ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme" }; + const own = await read(owner); + assert.equal(own.status, 200); + assert.deepEqual(own.body.messages.map((m) => m.id), ["m1", "m2", "m3", "m3b", "d1", "g1"]); + assert.equal((await read(newUserId("stranger"))).status, 404); + // Someone orgo-web says is in it whom the owner gave no bots: the workspace, and nothing of it. + roles[`${outsider} ${PROJECT}`] = { role: "member", ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme" }; + const none = await read(outsider); + assert.equal(none.status, 200); + assert.deepEqual(none.body.state.workspace, { id: "ws_acme", name: "Acme", createdAt: 1 }); + assert.deepEqual([none.body.state.bots, none.body.state.chats, none.body.state.sessions, none.body.state.routines, none.body.messages], [[], [], [], [], []]); + assert.ok(!/@example\.com\b/.test(JSON.stringify(none.body)), "nobody's email, theirs or the member's"); +}); + +test("an owner whose state the cloud hasn't got, or that has no such workspace, gives a member nothing to open", async () => { + const P2 = "22222222-2222-3333-4444-555555555555"; + const absent = newUserId("absent"); + roles[`${member} ${P2}`] = { role: "member", ownerUserId: absent, bopsWsId: "ws_acme", name: "Acme" }; + const get = () => fetch(`${cloud.url}/v1/ws/${P2}/state`, { headers: { Authorization: `Bearer ${keyOf(member)}` } }); + assert.equal((await get()).status, 404, "no state of the owner's"); + roles[`${member} ${P2}`] = { role: "member", ownerUserId: owner, bopsWsId: "ws_deleted", name: "Gone" }; + resetMembers(); + assert.equal((await get()).status, 404, "no such workspace in it"); + delete roles[`${member} ${P2}`]; + resetMembers(); +}); + +test("the member's stream hears the owner's write with the fingerprint a read gives", async () => { + const ctl = new AbortController(); + const res = await fetch(`${cloud.url}/v1/live?project=${PROJECT}`, { headers: { Authorization: `Bearer ${keyOf(member)}` }, signal: ctl.signal }); + assert.equal(res.status, 200); + const got: { type: string; data: Record }[] = []; + void (async () => { + let text = ""; + const dec = new TextDecoder(); + try { + for await (const chunk of res.body!) { + text += dec.decode(chunk as Uint8Array, { stream: true }); + for (let i = text.indexOf("\n\n"); i >= 0; i = text.indexOf("\n\n")) { + const block = text.slice(0, i); + text = text.slice(i + 2); + const field = (name: string) => block.split("\n").find((l) => l.startsWith(`${name}: `))?.slice(name.length + 2); + if (field("event")) got.push({ type: field("event")!, data: JSON.parse(field("data") ?? "{}") }); + } + } + } catch {} + })(); + await until(() => got.some((g) => g.type === "reset"), "the stream to start"); + const version = (await (await fetch(`${cloud.url}/v1/state/head`, { headers: headers(owner) })).json()) as { version: number }; + assert.equal((await fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: headers(owner), body: JSON.stringify({ base: version.version, state: blob("done") }) })).status, 200); + const ev = await until(() => got.find((g) => g.type === "state"), "the state event"); + const { body } = await read(member); + assert.equal(ev.data.hash, body.hash); + assert.equal(body.state.sessions[0].status, "done"); + // A message in the main workspace's chat (known now), or in a chat of a bot they weren't given, doesn't + // wake the member; one the owner keeps between us in Max's chat does. + const before = got.filter((g) => g.type === "state").length; + await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: [{ id: "m5", chatId: "bot:boppy", role: "user", text: "x", at: 20 }], remove: [] }) }); + await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: [{ id: "d2", chatId: "bot:dana", role: "user", text: "z", at: 20 }], remove: [] }) }); + await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: [{ id: "m6", chatId: "bot:max", role: "user", text: "y", at: 21, secret: true }], remove: [] }) }); + await until(() => got.filter((g) => g.type === "state").length === before + 1, "Acme's message"); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(got.filter((g) => g.type === "state").length, before + 1); + ctl.abort(); +}); + +test("a page of a long chat shows every message of it, the ones after a message kept between us too", async () => { + // bot:max so far: m1, m2, m3 (between us), m3b (the answer to it), m6 (the owner's next, kept between us too). 198 more after m6. + const more = Array.from({ length: 198 }, (_, i) => ({ id: `n${i}`, chatId: "bot:max", role: "bot", botId: "max", text: `note ${i}`, at: 30 + i })); + assert.equal((await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: more, remove: [] }) })).status, 200); + // The newest 200 start at m3b, after m3. + const { body } = await read(member); + const ids = body.messages.map((m) => m.id); + assert.deepEqual(body.more, ["bot:max"]); + assert.deepEqual(ids.slice(0, 3), ["m3b", "m6", "n0"]); + assert.equal(ids.length, 200); + // A page picked so the answer is its oldest row: the same. + const page = await read(member, "?chat=bot:max&before=228"); + assert.deepEqual(page.body.more, ["bot:max"]); + assert.equal(page.body.messages[0]?.id, "m3b"); + // The page before it has the whole start of the chat. + assert.deepEqual((await read(member, "?chat=bot:max&before=14")).body.messages.map((m) => m.id), ["m1", "m2", "m3", "m3b"]); +}); + +test("a member given two bots reads their conversation with each other (Max asked Dana) and its older pages; one given only one of them doesn't", async () => { + roles[`${both} ${PROJECT}`] = { role: "member", ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme" }; + const state = blob("done"); + (state.workspaces[1].memberAccess as Record)[`${both}@example.com`] = { others: "none", bots: { max: "read", dana: "read" } }; + const version = (await (await fetch(`${cloud.url}/v1/state/head`, { headers: headers(owner) })).json()) as { version: number }; + assert.equal((await fetch(`${cloud.url}/v1/state`, { method: "PUT", headers: headers(owner), body: JSON.stringify({ base: version.version, state }) })).status, 200); + const talk = [ + { id: "p1", chatId: "pair:dana:max", role: "bot", botId: "max", text: "Is the deck ready?", at: 500 }, + { id: "p2", chatId: "pair:dana:max", role: "bot", botId: "dana", text: "Yes, sent it this morning.", at: 501 }, + { id: "m9", chatId: "bot:max", role: "bot", botId: "max", text: "Dana says the deck is ready.", at: 502, asked: [{ botId: "dana", question: "Is the deck ready?", answer: "Yes, sent it this morning.", questionId: "p1" }] }, + ]; + assert.equal((await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert: talk, remove: [] }) })).status, 200); + + const { status, body } = await read(both); + assert.equal(status, 200); + assert.deepEqual(body.messages.filter((m) => m.chatId === "pair:dana:max").map((m) => m.id), ["p1", "p2"], "the conversation Asked ● Dana opens"); + assert.deepEqual(body.messages.find((m) => m.id === "m9")?.asked, [{ botId: "dana", question: "Is the deck ready?", answer: "Yes, sent it this morning.", questionId: "p1" }], "where in it Max asked"); + assert.deepEqual((await read(both, "?chat=pair:dana:max&before=501")).body.messages.map((m) => m.id), ["p1"], "a page of it"); + // Given Max only: none of it, nor a page. + const one = await read(member); + assert.ok(!one.body.messages.some((m) => String(m.chatId).startsWith("pair:"))); + assert.deepEqual(one.body.messages.find((m) => m.id === "m9")?.asked, undefined, "what Dana said isn't theirs"); + assert.equal((await read(member, "?chat=pair:dana:max&before=999")).status, 404); + // Orgo says they joined after what's kept for them was set (removed and back since): none of it is theirs now. + roles[`${both} ${PROJECT}`] = { role: "member", ownerUserId: owner, bopsWsId: "ws_acme", name: "Acme", joinedAt: new Date().toISOString() }; + resetMembers(); + const back = await read(both); + assert.equal(back.status, 200); + assert.deepEqual([back.body.state.bots, back.body.state.chats, back.body.messages], [[], [], []]); + delete roles[`${both} ${PROJECT}`]; + resetMembers(); +}); + +test("a page boundary between two messages of the same millisecond (a bot's answer and its note) loses neither: the next page comes before the oldest the member has, by its id too", async () => { + // Max answers and notes what he read in one tick, then 199 more: the newest 200 start at the note. + const tick = [ + { id: "t-answer", chatId: "bot:max", role: "bot", botId: "max", text: "Here's what I found", at: 5000 }, + { id: "t-note", chatId: "bot:max", role: "system", text: "Read example.com", at: 5000 }, + ]; + const after = Array.from({ length: 199 }, (_, i) => ({ id: `t${i}`, chatId: "bot:max", role: "bot", botId: "max", text: `later ${i}`, at: 5001 + i })); + for (const upsert of [tick, after]) assert.equal((await fetch(`${cloud.url}/v1/messages`, { method: "POST", headers: headers(owner), body: JSON.stringify({ upsert, remove: [] }) })).status, 200); + const { body } = await read(member); + assert.equal(body.messages[0]?.id, "t-note"); + assert.deepEqual(body.more, ["bot:max"]); + const page = await read(member, "?chat=bot:max&before=5000&beforeId=t-note"); + assert.equal(page.status, 200); + assert.equal(page.body.messages.at(-1)?.id, "t-answer", "the answer the note is under is in the page before it"); + assert.ok(!page.body.messages.some((m) => m.id === "t-note"), "and the note isn't again"); + // An id of none of that chat's messages at that time (or none): the ones before that millisecond, as before. + for (const qs of ["?chat=bot:max&before=5000", "?chat=bot:max&before=5000&beforeId=t0", "?chat=bot:max&before=5000&beforeId=d1"]) + assert.ok(!(await read(member, qs)).body.messages.some((m) => String(m.id).startsWith("t-")), qs); +}); diff --git a/cloud/test/edge-state-sync.test.ts b/cloud/test/edge-state-sync.test.ts index 86b0bcff..f8e417ec 100644 --- a/cloud/test/edge-state-sync.test.ts +++ b/cloud/test/edge-state-sync.test.ts @@ -231,7 +231,7 @@ test("builds from before: their whole-state upload is taken until a newer build }); /** What 0.0.29 and later send besides (lib/server/app-version.ts appHeaders): which state they may be given. */ -const NEWER = { "x-bops-state-level": "3" }; +const NEWER = { "x-bops-state-level": "4" }; /** A routine 0.0.28 would start over and over (its nextRun throws on `time` after the task started). */ const EVERY = { id: "rtn_every", botId: "boppy", title: "Inbox", goal: "Check my inbox", schedule: { kind: "every", minutes: 30 }, enabled: true, nextRunAt: 1 }; const noticesOf = async (userId: string, headers: Record = {}) => @@ -250,7 +250,7 @@ test("once a Mac on a newer Bops wrote the user's state, an older Mac is kept ou // The newer Mac writes what the older one can't run: the row is its level from then on. assert.equal((await putState(id, 1, { owner: { name: "Old" }, routines: [EVERY] }, newer)).status, 200); got = (await (await call(id, "/v1/state", newer)).json()) as CloudState; - assert.deepEqual([got.version, got.protocol, got.writer], [2, 3, "mac-new"]); + assert.deepEqual([got.version, got.protocol, got.writer], [2, 4, "mac-new"]); // The older Mac gets none of it, nor a 409 with it to merge, and nothing it sends lands. const refused = async (res: Response, what: string) => { @@ -293,7 +293,7 @@ test("once a Mac on a newer Bops wrote the user's state, an older Mac is kept ou assert.equal((await putState(id, 2, { owner: { name: "New" }, routines: [EVERY] }, newer)).status, 200); await until(() => mac.frames.find((f) => f.t === "state" && f.version === 3), "the newer Mac hearing of its write"); got = (await (await call(id, "/v1/state", newer)).json()) as CloudState; - assert.deepEqual([got.version, got.protocol, got.state.owner], [3, 3, { name: "New" }]); + assert.deepEqual([got.version, got.protocol, got.state.owner], [3, 4, { name: "New" }]); } finally { mac.ws.close(); } diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts new file mode 100644 index 00000000..d00c0217 --- /dev/null +++ b/cloud/ws-slice.ts @@ -0,0 +1,368 @@ +import { createHash } from "node:crypto"; +import { accessOf, currentFor, isAccessLevel, levelOf, seesChat, type AccessLevel, type MemberAccess, type MemberAccessMap } from "./member-access.ts"; +import type { SharedWsSlice, WsRole } from "./protocol.ts"; + +/** + * What a member of a shared workspace is shown of its owner's state (members.ts, GET /v1/ws/:projectId/state), + * and the fingerprint "state" events carry for it (live.ts). Everything here is an allow list: a field + * is shown only when it's named below, and a nested object only as far as its own list goes, so a field + * the app adds later stays the owner's until it's named here. + * + * Shown: the workspace (its name), its bots (who they are, never their keys, apps, computers or brains), + * its chats (the owner's 1:1 chats with bots too, the owner's own messages in them included), its + * threads with what makes them Need you, their notes and results, what its bots wait on the owner's OK for + * (read only), its routines, its watched screens, and its chats' messages. Never: the usage ledger, the Macs, the vault, keys or their names, channels and + * their pair codes, connected accounts, the bot to key mapping, the owner's own settings, who else the + * owner added or what they may see (memberAccess), or anything of another workspace. A message the owner + * kept "between us" (Message.secret) is shown like any other, and so is all that came of it: that mark + * only keeps things out of the shared memory. Something the owner wants kept from a member stays with a + * bot that member wasn't given. + * + * Anyone but the owner (an admin or a member) is shown only the bots the owner gave them + * (member-access.ts, kept on the workspace as memberAccess), and everything of each of those: a chat + * only when every bot in it is one of those, and those bots' threads, routines and watches. Someone the + * owner gave nothing (no entry by their email, or by their user id for a guest) is shown no bot at all. + * A thread a hidden bot handed over reads as its own bot's, and the note the hand-off left isn't shown + * (keepSession, sharedMessage), so neither names that bot. The main bot's "While you were busy" digest + * tells them only what the watches of bots they have found (sharedMessage). + */ + +/** The workspace every older bot and chat belongs to (lib/types.ts MAIN_WORKSPACE). */ +export const MAIN_WORKSPACE = "ws_main"; + +/** + * What's kept of a value: `true` a plain value (text, a number, yes or no, or a list of them; an object + * there is dropped), an object of specs its named fields, and `[spec]` a list whose items are each kept by spec. + */ +export type Spec = true | readonly [Spec] | { readonly [field: string]: Spec }; + +const isObject = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); +const plain = (v: unknown) => v === null || ["string", "number", "boolean"].includes(typeof v); + +/** `v` as far as `spec` names it; undefined when nothing of it is shown. */ +export function keep(v: unknown, spec: Spec): unknown { + if (spec === true) return plain(v) ? v : Array.isArray(v) && v.every(plain) ? v : undefined; + if (Array.isArray(spec)) return Array.isArray(v) ? v.map((x) => keep(x, spec[0])).filter((x) => x !== undefined) : undefined; + if (!isObject(v)) return undefined; + const out: Record = {}; + for (const [k, s] of Object.entries(spec as Record)) { + if (!Object.hasOwn(v, k)) continue; + const x = keep(v[k], s); + if (x !== undefined) out[k] = x; + } + return out; +} + +const WORKSPACE: Spec = { id: true, name: true, createdAt: true }; +const BOT: Spec = { id: true, name: true, role: true, color: true, isMain: true, workspaceId: true, computer: true, computerStatus: true, phone: true, email: true, runsOn: true, effort: true }; +/** `readAt` is the owner's own (their unread dots), and `typing` is never synced: the moods say it. */ +const CHAT: Spec = { id: true, kind: true, botIds: true, title: true, createdAt: true, workspaceId: true }; +const SESSION: Spec = { + id: true, + botId: true, + chatId: true, + sentVia: true, + sentViaBot: true, + title: true, + goal: true, + routineId: true, + host: true, + status: true, + display: true, + steps: [{ at: true, tool: true, detail: true, who: true, screen: true }], + replies: [{ id: true, role: true, text: true, at: true, delivered: true, note: true, from: true }], + answer: true, + options: true, + helperNames: true, + activity: true, + waitingOnYou: true, + askWhere: true, + approveElsewhere: true, + blocker: true, + // What a thread waits on (lib/asks.ts): a safety check's words, never a tool's output. + owed: [{ id: true, type: true, checks: [{ id: true, code: true, message: true }] }], + brainRun: { kind: true, waits: true }, + runsOn: true, + group: { id: true, title: true }, + partOf: true, + dismissed: true, + replacedBy: true, + movedFrom: true, + error: true, + createdAt: true, + startedAt: true, + endedAt: true, +}; +const SCHEDULE: Spec = { kind: true, time: true, times: true, day: true, days: true, dayOfMonth: true, at: true, minutes: true, hours: { from: true, to: true }, source: true }; +const ROUTINE: Spec = { + id: true, + botId: true, + title: true, + goal: true, + schedule: SCHEDULE, + timeZone: true, + enabled: true, + nextRunAt: true, + lastRunAt: true, + lateRun: true, + skips: true, + where: true, + reminder: true, + run: true, + quiet: true, + last: { at: true, status: true, error: true, sessionId: true }, + source: { app: true, template: true }, + tags: true, +}; +const WATCH: Spec = { + id: true, + botId: true, + display: true, + site: true, + lookFor: true, + since: true, + alert: { text: true, at: true, level: true }, + readAt: true, + mac: { app: true, title: true }, + target: true, + away: true, + source: { app: true, template: true }, + page: { url: true, everyMin: true, checkedAt: true, changedAt: true, error: true, via: true }, + paused: true, +}; +/** + * What one of its bots asked the owner's OK for, while it waits (lib/types.ts AppApproval): shown read only, so + * someone given that bot sees what its thread waits on. Never the reply, which device asked, or what resumes its + * run: answering is the owner's alone, from a Bops of their own. + */ +const APPROVAL: Spec = { id: true, botId: true, chatId: true, threadId: true, routineId: true, action: true, resources: true, preview: true, kind: true, api: { label: true, method: true, path: true }, at: true }; +/** A message as a member reads it: what the chat shows, never where it came from on the owner's side (inbox, channel, phone ids) or a CRM file's copy. */ +const MESSAGE: Spec = { + id: true, + chatId: true, + role: true, + botId: true, + text: true, + at: true, + sessionIds: true, + resultOf: true, + call: { seconds: true }, + via: true, + sms: { dir: true, from: true, to: true }, + appResult: { action: true, ok: true }, + called: true, + watch: { id: true }, + routine: { id: true }, + replyTo: true, + reactions: [{ by: true, type: true, emoji: true, at: true }], + options: true, + ping: true, + needed: true, + email: { dir: true, from: true, to: true, cc: true, subject: true, files: [{ name: true, size: true }], bulk: true }, + emailed: true, + images: [{ id: true, type: true, w: true, h: true }], + picture: { prompt: true }, + // `questionId`: where that exchange is in the two bots' own conversation (pairChatsOf), which "Asked ● Sam" opens at. + asked: [{ botId: true, question: true, answer: true, questionId: true }], + memory: { fact: true, undone: true }, + crm: { file: true, added: true, changed: true, created: true, undone: true }, + // Bops' own line in its bot's voice (a thread it couldn't finish): a shared workspace reads it as the owner's (lib/server/shared-core.ts viewState). + bops: 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. */ +function keepChat(c: Record, bots?: ReadonlySet) { + const out = keep(c, CHAT) as Record; + if (isObject(c.asking)) out.asking = Object.fromEntries(Object.entries(c.asking).filter(([k, v]) => typeof v === "string" && (!bots || (bots.has(k) && bots.has(v))))); + return out; +} + +export const list = (v: unknown): Record[] => (Array.isArray(v) ? v.filter(isObject) : []); +export const wsOfBot = (b: Record | undefined) => (typeof b?.workspaceId === "string" ? b.workspaceId : MAIN_WORKSPACE); + +/** Which workspace a chat is in: its own, else its first bot's (a bot's chat follows its bot). */ +export function chatWsOf(byId: Map>) { + return (c: Record) => (typeof c.workspaceId === "string" ? c.workspaceId : wsOfBot(byId.get(Array.isArray(c.botIds) ? c.botIds[0] : undefined))); +} + +/** + * Who a slice is for: the owner sees all of the workspace; anyone else (an admin or a member) only what + * the owner gave them, found by their email (any case) or, for a guest with none, their Orgo user id, and + * only when it was given for their membership now, begun at `joinedAt` (member-access.ts currentFor). + */ +export type SliceViewer = { role: WsRole; email?: string; id: string; joinedAt?: number }; +/** The owner, as sharedSlice takes them (who they are doesn't change what they see: all of it). */ +export const OWNER_VIEW: SliceViewer = { role: "owner", id: "" }; + +/** + * What `viewer` may do with each of the workspace's bots, as the owner's state keeps it (member-access.ts + * accessOf): anything that isn't a level is none, and a bot whose id every object has a field for + * ("constructor", a bot named Constructor) is only ever the level kept for it, else `others`. + */ +function accessIn(workspace: Record | null, viewer: SliceViewer): MemberAccess { + const map = isObject(workspace?.memberAccess) ? (workspace.memberAccess as MemberAccessMap) : undefined; + const kept: unknown = accessOf(map, viewer); + // Set before they were removed (or left) and came back: not theirs now. + const o = isObject(kept) && currentFor(kept, viewer.joinedAt) ? kept : {}; + const bots: Record = Object.create(null); + if (isObject(o.bots)) for (const [id, level] of Object.entries(o.bots)) bots[id] = isAccessLevel(level) ? level : "none"; + return { others: isAccessLevel(o.others) ? o.others : "none", bots }; +} + +/** A chat's bots, or none when they aren't all bot ids (so it's never shown to anyone but the owner). */ +const botsOfChat = (c: Record): string[] => (Array.isArray(c.botIds) && c.botIds.every((id) => typeof id === "string") ? c.botIds : []); + +/** + * One workspace's part of a state blob, every item whole: what live.ts compares to tell its devices it + * changed. With a `viewer` other than the owner, only what they're shown of it: the bots they have at + * read or more, the chats whose every bot is one of those (a group chat with a bot hidden from them, or + * with one of another workspace or gone, stays hidden), and those bots' threads, routines and watches. + */ +export function sliceOf(blob: Record, ws: string, viewer?: SliceViewer) { + const workspace = list(blob.workspaces).find((w) => w.id === ws) ?? null; + const chatWs = chatWsOf(new Map(list(blob.bots).map((b) => [b.id, b]))); + let bots = list(blob.bots).filter((b) => wsOfBot(b) === ws); + let chats = list(blob.chats).filter((c) => chatWs(c) === ws); + if (viewer && viewer.role !== "owner") { + const access = accessIn(workspace, viewer); + bots = bots.filter((b) => typeof b.id === "string" && levelOf(access, b.id) !== "none"); + const seen = new Set(bots.map((b) => b.id)); + chats = chats.filter((c) => { + const ids = botsOfChat(c); + return seesChat(access, ids) && ids.every((id) => seen.has(id)); + }); + } + const ids = new Set(bots.map((b) => b.id)); + // What those bots ask the owner's OK for, while it waits: only there when there's any (an older Bops Cloud sent none). + const approvals = list(blob.appApprovals).filter((a) => ids.has(a.botId) && a.reply === undefined); + return { + workspace, + bots, + chats, + sessions: list(blob.sessions).filter((s) => ids.has(s.botId)), + routines: list(blob.routines).filter((r) => ids.has(r.botId)), + watches: list(blob.watches).filter((w) => ids.has(w.botId)), + ...(approvals.length ? { approvals } : {}), + }; +} + +/** Who started a thread when it wasn't the owner or a routine: the bot that handed it over (Session.sentVia), a bot from before whose id is "you" or "routine" too (Session.sentViaBot). */ +const sender = (s: Record) => (typeof s.sentVia === "string" && (s.sentViaBot === true || (s.sentVia !== "you" && s.sentVia !== "routine")) ? s.sentVia : undefined); + +/** + * A thread as someone other than the owner is shown it (`bots`, `chats`: the ones they see). One a bot + * hidden from them handed over, or started in a chat they don't have (that bot's own, or a group with + * one), reads as its bot's own, in that bot's chat: the bot that sent it and that chat's id would name + * what's hidden. Its goal is the job its bot was given, which is theirs to read. + */ +function keepSession(s: Record, bots?: ReadonlySet, chats?: ReadonlySet) { + const out = keep(s, SESSION) as Record; + if (!bots || !chats) return out; + const via = sender(out); + if (via !== undefined && !bots.has(via)) out.sentVia = out.botId; + if (out.chatId !== undefined && !chats.has(out.chatId)) out.chatId = `bot:${String(out.botId)}`; + return out; +} + +/** What `viewer` is shown of one workspace (the allow lists above, and only their bots for anyone but the owner), from the owner's whole blob. */ +export type SharedSlice = SharedWsSlice; +export function sharedSlice(blob: Record, ws: string, viewer: SliceViewer): SharedSlice { + const s = sliceOf(blob, ws, viewer); + const all = (items: Record[], spec: Spec) => items.map((x) => keep(x, spec) as Record); + const bots = viewer.role === "owner" ? undefined : new Set(s.bots.map((b) => b.id)); + const chats = bots && new Set(s.chats.map((c) => c.id)); + return { + workspace: s.workspace ? (keep(s.workspace, WORKSPACE) as Record) : null, + bots: all(s.bots, BOT), + chats: s.chats.map((c) => keepChat(c, bots)), + sessions: s.sessions.map((x) => keepSession(x, bots, chats)), + routines: all(s.routines, ROUTINE), + watches: all(s.watches, WATCH), + // An ask in a chat they don't have reads as its bot's own chat's, as its thread does (keepSession). + ...(s.approvals ? { approvals: s.approvals.map((a) => ({ ...(keep(a, APPROVAL) as Record), ...(chats && a.chatId !== undefined && !chats.has(a.chatId) ? { chatId: `bot:${String(a.botId)}` } : {}) })) } : {}), + }; +} + +/** The bots a slice shows, by id: what a viewer other than the owner may read the messages of (sharedMessages). */ +export const botsOf = (slice: SharedSlice): Set => new Set(slice.bots.flatMap((b) => (typeof b.id === "string" ? [b.id] : []))); + +/** + * Where each two of these bots' conversation with each other is kept (lib/types.ts pairChatId, "pair::": + * Max asks Sam, Sam answers): there's no chat for it in the owner's state, so the chats a viewer reads and + * is woken for (members.ts, live.ts liveScope) add one for every two bots they see, and none with a bot + * they don't. + */ +export function pairChatsOf(bots: Iterable): string[] { + const ids = [...new Set(bots)].sort(); + const out: string[] = []; + for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) out.push(`pair:${ids[i]}:${ids[j]}`); + return out; +} + +/** The chats `slice` shows, by id, and every two of its bots' conversation with each other (pairChatsOf): the messages its viewer reads. */ +export const chatIdsOf = (slice: SharedSlice): string[] => [...slice.chats.map((c) => String(c.id)), ...pairChatsOf(botsOf(slice))]; + +/** + * The threads of the owner's state a bot not in `bots` handed over (Session.sentVia): the note that left + * in the chat ("Sam handed this to Max") names that bot, so it isn't shown with `bots` (sharedMessage). + */ +export function handedByOthers(blob: Record, bots: ReadonlySet): Set { + const out = new Set(); + for (const s of list(blob.sessions)) { + const via = sender(s); + if (typeof s.id === "string" && via !== undefined && !bots.has(via)) out.add(s.id); + } + return out; +} + +/** The words of the main bot's digest of news held for later (lib/server/attention.ts flush), as it posts them. */ +const HELD_HEAD = "While you were busy:\n"; +const heldText = (lines: string[]) => `${HELD_HEAD}${lines.map((t) => `• ${t}`).join("\n")}`; + +/** + * A message as a member reads it, or null: as its chat shows it, the owner's own and one they kept + * "between us" too. With `bots` (the bots someone other than the owner sees), not one a bot they don't + * see wrote, nor what one of those said when it was asked (Message.asked), nor a note one of those left + * about handing work on: by the bot it came from (Message.from), or for a note from before that was + * kept, by its threads all being ones such a bot handed over (`handed`: handedByOthers). The main bot's + * digest of what every bot's watches found (Message.held) tells them only the lines of bots they see, + * and isn't shown when none is theirs; one from before its lines said whose they were, never. + */ +export function sharedMessage(json: Record, bots?: ReadonlySet, handed?: ReadonlySet): Record | null { + if (bots && typeof json.botId === "string" && !bots.has(json.botId)) return null; + if (bots && typeof json.from === "string" && !bots.has(json.from)) return null; + const threads = Array.isArray(json.sessionIds) ? json.sessionIds : []; + if (bots && handed && json.role === "system" && threads.length > 0 && threads.every((id) => handed.has(id))) return null; + const lines = Array.isArray(json.held) ? json.held.filter(isObject) : undefined; + const theirs = bots && lines?.filter((h) => typeof h.botId === "string" && bots.has(h.botId) && typeof h.text === "string"); + if (bots && !lines && json.role === "bot" && typeof json.text === "string" && json.text.startsWith(HELD_HEAD)) return null; + if (theirs && !theirs.length) return null; + const out = keep(json, MESSAGE) as Record; + if (theirs && lines && theirs.length < lines.length) out.text = heldText(theirs.map((h) => String(h.text))); + if (bots && Array.isArray(out.asked)) { + out.asked = (out.asked as Record[]).filter((a) => typeof a.botId === "string" && bots.has(a.botId)); + if (!(out.asked as unknown[]).length) delete out.asked; + } + return out; +} + +/** Messages as a member reads them, in the order given (sharedMessage each; `bots`, `handed`: as it takes them). */ +export const sharedMessages = (all: Record[], bots?: ReadonlySet, handed?: ReadonlySet): Record[] => + all.flatMap((json) => { + const m = sharedMessage(json, bots, handed); + return m ? [m] : []; + }); + +/** JSON with every object's keys in order, so the same content from two Macs is the same text. */ +export function canonical(v: unknown): string { + return JSON.stringify(v, (_k, x) => (isObject(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x)); +} +const digest = (v: unknown) => createHash("sha256").update(canonical(v)).digest("base64url").slice(0, 22); + +/** A fingerprint of one workspace's whole part (live.ts: did it change?). */ +export const fullHash = (blob: Record, ws: string) => digest(sliceOf(blob, ws)); +/** + * A fingerprint of what one viewer is shown of it: the hash GET /v1/ws/:projectId/state answers with, and + * their stream's "state" events carry. Each viewer's own, so one copy that's current is never another's. + */ +export const sharedHash = (slice: SharedSlice) => digest(slice); diff --git a/components/app/attachments.tsx b/components/app/attachments.tsx index 9fe9af9e..c044e41f 100644 --- a/components/app/attachments.tsx +++ b/components/app/attachments.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { Message } from "@/lib/types"; +import { readAllowed } from "./view-only"; /** * Images in chat: attaching them (the + button, pasting, dropping), the tray of what's about to be @@ -129,6 +130,13 @@ export function MessageImages({ images, previews, align = "end" }: { images?: Pi const [open, setOpen] = useState(null); const srcs = previews ?? (images ?? []).map((i) => `/api/uploads/${i.id}`); if (!srcs.length) return null; + // A workspace shared with the user: its images are the owner's, kept where they were sent from, not here. + if (!previews && !readAllowed(srcs[0])) + return ( +
+ {srcs.length === 1 ? "An image" : `${srcs.length} images`}. Images in a shared workspace aren't shown yet. +
+ ); const one = srcs.length === 1; const ratio = (i: number) => (images?.[i]?.w && images[i].h ? images[i].w! / images[i].h! : undefined); return ( diff --git a/components/app/autopilot.tsx b/components/app/autopilot.tsx index 7ba8cbbd..4aa61e24 100644 --- a/components/app/autopilot.tsx +++ b/components/app/autopilot.tsx @@ -4,7 +4,8 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, use import { flushSync } from "react-dom"; import { describeSchedule, whenText } from "@/cloud/schedule"; import { appNameFor, canonicalApp, CHECK_TEMPLATE, CHECK_TIMES, sameApp, templateById } from "@/lib/routine-catalog"; -import { botChatId, DISPLAYS, isScreenWatch, onceDone, runningNow, type AppState, type Bot, type Delivery, type Routine, type RoutineRun, type RoutineSource, type Session, type Watch } from "@/lib/types"; +import { botChatId, computerWait, DISPLAYS, isScreenWatch, onceDone, runningNow, waitingAsks, type AppState, type Bot, type Delivery, type Routine, type RoutineRun, type RoutineSource, type Watch } from "@/lib/types"; +import { you, your, yourMac } from "@/lib/shared"; import { Fold } from "./fold"; import { Mascot } from "./mascot"; import { @@ -24,7 +25,7 @@ import { import { watchName } from "./watch-overlay"; import { AppLogo, BotChip, botApps, rowTrashBtn, TrashIcon } from "./apps"; import { asCadence, asSchedule, CadencePicker, sameCadence, suggestedOf, tidy, type Cadence } from "./cadence-picker"; -import { CloseButton, currentWorkspace, needsYou, post, teamOf } from "./ui"; +import { CloseButton, currentWorkspace, needsYou, post, ReadOnlyLine, teamOf } from "./ui"; import { FooterButton } from "./sidebar-footer"; import { deleteQuestion, RoutineWeek, useClock, weekPlan, WeekLegend, WeekLetters } from "./routine-week"; @@ -80,11 +81,17 @@ const matchMonitor = (w: Watch, q: string) => has(q, watchName(w), w.site, w.loo /* ---------------- Words ---------------- */ -/** When a schedule runs next ("Tomorrow 9 AM"), or that it's running now (really: runningNow), paused, or done. */ -export function nextWords(r: Routine, sessions: Session[], tz?: string) { - if (runningNow(r, sessions)) return "Running now"; +/** + * When a schedule runs next ("Tomorrow 9 AM"), or that it's running now (really: runningNow), paused, or done; + * or, while its bot's computer is down or asleep with it overdue, that it waits and why ("Waiting: Max's + * computer is stopped", computerWait): it runs there once the computer is up. + */ +export function nextWords(r: Routine, state: Pick, tz?: string) { + if (runningNow(r, state.sessions)) return "Running now"; if (done(r)) return "Done"; if (!r.enabled) return "Paused"; + const wait = computerWait(state, r); + if (wait) return `Waiting: ${wait}`; return r.nextRunAt ? whenText(r.nextRunAt, Date.now(), tz) : describeSchedule(r.schedule, tz); } @@ -104,19 +111,23 @@ export function trouble(r: Routine) { /** * A schedule needs the user: its last run or delivery failed, or the run it started is waiting on them - * (an app action to approve, a sign-in, a question). + * (an app action to approve, a sign-in, a question). Never in a shared workspace: what's wrong there + * waits on its owner, never the viewer (no Needs you filter, count or row note). */ export function scheduleNeedsYou(r: Routine, state: AppState) { + if (state.shared) return false; if (trouble(r)) return true; + // A chat run of it paused for the user's OK (its request names the routine and no thread). + if (waitingAsks(state).some((a) => a.routineId === r.id && !a.threadId)) return true; const sid = r.last?.sessionId; if (!sid) return false; - if (state.appApprovals?.some((a) => a.sessionId === sid)) return true; + if (waitingAsks(state, sid).length) return true; const s = state.sessions.find((x) => x.id === sid); return !!s && needsYou(s); } -/** A monitor needs the user when its page couldn't be read (news is its own mark, New). */ -export const monitorNeedsYou = (w: Watch) => !!w.page?.error && !w.paused; +/** A monitor needs the user when its page couldn't be read (news is its own mark, New). Never in a shared workspace (scheduleNeedsYou). */ +export const monitorNeedsYou = (w: Watch, state?: Pick) => !state?.shared && !!w.page?.error && !w.paused; /** A Connected App's or a key's name as the user knows it: from their accounts or the catalog ("Stripe"), else its slug, capitalized. */ export function appNameOf(state: AppState, app: string) { @@ -189,6 +200,8 @@ export const RAN: Record = { failed: "Failed", skipped: "Skipped", }; +/** How a run went, as a viewer reads it: a quiet run had nothing for the owner in a workspace shared with them (lib/shared.ts you). */ +const ranWords = (status: RoutineRun["status"], state: AppState) => (status === "quiet" ? `Ran, nothing for ${you(state)}` : RAN[status]); /** How often a page is read: "Every 30 minutes", "Every hour", "Every 2 hours". */ function everyWords(min: number) { @@ -203,7 +216,7 @@ const shortUrl = (url: string) => url.replace(/^https?:\/\//i, "").replace(/^www /** What a watch looks at, in words: a page, a window on the user's Mac, or one of a bot's screens. */ function whereWords(w: Watch, state: AppState) { if (w.page) return shortUrl(w.page.url); - if (w.mac) return `${w.mac.app} on your Mac`; + if (w.mac) return `${w.mac.app} on ${yourMac(state)}`; return `${state.bots.find((b) => b.id === w.botId)?.name ?? "Bot"}'s screen ${DISPLAYS.indexOf(w.display) + 1}`; } @@ -301,7 +314,7 @@ export function AutopilotButton({ state, expanded = false, pressed = expanded, o expanded={expanded} pressed={pressed} label={GROUP_NAME} - tip={news ? `${GROUP_NAME}: ${news === 1 ? "a monitor has" : `${news} monitors have`} news` : `${GROUP_NAME}: what your bots do on their own`} + tip={news ? `${GROUP_NAME}: ${news === 1 ? "a monitor has" : `${news} monitors have`} news` : `${GROUP_NAME}: what ${your(state)} bots do on their own`} ariaLabel={news ? `${GROUP_NAME}, ${news} with news` : GROUP_NAME} onClick={() => onOpen({ section, focus: fresh?.id })} icon={} @@ -349,9 +362,9 @@ function SidebarGroup({ // One row, and what comes next under it: monitors with news, then the soonest // schedules, then the other monitors. Nothing under it while there are none (the page says how). const next = [ - ...monitors.filter((w) => w.alert).map((w) => onOpenWatch(w)} />), - ...schedules.map((r) => onOpen({ section: "schedules", focus: r.id })} />), - ...monitors.filter((w) => !w.alert).map((w) => onOpenWatch(w)} />), + ...monitors.filter((w) => w.alert).map((w) => onOpenWatch(w)} readOnly={!!state.shared} />), + ...schedules.map((r) => onOpen({ section: "schedules", focus: r.id })} />), + ...monitors.filter((w) => !w.alert).map((w) => onOpenWatch(w)} readOnly={!!state.shared} />), ]; const max = q ? FIRST : 3; const first = SECTIONS.find((s) => lists[s.id].length)?.id ?? "schedules"; @@ -383,10 +396,10 @@ const SIDE_ROW = "flex h-[30px] shrink-0 items-center gap-2 rounded-[10px] pl-8 pr-2.5 text-left outline-none hover:bg-black/[0.03] focus-visible:shadow-[inset_0_0_0_1.5px_#0A0A0A]"; /** A schedule in the sidebar: its name, then when it runs next (or Paused, Done) and "quiet" in grey; a red dot when its last run or delivery failed. */ -function ScheduleLine({ routine: r, sessions, tz, onClick }: { routine: Routine; sessions: Session[]; tz?: string; onClick: () => void }) { +function ScheduleLine({ routine: r, state, tz, onClick }: { routine: Routine; state: AppState; tz?: string; onClick: () => void }) { const fresh = useFresh(r.id); const bad = trouble(r); - const line = [nextWords(r, sessions, tz), r.quiet ? "quiet" : ""].filter(Boolean).join(" · "); + const line = [nextWords(r, state, tz), r.quiet ? "quiet" : ""].filter(Boolean).join(" · "); return ( ); } @@ -451,7 +466,7 @@ export type RoutineFilter = { bot?: string; app?: string; needsYou?: boolean }; export function filterRoutines(state: AppState, lists: { schedules: Routine[]; monitors: Watch[] }, f: RoutineFilter) { return { schedules: lists.schedules.filter((r) => (!f.bot || r.botId === f.bot) && (!f.app || (!!r.source && sameApp(r.source.app, f.app))) && (!f.needsYou || scheduleNeedsYou(r, state))), - monitors: lists.monitors.filter((w) => (!f.bot || w.botId === f.bot) && (!f.app || (!!w.source && sameApp(w.source.app, f.app))) && (!f.needsYou || monitorNeedsYou(w))), + monitors: lists.monitors.filter((w) => (!f.bot || w.botId === f.bot) && (!f.app || (!!w.source && sameApp(w.source.app, f.app))) && (!f.needsYou || monitorNeedsYou(w, state))), }; } @@ -686,6 +701,8 @@ export function AutopilotPage({ return (
+ {/* A shared workspace's routines are the owner's: shown, never changed here (view-only.ts holds every change). */} +
@@ -696,7 +713,7 @@ export function AutopilotPage({ {GROUP_NAME} - What your bots do on their own. How they do each job is in{" "} + {state.shared ? "What the owner's bots do on their own" : "What your bots do on their own"}. How they do each job is in{" "} {/* The link and its period together, so the period never starts a line on its own. */} {canSkip && ( @@ -1326,6 +1344,7 @@ export function ScheduleRow({ {skips.map((x) => ( {whenText(x, now, tz)} + {!state.shared && ( + )} ))} ), ], ["Runs", howWords(r)], - ["Quiet", r.quiet ? "Yes: it posts only when there's something for you" : ""], + ["Quiet", r.quiet ? `Yes: it posts only when there's something for ${you(state)}` : ""], ["Results go to", destinations(r, state)], // Set up on someone else's say (an email or a text from outside): who, so the user can tell. ["Set up by", r.fromOutside ? undash(r.fromOutside) : ""], @@ -1349,7 +1369,7 @@ export function ScheduleRow({ ran && ( - {RAN[ran.status]} · {since(ran.at)} + {ranWords(ran.status, state)} · {since(ran.at)} {ran.error && {undash(ran.error)}} @@ -1430,7 +1450,7 @@ export function MonitorRow({ open={open} onToggle={onToggle} rowRef={row} - remove={{ label: `Stop watching ${watchName(w)}`, tip: "Stop", busy: !!busy, onAsk: ask }} + remove={state.shared ? undefined : { label: `Stop watching ${watchName(w)}`, tip: "Stop", busy: !!busy, onAsk: ask }} confirm={ sure && ( <> @@ -1447,7 +1467,7 @@ export function MonitorRow({ ) } > - {!sure && ( + {!sure && !state.shared && (
{(isScreenWatch(w) || !!w.mac) && (
)} {/* Before Account, so the plans open on top of it and closing them shows it again. */} @@ -989,11 +1081,14 @@ function Bops({ state }: { state: AppState | null }) { {/* The first time a workspace gets email: "Pick your Bops address" (or an offer to change one Bops picked). */} {!settings && !account && !setup && !members && } {/* What bots are doing on the user's Mac, live, in the corner (hidden while the Your Mac tab is open). */} - {!(tab?.kind === "mac" && panelOpen) && } + {!(tab?.kind === "mac" && panelOpen) && !state.shared && }
+ + - + + ); } @@ -1323,23 +1418,6 @@ function OwnerSettings({ owner }: { owner: AppState["owner"] }) { ); } -const option = (on: boolean) => (on ? "bg-[#FBFFE0] shadow-[0_0_0_1.5px_#0A0A0A]" : "shadow-[0_0_0_1px_#E6E6E3] hover:bg-[#FCFCFB]"); -const radio = (on: boolean) => - on ? ( - - - - ) : ( - - ); - -/** A bot's computer, in plain words. A bot that shares says whose computer it works on. */ -const computerNow = (state: AppState, b: AppState["bots"][number]) => { - const c = workBot(b, state.bots); - if (state.host !== "mac" && c.id !== b.id) return `Works on ${c.name}'s computer`; - return state.host === "mac" ? "Uses this Mac" : b.computerStatus === "ready" ? "Ready" : b.computerStatus === "cloning" ? "Setting up" : b.computerStatus === "none" ? "Set up on its first task" : "Couldn't be set up yet"; -}; - /** * For self-hosting (BOPS_SELF_HOSTED=1): the providers behind Bops, from .env.local. How screens are * reached (Tailscale), the email domain, the phone service, and each computer's id. @@ -1388,7 +1466,7 @@ function SelfHosting({ state }: { state: AppState }) { {state.bots.map((b) => (
{b.name} - {state.host === "mac" ? "this Mac" : b.computerId ? `${b.computerId}${b.tailnet ? ` · ${b.tailnet.ip}` : ""}` : sharesComputer(b) ? `shares ${workBot(b, state.bots).name}'s` : "none yet"} + {!state.shared && state.host === "mac" ? "this Mac" : b.computerId ? `${b.computerId}${b.tailnet ? ` · ${b.tailnet.ip}` : ""}` : sharesComputer(b) ? `shares ${workBot(b, state.bots).name}'s` : "none yet"}
))}
@@ -1443,25 +1521,13 @@ export function Settings({ state, onClose, onShowScreen, onOpenVault }: { state:
+ {/* Bots in browsers on this Mac have no computer to run routines on; a self-hosted Bops has no Bops Cloud for a runner to share the state through. */} + {!selfHosted && state.host !== "mac" && } + {BRAINS_ON && } -
- Your bots' computers - {teamOf(state).map((b) => ( -
- - - {b.name} - - - - {computerNow(state, b)} - -
- ))} -
+ {/* (A shared workspace's computers are its owner's: nothing asks this Mac's Orgo about their keys.) */} + {selfHosted && } diff --git a/components/app/bot-computers.tsx b/components/app/bot-computers.tsx new file mode 100644 index 00000000..da138406 --- /dev/null +++ b/components/app/bot-computers.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { runnerNeedsKey, workBot, type AppState, type Bot, type ComputerOrgoKey, type RunnerState } from "@/lib/types"; +import { Your, yourMac } from "@/lib/shared"; +import { Mascot } from "./mascot"; +import { since } from "./side-group"; +import { teamOf } from "./ui"; + +/** What /api/runs-on/keys says: each computer's own Orgo key (null: it has none; left out: Orgo couldn't say), and where to manage it on Orgo. */ +export type ComputerKeys = { keys: Record; manage: string }; + +/** + * Whether the bots listed work in browsers on this Mac: the user's own, with Bops set to this Mac. Never a + * workspace shared with them's (state.host is the viewer's own app's; the owner's bots never work here). + */ +const onThisMac = (state: AppState) => !state.shared && state.host === "mac"; + +/** A bot's computer, in plain words. A bot that shares says whose computer it works on. */ +const computerNow = (state: AppState, b: Bot) => { + const c = workBot(b, state.bots); + if (onThisMac(state)) return "Uses this Mac"; + // A shared workspace's bot set to its owner's Mac works there, as its Details say (computer.tsx ComputerSummary). + if (state.shared && b.runsOn === "mac") return `Works on ${yourMac(state)}`; + if (c.id !== b.id) return `Works on ${c.name}'s computer`; + return b.computerStatus === "ready" ? "Ready" : b.computerStatus === "cloning" ? "Setting up" : b.computerStatus === "none" ? "Set up on its first task" : "Couldn't be set up yet"; +}; + +/** The computer that's the bot's own (not one it works on of another bot's), if it has one. */ +const ownComputer = (state: AppState, b: Bot) => (b.computerId && workBot(b, state.bots).id === b.id ? b.computerId : undefined); + +/** + * Whether a computer holds its own Orgo key, in a line under its bot: "Own Orgo key sk_live_ab12…" and + * when it was last used, or "No Orgo key" when it has none and its runner counts on one. Null: say + * nothing (it has none and nothing needs it, or Orgo couldn't say). + */ +export function keyLine(k: ComputerOrgoKey | null | undefined, x: RunnerState | undefined, now = Date.now()): string | null { + if (k === undefined) return null; + if (k === null) return runnerNeedsKey(x) ? "No Orgo key" : null; + const when = k.lastUsed === null ? "" : since(k.lastUsed); + const used = k.expiresAt !== null && k.expiresAt <= now ? "expired" : !when ? "never used" : `used ${when === "just now" || when.endsWith(" ago") ? when : `on ${when}`}`; + return `Own Orgo key${k.prefix ? ` ${k.prefix}…` : ""} · ${used}`; +} + +/** What a runner on its computer's Orgo identity says it signs in with (RunnerState.auth), above its key line while the computer still holds one. */ +export const IDENTITY_LINE = "Orgo identity (this computer only)"; + +/** + * Settings, Your bots' computers: each of the workspace's bots and how its computer is, and under a bot + * that has its own computer, whether its runner signs in with that computer's Orgo identity and whether + * the computer holds its own Orgo key (`keys`, from /api/runs-on/keys; null until it answers, and + * nothing said meanwhile). A bot that works on another bot's computer says only whose. Exported for the + * tests. + */ +export function BotComputersList({ state, keys, padded }: { state: AppState; keys: ComputerKeys | null; padded: boolean }) { + return ( +
+ ); +} + +/** + * Settings, Your bots' computers, in every mode. Each bot's own computer listed is asked about once while + * Settings is open, whether it holds its own Orgo key (Bops asks Orgo, never the window; not when + * `askKeys` is off); a row never waits on that. + */ +export function BotComputers({ state, padded, askKeys }: { state: AppState; padded: boolean; askKeys: boolean }) { + const [keys, setKeys] = useState(null); + const asked = useRef(new Set()); + const open = useRef(true); + useEffect(() => { + open.current = true; + return () => { + open.current = false; + }; + }, []); + const ids = askKeys + ? teamOf(state) + .map((b) => ownComputer(state, b)) + .filter((id): id is string => !!id) + .join(",") + : ""; + useEffect(() => { + const want = ids.split(",").filter((id) => id && !asked.current.has(id)); + if (!want.length) return; + for (const id of want) asked.current.add(id); + fetch(`/api/runs-on/keys?ids=${encodeURIComponent(want.join(","))}`, { cache: "no-store" }) + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .then((j) => open.current && j && setKeys((k) => ({ manage: j.manage, keys: { ...k?.keys, ...j.keys } }))) + .catch(() => {}); + }, [ids]); + return ; +} diff --git a/components/app/bot-memory.tsx b/components/app/bot-memory.tsx index 486c44ef..a2c741c1 100644 --- a/components/app/bot-memory.tsx +++ b/components/app/bot-memory.tsx @@ -1,9 +1,10 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { AppState, Bot, Session } from "@/lib/types"; +import { waitsForOk, type AppState, type Bot, type Session } from "@/lib/types"; import { MemorySheet } from "./memory-sheet"; import { ago, post, quietNothing, StatusIcon } from "./ui"; +import { you } from "@/lib/shared"; type Fact = { id: string; text: string; private: boolean; at?: number; source?: string }; type Data = { workspace: string; ws: string; card: Fact[]; facts: Fact[]; error?: string }; @@ -113,7 +114,7 @@ export function BotMemory({ state, bot: b, onOpenThread }: { state: AppState; bo setAnswer(null); setResults(null); }} - placeholder={`Ask ${b.name} what it knows about you`} + placeholder={`Ask ${b.name} what it knows about ${you(state)}`} className="h-8 min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-[#9A9A98]" /> {searching && ( @@ -197,7 +198,7 @@ export function BotMemory({ state, bot: b, onOpenThread }: { state: AppState; bo {(allWork ? done : done.slice(0, 3)).map((s) => ( - {!b.isMain && } + {!b.isMain && } ); } @@ -398,7 +401,7 @@ function ComputerChoice({ state, bot: b, className, onUpgrade }: { state: AppSta Computer {b.isMain - ? `Works on your free Bops computer, which ${main.name} has, with ${b.name}'s team` + ? `Works on ${your(state)} free Bops computer, which ${main.name} has, with ${b.name}'s team` : shared ? `Works on ${main.name}'s computer, on screens no one else is using` : plan?.plan?.bops @@ -447,8 +450,8 @@ function ComputerChoice({ state, bot: b, className, onUpgrade }: { state: AppSta ); } -/** Deleting a bot takes its computer with it, so it asks once more, saying exactly that. */ -function DeleteBot({ bot: b }: { bot: Bot }) { +/** Deleting a bot takes its computer with it, so it asks once more, saying exactly that (and that the apps it made stay, `apps`). */ +function DeleteBot({ bot: b, apps }: { bot: Bot; apps: string | null }) { const [sure, setSure] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -470,8 +473,8 @@ function DeleteBot({ bot: b }: { bot: Bot }) {
{sharesComputer(b) - ? `Delete ${b.name}? Its chat, threads and routines go with it; the computer it works on stays. This can't be undone.` - : `Delete ${b.name}? Its computer, chat, threads and routines go with it. This can't be undone.`} + ? `Delete ${b.name}? Its chat, threads and routines go with it; the computer it works on stays.${apps ? ` ${apps}` : ""} This can't be undone.` + : `Delete ${b.name}? Its computer, chat, threads and routines go with it.${apps ? ` ${apps}` : ""} This can't be undone.`} {error && {error}}
@@ -962,7 +965,7 @@ function Uses({ state, bot: b, onOpenVault, onOpenRoutines, className }: { state )}
{popover && (
@@ -1019,7 +1022,9 @@ function Phone({ bot: b }: { bot: Bot }) { ))}
- Not available yet. Until then, you can text and call {b.isMain ? b.name : "your main bot"} on its number (Settings, How your bots reach you). + {state.shared + ? `Not available yet. Until then, the owner can text and call ${b.isMain ? b.name : "their main bot"} on its number.` + : `Not available yet. Until then, you can text and call ${b.isMain ? b.name : "your main bot"} on its number (Settings, How your bots reach you).`}
diff --git a/components/app/bot-skills.tsx b/components/app/bot-skills.tsx index d3da749c..05d5a1e4 100644 --- a/components/app/bot-skills.tsx +++ b/components/app/bot-skills.tsx @@ -6,6 +6,7 @@ import { AppLogo, accountTitle, botApps } from "./apps"; import { BotKeys } from "./keys"; import { botSkills } from "./skills"; import { undash } from "./side-group"; +import { you, yourMac } from "@/lib/shared"; /* * Skills: what a bot can do, on one screen of its profile (bot-panel.tsx). Its workspace's skills and apps @@ -44,8 +45,8 @@ export function BotSkills({ // What's not set up yet, said once at the end instead of an empty card each. const missing = [ !skills.length && { label: "Save a skill from its chat", onClick: chat }, - !apps.length && { label: "Add Connected Apps in Vault", onClick: onOpenVault }, - !keyed && { label: "Add keys in Vault", onClick: () => onOpenKeys(b.id) }, + !apps.length && { label: "Add Connected Apps in Logins", onClick: onOpenVault }, + !keyed && { label: "Add keys in Logins", onClick: () => onOpenKeys(b.id) }, ].filter((x): x is { label: string; onClick: () => void } => !!x); return ( @@ -66,7 +67,7 @@ export function BotSkills({ )} {apps.length > 0 && ( - + {apps.map((x, i) => (
@@ -128,9 +129,10 @@ function builtIn(state: AppState, b: Bot): string[] { return [ "Searches and reads the web", "Makes pictures and pages", - b.runsOn === "mac" || state.host === "mac" ? "Works on your Mac" : host.id === b.id ? "Works on its computer" : `Works on ${host.name}'s computer`, + // (A shared workspace's bot never works on the viewer's own Mac, whatever their app's host: only on the owner's, when it's set to.) + b.runsOn === "mac" || (!state.shared && state.host === "mac") ? `Works on ${yourMac(state)}` : host.id === b.id ? "Works on its computer" : `Works on ${host.name}'s computer`, b.email && !b.mail?.paused ? "Email" : "", - line || ownLine ? "Texts you" : "", + line || ownLine ? `Texts ${you(state)}` : "", calls ? "Takes calls" : "", categoriesOpen(state) ? ORBIT : "", state.businessData && !b.dataOff ? "Looks up companies and people" : "", diff --git a/components/app/brains.tsx b/components/app/brains.tsx index f3a072f1..958a5386 100644 --- a/components/app/brains.tsx +++ b/components/app/brains.tsx @@ -74,7 +74,7 @@ export function needsSignIn(state: AppState, x: Brain): Bot | undefined { * key brain's key isn't there. */ export function brainLine(state: AppState, x: Brain, keys?: string[]): string { - if (x.auth === "key") return `Key: ${x.keyName ?? BRAIN_KEYS[x.kind]}${keys && !keys.includes(x.keyName ?? BRAIN_KEYS[x.kind]) ? " · not in your Vault" : ""}`; + if (x.auth === "key") return `Key: ${x.keyName ?? BRAIN_KEYS[x.kind]}${keys && !keys.includes(x.keyName ?? BRAIN_KEYS[x.kind]) ? " · not in Logins" : ""}`; const going = x.signingIn; // Whose computer it is: the bot that has it (a computer that's gone isn't anyone's any more). const owner = (computerId: string) => state.bots.find((b) => b.computerId === computerId)?.name; @@ -346,9 +346,9 @@ const choice = (on: boolean) => `flex flex-1 flex-col gap-0.5 rounded-[12px] px- const SIGN_INS: Record = { claude: [ { id: "plan", name: "Claude account with subscription", hint: "Pro, Max, Team, or Enterprise. You sign in with Claude Code's own sign-in on the bot's computer." }, - { id: "key", name: "Anthropic API key", hint: "Your key from the Vault. Usage is billed to your Anthropic account." }, + { id: "key", name: "Anthropic API key", hint: "Your key from Logins. Usage is billed to your Anthropic account." }, ], - codex: [{ id: "key", name: "OpenAI API key", hint: "Your key from the Vault. Usage is billed to your OpenAI account." }], + codex: [{ id: "key", name: "OpenAI API key", hint: "Your key from Logins. Usage is billed to your OpenAI account." }], }; /** @@ -455,14 +455,14 @@ export function AddBrain({ {auth === "key" && (saved ? ( - Uses {saved.name} from your Vault{saved.scope === "workspace" ? ", this workspace's" : ""}. + Uses {saved.name} from Logins{saved.scope === "workspace" ? ", this workspace's" : ""}. ) : ( - {keys === null ? "Checking your Vault" : `Add your ${vendor} key in the Vault first. Only ${BRAIN_NAMES[kind ?? "claude"]} on the bot's computer gets it.`} + {keys === null ? "Checking Logins" : `Add your ${vendor} key in Logins first. Only ${BRAIN_NAMES[kind ?? "claude"]} on the bot's computer gets it.`} {keys !== null && ( )} diff --git a/components/app/call-bar.tsx b/components/app/call-bar.tsx index 00bc752f..70319896 100644 --- a/components/app/call-bar.tsx +++ b/components/app/call-bar.tsx @@ -84,11 +84,34 @@ function hangUpTone() { /** The bot lets it ring at least this long before picking up, like a person reaching for their phone. */ const RING_AT_LEAST_MS = 1300; +/** + * Whether the bot is talking, from how loud its voice is coming through now (0 to 1, as the waveform + * meters it) and when it was last loud: it stays talking through the short gaps between words, so its + * mascot's mouth doesn't snap shut mid-sentence. + */ +export const BOT_TALKING = { level: 0.08, holdMs: 400 }; +export const botTalking = (level: number, loudAt: number, now: number) => level > BOT_TALKING.level || now - loudAt < BOT_TALKING.holdMs; + /** * `compact`: you're in another chat (or the computer), so the call waits small in the sidebar's - * corner: who, the time, mute and hang up. Click the bot to go back to its chat. + * corner: who, the time, mute and hang up. Click the bot to go back to its chat. `onSpeaking` hears + * when the bot starts and stops talking (its mascots talk while it does). */ -export function CallBar({ bot: b, owner, onClose, compact, onOpen }: { bot: Bot; owner?: string; onClose: () => void; compact?: boolean; onOpen?: () => void }) { +export function CallBar({ + bot: b, + owner, + onClose, + compact, + onOpen, + onSpeaking, +}: { + bot: Bot; + owner?: string; + onClose: () => void; + compact?: boolean; + onOpen?: () => void; + onSpeaking?: (speaking: boolean) => void; +}) { // The name the bot greets you by, read when the call connects (a later change shouldn't restart the call). const ownerRef = useRef(owner); useEffect(() => { @@ -284,8 +307,11 @@ export function CallBar({ bot: b, owner, onClose, compact, onOpen }: { bot: Bot; }, [b.id]); // The waveform: every ~90ms, how loud the call is right now (whoever is talking) becomes the newest dot. + // The bot's own loudness also says whether it's talking (botTalking), told on each change. useEffect(() => { if (phase !== "live") return; + let loudAt = 0; + let talking = false; const buf = new Uint8Array(512); const loudness = (node: AnalyserNode | undefined, gain: number) => { if (!node) return 0; @@ -296,11 +322,18 @@ export function CallBar({ bot: b, owner, onClose, compact, onOpen }: { bot: Bot; }; const t = setInterval(() => { // The bot's audio arrives quieter than a mic, so it gets a little more gain. - const level = Math.max(loudness(meters.current.bot, 7), muted ? 0 : loudness(meters.current.you, 3.5)); + const bot = loudness(meters.current.bot, 7); + const level = Math.max(bot, muted ? 0 : loudness(meters.current.you, 3.5)); setLevels((l) => [...l.slice(1), level]); + const now = Date.now(); + if (bot > BOT_TALKING.level) loudAt = now; + if (botTalking(bot, loudAt, now) !== talking) onSpeaking?.((talking = !talking)); }, 90); - return () => clearInterval(t); - }, [phase, muted]); + return () => { + clearInterval(t); + if (talking) onSpeaking?.(false); + }; + }, [phase, muted, onSpeaking]); // The call timer. useEffect(() => { @@ -347,7 +380,7 @@ export function CallBar({ bot: b, owner, onClose, compact, onOpen }: { bot: Bot; className="flex size-10 shrink-0 items-center justify-center rounded-full enabled:hover:brightness-95" style={{ background: `color-mix(in oklab, ${b.color} 18%, white)` }} > - +
{b.name} diff --git a/components/app/channels.tsx b/components/app/channels.tsx index 4603efe3..ffe0360b 100644 --- a/components/app/channels.tsx +++ b/components/app/channels.tsx @@ -13,7 +13,16 @@ import { BrandTile, post } from "./ui"; type Kind = "slack" | "telegram" | "discord"; -export function WhereToFind({ state, bot: b }: { state: AppState; bot: Bot }) { +/** + * Never in a workspace shared with the user: the channels here are the viewer's own (state.channels), and + * a link of theirs for a bot of the same id ("boppy" is every workspace's first bot) would read as where + * the owner's bot can be reached. Setting one up there would change the owner's bot too. + */ +export function WhereToFind(props: { state: AppState; bot: Bot }) { + return props.state.shared ? null : ; +} + +function WhereToFindCard({ state, bot: b }: { state: AppState; bot: Bot }) { const [open, setOpen] = useState(null); const links = (state.channels ?? []).filter((l) => l.botId === b.id); const line = state.workspaces?.find((w) => w.id === workspaceOf(b))?.line; diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 4caab77d..5cb64342 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -2,13 +2,16 @@ import { screenMap, screenNo } from "@/lib/screens"; import { askKind, chatAsks } from "@/lib/asks"; +import { alwaysScope, toDo } from "@/lib/permission"; import { createContext, Fragment, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { BLOCKER_ASK, BRAINS_ON, brainOf, live, pairChatId, threadRunner, workspaceOf, TAPBACK_EMOJI, TAPBACKS, type AppApproval, type AppState, type Bot, type Chat, type Message, type Session, type Tapback } from "@/lib/types"; +import { BLOCKER_ASK, BRAINS_ON, brainOf, live, ownerSent, pairChatId, routineSent, threadRunner, waitingAsks, WAITING_FOR_OK, waitsForOk, workspaceOf, TAPBACK_EMOJI, TAPBACKS, type AppApproval, type AppState, type Blocker, type Bot, type Chat, type MadeApp, type Message, type Session, type Tapback } from "@/lib/types"; import { Tapback as TapbackBalloon, TapbackGlyph, tapbackVars } from "@/components/message-ui/tapback"; import { Mascot } from "./mascot"; import { OpenLink } from "./panel-tabs"; import { crmOpen } from "@/lib/crm-access"; +import { isViewerOwn, ownerWho } from "@/lib/shared"; import { CrmNote } from "./crm"; +import { LookingAt, MadeAppLine } from "./made-apps"; import { MacIcon } from "./mac-tab"; import { AttachmentTray, MessageImages, useAttachments } from "./attachments"; import { ComposerInput } from "./composer-input"; @@ -18,12 +21,29 @@ import { SaveSkillDialog } from "./skill-dialog"; import { undash } from "./side-group"; import { ApiAsk, apiAskOf, apiCallOf, ApiNote } from "./keys"; import { BrainBadge, brainSignIn, openInTerminal, threadBadge } from "./brains"; -import { ChatAvatar, chatName, clockTime, needsYou, post, StatusIcon, teamOf, useNow } from "./ui"; +import { ChatAvatar, chatName, clockTime, needsYou, post, ReadOnlyLine, StatusIcon, teamOf, useNow } from "./ui"; /** 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. */ const PAGE = 60; +/** + * Who a chat's sentences say something waits on or was emailed to: the user ("you") in their own + * workspace, its owner ("the owner") in one shared with them, where none of it is the viewer's (ownerWho). + */ +const ownerYou = (state: AppState) => (state.shared ? "the owner" : "you"); +/** Whose Mac a thread runs on or could move to, the same way: "Your Mac", or the owner's in a shared workspace. */ +const ownerMac = (state: AppState) => (state.shared ? "Owner's Mac" : "Your Mac"); +/** What a blocked thread needs (BLOCKER_ASK says it of the user), of the owner in a shared workspace. */ +const OWNER_BLOCKER_ASK: Record = { + sign_in: "the owner to sign in", + two_factor: "a code from the owner", + captcha: "the owner to pass a captcha", + payment: "the owner's OK to pay", + error: "the owner: the page errored", +}; +const blockerAsk = (state: AppState, b: Blocker) => (state.shared ? OWNER_BLOCKER_ASK[b] : BLOCKER_ASK[b]); + /** The bot whose text this is, so a file it links opens from its computer (see Inline). */ const LinkedBy = createContext(undefined); @@ -182,6 +202,7 @@ export function ChatView({ state, chat: c, wide, + lookingAt, onOpenThread, onShowBot, onShowMac, @@ -198,12 +219,14 @@ export function ChatView({ chat: Chat; /** The right panel is collapsed, so the chat centers itself. */ wide: boolean; + /** An app a bot made, open beside the chat: what the user says is about it, unless they say otherwise (made-apps.tsx). */ + lookingAt?: MadeApp; onOpenThread: (s: Session) => void; onShowBot: (botId: string) => void; /** Open the "Your Mac" tab. */ onShowMac: () => void; - /** Open a bot's computer on one screen, e.g. the watched screen a heads-up is about. */ - onShowScreen: (botId: string, display: number) => void; + /** Open a bot's computer on one screen, e.g. the watched screen a heads-up is about; `sessionId`, for the thread waiting there whose card it's from. */ + onShowScreen: (botId: string, display: number, sessionId?: string) => void; /** Open the Account sheet, where AI credit is bought. */ onUpgrade?: () => void; peek?: React.ReactNode; @@ -242,6 +265,9 @@ export function ChatView({ } prefilledDone.current?.(); }, [prefilled]); + // The app the user took the "Looking at" chip off: what they say isn't about it, until they open another. + const [notLooking, setNotLooking] = useState(null); + const looking = lookingAt && notLooking !== lookingAt.id ? lookingAt : undefined; // The message an inline reply is to (iMessage's swipe-to-reply), and the reply view that's open. const [replyingTo, setReplying] = useState(null); const [repliesOpen, setRepliesOpen] = useState(null); @@ -254,6 +280,13 @@ export function ChatView({ const setShown = (f: (n: number) => number) => setWindow({ chat: c.id, shown: f(shown) }); const hiddenCount = Math.max(0, messages.length - shown); const visible = hiddenCount ? messages.slice(hiddenCount) : messages; + // A shared workspace's chat with older messages in Bops Cloud than the window has: read a page more as + // the ones here run out (lib/server/shared-core.ts SharedCopy.older). + const olderThere = !!state.shared?.more?.includes(c.id); + const showEarlier = () => { + setShown((n) => n + PAGE); + if (olderThere && hiddenCount < 2 * PAGE) void post("/api/workspaces/shared", { older: c.id }); + }; // A thread gets one chip, on the latest message about it, so it moves down as the thread moves on. const chipHome = useMemo(() => { const home = new Map(); @@ -262,12 +295,14 @@ export function ChatView({ }, [messages]); const members = c.botIds.map((b) => state.bots.find((x) => x.id === b)).filter(Boolean) as Bot[]; // What's waiting on you, pinned over the composer: threads started here (in the main bot's chat, - // every bot's: lib/asks.ts chatAsks), and app actions asked here or by one of those threads. - const threadAsks = chatAsks(state, c.id); + // every bot's: lib/asks.ts chatAsks), and what a bot asked your OK for here or in one of those threads, + // wherever its run runs (a bot's computer too: the requests are shared, lib/server/permission.ts). A thread + // waiting for your OK shows as its request's card. const mainChat = c.kind === "bot" && state.bots.some((b) => b.isMain && c.botIds.includes(b.id)); - const appAsks = (state.appApprovals ?? []).filter( - (a) => a.chatId === c.id || state.sessions.some((x) => x.id === a.sessionId && x.chatId === c.id) || (mainChat && workspaceOf(state.bots.find((b) => b.id === a.botId) ?? members[0]) === workspaceOf(members[0])), + const appAsks = waitingAsks(state).filter( + (a) => a.chatId === c.id || state.sessions.some((x) => x.id === a.threadId && x.chatId === c.id) || (mainChat && workspaceOf(state.bots.find((b) => b.id === a.botId) ?? members[0]) === workspaceOf(members[0])), ); + const threadAsks = chatAsks(state, c.id).filter((x) => !appAsks.some((a) => a.threadId === x.id)); const [allAsks, setAllAsks] = useState(false); const shownAsks = allAsks ? threadAsks : threadAsks.slice(0, 2); const solo = c.kind === "bot" ? members[0] : undefined; @@ -354,7 +389,7 @@ export function ChatView({ // Shows at once; goes once its images have finished uploading (usually they already have). const images = (await Promise.all(items.map((a) => a.done))).filter((x) => !!x); if (!text && !images.length) return drop(); - await post(`/api/chats/${encodeURIComponent(c.id)}/messages`, { text, replyTo: replying?.id, images: images.length ? images : undefined }).finally(drop); + await post(`/api/chats/${encodeURIComponent(c.id)}/messages`, { text, replyTo: replying?.id, images: images.length ? images : undefined, lookingAt: looking?.id }).finally(drop); }; const filesOf = (d: DataTransfer | null) => [...(d?.files ?? [])].filter((f) => f.type.startsWith("image/")); // Sent messages the server's state doesn't have yet. @@ -392,7 +427,8 @@ export function ChatView({ ? `Working on ${running.length} thing${running.length === 1 ? "" : "s"}` : solo ? solo.role - : members.map((m) => m.name).join(", ") + " and you"; + : // A shared workspace's chats are its owner's: the viewer isn't in them. + members.map((m) => m.name).join(", ") + (state.shared ? "" : " and you"); const width = wide ? "mx-auto w-full max-w-[680px]" : ""; @@ -401,7 +437,8 @@ export function ChatView({
{ - if (![...e.dataTransfer.types].includes("Files")) return; + // Nothing's sent in a shared workspace, so it takes no images (the window refuses them: bops-app.tsx). + if (state.shared || ![...e.dataTransfer.types].includes("Files")) return; e.preventDefault(); if (!dropping) setDropping(true); }} @@ -409,6 +446,7 @@ export function ChatView({ onDrop={(e) => { e.preventDefault(); setDropping(false); + if (state.shared) return; const files = filesOf(e.dataTransfer); if (files.length) { attach.add(files); @@ -424,8 +462,9 @@ export function ChatView({ {pair && setPair(null)} />} {saving && }
- {/* The chat's own menu (a bot's chat, like Call): Save as skill rereads this chat for how the bot did a job. */} - {solo && !call && ( + {/* The chat's own menu (a bot's chat, like Call): Save as skill rereads this chat for how the bot did a job. + Neither in a shared workspace: its bots are the owner's to call and teach. */} + {solo && !call && !state.shared && ( , onClick: () => setSaving({ botId: solo.id, chatId: c.id }) }]} /> )} - {solo && !call && ( + {solo && !call && !state.shared && ( )} @@ -506,7 +546,7 @@ export function ChatView({ {...rowProps(m)} /> {i + hiddenCount === lastUserIdx && repliedAfterUser && ( -
Read {clockTime(m.at)}
+
Read {clockTime(m.at)}
)} ); @@ -533,7 +573,8 @@ export function ChatView({ )}
- {c.kind === "group" && tb && } + {/* Its mascot beside the dots thinks (or writes, when a thread of its is writing), in a group or 1:1. */} + {tb && }
{/* A wave, each dot 150 ms behind the last. The delays are a whole 1.2 s wave early, so no dot sits dark and still before its turn comes. */} @@ -554,9 +595,12 @@ export function ChatView({
- {state.credits?.out && } + {/* The viewer's own credit, which a shared workspace's bots (the owner's) don't run on. */} + {state.credits?.out && !state.shared && } {(appAsks.length > 0 || threadAsks.length > 0) && (
+ {/* A shared workspace's asks are the owner's to answer: shown, never answered here. */} +
{shownAsks.map((x) => ( (x.display ?? x.lastDisplay) !== undefined ? onShowScreen(x.botId, (x.display ?? x.lastDisplay)!) : onOpenThread(x)} + onShow={() => (x.display ?? x.lastDisplay) !== undefined ? onShowScreen(x.botId, (x.display ?? x.lastDisplay)!, x.id) : onOpenThread(x)} onShowScreen={onShowScreen} onSomethingElse={() => onOpenThread(x)} onOpen={() => onOpenThread(x)} @@ -572,12 +616,19 @@ export function ChatView({ ))} {threadAsks.length > shownAsks.length && ( )} {appAsks.map((a) => ( ))} +
+
+ )} + {/* The app open beside the chat, which what's said is about (made-apps.tsx); its X takes it off. */} + {looking && ( +
+ setNotLooking(looking.id)} />
)} {/* Folds open over the composer. Keyed by chat, so switching chats shows it as it is there, with @@ -603,6 +654,9 @@ export function ChatView({ )} + {state.shared ? ( + + ) : (
{ e.preventDefault(); @@ -663,6 +717,7 @@ export function ChatView({
+ )} {micError &&
{micError}
}
@@ -772,6 +827,9 @@ function RepliesView({ {typing.length > 0 && {typing.map((t) => state.bots.find((b) => b.id === t)?.name).join(", ")} typing…}
+ {state.shared ? ( + + ) : (
{ e.preventDefault(); @@ -794,6 +852,7 @@ function RepliesView({ + )}
); @@ -986,7 +1045,11 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO onPointerUp: () => press.current && clearTimeout(press.current), onPointerLeave: () => press.current && clearTimeout(press.current), }; - const outgoing = m.role === "user"; + // A shared workspace's messages are the owner's: read and copied, never reacted to, replied to or deleted here. + const ro = !!state.shared; + // The user's own words go out on the right. In a workspace shared with them the owner's come in on the left, + // named as the owner's, never in the viewer's own outgoing bubble. + const outgoing = m.role === "user" && !ro; // The bot's latest message can offer replies as buttons (see ASKING); tapping one sends it. const newest = lasts(state.messages).byChat.get(m.chatId) === m.id; const mine = m.reactions?.find((r) => r.by === "owner"); @@ -996,7 +1059,9 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO const same = r && mine && (r.type ? mine.type === r.type : mine.emoji === r.emoji); void post(`/api/chats/${encodeURIComponent(m.chatId)}/messages`, { id: m.id, reaction: same ? null : r }, "PATCH"); }; - const nameOf = (by: string | undefined) => (by === "owner" ? "You" : (state.bots.find((x) => x.id === by)?.name ?? "A bot")); + // In a shared workspace the owner's words are theirs, never the user's. + const you = ownerWho(state.shared); + const nameOf = (by: string | undefined) => (by === "owner" ? you : (state.bots.find((x) => x.id === by)?.name ?? "A bot")); // Tapbacks sit on the bubble's top corner, away from its sender, like Messages: yours blue, the bots' gray. const balloons = !!m.reactions?.length && ( @@ -1007,7 +1072,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO > {m.reactions.slice(-3).map((r, i) => ( - + ))} @@ -1016,6 +1081,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO // Hovering a message: react, reply, delete. The picker opens above the bubble. const actions = ( + {!ro && ( - {!inReplies && ( + )} + {!inReplies && !ro && ( @@ -1060,7 +1127,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO // Both open out of the bubble's corner they hang from: right for yours, left for the bot's. const actionMenu = menu && (
- {!inReplies && ( + {!inReplies && !ro && ( + {!ro && ( + )}
); const picker = (picking || menu) && ( <>
(e.preventDefault(), close())} /> + {!ro && (
@@ -1117,6 +1187,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO ))}
+ )} ); // A reply quotes what it answers; the message replied to says how many replies it has. @@ -1124,7 +1195,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO ); @@ -1143,7 +1214,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO const latest = m.watch && lasts(state.messages).byWatch.get(m.watch.id) === m.id; const watch = latest ? state.watches?.find((w) => w.id === m.watch!.id) : undefined; const page = watch?.page; - const watchActions = watch && page ? ( + const watchActions = ro ? null : watch && page ? (
)} - {chips + {!ro && chips .filter((s) => s.askWhere && live(s)) .map((s) => (
@@ -1239,7 +1310,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO
))} {/* The bot (or someone's words) suggested the Mac, but the user didn't ask: nothing moves until they say so (sessions.ts offerMove). Only while it runs (once it's over, a move would do it all again), and not while its card above offers Try on your Mac. */} - {chips + {!ro && chips .filter((s) => s.offerMac && live(s) && s.runsOn !== "mac" && state.mac?.ready && !(needsYou(s) && (s.blocker || s.status === "failed"))) .map((s) => (
@@ -1257,23 +1328,27 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO if (m.role === "user") return ( -
+
+ {!outgoing && !continued &&
{you}
} {quote} {/* Room above the bubble for its tapbacks, so they don't cover the quote or sender line (it eases open as the first one pops in). */}
- {actions} + {outgoing && actions}
{picker} {actionMenu} {balloons} -
- {!!m.images?.length && } - {m.text &&
{m.text}
} +
+ {!!m.images?.length && } + {m.text && ( +
{m.text}
+ )}
+ {!outgoing && actions}
{m.via === "sms" && ( - + @@ -1282,7 +1357,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO )} {m.via && m.via !== "sms" && ( - + {/* eslint-disable-next-line @next/next/no-img-element */} via {CHANNEL_NAME[m.via]} @@ -1296,14 +1371,15 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO // A text or call to the bot's number from someone else. if (m.role === "system" && m.sms) return ; - // A call shows as a bubble from you in the bot's color, like a call in Messages. + // A call shows as a bubble from you in the bot's color, like a call in Messages. The owner's, in a + // workspace shared with the user, comes in on the left, named as theirs. if (m.call) { const caller = state.bots.find((x) => `bot:${x.id}` === m.chatId); const fill = !caller || caller.isMain ? "#0A0A0A" : caller.color; const mins = Math.floor(m.call.seconds / 60); - return ( + const bubble = (
@@ -1313,26 +1389,47 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO {m.call.seconds % 60}s · {m.call.phone ? "Phone call" : "Call"} ended
); + return ro ? ( +
+
{you}
+ {bubble} +
+ ) : ( + bubble + ); } if (m.role === "system" && m.email) return ; // Something just remembered rises in with the soft wash Orbit gives what it learns. - if (m.role === "system" && m.memory) return ; + // In a workspace shared with the user it's the owner's memory: shown, nothing to undo or forget (readOnly). + if (m.role === "system" && m.memory) return ; - // A bot's save to the CRM: Open the file, or Undo it (only where the CRM is open: lib/crm-access.ts). - // CrmNote takes no class, so a new one rises in inside a box that leaves its own alignment as it was. + // A bot's save to the CRM: Open the file, or Undo it (only where the CRM is open: lib/crm-access.ts; and + // neither in a workspace shared with the user, whose CRM is the owner's). CrmNote takes no class, so a new + // one rises in inside a box that leaves its own alignment as it was. if (m.role === "system" && m.crm) return crmOpen(state) ? ( enter ? (
- +
) : ( - + ) ) : null; + // A bot building or changing an app: Show it, Open it, or Undo the change (made-apps.tsx). Like + // CrmNote, a new one rises in inside a box that leaves its own alignment as it was. + if (m.role === "system" && m.madeApp) + return enter ? ( +
+ +
+ ) : ( + + ); + // A call a bot made with one of your keys (keys.tsx): the service, the method and the path. const api = apiCallOf(m); if (m.role === "system") @@ -1372,8 +1469,8 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO {actions}
{replies} - {m.needed && m.resultOf && } - {newest && !!m.options?.length && ( + {m.needed && m.resultOf && } + {newest && !ro && !!m.options?.length && (
{m.options.map((o) => ( + )} {messages.length === 0 &&
Nothing between them yet.
} {messages.map((m, i) => { const b = state.bots.find((x) => x.id === m.botId); @@ -1580,7 +1689,7 @@ function EmailCard({ m, state, className = "" }: { m: Message; state: AppState; const b = state.bots.find((x) => `bot:${x.id}` === m.chatId); const from = person(e.from); const long = m.text.length > 280 || m.text.split("\n").length > 4; - const label = e.dir === "in" ? (e.fromOwner ? `You emailed ${b?.name ?? "the bot"}` : `Email to ${b?.name ?? "the bot"}`) : `${b?.name ?? "The bot"} sent an email`; + const label = e.dir === "in" ? (e.fromOwner ? `${ownerWho(state.shared)} emailed ${b?.name ?? "the bot"}` : `Email to ${b?.name ?? "the bot"}`) : `${b?.name ?? "The bot"} sent an email`; return (
@@ -1652,7 +1761,8 @@ function SystemNote({ text }: { text: string }) { ); } -function MemoryNote({ m, className = "" }: { m: Message; className?: string }) { +/** Something a bot remembered, with Undo (and Forget old for what it replaced): neither when `readOnly` (a shared workspace's, its owner's memory). */ +function MemoryNote({ m, readOnly = false, className = "" }: { m: Message; readOnly?: boolean; className?: string }) { const mem = m.memory!; const [busy, setBusy] = useState(false); const act = (action: "undo" | "forget-old") => { @@ -1672,7 +1782,7 @@ function MemoryNote({ m, className = "" }: { m: Message; className?: string }) { {mem.fact}
- {!mem.undone && ( + {!mem.undone && !readOnly && ( @@ -1687,9 +1797,11 @@ function MemoryNote({ m, className = "" }: { m: Message; className?: string }) { Replaces: {mem.old.text} - + {!readOnly && ( + + )} )}
@@ -1715,27 +1827,27 @@ function SessionChip({ session: s, state, onClick }: { session: Session; state: onClick={onClick} className="flex max-w-full items-center gap-[9px] rounded-[14px] bg-white py-[9px] pl-3 pr-3.5 text-left shadow-[0_0_0_1px_#E6E6E3] hover:shadow-[0_0_0_1px_#C9C9C6]" > - + {s.title} {/* The brain that runs it, when one does (threadRunner); Bops agent only where its bot is on a brain now. */} {badge && } - {/* Where it runs: the user's Mac or the cloud (deciding: nothing yet). */} + {/* Where it runs: the user's Mac (the owner's, in a shared workspace) or the cloud (deciding: nothing yet). */} {s.runsOn === "mac" ? ( - + - Your Mac + {ownerMac(state)} ) : s.askWhere && live(s) ? ( · where? ) : null} - {waiting && · {s.blocker ? `needs ${BLOCKER_ASK[s.blocker]}` : "needs you"}} + {waiting && · {s.blocker ? `needs ${blockerAsk(state, s.blocker)}` : `needs ${ownerYou(state)}`}} {/* Which screen it's on, live: the number on the panel's tiles. */} {live(s) && s.runsOn !== "mac" && s.display !== undefined && ( Screen {screenNo(s.display)} )} - {b && s.sentVia !== "you" && s.chatId !== `bot:${b.id}` && · {b.name}} + {b && !ownerSent(s) && s.chatId !== `bot:${b.id}` && · {b.name}} ); } @@ -1785,8 +1897,8 @@ export function ThreadSheet({ session: Session; onClose: () => void; onShowComputer: () => void; - /** Show one of a bot's screens up close: where its brain's sign-in opened (NeedsYouCard). */ - onShowScreen?: (botId: string, display: number) => void; + /** Show one of a bot's screens up close: where its brain's sign-in opened, or the thread's own, for it (NeedsYouCard). */ + onShowScreen?: (botId: string, display: number, sessionId?: string) => void; }) { // "Working for 1m 23s" counts up while it's open. useNow(live(s) ? 1000 : 60_000); @@ -1811,7 +1923,8 @@ export function ThreadSheet({ const closeSaving = useCallback(() => setSaving(null), []); const end = useRef(null); const b = state.bots.find((x) => x.id === s.botId)!; - const via = s.sentVia === "routine" ? undefined : s.sentVia === "you" ? b : state.bots.find((x) => x.id === s.sentVia); + // Sent via the bot itself when the user gave it; else the bot that handed it on (a bot from before named You too: ownerSent). + const via = routineSent(s) ? undefined : ownerSent(s) ? b : state.bots.find((x) => x.id === s.sentVia); // The routine whose run this is, while it's there (Session.routineId). const routine = s.routineId ? state.routines.find((r) => r.id === s.routineId) : undefined; const lines = timeline(s); @@ -1860,13 +1973,15 @@ export function ThreadSheet({ {badge && }
- {s.runsOn !== "mac" && state.mac?.ready && (s.blocker || s.status === "failed" || (s.offerMac && live(s))) && ( + {/* A shared workspace's thread is the owner's: nothing here moves, stops, deletes or saves it. */} + {/* Not for a run waiting for your OK: its card answers it where it runs. */} + {!state.shared && s.runsOn !== "mac" && state.mac?.ready && !waitsForOk(s, state) && (s.blocker || s.status === "failed" || (s.offerMac && live(s))) && ( )} - {canTerminal && ( + {canTerminal && !state.shared && ( )} - {live(s) ? ( + {state.shared ? null : live(s) ? ( @@ -1902,7 +2017,7 @@ export function ThreadSheet({ )} {/* Save as skill: the bot rereads this thread for how it did the job. Keyed by thread, so another one's sheet starts it shut. */} - , onClick: () => setSaving(s.id) }]} /> + {!state.shared && , onClick: () => setSaving(s.id) }]} />}
)} - {s.askWhere && live(s) ? `Waiting for you for ${since(started)}` : live(s) ? `Working for ${since(started)}` : s.status === "queued" ? "Up next" : `Worked for ${since(started, s.endedAt)}`} + {s.askWhere && live(s) + ? `Waiting for ${ownerYou(state)} for ${since(started)}` + : waitsForOk(s, state) + ? state.shared + ? "Waiting for the owner's OK" + : WAITING_FOR_OK + : live(s) + ? `Working for ${since(started)}` + : s.status === "queued" + ? "Up next" + : `Worked for ${since(started, s.endedAt)}`} {s.helperNames?.length ? ` · with helpers ${s.helperNames.join(", ")}` : live(s) && s.helperScreens?.length @@ -1995,6 +2120,12 @@ export function ThreadSheet({ {l.text} + ) : l.role === "user" && state.shared ? ( + // The owner's reply, in a workspace shared with the user: named as theirs, on the incoming side, never as the viewer's own. +
+ {ownerWho(state.shared)} +
{l.text}
+
) : l.role === "user" ? (
{l.text} @@ -2022,15 +2153,33 @@ export function ThreadSheet({ {termError.text} )} - {live(s) && {s.status === "queued" ? "Queued" : "Thinking"}} + {live(s) && !waitsForOk(s, state) && {s.status === "queued" ? "Queued" : "Thinking"}}
+ {/* Waiting for your OK: its request's card, with Deny, Always allow and Allow once (the owner's alone in a shared workspace). */} + {waitingAsks(state, s.id).length > 0 && ( +
+ {waitingAsks(state, s.id).map((a) => ( + + ))} +
+ )} {needsYou(s) && ( -
- document.getElementById("thread-reply")?.focus()} /> +
+ (onShowScreen && (s.display ?? s.lastDisplay) !== undefined ? onShowScreen(s.botId, (s.display ?? s.lastDisplay)!, s.id) : onShowComputer())} + onShowScreen={onShowScreen} + onSomethingElse={() => document.getElementById("thread-reply")?.focus()} + />
)} + {state.shared ? ( + + ) : (
{ e.preventDefault(); @@ -2060,6 +2209,7 @@ export function ThreadSheet({
+ )}
); @@ -2280,7 +2430,8 @@ function NeedsYouCard({ await post(`/api/sessions/${s.id}/reply`, { text }); setBusy(false); }; - const toMac = s.runsOn !== "mac" && !!state.mac?.ready; + // The user's own Mac (state.mac): never where a shared workspace's thread goes. + const toMac = s.runsOn !== "mac" && !!state.mac?.ready && !state.shared; // Dismiss means done with it: the thread stops and never asks again. const dismiss = () => { setBusy(true); @@ -2296,22 +2447,28 @@ function NeedsYouCard({ setError(await brainSignIn(brain.id, s.botId, onShowScreen ?? (() => onShow()))); setBusy(false); }; + // A shared workspace's asks wait on its owner, never the viewer. const title = s.blocker - ? `${name} needs ${BLOCKER_ASK[s.blocker]}` + ? `${name} needs ${blockerAsk(state, s.blocker)}` : kind === "brain" ? `${name} needs ${brain?.name ?? "Claude Code"} signed in` : failed ? `${name} couldn't finish` : kind === "elsewhere" - ? `${name} needs you to approve it on your phone` + ? state.shared + ? `${name} needs the owner to approve it on their phone` + : `${name} needs you to approve it on your phone` : kind === "safety" - ? `${name} needs your OK to go on` - : `${name} is waiting on you`; + ? `${name} needs ${state.shared ? "the owner's" : "your"} OK to go on` + : `${name} is waiting on ${ownerYou(state)}`; const answer = withoutBriefing(s.answer ?? "").trim(); // The question itself, so the card says what to answer (the last sentence that asks something); pinned, the bot's words when none asks. const asked = answer.split(/(?<=[.!?])\s+|\n+/).reverse().find((x) => x.trim().endsWith("?"))?.trim(); const question = quote && !s.blocker && !failed ? (asked ?? (pinned ? answer : undefined)) : pinned && failed ? s.error : undefined; const show: Record = { sign_in: `Sign in for ${name}`, two_factor: "Enter the code", captcha: "Solve the check", payment: "Review the payment", error: "Look at the screen" }; + // Done at the screen yourself (signed in by taking control, say): the run that waited for it carries on, where it ran. + const doneAtScreen: Record = { sign_in: ["I signed in", "I signed in. Carry on."], two_factor: ["I entered the code", "I entered the code. Carry on."], captcha: ["I solved it", "I solved the check. Carry on."] }; + const carryOn = s.blocker && !live(s) ? doneAtScreen[s.blocker] : undefined; const primary = "rounded-full bg-ink px-3.5 py-1.5 text-[13px] font-semibold leading-4 text-white disabled:opacity-50"; const plain = "rounded-full bg-white px-3.5 py-1.5 text-[13px] font-medium leading-4 shadow-[0_0_0_1px_#E2E2DF] hover:bg-[#F7F7F6] disabled:opacity-50"; const showScreen = display !== undefined && ( @@ -2338,6 +2495,11 @@ function NeedsYouCard({ + {carryOn && ( + + )} {toMac && ( - + {api && } + {!api && a.resources.length > 0 && a.kind !== "email" && ( +
+ {a.resources.map((x) => ( + + {x} + + ))} +
+ )} + {a.preview && !api &&
{a.preview}
} + {telling && ( +