mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 01:22:09 +00:00
1102 lines
41 KiB
Python
1102 lines
41 KiB
Python
"""Fail-closed, in-memory runtime assembly for materialized Bundles."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from collections.abc import Callable, Iterable
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from app.run_journal import (
|
|
SQLiteRunJournal,
|
|
WorkspaceBundleInstallProposalRecord,
|
|
WorkspaceBundleLocalBindingRecord,
|
|
WorkspaceBundleSecretBindingRecord,
|
|
)
|
|
from app.workspace_bundle.mcp_destination import (
|
|
McpDestinationError,
|
|
attestation_from_grants,
|
|
inspect_bundle_mcp_destination,
|
|
secret_binding_attestation,
|
|
secret_binding_attestation_from_grants,
|
|
)
|
|
from app.workspace_bundle.secrets import (
|
|
WorkspaceSecretBroker,
|
|
WorkspaceSecretBrokerError,
|
|
WorkspaceSecretIdentity,
|
|
)
|
|
from app.workspace_config.models import (
|
|
EffectiveEnvironmentSpec,
|
|
WorkspaceBundleManifest,
|
|
WorkspaceLock,
|
|
canonical_digest,
|
|
)
|
|
|
|
|
|
class EnvironmentSetupRequiredError(RuntimeError):
|
|
"""The pinned Bundle cannot safely dispatch tools on this device."""
|
|
|
|
code = "environment_setup_required"
|
|
|
|
def __init__(self, issues: Iterable[str]) -> None:
|
|
normalized = tuple(sorted(set(str(item) for item in issues if item)))
|
|
self.issues = normalized or ("workspace_bundle_setup_incomplete",)
|
|
super().__init__(
|
|
"Workspace Bundle setup is required before this Run can start: "
|
|
+ ", ".join(self.issues)
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, repr=False)
|
|
class ResolvedRuntimeContext:
|
|
id: str
|
|
kind: str
|
|
content: str | None = field(default=None, repr=False)
|
|
path: str | None = None
|
|
query: dict[str, Any] | None = field(default=None, repr=False)
|
|
|
|
|
|
@dataclass(frozen=True, repr=False)
|
|
class ResolvedRuntimeSkill:
|
|
ref: str
|
|
path: str
|
|
content: str = field(repr=False)
|
|
assign_to: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True, repr=False)
|
|
class ResolvedRuntimeConnector:
|
|
id: str
|
|
connector_id: str
|
|
slot_id: str
|
|
opaque_connection_id: str = field(repr=False)
|
|
required_grants: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True, repr=False)
|
|
class ResolvedRuntimeEnvironment:
|
|
"""A non-serializable snapshot consumed by one live Attempt.
|
|
|
|
Secret-bearing MCP configuration intentionally has no repr. This object
|
|
must never be placed in RunJournal payloads or events.
|
|
"""
|
|
|
|
environment_spec_id: str
|
|
bundle_revision_id: str
|
|
proposal_id: str
|
|
configuration_root: str
|
|
instructions: tuple[tuple[str, str], ...] = field(repr=False)
|
|
context: tuple[ResolvedRuntimeContext, ...] = field(repr=False)
|
|
skills: tuple[ResolvedRuntimeSkill, ...] = field(repr=False)
|
|
connectors: tuple[ResolvedRuntimeConnector, ...] = field(repr=False)
|
|
agent_aliases: tuple[str, ...]
|
|
permission_profile: str
|
|
permission_rules: tuple[tuple[str, str], ...]
|
|
_mcp_servers: dict[str, dict[str, Any]] = field(repr=False)
|
|
_secret_identities: tuple[WorkspaceSecretIdentity, ...] = field(repr=False)
|
|
_environment_bindings: tuple[tuple[str, str], ...] = field(repr=False)
|
|
_secret_broker_factory: Callable[[], WorkspaceSecretBroker] = field(
|
|
repr=False,
|
|
compare=False,
|
|
)
|
|
|
|
@contextmanager
|
|
def process_environment(self):
|
|
"""Resolve only workspace environment bindings for one spawn."""
|
|
|
|
requirements = {
|
|
requirement for _, requirement in self._environment_bindings
|
|
}
|
|
identities = tuple(
|
|
item
|
|
for item in self._secret_identities
|
|
if item.slot_id in requirements
|
|
)
|
|
values = _resolve_bound_secret_values(
|
|
self._secret_broker_factory,
|
|
identities,
|
|
)
|
|
environment = {
|
|
name: values[requirement]
|
|
for name, requirement in self._environment_bindings
|
|
}
|
|
try:
|
|
yield environment
|
|
finally:
|
|
environment.clear()
|
|
values.clear()
|
|
|
|
def pinned_skill_sources(
|
|
self,
|
|
agent_name: str | None = None,
|
|
) -> dict[str, str]:
|
|
aliases = {
|
|
str(agent_name or "").strip(),
|
|
"single_agent",
|
|
*self.agent_aliases,
|
|
}
|
|
return {
|
|
item.path: item.content
|
|
for item in self.skills
|
|
if not item.assign_to or bool(set(item.assign_to) & aliases)
|
|
}
|
|
|
|
def connector_bindings(self) -> tuple[ResolvedRuntimeConnector, ...]:
|
|
"""Return verified opaque bindings for the connector adapter."""
|
|
|
|
return self.connectors
|
|
|
|
def has_process_environment(self) -> bool:
|
|
return bool(self._environment_bindings)
|
|
|
|
def mcp_config_without_secrets(self) -> dict[str, Any] | None:
|
|
if any(
|
|
identity.slot_id.startswith("mcp_secret:")
|
|
for identity in self._secret_identities
|
|
):
|
|
raise EnvironmentSetupRequiredError(
|
|
["mcp_destination_confirmation_required"]
|
|
)
|
|
if not self._mcp_servers:
|
|
return None
|
|
return {"mcpServers": copy.deepcopy(self._mcp_servers)}
|
|
|
|
def prompt_context(self) -> str:
|
|
blocks: list[str] = []
|
|
if self.instructions:
|
|
blocks.append("=== Workspace Bundle Instructions ===")
|
|
blocks.extend(
|
|
f"[{role}]\n{content}" for role, content in self.instructions
|
|
)
|
|
blocks.append("=== End Workspace Bundle Instructions ===")
|
|
if self.context:
|
|
blocks.append("=== Workspace Bundle Context ===")
|
|
for item in self.context:
|
|
if item.content is not None:
|
|
blocks.append(f"[{item.id} | {item.kind}]\n{item.content}")
|
|
elif item.path is not None:
|
|
blocks.append(f"[{item.id} | {item.kind}] {item.path}")
|
|
elif item.query is not None:
|
|
blocks.append(
|
|
f"[{item.id} | {item.kind}] "
|
|
+ json.dumps(
|
|
item.query,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
blocks.append("=== End Workspace Bundle Context ===")
|
|
if self.connectors:
|
|
blocks.append("=== Workspace Bundle Connections ===")
|
|
blocks.extend(
|
|
f"[{item.id}] {item.connector_id} "
|
|
f"(grants: {', '.join(item.required_grants) or 'none'})"
|
|
for item in self.connectors
|
|
)
|
|
blocks.append("=== End Workspace Bundle Connections ===")
|
|
return "\n\n".join(blocks)
|
|
|
|
|
|
def _resolve_bound_secret_values(
|
|
broker_factory: Callable[[], WorkspaceSecretBroker],
|
|
identities: tuple[WorkspaceSecretIdentity, ...],
|
|
) -> dict[str, str]:
|
|
if not identities:
|
|
return {}
|
|
try:
|
|
broker = broker_factory()
|
|
resolutions = []
|
|
maximum = max(1, int(getattr(broker, "MAX_BATCH_BINDINGS", 100)))
|
|
for index in range(0, len(identities), maximum):
|
|
resolutions.extend(
|
|
broker.resolve_many(identities[index : index + maximum])
|
|
)
|
|
except WorkspaceSecretBrokerError as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["workspace_secrets_unavailable"]
|
|
) from exc
|
|
if len(resolutions) != len(identities):
|
|
raise EnvironmentSetupRequiredError(["workspace_secrets_unavailable"])
|
|
values: dict[str, str] = {}
|
|
for expected, resolution in zip(identities, resolutions, strict=True):
|
|
encoded = resolution.value.encode("utf-8")
|
|
if resolution.identity != expected or len(encoded) > 64 * 1024:
|
|
raise EnvironmentSetupRequiredError(
|
|
["workspace_secret_value_invalid"]
|
|
)
|
|
values[expected.slot_id] = resolution.value
|
|
return values
|
|
|
|
|
|
def _verify_bound_secret_identities(
|
|
broker_factory: Callable[[], WorkspaceSecretBroker],
|
|
identities: tuple[WorkspaceSecretIdentity, ...],
|
|
) -> None:
|
|
if not identities:
|
|
return
|
|
try:
|
|
broker = broker_factory()
|
|
verifications = []
|
|
maximum = max(1, int(getattr(broker, "MAX_BATCH_BINDINGS", 100)))
|
|
for index in range(0, len(identities), maximum):
|
|
verifications.extend(
|
|
broker.verify_many(identities[index : index + maximum])
|
|
)
|
|
except WorkspaceSecretBrokerError as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["workspace_secrets_unavailable"]
|
|
) from exc
|
|
if len(verifications) != len(identities) or any(
|
|
verification.identity != expected or verification.state != "available"
|
|
for expected, verification in zip(
|
|
identities,
|
|
verifications,
|
|
strict=True,
|
|
)
|
|
):
|
|
raise EnvironmentSetupRequiredError(["workspace_secrets_unavailable"])
|
|
|
|
|
|
def bundle_runtime_binding_digest(
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
local_bindings: Iterable[WorkspaceBundleLocalBindingRecord],
|
|
secret_bindings: Iterable[WorkspaceBundleSecretBindingRecord],
|
|
) -> str:
|
|
"""Pin opaque local authorities without including any secret value."""
|
|
|
|
return canonical_digest(
|
|
{
|
|
"proposal_id": proposal.proposal_id,
|
|
"proposal_version": proposal.version,
|
|
"revision_id": proposal.revision_id,
|
|
"local_bindings": [
|
|
{
|
|
"slot_id": item.slot_id,
|
|
"binding_kind": item.binding_kind,
|
|
"connector_id": item.connector_id,
|
|
"opaque_connection_id": item.opaque_connection_id,
|
|
"local_path": item.local_path,
|
|
"required_grants": list(item.required_grants),
|
|
}
|
|
for item in sorted(
|
|
local_bindings,
|
|
key=lambda value: value.slot_id,
|
|
)
|
|
],
|
|
"secret_bindings": [
|
|
{
|
|
"requirement_key": item.requirement_key,
|
|
"requirement_kind": item.requirement_kind,
|
|
"binding_version": item.binding_version,
|
|
"secret_ref": item.secret_ref,
|
|
"account_scope_digest": item.account_scope_digest,
|
|
}
|
|
for item in sorted(
|
|
secret_bindings,
|
|
key=lambda value: value.requirement_key,
|
|
)
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
class RuntimeEnvironmentAssembler:
|
|
"""Resolve one pinned EnvironmentSpec without consulting legacy config."""
|
|
|
|
MAX_TEXT_ASSET_BYTES = 1024 * 1024
|
|
MAX_PROMPT_BYTES = 2 * 1024 * 1024
|
|
|
|
def __init__(
|
|
self,
|
|
journal: SQLiteRunJournal,
|
|
*,
|
|
state_root: Path,
|
|
secret_broker_factory: Callable[[], WorkspaceSecretBroker]
|
|
| None = None,
|
|
) -> None:
|
|
self.journal = journal
|
|
self.state_root = state_root.expanduser().resolve()
|
|
self.secret_broker_factory = (
|
|
secret_broker_factory or WorkspaceSecretBroker.from_environment
|
|
)
|
|
|
|
def assemble(
|
|
self,
|
|
spec: EffectiveEnvironmentSpec,
|
|
*,
|
|
space_id: str,
|
|
space_root: Path,
|
|
) -> ResolvedRuntimeEnvironment | None:
|
|
marker = spec.semantic_spec.get("runtime_capability_manifest", {}).get(
|
|
"workspace_bundle"
|
|
)
|
|
if not isinstance(marker, dict):
|
|
return None
|
|
|
|
issues: list[str] = []
|
|
materialization = (
|
|
self.journal.get_latest_workspace_config_materialization(space_id)
|
|
)
|
|
if (
|
|
materialization is None
|
|
or materialization.state != "materialized"
|
|
or materialization.revision_id != spec.bundle_revision_id
|
|
):
|
|
raise EnvironmentSetupRequiredError(
|
|
["materialized_bundle_revision_changed"]
|
|
)
|
|
revision = self.journal.get_workspace_config_revision(
|
|
spec.bundle_revision_id
|
|
)
|
|
if revision is None:
|
|
raise EnvironmentSetupRequiredError(["bundle_revision_missing"])
|
|
try:
|
|
manifest = WorkspaceBundleManifest.model_validate(
|
|
spec.semantic_spec.get("bundle")
|
|
)
|
|
except Exception as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["pinned_bundle_manifest_invalid"]
|
|
) from exc
|
|
if (
|
|
manifest.revision_id != spec.bundle_revision_id
|
|
or manifest.digest != spec.manifest_digest
|
|
or revision.manifest_digest != spec.manifest_digest
|
|
or revision.manifest != manifest.canonical_payload()
|
|
):
|
|
raise EnvironmentSetupRequiredError(
|
|
["pinned_bundle_manifest_changed"]
|
|
)
|
|
|
|
proposal_id = spec.local_materialization.bundle_proposal_id
|
|
if not proposal_id:
|
|
raise EnvironmentSetupRequiredError(
|
|
["bundle_proposal_pin_missing"]
|
|
)
|
|
proposal = self.journal.get_workspace_bundle_install_proposal(
|
|
proposal_id
|
|
)
|
|
if (
|
|
proposal is None
|
|
or proposal.space_id != space_id
|
|
or proposal.revision_id != spec.bundle_revision_id
|
|
or proposal.state != "materialized"
|
|
or proposal.version
|
|
!= spec.local_materialization.bundle_proposal_version
|
|
):
|
|
raise EnvironmentSetupRequiredError(["bundle_proposal_changed"])
|
|
local_bindings = self.journal.list_workspace_bundle_local_bindings(
|
|
proposal.proposal_id
|
|
)
|
|
secret_bindings = self.journal.list_workspace_bundle_secret_bindings(
|
|
proposal.proposal_id
|
|
)
|
|
if (
|
|
bundle_runtime_binding_digest(
|
|
proposal, local_bindings, secret_bindings
|
|
)
|
|
!= spec.local_materialization.bundle_binding_digest
|
|
):
|
|
raise EnvironmentSetupRequiredError(["bundle_bindings_changed"])
|
|
|
|
expected_root = self._configuration_root(
|
|
space_id=space_id,
|
|
space_root=space_root,
|
|
placement=materialization.config_placement,
|
|
)
|
|
pinned_root = spec.local_materialization.configuration_root
|
|
if not pinned_root:
|
|
raise EnvironmentSetupRequiredError(
|
|
["configuration_root_pin_missing"]
|
|
)
|
|
try:
|
|
configuration_root = (
|
|
Path(pinned_root).expanduser().resolve(strict=True)
|
|
)
|
|
except OSError as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["configuration_root_unavailable"]
|
|
) from exc
|
|
if configuration_root != expected_root:
|
|
raise EnvironmentSetupRequiredError(["configuration_root_changed"])
|
|
|
|
lock = self._load_configuration_contract(configuration_root, manifest)
|
|
asset_digests = {
|
|
item.ref: item.digest
|
|
for item in (*lock.assets, *lock.skills, *lock.mcp_packages)
|
|
}
|
|
executable_assets = {
|
|
item.ref: {
|
|
"content_digest": item.digest,
|
|
"executable": item.executable,
|
|
}
|
|
for item in (*lock.assets, *lock.skills, *lock.mcp_packages)
|
|
}
|
|
file_cache: dict[str, tuple[Path, bytes]] = {}
|
|
|
|
def asset(ref: str) -> tuple[Path, bytes]:
|
|
cached = file_cache.get(ref)
|
|
if cached is not None:
|
|
return cached
|
|
resolved = self._read_bundle_asset(
|
|
configuration_root,
|
|
ref,
|
|
asset_digests,
|
|
)
|
|
file_cache[ref] = resolved
|
|
return resolved
|
|
|
|
local_by_slot = {item.slot_id: item for item in local_bindings}
|
|
self._validate_declared_bindings(
|
|
manifest,
|
|
proposal,
|
|
local_by_slot,
|
|
secret_bindings,
|
|
spec,
|
|
issues,
|
|
)
|
|
if issues:
|
|
raise EnvironmentSetupRequiredError(issues)
|
|
|
|
declared_agents = tuple(item.id for item in manifest.spec.agents)
|
|
if len(declared_agents) > 1:
|
|
raise EnvironmentSetupRequiredError(
|
|
["multi_agent_runtime_adapter_unavailable"]
|
|
)
|
|
|
|
if manifest.spec.connectors:
|
|
# Binding verification alone does not create an executable Cloud
|
|
# connector adapter in Brain. Reporting this installation Ready
|
|
# would mislead the model and user, so remain fail-closed until the
|
|
# gateway consumer is wired in a dedicated phase.
|
|
raise EnvironmentSetupRequiredError(
|
|
[
|
|
f"connector_runtime_adapter_unavailable:{item.id}"
|
|
for item in manifest.spec.connectors
|
|
]
|
|
)
|
|
|
|
secret_mcp_servers = tuple(
|
|
server
|
|
for server in manifest.spec.mcp_servers
|
|
if server.secret_slots
|
|
)
|
|
if secret_mcp_servers:
|
|
self._validate_secret_mcp_attestations(
|
|
proposal=proposal,
|
|
servers=secret_mcp_servers,
|
|
local_by_slot=local_by_slot,
|
|
secret_bindings=secret_bindings,
|
|
asset=asset,
|
|
executable_assets=executable_assets,
|
|
)
|
|
# The authorization contract is now complete and stale-safe, but
|
|
# plaintext injection still requires an Eigent-owned stdio adapter
|
|
# that does not retain values in MCPClient configuration. Until
|
|
# that adapter lands, remain explicitly fail-closed before any
|
|
# secret broker call.
|
|
raise EnvironmentSetupRequiredError(
|
|
[
|
|
f"mcp_secret_stdio_runtime_adapter_unavailable:{server.id}"
|
|
for server in secret_mcp_servers
|
|
]
|
|
)
|
|
|
|
declared_secret_keys = {
|
|
f"mcp_secret:{server.id}:{slot}"
|
|
for server in manifest.spec.mcp_servers
|
|
for slot in server.secret_slots
|
|
}
|
|
declared_secret_keys.update(
|
|
f"environment:{item.name}"
|
|
for item in (
|
|
manifest.spec.environment.variables
|
|
if manifest.spec.environment
|
|
else ()
|
|
)
|
|
)
|
|
runtime_secret_bindings = tuple(
|
|
item
|
|
for item in secret_bindings
|
|
if item.requirement_key in declared_secret_keys
|
|
)
|
|
secret_identities = self._secret_identities(
|
|
proposal,
|
|
runtime_secret_bindings,
|
|
)
|
|
# Admission verifies only identity-bound availability. Plaintext is
|
|
# resolved later at the exact process/client boundary.
|
|
_verify_bound_secret_identities(
|
|
self.secret_broker_factory,
|
|
secret_identities,
|
|
)
|
|
instructions: list[tuple[str, str]] = []
|
|
prompt_bytes = 0
|
|
for role, ref in sorted(manifest.spec.instructions.items()):
|
|
_, content = asset(ref)
|
|
text = self._decode_text(ref, content)
|
|
prompt_bytes += len(content)
|
|
instructions.append((role, text))
|
|
|
|
contexts: list[ResolvedRuntimeContext] = []
|
|
pinned_sources = {
|
|
item.id: item
|
|
for item in spec.local_materialization.context_sources
|
|
}
|
|
for source in manifest.spec.context:
|
|
if source.kind == "bundle_asset":
|
|
assert source.path is not None
|
|
_, content = asset(source.path)
|
|
text = self._decode_text(source.path, content)
|
|
prompt_bytes += len(content)
|
|
contexts.append(
|
|
ResolvedRuntimeContext(
|
|
id=source.id,
|
|
kind=source.kind,
|
|
content=text,
|
|
)
|
|
)
|
|
elif source.kind == "inline":
|
|
content = source.content or ""
|
|
prompt_bytes += len(content.encode("utf-8"))
|
|
contexts.append(
|
|
ResolvedRuntimeContext(
|
|
id=source.id,
|
|
kind=source.kind,
|
|
content=content,
|
|
)
|
|
)
|
|
elif source.kind == "local_path_slot":
|
|
pinned = pinned_sources.get(source.id)
|
|
binding = local_by_slot.get(source.slot or "")
|
|
if (
|
|
pinned is None
|
|
or binding is None
|
|
or not pinned.absolute_path
|
|
or binding.local_path != pinned.absolute_path
|
|
):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"path_binding_changed:{source.slot}"]
|
|
)
|
|
contexts.append(
|
|
ResolvedRuntimeContext(
|
|
id=source.id,
|
|
kind=source.kind,
|
|
path=pinned.absolute_path,
|
|
)
|
|
)
|
|
else:
|
|
contexts.append(
|
|
ResolvedRuntimeContext(
|
|
id=source.id,
|
|
kind=source.kind,
|
|
path=source.path,
|
|
query=copy.deepcopy(source.query),
|
|
)
|
|
)
|
|
if prompt_bytes > self.MAX_PROMPT_BYTES:
|
|
raise EnvironmentSetupRequiredError(["bundle_prompt_too_large"])
|
|
|
|
skills: list[ResolvedRuntimeSkill] = []
|
|
for skill in manifest.spec.skills:
|
|
if not skill.ref.startswith("bundle://"):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"registry_skill_unmaterialized:{skill.ref}"]
|
|
)
|
|
path, content = asset(skill.ref)
|
|
text = self._decode_text(skill.ref, content)
|
|
if path.name != "SKILL.md":
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_skill_not_portable:{skill.ref}"]
|
|
)
|
|
skills.append(
|
|
ResolvedRuntimeSkill(
|
|
ref=skill.ref,
|
|
path=str(path),
|
|
content=text,
|
|
assign_to=skill.assign_to,
|
|
)
|
|
)
|
|
|
|
mcp_servers: dict[str, dict[str, Any]] = {}
|
|
for server in manifest.spec.mcp_servers:
|
|
if not server.definition.startswith("bundle://"):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"registry_mcp_unmaterialized:{server.id}"]
|
|
)
|
|
definition_path, content = asset(server.definition)
|
|
mcp_servers[server.id] = self._resolve_mcp_server(
|
|
server_id=server.id,
|
|
definition_path=definition_path,
|
|
content=content,
|
|
secret_slots=server.secret_slots,
|
|
)
|
|
|
|
connectors = tuple(
|
|
ResolvedRuntimeConnector(
|
|
id=item.id,
|
|
connector_id=item.connector,
|
|
slot_id=item.connection_slot,
|
|
opaque_connection_id=(
|
|
local_by_slot[item.connection_slot].opaque_connection_id
|
|
or ""
|
|
),
|
|
required_grants=item.required_grants,
|
|
)
|
|
for item in manifest.spec.connectors
|
|
)
|
|
return ResolvedRuntimeEnvironment(
|
|
environment_spec_id=spec.spec_id,
|
|
bundle_revision_id=spec.bundle_revision_id,
|
|
proposal_id=proposal.proposal_id,
|
|
configuration_root=str(configuration_root),
|
|
instructions=tuple(instructions),
|
|
context=tuple(contexts),
|
|
skills=tuple(skills),
|
|
connectors=connectors,
|
|
# A one-Agent Bundle may use any portable logical id (for example
|
|
# ``coordinator`` or ``lead``). The current Desktop runtime maps
|
|
# that sole logical Agent onto its concrete ``single_agent``
|
|
# implementation; only actual multi-Agent graphs are unsupported.
|
|
agent_aliases=declared_agents,
|
|
permission_profile=manifest.spec.permissions.profile,
|
|
permission_rules=tuple(
|
|
(item.action, item.effect)
|
|
for item in manifest.spec.permissions.rules
|
|
),
|
|
_mcp_servers=mcp_servers,
|
|
_secret_identities=secret_identities,
|
|
_environment_bindings=tuple(
|
|
(
|
|
item.name,
|
|
f"environment:{item.name}",
|
|
)
|
|
for item in (
|
|
manifest.spec.environment.variables
|
|
if manifest.spec.environment
|
|
else ()
|
|
)
|
|
if f"environment:{item.name}" in declared_secret_keys
|
|
and any(
|
|
binding.requirement_key == f"environment:{item.name}"
|
|
for binding in runtime_secret_bindings
|
|
)
|
|
),
|
|
_secret_broker_factory=self.secret_broker_factory,
|
|
)
|
|
|
|
def _configuration_root(
|
|
self,
|
|
*,
|
|
space_id: str,
|
|
space_root: Path,
|
|
placement: str,
|
|
) -> Path:
|
|
root = space_root.expanduser().resolve()
|
|
if placement == "in_repo":
|
|
candidate = root / ".eigent"
|
|
elif placement == "sidecar":
|
|
if not space_id or any(
|
|
character
|
|
not in (
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
"0123456789._-"
|
|
)
|
|
for character in space_id
|
|
):
|
|
raise EnvironmentSetupRequiredError(["space_identity_invalid"])
|
|
candidate = self.state_root / "spaces" / space_id / "configuration"
|
|
else:
|
|
raise EnvironmentSetupRequiredError(["config_placement_invalid"])
|
|
try:
|
|
return candidate.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["configuration_root_unavailable"]
|
|
) from exc
|
|
|
|
def _load_configuration_contract(
|
|
self,
|
|
root: Path,
|
|
manifest: WorkspaceBundleManifest,
|
|
) -> WorkspaceLock:
|
|
try:
|
|
manifest_value = yaml.safe_load(
|
|
self._read_limited(root / "workspace.yaml").decode("utf-8")
|
|
)
|
|
materialized_manifest = WorkspaceBundleManifest.model_validate(
|
|
manifest_value
|
|
)
|
|
lock_value = yaml.safe_load(
|
|
self._read_limited(root / "workspace.lock").decode("utf-8")
|
|
)
|
|
lock = WorkspaceLock.model_validate(lock_value)
|
|
except Exception as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["configuration_contract_invalid"]
|
|
) from exc
|
|
if (
|
|
materialized_manifest.digest != manifest.digest
|
|
or materialized_manifest.revision_id != manifest.revision_id
|
|
or lock.bundle_revision != manifest.revision_id
|
|
or lock.manifest_digest != manifest.digest
|
|
):
|
|
raise EnvironmentSetupRequiredError(
|
|
["configuration_contract_changed"]
|
|
)
|
|
return lock
|
|
|
|
def _read_bundle_asset(
|
|
self,
|
|
root: Path,
|
|
ref: str,
|
|
asset_digests: dict[str, str],
|
|
) -> tuple[Path, bytes]:
|
|
if not ref.startswith("bundle://") or ref not in asset_digests:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_asset_missing:{ref}"]
|
|
)
|
|
relative = PurePosixPath(ref.removeprefix("bundle://"))
|
|
if (
|
|
relative.is_absolute()
|
|
or not relative.parts
|
|
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
):
|
|
raise EnvironmentSetupRequiredError(["bundle_asset_path_invalid"])
|
|
try:
|
|
target = (root / Path(*relative.parts)).resolve(strict=True)
|
|
target.relative_to(root)
|
|
except (OSError, ValueError) as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_asset_unavailable:{ref}"]
|
|
) from exc
|
|
if not target.is_file():
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_asset_unavailable:{ref}"]
|
|
)
|
|
content = self._read_limited(target)
|
|
if hashlib.sha256(content).hexdigest() != asset_digests[ref]:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_asset_digest_changed:{ref}"]
|
|
)
|
|
return target, content
|
|
|
|
def _read_limited(self, path: Path) -> bytes:
|
|
with path.open("rb") as handle:
|
|
content = handle.read(self.MAX_TEXT_ASSET_BYTES + 1)
|
|
if len(content) > self.MAX_TEXT_ASSET_BYTES:
|
|
raise EnvironmentSetupRequiredError(
|
|
["bundle_text_asset_too_large"]
|
|
)
|
|
return content
|
|
|
|
@staticmethod
|
|
def _decode_text(ref: str, content: bytes) -> str:
|
|
try:
|
|
return content.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"bundle_text_asset_invalid:{ref}"]
|
|
) from exc
|
|
|
|
def _validate_declared_bindings(
|
|
self,
|
|
manifest: WorkspaceBundleManifest,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
local_by_slot: dict[str, WorkspaceBundleLocalBindingRecord],
|
|
secret_bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
spec: EffectiveEnvironmentSpec,
|
|
issues: list[str],
|
|
) -> None:
|
|
pinned_connectors = {
|
|
item.slot_id: item
|
|
for item in spec.local_materialization.connector_bindings
|
|
}
|
|
for connector in manifest.spec.connectors:
|
|
binding = local_by_slot.get(connector.connection_slot)
|
|
pinned = pinned_connectors.get(connector.connection_slot)
|
|
if (
|
|
binding is None
|
|
or binding.binding_kind != "connector"
|
|
or binding.connector_id != connector.connector
|
|
or not binding.opaque_connection_id
|
|
or tuple(sorted(binding.required_grants))
|
|
!= tuple(sorted(connector.required_grants))
|
|
or pinned is None
|
|
or pinned.connector_id != connector.connector
|
|
or pinned.local_binding_id != binding.opaque_connection_id
|
|
or tuple(sorted(pinned.required_grants))
|
|
!= tuple(sorted(connector.required_grants))
|
|
):
|
|
issues.append(
|
|
f"connector_binding_missing:{connector.connection_slot}"
|
|
)
|
|
for source in manifest.spec.context:
|
|
if source.kind != "local_path_slot" or not source.slot:
|
|
continue
|
|
binding = local_by_slot.get(source.slot)
|
|
if (
|
|
binding is None
|
|
or binding.binding_kind != "local_path"
|
|
or not binding.local_path
|
|
or not Path(binding.local_path).expanduser().is_dir()
|
|
):
|
|
issues.append(f"path_binding_missing:{source.slot}")
|
|
script_actions = {
|
|
*(
|
|
f"skill.script.execute:{item.ref}"
|
|
for item in manifest.spec.skills
|
|
),
|
|
*(
|
|
f"mcp.server.start:{item.id}"
|
|
for item in manifest.spec.mcp_servers
|
|
),
|
|
}
|
|
for action in script_actions:
|
|
binding = local_by_slot.get(action)
|
|
if binding is None or binding.binding_kind != "script_approval":
|
|
issues.append(f"script_approval_missing:{action}")
|
|
secret_by_key = {
|
|
item.requirement_key: item for item in secret_bindings
|
|
}
|
|
required_secrets = {
|
|
f"mcp_secret:{server.id}:{slot}"
|
|
for server in manifest.spec.mcp_servers
|
|
for slot in server.secret_slots
|
|
}
|
|
required_secrets.update(
|
|
f"environment:{item.name}"
|
|
for item in (
|
|
manifest.spec.environment.variables
|
|
if manifest.spec.environment
|
|
else ()
|
|
)
|
|
if item.required
|
|
)
|
|
for requirement in sorted(required_secrets):
|
|
if requirement not in secret_by_key:
|
|
issues.append(f"secret_binding_missing:{requirement}")
|
|
if (
|
|
proposal.manifest_digest != manifest.digest
|
|
or proposal.manifest != manifest.canonical_payload()
|
|
):
|
|
issues.append("proposal_manifest_changed")
|
|
|
|
def _validate_secret_mcp_attestations(
|
|
self,
|
|
*,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
servers,
|
|
local_by_slot: dict[str, WorkspaceBundleLocalBindingRecord],
|
|
secret_bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
asset: Callable[[str], tuple[Path, bytes]],
|
|
executable_assets: dict[str, dict[str, Any]],
|
|
) -> None:
|
|
destinations = {
|
|
str(item.get("mcp_id")): item
|
|
for item in proposal.install_plan.get("mcp_destinations", [])
|
|
if isinstance(item, dict) and item.get("mcp_id")
|
|
}
|
|
secret_records = [
|
|
{
|
|
"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 secret_bindings
|
|
]
|
|
for server in servers:
|
|
issue = f"mcp_destination_confirmation_stale:{server.id}"
|
|
destination = destinations.get(server.id)
|
|
binding = local_by_slot.get(f"mcp.server.start:{server.id}")
|
|
if (
|
|
destination is None
|
|
or binding is None
|
|
or binding.binding_kind != "script_approval"
|
|
):
|
|
raise EnvironmentSetupRequiredError([issue])
|
|
availability_issue = destination.get("availability_issue")
|
|
if availability_issue:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"{availability_issue}:{server.id}"]
|
|
)
|
|
expected_destination_digest = destination.get("attestation_digest")
|
|
if not isinstance(expected_destination_digest, str):
|
|
raise EnvironmentSetupRequiredError([issue])
|
|
if not server.definition.startswith("bundle://"):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"registry_mcp_unmaterialized:{server.id}"]
|
|
)
|
|
try:
|
|
_, definition_content = asset(server.definition)
|
|
current = inspect_bundle_mcp_destination(
|
|
revision_id=proposal.revision_id,
|
|
mcp_id=server.id,
|
|
definition_ref=server.definition,
|
|
definition_digest=hashlib.sha256(
|
|
definition_content
|
|
).hexdigest(),
|
|
content=definition_content,
|
|
secret_slots=server.secret_slots,
|
|
executable_assets_by_ref=executable_assets,
|
|
)
|
|
executable_ref = current.get("executable_asset_ref")
|
|
if isinstance(executable_ref, str):
|
|
# ``asset`` repeats the locked digest check against the
|
|
# actual materialized executable before authorization is
|
|
# considered current.
|
|
asset(executable_ref)
|
|
except (McpDestinationError, EnvironmentSetupRequiredError):
|
|
raise EnvironmentSetupRequiredError([issue]) from None
|
|
current_destination_digest = current.get("attestation_digest")
|
|
current_secret_digest = secret_binding_attestation(
|
|
mcp_id=server.id,
|
|
bindings=secret_records,
|
|
)
|
|
if (
|
|
current_destination_digest != expected_destination_digest
|
|
or attestation_from_grants(binding.required_grants)
|
|
!= current_destination_digest
|
|
or secret_binding_attestation_from_grants(
|
|
binding.required_grants
|
|
)
|
|
!= current_secret_digest
|
|
):
|
|
raise EnvironmentSetupRequiredError([issue])
|
|
|
|
@staticmethod
|
|
def _secret_identities(
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
) -> tuple[WorkspaceSecretIdentity, ...]:
|
|
return 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 bindings
|
|
)
|
|
|
|
def _resolve_secret_values(
|
|
self,
|
|
proposal: WorkspaceBundleInstallProposalRecord,
|
|
bindings: tuple[WorkspaceBundleSecretBindingRecord, ...],
|
|
) -> dict[str, str]:
|
|
return _resolve_bound_secret_values(
|
|
self.secret_broker_factory,
|
|
self._secret_identities(proposal, bindings),
|
|
)
|
|
|
|
def _resolve_mcp_server(
|
|
self,
|
|
*,
|
|
server_id: str,
|
|
definition_path: Path,
|
|
content: bytes,
|
|
secret_slots: tuple[str, ...],
|
|
) -> dict[str, Any]:
|
|
try:
|
|
definition = json.loads(content)
|
|
server = copy.deepcopy(definition["mcpServers"][server_id])
|
|
except Exception as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_definition_invalid:{server_id}"]
|
|
) from exc
|
|
if not isinstance(server, dict):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_definition_invalid:{server_id}"]
|
|
)
|
|
root = definition_path.parent.resolve()
|
|
used_slots: set[str] = set()
|
|
for category in ("env", "headers"):
|
|
values = server.get(category, {})
|
|
if not isinstance(values, dict):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_definition_invalid:{server_id}"]
|
|
)
|
|
for name, value in list(values.items()):
|
|
if not isinstance(name, str) or not isinstance(value, str):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_definition_invalid:{server_id}"]
|
|
)
|
|
if not value.startswith("slot://"):
|
|
continue
|
|
slot = value.removeprefix("slot://")
|
|
if slot not in secret_slots:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_secret_unavailable:{server_id}:{slot}"]
|
|
)
|
|
values[name] = f"slot://mcp_secret:{server_id}:{slot}"
|
|
used_slots.add(slot)
|
|
if used_slots != set(secret_slots):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_secret_mapping_changed:{server_id}"]
|
|
)
|
|
for key in ("command", "cwd"):
|
|
value = server.get(key)
|
|
if isinstance(value, str):
|
|
server[key] = value.replace("${PLUGIN_ROOT}", str(root))
|
|
args = server.get("args")
|
|
if isinstance(args, list):
|
|
server["args"] = [
|
|
item.replace("${PLUGIN_ROOT}", str(root))
|
|
if isinstance(item, str)
|
|
else item
|
|
for item in args
|
|
]
|
|
command = server.get("command")
|
|
if isinstance(command, str) and command.startswith("./"):
|
|
executable = self._contained_runtime_path(root, command[2:])
|
|
if not executable.is_file() or not os.access(executable, os.X_OK):
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_executable_unavailable:{server_id}"]
|
|
)
|
|
server["command"] = str(executable)
|
|
cwd = server.get("cwd")
|
|
if isinstance(cwd, str):
|
|
candidate = Path(cwd)
|
|
if not candidate.is_absolute():
|
|
candidate = self._contained_runtime_path(root, cwd)
|
|
try:
|
|
candidate = candidate.resolve(strict=True)
|
|
candidate.relative_to(root)
|
|
except (OSError, ValueError) as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_cwd_unavailable:{server_id}"]
|
|
) from exc
|
|
if not candidate.is_dir():
|
|
raise EnvironmentSetupRequiredError(
|
|
[f"mcp_cwd_unavailable:{server_id}"]
|
|
)
|
|
server["cwd"] = str(candidate)
|
|
return server
|
|
|
|
@staticmethod
|
|
def _contained_runtime_path(root: Path, relative: str) -> Path:
|
|
logical = PurePosixPath(relative)
|
|
if (
|
|
logical.is_absolute()
|
|
or not logical.parts
|
|
or any(part in {"", ".", ".."} for part in logical.parts)
|
|
):
|
|
raise EnvironmentSetupRequiredError(["runtime_path_invalid"])
|
|
try:
|
|
candidate = (root / Path(*logical.parts)).resolve(strict=True)
|
|
candidate.relative_to(root)
|
|
except (OSError, ValueError) as exc:
|
|
raise EnvironmentSetupRequiredError(
|
|
["runtime_path_unavailable"]
|
|
) from exc
|
|
return candidate
|