Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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-<version>-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`).

Expand Down
2 changes: 2 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
17 changes: 13 additions & 4 deletions app/api/apps/approvals/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
4 changes: 4 additions & 0 deletions app/api/bots/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
}

Expand Down
10 changes: 8 additions & 2 deletions app/api/chats/[chatId]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
26 changes: 26 additions & 0 deletions app/api/hosted-apps/[name]/commits/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
27 changes: 27 additions & 0 deletions app/api/hosted-apps/[name]/logs/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
34 changes: 34 additions & 0 deletions app/api/hosted-apps/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 });
}
}
26 changes: 26 additions & 0 deletions app/api/hosted-apps/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
47 changes: 47 additions & 0 deletions app/api/made-apps/[appId]/bridge/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<string, unknown> } = {};
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 });
}
}
36 changes: 36 additions & 0 deletions app/api/made-apps/[appId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
33 changes: 33 additions & 0 deletions app/api/made-apps/[appId]/view/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
Loading
Loading