feat: add dashboard token auth for android clients

This commit is contained in:
ejclaw
2026-05-25 03:27:49 +09:00
parent 8ef0f5630a
commit fe106645f9
8 changed files with 242 additions and 25 deletions

View File

@@ -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:

View File

@@ -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 <token>`.
- 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`.

View File

@@ -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',
});
});

View File

@@ -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),

View File

@@ -83,6 +83,7 @@ export interface AppConfig {
host: string;
port: number;
staticDir: string;
token: string;
};
codexWarmup: {
enabled: boolean;

View File

@@ -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'), '<main>ok</main>');
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('<main>ok</main>');
const api = await handler(
new Request('http://localhost/api/health', {
headers: { 'x-ejclaw-dashboard-token': 'mobile-secret' },
}),
);
expect(api.status).toBe(200);
});
});

View File

@@ -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<string>();
return async (request: Request): Promise<Response> => {
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);
};
}

View File

@@ -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 });