fix: authenticate local run control APIs

This commit is contained in:
4pmtong 2026-08-05 23:57:25 +08:00
parent 71bc4d1efa
commit 14722667ee
12 changed files with 436 additions and 25 deletions

View file

@ -19,6 +19,12 @@ from app.auth.brain_auth import (
set_brain_auth_provider,
with_brain_auth_provider,
)
from app.auth.local_control import (
LOCAL_CONTROL_CAPABILITY_ENV,
LOCAL_CONTROL_CAPABILITY_HEADER,
LocalControlPrincipal,
require_local_control_principal,
)
from app.auth.interface import IAuthProvider, NoneAuth
__all__ = [
@ -29,4 +35,8 @@ __all__ = [
"get_brain_auth_provider",
"set_brain_auth_provider",
"with_brain_auth_provider",
"LOCAL_CONTROL_CAPABILITY_ENV",
"LOCAL_CONTROL_CAPABILITY_HEADER",
"LocalControlPrincipal",
"require_local_control_principal",
]

View file

@ -0,0 +1,104 @@
"""Authentication boundary for Desktop-local Run and Command control APIs."""
from __future__ import annotations
import hmac
import ipaddress
import os
from dataclasses import dataclass
from fastapi import HTTPException, Request
from app.auth.brain_auth import (
get_brain_auth_context,
get_brain_auth_provider,
)
from app.auth.interface import NoneAuth
LOCAL_CONTROL_CAPABILITY_ENV = "EIGENT_LOCAL_CONTROL_CAPABILITY"
LOCAL_CONTROL_CAPABILITY_HEADER = "X-Eigent-Local-Capability"
@dataclass(frozen=True)
class LocalControlPrincipal:
kind: str
user_id: str
def _is_loopback(host: str | None) -> bool:
if not host:
return False
if host.lower() == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
async def require_local_control_principal(
request: Request,
) -> LocalControlPrincipal:
"""Authorize the renderer capability or an authenticated remote Brain user.
Electron injects a random capability into the child Brain process and gives
it to the trusted renderer through IPC. It is deliberately separate from
Cloud device credentials, user bearer tokens, and Remote Control link tokens.
"""
expected = os.environ.get(LOCAL_CONTROL_CAPABILITY_ENV, "")
if expected:
if not _is_loopback(getattr(request.client, "host", None)):
raise HTTPException(
status_code=403,
detail={
"code": "local_control_loopback_required",
"message": "Desktop control APIs only accept loopback clients.",
},
)
presented = request.headers.get(LOCAL_CONTROL_CAPABILITY_HEADER, "")
if not presented or not hmac.compare_digest(presented, expected):
raise HTTPException(
status_code=401,
detail={
"code": "local_control_capability_required",
"message": "A valid Desktop control capability is required.",
},
)
principal = LocalControlPrincipal(kind="desktop_renderer", user_id="local")
request.state.local_control_principal = principal
return principal
if os.environ.get("EIGENT_RUNTIME", "").lower() == "electron":
raise HTTPException(
status_code=503,
detail={
"code": "local_control_capability_unconfigured",
"message": "Desktop control capability is not configured.",
},
)
# Non-Electron deployments must configure a real Brain auth provider.
# Header presence alone is not authentication while NoneAuth is active.
if isinstance(get_brain_auth_provider(), NoneAuth):
raise HTTPException(
status_code=503,
detail={
"code": "local_control_auth_unconfigured",
"message": "Control API authentication is not configured.",
},
)
brain_auth = await get_brain_auth_context(request)
if not brain_auth.authorization_present:
raise HTTPException(
status_code=401,
detail={
"code": "brain_auth_required_for_control",
"message": "Brain authentication is required for control APIs.",
},
)
principal = LocalControlPrincipal(
kind="brain_user", user_id=brain_auth.user_id
)
request.state.local_control_principal = principal
return principal

View file

@ -8,16 +8,20 @@ from dataclasses import asdict
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.auth import require_local_control_principal
from app.run_journal import get_default_run_journal
from app.run_sync.runtime import (
notify_default_cloud_sync_worker,
persist_and_confirm_remote_command,
)
router = APIRouter(prefix="/remote-control/commands")
router = APIRouter(
prefix="/remote-control/commands",
dependencies=[Depends(require_local_control_principal)],
)
_HIGH_RISK_COMMAND_TYPES = {
"stop",

View file

@ -29,10 +29,11 @@ from contextlib import suppress
from dataclasses import asdict
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.auth import require_local_control_principal
from app.run_journal import (
CommittedRunEvent,
IdempotencyConflictError,
@ -54,7 +55,7 @@ from app.run_runtime import (
get_default_run_coordinator,
)
router = APIRouter()
router = APIRouter(dependencies=[Depends(require_local_control_principal)])
_EVENT_PAGE_SIZE = 500
_DEFAULT_HEARTBEAT_SECONDS = 15.0

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from app.auth.brain_auth import with_brain_auth_provider
from app.auth.interface import IAuthProvider
from app.auth.local_control import (
LOCAL_CONTROL_CAPABILITY_HEADER,
require_local_control_principal,
)
from app.controller import remote_command_controller, run_controller
class _VerifiedAuth(IAuthProvider):
async def authenticate(self, scope):
_ = scope
return {"user_id": "user-1", "tenant_id": "tenant-1"}
class _UnexpectedCloudAuth(IAuthProvider):
async def authenticate(self, scope):
_ = scope
raise AssertionError("Desktop capability must not invoke Cloud auth")
def _app() -> FastAPI:
app = FastAPI()
@app.get("/control")
async def control(request: Request):
return await require_local_control_principal(request)
return app
def test_electron_control_requires_matching_loopback_capability(monkeypatch):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
client = TestClient(_app(), client=("127.0.0.1", 50000))
assert client.get("/control").status_code == 401
assert (
client.get(
"/control", headers={LOCAL_CONTROL_CAPABILITY_HEADER: "wrong"}
).status_code
== 401
)
response = client.get(
"/control", headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"}
)
assert response.status_code == 200
assert response.json()["kind"] == "desktop_renderer"
def test_device_and_link_identity_do_not_replace_renderer_capability(monkeypatch):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
client = TestClient(_app(), client=("127.0.0.1", 50000))
response = client.get(
"/control",
headers={
"Authorization": "Bearer cloud-user-token",
"X-Desktop-Instance-ID": "device-1",
"X-Remote-Control-Token": "link-1",
},
)
assert response.status_code == 401
def test_desktop_capability_is_independent_from_cloud_auth(monkeypatch):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
client = TestClient(_app(), client=("127.0.0.1", 50000))
with with_brain_auth_provider(_UnexpectedCloudAuth()):
response = client.get(
"/control",
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
)
assert response.status_code == 200
def test_electron_control_fails_closed_when_capability_is_missing(monkeypatch):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.delenv("EIGENT_LOCAL_CONTROL_CAPABILITY", raising=False)
response = TestClient(_app()).get(
"/control", headers={"Authorization": "Bearer cloud-user-token"}
)
assert response.status_code == 503
def test_non_loopback_cannot_use_desktop_capability(monkeypatch):
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
client = TestClient(_app(), client=("203.0.113.8", 50000))
response = client.get(
"/control", headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"}
)
assert response.status_code == 403
def test_rotated_desktop_capability_rejects_the_previous_process_token(
monkeypatch,
):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "startup-1")
client = TestClient(_app(), client=("127.0.0.1", 50000))
assert (
client.get(
"/control",
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "startup-1"},
).status_code
== 200
)
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "startup-2")
assert (
client.get(
"/control",
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "startup-1"},
).status_code
== 401
)
assert (
client.get(
"/control",
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "startup-2"},
).status_code
== 200
)
def test_non_electron_control_requires_brain_authorization(monkeypatch):
monkeypatch.delenv("EIGENT_RUNTIME", raising=False)
monkeypatch.delenv("EIGENT_LOCAL_CONTROL_CAPABILITY", raising=False)
client = TestClient(_app())
assert client.get("/control").status_code == 503
assert (
client.get(
"/control", headers={"Authorization": "Bearer unverified-token"}
).status_code
== 503
)
with with_brain_auth_provider(_VerifiedAuth()):
assert client.get("/control").status_code == 401
response = client.get(
"/control", headers={"Authorization": "Bearer verified-token"}
)
assert response.status_code == 200
assert response.json()["kind"] == "brain_user"
def test_run_and_command_routers_enforce_the_control_principal(monkeypatch):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
app = FastAPI()
app.include_router(run_controller.router)
app.include_router(remote_command_controller.router)
client = TestClient(app, client=("127.0.0.1", 50000))
journal = MagicMock()
journal.get_run.return_value = None
journal.list_reconcilable_commands.return_value = []
assert client.get("/runs/missing").status_code == 401
assert client.get("/remote-control/commands/inbox/pending").status_code == 401
with (
patch(
"app.controller.run_controller.get_default_run_journal",
return_value=journal,
),
patch(
"app.controller.remote_command_controller.get_default_run_journal",
return_value=journal,
),
):
headers = {LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"}
assert client.get("/runs/missing", headers=headers).status_code == 404
response = client.get(
"/remote-control/commands/inbox/pending", headers=headers
)
assert response.status_code == 200
assert response.json() == {"items": []}

View file

@ -93,6 +93,7 @@ let fileReader: FileReader | null = null;
let python_process: ChildProcessWithoutNullStreams | null = null;
let backendPort: number = 5001;
let backendStartPromise: Promise<BackendStartResult> | null = null;
const localControlCapability = crypto.randomBytes(32).toString('base64url');
let browser_port = 9222;
let use_external_cdp = false;
let proxyUrl: string | null = null;
@ -1059,6 +1060,14 @@ function registerIpcHandlers() {
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-local-control-capability', (event) => {
if (!win || event.sender.id !== win.webContents.id) {
throw new Error(
'Local control capability is restricted to the main renderer'
);
}
return localControlCapability;
});
// ==================== restart app handler ====================
ipcMain.handle('restart-app', async () => {
@ -3169,6 +3178,7 @@ const checkAndStartBackend = async (
{
...codexResolverEnv,
EIGENT_EXAMPLE_SKILLS_DIR: exampleSkillsDir,
EIGENT_LOCAL_CONTROL_CAPABILITY: localControlCapability,
}
);

View file

@ -118,6 +118,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
checkInstallBrowser: () => ipcRenderer.invoke('check-install-browser'),
getInstallationStatus: () => ipcRenderer.invoke('get-installation-status'),
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
getLocalControlCapability: () =>
ipcRenderer.invoke('get-local-control-capability'),
restartBackend: () => ipcRenderer.invoke('restart-backend'),
onInstallDependenciesStart: (callback: () => void) => {
ipcRenderer.on('install-dependencies-start', callback);

View file

@ -30,6 +30,32 @@ import {
const defaultHeaders = {
'Content-Type': 'application/json',
};
const LOCAL_CONTROL_CAPABILITY_HEADER = 'X-Eigent-Local-Capability';
let localControlCapabilityPromise: Promise<string> | null = null;
export async function getLocalControlCapability(): Promise<string> {
const api = createHost().electronAPI;
if (!api?.getLocalControlCapability) {
return '';
}
if (!localControlCapabilityPromise) {
localControlCapabilityPromise = Promise.resolve(
api.getLocalControlCapability()
).then(
(token) => {
if (!token) {
localControlCapabilityPromise = null;
}
return token || '';
},
() => {
localControlCapabilityPromise = null;
return '';
}
);
}
return localControlCapabilityPromise;
}
export function getDefaultBrainEndpoint(): string {
const envEndpoint = import.meta.env.VITE_BRAIN_ENDPOINT;
@ -60,11 +86,11 @@ function shouldAttachAuthHeader(url: string): boolean {
return !url.includes('http://') && !url.includes('https://');
}
function buildBrainHeaders(
async function buildBrainHeaders(
url: string,
customHeaders: Record<string, string> = {},
includeContentType = true
): Record<string, string> {
): Promise<Record<string, string>> {
const { token, user_id } = getAuthStore();
const conn = getConnectionConfig();
const headers: Record<string, string> = {
@ -80,6 +106,10 @@ function buildBrainHeaders(
}
if (shouldAttachAuthHeader(url)) {
headers['X-Desktop-Instance-ID'] = getDesktopInstanceId();
const localControlCapability = await getLocalControlCapability();
if (localControlCapability) {
headers[LOCAL_CONTROL_CAPABILITY_HEADER] = localControlCapability;
}
}
if (user_id != null) {
headers['X-User-ID'] = String(user_id);
@ -127,7 +157,7 @@ async function fetchRequest(
): Promise<any> {
const baseURL = await getBaseURL();
const fullUrl = `${baseURL}${url}`;
const headers = buildBrainHeaders(url, customHeaders);
const headers = await buildBrainHeaders(url, customHeaders);
const options: RequestInit = {
method,
@ -291,7 +321,7 @@ export async function fetchPostForm(
): Promise<any> {
const baseURL = await getBaseURL();
const fullUrl = `${baseURL}${url}`;
const headers = buildBrainHeaders(url, customHeaders, false);
const headers = await buildBrainHeaders(url, customHeaders, false);
return handleResponse(
fetch(fullUrl, { method: 'POST', headers, body: formData })
);
@ -329,7 +359,7 @@ export async function sseTransport(
? options.url
: `${baseURL}${options.url}`;
const headers = buildBrainHeaders(options.url, options.extraHeaders);
const headers = await buildBrainHeaders(options.url, options.extraHeaders);
const body =
typeof options.body === 'string'
? options.body

View file

@ -12,7 +12,13 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { getBaseURL, proxyFetchGet, proxyFetchPost } from '@/api/http';
import {
fetchGet,
fetchPost,
getBaseURL,
getLocalControlCapability,
proxyFetchGet,
} from '@/api/http';
import { isDesktop } from '@/client/platform';
import {
getRemoteControlDesktopInstanceId,
@ -290,12 +296,16 @@ async function requestBrain(
);
try {
const baseURL = await getBaseURL();
const localControlCapability = await getLocalControlCapability();
const response = await fetch(`${baseURL}${path}`, {
method,
signal: controller.signal,
headers: {
...brainHeaders(command),
Authorization: `Bearer ${token}`,
...(localControlCapability
? { 'X-Eigent-Local-Capability': localControlCapability }
: {}),
},
body: body ? JSON.stringify(body) : undefined,
});
@ -994,7 +1004,7 @@ export function useRemoteControlBridge(token: string | null | undefined) {
body: CommandResultBody
) => {
queuePendingCommandResult({ command, body });
await proxyFetchPost(
await fetchPost(
`/remote-control/commands/${encodeURIComponent(command.id)}/result`,
body,
brainHeaders(command)
@ -1018,7 +1028,7 @@ export function useRemoteControlBridge(token: string | null | undefined) {
const persistCommandAndExecute = async (
command: RemoteCommand
): Promise<BridgeAck> => {
const persisted = await proxyFetchPost(
const persisted = await fetchPost(
'/remote-control/commands/inbox',
command,
brainHeaders(command)
@ -1083,7 +1093,7 @@ export function useRemoteControlBridge(token: string | null | undefined) {
};
}
if (!persisted?.may_execute) {
await proxyFetchPost(
await fetchPost(
`/remote-control/commands/${encodeURIComponent(command.id)}/admission`,
{
status: 'rejected',
@ -1101,7 +1111,7 @@ export function useRemoteControlBridge(token: string | null | undefined) {
};
}
await proxyFetchPost(
await fetchPost(
`/remote-control/commands/${encodeURIComponent(command.id)}/admission`,
{
status: 'accepted',
@ -1191,7 +1201,7 @@ export function useRemoteControlBridge(token: string | null | undefined) {
const replayDurableInbox = async () => {
try {
const response = await proxyFetchGet(
const response = await fetchGet(
'/remote-control/commands/inbox/pending',
{ limit: 100 }
);

View file

@ -150,6 +150,7 @@ interface ElectronAPI {
error?: string;
}>;
getBackendPort: () => Promise<number | null>;
getLocalControlCapability: () => Promise<string>;
restartBackend: () => Promise<{ success: boolean; error?: string }>;
onInstallDependenciesStart: (callback: () => void) => void;
onInstallDependenciesLog: (

View file

@ -18,20 +18,34 @@ vi.mock('@/store/authStore', () => ({
getAuthStore: () => ({ token: null }),
}));
const showCreditsToast = vi.fn();
const showStorageToast = vi.fn();
const showTrafficToast = vi.fn();
const mocked = vi.hoisted(() => ({
getLocalControlCapability: vi.fn(() =>
Promise.resolve('renderer-capability')
),
showCreditsToast: vi.fn(),
showStorageToast: vi.fn(),
showTrafficToast: vi.fn(),
}));
vi.mock('@/host/createHost', () => ({
createHost: () => ({
electronAPI: {
getLocalControlCapability: mocked.getLocalControlCapability,
},
ipcRenderer: null,
}),
}));
vi.mock('@/components/Toast/creditsToast', () => ({
showCreditsToast,
showCreditsToast: mocked.showCreditsToast,
}));
vi.mock('@/components/Toast/storageToast', () => ({
showStorageToast,
showStorageToast: mocked.showStorageToast,
}));
vi.mock('@/components/Toast/trafficToast', () => ({
showTrafficToast,
showTrafficToast: mocked.showTrafficToast,
}));
import { fetchPost, getBaseURL } from '@/api/http';
@ -47,9 +61,9 @@ describe('api/http handleResponse', () => {
brainEndpoint: 'http://brain.local',
channel: 'web',
});
showCreditsToast.mockClear();
showStorageToast.mockClear();
showTrafficToast.mockClear();
mocked.showCreditsToast.mockClear();
mocked.showStorageToast.mockClear();
mocked.showTrafficToast.mockClear();
vi.restoreAllMocks();
});
@ -74,7 +88,28 @@ describe('api/http handleResponse', () => {
const res = await fetchPost('/chat', { question: 'x' });
expect(res.code).toBe(20);
expect(showCreditsToast).toHaveBeenCalledTimes(1);
expect(mocked.showCreditsToast).toHaveBeenCalledTimes(1);
});
it('attaches the ephemeral renderer capability to Brain requests', async () => {
const request = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(null, { status: 204 }));
await fetchPost(
'/runs/run-1/cancel',
{ request_id: 'cancel-1' },
{ 'X-Eigent-Local-Capability': 'forged-capability' }
);
expect(request).toHaveBeenCalledWith(
'http://brain.local/runs/run-1/cancel',
expect.objectContaining({
headers: expect.objectContaining({
'X-Eigent-Local-Capability': 'renderer-capability',
}),
})
);
});
});

View file

@ -16,9 +16,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/http', () => ({
fetchDelete: vi.fn(),
fetchGet: vi.fn(() => Promise.resolve({ items: [] })),
fetchPost: vi.fn(),
fetchPut: vi.fn(),
getBaseURL: vi.fn(() => Promise.resolve('')),
getLocalControlCapability: vi.fn(() =>
Promise.resolve('renderer-capability')
),
proxyFetchGet: vi.fn(() => Promise.resolve({ items: [] })),
proxyFetchPost: vi.fn(() => Promise.resolve({ id: 'history-id' })),
proxyFetchPut: vi.fn(),
@ -120,6 +124,13 @@ describe('useRemoteControlBridge internals', () => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/chat/project-target/status');
expect(fetchSpy.mock.calls[1]?.[0]).toBe('/chat/project-target');
expect(fetchSpy.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
'X-Eigent-Local-Capability': 'renderer-capability',
}),
})
);
});
it('starts local user_message tasks against the target Project without switching foreground Project', async () => {