diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index c9a3d5a5..45e5c0ec 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -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", ] diff --git a/backend/app/auth/local_control.py b/backend/app/auth/local_control.py new file mode 100644 index 00000000..e7fc279a --- /dev/null +++ b/backend/app/auth/local_control.py @@ -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 diff --git a/backend/app/controller/remote_command_controller.py b/backend/app/controller/remote_command_controller.py index 5e03f995..55843a84 100644 --- a/backend/app/controller/remote_command_controller.py +++ b/backend/app/controller/remote_command_controller.py @@ -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", diff --git a/backend/app/controller/run_controller.py b/backend/app/controller/run_controller.py index c662705b..741f87d9 100644 --- a/backend/app/controller/run_controller.py +++ b/backend/app/controller/run_controller.py @@ -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 diff --git a/backend/tests/app/auth/test_local_control.py b/backend/tests/app/auth/test_local_control.py new file mode 100644 index 00000000..80931b64 --- /dev/null +++ b/backend/tests/app/auth/test_local_control.py @@ -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": []} diff --git a/electron/main/index.ts b/electron/main/index.ts index 397e30f8..90078c6c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -93,6 +93,7 @@ let fileReader: FileReader | null = null; let python_process: ChildProcessWithoutNullStreams | null = null; let backendPort: number = 5001; let backendStartPromise: Promise | 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, } ); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index caa4d5cf..63cf9a67 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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); diff --git a/src/api/http.ts b/src/api/http.ts index cdd5dd1a..cb6932e4 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -30,6 +30,32 @@ import { const defaultHeaders = { 'Content-Type': 'application/json', }; +const LOCAL_CONTROL_CAPABILITY_HEADER = 'X-Eigent-Local-Capability'; +let localControlCapabilityPromise: Promise | null = null; + +export async function getLocalControlCapability(): Promise { + 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 = {}, includeContentType = true -): Record { +): Promise> { const { token, user_id } = getAuthStore(); const conn = getConnectionConfig(); const headers: Record = { @@ -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 { 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 { 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 diff --git a/src/hooks/useRemoteControlBridge.ts b/src/hooks/useRemoteControlBridge.ts index 1cb19e29..393aa0d6 100644 --- a/src/hooks/useRemoteControlBridge.ts +++ b/src/hooks/useRemoteControlBridge.ts @@ -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 => { - 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 } ); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 93dd4dc2..55d25de8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -150,6 +150,7 @@ interface ElectronAPI { error?: string; }>; getBackendPort: () => Promise; + getLocalControlCapability: () => Promise; restartBackend: () => Promise<{ success: boolean; error?: string }>; onInstallDependenciesStart: (callback: () => void) => void; onInstallDependenciesLog: ( diff --git a/test/unit/api/http.test.ts b/test/unit/api/http.test.ts index 781e49d4..aa1f06f4 100644 --- a/test/unit/api/http.test.ts +++ b/test/unit/api/http.test.ts @@ -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', + }), + }) + ); }); }); diff --git a/test/unit/hooks/useRemoteControlBridge.test.ts b/test/unit/hooks/useRemoteControlBridge.test.ts index e1229b1a..ab9b5d1d 100644 --- a/test/unit/hooks/useRemoteControlBridge.test.ts +++ b/test/unit/hooks/useRemoteControlBridge.test.ts @@ -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 () => {