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
35 changes: 34 additions & 1 deletion .github/workflows/desktop-ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: Desktop CI

on:
push:
tags:
- "desktop-v*"
pull_request:
paths:
- "apps/desktop/**"
Expand Down Expand Up @@ -52,6 +55,7 @@ jobs:
run: |
npm run typecheck --workspace @blockrun/franklin-desktop
npm run lint --workspace @blockrun/franklin-desktop
npm test --workspace @blockrun/franklin-desktop

- name: Build unsigned test installer
run: npm run ${{ matrix.script }} --workspace @blockrun/franklin-desktop
Expand All @@ -61,7 +65,36 @@ jobs:
- name: Upload test installer
uses: actions/upload-artifact@v4
with:
name: franklin-desktop-${{ matrix.name }}-unsigned-test
name: franklin-desktop-${{ matrix.name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 7

release:
if: startsWith(github.ref, 'refs/tags/desktop-v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download installers
uses: actions/download-artifact@v4
with:
pattern: franklin-desktop-*
path: release
merge-multiple: true

- name: Generate checksums
run: sha256sum release/* > release/SHA256SUMS.txt

- name: Publish Desktop prerelease
uses: softprops/action-gh-release@v2
with:
name: Franklin Desktop ${{ github.ref_name }}
prerelease: true
generate_release_notes: true
body: |
Franklin Desktop beta for macOS Apple Silicon and Windows x64.

These beta installers are currently unsigned. On first launch, your operating system may ask you to confirm that you want to open the app.
files: release/*
104 changes: 52 additions & 52 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -1,83 +1,83 @@
# Franklin Desktop

Franklin Desktop is the native macOS and Windows workspace for the
[Franklin agent](https://github.com/BlockRunAI/Franklin). It packages the real
Franklin runtime inside an Electron shell; it is not a hosted web client or a
mock of the CLI.
Franklin Desktop is the native Electron interface for the Franklin agent. It
lives in the main Franklin repository so every Desktop release is built from a
reviewed Franklin runtime and the matching UI.

> **Status: Beta.** CI produces unsigned test installers for macOS Apple silicon
> and Windows x64. Signed public downloads and automatic updates are not live yet.
> Team Mode and Studio adapters for additional agent CLIs are being developed
> separately and are not included in this stable integration.
## Current beta

## What is included
- Franklin chat with streaming tool activity and inline permission requests
- local Base and Solana wallets with in-app network switching
- model catalog, skills, MCP, media generation, wallet activity, and market tools
- Agent Studio for discovering and importing supported local agent runtimes
- personal and Team conversation spaces
- Team projects with members, shared conversations, and versioned files
- local Team sidecar protected by per-launch tokens and strict loopback access

- Franklin chat with streaming tool activity and permission requests
- Persistent local conversations and search
- Model selection through the BlockRun router
- Local wallet, spend, media gallery, tools, skills, and CLI panels
- Collapsible navigation and light/dark themes
- The same Franklin agent loop and tool registry used by the CLI

## Workspace and security

Packaged builds use `Documents/Franklin` as the default workspace. Workspace
files are available to Franklin normally. A direct or symlinked path outside the
workspace is still usable, but the app asks for explicit approval first. Shell
commands also ask for approval in the current beta.

The Desktop shell creates a private credential for each local agent launch,
restricts local service origins and file URLs, and does not silently inherit
ambient API keys from the launching shell. Cloud session sync is disabled in
packaged Desktop builds unless the user explicitly enables it.
Private keys remain in the local Franklin process. The renderer receives only
the wallet address, balance, network, and the narrow operations exposed by the
Electron preload bridge.

## Development

Use Node.js 22 LTS (22.12 or newer is recommended), then run these commands from
the repository root:
Run commands from the Franklin repository root:

```bash
npm install
npm ci
npm run build
npm run desktop:real --workspace @blockrun/franklin-desktop
npm run desktop:real
```

For visual work that does not need a live agent, start the Electron app with the
mock backend:
For UI development with the mock agent backend:

```bash
npm run desktop:dev
```

## Check and package
The Vite renderer, Franklin agent, and Team sidecar use loopback-only services.
Packaged builds select ephemeral ports and pass unguessable credentials through
the isolated preload bridge.

## Validation

```bash
npm run typecheck --workspace @blockrun/franklin-desktop
npm run lint --workspace @blockrun/franklin-desktop
npm test --workspace @blockrun/franklin-desktop
npm run build --workspace @blockrun/franklin-desktop
```

The test suite covers Electron URL and IPC boundaries, hostile WebSocket
origins, the Team control plane, sandbox staging, SIWE authentication, and the
Team-to-Franklin agent proxy.

## Packaging

```bash
npm run desktop:build
npm run desktop:package:mac
npm run desktop:package:win
```

Installers are written to `apps/desktop/release/`. Local and CI packages are
unsigned test builds, so operating systems may show a developer verification
warning. The CI workflow uploads its installers for seven days.
`scripts/prepare-runtime.mjs` copies the main repository's built Franklin
runtime into the application before `electron-builder` creates the installer.
The release workflow builds macOS Apple Silicon and Windows x64 installers from
tags matching `desktop-v*`, then publishes them as a GitHub prerelease with
SHA-256 checksums.

## Repository structure
Beta installers are currently unsigned. Users may need to confirm the first
launch through their operating system's security prompt.

```text
apps/desktop/
├── electron/ Electron main process and preload bridge
├── franklin-agent/ Packaged launcher for the Franklin runtime
├── src/ React renderer, panels, hooks, and styles
├── build/ Desktop icons and packaging assets
└── release/ Local packaging output (not committed)
## Team service

src/serve/ Authenticated local Franklin service shared by Desktop
```
Electron starts `cloud-server/server.mjs` as a local sidecar. It accepts only an
explicit action allowlist and requires the per-process Desktop token. A private
remote Team endpoint can be configured with `FRANKLIN_TEAM_CLOUD_URL` or
`~/.blockrun/franklin-team-cloud-url`.

The renderer communicates with the local service over an authenticated WebSocket
protocol. Shared message types live in `apps/desktop/src/lib/wire.ts` and the
server implementation lives in `src/serve/`.
The included standalone control-plane and sandbox provider are development
testbeds. Production Team execution must use an isolated remote worker and
wallet broker rather than exposing a local runtime or Docker socket.

## License

Apache-2.0, the same license as Franklin.
Apache-2.0
20 changes: 20 additions & 0 deletions apps/desktop/cloud-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM node:22-alpine

WORKDIR /app
COPY server.mjs /app/server.mjs

ENV FRANKLIN_CLOUD_HOST=0.0.0.0 \
FRANKLIN_CLOUD_PORT=3740 \
FRANKLIN_CLOUD_DATA_DIR=/data \
FRANKLIN_CLOUD_SANDBOX_PROVIDER=directory

RUN mkdir -p /data && chown -R node:node /data /app
USER node

EXPOSE 3740
VOLUME ["/data"]

HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:3740/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"

CMD ["node", "/app/server.mjs"]
6 changes: 6 additions & 0 deletions apps/desktop/cloud-server/Dockerfile.sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM node:22-alpine

WORKDIR /app
COPY sandbox-worker.mjs /app/sandbox-worker.mjs

ENTRYPOINT ["node", "/app/sandbox-worker.mjs"]
20 changes: 20 additions & 0 deletions apps/desktop/cloud-server/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
services:
control-plane:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${FRANKLIN_CLOUD_BIND:-127.0.0.1}:3740:3740"
environment:
FRANKLIN_CLOUD_HOST: 0.0.0.0
FRANKLIN_CLOUD_PORT: 3740
FRANKLIN_CLOUD_DATA_DIR: /data
FRANKLIN_CLOUD_BOOTSTRAP_KEY: "${FRANKLIN_CLOUD_BOOTSTRAP_KEY:?Set FRANKLIN_CLOUD_BOOTSTRAP_KEY}"
FRANKLIN_CLOUD_ALLOWED_ORIGINS: "${FRANKLIN_CLOUD_ALLOWED_ORIGINS:-https://desktop.blockrun.ai}"
FRANKLIN_CLOUD_SANDBOX_PROVIDER: directory
volumes:
- franklin-cloud-data:/data

volumes:
franklin-cloud-data:
124 changes: 124 additions & 0 deletions apps/desktop/cloud-server/e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env node

import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const base = process.env.FRANKLIN_CLOUD_URL || "http://127.0.0.1:3740";
const expectArtifact = process.env.FRANKLIN_CLOUD_EXPECT_ARTIFACT !== "0";
const bootstrapKey = process.env.FRANKLIN_CLOUD_BOOTSTRAP_KEY || "";
const allowedOrigin = process.env.FRANKLIN_CLOUD_TEST_ORIGIN || "http://localhost:5174";

function assert(value, message) {
if (!value) throw new Error(`Assertion failed: ${message}`);
}

async function call(path, { token, method = "GET", body, expected } = {}) {
const response = await fetch(`${base}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(bootstrapKey ? { "X-Franklin-Bootstrap-Key": bootstrapKey } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const result = await response.json();
if (expected !== undefined) {
assert(response.status === expected, `${method} ${path}: expected ${expected}, got ${response.status}`);
return result;
}
if (!response.ok) throw new Error(`${method} ${path}: ${result.error || response.status}`);
return result;
}

const stamp = Date.now().toString(36);
const allowedCors = await fetch(`${base}/health`, { headers: { Origin: allowedOrigin } });
assert(allowedCors.ok && allowedCors.headers.get("access-control-allow-origin") === allowedOrigin, "configured browser origin should be allowed");
const deniedCors = await fetch(`${base}/health`, { headers: { Origin: "https://untrusted.example" } });
assert(deniedCors.status === 403, "unknown browser origin should be rejected");
const disabledDesktopProxy = await fetch(`${base}/v1/franklin-team`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "workspace.list" }),
});
assert(disabledDesktopProxy.status === 404, "desktop wallet proxy must be disabled without a per-process token");
if (bootstrapKey) {
const deniedBootstrap = await fetch(`${base}/v1/demo/devices`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Unprovisioned" }),
});
assert(deniedBootstrap.status === 401, "remote device provisioning should require the private-preview key");
}
const owner = await call("/v1/demo/devices", { method: "POST", body: { name: `Owner-${stamp}` } });
const guest = await call("/v1/demo/devices", { method: "POST", body: { name: `Member-${stamp}` } });
const sameNameDevice = await call("/v1/demo/devices", { method: "POST", body: { name: `Owner-${stamp}` } });
const created = await call("/v1/workspaces", { token: owner.token, method: "POST", body: { name: `E2E Workspace ${stamp}` } });
const workspaceId = created.workspace.id;
const sameNameWorkspaces = await call("/v1/workspaces", { token: sameNameDevice.token });
assert(sameNameWorkspaces.workspaces.length === 0, "display names must not be usable for identity impersonation");

const firstWrite = await call(`/v1/workspaces/${workspaceId}/files`, {
token: owner.token, method: "PUT", body: { path: "brief.md", content: "# Shared brief\n\nVisible to every member.\n" },
});
await call(`/v1/workspaces/${workspaceId}/files`, {
token: owner.token, method: "PUT", body: { path: "brief.md", content: "stale overwrite", expectedVersion: firstWrite.version - 1 }, expected: 409,
});
const dataDir = process.env.FRANKLIN_CLOUD_DATA_DIR;
if (dataDir) {
const link = path.join(dataDir, "workspaces", workspaceId, "shared", "escape-link");
await fsp.symlink(os.tmpdir(), link);
await call(`/v1/workspaces/${workspaceId}/files`, {
token: owner.token, method: "PUT", body: { path: "escape-link/franklin-cloud-escape-probe.txt", content: "must not escape" }, expected: 400,
});
await fsp.unlink(link);
}
const invite = await call(`/v1/workspaces/${workspaceId}/invites`, { token: owner.token, method: "POST", body: { role: "member" } });
await call("/v1/workspaces/join", { token: guest.token, method: "POST", body: { code: invite.invite.code } });

const joined = await call(`/v1/workspaces/${workspaceId}`, { token: guest.token });
assert(joined.workspace.members.length === 2, "owner and member should share the workspace");
assert(joined.workspace.role === "member", "joined user should receive member role");

await call(`/v1/workspaces/${workspaceId}/invites`, { token: guest.token, method: "POST", body: {}, expected: 403 });

const firstTurn = await call(`/v1/workspaces/${workspaceId}/messages`, {
token: guest.token, method: "POST", body: { content: "Read the shared brief and prepare a status artifact." },
});
assert(firstTurn.task.status === "completed", "member task should complete");
if (expectArtifact) assert(firstTurn.task.changes.length === 1, "sandbox should contain one staged artifact");

const beforeApply = await call(`/v1/workspaces/${workspaceId}/files`, { token: owner.token });
let afterApply = beforeApply;
if (expectArtifact) {
assert(!beforeApply.files.some((file) => file.path === firstTurn.task.changes[0].path), "sandbox artifact must not leak into shared files before approval");
await call(`/v1/workspaces/${workspaceId}/tasks/${firstTurn.task.id}/apply`, { token: owner.token, method: "POST", body: {} });
afterApply = await call(`/v1/workspaces/${workspaceId}/files`, { token: guest.token });
assert(afterApply.files.some((file) => file.path === firstTurn.task.changes[0].path), "approved artifact should become shared");
}

const secondTurn = await call(`/v1/workspaces/${workspaceId}/messages`, {
token: owner.token, method: "POST", body: { content: "Create a second independent workspace artifact." },
});
assert(secondTurn.task.id !== firstTurn.task.id, "every turn should have a distinct sandbox task id");
if (expectArtifact) {
const conflictingPath = secondTurn.task.changes[0].path;
await call(`/v1/workspaces/${workspaceId}/files`, {
token: guest.token, method: "PUT", body: { path: conflictingPath, content: "Member-authored content wins until a conflict is resolved.\n" },
});
const conflict = await call(`/v1/workspaces/${workspaceId}/tasks/${secondTurn.task.id}/apply`, {
token: owner.token, method: "POST", body: {}, expected: 409,
});
assert(conflict.error.includes(conflictingPath), "stale sandbox apply should report the conflicting shared file");
const preserved = await call(`/v1/workspaces/${workspaceId}/files?path=${encodeURIComponent(conflictingPath)}`, { token: owner.token });
assert(preserved.content.startsWith("Member-authored content"), "conflicting shared content must not be overwritten");
}
const messages = await call(`/v1/workspaces/${workspaceId}/messages`, { token: guest.token });
assert(messages.messages.length === 4, "both members should see both user and Franklin messages");

console.log(JSON.stringify({
ok: true,
workspaceId,
memberCount: joined.workspace.members.length,
messageCount: messages.messages.length,
sandboxTasks: [firstTurn.task.id, secondTurn.task.id],
stagedThenApplied: firstTurn.task.changes?.[0]?.path || null,
sharedVersion: afterApply.version,
}, null, 2));
27 changes: 27 additions & 0 deletions apps/desktop/cloud-server/sandbox-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";

const chunks = [];
let bytes = 0;
for await (const chunk of process.stdin) {
bytes += chunk.length;
if (bytes > 256 * 1024) throw new Error("Sandbox task input exceeds 256 KiB");
chunks.push(chunk);
}
const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
const taskId = String(input.taskId || "").replace(/[^a-zA-Z0-9_-]/g, "");
const memberName = String(input.memberName || "Member").slice(0, 80);
const prompt = String(input.prompt || "").slice(0, 20_000);
if (!taskId || !prompt) throw new Error("taskId and prompt are required");

const relative = `artifacts/${taskId}.md`;
const target = path.join("/workspace", relative);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, `# Franklin Cloud task\n\nMember: ${memberName}\n\nPrompt: ${prompt}\n`, { encoding: "utf8", mode: 0o600 });

process.stdout.write(JSON.stringify({
reply: `Cloud Franklin received the task from ${memberName}. I worked inside isolated container ${taskId} and prepared ${relative}.`,
artifact: relative,
}));
Loading
Loading