mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-17 20:53:30 +00:00
fix: authenticate local run control APIs
This commit is contained in:
parent
71bc4d1efa
commit
14722667ee
12 changed files with 436 additions and 25 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
104
backend/app/auth/local_control.py
Normal file
104
backend/app/auth/local_control.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
193
backend/tests/app/auth/test_local_control.py
Normal file
193
backend/tests/app/auth/test_local_control.py
Normal 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": []}
|
||||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue