mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 17:41:56 +00:00
1011 lines
38 KiB
Python
1011 lines
38 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. =========
|
|
|
|
"""Review-first local Workspace Bundle installation and materialization."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app.run_journal import (
|
|
InvalidRunTransitionError,
|
|
SQLiteRunJournal,
|
|
WorkspaceBundleInstallProposalRecord,
|
|
WorkspaceBundleLocalBindingRecord,
|
|
WorkspaceBundleSecretBindingRecord,
|
|
)
|
|
from app.workspace_bundle.cloud import WorkspaceBundleCloudTransport
|
|
from app.workspace_bundle.mcp_destination import (
|
|
McpDestinationError,
|
|
attestation_grant,
|
|
inspect_bundle_mcp_destination,
|
|
registry_unavailable_destination,
|
|
secret_binding_attestation,
|
|
secret_binding_grant,
|
|
)
|
|
from app.workspace_bundle.secrets import (
|
|
WorkspaceSecretBroker,
|
|
WorkspaceSecretBrokerError,
|
|
WorkspaceSecretIdentity,
|
|
)
|
|
from app.workspace_config import (
|
|
ConfigPlacement,
|
|
SecretValueInManifestError,
|
|
WorkspaceBundleManifest,
|
|
assert_bundle_asset_safe,
|
|
canonical_digest,
|
|
)
|
|
from app.workspace_git import ConfigurationRepositoryService
|
|
|
|
|
|
class WorkspaceBundleInstallError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class WorkspaceBundleBindingsIncomplete(WorkspaceBundleInstallError):
|
|
def __init__(self, missing_slots: list[str]) -> None:
|
|
self.missing_slots = tuple(sorted(missing_slots))
|
|
super().__init__(
|
|
"Bundle installation is missing explicit bindings: "
|
|
+ ", ".join(self.missing_slots)
|
|
)
|
|
|
|
|
|
class WorkspaceBundleInstaller:
|
|
MAX_TOTAL_ASSET_BYTES = 128 * 1024 * 1024
|
|
REQUIRED_CLOUD_CAPABILITIES = frozenset(
|
|
{
|
|
"bundle.asset.integrity.v1",
|
|
"bundle.install.review.v1",
|
|
}
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
journal: SQLiteRunJournal,
|
|
configuration_repository: ConfigurationRepositoryService,
|
|
cloud: WorkspaceBundleCloudTransport | None,
|
|
secret_broker: WorkspaceSecretBroker | None = None,
|
|
) -> None:
|
|
self.journal = journal
|
|
self.configuration_repository = configuration_repository
|
|
self.cloud = cloud
|
|
self.secret_broker = secret_broker
|
|
|
|
async def propose(
|
|
self,
|
|
*,
|
|
proposal_id: str,
|
|
request_id: str,
|
|
space_id: str,
|
|
publisher_namespace: str,
|
|
slug: str,
|
|
version: int,
|
|
config_placement: ConfigPlacement,
|
|
) -> WorkspaceBundleInstallProposalRecord:
|
|
if self.cloud is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle Cloud transport is unavailable"
|
|
)
|
|
revision = await self.cloud.get_catalog_revision(
|
|
publisher_namespace,
|
|
slug,
|
|
version,
|
|
)
|
|
if revision.get("status") != "published":
|
|
raise WorkspaceBundleInstallError(
|
|
"Only published Bundle revisions can be installed"
|
|
)
|
|
manifest_value = revision.get("manifest")
|
|
if not isinstance(manifest_value, dict):
|
|
raise WorkspaceBundleInstallError("Bundle manifest is missing")
|
|
manifest = WorkspaceBundleManifest.model_validate(manifest_value)
|
|
bundle_id = revision.get("bundle_id")
|
|
revision_id = revision.get("revision_id")
|
|
if (
|
|
not isinstance(bundle_id, str)
|
|
or not isinstance(revision_id, str)
|
|
or manifest.metadata.id != slug
|
|
or manifest.metadata.revision != version
|
|
or revision.get("publisher_namespace") != publisher_namespace
|
|
or revision.get("slug") != slug
|
|
or revision.get("version") != version
|
|
):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle revision identity mismatch"
|
|
)
|
|
if revision.get("manifest_digest") != manifest.digest:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle manifest digest mismatch"
|
|
)
|
|
assets = revision.get("assets", [])
|
|
if not isinstance(assets, list):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset manifest is invalid"
|
|
)
|
|
normalized_assets = [
|
|
self._validate_asset_descriptor(item) for item in assets
|
|
]
|
|
logical_paths = [item["logical_path"] for item in normalized_assets]
|
|
if len(set(logical_paths)) != len(logical_paths):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset logical paths must be unique"
|
|
)
|
|
mcp_destinations = await self._inspect_mcp_destinations(
|
|
manifest,
|
|
normalized_assets,
|
|
)
|
|
install_plan = self._install_plan(
|
|
manifest,
|
|
normalized_assets,
|
|
mcp_destinations=mcp_destinations,
|
|
)
|
|
install_plan["public_coordinate"] = (
|
|
f"@{publisher_namespace}/{slug}@{version}"
|
|
)
|
|
return self.journal.put_workspace_bundle_install_proposal(
|
|
proposal_id=proposal_id,
|
|
request_id=request_id,
|
|
space_id=space_id,
|
|
bundle_id=bundle_id,
|
|
revision_id=revision_id,
|
|
config_placement=config_placement.value,
|
|
manifest=manifest.canonical_payload(),
|
|
assets=normalized_assets,
|
|
install_plan=install_plan,
|
|
)
|
|
|
|
def decide(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
expected_version: int,
|
|
approved: bool,
|
|
decided_by: str,
|
|
) -> WorkspaceBundleInstallProposalRecord:
|
|
return self.journal.transition_workspace_bundle_install_proposal(
|
|
proposal_id,
|
|
expected_version=expected_version,
|
|
state="approved" if approved else "rejected",
|
|
decided_by=decided_by,
|
|
)
|
|
|
|
def bind_connector(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
expected_version: int,
|
|
slot_id: str,
|
|
connector_id: str,
|
|
opaque_connection_id: str,
|
|
authorized_by: str,
|
|
) -> tuple[
|
|
WorkspaceBundleLocalBindingRecord,
|
|
WorkspaceBundleInstallProposalRecord,
|
|
]:
|
|
proposal = self._proposal(proposal_id)
|
|
connector = next(
|
|
(
|
|
item
|
|
for item in proposal.install_plan["connector_slots"]
|
|
if item["slot_id"] == slot_id
|
|
),
|
|
None,
|
|
)
|
|
if connector is None or connector["connector_id"] != connector_id:
|
|
raise WorkspaceBundleInstallError(
|
|
"Connector does not match a declared Bundle slot"
|
|
)
|
|
return self.journal.put_workspace_bundle_local_binding(
|
|
proposal_id=proposal_id,
|
|
expected_proposal_version=expected_version,
|
|
slot_id=slot_id,
|
|
binding_kind="connector",
|
|
connector_id=connector_id,
|
|
opaque_connection_id=opaque_connection_id,
|
|
local_path=None,
|
|
required_grants=connector["required_grants"],
|
|
authorized_by=authorized_by,
|
|
)
|
|
|
|
def bind_local_path(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
expected_version: int,
|
|
slot_id: str,
|
|
local_path: Path,
|
|
authorized_by: str,
|
|
) -> tuple[
|
|
WorkspaceBundleLocalBindingRecord,
|
|
WorkspaceBundleInstallProposalRecord,
|
|
]:
|
|
proposal = self._proposal(proposal_id)
|
|
if slot_id not in proposal.install_plan["local_path_slots"]:
|
|
raise WorkspaceBundleInstallError(
|
|
"Local path does not match a declared Bundle slot"
|
|
)
|
|
resolved = local_path.expanduser().resolve()
|
|
if not resolved.is_dir():
|
|
raise WorkspaceBundleInstallError("Local path must be a directory")
|
|
return self.journal.put_workspace_bundle_local_binding(
|
|
proposal_id=proposal_id,
|
|
expected_proposal_version=expected_version,
|
|
slot_id=slot_id,
|
|
binding_kind="local_path",
|
|
connector_id=None,
|
|
opaque_connection_id=None,
|
|
local_path=str(resolved),
|
|
required_grants=[],
|
|
authorized_by=authorized_by,
|
|
)
|
|
|
|
def approve_script_action(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
expected_version: int,
|
|
action_id: str,
|
|
authorized_by: str,
|
|
) -> tuple[
|
|
WorkspaceBundleLocalBindingRecord,
|
|
WorkspaceBundleInstallProposalRecord,
|
|
]:
|
|
proposal = self._proposal(proposal_id)
|
|
if action_id not in proposal.install_plan["script_actions"]:
|
|
raise WorkspaceBundleInstallError(
|
|
"Script action is not declared by this Bundle"
|
|
)
|
|
required_grants: list[str] = []
|
|
if action_id.startswith("mcp.server.start:"):
|
|
server_id = action_id.removeprefix("mcp.server.start:")
|
|
destination = next(
|
|
(
|
|
item
|
|
for item in proposal.install_plan.get(
|
|
"mcp_destinations", []
|
|
)
|
|
if item.get("mcp_id") == server_id
|
|
),
|
|
None,
|
|
)
|
|
if destination is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"MCP destination review is unavailable"
|
|
)
|
|
if destination.get("requires_secret_confirmation"):
|
|
digest = destination.get("attestation_digest")
|
|
if not isinstance(digest, str):
|
|
issue = destination.get("availability_issue") or (
|
|
"mcp_destination_confirmation_required"
|
|
)
|
|
raise WorkspaceBundleInstallError(str(issue))
|
|
required_grants.append(attestation_grant(digest))
|
|
secret_bindings = (
|
|
self.journal.list_workspace_bundle_secret_bindings(
|
|
proposal_id
|
|
)
|
|
)
|
|
expected_keys = {
|
|
item["requirement_key"]
|
|
for item in proposal.install_plan.get(
|
|
"mcp_secret_requirements", []
|
|
)
|
|
if item.get("mcp_id") == server_id
|
|
}
|
|
current = {
|
|
item.requirement_key: item
|
|
for item in secret_bindings
|
|
if item.requirement_key in expected_keys
|
|
}
|
|
if set(current) != expected_keys:
|
|
raise WorkspaceBundleBindingsIncomplete(
|
|
sorted(expected_keys - set(current))
|
|
)
|
|
binding_digest = secret_binding_attestation(
|
|
mcp_id=server_id,
|
|
bindings=[
|
|
{
|
|
"requirement_key": item.requirement_key,
|
|
"secret_ref": item.secret_ref,
|
|
"binding_version": item.binding_version,
|
|
"account_scope_digest": (
|
|
item.account_scope_digest
|
|
),
|
|
}
|
|
for item in current.values()
|
|
],
|
|
)
|
|
required_grants.append(secret_binding_grant(binding_digest))
|
|
return self.journal.put_workspace_bundle_local_binding(
|
|
proposal_id=proposal_id,
|
|
expected_proposal_version=expected_version,
|
|
slot_id=action_id,
|
|
binding_kind="script_approval",
|
|
connector_id=None,
|
|
opaque_connection_id=None,
|
|
local_path=None,
|
|
required_grants=required_grants,
|
|
authorized_by=authorized_by,
|
|
)
|
|
|
|
def bind_local_values(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
client_request_id: str,
|
|
expected_version: int,
|
|
bindings: list[dict[str, Any]],
|
|
authorized_by: str,
|
|
) -> tuple[
|
|
tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
WorkspaceBundleInstallProposalRecord,
|
|
]:
|
|
proposal = self._proposal(proposal_id)
|
|
requirements = {
|
|
item["requirement_key"]: ("environment", item)
|
|
for item in proposal.install_plan.get(
|
|
"environment_requirements", []
|
|
)
|
|
}
|
|
requirements.update(
|
|
{
|
|
item["requirement_key"]: ("mcp_secret", item)
|
|
for item in proposal.install_plan.get(
|
|
"mcp_secret_requirements", []
|
|
)
|
|
}
|
|
)
|
|
if self.secret_broker is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"Local secret storage is unavailable"
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
identities: list[WorkspaceSecretIdentity] = []
|
|
for binding in bindings:
|
|
requirement_key = str(binding.get("requirement_key") or "")
|
|
requirement_kind = str(binding.get("requirement_kind") or "")
|
|
requirement = requirements.get(requirement_key)
|
|
if requirement is None or requirement[0] != requirement_kind:
|
|
raise WorkspaceBundleInstallError(
|
|
"Local value does not match a declared Bundle requirement"
|
|
)
|
|
identity = WorkspaceSecretIdentity(
|
|
secret_ref=str(binding.get("secret_ref") or ""),
|
|
account_scope_digest=str(
|
|
binding.get("account_scope_digest") or ""
|
|
),
|
|
space_id=proposal.space_id,
|
|
revision_id=proposal.revision_id,
|
|
slot_id=requirement_key,
|
|
)
|
|
identities.append(identity)
|
|
normalized.append(
|
|
{
|
|
"requirement_key": requirement_key,
|
|
"requirement_kind": requirement_kind,
|
|
"secret_ref": identity.secret_ref,
|
|
"account_scope_digest": identity.account_scope_digest,
|
|
"expected_binding_version": binding.get(
|
|
"expected_binding_version"
|
|
),
|
|
}
|
|
)
|
|
try:
|
|
verifications = self.secret_broker.verify_many(identities)
|
|
except WorkspaceSecretBrokerError as exc:
|
|
raise WorkspaceBundleInstallError(
|
|
"Local values must be rebound"
|
|
) from exc
|
|
unavailable = [
|
|
verification.identity.slot_id
|
|
for verification in verifications
|
|
if verification.state != "available"
|
|
]
|
|
if unavailable:
|
|
raise WorkspaceBundleInstallError(
|
|
f"Local value {unavailable[0]!r} must be rebound"
|
|
)
|
|
return self.journal.put_workspace_bundle_secret_bindings(
|
|
proposal_id=proposal_id,
|
|
client_request_id=client_request_id,
|
|
expected_proposal_version=expected_version,
|
|
bindings=normalized,
|
|
authorized_by=authorized_by,
|
|
)
|
|
|
|
async def materialize(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
expected_version: int,
|
|
space_root: Path,
|
|
actor_id: str,
|
|
allow_content_repository_init: bool = False,
|
|
) -> WorkspaceBundleInstallProposalRecord:
|
|
proposal = self._proposal(proposal_id)
|
|
if self.cloud is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle Cloud transport is unavailable"
|
|
)
|
|
if proposal.state == "materialized":
|
|
return proposal
|
|
if proposal.version != expected_version:
|
|
raise InvalidRunTransitionError("Bundle install proposal changed")
|
|
bindings = self.journal.list_workspace_bundle_local_bindings(
|
|
proposal_id
|
|
)
|
|
secret_bindings = self.journal.list_workspace_bundle_secret_bindings(
|
|
proposal_id
|
|
)
|
|
self._require_complete_bindings(proposal, bindings, secret_bindings)
|
|
self._verify_secret_bindings(proposal, secret_bindings)
|
|
materializing = (
|
|
self.journal.transition_workspace_bundle_install_proposal(
|
|
proposal_id,
|
|
expected_version=expected_version,
|
|
state="materializing",
|
|
)
|
|
)
|
|
try:
|
|
(
|
|
cloud_installation,
|
|
previous_revision_id,
|
|
) = await self._ensure_cloud_installation(proposal)
|
|
cloud_version = int(cloud_installation["version"])
|
|
for binding in bindings:
|
|
if binding.binding_kind != "connector":
|
|
continue
|
|
cloud_installation = await self.cloud.bind_connection(
|
|
proposal.space_id,
|
|
{
|
|
"bundle_id": proposal.bundle_id,
|
|
"slot_id": binding.slot_id,
|
|
"connector_id": binding.connector_id,
|
|
"connection_id": binding.opaque_connection_id,
|
|
"acknowledged_grants": list(binding.required_grants),
|
|
"expected_installation_version": cloud_version,
|
|
},
|
|
)
|
|
cloud_version = int(cloud_installation["version"])
|
|
assets, executable_assets = await self._download_assets(proposal)
|
|
manifest = WorkspaceBundleManifest.model_validate(
|
|
proposal.manifest
|
|
)
|
|
lock_payload = {
|
|
"apiVersion": "eigent.ai/lock/v1alpha1",
|
|
"bundleRevision": proposal.revision_id,
|
|
"manifestDigest": proposal.manifest_digest,
|
|
"assets": [
|
|
{
|
|
"ref": f"bundle://{item['logical_path']}",
|
|
"digest": item["content_digest"],
|
|
"provenance": item["provenance"],
|
|
"executable": item["executable"],
|
|
}
|
|
for item in proposal.assets
|
|
],
|
|
"skills": [],
|
|
"mcpPackages": [],
|
|
}
|
|
await asyncio.to_thread(
|
|
self.configuration_repository.bootstrap,
|
|
space_id=proposal.space_id,
|
|
space_root=space_root,
|
|
manifest=manifest,
|
|
placement=ConfigPlacement(proposal.config_placement),
|
|
created_by=actor_id,
|
|
lock_payload=lock_payload,
|
|
assets=assets,
|
|
executable_assets=executable_assets,
|
|
expected_previous_revision_id=previous_revision_id,
|
|
allow_content_repository_init=allow_content_repository_init,
|
|
)
|
|
revision = self.journal.get_workspace_config_revision(
|
|
proposal.revision_id
|
|
)
|
|
if revision is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"Materialized Bundle revision was not persisted"
|
|
)
|
|
self.journal.transition_workspace_config_revision(
|
|
proposal.revision_id,
|
|
expected_version=revision.version,
|
|
status="published",
|
|
)
|
|
projection = self._space_projection(proposal, bindings)
|
|
semantic_spec_digest = canonical_digest(proposal.manifest["spec"])
|
|
projection_id = self._environment_projection_id(
|
|
space_id=proposal.space_id,
|
|
owner_type="space",
|
|
owner_id=proposal.space_id,
|
|
semantic_spec_digest=semantic_spec_digest,
|
|
projection_digest=projection["projection_digest"],
|
|
redaction_schema_version=1,
|
|
)
|
|
await self.cloud.put_environment_projection(
|
|
{
|
|
"projection_id": projection_id,
|
|
"space_id": proposal.space_id,
|
|
"owner_type": "space",
|
|
"owner_id": proposal.space_id,
|
|
"semantic_spec_digest": semantic_spec_digest,
|
|
"redacted_spec": projection["redacted_spec"],
|
|
"redaction_schema_version": 1,
|
|
"projection_digest": projection["projection_digest"],
|
|
"capability_revision": "unresolved-at-space-install",
|
|
"installation_version": cloud_version,
|
|
}
|
|
)
|
|
except Exception:
|
|
self.journal.transition_workspace_bundle_install_proposal(
|
|
proposal_id,
|
|
expected_version=materializing.version,
|
|
state="needs_attention",
|
|
error_code="bundle_materialization_failed",
|
|
)
|
|
raise
|
|
return self.journal.transition_workspace_bundle_install_proposal(
|
|
proposal_id,
|
|
expected_version=materializing.version,
|
|
state="materialized",
|
|
)
|
|
|
|
async def _ensure_cloud_installation(
|
|
self,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
) -> tuple[dict[str, Any], str | None]:
|
|
assert self.cloud is not None
|
|
environment = await self.cloud.get_environment(proposal.space_id)
|
|
if "protocol_capabilities" in environment:
|
|
capabilities = environment["protocol_capabilities"]
|
|
if not isinstance(capabilities, list):
|
|
raise WorkspaceBundleInstallError(
|
|
"Cloud returned invalid Bundle protocol capabilities"
|
|
)
|
|
available = {str(item) for item in capabilities}
|
|
missing = sorted(self.REQUIRED_CLOUD_CAPABILITIES - available)
|
|
if missing:
|
|
raise WorkspaceBundleInstallError(
|
|
"Cloud must be upgraded before installing this Bundle; "
|
|
"missing protocol capabilities: " + ", ".join(missing)
|
|
)
|
|
installation = environment.get("installation")
|
|
if installation is None:
|
|
installation = await self.cloud.install(
|
|
proposal.space_id,
|
|
proposal.bundle_id,
|
|
proposal.revision_id,
|
|
)
|
|
return await self._confirm_cloud_installation(
|
|
proposal, installation
|
|
), None
|
|
if not isinstance(installation, dict):
|
|
raise WorkspaceBundleInstallError(
|
|
"Cloud returned an invalid Bundle installation"
|
|
)
|
|
installed_bundle_id = str(installation.get("bundle_id", ""))
|
|
installed_revision_id = str(
|
|
installation.get("installed_revision_id", "")
|
|
)
|
|
if installed_bundle_id != proposal.bundle_id:
|
|
raise WorkspaceBundleInstallError(
|
|
"Space already uses a different Workspace Bundle"
|
|
)
|
|
if installed_revision_id == proposal.revision_id:
|
|
local_materialization = (
|
|
self.journal.get_latest_workspace_config_materialization(
|
|
proposal.space_id
|
|
)
|
|
)
|
|
previous_revision_id = (
|
|
local_materialization.revision_id
|
|
if local_materialization is not None
|
|
and local_materialization.revision_id != proposal.revision_id
|
|
else None
|
|
)
|
|
return (
|
|
await self._confirm_cloud_installation(proposal, installation),
|
|
previous_revision_id,
|
|
)
|
|
try:
|
|
installed_version = int(installation["version"])
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise WorkspaceBundleInstallError(
|
|
"Cloud Bundle installation version is invalid"
|
|
) from exc
|
|
upgraded = await self.cloud.upgrade(
|
|
proposal.space_id,
|
|
revision_id=proposal.revision_id,
|
|
expected_installed_revision_id=installed_revision_id,
|
|
expected_version=installed_version,
|
|
)
|
|
return (
|
|
await self._confirm_cloud_installation(proposal, upgraded),
|
|
installed_revision_id,
|
|
)
|
|
|
|
async def _confirm_cloud_installation(
|
|
self,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
installation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
assert self.cloud is not None
|
|
if installation.get("state") != "proposed":
|
|
return installation
|
|
try:
|
|
expected_version = int(installation["version"])
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise WorkspaceBundleInstallError(
|
|
"Cloud Bundle install proposal version is invalid"
|
|
) from exc
|
|
reviewed_slots = [
|
|
{
|
|
"slot_id": item["slot_id"],
|
|
"connector_id": item["connector_id"],
|
|
"required_grants": sorted(
|
|
set(item.get("required_grants", []))
|
|
),
|
|
}
|
|
for item in proposal.install_plan["connector_slots"]
|
|
]
|
|
return await self.cloud.confirm_install(
|
|
proposal.space_id,
|
|
{
|
|
"bundle_id": proposal.bundle_id,
|
|
"revision_id": proposal.revision_id,
|
|
"expected_version": expected_version,
|
|
"reviewed_manifest_digest": proposal.manifest_digest,
|
|
"reviewed_slots": reviewed_slots,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def _environment_projection_id(
|
|
*,
|
|
space_id: str,
|
|
owner_type: str,
|
|
owner_id: str,
|
|
semantic_spec_digest: str,
|
|
projection_digest: str,
|
|
redaction_schema_version: int,
|
|
) -> str:
|
|
return (
|
|
"envproj_"
|
|
+ canonical_digest(
|
|
{
|
|
"space_id": space_id,
|
|
"owner_type": owner_type,
|
|
"owner_id": owner_id,
|
|
"semantic_spec_digest": semantic_spec_digest,
|
|
"projection_digest": projection_digest,
|
|
"redaction_schema_version": redaction_schema_version,
|
|
}
|
|
)[:40]
|
|
)
|
|
|
|
def _proposal(
|
|
self, proposal_id: str
|
|
) -> WorkspaceBundleInstallProposalRecord:
|
|
proposal = self.journal.get_workspace_bundle_install_proposal(
|
|
proposal_id
|
|
)
|
|
if proposal is None:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle install proposal not found"
|
|
)
|
|
return proposal
|
|
|
|
@staticmethod
|
|
def _validate_asset_descriptor(value: Any) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset descriptor is invalid"
|
|
)
|
|
required = (
|
|
"id",
|
|
"logical_path",
|
|
"content_digest",
|
|
"media_type",
|
|
"size_bytes",
|
|
)
|
|
if any(value.get(key) is None for key in required):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset descriptor is incomplete"
|
|
)
|
|
digest = str(value["content_digest"])
|
|
size = int(value["size_bytes"])
|
|
if len(digest) != 64 or set(digest) - set("0123456789abcdef"):
|
|
raise WorkspaceBundleInstallError("Bundle asset digest is invalid")
|
|
if size < 0 or size > 16 * 1024 * 1024:
|
|
raise WorkspaceBundleInstallError("Bundle asset size is invalid")
|
|
executable = value.get("executable", False)
|
|
if not isinstance(executable, bool):
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset executable metadata is invalid"
|
|
)
|
|
return {
|
|
"id": str(value["id"]),
|
|
"logical_path": str(value["logical_path"]),
|
|
"content_digest": digest,
|
|
"media_type": str(value["media_type"]),
|
|
"size_bytes": size,
|
|
"provenance": str(value.get("provenance", "bundle_author")),
|
|
"executable": executable,
|
|
}
|
|
|
|
@staticmethod
|
|
def _install_plan(
|
|
manifest: WorkspaceBundleManifest,
|
|
assets: list[dict[str, Any]],
|
|
*,
|
|
mcp_destinations: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
script_actions = [
|
|
f"skill.script.execute:{item.ref}"
|
|
for item in manifest.spec.skills
|
|
if item.ref.startswith("bundle://")
|
|
] + [
|
|
f"mcp.server.start:{item.id}" for item in manifest.spec.mcp_servers
|
|
]
|
|
return {
|
|
"connector_slots": [
|
|
{
|
|
"slot_id": item.connection_slot,
|
|
"connector_id": item.connector,
|
|
"required_grants": list(item.required_grants),
|
|
}
|
|
for item in manifest.spec.connectors
|
|
],
|
|
"local_path_slots": sorted(
|
|
{
|
|
source.slot
|
|
for source in manifest.spec.context
|
|
if source.kind == "local_path_slot" and source.slot
|
|
}
|
|
),
|
|
"script_actions": sorted(script_actions),
|
|
"environment_requirements": [
|
|
{
|
|
"requirement_key": f"environment:{item.name}",
|
|
"name": item.name,
|
|
"required": item.required,
|
|
"sensitive": item.sensitive,
|
|
"description": item.description,
|
|
"example": item.example,
|
|
}
|
|
for item in (
|
|
manifest.spec.environment.variables
|
|
if manifest.spec.environment
|
|
else ()
|
|
)
|
|
],
|
|
"mcp_secret_requirements": sorted(
|
|
(
|
|
{
|
|
"requirement_key": (
|
|
f"mcp_secret:{server.id}:{slot_id}"
|
|
),
|
|
"mcp_id": server.id,
|
|
"slot_id": slot_id,
|
|
"required": True,
|
|
}
|
|
for server in manifest.spec.mcp_servers
|
|
for slot_id in server.secret_slots
|
|
),
|
|
key=lambda item: item["requirement_key"],
|
|
),
|
|
"mcp_destinations": list(mcp_destinations or []),
|
|
"permission_profile": manifest.spec.permissions.profile,
|
|
"git_policy": manifest.spec.git.model_dump(
|
|
by_alias=True, mode="json"
|
|
),
|
|
"asset_count": len(assets),
|
|
"asset_bytes": sum(int(item["size_bytes"]) for item in assets),
|
|
"automatic_grants": [],
|
|
}
|
|
|
|
async def _inspect_mcp_destinations(
|
|
self,
|
|
manifest: WorkspaceBundleManifest,
|
|
assets: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
assert self.cloud is not None
|
|
assets_by_ref = {
|
|
f"bundle://{item['logical_path']}": item for item in assets
|
|
}
|
|
destinations: list[dict[str, Any]] = []
|
|
for server in manifest.spec.mcp_servers:
|
|
if server.definition.startswith("registry://"):
|
|
destinations.append(
|
|
registry_unavailable_destination(
|
|
mcp_id=server.id,
|
|
definition_ref=server.definition,
|
|
secret_slots=server.secret_slots,
|
|
)
|
|
)
|
|
continue
|
|
descriptor = assets_by_ref.get(server.definition)
|
|
if descriptor is None:
|
|
raise WorkspaceBundleInstallError(
|
|
f"MCP definition asset is missing: {server.id}"
|
|
)
|
|
content = await self.cloud.download_asset(
|
|
manifest.metadata.id,
|
|
manifest.revision_id,
|
|
descriptor["id"],
|
|
)
|
|
if len(content) != int(descriptor["size_bytes"]):
|
|
raise WorkspaceBundleInstallError(
|
|
"MCP definition asset size mismatch"
|
|
)
|
|
try:
|
|
assert_bundle_asset_safe(descriptor["logical_path"], content)
|
|
destination = inspect_bundle_mcp_destination(
|
|
revision_id=manifest.revision_id,
|
|
mcp_id=server.id,
|
|
definition_ref=server.definition,
|
|
definition_digest=descriptor["content_digest"],
|
|
content=content,
|
|
secret_slots=server.secret_slots,
|
|
executable_assets_by_ref=assets_by_ref,
|
|
)
|
|
except (McpDestinationError, SecretValueInManifestError) as exc:
|
|
raise WorkspaceBundleInstallError(
|
|
f"MCP destination review failed: {server.id}"
|
|
) from exc
|
|
destinations.append(destination)
|
|
return destinations
|
|
|
|
@staticmethod
|
|
def _require_complete_bindings(
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
bindings: tuple[WorkspaceBundleLocalBindingRecord, ...],
|
|
secret_bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
) -> None:
|
|
present = {binding.slot_id for binding in bindings}
|
|
required = {
|
|
item["slot_id"]
|
|
for item in proposal.install_plan["connector_slots"]
|
|
}
|
|
required.update(proposal.install_plan["local_path_slots"])
|
|
required.update(proposal.install_plan["script_actions"])
|
|
secret_present = {
|
|
binding.requirement_key for binding in secret_bindings
|
|
}
|
|
secret_required = {
|
|
item["requirement_key"]
|
|
for item in proposal.install_plan.get(
|
|
"environment_requirements", []
|
|
)
|
|
if item["required"]
|
|
}
|
|
secret_required.update(
|
|
item["requirement_key"]
|
|
for item in proposal.install_plan.get(
|
|
"mcp_secret_requirements", []
|
|
)
|
|
)
|
|
missing = sorted(
|
|
(required - present) | (secret_required - secret_present)
|
|
)
|
|
if missing:
|
|
raise WorkspaceBundleBindingsIncomplete(missing)
|
|
|
|
def _verify_secret_bindings(
|
|
self,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
) -> None:
|
|
if not bindings:
|
|
return
|
|
if self.secret_broker is None:
|
|
raise WorkspaceBundleBindingsIncomplete(
|
|
[binding.requirement_key for binding in bindings]
|
|
)
|
|
identities = tuple(
|
|
WorkspaceSecretIdentity(
|
|
secret_ref=binding.secret_ref,
|
|
account_scope_digest=binding.account_scope_digest,
|
|
space_id=proposal.space_id,
|
|
revision_id=proposal.revision_id,
|
|
slot_id=binding.requirement_key,
|
|
)
|
|
for binding in bindings
|
|
)
|
|
try:
|
|
verifications = self.secret_broker.verify_many(identities)
|
|
missing = [
|
|
verification.identity.slot_id
|
|
for verification in verifications
|
|
if verification.state != "available"
|
|
]
|
|
except WorkspaceSecretBrokerError:
|
|
missing = [identity.slot_id for identity in identities]
|
|
if missing:
|
|
raise WorkspaceBundleBindingsIncomplete(missing)
|
|
|
|
async def _download_assets(
|
|
self, proposal: WorkspaceBundleInstallProposalRecord
|
|
) -> tuple[dict[str, bytes], set[str]]:
|
|
total = sum(int(item["size_bytes"]) for item in proposal.assets)
|
|
if total > self.MAX_TOTAL_ASSET_BYTES:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle assets exceed the Desktop installation limit"
|
|
)
|
|
downloaded: dict[str, bytes] = {}
|
|
executable_assets: set[str] = set()
|
|
for item in proposal.assets:
|
|
content = await self.cloud.download_asset(
|
|
proposal.bundle_id,
|
|
proposal.revision_id,
|
|
item["id"],
|
|
)
|
|
if len(content) != int(item["size_bytes"]):
|
|
raise WorkspaceBundleInstallError("Bundle asset size mismatch")
|
|
if hashlib.sha256(content).hexdigest() != item["content_digest"]:
|
|
raise WorkspaceBundleInstallError(
|
|
"Bundle asset digest mismatch"
|
|
)
|
|
try:
|
|
assert_bundle_asset_safe(item["logical_path"], content)
|
|
except SecretValueInManifestError as exc:
|
|
raise WorkspaceBundleInstallError(
|
|
f"Bundle asset failed the local safety scan: "
|
|
f"{item['logical_path']}"
|
|
) from exc
|
|
downloaded[item["logical_path"]] = content
|
|
if item["executable"]:
|
|
executable_assets.add(item["logical_path"])
|
|
return downloaded, executable_assets
|
|
|
|
@staticmethod
|
|
def _space_projection(
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
bindings: tuple[WorkspaceBundleLocalBindingRecord, ...],
|
|
) -> dict[str, Any]:
|
|
local_paths = [
|
|
{
|
|
"slot_id": item.slot_id,
|
|
"root_fingerprint_digest": canonical_digest(
|
|
{"slot_id": item.slot_id, "local_path": item.local_path}
|
|
),
|
|
}
|
|
for item in bindings
|
|
if item.binding_kind == "local_path"
|
|
]
|
|
connectors = [
|
|
{
|
|
"slot_id": item.slot_id,
|
|
"connector_id": item.connector_id,
|
|
"required_grants": list(item.required_grants),
|
|
}
|
|
for item in bindings
|
|
if item.binding_kind == "connector"
|
|
]
|
|
redacted = {
|
|
"bundle_revision_id": proposal.revision_id,
|
|
"manifest_digest": proposal.manifest_digest,
|
|
"context_sources": local_paths,
|
|
"connector_bindings": connectors,
|
|
"permission_profile": proposal.install_plan["permission_profile"],
|
|
"git_policy": proposal.install_plan["git_policy"],
|
|
}
|
|
return {
|
|
"redacted_spec": redacted,
|
|
"projection_digest": canonical_digest(redacted),
|
|
}
|