diff --git a/.env.example b/.env.example index dc1e402..dd6ace4 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,10 @@ CODEX_EFFORT=xhigh # Codex reasoning effort STATUS_CHANNEL_ID= # Discord channel ID for live status updates # STATUS_SHOW_ROOMS=true # Show room status in dashboard # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info +# WEB_DASHBOARD_ENABLED=false # Local dashboard server +# WEB_DASHBOARD_HOST=127.0.0.1 # Keep localhost unless using VPN/tunnel +# WEB_DASHBOARD_PORT=8734 +# WEB_DASHBOARD_TOKEN= # Optional bearer token for /api/* when exposing to phone clients # --- Discord image attachments --- # Extra local directories that agents may attach from. Keep this narrow: diff --git a/docs/android-rayban-display-plan.md b/docs/android-rayban-display-plan.md new file mode 100644 index 0000000..024611a --- /dev/null +++ b/docs/android-rayban-display-plan.md @@ -0,0 +1,94 @@ +# Android / Meta Ray-Ban Display Plan + +## Goal + +Build a personal Android companion for EJClaw that reuses the existing server and dashboard APIs, then extend it to Meta Ray-Ban Display through the Meta Wearables Device Access Toolkit (DAT) when the device developer preview is available to the account. + +## Current Platform Facts + +- EJClaw is a Bun/Node host service. Android should be a thin client, not a port of the runtime. +- Meta DAT is in developer preview and provides Android SDK artifacts and sample app guidance through the public `facebook/meta-wearables-dat-android` repo. +- DAT preview allows SDK/documentation access and sharing builds with organization/team testers; general public publishing is limited during preview. +- Ray-Ban Display currently has two developer paths: Web Apps Dev Mode for HTML/CSS/JS standalone display apps, and DAT Display Developer Preview for extending Android/iOS apps onto the display. + +Sources: + +- https://developers.meta.com/blog/introducing-meta-wearables-device-access-toolkit/ +- https://github.com/facebook/meta-wearables-dat-android +- https://www.levinriegner.com/news/l-r-joins-meta-to-open-ray-ban-display-to-developers/ + +## MVP Shape + +1. Keep EJClaw running on the existing server. +2. Expose the web dashboard only through localhost, VPN, or a private tunnel. +3. Require `WEB_DASHBOARD_TOKEN` for `/api/*` before phone clients connect. +4. Build an Android app that talks to the existing dashboard API: + - `GET /api/overview` + - `GET /api/rooms-timeline` + - `GET /api/rooms/:jid/timeline` + - `POST /api/rooms/:jid/messages` +5. Add DAT only for the display surface: + - short current-room status + - latest assistant output + - progress text + - quick reply / send command entry + +## Security Model + +Do not expose EJClaw directly to the internet without a private transport. + +Recommended personal setup: + +- `WEB_DASHBOARD_HOST=127.0.0.1` for local-only use, or bind through Tailscale / VPN / SSH tunnel. +- Set `WEB_DASHBOARD_TOKEN` and send it from Android as `Authorization: Bearer `. +- Keep restart/settings/account routes available only over the same protected API; do not add unauthenticated mobile-only shortcuts. + +## Android App Plan + +Use Kotlin for the first native client. The app can be small: + +- `EJClawApi`: HTTP client with bearer token. +- `RoomListViewModel`: fetches `/api/rooms-timeline` on interval. +- `RoomThreadViewModel`: fetches room timeline and sends messages. +- `DisplayBridge`: DAT integration boundary, initially hidden behind an interface so the app can run without glasses. + +The first build does not need DAT to validate EJClaw connectivity. DAT comes after the phone client can read rooms and send one message. + +## DAT Integration Boundary + +Keep Meta SDK code isolated under an Android module/package such as: + +```text +apps/android/app/src/main/java/.../display/ +``` + +Suggested interfaces: + +```kotlin +interface DisplaySurface { + fun showStatus(roomName: String, state: String, progress: String?) + fun showMessage(roomName: String, text: String) +} + +class NoopDisplaySurface : DisplaySurface { ... } +class MetaDatDisplaySurface : DisplaySurface { ... } +``` + +This avoids blocking normal phone testing when DAT access, glasses firmware, region support, or Developer Mode is not ready. + +## Open Questions + +- Is the Meta Wearables Developer Center account approved for DAT Display Developer Preview? +- Is the target phone able to pair with Ray-Ban Display in the current region/account setup? +- Should first Android build be native Kotlin UI or a minimal WebView wrapper around the existing dashboard? +- Which private transport will be used: Tailscale, SSH tunnel, Cloudflare Tunnel with access policy, or LAN only? + +## Next Step + +Implement the Android MVP only after the API token guard is merged and deployed. The initial Android client should prove: + +1. Authenticate to EJClaw. +2. List rooms. +3. Open one room timeline. +4. Send one message. +5. Run without DAT using `NoopDisplaySurface`. diff --git a/src/config.test.ts b/src/config.test.ts index 6d54501..34265d8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -46,6 +46,7 @@ describe('config/env loading', () => { delete process.env.WEB_DASHBOARD_HOST; delete process.env.WEB_DASHBOARD_PORT; delete process.env.WEB_DASHBOARD_STATIC_DIR; + delete process.env.WEB_DASHBOARD_TOKEN; delete process.env.SESSION_COMMAND_USER_IDS; vi.resetModules(); }); @@ -175,12 +176,14 @@ describe('config/env loading', () => { expect(config.WEB_DASHBOARD.staticDir).toBe( path.resolve(tempRoot, 'apps', 'dashboard', 'dist'), ); + expect(config.WEB_DASHBOARD.token).toBe(''); vi.resetModules(); process.env.WEB_DASHBOARD_ENABLED = 'true'; process.env.WEB_DASHBOARD_HOST = '0.0.0.0'; process.env.WEB_DASHBOARD_PORT = '9001'; process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist'; + process.env.WEB_DASHBOARD_TOKEN = 'mobile-secret'; config = await import('./config.js'); expect(config.WEB_DASHBOARD).toEqual({ @@ -188,6 +191,7 @@ describe('config/env loading', () => { host: '0.0.0.0', port: 9001, staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'), + token: 'mobile-secret', }); }); diff --git a/src/config/load-config.ts b/src/config/load-config.ts index a077c71..a457013 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -274,6 +274,7 @@ export function loadConfig(): AppConfig { readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ?? path.join(projectRoot, 'apps', 'dashboard', 'dist'), ), + token: readNonEmptyText('WEB_DASHBOARD_TOKEN') ?? '', }, codexWarmup: { enabled: readBoolean('CODEX_WARMUP_ENABLED', false), diff --git a/src/config/schema.ts b/src/config/schema.ts index 15afdf1..5f143c3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -83,6 +83,7 @@ export interface AppConfig { host: string; port: number; staticDir: string; + token: string; }; codexWarmup: { enabled: boolean; diff --git a/src/web-dashboard-auth.test.ts b/src/web-dashboard-auth.test.ts new file mode 100644 index 0000000..695770c --- /dev/null +++ b/src/web-dashboard-auth.test.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createWebDashboardHandler } from './web-dashboard-server.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('web dashboard API auth', () => { + it('requires a bearer token for API routes when dashboard auth is configured', async () => { + const handler = createWebDashboardHandler({ + authToken: 'mobile-secret', + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + startBackgroundCacheRefresh: false, + }); + + const missing = await handler(new Request('http://localhost/api/health')); + expect(missing.status).toBe(401); + expect(missing.headers.get('www-authenticate')).toBe('Bearer'); + + const wrong = await handler( + new Request('http://localhost/api/health', { + headers: { authorization: 'Bearer wrong-secret' }, + }), + ); + expect(wrong.status).toBe(401); + + const ok = await handler( + new Request('http://localhost/api/health', { + headers: { authorization: 'Bearer mobile-secret' }, + }), + ); + expect(ok.status).toBe(200); + await expect(ok.json()).resolves.toEqual({ ok: true }); + }); + + it('accepts the mobile token header and leaves static assets readable', async () => { + const staticDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-dashboard-auth-'), + ); + tempDirs.push(staticDir); + fs.writeFileSync(path.join(staticDir, 'index.html'), '
ok
'); + const handler = createWebDashboardHandler({ + authToken: 'mobile-secret', + staticDir, + readStatusSnapshots: () => [], + getTasks: () => [], + startBackgroundCacheRefresh: false, + }); + + const asset = await handler(new Request('http://localhost/')); + expect(asset.status).toBe(200); + await expect(asset.text()).resolves.toContain('
ok
'); + + const api = await handler( + new Request('http://localhost/api/health', { + headers: { 'x-ejclaw-dashboard-token': 'mobile-secret' }, + }), + ); + expect(api.status).toBe(200); + }); +}); diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 250e2be..bbd066b 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; +import { timingSafeEqual } from 'crypto'; import { WEB_DASHBOARD } from './config.js'; import { @@ -67,6 +68,12 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = const UNIT_NOT_FOUND_PATTERN = /(Unit .* not found|Could not find the requested service|not-found)/i; +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + export type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; export interface WebDashboardHandlerOptions { @@ -138,6 +145,7 @@ export interface WebDashboardHandlerOptions { enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; nudgeScheduler?: () => void; restartServiceStack?: () => string[]; + authToken?: string; now?: () => string; } @@ -236,6 +244,43 @@ function serveStaticFile(staticDir: string, pathname: string): Response { }); } +function extractDashboardAuthToken(request: Request): string { + const authorization = request.headers.get('authorization') ?? ''; + const bearer = authorization.match(/^Bearer\s+(.+)$/i); + if (bearer) return bearer[1]!.trim(); + return request.headers.get('x-ejclaw-dashboard-token')?.trim() ?? ''; +} + +function safeTokenEquals(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + if (actualBytes.length !== expectedBytes.length) return false; + return timingSafeEqual(actualBytes, expectedBytes); +} + +function isDashboardApiAuthorized(request: Request, token: string): boolean { + if (!token) return true; + const actual = extractDashboardAuthToken(request); + return Boolean(actual) && safeTokenEquals(actual, token); +} + +function rejectUnauthorizedApi( + url: URL, + request: Request, + token: string, + jsonResponse: JsonResponse, +): Response | null { + if (!url.pathname.startsWith('/api/')) return null; + if (isDashboardApiAuthorized(request, token)) return null; + return jsonResponse( + { error: 'Unauthorized' }, + { + status: 401, + headers: { 'www-authenticate': 'Bearer' }, + }, + ); +} + function isRoot(): boolean { return typeof process.getuid === 'function' && process.getuid() === 0; } @@ -329,7 +374,7 @@ export function createWebDashboardHandler( const restartServiceStack = opts.restartServiceStack ?? restartEjclawStackServices; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; - + const authToken = opts.authToken ?? WEB_DASHBOARD.token; const roomsTimelineDeps: RoomsTimelineRouteDependencies = { statusMaxAgeMs, readSnapshots, @@ -342,19 +387,22 @@ export function createWebDashboardHandler( loadRecentDeliveredWorkItemsForChat, loadRecentChatMessages, }; - if (opts.startBackgroundCacheRefresh !== false) { startRoomsTimelineCacheRefresh(roomsTimelineDeps); } - const rememberRoomMessageId = createRoomMessageIdCache(); const inboxDismissTracker = createInboxDismissTracker(); const recentServiceRestarts: ServiceRestartRecord[] = []; const activeServiceRestartTargets = new Set(); - return async (request: Request): Promise => { const url = new URL(request.url); - + const authFailure = rejectUnauthorizedApi( + url, + request, + authToken, + jsonResponse, + ); + if (authFailure) return authFailure; const scheduledTaskRoute = await handleScheduledTaskRoute({ url, request, @@ -368,7 +416,6 @@ export function createWebDashboardHandler( now: opts.now, }); if (scheduledTaskRoute) return scheduledTaskRoute; - const inboxActionRoute = await handleInboxActionRoute({ url, request, @@ -384,7 +431,6 @@ export function createWebDashboardHandler( now: opts.now, }); if (inboxActionRoute) return inboxActionRoute; - const roomMessageRoute = await handleRoomMessageRoute({ url, request, @@ -398,7 +444,6 @@ export function createWebDashboardHandler( now: opts.now, }); if (roomMessageRoute) return roomMessageRoute; - const roomTimelineRoute = handleRoomTimelineRoute({ url, request, @@ -406,7 +451,6 @@ export function createWebDashboardHandler( ...roomsTimelineDeps, }); if (roomTimelineRoute) return roomTimelineRoute; - const serviceRoute = await handleServiceRoute({ url, request, @@ -417,18 +461,15 @@ export function createWebDashboardHandler( now: opts.now, }); if (serviceRoute) return serviceRoute; - const settingsRoute = await handleSettingsRoute({ url, request, jsonResponse, }); if (settingsRoute) return settingsRoute; - if (request.method !== 'GET' && request.method !== 'HEAD') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); } - const simpleGetRoute = handleSimpleGetRoute({ url, statusMaxAgeMs, @@ -437,7 +478,6 @@ export function createWebDashboardHandler( jsonResponse, }); if (simpleGetRoute) return simpleGetRoute; - const overviewRoute = handleOverviewRoute({ url, jsonResponse, @@ -450,17 +490,14 @@ export function createWebDashboardHandler( now: opts.now, }); if (overviewRoute) return overviewRoute; - if (url.pathname.startsWith('/api/')) { return jsonResponse({ error: 'Not found' }, { status: 404 }); } - if (!opts.staticDir) { return new Response('Dashboard static directory is not configured', { status: 404, }); } - return serveStaticFile(opts.staticDir, url.pathname); }; } diff --git a/src/workspace-package-manager.test.ts b/src/workspace-package-manager.test.ts index 6288b03..85a4fba 100644 --- a/src/workspace-package-manager.test.ts +++ b/src/workspace-package-manager.test.ts @@ -21,18 +21,18 @@ import { resolveWorkspaceInstallCommand, } from './workspace-package-manager.js'; -describe('workspace package manager helpers', () => { - let tempRoot: string; +let tempRoot: string; - beforeEach(() => { - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-')); - execFileSyncMock.mockReset(); - }); +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-')); + execFileSyncMock.mockReset(); +}); - afterEach(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); +afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); +describe('workspace package manager detection', () => { it('detects package managers from packageManager field and lockfiles', () => { const pnpmRepo = path.join(tempRoot, 'pnpm'); fs.mkdirSync(pnpmRepo, { recursive: true }); @@ -110,7 +110,9 @@ describe('workspace package manager helpers', () => { commandText: 'bun run build', }); }); +}); +describe('workspace dependency installs', () => { it('installs dependencies once and tracks install fingerprint', () => { const repoDir = path.join(tempRoot, 'repo'); fs.mkdirSync(repoDir, { recursive: true }); @@ -312,7 +314,9 @@ describe('workspace package manager helpers', () => { expect(execFileSyncMock).toHaveBeenCalledTimes(2); expect(hasInstalledNodeModules(repoDir)).toBe(true); }); +}); +describe('workspace package manager environment', () => { it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => { const parentDir = path.join(tempRoot, 'parent'); fs.mkdirSync(parentDir, { recursive: true });