eigent/backend/app/controller/workspace_bundle_controller.py
2026-08-17 15:44:42 +08:00

689 lines
24 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Capability-protected local API for review-first Bundle installation."""
from __future__ import annotations
import os
from dataclasses import asdict
from pathlib import Path
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from app.auth import require_local_control_principal
from app.component.environment import env
from app.router_layer.hands_resolver import get_environment_hands
from app.run_journal import (
IdempotencyConflictError,
InvalidRunTransitionError,
OptimisticConcurrencyError,
configured_run_journal_path,
get_default_run_journal,
)
from app.utils.workspace_resolver import get_workspace_resolver
from app.workspace_bundle import (
HttpWorkspaceBundleCloudTransport,
WorkspaceBundleBindingsIncomplete,
WorkspaceBundleCloudError,
WorkspaceBundleInstaller,
WorkspaceBundleInstallError,
WorkspaceSecretBroker,
WorkspaceSecretBrokerError,
WorkspaceSecretIdentity,
)
from app.workspace_bundle.mcp_destination import (
attestation_from_grants,
secret_binding_attestation,
secret_binding_attestation_from_grants,
)
from app.workspace_config import ConfigPlacement
from app.workspace_git import ConfigurationRepositoryService
router = APIRouter(dependencies=[Depends(require_local_control_principal)])
class BundleProposalBody(BaseModel):
proposal_id: str = Field(min_length=1, max_length=128)
request_id: str = Field(min_length=1, max_length=128)
space_id: str = Field(min_length=1, max_length=256)
publisher_namespace: str = Field(
min_length=1,
max_length=80,
pattern=r"^[a-z0-9][a-z0-9._-]{0,79}$",
)
slug: str = Field(
min_length=1,
max_length=80,
pattern=r"^[a-z0-9][a-z0-9._-]{0,79}$",
)
version: int = Field(ge=1)
config_placement: Literal["in_repo", "sidecar"] = "sidecar"
class BundleDecisionBody(BaseModel):
expected_version: int = Field(ge=0)
approved: bool
actor_id: str = Field(min_length=1, max_length=200)
class BundleConnectorBindingBody(BaseModel):
expected_version: int = Field(ge=0)
slot_id: str = Field(min_length=1, max_length=255)
connector_id: str = Field(min_length=1, max_length=255)
connection_id: str = Field(min_length=1, max_length=255)
actor_id: str = Field(min_length=1, max_length=200)
class BundleLocalPathBindingBody(BaseModel):
expected_version: int = Field(ge=0)
slot_id: str = Field(min_length=1, max_length=255)
local_path: str = Field(min_length=1, max_length=4096)
actor_id: str = Field(min_length=1, max_length=200)
class BundleScriptApprovalBody(BaseModel):
expected_version: int = Field(ge=0)
action_id: str = Field(min_length=1, max_length=1024)
actor_id: str = Field(min_length=1, max_length=200)
class BundleMaterializeBody(BaseModel):
expected_version: int = Field(ge=0)
email: str = Field(min_length=1, max_length=512)
user_id: str | int | None = None
actor_id: str = Field(min_length=1, max_length=200)
allow_content_repository_init: bool = False
class BundleLocalValueBinding(BaseModel):
model_config = ConfigDict(extra="forbid")
requirement_key: str = Field(min_length=1, max_length=1024)
requirement_kind: Literal["environment", "mcp_secret"]
secret_ref: str = Field(pattern=r"^wsvault_[A-Za-z0-9_-]{32}$")
account_scope_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
expected_binding_version: int | None = Field(default=None, ge=1)
class BundleLocalValuesBody(BaseModel):
model_config = ConfigDict(extra="forbid")
client_request_id: str = Field(min_length=1, max_length=200)
expected_version: int = Field(ge=0)
actor_id: str = Field(min_length=1, max_length=200)
bindings: list[BundleLocalValueBinding] = Field(
min_length=1,
max_length=100,
)
def _configuration_repository() -> ConfigurationRepositoryService:
journal = get_default_run_journal()
return ConfigurationRepositoryService(
journal,
state_root=configured_run_journal_path().parent / "workspace-git",
)
def _installer(cloud=None) -> WorkspaceBundleInstaller:
try:
secret_broker = WorkspaceSecretBroker.from_environment()
except WorkspaceSecretBrokerError:
secret_broker = None
return WorkspaceBundleInstaller(
get_default_run_journal(),
_configuration_repository(),
cloud,
secret_broker,
)
def _cloud(authorization: str) -> HttpWorkspaceBundleCloudTransport:
# The bearer credential may only be sent to the process-owned SERVER_URL.
# Renderer input cannot choose or override its destination.
server_url = env("SERVER_URL", "").strip()
if not server_url:
raise WorkspaceBundleInstallError("SERVER_URL is not configured")
return HttpWorkspaceBundleCloudTransport(
server_url=server_url,
authorization=authorization,
desktop_instance_id=os.environ.get("EIGENT_DESKTOP_INSTANCE_ID", ""),
)
def _payload(proposal_id: str) -> dict:
journal = get_default_run_journal()
proposal = journal.get_workspace_bundle_install_proposal(proposal_id)
if proposal is None:
raise HTTPException(
status_code=404,
detail={"code": "bundle_install_proposal_not_found"},
)
local_bindings = journal.list_workspace_bundle_local_bindings(proposal_id)
secret_bindings = journal.list_workspace_bundle_secret_bindings(
proposal_id
)
configured_values = {item.requirement_key for item in secret_bindings}
available_values: set[str] = set()
try:
broker = WorkspaceSecretBroker.from_environment()
except WorkspaceSecretBrokerError:
broker = None
if broker is not None and secret_bindings:
identities = tuple(
WorkspaceSecretIdentity(
secret_ref=item.secret_ref,
account_scope_digest=item.account_scope_digest,
space_id=proposal.space_id,
revision_id=proposal.revision_id,
slot_id=item.requirement_key,
)
for item in secret_bindings
)
try:
available_values.update(
verification.identity.slot_id
for verification in broker.verify_many(identities)
if verification.state == "available"
)
except WorkspaceSecretBrokerError:
# Availability is advisory in this payload, but must fail closed.
available_values.clear()
binding_versions = {
item.requirement_key: item.binding_version for item in secret_bindings
}
install_plan = proposal.install_plan
environment_requirements = install_plan.get("environment_requirements", [])
mcp_secret_requirements = install_plan.get("mcp_secret_requirements", [])
value_requirements = [
{
**item,
"requirement_kind": "environment",
"configured": item["requirement_key"] in configured_values,
"available": item["requirement_key"] in available_values,
"binding_version": binding_versions.get(item["requirement_key"]),
}
for item in environment_requirements
] + [
{
**item,
"requirement_kind": "mcp_secret",
"configured": item["requirement_key"] in configured_values,
"available": item["requirement_key"] in available_values,
"binding_version": binding_versions.get(item["requirement_key"]),
}
for item in mcp_secret_requirements
]
required = {
item["slot_id"] for item in install_plan.get("connector_slots", [])
}
required.update(install_plan.get("local_path_slots", []))
required.update(install_plan.get("script_actions", []))
required.update(
item["requirement_key"]
for item in environment_requirements
if item.get("required")
)
required.update(
item["requirement_key"] for item in mcp_secret_requirements
)
# Materialization verifies every binding, including optional values that
# the user chose to configure. Do not show Ready while such a binding is
# unreadable and would fail one step later.
required.update(configured_values)
destinations = {
str(item.get("mcp_id")): item
for item in install_plan.get("mcp_destinations", [])
if isinstance(item, dict) and item.get("mcp_id")
}
mcp_secret_keys = {
str(item.get("mcp_id")): {
str(requirement.get("requirement_key"))
for requirement in mcp_secret_requirements
if requirement.get("mcp_id") == item.get("mcp_id")
}
for item in install_plan.get("mcp_destinations", [])
if isinstance(item, dict) and item.get("mcp_id")
}
current_secret_approvals: dict[str, bool] = {}
serialized_bindings: list[dict] = []
for item in local_bindings:
serialized = asdict(item)
current = True
prefix = "mcp.server.start:"
if item.slot_id.startswith(prefix):
server_id = item.slot_id.removeprefix(prefix)
destination = destinations.get(server_id)
if destination and destination.get("requires_secret_confirmation"):
relevant = [
{
"requirement_key": secret.requirement_key,
"secret_ref": secret.secret_ref,
"binding_version": secret.binding_version,
"account_scope_digest": (secret.account_scope_digest),
}
for secret in secret_bindings
if secret.requirement_key
in mcp_secret_keys.get(server_id, set())
]
current = bool(
destination.get("attestation_digest")
and destination.get("availability_issue") is None
and {value["requirement_key"] for value in relevant}
== mcp_secret_keys.get(server_id, set())
and attestation_from_grants(item.required_grants)
== destination.get("attestation_digest")
and secret_binding_attestation_from_grants(
item.required_grants
)
== secret_binding_attestation(
mcp_id=server_id,
bindings=relevant,
)
)
current_secret_approvals[server_id] = current
serialized["current"] = current
serialized_bindings.append(serialized)
configured = {
item.slot_id
for item, serialized in zip(
local_bindings, serialized_bindings, strict=True
)
if serialized["current"]
}
configured.update(available_values)
missing = sorted(required - configured)
runtime_issues: list[str] = []
manifest_spec = proposal.manifest.get("spec", {})
manifest_connectors = manifest_spec.get("connectors")
manifest_mcp_servers = manifest_spec.get(
"mcpServers",
manifest_spec.get("mcp_servers"),
)
manifest_agents = manifest_spec.get("agents", [])
manifest_skills = manifest_spec.get("skills", [])
connector_slots = (
manifest_connectors
if isinstance(manifest_connectors, list)
else install_plan.get("connector_slots", [])
)
agent_entries = (
manifest_agents if isinstance(manifest_agents, list) else []
)
agent_ids = {
str(agent.get("id", ""))
for agent in agent_entries
if isinstance(agent, dict)
}
# Portable Bundle agent ids are logical names. A single logical Agent is
# mapped to Desktop's concrete ``single_agent`` runtime; only a true
# multi-Agent graph requires the future workforce adapter.
has_unsupported_agents = len(agent_ids) > 1
has_unmaterialized_registry_dependencies = bool(
isinstance(manifest_skills, list)
and any(
isinstance(skill, dict)
and isinstance(skill.get("ref"), str)
and not skill["ref"].startswith("bundle://")
for skill in manifest_skills
)
) or bool(
isinstance(manifest_mcp_servers, list)
and any(
isinstance(server, dict)
and isinstance(server.get("definition"), str)
and not server["definition"].startswith("bundle://")
for server in manifest_mcp_servers
)
)
if connector_slots:
runtime_issues.append("connector_runtime_adapter_unavailable")
secret_mcp_ids = {
str(item.get("mcp_id"))
for item in mcp_secret_requirements
if item.get("mcp_id")
}
if not secret_mcp_ids and isinstance(manifest_mcp_servers, list):
secret_mcp_ids = {
str(item.get("id"))
for item in manifest_mcp_servers
if isinstance(item, dict)
and item.get("id")
and item.get("secretSlots", item.get("secret_slots", []))
}
needs_mcp_confirmation = False
has_unavailable_secret_mcp = False
for server_id in sorted(secret_mcp_ids):
destination = destinations.get(server_id)
action_id = f"mcp.server.start:{server_id}"
binding_exists = any(
item.slot_id == action_id for item in local_bindings
)
if destination and destination.get("availability_issue"):
runtime_issues.append(
f"{destination['availability_issue']}:{server_id}"
)
has_unavailable_secret_mcp = True
elif not binding_exists:
runtime_issues.append(
f"mcp_destination_confirmation_required:{server_id}"
)
needs_mcp_confirmation = True
elif not current_secret_approvals.get(server_id, False):
runtime_issues.append(
f"mcp_destination_confirmation_stale:{server_id}"
)
needs_mcp_confirmation = True
else:
runtime_issues.append(
f"mcp_secret_stdio_runtime_adapter_unavailable:{server_id}"
)
has_unavailable_secret_mcp = True
if has_unsupported_agents:
runtime_issues.append("multi_agent_runtime_adapter_unavailable")
if has_unmaterialized_registry_dependencies:
runtime_issues.append("registry_dependencies_unmaterialized")
if missing:
runtime_issues.append("local_setup_incomplete")
is_materialized = proposal.state == "materialized"
if not is_materialized:
runtime_issues.append("workspace_bundle_not_materialized")
if (
connector_slots
or has_unsupported_agents
or has_unmaterialized_registry_dependencies
or has_unavailable_secret_mcp
or missing
or not is_materialized
):
runtime_readiness = "unavailable"
elif needs_mcp_confirmation:
runtime_readiness = "needs_confirmation"
else:
runtime_readiness = "ready"
return {
"proposal": asdict(proposal),
"bindings": serialized_bindings,
"value_requirements": value_requirements,
"readiness": {
"ready": not missing,
"missing_requirements": missing,
},
# File materialization and runtime dispatch readiness are deliberately
# separate. A materialized proposal may still require an MCP
# destination attestation or an executable connector adapter.
"runtime_readiness": runtime_readiness,
"runtime_readiness_issues": runtime_issues,
}
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, HTTPException):
return exc
if isinstance(exc, WorkspaceBundleCloudError):
return HTTPException(
status_code=exc.status_code,
detail={"code": "bundle_cloud_error", "upstream": exc.detail},
)
if isinstance(exc, WorkspaceBundleBindingsIncomplete):
return HTTPException(
status_code=409,
detail={
"code": "bundle_bindings_incomplete",
"missing_slots": list(exc.missing_slots),
},
)
if isinstance(
exc,
(
IdempotencyConflictError,
InvalidRunTransitionError,
OptimisticConcurrencyError,
),
):
return HTTPException(
status_code=409,
detail={"code": "bundle_install_conflict", "message": str(exc)},
)
if isinstance(exc, (WorkspaceBundleInstallError, ValueError)):
return HTTPException(
status_code=422,
detail={"code": "bundle_install_invalid", "message": str(exc)},
)
return HTTPException(
status_code=500,
detail={"code": "bundle_install_failed"},
)
def _authorized_local_path(request: Request, value: str) -> Path:
hands = getattr(request.state, "hands", None) or get_environment_hands()
validator = getattr(hands, "validate_workspace_binding_path", None)
if validator is not None:
ok, reason = validator(value)
if not ok:
raise WorkspaceBundleInstallError(
f"Local path is not allowed: {reason or 'path_not_allowed'}"
)
else:
can_access = getattr(hands, "can_access_filesystem", None)
if can_access is None or not can_access(value):
raise WorkspaceBundleInstallError(
"Local path is outside the Desktop filesystem capability"
)
return Path(value)
@router.post("/workspace-bundles/install-proposals")
async def propose_bundle_install(
body: BundleProposalBody,
authorization: Annotated[str, Header(alias="Authorization")],
) -> dict:
cloud = None
try:
cloud = _cloud(authorization)
await _installer(cloud).propose(
proposal_id=body.proposal_id,
request_id=body.request_id,
space_id=body.space_id,
publisher_namespace=body.publisher_namespace,
slug=body.slug,
version=body.version,
config_placement=ConfigPlacement(body.config_placement),
)
return _payload(body.proposal_id)
except Exception as exc:
raise _error(exc) from exc
finally:
if cloud is not None:
await cloud.close()
@router.get("/workspace-bundles/install-proposals/{proposal_id}")
async def get_bundle_install_proposal(proposal_id: str) -> dict:
return _payload(proposal_id)
@router.get("/spaces/{space_id}/workspace-bundle-installation")
async def get_space_bundle_installation(space_id: str) -> dict:
proposal = (
get_default_run_journal().get_latest_workspace_bundle_install_proposal(
space_id=space_id
)
)
if proposal is None:
# A locally-authored Space normally has no Bundle installation. This
# endpoint is an optional relationship lookup, so absence is a normal
# empty state rather than a missing-resource error. Keep the 404 on
# the proposal-id endpoint, where the caller asked for a concrete
# resource that does not exist.
return {"proposal": None}
return _payload(proposal.proposal_id)
@router.post("/workspace-bundles/install-proposals/{proposal_id}/decision")
async def decide_bundle_install(
proposal_id: str, body: BundleDecisionBody
) -> dict:
try:
_installer().decide(
proposal_id,
expected_version=body.expected_version,
approved=body.approved,
decided_by=body.actor_id,
)
return _payload(proposal_id)
except Exception as exc:
raise _error(exc) from exc
@router.post(
"/workspace-bundles/install-proposals/{proposal_id}/connector-bindings"
)
async def bind_bundle_connector(
proposal_id: str, body: BundleConnectorBindingBody
) -> dict:
try:
_installer().bind_connector(
proposal_id,
expected_version=body.expected_version,
slot_id=body.slot_id,
connector_id=body.connector_id,
opaque_connection_id=body.connection_id,
authorized_by=body.actor_id,
)
return _payload(proposal_id)
except Exception as exc:
raise _error(exc) from exc
@router.post(
"/workspace-bundles/install-proposals/{proposal_id}/local-path-bindings"
)
async def bind_bundle_local_path(
proposal_id: str, body: BundleLocalPathBindingBody, request: Request
) -> dict:
try:
_installer().bind_local_path(
proposal_id,
expected_version=body.expected_version,
slot_id=body.slot_id,
local_path=_authorized_local_path(request, body.local_path),
authorized_by=body.actor_id,
)
return _payload(proposal_id)
except Exception as exc:
raise _error(exc) from exc
@router.post(
"/workspace-bundles/install-proposals/{proposal_id}/script-approvals"
)
async def approve_bundle_script(
proposal_id: str, body: BundleScriptApprovalBody
) -> dict:
try:
_installer().approve_script_action(
proposal_id,
expected_version=body.expected_version,
action_id=body.action_id,
authorized_by=body.actor_id,
)
return _payload(proposal_id)
except Exception as exc:
raise _error(exc) from exc
@router.post("/workspace-bundles/install-proposals/{proposal_id}/materialize")
async def materialize_bundle(
proposal_id: str,
body: BundleMaterializeBody,
authorization: Annotated[str, Header(alias="Authorization")],
) -> dict:
proposal = get_default_run_journal().get_workspace_bundle_install_proposal(
proposal_id
)
if proposal is None:
return _payload(proposal_id)
binding = get_workspace_resolver().store.get_binding(
body.email,
proposal.space_id,
body.user_id,
)
if binding is None:
raise HTTPException(
status_code=404,
detail={"code": "workspace_binding_not_found"},
)
space_root = Path(binding.workspace_root).expanduser().resolve()
if not space_root.is_dir():
raise HTTPException(
status_code=409,
detail={"code": "workspace_binding_unavailable"},
)
cloud = None
try:
cloud = _cloud(authorization)
await _installer(cloud).materialize(
proposal_id,
expected_version=body.expected_version,
space_root=space_root,
actor_id=body.actor_id,
allow_content_repository_init=(body.allow_content_repository_init),
)
return _payload(proposal_id)
except Exception as exc:
raise _error(exc) from exc
finally:
if cloud is not None:
await cloud.close()
@router.put("/workspace-bundles/install-proposals/{proposal_id}/local-values")
async def bind_bundle_local_values(
proposal_id: str,
body: BundleLocalValuesBody,
) -> dict:
try:
journal = get_default_run_journal()
previous_refs = {
item.requirement_key: item.secret_ref
for item in journal.list_workspace_bundle_secret_bindings(
proposal_id
)
}
updated, _ = _installer().bind_local_values(
proposal_id,
client_request_id=body.client_request_id,
expected_version=body.expected_version,
bindings=[item.model_dump() for item in body.bindings],
authorized_by=body.actor_id,
)
payload = _payload(proposal_id)
payload["cleanup_secret_refs"] = sorted(
{
previous_refs[item.requirement_key]
for item in updated
if item.requirement_key in previous_refs
and previous_refs[item.requirement_key] != item.secret_ref
}
)
return payload
except Exception as exc:
raise _error(exc) from exc