Merge pull request #156 from phj1081/codex/owner/ejclaw
feat: add dashboard token auth for android clients
This commit is contained in:
@@ -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:
|
||||
|
||||
94
docs/android-rayban-display-plan.md
Normal file
94
docs/android-rayban-display-plan.md
Normal 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`.
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface AppConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
staticDir: string;
|
||||
token: string;
|
||||
};
|
||||
codexWarmup: {
|
||||
enabled: boolean;
|
||||
|
||||
72
src/web-dashboard-auth.test.ts
Normal file
72
src/web-dashboard-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user