diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index 7c21b5e7..8cb2ca7a 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -1,6 +1,9 @@ name: Desktop CI on: + push: + tags: + - "desktop-v*" pull_request: paths: - "apps/desktop/**" @@ -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 @@ -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/* diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b68e4d74..6142f63b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/cloud-server/Dockerfile b/apps/desktop/cloud-server/Dockerfile new file mode 100644 index 00000000..2387b974 --- /dev/null +++ b/apps/desktop/cloud-server/Dockerfile @@ -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"] diff --git a/apps/desktop/cloud-server/Dockerfile.sandbox b/apps/desktop/cloud-server/Dockerfile.sandbox new file mode 100644 index 00000000..bf4a824f --- /dev/null +++ b/apps/desktop/cloud-server/Dockerfile.sandbox @@ -0,0 +1,6 @@ +FROM node:22-alpine + +WORKDIR /app +COPY sandbox-worker.mjs /app/sandbox-worker.mjs + +ENTRYPOINT ["node", "/app/sandbox-worker.mjs"] diff --git a/apps/desktop/cloud-server/compose.yml b/apps/desktop/cloud-server/compose.yml new file mode 100644 index 00000000..e9abf8d6 --- /dev/null +++ b/apps/desktop/cloud-server/compose.yml @@ -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: diff --git a/apps/desktop/cloud-server/e2e.mjs b/apps/desktop/cloud-server/e2e.mjs new file mode 100644 index 00000000..f29a30c3 --- /dev/null +++ b/apps/desktop/cloud-server/e2e.mjs @@ -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)); diff --git a/apps/desktop/cloud-server/sandbox-worker.mjs b/apps/desktop/cloud-server/sandbox-worker.mjs new file mode 100644 index 00000000..ee894a6d --- /dev/null +++ b/apps/desktop/cloud-server/sandbox-worker.mjs @@ -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, +})); diff --git a/apps/desktop/cloud-server/server.mjs b/apps/desktop/cloud-server/server.mjs new file mode 100644 index 00000000..27e7d8c7 --- /dev/null +++ b/apps/desktop/cloud-server/server.mjs @@ -0,0 +1,824 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const port = parsePort(process.env.FRANKLIN_CLOUD_PORT || 3740); +const host = process.env.FRANKLIN_CLOUD_HOST || "127.0.0.1"; +const dataRoot = path.resolve( + process.env.FRANKLIN_CLOUD_DATA_DIR || path.join(os.homedir(), ".blockrun", "franklin-cloud-demo"), +); +const statePath = path.join(dataRoot, "state.json"); +const runtimeEntry = process.env.FRANKLIN_RUNTIME_ENTRY || path.resolve(here, "..", "..", "..", "dist", "index.js"); +const runtimeModel = process.env.FRANKLIN_CLOUD_MODEL || "nvidia/nemotron-nano-9b-v2"; +const teamCloudConfigPath = path.join(os.homedir(), ".blockrun", "franklin-team-cloud-url"); +const configuredTeamCloud = (() => { + try { return fs.readFileSync(teamCloudConfigPath, "utf8").trim(); } + catch { return ""; } +})(); +const teamCloudUrl = parseTeamCloudUrl(process.env.FRANKLIN_TEAM_CLOUD_URL || configuredTeamCloud || process.env.FRANKLIN_CLOUD_URL || "https://franklin.run"); +const teamCloudBase = teamCloudUrl.href.replace(/\/$/, ""); +const desktopToken = process.env.FRANKLIN_CLOUD_TOKEN || ""; +const teamFakeAgent = process.env.FRANKLIN_TEAM_FAKE_AGENT === "1"; +// Safe by default: the packaged prototype uses the deterministic runtime and +// never touches a local wallet. A production worker must opt in explicitly +// after wiring an isolated identity + wallet broker. +const realAgent = process.env.FRANKLIN_CLOUD_ENABLE_REAL_AGENT === "1"; +const fakeAgent = !realAgent || process.env.FRANKLIN_CLOUD_FAKE_AGENT === "1"; +const sandboxProvider = process.env.FRANKLIN_CLOUD_SANDBOX_PROVIDER === "docker" ? "docker" : "directory"; +const sandboxImage = process.env.FRANKLIN_CLOUD_SANDBOX_IMAGE || "franklin-cloud-sandbox:local"; +const bootstrapKey = process.env.FRANKLIN_CLOUD_BOOTSTRAP_KEY || ""; +const allowedOrigins = new Set( + (process.env.FRANKLIN_CLOUD_ALLOWED_ORIGINS || "http://localhost:5173,http://localhost:5174") + .split(",").map((value) => value.trim()).filter(Boolean), +); + +if (!isLoopbackHost(host) && bootstrapKey.length < 32) { + throw new Error("A non-loopback cloud bind requires FRANKLIN_CLOUD_BOOTSTRAP_KEY with at least 32 characters"); +} + +function parsePort(value) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) throw new Error("FRANKLIN_CLOUD_PORT must be an integer from 0 to 65535"); + return parsed; +} + +function isLoopbackHost(value) { + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function parseTeamCloudUrl(value) { + let url; + try { url = new URL(String(value)); } + catch { throw new Error("Franklin Team Cloud URL is invalid"); } + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("Franklin Team Cloud URL must use HTTPS (HTTP is allowed only on loopback)"); + } + if (url.username || url.password || url.search || url.hash) throw new Error("Franklin Team Cloud URL must not include credentials, query, or fragment"); + return url; +} + +const now = () => new Date().toISOString(); +const id = (prefix) => `${prefix}_${crypto.randomBytes(10).toString("hex")}`; +const tokenHash = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const inviteCode = () => `FW-${crypto.randomBytes(12).toString("base64url").toUpperCase()}`; +const rateBuckets = new Map(); + +function rateLimit(req, scope, limit, windowMs) { + const remote = req.socket.remoteAddress || "unknown"; + const key = `${scope}:${remote}`; + const time = Date.now(); + let bucket = rateBuckets.get(key); + if (!bucket || time - bucket.startedAt >= windowMs) { + bucket = { startedAt: time, count: 0 }; + rateBuckets.set(key, bucket); + } + bucket.count += 1; + if (bucket.count > limit) throw new HttpError(429, "Too many requests; try again later"); + if (rateBuckets.size > 10_000) { + for (const [bucketKey, value] of rateBuckets) { + if (time - value.startedAt >= windowMs) rateBuckets.delete(bucketKey); + } + } +} + +function enforceCount(items, predicate, limit, message) { + if (items.filter(predicate).length >= limit) throw new HttpError(507, message); +} + +function initialState() { + return { version: 1, users: [], devices: [], workspaces: [], memberships: [], invites: [], messages: [], tasks: [] }; +} + +await fsp.mkdir(dataRoot, { recursive: true, mode: 0o700 }); +await fsp.chmod(dataRoot, 0o700); +let state = initialState(); +try { + const parsed = JSON.parse(await fsp.readFile(statePath, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Cloud state root must be an object"); + state = { ...initialState(), ...parsed }; + for (const key of ["users", "devices", "workspaces", "memberships", "invites", "messages", "tasks"]) { + if (!Array.isArray(state[key])) throw new Error(`Cloud state ${key} must be an array`); + } + await fsp.chmod(statePath, 0o600); +} catch (error) { + if (error?.code !== "ENOENT") throw new Error(`Refusing to discard invalid Franklin Cloud state: ${error.message || error}`); +} + +let writeQueue = Promise.resolve(); +function saveState() { + const write = async () => { + const tmp = `${statePath}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await fsp.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600, flag: "wx" }); + await fsp.rename(tmp, statePath); + await fsp.chmod(statePath, 0o600); + } catch (error) { + await fsp.rm(tmp, { force: true }).catch(() => {}); + throw error; + } + }; + writeQueue = writeQueue.then(write, write); + return writeQueue; +} + +function workspaceRoot(workspaceId) { + return path.join(dataRoot, "workspaces", workspaceId); +} +function sharedRoot(workspaceId) { + return path.join(workspaceRoot(workspaceId), "shared"); +} +function sandboxRoot(workspaceId, taskId) { + return path.join(workspaceRoot(workspaceId), "sandboxes", taskId, "work"); +} +function cleanRelative(value) { + const normalized = path.posix.normalize(String(value || "").replaceAll("\\", "/")).replace(/^\/+/, ""); + if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) { + throw new HttpError(400, "A safe relative file path is required"); + } + return normalized; +} +function within(root, relative) { + const resolved = path.resolve(root, relative); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new HttpError(400, "Path escapes workspace"); + return resolved; +} + +async function safeWritablePath(root, relative) { + const absolute = within(root, relative); + await fsp.mkdir(path.dirname(absolute), { recursive: true, mode: 0o700 }); + const [realRoot, realParent] = await Promise.all([fsp.realpath(root), fsp.realpath(path.dirname(absolute))]); + if (realParent !== realRoot && !realParent.startsWith(`${realRoot}${path.sep}`)) { + throw new HttpError(400, "Path traverses a symbolic link outside the workspace"); + } + const existing = await fsp.lstat(absolute).catch(() => null); + if (existing?.isSymbolicLink()) throw new HttpError(400, "Symbolic-link files are not supported"); + return absolute; +} + +async function safeReadableFile(root, relative) { + const absolute = within(root, relative); + const [realRoot, realFile] = await Promise.all([fsp.realpath(root), fsp.realpath(absolute).catch(() => null)]); + if (!realFile || (realFile !== realRoot && !realFile.startsWith(`${realRoot}${path.sep}`))) { + throw new HttpError(realFile ? 400 : 404, realFile ? "Path escapes workspace through a symbolic link" : "File not found"); + } + const stat = await fsp.stat(realFile); + if (!stat.isFile()) throw new HttpError(404, "File not found"); + return { absolute: realFile, stat }; +} + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +function auth(req) { + const raw = req.headers.authorization || ""; + const token = raw.startsWith("Bearer ") ? raw.slice(7) : ""; + const device = state.devices.find((item) => item.tokenHash === tokenHash(token) && !item.revokedAt); + if (!device) throw new HttpError(401, "Invalid or expired device session"); + const user = state.users.find((item) => item.id === device.userId); + if (!user) throw new HttpError(401, "Device user not found"); + return { user, device }; +} +function workspaceById(workspaceId) { + const workspace = state.workspaces.find((item) => item.id === workspaceId); + if (!workspace) throw new HttpError(404, "Workspace not found"); + return workspace; +} +function membership(workspaceId, userId) { + const member = state.memberships.find((item) => item.workspaceId === workspaceId && item.userId === userId && !item.revokedAt); + if (!member) throw new HttpError(403, "You are not a member of this workspace"); + return member; +} +function publicWorkspace(workspace, userId) { + const members = state.memberships + .filter((item) => item.workspaceId === workspace.id && !item.revokedAt) + .map((item) => ({ + userId: item.userId, + name: state.users.find((user) => user.id === item.userId)?.name || "Unknown", + role: item.role, + joinedAt: item.joinedAt, + })); + return { ...workspace, role: membership(workspace.id, userId).role, members }; +} + +async function body(req) { + const chunks = []; + let bytes = 0; + for await (const chunk of req) { + bytes += chunk.length; + if (bytes > 2_000_000) throw new HttpError(413, "Request body is too large"); + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { throw new HttpError(400, "Invalid JSON body"); } +} + +function json(res, status, value) { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Franklin-Bootstrap-Key, X-Franklin-Desktop-Token", + "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Cross-Origin-Resource-Policy": "same-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Referrer-Policy": "no-referrer", + }); + res.end(JSON.stringify(value)); +} + +function secureEqual(actual, expected) { + const left = Buffer.from(String(actual || "")); + const right = Buffer.from(String(expected || "")); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function desktopAuth(req) { + // Browser-only development can run without a token. Electron always injects + // one into both preload and this process, making wallet operations private to + // this Franklin window instead of merely trusting the loopback interface. + if (!desktopToken) throw new HttpError(404, "Route not found"); + if (!secureEqual(req.headers["x-franklin-desktop-token"], desktopToken)) { + throw new HttpError(401, "Invalid Franklin Desktop session"); + } +} + +function originAllowed(req, origin) { + if (allowedOrigins.has(origin)) return true; + if (origin !== "null" || !desktopToken) return false; + if (req.method === "OPTIONS") { + return String(req.headers["access-control-request-headers"] || "").toLowerCase().split(",").map((value) => value.trim()).includes("x-franklin-desktop-token"); + } + return secureEqual(req.headers["x-franklin-desktop-token"], desktopToken); +} + +const NONCE_COOKIE = "franklin_try_nonce"; +const SESSION_COOKIE = "franklin_try_session"; +const TEAM_TIMEOUT = 20_000; +let teamSessionCookie = null; +let runtimeModulesPromise = null; +let signingModulesPromise = null; +const runningTeamWorkspaces = new Set(); +const TEAM_ACTIONS = new Set([ + "workspace.list", "workspace.create", "workspace.get", "workspace.snapshot", "workspace.invite", "workspace.join", + "member.role", "message.list", "message.append", "file.list", "file.read", "file.save", +]); + +function setCookie(res, name) { + const values = res.headers.getSetCookie?.() || []; + const value = values.find((item) => item.startsWith(`${name}=`)); + if (value) return value.split(";")[0]; + const fallback = res.headers.get("set-cookie"); + if (!fallback) return null; + const match = fallback.match(new RegExp(`(?:^|,\\s*)(${name}=[^;]+)`)); + return match?.[1] || null; +} + +async function responseJson(response, maxBytes) { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) throw new HttpError(502, "Franklin Cloud response is too large"); + if (!response.body) return {}; + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new HttpError(502, "Franklin Cloud response is too large"); + } + chunks.push(Buffer.from(value)); + } + try { return JSON.parse(Buffer.concat(chunks, total).toString("utf8") || "{}"); } + catch { throw new HttpError(502, "Franklin Cloud returned invalid JSON"); } +} + +function teamPayload(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new HttpError(400, "Team request must be an object"); + const action = String(input.action || ""); + if (!TEAM_ACTIONS.has(action)) throw new HttpError(400, "Unknown Franklin Team action"); + return { ...input, action }; +} + +async function runtimeModules() { + if (!runtimeModulesPromise) runtimeModulesPromise = (async () => { + if (!runtimeEntry || !fs.existsSync(runtimeEntry)) throw new Error("Franklin runtime is not installed"); + const distRoot = path.dirname(runtimeEntry); + const [llm, config] = await Promise.all([ + import(pathToFileURL(path.join(distRoot, "agent", "llm.js")).href), + import(pathToFileURL(path.join(distRoot, "config.js")).href), + ]); + return { llm, config }; + })(); + return runtimeModulesPromise; +} + +async function signingModules() { + if (!signingModulesPromise) { + const packageRoot = path.dirname(path.dirname(runtimeEntry)); + const modulesRoot = path.join(packageRoot, "node_modules", "@noble"); + signingModulesPromise = Promise.all([ + import(pathToFileURL(path.join(modulesRoot, "curves", "secp256k1.js")).href), + import(pathToFileURL(path.join(modulesRoot, "hashes", "sha3.js")).href), + ]).then(([curves, hashes]) => ({ secp256k1: curves.secp256k1, keccak256: hashes.keccak_256 })); + } + return signingModulesPromise; +} + +async function localWallet() { + const { secp256k1, keccak256 } = await signingModules(); + const walletDir = path.join(os.homedir(), ".blockrun"); + const walletFile = path.join(walletDir, ".session"); + const legacyFile = path.join(walletDir, "wallet.key"); + let privateKey = process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY || ""; + if (!privateKey) privateKey = await fsp.readFile(walletFile, "utf8").then((value) => value.trim()).catch(() => ""); + if (!privateKey) privateKey = await fsp.readFile(legacyFile, "utf8").then((value) => value.trim()).catch(() => ""); + if (!privateKey) throw new Error("No local Franklin wallet is configured; create one in Franklin before connecting Team Cloud"); + const privateBytes = Buffer.from(privateKey.replace(/^0x/, ""), "hex"); + if (privateBytes.length !== 32) throw new Error("Franklin wallet key is invalid"); + const publicKey = secp256k1.getPublicKey(privateBytes, false); + const address = `0x${Buffer.from(keccak256(publicKey.slice(1))).subarray(-20).toString("hex")}`; + const signMessage = (message) => { + const messageBytes = Buffer.from(message, "utf8"); + const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${messageBytes.length}`, "utf8"); + const digest = keccak256(Buffer.concat([prefix, messageBytes])); + // Noble v1 returns a Signature object; v2 returns bytes and requires the + // recovered format to include the recovery id. Support both because the + // Desktop app can reuse a Franklin runtime installed by an older release. + const signed = secp256k1.sign(digest, privateBytes, { format: "recovered", prehash: false }); + const byteResult = signed instanceof Uint8Array; + const recoveryId = byteResult ? (signed.length === 65 ? signed[0] : 0) : (signed.recovery ?? 0); + const compact = byteResult + ? Buffer.from(signed.length === 65 ? signed.slice(1) : signed).toString("hex") + : signed.toCompactHex(); + return `0x${compact}${(recoveryId + 27).toString(16).padStart(2, "0")}`; + }; + return { address, signMessage }; +} + +async function teamLogin() { + const nonceRes = await fetch(`${teamCloudBase}/api/try/auth/nonce`, { redirect: "manual", signal: AbortSignal.timeout(TEAM_TIMEOUT) }); + if (!nonceRes.ok) throw new HttpError(502, `Franklin Cloud nonce failed (${nonceRes.status})`); + const nonceCookie = setCookie(nonceRes, NONCE_COOKIE); + const { nonce } = await responseJson(nonceRes, 64 * 1024); + if (!/^[a-f0-9]{32}$/i.test(String(nonce || "")) || !nonceCookie) throw new HttpError(502, "Franklin Cloud did not return a valid nonce"); + + const { address, signMessage } = await localWallet(); + const message = + `${teamCloudUrl.hostname} wants you to sign in with your Ethereum account:\n${address}\n\n` + + `Sign in to Franklin Desktop Team Workspace.\n\n` + + `URI: ${teamCloudUrl.origin}\nVersion: 1\nChain ID: 8453\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`; + const signature = signMessage(message); + const verifyRes = await fetch(`${teamCloudBase}/api/try/auth/verify`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: nonceCookie }, + body: JSON.stringify({ address, message, signature }), + redirect: "manual", + signal: AbortSignal.timeout(TEAM_TIMEOUT), + }); + if (!verifyRes.ok) throw new HttpError(502, `Franklin Cloud wallet verification failed (${verifyRes.status})`); + const verified = await responseJson(verifyRes, 64 * 1024); + if (!secureEqual(String(verified.address || "").toLowerCase(), address.toLowerCase())) throw new HttpError(502, "Franklin Cloud verified an unexpected wallet"); + teamSessionCookie = setCookie(verifyRes, SESSION_COOKIE); + if (!teamSessionCookie) throw new HttpError(502, "Franklin Cloud did not return a session"); +} + +async function teamFetch(payload) { + payload = teamPayload(payload); + if (!teamSessionCookie) await teamLogin(); + const request = () => fetch(`${teamCloudBase}/api/try/team`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: teamSessionCookie }, + body: JSON.stringify(payload), + redirect: "manual", + signal: AbortSignal.timeout(TEAM_TIMEOUT), + }); + let response = await request(); + if (response.status === 401) { + teamSessionCookie = null; + await teamLogin(); + response = await request(); + } + const result = await responseJson(response, 4 * 1024 * 1024); + if (!response.ok) { + const message = response.status === 404 + ? "Franklin Cloud Team API is not deployed on this server yet" + : (result.error || `Franklin Cloud request failed (${response.status})`); + throw new HttpError(response.status, message); + } + return result; +} + +async function runTeamAgentTurn(input) { + const workspaceId = String(input.workspaceId || ""); + const content = String(input.content || "").trim().slice(0, 20_000); + if (!workspaceId || !content) throw new HttpError(400, "Workspace and message are required"); + if (runningTeamWorkspaces.has(workspaceId)) throw new HttpError(409, "Team Franklin is already working in this workspace"); + runningTeamWorkspaces.add(workspaceId); + try { + const snapshot = await teamFetch({ action: "workspace.snapshot", workspaceId }); + const userResult = await teamFetch({ action: "message.append", workspaceId, role: "user", content }); + let reply; + if (teamFakeAgent) { + reply = `Team Franklin received your message in ${snapshot.workspace.name}. This test turn used ${snapshot.files.length} shared file(s) and ${snapshot.messages.length} earlier message(s).`; + } else { + const { llm, config } = await runtimeModules(); + const chain = config.loadChain(); + const client = new llm.ModelClient({ apiUrl: config.API_URLS[chain], chain }); + const sharedFiles = snapshot.files.slice(0, 30).map((file) => `--- ${file.path} ---\n${String(file.content || "").slice(0, 12_000)}`).join("\n\n"); + const recent = snapshot.messages.slice(-20).map((message) => ({ + role: message.role === "assistant" ? "assistant" : "user", + content: `${message.authorName}: ${message.content}`, + })); + const response = await client.complete({ + model: runtimeModel, + system: [ + `You are Team Franklin for the shared workspace \"${snapshot.workspace.name}\".`, + "Answer for the whole team. Use the shared files as context and clearly state when information is missing.", + sharedFiles ? `Shared workspace files:\n${sharedFiles}` : "There are no shared files yet.", + ].join("\n\n"), + messages: [...recent, { role: "user", content }], + max_tokens: 4096, + stream: false, + }); + reply = response.content.filter((part) => part.type === "text").map((part) => part.text).join("").trim(); + if (!reply) throw new Error("Team Franklin returned no text response"); + } + const assistantResult = await teamFetch({ action: "message.append", workspaceId, role: "assistant", content: reply }); + return { userMessage: userResult.message, assistant: assistantResult.message, workspaceVersion: assistantResult.version }; + } finally { + runningTeamWorkspaces.delete(workspaceId); + } +} + +async function listFiles(root) { + const files = []; + let totalBytes = 0; + async function walk(dir, prefix = "") { + let entries = []; + try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name === ".git") continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(absolute, rel); + if (entry.isFile()) { + if (files.length >= 2_000) throw new HttpError(413, "Workspace contains too many files"); + const stat = await fsp.stat(absolute); + if (stat.size > 10 * 1024 * 1024) throw new HttpError(413, `Workspace file is too large: ${rel}`); + totalBytes += stat.size; + if (totalBytes > 100 * 1024 * 1024) throw new HttpError(413, "Workspace exceeds 100 MiB"); + const data = await fsp.readFile(absolute); + files.push({ path: rel, bytes: data.length, sha256: crypto.createHash("sha256").update(data).digest("hex") }); + } + } + } + await walk(root); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +async function snapshotWorkspace(workspaceId, taskId) { + const from = sharedRoot(workspaceId); + const to = sandboxRoot(workspaceId, taskId); + await listFiles(from); + await fsp.mkdir(path.dirname(to), { recursive: true }); + await fsp.cp(from, to, { recursive: true, force: false, errorOnExist: false }); + return to; +} + +function stripAnsi(value) { + return value.replace(/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, "").trim(); +} + +async function runProcess(command, args, options) { + return new Promise((resolve, reject) => { + const { input, ...spawnOptions } = options; + const child = spawn(command, args, spawnOptions); + let stdout = ""; + let stderr = ""; + let settled = false; + const maxOutput = 2 * 1024 * 1024; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(result); + }; + const remember = (kind, chunk) => { + const text = chunk.toString(); + if (kind === "stdout") stdout += text; + else stderr += text; + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxOutput) { + child.kill("SIGKILL"); + finish(new Error("Cloud Franklin output exceeded 2 MiB")); + } + }; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 2_000).unref(); + finish(new Error("Cloud Franklin task exceeded 120 seconds")); + }, 120_000); + child.stdout.on("data", (chunk) => remember("stdout", chunk)); + child.stderr.on("data", (chunk) => remember("stderr", chunk)); + child.on("error", (error) => finish(error)); + child.on("exit", (code) => { + if (code === 0) finish(null, { stdout, stderr }); + else finish(new Error(stripAnsi(stderr || stdout).slice(0, 4_000) || `Franklin exited with code ${code}`)); + }); + if (input !== undefined && child.stdin) child.stdin.end(input); + }); +} + +async function runDockerSandbox({ root, task, user, content }) { + if (realAgent) throw new Error("Real Franklin requires a dedicated remote worker and wallet broker; it cannot run in the preview container"); + const mountRoot = await fsp.realpath(root); + const uid = typeof process.getuid === "function" ? process.getuid() : 1000; + const gid = typeof process.getgid === "function" ? process.getgid() : 1000; + const result = await runProcess("docker", [ + "run", "--rm", "-i", "--network", "none", "--read-only", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--memory", "512m", "--cpus", "1", "--pids-limit", "128", + "--user", `${uid}:${gid}`, + "--mount", `type=bind,src=${mountRoot},dst=/workspace`, + "--workdir", "/workspace", sandboxImage, + ], { + cwd: root, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + stdio: ["pipe", "pipe", "pipe"], + input: JSON.stringify({ taskId: task.id, memberName: user.name, prompt: content }), + }); + const parsed = JSON.parse(result.stdout || "{}"); + if (!parsed.reply) throw new Error("Sandbox worker returned no reply"); + return parsed.reply; +} + +async function runAgentTurn({ workspace, user, content, task }) { + const root = sandboxRoot(workspace.id, task.id); + if (sandboxProvider === "docker") return runDockerSandbox({ root, task, user, content }); + if (fakeAgent) { + const proofName = `artifacts/${task.id}.md`; + const proofPath = within(root, proofName); + await fsp.mkdir(path.dirname(proofPath), { recursive: true }); + await fsp.writeFile(proofPath, `# Franklin Cloud task\n\nMember: ${user.name}\n\nPrompt: ${content}\n`, { encoding: "utf8", mode: 0o600 }); + return `Cloud Franklin received the task from ${user.name}. I worked inside isolated sandbox ${task.id} and prepared ${proofName}.`; + } + throw new Error("In-process real-agent execution is disabled; use a dedicated remote worker with an isolated identity and wallet broker"); +} + +async function taskChanges(workspaceId, taskId, baseFiles = []) { + const before = new Map(baseFiles.map((file) => [file.path, file])); + const after = await listFiles(sandboxRoot(workspaceId, taskId)); + return after.filter((file) => before.get(file.path)?.sha256 !== file.sha256); +} + +async function handle(req, res) { + const origin = String(req.headers.origin || ""); + if (origin) { + if (!originAllowed(req, origin)) throw new HttpError(403, "Origin is not allowed"); + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + if (req.method === "OPTIONS") return json(res, 204, {}); + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); + const parts = url.pathname.split("/").filter(Boolean); + + if (req.method === "GET" && url.pathname === "/health") { + return json(res, 200, { + ok: true, service: "franklin-cloud-workspace", mode: fakeAgent ? "test" : "franklin", + sandboxProvider, authMode: bootstrapKey ? "private-preview" : "demo-device", + desktopProtected: Boolean(desktopToken), + }); + } + + // Product Team Mode: Desktop owns wallet access and proxies only this + // explicit command surface to the existing SIWE-authenticated Franklin Cloud. + // The legacy demo routes below remain for the standalone cloud-server tests. + if (req.method === "POST" && url.pathname === "/v1/franklin-team") { + rateLimit(req, "team-proxy", 240, 60_000); + desktopAuth(req); + return json(res, 200, await teamFetch(await body(req))); + } + if (req.method === "POST" && url.pathname === "/v1/franklin-team/agent-turn") { + rateLimit(req, "team-turn", 20, 60_000); + desktopAuth(req); + return json(res, 201, await runTeamAgentTurn(await body(req))); + } + + // Development device bootstrap. The token is random and only its hash is persisted. + // Production replaces this route with browser OAuth + PKCE/DPoP. + if (req.method === "POST" && url.pathname === "/v1/demo/devices") { + rateLimit(req, "device-bootstrap", 20, 60 * 60_000); + if (bootstrapKey && !secureEqual(req.headers["x-franklin-bootstrap-key"], bootstrapKey)) { + throw new HttpError(401, "A valid private-preview access key is required"); + } + const input = await body(req); + const name = String(input.name || "").trim().slice(0, 80); + if (!name) throw new HttpError(400, "Member name is required"); + enforceCount(state.devices, () => true, 10_000, "This preview has reached its device limit"); + // A display name is not an identity proof. Always create a distinct demo + // principal so entering somebody else's name cannot impersonate them. + const user = { id: id("usr"), name, createdAt: now() }; + state.users.push(user); + const token = crypto.randomBytes(32).toString("base64url"); + const device = { id: id("dev"), userId: user.id, name: String(input.deviceName || "Franklin Desktop").slice(0, 120), tokenHash: tokenHash(token), createdAt: now() }; + state.devices.push(device); + await saveState(); + return json(res, 201, { user, device: { ...device, tokenHash: undefined }, token, authMode: "demo-device" }); + } + + const { user, device } = auth(req); + + if (req.method === "GET" && url.pathname === "/v1/me") { + return json(res, 200, { user, device: { id: device.id, name: device.name, createdAt: device.createdAt } }); + } + if (req.method === "GET" && url.pathname === "/v1/workspaces") { + const memberships = state.memberships.filter((item) => item.userId === user.id && !item.revokedAt); + return json(res, 200, { workspaces: memberships.map((item) => publicWorkspace(workspaceById(item.workspaceId), user.id)) }); + } + if (req.method === "POST" && url.pathname === "/v1/workspaces") { + const input = await body(req); + const name = String(input.name || "").trim().slice(0, 100); + if (!name) throw new HttpError(400, "Workspace name is required"); + enforceCount(state.memberships, (item) => item.userId === user.id && !item.revokedAt, 100, "Workspace limit reached"); + const workspace = { id: id("ws"), name, createdBy: user.id, createdAt: now(), version: 1, runtime: "isolated-directory" }; + state.workspaces.push(workspace); + state.memberships.push({ workspaceId: workspace.id, userId: user.id, role: "owner", joinedAt: now() }); + await fsp.mkdir(sharedRoot(workspace.id), { recursive: true, mode: 0o700 }); + await fsp.writeFile(path.join(sharedRoot(workspace.id), "README.md"), `# ${name}\n\nShared Franklin Cloud workspace.\n`, { encoding: "utf8", mode: 0o600 }); + await saveState(); + return json(res, 201, { workspace: publicWorkspace(workspace, user.id) }); + } + if (req.method === "POST" && url.pathname === "/v1/workspaces/join") { + rateLimit(req, `workspace-join:${device.id}`, 60, 60 * 60_000); + const input = await body(req); + const code = String(input.code || "").trim().toUpperCase(); + const invite = state.invites.find((item) => item.code === code && !item.usedBy && Date.parse(item.expiresAt) > Date.now()); + if (!invite) throw new HttpError(404, "Invite code is invalid, used, or expired"); + if (!state.memberships.some((item) => item.workspaceId === invite.workspaceId && item.userId === user.id && !item.revokedAt)) { + state.memberships.push({ workspaceId: invite.workspaceId, userId: user.id, role: invite.role, joinedAt: now() }); + } + invite.usedBy = user.id; + invite.usedAt = now(); + await saveState(); + return json(res, 200, { workspace: publicWorkspace(workspaceById(invite.workspaceId), user.id) }); + } + + if (parts[0] !== "v1" || parts[1] !== "workspaces" || !parts[2]) throw new HttpError(404, "Route not found"); + const workspaceId = parts[2]; + const workspace = workspaceById(workspaceId); + const member = membership(workspaceId, user.id); + + if (req.method === "GET" && parts.length === 3) { + return json(res, 200, { workspace: publicWorkspace(workspace, user.id) }); + } + if (req.method === "POST" && parts[3] === "invites") { + if (member.role !== "owner" && member.role !== "admin") throw new HttpError(403, "Only owners and admins can invite members"); + rateLimit(req, `workspace-invite:${device.id}`, 30, 60 * 60_000); + enforceCount(state.invites, (item) => item.workspaceId === workspaceId && !item.usedBy && Date.parse(item.expiresAt) > Date.now(), 100, "Too many active invites"); + const input = await body(req); + const invite = { + id: id("inv"), workspaceId, code: inviteCode(), role: input.role === "viewer" ? "viewer" : "member", + createdBy: user.id, createdAt: now(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }; + state.invites.push(invite); + await saveState(); + return json(res, 201, { invite }); + } + if (req.method === "GET" && parts[3] === "messages") { + return json(res, 200, { messages: state.messages.filter((item) => item.workspaceId === workspaceId) }); + } + if (req.method === "POST" && parts[3] === "messages") { + if (member.role === "viewer") throw new HttpError(403, "Viewers cannot send messages"); + rateLimit(req, `workspace-message:${device.id}`, 30, 60_000); + enforceCount(state.messages, (item) => item.workspaceId === workspaceId, 500, "Workspace message limit reached; archive this preview before continuing"); + enforceCount(state.tasks, (item) => item.workspaceId === workspaceId, 100, "Workspace task limit reached; archive this preview before continuing"); + const input = await body(req); + const content = String(input.content || "").trim().slice(0, 20_000); + if (!content) throw new HttpError(400, "Message is required"); + const userMessage = { id: id("msg"), workspaceId, role: "user", authorId: user.id, authorName: user.name, content, createdAt: now() }; + state.messages.push(userMessage); + const task = { id: id("task"), workspaceId, createdBy: user.id, prompt: content, status: "running", createdAt: now(), baseVersion: workspace.version }; + state.tasks.push(task); + await snapshotWorkspace(workspaceId, task.id); + task.baseFiles = await listFiles(sandboxRoot(workspaceId, task.id)); + await saveState(); + try { + const reply = await runAgentTurn({ workspace, user, content, task }); + task.status = "completed"; + task.completedAt = now(); + task.changes = await taskChanges(workspaceId, task.id, task.baseFiles); + const assistant = { id: id("msg"), workspaceId, role: "assistant", authorId: "franklin-cloud", authorName: "Franklin Cloud", content: reply, taskId: task.id, createdAt: now() }; + state.messages.push(assistant); + await saveState(); + return json(res, 201, { userMessage, assistant, task }); + } catch (error) { + task.status = "failed"; + task.completedAt = now(); + task.error = error instanceof Error ? error.message : String(error); + await saveState(); + throw new HttpError(502, `Cloud Franklin failed: ${task.error}`); + } + } + if (req.method === "GET" && parts[3] === "files") { + const requested = url.searchParams.get("path"); + if (!requested) return json(res, 200, { files: await listFiles(sharedRoot(workspaceId)), version: workspace.version }); + const rel = cleanRelative(requested); + const { absolute, stat } = await safeReadableFile(sharedRoot(workspaceId), rel); + if (stat.size > 500_000) throw new HttpError(413, "File is too large to preview"); + return json(res, 200, { path: rel, content: await fsp.readFile(absolute, "utf8"), bytes: stat.size, version: workspace.version }); + } + if (req.method === "PUT" && parts[3] === "files") { + if (member.role === "viewer") throw new HttpError(403, "Viewers cannot edit files"); + rateLimit(req, `workspace-file:${device.id}`, 120, 60_000); + const input = await body(req); + if (input.expectedVersion !== undefined && (!Number.isInteger(input.expectedVersion) || input.expectedVersion !== workspace.version)) { + throw new HttpError(409, "Workspace changed since this file was opened; refresh before saving"); + } + const rel = cleanRelative(input.path); + const content = String(input.content ?? ""); + if (Buffer.byteLength(content) > 500_000) throw new HttpError(413, "File is too large"); + const absolute = await safeWritablePath(sharedRoot(workspaceId), rel); + await fsp.writeFile(absolute, content, { encoding: "utf8", mode: 0o600 }); + await fsp.chmod(absolute, 0o600); + workspace.version += 1; + await saveState(); + return json(res, 200, { ok: true, path: rel, version: workspace.version }); + } + if (req.method === "GET" && parts[3] === "tasks") { + return json(res, 200, { tasks: state.tasks.filter((item) => item.workspaceId === workspaceId).sort((a, b) => b.createdAt.localeCompare(a.createdAt)) }); + } + if (req.method === "POST" && parts[3] === "tasks" && parts[5] === "apply") { + if (member.role !== "owner" && member.role !== "admin") throw new HttpError(403, "Only owners and admins can apply task changes"); + const task = state.tasks.find((item) => item.id === parts[4] && item.workspaceId === workspaceId); + if (!task || task.status !== "completed") throw new HttpError(404, "Completed task not found"); + if (task.appliedAt) throw new HttpError(409, "Task changes were already applied"); + const baseFiles = new Map((task.baseFiles || []).map((file) => [file.path, file])); + const currentFiles = new Map((await listFiles(sharedRoot(workspaceId))).map((file) => [file.path, file])); + const conflicts = (task.changes || []) + .filter((file) => (baseFiles.get(file.path)?.sha256 || null) !== (currentFiles.get(file.path)?.sha256 || null)) + .map((file) => file.path); + if (conflicts.length) { + throw new HttpError(409, `Shared files changed after sandbox snapshot: ${conflicts.join(", ")}`); + } + for (const file of task.changes || []) { + const rel = cleanRelative(file.path); + const { absolute: from } = await safeReadableFile(sandboxRoot(workspaceId, task.id), rel); + const to = await safeWritablePath(sharedRoot(workspaceId), rel); + await fsp.copyFile(from, to); + await fsp.chmod(to, 0o600); + } + task.appliedAt = now(); + task.appliedBy = user.id; + workspace.version += 1; + await saveState(); + return json(res, 200, { ok: true, version: workspace.version, changes: task.changes || [] }); + } + throw new HttpError(404, "Route not found"); +} + +const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + // Keep the HTTP parser aligned for a reused loopback connection when an + // auth/origin check rejects a POST before body() has consumed its payload. + req.resume(); + const status = error instanceof HttpError ? error.status : 500; + if (status === 500) console.error("[franklin-cloud] request failed", error); + json(res, status, { error: error instanceof HttpError ? error.message : "Internal server error" }); + }); +}); + +server.requestTimeout = 30_000; +server.headersTimeout = 15_000; +server.keepAliveTimeout = 5_000; + +server.listen(port, host, () => { + const address = server.address(); + const readyPort = address && typeof address !== "string" ? address.port : port; + console.log(`[franklin-cloud] http://${host}:${readyPort} data=${dataRoot} runtime=${fakeAgent ? "test" : runtimeEntry} sandbox=${sandboxProvider}`); + if (typeof process.send === "function") process.send({ type: "franklin:cloud-ready", port: readyPort }); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, async () => { + server.close(); + await writeQueue.catch(() => {}); + process.exit(0); + }); +} diff --git a/apps/desktop/cloud-server/team-proxy.test.mjs b/apps/desktop/cloud-server/team-proxy.test.mjs new file mode 100644 index 00000000..273c9bf0 --- /dev/null +++ b/apps/desktop/cloud-server/team-proxy.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const token = "desktop-team-test-token"; +const workspace = { + id: "tw_test", name: "Proxy Test", createdAt: new Date().toISOString(), version: 1, + runtime: "member-franklin", role: "owner", + members: [{ userId: "0x1111111111111111111111111111111111111111", name: "0x1111…1111", role: "owner", joinedAt: new Date().toISOString() }], +}; +const messages = []; +const files = [{ path: "README.md", content: "# Proxy Test", bytes: 12, version: 1, updatedAt: new Date().toISOString(), updatedBy: workspace.members[0].userId }]; +let verifiedAuth = null; + +const json = (res, status, value, headers = {}) => { + res.writeHead(status, { "Content-Type": "application/json", ...headers }); + res.end(JSON.stringify(value)); +}; +const readBody = async (req) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); +}; +const mockCloud = http.createServer(async (req, res) => { + if (req.url === "/api/try/auth/nonce") return json(res, 200, { nonce: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { "Set-Cookie": "franklin_try_nonce=test; Path=/; HttpOnly" }); + if (req.url === "/api/try/auth/verify") { + const input = await readBody(req); + verifiedAuth = input; + return json(res, 200, { address: input.address }, { "Set-Cookie": "franklin_try_session=test-session; Path=/; HttpOnly" }); + } + if (req.url !== "/api/try/team" || !String(req.headers.cookie || "").includes("franklin_try_session=test-session")) return json(res, 401, { error: "Not signed in" }); + const input = await readBody(req); + if (input.action === "workspace.list") return json(res, 200, { workspaces: [workspace], wallet: workspace.members[0].userId }); + if (input.action === "workspace.snapshot") return json(res, 200, { workspace, messages, files }); + if (input.action === "message.append") { + const message = { + id: `tm_${messages.length + 1}`, role: input.role, authorId: workspace.members[0].userId, + authorName: input.role === "assistant" ? "Franklin · 0x1111…1111" : "0x1111…1111", + content: input.content, createdAt: new Date().toISOString(), + }; + messages.push(message); + workspace.version += 1; + return json(res, 201, { message, version: workspace.version }); + } + return json(res, 400, { error: "Unknown test action" }); +}); + +const listen = (server) => new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server.address().port))); +const remotePort = await listen(mockCloud); +let proxyPort = 0; +const child = spawn(process.execPath, [path.join(here, "server.mjs")], { + cwd: path.join(here, ".."), + env: { + ...process.env, + FRANKLIN_CLOUD_PORT: "0", + FRANKLIN_CLOUD_TOKEN: token, + FRANKLIN_TEAM_CLOUD_URL: `http://127.0.0.1:${remotePort}`, + FRANKLIN_TEAM_FAKE_AGENT: "1", + BLOCKRUN_WALLET_KEY: `0x${"11".repeat(32)}`, + FRANKLIN_RUNTIME_ENTRY: process.env.FRANKLIN_TEST_RUNTIME_ENTRY + || path.join(here, "..", "..", "..", "dist", "index.js"), + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], +}); + +const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Team proxy did not report readiness")), 5_000); + child.on("message", (message) => { + if (message?.type !== "franklin:cloud-ready" || !Number.isInteger(message.port) || message.port < 1) return; + clearTimeout(timer); + proxyPort = message.port; + resolve(); + }); + child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Team proxy exited before readiness (${code})`)); }); +}); + +try { + await ready; + for (let attempt = 0; attempt < 50; attempt++) { + try { if ((await fetch(`http://127.0.0.1:${proxyPort}/health`)).ok) break; } + catch { /* still starting */ } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const unauthorized = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "workspace.list" }) }); + assert.equal(unauthorized.status, 401); + + const hostileNullOrigin = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers: { "Content-Type": "application/json", Origin: "null", "X-Franklin-Desktop-Token": "wrong" }, body: JSON.stringify({ action: "workspace.list" }) }); + assert.equal(hostileNullOrigin.status, 403); + + const headers = { "Content-Type": "application/json", "X-Franklin-Desktop-Token": token }; + const listed = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers, body: JSON.stringify({ action: "workspace.list" }) }); + assert.equal(listed.status, 200); + assert.equal((await listed.json()).workspaces[0].id, workspace.id); + assert.match(verifiedAuth?.message || "", new RegExp(`127\\.0\\.0\\.1 wants you to sign in with your Ethereum account:\\n${verifiedAuth?.address}`)); + assert.match(verifiedAuth?.message || "", new RegExp(`URI: http://127\\.0\\.0\\.1:${remotePort}\\nVersion: 1\\nChain ID: 8453`)); + assert.match(verifiedAuth?.message || "", /Nonce: a{32}\nIssued At: /); + assert.match(verifiedAuth?.signature || "", /^0x[0-9a-f]{130}$/i); + + const unknown = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers, body: JSON.stringify({ action: "wallet.export" }) }); + assert.equal(unknown.status, 400); + + const turn = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team/agent-turn`, { method: "POST", headers, body: JSON.stringify({ workspaceId: workspace.id, content: "Summarize the workspace" }) }); + assert.equal(turn.status, 201); + const result = await turn.json(); + assert.equal(result.userMessage.content, "Summarize the workspace"); + assert.match(result.assistant.content, /1 shared file/); + assert.equal(messages.length, 2); + console.log("team-proxy: token isolation, SIWE bridge, workspace list, and agent turn passed"); +} finally { + child.kill("SIGTERM"); + await new Promise((resolve) => mockCloud.close(resolve)); +} diff --git a/apps/desktop/cloud-server/test.mjs b/apps/desktop/cloud-server/test.mjs new file mode 100644 index 00000000..a9f46e67 --- /dev/null +++ b/apps/desktop/cloud-server/test.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// Colima only bind-mounts user directories by default. Keep Docker-provider +// fixtures inside the checkout; the finally block removes them after each run. +const testRoot = process.env.FRANKLIN_CLOUD_SANDBOX_PROVIDER === "docker" ? process.cwd() : os.tmpdir(); +const dataDir = await fsp.mkdtemp(path.join(testRoot, ".franklin-cloud-test-")); +let base = ""; +const bootstrapKey = "e2e-private-preview-key"; +const server = spawn(process.execPath, [path.join(here, "server.mjs")], { + env: { + ...process.env, + FRANKLIN_CLOUD_PORT: "0", + FRANKLIN_CLOUD_DATA_DIR: dataDir, + FRANKLIN_CLOUD_FAKE_AGENT: "1", + FRANKLIN_CLOUD_BOOTSTRAP_KEY: bootstrapKey, + FRANKLIN_CLOUD_ALLOWED_ORIGINS: "http://localhost:5174", + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], +}); + +let stderr = ""; +server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + +const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Cloud test server did not report readiness: ${stderr}`)), 5_000); + server.on("message", (message) => { + if (message?.type !== "franklin:cloud-ready" || !Number.isInteger(message.port) || message.port < 1) return; + clearTimeout(timer); + base = `http://127.0.0.1:${message.port}`; + resolve(); + }); + server.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Cloud test server exited before readiness (${code}): ${stderr}`)); }); +}); + +async function waitForHealth() { + for (let attempt = 0; attempt < 50; attempt++) { + try { + const response = await fetch(`${base}/health`); + if (response.ok) return; + } catch { /* still starting */ } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Cloud test server did not start: ${stderr}`); +} + +function runE2e() { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(here, "e2e.mjs")], { + env: { ...process.env, FRANKLIN_CLOUD_URL: base, FRANKLIN_CLOUD_BOOTSTRAP_KEY: bootstrapKey, FRANKLIN_CLOUD_DATA_DIR: dataDir }, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`E2E exited with ${code}`))); + }); +} + +try { + await ready; + await waitForHealth(); + await runE2e(); +} finally { + server.kill("SIGTERM"); + await fsp.rm(dataDir, { recursive: true, force: true }); +} diff --git a/apps/desktop/dev-server/mock.mjs b/apps/desktop/dev-server/mock.mjs index fa645404..10d8e4df 100644 --- a/apps/desktop/dev-server/mock.mjs +++ b/apps/desktop/dev-server/mock.mjs @@ -14,7 +14,19 @@ import { WebSocketServer } from "ws"; import http from "node:http"; -const PORT = Number(process.env.FRANKLIN_AGENT_PORT) || 3737; +const requestedPort = Number(process.env.FRANKLIN_AGENT_PORT || 3737); +if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535) throw new Error("FRANKLIN_AGENT_PORT must be an integer from 0 to 65535"); +const PORT = requestedPort; + +function allowedOrigin(origin) { + if (!origin) return true; + try { + const url = new URL(origin); + return (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]"); + } catch { + return false; + } +} // ── Fake state ───────────────────────────────────────────────────────────── @@ -31,14 +43,15 @@ const wallet = { }; const models = [ - { id: "nvidia/deepseek-v4-flash", label: "DeepSeek V4 Flash", free: true, group: "Free" }, - { id: "nvidia/qwen3-coder-480b", label: "Qwen3 Coder 480B", free: true, group: "Free" }, - { id: "nvidia/llama-4-maverick", label: "Llama 4 Maverick", free: true, group: "Free" }, + { id: "nvidia/nemotron-nano-9b-v2", label: "Nemotron Nano 9B v2", free: true, group: "Free" }, + { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni", free: true, group: "Free" }, + { id: "nvidia/mistral-nemotron", label: "Mistral Nemotron", free: true, group: "Free" }, { id: "anthropic/claude-opus-4.8", label: "Claude Opus 4.8", free: false, group: "Premium frontier" }, { id: "anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", free: false, group: "Premium frontier" }, + { id: "qwen/qwen3.7-max", label: "Qwen3.7 Max", free: false, group: "Premium frontier", contextWindow: 1000000 }, { id: "openai/gpt-5.5", label: "GPT-5.5", free: false, group: "Premium frontier" }, { id: "google/gemini-3.5-flash", label: "Gemini 3.5 Flash", free: false, group: "Reasoning" }, - { id: "anthropic/claude-haiku-4.5-20251001", label: "Claude Haiku 4.5", free: false, group: "Budget" }, + { id: "anthropic/claude-haiku-4.5", label: "Claude Haiku 4.5", free: false, group: "Budget" }, ]; function mkSession(title, lastUser) { @@ -67,7 +80,12 @@ const server = http.createServer((req, res) => { // ── WebSocket router ─────────────────────────────────────────────────────── -const wss = new WebSocketServer({ server, path: "/agent" }); +const wss = new WebSocketServer({ + server, + path: "/agent", + maxPayload: 8 * 1024 * 1024, + verifyClient: (info) => allowedOrigin(info.origin || info.req.headers.origin), +}); wss.on("connection", (ws) => { console.log("[mock] client connected"); @@ -226,12 +244,12 @@ function chunkText(s, size) { } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } -server.listen(PORT, () => { +server.listen(PORT, "127.0.0.1", () => { const address = server.address(); - const effectivePort = address && typeof address === 'object' ? address.port : PORT; - if (typeof process.send === 'function') process.send({ type: 'franklin:server-ready', port: effectivePort }); - console.log(`[mock] franklin agent server on http://localhost:${effectivePort}`); - console.log(`[mock] WebSocket: ws://localhost:${effectivePort}/agent`); + const readyPort = address && typeof address !== "string" ? address.port : PORT; + console.log(`[mock] franklin agent server on http://localhost:${readyPort}`); + console.log(`[mock] WebSocket: ws://localhost:${readyPort}/agent`); console.log(`[mock] Vite dev server proxies /agent → here. Run \`npm run dev:vite\` in another terminal,`); console.log(`[mock] then open http://localhost:5173.`); + if (typeof process.send === "function") process.send({ type: "franklin:server-ready", port: readyPort }); }); diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index a3c7ac3b..62b1686e 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -11,13 +11,20 @@ // real `franklin serve` agent server (or an in-process import of // @blockrun/franklin) without touching the renderer. -const { app, BrowserWindow, shell, ipcMain } = require("electron"); +const { app, BrowserWindow, shell, ipcMain, session } = require("electron"); const crypto = require("node:crypto"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const net = require("node:net"); -const { spawn } = require("node:child_process"); +const { spawn, execFile } = require("node:child_process"); const { pathToFileURL } = require("node:url"); +const { + externalHttpUrl, + loopbackHttpUrl, + sameOriginUrl, + trustedRendererUrl, +} = require("./security.cjs"); // Is something already listening on a local port? Used so we don't double-spawn // Franklin Canvas (a second `npm start` would hit EADDRINUSE on :3100 and, per @@ -30,25 +37,41 @@ function isPortOpen(port) { }); } -const DEV_URL = process.env.FRANKLIN_DESKTOP_DEV_URL; // set by the desktop:dev script -const CANVAS_URL = process.env.FRANKLIN_CANVAS_URL || "http://localhost:5173"; -const AGENT_TOKEN = DEV_URL ? "" : crypto.randomBytes(32).toString("base64url"); -const FILE_TOKEN = DEV_URL ? "" : crypto.randomBytes(32).toString("base64url"); - -let agentPort = process.env.FRANKLIN_AGENT_PORT || (DEV_URL ? "3737" : "0"); -if (AGENT_TOKEN) process.env.FRANKLIN_SERVE_TOKEN = AGENT_TOKEN; +const DEV_URL = process.env.FRANKLIN_DESKTOP_DEV_URL + ? loopbackHttpUrl(process.env.FRANKLIN_DESKTOP_DEV_URL, "FRANKLIN_DESKTOP_DEV_URL") + : null; +function configuredPort(value, fallback, label) { + const parsed = Number(value === undefined || value === "" ? fallback : value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) throw new Error(`${label} must be an integer from 0 to 65535`); + return parsed; +} +const CONFIGURED_AGENT_PORT = configuredPort(process.env.FRANKLIN_AGENT_PORT, DEV_URL ? 3737 : 0, "FRANKLIN_AGENT_PORT"); +const CONFIGURED_CLOUD_PORT = configuredPort(process.env.FRANKLIN_CLOUD_PORT, 0, "FRANKLIN_CLOUD_PORT"); +const CLOUD_TOKEN = process.env.FRANKLIN_CLOUD_TOKEN || crypto.randomBytes(32).toString("base64url"); +const AGENT_TOKEN = process.env.FRANKLIN_SERVE_TOKEN || crypto.randomBytes(32).toString("base64url"); +const CANVAS_URL = loopbackHttpUrl(process.env.FRANKLIN_CANVAS_URL || "http://127.0.0.1:5173", "FRANKLIN_CANVAS_URL"); +const DIST_ROOT = path.join(__dirname, "..", "dist"); + +// The preload and the loopback service inherit the same unguessable token. +// This prevents an arbitrary web page from driving wallet-authenticated Team +// operations just because it can reach localhost. +process.env.FRANKLIN_CLOUD_TOKEN = CLOUD_TOKEN; +process.env.FRANKLIN_SERVE_TOKEN = AGENT_TOKEN; let win = null; let startupWin = null; let backend = null; +let cloudBackend = null; let canvas = null; let canvasWin = null; +let activeAgentPort = null; +let activeCloudPort = null; let quitting = false; let backendReady = false; const rendererReadyWaiters = new Map(); let startupState = { status: "loading", - message: "Preparing your secure workspace…", + message: "Preparing your secure workspace...", }; const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) app.exit(0); @@ -61,50 +84,48 @@ function backendBaseEnvironment() { return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : [])); } -function isSafeExternalUrl(raw) { - try { - const protocol = new URL(raw).protocol; - return protocol === "https:" || protocol === "http:"; - } catch { - return false; - } +function trustedMainRendererUrl(value) { + return trustedRendererUrl(value, { devUrl: DEV_URL, distRoot: DIST_ROOT }); } -function sameOrigin(candidate, trusted) { - try { return new URL(candidate).origin === new URL(trusted).origin; } - catch { return false; } +function trustedCanvasUrl(value) { + return sameOriginUrl(value, CANVAS_URL); } -function isTrustedMainRendererUrl(raw) { - if (DEV_URL) return sameOrigin(raw, DEV_URL); - return raw === pathToFileURL(path.join(__dirname, "..", "dist", "index.html")).toString(); +async function openExternal(value) { + const url = externalHttpUrl(value); + if (url) await shell.openExternal(url.href); } -function isTrustedStartupRendererUrl(raw) { - return raw === pathToFileURL(path.join(__dirname, "startup.html")).toString(); -} - -function guardWindowNavigation(browserWindow, trustedUrl) { +function guardWindowNavigation(browserWindow, isAllowed) { browserWindow.webContents.on("will-navigate", (event, url) => { - const allowed = trustedUrl.startsWith("file:") ? url === trustedUrl : sameOrigin(url, trustedUrl); - if (!allowed) event.preventDefault(); + if (isAllowed(url)) return; + event.preventDefault(); + void openExternal(url); }); + browserWindow.webContents.on("will-attach-webview", (event) => event.preventDefault()); browserWindow.webContents.setWindowOpenHandler(({ url }) => { - if (isSafeExternalUrl(url)) void shell.openExternal(url); + void openExternal(url); return { action: "deny" }; }); } -function assertTrustedIpcSender(event) { - if (!win || win.isDestroyed() || event.sender !== win.webContents || !isTrustedMainRendererUrl(event.sender.getURL())) { - throw new Error("Rejected IPC from an untrusted renderer"); +function requireTrustedIpc(event) { + if (!win || win.isDestroyed() || event.sender !== win.webContents || event.senderFrame !== win.webContents.mainFrame) { + throw new Error("IPC request did not come from the Franklin main frame"); } + if (!trustedMainRendererUrl(event.senderFrame.url)) throw new Error("IPC request came from an untrusted renderer URL"); +} + +function trustedStartupRendererUrl(value) { + return value === pathToFileURL(path.join(__dirname, "startup.html")).href; } -function assertTrustedStartupIpcSender(event) { - if (!startupWin || startupWin.isDestroyed() || event.sender !== startupWin.webContents || !isTrustedStartupRendererUrl(event.sender.getURL())) { - throw new Error("Rejected startup IPC from an untrusted renderer"); +function requireTrustedStartupIpc(event) { + if (!startupWin || startupWin.isDestroyed() || event.sender !== startupWin.webContents || event.senderFrame !== startupWin.webContents.mainFrame) { + throw new Error("IPC request did not come from the Franklin startup frame"); } + if (!trustedStartupRendererUrl(event.senderFrame.url)) throw new Error("IPC request came from an untrusted startup URL"); } function updateStartupState(status, message) { @@ -136,11 +157,13 @@ function createStartupWindow() { contextIsolation: true, nodeIntegration: false, sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + navigateOnDragDrop: false, }, }); - const startupUrl = pathToFileURL(path.join(__dirname, "startup.html")).toString(); - guardWindowNavigation(startupWin, startupUrl); + guardWindowNavigation(startupWin, trustedStartupRendererUrl); startupWin.once("ready-to-show", () => { if (startupWin && !startupWin.isDestroyed()) startupWin.show(); }); @@ -172,13 +195,85 @@ if (hasSingleInstanceLock) { }); } +const STUDIO_RUNTIME_SPECS = { + codex: { + command: "codex", + candidates: [ + process.env.CODEX_PATH, + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + path.join(os.homedir(), ".local", "bin", "codex"), + ], + }, + claude: { + command: "claude", + candidates: [process.env.CLAUDE_PATH, "/usr/local/bin/claude", "/opt/homebrew/bin/claude"], + }, + hermes: { command: "hermes", candidates: [process.env.HERMES_PATH] }, + deepseek: { command: "dsh", candidates: [process.env.DSH_PATH] }, +}; + +function execFileText(file, args, timeout = 5000) { + return new Promise((resolve) => { + execFile(file, args, { timeout, encoding: "utf8" }, (error, stdout, stderr) => { + resolve({ ok: !error, output: String(stdout || stderr || "").trim(), error: error ? String(error.message || error) : "" }); + }); + }); +} + +async function resolveStudioRuntime(id) { + const spec = STUDIO_RUNTIME_SPECS[id]; + if (!spec) return null; + const candidates = [...spec.candidates.filter(Boolean)]; + for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) candidates.push(path.join(dir, spec.command)); + if (id === "claude") { + const nvmRoot = path.join(os.homedir(), ".nvm", "versions", "node"); + try { + for (const version of fs.readdirSync(nvmRoot).sort().reverse()) candidates.push(path.join(nvmRoot, version, "bin", "claude")); + } catch { /* nvm is optional */ } + } + for (const candidate of candidates) { + try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* try next */ } + } + return null; +} + +async function inspectStudioRuntime(id) { + const executable = await resolveStudioRuntime(id); + if (!executable) return { id, available: false, running: false }; + const versionResult = await execFileText(executable, ["--version"], 5000); + const running = false; + return { + id, + available: true, + running, + path: executable, + version: versionResult.output.split("\n")[0] || "Detected", + lifecycleSupported: false, + }; +} + +async function scanStudioRuntimes() { + return Promise.all(Object.keys(STUDIO_RUNTIME_SPECS).map(inspectStudioRuntime)); +} + +async function startStudioRuntime(id) { + const detected = await inspectStudioRuntime(id); + return { ok: false, running: false, ...detected, error: "Runtime detected, but the Desktop protocol adapter is not implemented yet." }; +} + +async function stopStudioRuntime(id) { + return { ok: false, running: false, error: `The ${id} Desktop protocol adapter is not implemented yet.` }; +} + // Auto-start Franklin Canvas (its own backend :3100 + Vite UI :5173) so the // embedded canvas mode "just works" — the user never juggles a second terminal // or port. Dev only for now; packaging the canvas is a follow-up. async function startCanvas() { if (!DEV_URL) return; // dev only // Already running (manual instance, or a previous launch)? Reuse it. - const canvasPort = Number(new URL(CANVAS_URL).port) || 5173; + const canvasPort = Number(CANVAS_URL.port) || 5173; if (await isPortOpen(canvasPort)) { console.log(`[franklin-desktop] canvas already running on :${canvasPort} — reusing`); return; @@ -188,11 +283,12 @@ async function startCanvas() { console.log("[franklin-desktop] franklin-canvas not found — skipping canvas auto-start"); return; } - canvas = spawn("npm", ["start"], { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + canvas = spawn(npmCommand, ["start"], { cwd: dir, env: { ...process.env, FORCE_COLOR: "1" }, stdio: "inherit", - shell: true, // npm is a shell script; resolve via PATH + shell: false, }); canvas.on("exit", (code) => { console.log(`[franklin-desktop] canvas exited (${code})`); @@ -206,9 +302,8 @@ async function startCanvas() { // The real backend is Franklin's `serve` server (drives the actual agent loop, // wallet and tools). Set FRANKLIN_USE_MOCK=1 to fall back to the dev mock. function resolveFranklinEntry() { - // Packaged app: the agent runtime lives inside app.asar next to the - // production dependencies electron-builder already collects. Keeping the - // runtime there avoids shipping a second, full copy of node_modules. + // Packaged app: the prepared runtime is included in app.asar. Dev resolves + // the Franklin workspace dependency. if (app.isPackaged) { const bundled = path.join(app.getAppPath(), "franklin-agent", "dist", "index.js"); if (require("node:fs").existsSync(bundled)) return bundled; @@ -222,68 +317,131 @@ function resolveFranklinEntry() { } function startBackend() { - if (DEV_URL) return Promise.resolve(String(agentPort)); - const useMock = process.env.FRANKLIN_USE_MOCK === "1"; - if (app.isPackaged && useMock) { - throw new Error("The development mock is disabled in packaged Franklin builds."); + if (DEV_URL) { + activeAgentPort = CONFIGURED_AGENT_PORT; + process.env.FRANKLIN_AGENT_PORT = String(activeAgentPort); + return Promise.resolve(activeAgentPort); } + const useMock = process.env.FRANKLIN_USE_MOCK === "1"; const franklinEntry = useMock ? null : resolveFranklinEntry(); - if (!useMock && !franklinEntry) { - throw new Error("The packaged Franklin agent runtime is missing."); - } - // Keep the default tool root out of the user's entire home directory. A user - // can still select an explicit workspace with FRANKLIN_WORK_DIR. const workDir = process.env.FRANKLIN_WORK_DIR ? path.resolve(process.env.FRANKLIN_WORK_DIR) : path.join(app.getPath("documents"), "Franklin"); fs.mkdirSync(workDir, { recursive: true, mode: 0o700 }); - const [cmd, args] = franklinEntry - ? [franklinEntry, ["serve", "--port", agentPort, "--work-dir", workDir]] - : [path.join(__dirname, "..", "dev-server", "mock.mjs"), []]; - backend = spawn(process.execPath, [cmd, ...args], { + if (!franklinEntry && !useMock) { + return Promise.reject(new Error("Bundled Franklin runtime is missing; refusing to silently start the mock backend")); + } + const requestedPort = activeAgentPort ?? CONFIGURED_AGENT_PORT; + const [cmd, args] = useMock + ? [path.join(__dirname, "..", "dev-server", "mock.mjs"), []] + : [franklinEntry, ["serve", "--port", String(requestedPort), "--work-dir", workDir]]; + const child = spawn(process.execPath, [cmd, ...args], { cwd: workDir, env: { ...backendBaseEnvironment(), - FRANKLIN_AGENT_PORT: agentPort, + FRANKLIN_AGENT_PORT: String(requestedPort), FRANKLIN_SERVE_TOKEN: AGENT_TOKEN, - FRANKLIN_SERVE_FILE_TOKEN: FILE_TOKEN, FRANKLIN_SERVE_ALLOW_NULL_ORIGIN: "1", FRANKLIN_SERVE_DISCOVERY: "off", FRANKLIN_CLOUD_SYNC: "off", FRANKLIN_DESKTOP_WORKSPACE_BOUNDARY: "1", ELECTRON_RUN_AS_NODE: "1", // run the Node entry under Electron's Node }, - stdio: ["ignore", "inherit", "inherit", "ipc"], + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + backend = child; + child.on("exit", (code) => { + console.log(`[franklin-desktop] backend exited (${code})`); + if (backend === child) backend = null; }); return new Promise((resolve, reject) => { let ready = false; - const timeout = setTimeout(() => { - if (backend) backend.kill(); - reject(new Error("Franklin agent server did not become ready in time.")); - }, 20_000); - backend.once("error", (error) => { - clearTimeout(timeout); - reject(error); + const timer = setTimeout(() => { + if (backend === child) backend = null; + child.kill(); + reject(new Error("Franklin agent server did not become ready")); + }, 15_000); + child.on("message", (message) => { + if (message?.type !== "franklin:server-ready") return; + const readyPort = configuredPort(message.port, -1, "Franklin ready port"); + if (readyPort < 1) return; + clearTimeout(timer); + ready = true; + activeAgentPort = readyPort; + process.env.FRANKLIN_AGENT_PORT = String(readyPort); + resolve(readyPort); }); - backend.on("message", (message) => { - if (ready || !message || message.type !== "franklin:server-ready") return; - const reported = Number(message.port); - if (!Number.isInteger(reported) || reported <= 0 || reported > 65535) { - clearTimeout(timeout); - backend.kill(); - reject(new Error("Franklin agent server reported an invalid port.")); - return; + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("exit", (code) => { + if (!ready) { + clearTimeout(timer); + reject(new Error(`Franklin agent server exited before readiness (${code})`)); } + }); + }); +} + +async function switchWalletChain(chain) { + if (chain !== "base" && chain !== "solana") throw new Error("Unsupported wallet network"); + const blockrunDir = path.join(app.getPath("home"), ".blockrun"); + fs.mkdirSync(blockrunDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(blockrunDir, "payment-chain"), `${chain}\n`, { mode: 0o600 }); + fs.chmodSync(path.join(blockrunDir, "payment-chain"), 0o600); + if (!DEV_URL && backend) { + const previous = backend; + backend = null; + await new Promise((resolve) => { + const timer = setTimeout(resolve, 2500); + previous.once("exit", () => { clearTimeout(timer); resolve(); }); + previous.kill(); + }); + await startBackend(); + } + return { ok: true, chain }; +} + +function startCloudBackend() { + const entry = path.join(__dirname, "..", "cloud-server", "server.mjs"); + const requestedPort = activeCloudPort ?? CONFIGURED_CLOUD_PORT; + cloudBackend = spawn(process.execPath, [entry], { + cwd: app.getPath("home"), + env: { + ...process.env, + FRANKLIN_CLOUD_PORT: String(requestedPort), + FRANKLIN_CLOUD_TOKEN: CLOUD_TOKEN, + FRANKLIN_RUNTIME_ENTRY: resolveFranklinEntry() || "", + ELECTRON_RUN_AS_NODE: "1", + }, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + cloudBackend.on("exit", (code) => { + console.log(`[franklin-desktop] cloud workspace backend exited (${code})`); + cloudBackend = null; + }); + const child = cloudBackend; + return new Promise((resolve, reject) => { + let ready = false; + const timer = setTimeout(() => { + if (cloudBackend === child) cloudBackend = null; + child.kill(); + reject(new Error("Franklin Team sidecar did not become ready")); + }, 15_000); + child.on("message", (message) => { + if (message?.type !== "franklin:cloud-ready") return; + const readyPort = configuredPort(message.port, -1, "Franklin Team ready port"); + if (readyPort < 1) return; + clearTimeout(timer); ready = true; - clearTimeout(timeout); - resolve(String(reported)); + activeCloudPort = readyPort; + process.env.FRANKLIN_CLOUD_PORT = String(readyPort); + resolve(readyPort); }); - backend.on("exit", (code) => { - clearTimeout(timeout); - console.log(`[franklin-desktop] backend exited (${code})`); - backend = null; - if (!ready) reject(new Error(`Franklin agent server exited before readiness (${code}).`)); - else if (!quitting) app.quit(); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("exit", (code) => { + if (!ready) { + clearTimeout(timer); + reject(new Error(`Franklin Team sidecar exited before readiness (${code})`)); + } }); }); } @@ -307,12 +465,19 @@ async function openCanvasWindow() { // Normal native title bar (NOT hiddenInset) — the canvas app has no custom // drag region, so it needs the OS title bar to be movable. titleBarStyle: "default", - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + navigateOnDragDrop: false, + }, }); - guardWindowNavigation(canvasWin, CANVAS_URL); + guardWindowNavigation(canvasWin, trustedCanvasUrl); canvasWin.on("closed", () => { canvasWin = null; }); for (let i = 0; i < 40; i++) { - try { await canvasWin.loadURL(CANVAS_URL); return; } + try { await canvasWin.loadURL(CANVAS_URL.href); return; } catch { await new Promise((r) => setTimeout(r, 300)); } } } @@ -322,13 +487,13 @@ async function loadRenderer(targetWindow) { // Vite may still be coming up — retry until it answers. for (let i = 0; i < 40; i++) { try { - await targetWindow.loadURL(DEV_URL); + await targetWindow.loadURL(DEV_URL.href); return; } catch { await new Promise((r) => setTimeout(r, 300)); } } - await targetWindow.loadURL(DEV_URL); // final attempt; let the error surface + await targetWindow.loadURL(DEV_URL.href); // final attempt; let the error surface } else { await targetWindow.loadFile(path.join(__dirname, "..", "dist", "index.html")); } @@ -340,7 +505,7 @@ function createWindow() { height: 800, minWidth: 720, minHeight: 480, - backgroundColor: "#f7f6f1", + backgroundColor: "#ffffff", show: false, titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", webPreferences: { @@ -348,16 +513,16 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + navigateOnDragDrop: false, }, }); - const trustedRendererUrl = DEV_URL || pathToFileURL(path.join(__dirname, "..", "dist", "index.html")).toString(); - guardWindowNavigation(win, trustedRendererUrl); - + // Open target=_blank / external links in the system browser, not a new window. + guardWindowNavigation(win, trustedMainRendererUrl); win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (isMainFrame) { - console.error("[franklin-desktop] renderer load failed", { errorCode, errorDescription, validatedURL }); - } + if (isMainFrame) console.error("[franklin-desktop] renderer load failed", { errorCode, errorDescription, validatedURL }); }); win.webContents.on("render-process-gone", (_event, details) => { console.error("[franklin-desktop] renderer process exited", details); @@ -385,7 +550,7 @@ function waitForRendererContent(targetWindow) { callback(); }; const timeout = setTimeout(() => { - finish(() => reject(new Error("Franklin’s interface did not become ready in time."))); + finish(() => reject(new Error("Franklin's interface did not become ready in time."))); }, 15_000); rendererReadyWaiters.set(webContentsId, () => finish(resolve)); targetWindow.once("closed", () => { @@ -401,7 +566,6 @@ async function openMainWindow() { closeStartupWindow(); return; } - const mainWindow = createWindow(); const rendererReady = waitForRendererContent(mainWindow); await loadRenderer(mainWindow); @@ -414,27 +578,29 @@ async function openMainWindow() { async function startApplication() { createStartupWindow(); - updateStartupState("loading", "Preparing your secure workspace…"); - agentPort = await startBackend(); + updateStartupState("loading", "Preparing your secure workspace..."); + await Promise.all([startBackend(), startCloudBackend()]); backendReady = true; - process.env.FRANKLIN_AGENT_PORT = agentPort; - updateStartupState("loading", "Opening Franklin…"); + updateStartupState("loading", "Opening Franklin..."); void startCanvas(); await openMainWindow(); } app.whenReady().then(() => { if (!hasSingleInstanceLock) return; - ipcMain.handle("franklin:open-canvas", (event) => { - assertTrustedIpcSender(event); - return openCanvasWindow(); - }); + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + session.defaultSession.setPermissionCheckHandler(() => false); + ipcMain.handle("franklin:open-canvas", (event) => { requireTrustedIpc(event); return openCanvasWindow(); }); + ipcMain.handle("franklin:studio-scan", (event) => { requireTrustedIpc(event); return scanStudioRuntimes(); }); + ipcMain.handle("franklin:studio-start", (event, id) => { requireTrustedIpc(event); return startStudioRuntime(String(id)); }); + ipcMain.handle("franklin:studio-stop", (event, id) => { requireTrustedIpc(event); return stopStudioRuntime(String(id)); }); + ipcMain.handle("franklin:wallet-switch", (event, chain) => { requireTrustedIpc(event); return switchWalletChain(String(chain)); }); ipcMain.on("franklin:renderer-ready", (event) => { - assertTrustedIpcSender(event); + requireTrustedIpc(event); rendererReadyWaiters.get(event.sender.id)?.(); }); ipcMain.handle("franklin:startup-retry", (event) => { - assertTrustedStartupIpcSender(event); + requireTrustedStartupIpc(event); app.relaunch(); app.exit(0); }); @@ -443,7 +609,7 @@ app.whenReady().then(() => { if (win && !win.isDestroyed()) win.destroy(); win = null; createStartupWindow(); - updateStartupState("error", "Franklin couldn’t start. Please try again."); + updateStartupState("error", "Franklin couldn't start. Please try again."); }); app.on("activate", () => { if (!backendReady) { @@ -456,10 +622,10 @@ app.whenReady().then(() => { return; } createStartupWindow(); - updateStartupState("loading", "Opening Franklin…"); + updateStartupState("loading", "Opening Franklin..."); void openMainWindow().catch((error) => { console.error("[franklin-desktop] failed to reopen window", error); - updateStartupState("error", "Franklin couldn’t open its window. Please try again."); + updateStartupState("error", "Franklin couldn't open its window. Please try again."); }); }); }); @@ -472,5 +638,6 @@ app.on("before-quit", () => { quitting = true; }); app.on("quit", () => { if (backend) backend.kill(); + if (cloudBackend) cloudBackend.kill(); if (canvas) canvas.kill(); }); diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index c47a6f3b..f59656bc 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -5,11 +5,6 @@ const { contextBridge, clipboard, nativeImage, ipcRenderer } = require("electron"); -const port = process.env.FRANKLIN_AGENT_PORT || "3737"; -const token = process.env.FRANKLIN_SERVE_TOKEN || ""; -const agentUrl = new URL(`ws://127.0.0.1:${port}/agent`); -if (token) agentUrl.searchParams.set("token", token); - // `ready-to-show` can fire before React paints meaningful content. Wait for two // animation frames after DOMContentLoaded, then let the main process replace // the startup window. This keeps a blank Electron surface off screen. @@ -19,11 +14,27 @@ window.addEventListener("DOMContentLoaded", () => { }); }, { once: true }); +function readyPort(value, label) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) throw new Error(`${label} is not ready`); + return parsed; +} +const port = readyPort(process.env.FRANKLIN_AGENT_PORT, "Franklin agent port"); +const cloudPort = readyPort(process.env.FRANKLIN_CLOUD_PORT, "Franklin Team port"); +const agentToken = process.env.FRANKLIN_SERVE_TOKEN || ""; +const agentQuery = agentToken ? `?token=${encodeURIComponent(agentToken)}` : ""; + contextBridge.exposeInMainWorld("__FRANKLIN__", { - agentUrl: agentUrl.toString(), + agentUrl: `ws://127.0.0.1:${port}/agent${agentQuery}`, + cloudUrl: `http://127.0.0.1:${cloudPort}`, + cloudToken: process.env.FRANKLIN_CLOUD_TOKEN || "", // Franklin Canvas (node-based media studio) opens in its own native window; // Electron auto-starts the canvas server/UI, so there's nothing to run by hand. openCanvas: () => ipcRenderer.invoke("franklin:open-canvas"), + scanAgentRuntimes: () => ipcRenderer.invoke("franklin:studio-scan"), + startAgentRuntime: (id) => ipcRenderer.invoke("franklin:studio-start", id), + stopAgentRuntime: (id) => ipcRenderer.invoke("franklin:studio-stop", id), + switchWalletChain: (chain) => ipcRenderer.invoke("franklin:wallet-switch", chain), // Native clipboard — navigator.clipboard is unreliable inside Electron, so the // renderer prefers this when present. copy: (text) => { diff --git a/apps/desktop/electron/security.cjs b/apps/desktop/electron/security.cjs new file mode 100644 index 00000000..b16e0d00 --- /dev/null +++ b/apps/desktop/electron/security.cjs @@ -0,0 +1,54 @@ +const path = require("node:path"); +const { fileURLToPath } = require("node:url"); + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +function parsedUrl(value) { + try { return new URL(String(value)); } + catch { return null; } +} + +function loopbackHttpUrl(value, label = "URL") { + const url = parsedUrl(value); + if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) { + throw new Error(`${label} must be an HTTP(S) URL`); + } + if (!LOOPBACK_HOSTS.has(url.hostname) || url.username || url.password) { + throw new Error(`${label} must target loopback without credentials`); + } + return url; +} + +function externalHttpUrl(value) { + const url = parsedUrl(value); + if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return null; + if (url.username || url.password) return null; + return url; +} + +function sameOriginUrl(value, allowed) { + const url = parsedUrl(value); + return Boolean(url && allowed && url.origin === allowed.origin); +} + +function pathInside(root, candidate) { + const base = path.resolve(root); + const target = path.resolve(candidate); + return target === base || target.startsWith(`${base}${path.sep}`); +} + +function trustedRendererUrl(value, { devUrl, distRoot }) { + const url = parsedUrl(value); + if (!url) return false; + if (devUrl) return sameOriginUrl(url.href, devUrl); + if (url.protocol !== "file:" || url.username || url.password) return false; + try { return pathInside(distRoot, fileURLToPath(url)); } + catch { return false; } +} + +module.exports = { + externalHttpUrl, + loopbackHttpUrl, + sameOriginUrl, + trustedRendererUrl, +}; diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js index 42a1e118..5afc5971 100644 --- a/apps/desktop/eslint.config.js +++ b/apps/desktop/eslint.config.js @@ -3,6 +3,7 @@ import typescriptParser from "@typescript-eslint/parser"; import reactHooks from "eslint-plugin-react-hooks"; const browserGlobals = { + AbortSignal: "readonly", Blob: "readonly", File: "readonly", FileReader: "readonly", diff --git a/apps/desktop/index.html b/apps/desktop/index.html index c83d95ef..8fb73e50 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -2,9 +2,9 @@ - + Franklin diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e246f76c..f3f5875c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,22 +1,32 @@ { "name": "@blockrun/franklin-desktop", - "version": "0.1.3-beta.1", + "version": "0.2.0-beta.1", "description": "Franklin Desktop — native desktop app for the Franklin agent (Electron). Same agent, wallet and tools as the CLI, in a polished window.", "license": "Apache-2.0", "type": "module", "main": "electron/main.cjs", "scripts": { - "dev": "concurrently -k -n server,vite -c green,cyan \"npm:dev:server\" \"npm:dev:vite\"", + "dev": "concurrently -k -n server,cloud,vite -c green,yellow,cyan \"npm:dev:server\" \"npm:dev:cloud\" \"npm:dev:vite\"", "dev:vite": "vite", "dev:server": "node dev-server/mock.mjs", + "dev:cloud": "node cloud-server/server.mjs", + "dev:desktop-web": "concurrently -k -n server,vite -c green,cyan \"npm:dev:server\" \"npm:dev:vite\"", + "dev:desktop-real-web": "concurrently -k -n agent,vite -c green,cyan \"npm:dev:agent\" \"npm:dev:vite\"", + "test:cloud": "node cloud-server/test.mjs", + "test:team-proxy": "node cloud-server/team-proxy.test.mjs", + "test:security": "node --test test/*.test.cjs", + "test": "npm run test:security && npm run test:cloud && npm run test:team-proxy", + "cloud:build-sandbox": "docker build -f cloud-server/Dockerfile.sandbox -t franklin-cloud-sandbox:local cloud-server", + "test:cloud:docker": "npm run cloud:build-sandbox && FRANKLIN_CLOUD_SANDBOX_PROVIDER=docker npm run test:cloud", + "cloud:compose:up": "docker compose -f cloud-server/compose.yml up --build", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint src", "typecheck": "tsc -b --noEmit", "dev:agent": "FRANKLIN_DESKTOP_WORKSPACE_BOUNDARY=1 node ../../dist/index.js serve --port 3737", - "dev:real": "concurrently -k -n agent,vite -c green,cyan \"npm run dev:agent\" \"vite\"", - "desktop:dev": "concurrently -k -n web,electron -c green,magenta \"npm run dev\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"", - "desktop:real": "concurrently -k -n web,electron -c green,magenta \"npm run dev:real\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_AGENT_PORT=3737 FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"", + "dev:real": "concurrently -k -n agent,cloud,vite -c green,yellow,cyan \"npm run dev:agent\" \"npm run dev:cloud\" \"vite\"", + "desktop:dev": "concurrently -k -n web,electron -c green,magenta \"npm run dev:desktop-web\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"", + "desktop:real": "concurrently -k -n web,electron -c green,magenta \"npm run dev:desktop-real-web\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"", "desktop": "npm run build && ELECTRON_RUN_AS_NODE= electron electron/main.cjs", "prepare:runtime": "node scripts/prepare-runtime.mjs", "dist:mac": "npm run build && npm run prepare:runtime && electron-builder --mac --arm64 --publish never", @@ -41,9 +51,9 @@ "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "@vitejs/plugin-react": "^5.0.0", - "concurrently": "^9.1.0", + "concurrently": "^9.2.4", "electron": "44.0.0", - "electron-builder": "^26.8.1", + "electron-builder": "^26.15.3", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", "shadcn": "^4.8.3", @@ -51,7 +61,7 @@ "tw-animate-css": "^1.4.0", "ts-api-utils": "2.4.0", "typescript": "^5.9.3", - "vite": "^7.0.0", + "vite": "^7.3.5", "ws": "^8.18.0" }, "engines": { @@ -67,6 +77,7 @@ "dist/**", "electron/**", "franklin-agent/**", + "cloud-server/server.mjs", "package.json", "!node_modules/@blockrun/franklin", "!node_modules/@blockrun/franklin/**" diff --git a/apps/desktop/src/components/ActivitySummary.tsx b/apps/desktop/src/components/ActivitySummary.tsx index 4a58977b..0ae839d9 100644 --- a/apps/desktop/src/components/ActivitySummary.tsx +++ b/apps/desktop/src/components/ActivitySummary.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { ChevronDown, Search, Globe } from "lucide-react"; import type { ChatActivity } from "../hooks/use-franklin-chat"; import { useTryLang } from "../lib/i18n"; +import { safeExternalHttpUrl } from "../lib/external-url"; // Collapsed recap of a finished tool run — "searched N keywords · M sources", // expandable to show the queries and the pages Franklin referenced. @@ -34,12 +35,13 @@ export function ActivitySummary({ activity }: { activity: ChatActivity }) { )} {activity.sources.length > 0 && (
- {activity.sources.map((s) => ( - + {activity.sources.map((s) => { + const href = safeExternalHttpUrl(s.url); + return href ? {s.title} - - ))} + : null; + })}
)} diff --git a/apps/desktop/src/components/AgentsPanel.tsx b/apps/desktop/src/components/AgentsPanel.tsx new file mode 100644 index 00000000..45b7d492 --- /dev/null +++ b/apps/desktop/src/components/AgentsPanel.tsx @@ -0,0 +1,167 @@ +import { + Blocks, Bot, Box, ChevronRight, CircleStop, Download, Network, + Play, Power, RotateCcw, Settings2, ShieldCheck, Terminal, Trash2, Users, +} from "lucide-react"; +import type { AgentId, StudioAgent } from "../hooks/use-studio-registry"; + +interface Props { + agents: StudioAgent[]; + installedCount: number; + connectedCount: number; + teamModeEnabled: boolean; + scanning: boolean; + onScan: () => void | Promise; + onImport: (id: AgentId) => void | Promise; + onRemove: (id: AgentId) => void | Promise; + onRunning: (id: AgentId, running: boolean) => void | Promise; + onBlockRun: (id: AgentId, enabled: boolean) => void; + onTeamMode: (enabled: boolean) => void; +} + +function Toggle({ checked, onChange, label, disabled = false }: { checked: boolean; onChange: (checked: boolean) => void; label: string; disabled?: boolean }) { + return ( + + ); +} + +function AgentMark({ id }: { id: AgentId }) { + const labels: Record = { franklin: "F", codex: "C", claude: "A", hermes: "H", deepseek: "D" }; + return {labels[id]}; +} + +export function AgentsPanel({ + agents, installedCount, connectedCount, teamModeEnabled, onImport, onRemove, + onRunning, onBlockRun, onTeamMode, scanning, onScan, +}: Props) { + return ( +
+
+
+
+
Agent studio
+

Franklin

+

Bring your own agent. Keep one workspace, one capability layer and one wallet.

+
+ +
+ +
+
{installedCount}Agent runtimes
+
{connectedCount}Using BlockRun
+
1Shared workspace
+
LocalWallet broker
+
+ +
+
+
+

Agent runtimes

+

Each CLI is an adapter. Install, stop or remove it without changing Franklin.

+
+ {installedCount} installed +
+ +
+ {agents.map((agent) => ( +
+
+ +
+
+ {agent.name} + {agent.builtIn && Built in} + {agent.experimental && Experimental} +
+ {agent.command} +
+ + {agent.running ? "Running" : agent.installed ? "Stopped" : scanning ? "Checking" : agent.available ? "Detected" : agent.available === false ? "Not found" : "Available"} + +
+ +

{agent.description}

+
{agent.protocol}
+ {(agent.version || agent.endpoint) &&
{agent.version}{agent.endpoint && {agent.endpoint}}
} + {agent.error &&
{agent.error}
} + + {agent.installed ? ( + <> +
+ + BlockRunRouter · Models · MCP · Wallet + {agent.id === "franklin" + ? onBlockRun(agent.id, enabled)} label={`BlockRun for ${agent.name}`} disabled /> + : Adapter pending} +
+ {!agent.builtIn &&
+ + + {!agent.builtIn && } +
} + + ) : ( + + )} +
+ ))} +
+
+ +
+
+
+

Studio modules

+

Product modes are modules too. Turn them on only when this workspace needs them.

+
+
+
+
+ + Team ModeShared conversations, files, knowledge and reusable workflows. + Workspace module + +
+
+ + BlockRun Capability BrokerOne local router and wallet policy shared safely by every imported agent. + Planned + +
+
+ + Terminal fallbackPTY compatibility for CLIs that do not expose a structured protocol. + Planned + +
+
+
+ +
+ + Live runtime registry. Franklin now detects installed CLIs and manages the Codex app-server lifecycle. Router injection for imported agents remains a separate adapter layer. + +
+
+
+ ); +} diff --git a/apps/desktop/src/components/CloudWorkspaceDrawer.tsx b/apps/desktop/src/components/CloudWorkspaceDrawer.tsx new file mode 100644 index 00000000..306d4664 --- /dev/null +++ b/apps/desktop/src/components/CloudWorkspaceDrawer.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { Check, Cloud, Copy, Laptop2, Users, X } from "lucide-react"; +import type { CloudMember } from "../hooks/use-cloud-workspace"; + +const memberAvatar = (name: string) => name.startsWith("0x") ? name.slice(2, 4).toUpperCase() : name.slice(0, 2).toUpperCase(); + +interface Props { + open: boolean; + workspaceName: string; + workspaceRole: CloudMember["role"]; + members: CloudMember[]; + sessionId?: string; + onClose: () => void; + onUpdateMemberRole: (userId: string, role: "admin" | "member" | "viewer") => Promise; + onCreateInvite: (role: "member" | "viewer") => Promise<{ invite: { code: string } }>; +} + +export function CloudWorkspaceDrawer({ open, workspaceName, workspaceRole, members, sessionId, onClose, onUpdateMemberRole, onCreateInvite }: Props) { + const [inviteRole, setInviteRole] = useState<"member" | "viewer">("member"); + const [inviteCode, setInviteCode] = useState(null); + const [copied, setCopied] = useState(false); + if (!open) return null; + + const copyInvite = async () => { + if (!inviteCode) return; + await navigator.clipboard.writeText(inviteCode).catch(() => undefined); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }; + + return <> + +
+
This Mac · member fundedEach turn runs on the initiating member's local Franklin and wallet. Shared context and answers sync to Franklin Cloud.
+
MEMBERS {members.length}
+
{members.map((member) =>
{memberAvatar(member.name)}
{member.name}{member.userId === sessionId ? " · You" : ""}{workspaceRole === "owner" && member.role !== "owner" ? : {member.role}}
)}
+ {(workspaceRole === "owner" || workspaceRole === "admin") &&
} + {inviteCode &&
One-time invite{inviteCode}
} +
Cloud FranklinNot provisioned · Team turns currently require a member's Desktop.
Coming next
+
+ + ; +} diff --git a/apps/desktop/src/components/CloudWorkspacePanel.tsx b/apps/desktop/src/components/CloudWorkspacePanel.tsx new file mode 100644 index 00000000..51b590c3 --- /dev/null +++ b/apps/desktop/src/components/CloudWorkspacePanel.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Cloud, FileText, FolderOpen, Laptop2, Loader2, MessageSquare, Plus, RefreshCw, Send, ShieldCheck, Users, X } from "lucide-react"; +import { useCloudWorkspace } from "../hooks/use-cloud-workspace"; +import { MessageContent } from "./MessageContent"; +import { publishTeamWorkspaceNav, subscribeTeamWorkspaceRequest } from "../lib/team-workspace-events"; +import { CloudWorkspaceDrawer } from "./CloudWorkspaceDrawer"; + +const friendlyCloudError = (message: string) => message.toLowerCase().includes("fetch failed") + ? "Team Cloud is temporarily unavailable. Your shared data is safe—try again in a moment." + : message; + +function CloudRetryError({ message, className = "", onRetry }: { message: string; className?: string; onRetry: () => void }) { + return
{friendlyCloudError(message)}
; +} + +export function CloudWorkspacePanel() { + const cloud = useCloudWorkspace(); + const [workspaceName, setWorkspaceName] = useState("BlockRun Cloud Workspace"); + const [inviteInput, setInviteInput] = useState(""); + const [message, setMessage] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [fileSavedContent, setFileSavedContent] = useState(""); + const [fileDraft, setFileDraft] = useState(""); + const [savingFile, setSavingFile] = useState(false); + const [workspaceView, setWorkspaceView] = useState<"chat" | "files">("chat"); + const [fileStatus, setFileStatus] = useState<"idle" | "loading" | "saved">("idle"); + const [membersOpen, setMembersOpen] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [cloud.messages, cloud.sending]); + useEffect(() => publishTeamWorkspaceNav({ + items: cloud.workspaces.map((workspace) => ({ id: workspace.id, name: workspace.name, role: workspace.role, memberCount: workspace.members.length, version: workspace.version })), + activeId: cloud.activeId, + loading: cloud.loading && !cloud.session, + }), [cloud.activeId, cloud.loading, cloud.session, cloud.workspaces]); + useEffect(() => subscribeTeamWorkspaceRequest(cloud.setActiveId), [cloud.setActiveId]); + + const openFile = async (path: string) => { + setSelectedFile(path); + setWorkspaceView("files"); + setFileStatus("loading"); + try { + const content = (await cloud.readFile(path)).content; + setFileContent(content); + setFileSavedContent(content); + setFileStatus("idle"); + } + catch (error) { + setFileContent(error instanceof Error ? error.message : String(error)); + setFileStatus("idle"); + } + }; + + const createFile = async () => { + const path = fileDraft.trim(); + if (!path) return; + const content = `# ${path}\n\nCreated by ${cloud.session?.name}.\n`; + setSavingFile(true); + try { + await cloud.saveFile(path, content); + setFileDraft(""); + await openFile(path); + setFileStatus("saved"); + } finally { setSavingFile(false); } + }; + + if (!cloud.session) { + return ( +
+
+ +
FRANKLIN CLOUD · TEAM WORKSPACE
+

{cloud.connected ? "Connect your Franklin wallet" : "Start Franklin to continue"}

+

Your local Franklin signs in to franklin.run with SIWE. The wallet key never leaves this computer; Team data is stored in the existing Franklin Cloud.

+ + {cloud.error &&
{friendlyCloudError(cloud.error)}
} +
+
+ ); + } + + if (!cloud.active) { + return ( +
+
+
{cloud.session.name.slice(2, 3).toUpperCase()}
{cloud.session.name}Wallet-authenticated Franklin Cloud
+
CREATE OR JOIN
+

Start a shared Cloud Workspace

+
+

Create workspace

setWorkspaceName(event.target.value)} />
+

Join with invite

setInviteInput(event.target.value.toUpperCase())} />
+
+ {cloud.workspaces.length > 0 &&

Your workspaces

{cloud.workspaces.map((workspace) => )}
} + {cloud.error &&
{friendlyCloudError(cloud.error)}
} +
+
+ ); + } + + const submit = () => { + const content = message.trim(); + if (!content || cloud.sending) return; + setMessage(""); + void cloud.sendMessage(content); + }; + + const selectedFileMeta = cloud.files.find((file) => file.path === selectedFile); + const fileIsDirty = selectedFile !== null && fileContent !== fileSavedContent; + const enterFiles = () => { + if (!selectedFile && cloud.files[0]) void openFile(cloud.files[0].path); + else setWorkspaceView("files"); + }; + + return ( +
+
+
{cloud.active.name}Franklin Cloud · workspace v{cloud.active.version}
+ +
+ Runs on this Mac + + +
+
+ +
+ {workspaceView === "chat" ? ( +
+
+ {cloud.messages.length === 0 ?

Talk with your Team Franklin

Shared files and recent team messages are added to the initiating member's Franklin context.

: cloud.messages.map((item) =>
{item.authorName}{new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
)} + {cloud.sending &&
Team Franklin is workingReading shared context from workspace v{cloud.active.version}…
} +
+