From 6ffb54cd0152c82b23e205f2623997f746efeb2b Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Tue, 11 Aug 2026 04:52:59 +0800 Subject: [PATCH] feat: add secure workforce bundle installation --- backend/app/controller/chat_controller.py | 55 +- .../controller/workspace_bundle_controller.py | 184 ++- backend/app/exception/exception.py | 9 +- backend/app/exception/handler.py | 8 +- backend/app/run_journal/__init__.py | 6 +- backend/app/run_journal/models.py | 14 + backend/app/run_journal/store.py | 481 ++++++- backend/app/workspace_bundle/__init__.py | 14 +- backend/app/workspace_bundle/installer.py | 183 ++- backend/app/workspace_bundle/secrets.py | 258 ++++ backend/app/workspace_config/__init__.py | 2 + backend/app/workspace_config/admission.py | 8 +- backend/app/workspace_config/models.py | 14 + .../test_workspace_bundle_controller.py | 357 ++++++ backend/tests/app/run_journal/test_store.py | 202 +++ .../test_exception_handler_registration.py | 23 + .../app/workspace_bundle/test_installer.py | 320 +++++ .../app/workspace_bundle/test_secrets.py | 247 ++++ .../app/workspace_config/test_admission.py | 97 +- electron/main/index.ts | 17 + electron/main/terminal.ts | 2 +- electron/main/workspaceSecrets/broker.ts | 247 ++++ electron/main/workspaceSecrets/index.ts | 24 + electron/main/workspaceSecrets/ipc.ts | 47 + electron/main/workspaceSecrets/runtime.ts | 45 + electron/main/workspaceSecrets/types.ts | 46 + electron/main/workspaceSecrets/vault.ts | 426 +++++++ electron/preload/index.ts | 10 + .../WorkspaceBundleInstallWizard.test.tsx | 470 +++++++ .../WorkspaceBundleInstallWizard.tsx | 1105 +++++++++++++++++ src/pages/Home/components/HomeHubToolbar.tsx | 13 + src/pages/WorkspaceBundleInstall.tsx | 14 + src/pages/WorkspaceConfiguration.tsx | 43 +- src/routers/index.tsx | 7 + src/service/workspaceBundleInstallApi.test.ts | 145 +++ src/service/workspaceBundleInstallApi.ts | 286 +++++ src/types/electron.d.ts | 46 + test/unit/electron/main/terminal.test.ts | 2 + .../main/workspaceSecretWiring.test.ts | 27 + .../electron/main/workspaceSecrets.test.ts | 450 +++++++ 40 files changed, 5892 insertions(+), 62 deletions(-) create mode 100644 backend/app/workspace_bundle/secrets.py create mode 100644 backend/tests/app/controller/test_workspace_bundle_controller.py create mode 100644 backend/tests/app/workspace_bundle/test_secrets.py create mode 100644 electron/main/workspaceSecrets/broker.ts create mode 100644 electron/main/workspaceSecrets/index.ts create mode 100644 electron/main/workspaceSecrets/ipc.ts create mode 100644 electron/main/workspaceSecrets/runtime.ts create mode 100644 electron/main/workspaceSecrets/types.ts create mode 100644 electron/main/workspaceSecrets/vault.ts create mode 100644 src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx create mode 100644 src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx create mode 100644 src/pages/WorkspaceBundleInstall.tsx create mode 100644 src/service/workspaceBundleInstallApi.test.ts create mode 100644 src/service/workspaceBundleInstallApi.ts create mode 100644 test/unit/electron/main/workspaceSecretWiring.test.ts create mode 100644 test/unit/electron/main/workspaceSecrets.test.ts diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 26a33805..4e886ef5 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -85,7 +85,11 @@ from app.utils.event_loop_utils import schedule_async_task_from_worker from app.utils.server.sync_step import sync_step_event from app.utils.workspace_paths import camel_log_root from app.utils.workspace_resolver import get_workspace_resolver -from app.workspace_config import EffectiveEnvironmentSpec, canonical_digest +from app.workspace_config import ( + EffectiveEnvironmentSpec, + WorkspaceBundleReconfigurationPendingError, + canonical_digest, +) from app.workspace_config.admission import ( EnvironmentAdmissionService, EnvironmentAdmissionTemplate, @@ -114,6 +118,17 @@ class _PreparedChatRun: initial_action: ActionImproveData +def _workspace_bundle_admission_error( + exc: WorkspaceBundleReconfigurationPendingError, +) -> UserException: + return UserException( + code.error, + f"{exc}. Open Workspace configuration > Local setup and sync the " + "pending changes.", + error_code=exc.code, + ) + + _EXPLICIT_RESUME_INSTRUCTION = """ Resume the interrupted Run from its persisted Project context and durable tool ledger. Continue only unfinished work. Treat completed tool calls and @@ -682,14 +697,17 @@ async def _prepare_chat_run( environment = None if isinstance(journal, SQLiteRunJournal): template = _legacy_environment_template(data) - environment = await asyncio.to_thread( - EnvironmentAdmissionService(journal).persist_for_run, - run_id=run_context.run_id, - space_id=run_context.space_id, - working_directory=run_context.working_directory, - created_by=(run_context.user_id or "local-user"), - template=template, - ) + try: + environment = await asyncio.to_thread( + EnvironmentAdmissionService(journal).persist_for_run, + run_id=run_context.run_id, + space_id=run_context.space_id, + working_directory=run_context.working_directory, + created_by=(run_context.user_id or "local-user"), + template=template, + ) + except WorkspaceBundleReconfigurationPendingError as exc: + raise _workspace_bundle_admission_error(exc) from exc _apply_environment_to_task_lock( task_lock, environment.spec, @@ -1270,14 +1288,17 @@ async def _improve_chat( template, EnvironmentAdmissionTemplate, ): - environment = await asyncio.to_thread( - EnvironmentAdmissionService(journal).persist_for_run, - run_id=refreshed_context.run_id, - space_id=refreshed_context.space_id, - working_directory=refreshed_context.working_directory, - created_by=(refreshed_context.user_id or "local-user"), - template=template, - ) + try: + environment = await asyncio.to_thread( + EnvironmentAdmissionService(journal).persist_for_run, + run_id=refreshed_context.run_id, + space_id=refreshed_context.space_id, + working_directory=refreshed_context.working_directory, + created_by=(refreshed_context.user_id or "local-user"), + template=template, + ) + except WorkspaceBundleReconfigurationPendingError as exc: + raise _workspace_bundle_admission_error(exc) from exc _apply_environment_to_task_lock( task_lock, environment.spec, diff --git a/backend/app/controller/workspace_bundle_controller.py b/backend/app/controller/workspace_bundle_controller.py index e812ea1e..2ae2e982 100644 --- a/backend/app/controller/workspace_bundle_controller.py +++ b/backend/app/controller/workspace_bundle_controller.py @@ -8,10 +8,11 @@ from pathlib import Path from typing import Annotated, Literal from fastapi import APIRouter, Depends, Header, HTTPException, Request -from pydantic import BaseModel, Field +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, @@ -19,14 +20,16 @@ from app.run_journal import ( configured_run_journal_path, get_default_run_journal, ) -from app.router_layer.hands_resolver import get_environment_hands from app.utils.workspace_resolver import get_workspace_resolver from app.workspace_bundle import ( HttpWorkspaceBundleCloudTransport, WorkspaceBundleBindingsIncomplete, WorkspaceBundleCloudError, - WorkspaceBundleInstallError, WorkspaceBundleInstaller, + WorkspaceBundleInstallError, + WorkspaceSecretBroker, + WorkspaceSecretBrokerError, + WorkspaceSecretIdentity, ) from app.workspace_config import ConfigPlacement from app.workspace_git import ConfigurationRepositoryService @@ -78,6 +81,28 @@ class BundleMaterializeBody(BaseModel): 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( @@ -87,10 +112,15 @@ def _configuration_repository() -> ConfigurationRepositoryService: 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, ) @@ -103,9 +133,7 @@ def _cloud(authorization: str) -> HttpWorkspaceBundleCloudTransport: return HttpWorkspaceBundleCloudTransport( server_url=server_url, authorization=authorization, - desktop_instance_id=os.environ.get( - "EIGENT_DESKTOP_INSTANCE_ID", "" - ), + desktop_instance_id=os.environ.get("EIGENT_DESKTOP_INSTANCE_ID", ""), ) @@ -117,14 +145,89 @@ def _payload(proposal_id: str) -> dict: 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) + configured = {item.slot_id for item in local_bindings} + configured.update(available_values) + missing = sorted(required - configured) return { "proposal": asdict(proposal), - "bindings": [ - asdict(item) - for item in journal.list_workspace_bundle_local_bindings( - proposal_id - ) - ], + "bindings": [asdict(item) for item in local_bindings], + "value_requirements": value_requirements, + "readiness": { + "ready": not missing, + "missing_requirements": missing, + }, } @@ -214,6 +317,21 @@ 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: + raise HTTPException( + status_code=404, + detail={"code": "bundle_install_proposal_not_found"}, + ) + return _payload(proposal.proposal_id) + + @router.post("/workspace-bundles/install-proposals/{proposal_id}/decision") async def decide_bundle_install( proposal_id: str, body: BundleDecisionBody @@ -287,9 +405,7 @@ async def approve_bundle_script( raise _error(exc) from exc -@router.post( - "/workspace-bundles/install-proposals/{proposal_id}/materialize" -) +@router.post("/workspace-bundles/install-proposals/{proposal_id}/materialize") async def materialize_bundle( proposal_id: str, body: BundleMaterializeBody, @@ -324,9 +440,7 @@ async def materialize_bundle( expected_version=body.expected_version, space_root=space_root, actor_id=body.actor_id, - allow_content_repository_init=( - body.allow_content_repository_init - ), + allow_content_repository_init=(body.allow_content_repository_init), ) return _payload(proposal_id) except Exception as exc: @@ -334,3 +448,37 @@ async def materialize_bundle( 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 diff --git a/backend/app/exception/exception.py b/backend/app/exception/exception.py index d41e0b2a..221dfc60 100644 --- a/backend/app/exception/exception.py +++ b/backend/app/exception/exception.py @@ -14,9 +14,16 @@ class UserException(Exception): - def __init__(self, code: int, description: str): + def __init__( + self, + code: int, + description: str, + *, + error_code: str | None = None, + ): self.code = code self.description = description + self.error_code = error_code class TokenException(Exception): diff --git a/backend/app/exception/handler.py b/backend/app/exception/handler.py index 2da11bbf..4931c72f 100644 --- a/backend/app/exception/handler.py +++ b/backend/app/exception/handler.py @@ -59,7 +59,13 @@ async def token_exception(request: Request, e: TokenException): async def user_exception(request: Request, e: UserException): logger.info(f"User exception on {request.url.path}: {e.description}") - return JSONResponse(content={"code": e.code, "text": e.description}) + payload: dict[str, int | str] = { + "code": e.code, + "text": e.description, + } + if e.error_code is not None: + payload["error_code"] = e.error_code + return JSONResponse(content=payload) async def no_permission(request: Request, exception: NoPermissionException): diff --git a/backend/app/run_journal/__init__.py b/backend/app/run_journal/__init__.py index db62255e..f339ac5e 100644 --- a/backend/app/run_journal/__init__.py +++ b/backend/app/run_journal/__init__.py @@ -45,11 +45,12 @@ from app.run_journal.models import ( SpacePermissionProfileRevisionRecord, StartupReconciliationResult, ToolCallRecord, + WorkspaceBundleInstallProposalRecord, + WorkspaceBundleLocalBindingRecord, + WorkspaceBundleSecretBindingRecord, WorkspaceConfigDraftRecord, WorkspaceConfigMaterializationRecord, WorkspaceConfigRevisionRecord, - WorkspaceBundleInstallProposalRecord, - WorkspaceBundleLocalBindingRecord, WorkspaceOverlayEntryRecord, WorkspaceReadSnapshotRecord, WorkspaceSnapshotRangeRecord, @@ -122,6 +123,7 @@ __all__ = [ "WorkspaceConfigRevisionRecord", "WorkspaceBundleInstallProposalRecord", "WorkspaceBundleLocalBindingRecord", + "WorkspaceBundleSecretBindingRecord", "WorkspaceOverlayEntryRecord", "WorkspaceReadSnapshotRecord", "WorkspaceSnapshotRangeRecord", diff --git a/backend/app/run_journal/models.py b/backend/app/run_journal/models.py index adc338b7..b9825d25 100644 --- a/backend/app/run_journal/models.py +++ b/backend/app/run_journal/models.py @@ -172,6 +172,20 @@ class WorkspaceBundleLocalBindingRecord: authorized_at: float +@dataclass(frozen=True) +class WorkspaceBundleSecretBindingRecord: + binding_id: str + proposal_id: str + requirement_key: str + requirement_kind: str + binding_version: int + secret_ref: str + account_scope_digest: str + authorized_by: str + authorized_at: float + updated_at: float + + @dataclass(frozen=True) class EffectiveEnvironmentSpecRecord: environment_spec_id: str diff --git a/backend/app/run_journal/store.py b/backend/app/run_journal/store.py index d8459b91..64f22a1f 100644 --- a/backend/app/run_journal/store.py +++ b/backend/app/run_journal/store.py @@ -68,6 +68,7 @@ from app.run_journal.models import ( ToolCallRecord, WorkspaceBundleInstallProposalRecord, WorkspaceBundleLocalBindingRecord, + WorkspaceBundleSecretBindingRecord, WorkspaceConfigDraftRecord, WorkspaceConfigMaterializationRecord, WorkspaceConfigRevisionRecord, @@ -98,7 +99,7 @@ from app.workspace_config.models import ( canonical_json, ) -SCHEMA_VERSION = 16 +SCHEMA_VERSION = 17 logger = logging.getLogger("run_journal") _MIGRATION_V1 = """ @@ -1142,6 +1143,48 @@ PRAGMA user_version = 16; COMMIT; """ +_MIGRATION_V17 = """ +BEGIN IMMEDIATE; + +CREATE TABLE IF NOT EXISTS workspace_bundle_secret_bindings ( + binding_id TEXT PRIMARY KEY, + proposal_id TEXT NOT NULL REFERENCES workspace_bundle_install_proposals( + proposal_id + ) ON DELETE CASCADE, + requirement_key TEXT NOT NULL, + requirement_kind TEXT NOT NULL CHECK ( + requirement_kind IN ('environment', 'mcp_secret') + ), + binding_version INTEGER NOT NULL DEFAULT 1 CHECK (binding_version >= 1), + secret_ref TEXT NOT NULL, + account_scope_digest TEXT NOT NULL CHECK ( + length(account_scope_digest) = 64 + ), + authorized_by TEXT NOT NULL, + authorized_at REAL NOT NULL, + updated_at REAL NOT NULL, + UNIQUE(proposal_id, requirement_key) +); + +CREATE TABLE IF NOT EXISTS workspace_bundle_secret_binding_requests ( + client_request_id TEXT PRIMARY KEY, + proposal_id TEXT NOT NULL REFERENCES workspace_bundle_install_proposals( + proposal_id + ) ON DELETE CASCADE, + request_digest TEXT NOT NULL CHECK (length(request_digest) = 64), + created_at REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS workspace_bundle_secret_bindings_proposal_idx +ON workspace_bundle_secret_bindings(proposal_id, requirement_kind); + +INSERT OR IGNORE INTO run_journal_migrations(version, applied_at) +VALUES (17, CAST(strftime('%s', 'now') AS REAL)); + +PRAGMA user_version = 17; +COMMIT; +""" + class RunJournalError(RuntimeError): """Base error for local RunJournal operations.""" @@ -1759,9 +1802,7 @@ class SQLiteRunJournal: # working content and move it to the next revision. The Cloud # fact remains immutable at N while local edits continue at # N+1 instead of being stranded behind a permanent conflict. - document["metadata"]["revision"] = ( - receipt_revision_number + 1 - ) + document["metadata"]["revision"] = receipt_revision_number + 1 next_json = canonical_json(document) updated = connection.execute( """ @@ -1941,6 +1982,59 @@ class SQLiteRunJournal: else None ) + def get_active_workspace_bundle_proposal( + self, *, space_id: str, revision_id: str + ) -> WorkspaceBundleInstallProposalRecord | None: + """Return the proposal that currently controls an installed revision. + + Review-only proposals are deliberately excluded: creating a second + proposal must not disable a currently usable installation. Once an + approved proposal begins materialization, its state becomes an + admission gate until materialization completes. + """ + + with self._lock: + row = self._connection.execute( + """ + SELECT * FROM workspace_bundle_install_proposals + WHERE space_id = ? AND revision_id = ? + AND state IN ( + 'materializing', 'materialized', 'needs_attention' + ) + ORDER BY updated_at DESC, proposal_id DESC + LIMIT 1 + """, + (space_id, revision_id), + ).fetchone() + return ( + self._workspace_bundle_install_proposal_from_row(row) + if row is not None + else None + ) + + def get_latest_workspace_bundle_install_proposal( + self, + *, + space_id: str, + ) -> WorkspaceBundleInstallProposalRecord | None: + """Return the durable install/setup flow currently owned by a Space.""" + + with self._lock: + row = self._connection.execute( + """ + SELECT * FROM workspace_bundle_install_proposals + WHERE space_id = ? AND state != 'rejected' + ORDER BY updated_at DESC, proposal_id DESC + LIMIT 1 + """, + (space_id,), + ).fetchone() + return ( + self._workspace_bundle_install_proposal_from_row(row) + if row is not None + else None + ) + def transition_workspace_bundle_install_proposal( self, proposal_id: str, @@ -2098,21 +2192,80 @@ class SQLiteRunJournal: (proposal_id, slot_id), ).fetchone() if row is not None: - actual = ( - row["binding_id"], - row["proposal_id"], - row["slot_id"], + actual_resource = ( row["binding_kind"], row["connector_id"], row["opaque_connection_id"], row["local_path"], row["required_grants_json"], - row["authorized_by"], ) - if actual != expected: - raise IdempotencyConflictError( - f"Bundle slot {slot_id!r} already has another decision" + desired_resource = ( + binding_kind, + connector_id, + opaque_connection_id, + local_path, + grants_json, + ) + if actual_resource == desired_resource: + return ( + self._workspace_bundle_local_binding_from_row(row), + self._workspace_bundle_install_proposal_from_row( + proposal + ), ) + if int(proposal["version"]) != expected_proposal_version: + raise OptimisticConcurrencyError( + f"Bundle install proposal {proposal_id!r} changed" + ) + if proposal["state"] not in { + "approved", + "needs_attention", + "materialized", + }: + raise InvalidRunTransitionError( + "Bundle resources can only be rebound after approval" + ) + connection.execute( + """UPDATE workspace_bundle_local_bindings + SET binding_kind = ?, connector_id = ?, + opaque_connection_id = ?, local_path = ?, + required_grants_json = ?, authorized_by = ?, + authorized_at = ? + WHERE binding_id = ?""", + ( + binding_kind, + connector_id, + opaque_connection_id, + local_path, + grants_json, + authorized_by, + timestamp, + binding_id, + ), + ) + connection.execute( + """UPDATE workspace_bundle_install_proposals + SET version = version + 1, + state = CASE WHEN state = 'materialized' + THEN 'needs_attention' ELSE state END, + error_code = CASE WHEN state = 'materialized' + THEN 'bundle_reconfiguration_pending' + ELSE error_code END, + updated_at = ? + WHERE proposal_id = ? AND version = ?""", + (timestamp, proposal_id, expected_proposal_version), + ) + row = connection.execute( + """SELECT * FROM workspace_bundle_local_bindings + WHERE binding_id = ?""", + (binding_id,), + ).fetchone() + proposal = connection.execute( + """SELECT * FROM workspace_bundle_install_proposals + WHERE proposal_id = ?""", + (proposal_id,), + ).fetchone() + assert row is not None and proposal is not None return ( self._workspace_bundle_local_binding_from_row(row), self._workspace_bundle_install_proposal_from_row(proposal), @@ -2121,7 +2274,11 @@ class SQLiteRunJournal: raise OptimisticConcurrencyError( f"Bundle install proposal {proposal_id!r} changed" ) - if proposal["state"] not in {"approved", "needs_attention"}: + if proposal["state"] not in { + "approved", + "needs_attention", + "materialized", + }: raise InvalidRunTransitionError( "Bundle resources can only be bound after approval" ) @@ -2137,7 +2294,13 @@ class SQLiteRunJournal: ) connection.execute( """UPDATE workspace_bundle_install_proposals - SET version = version + 1, updated_at = ? + SET version = version + 1, + state = CASE WHEN state = 'materialized' + THEN 'needs_attention' ELSE state END, + error_code = CASE WHEN state = 'materialized' + THEN 'bundle_reconfiguration_pending' + ELSE error_code END, + updated_at = ? WHERE proposal_id = ? AND version = ?""", (timestamp, proposal_id, expected_proposal_version), ) @@ -2171,6 +2334,277 @@ class SQLiteRunJournal: for row in rows ) + def put_workspace_bundle_secret_bindings( + self, + *, + proposal_id: str, + client_request_id: str, + expected_proposal_version: int, + bindings: list[dict[str, Any]], + authorized_by: str, + now: float | None = None, + ) -> tuple[ + tuple[WorkspaceBundleSecretBindingRecord, ...], + WorkspaceBundleInstallProposalRecord, + ]: + """CAS opaque vault references without ever accepting secret values.""" + + if not client_request_id.strip() or not authorized_by.strip(): + raise ValueError("binding request and authorizer are required") + if not bindings: + raise ValueError("at least one secret binding is required") + normalized: list[dict[str, Any]] = [] + keys: set[str] = set() + allowed_ref_characters = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789_-" + ) + for item in bindings: + requirement_key = str(item.get("requirement_key") or "") + requirement_kind = str(item.get("requirement_kind") or "") + secret_ref = str(item.get("secret_ref") or "") + account_scope_digest = str(item.get("account_scope_digest") or "") + expected_binding_version = item.get("expected_binding_version") + if expected_binding_version is not None: + expected_binding_version = int(expected_binding_version) + if expected_binding_version < 1: + raise ValueError("invalid Bundle secret binding version") + if requirement_kind not in {"environment", "mcp_secret"}: + raise ValueError("invalid Bundle secret binding kind") + if ( + not requirement_key.strip() + or len(secret_ref) != 40 + or not secret_ref.startswith("wsvault_") + or any( + character not in allowed_ref_characters + for character in secret_ref[8:] + ) + or not self._is_sha256(account_scope_digest) + ): + raise ValueError("invalid Bundle secret binding reference") + if requirement_key in keys: + raise ValueError("duplicate Bundle secret binding requirement") + keys.add(requirement_key) + normalized.append( + { + "requirement_key": requirement_key, + "requirement_kind": requirement_kind, + "secret_ref": secret_ref, + "account_scope_digest": account_scope_digest, + "expected_binding_version": expected_binding_version, + } + ) + normalized.sort(key=lambda item: str(item["requirement_key"])) + request_digest = canonical_digest( + { + "proposal_id": proposal_id, + "bindings": normalized, + "authorized_by": authorized_by, + } + ) + timestamp = now if now is not None else time.time() + with self._write_transaction() as connection: + receipt = connection.execute( + """SELECT request_digest + FROM workspace_bundle_secret_binding_requests + WHERE client_request_id = ?""", + (client_request_id,), + ).fetchone() + if receipt is not None: + if receipt["request_digest"] != request_digest: + raise IdempotencyConflictError( + "Bundle secret binding request id was reused" + ) + rows = connection.execute( + f"""SELECT * FROM workspace_bundle_secret_bindings + WHERE proposal_id = ? AND requirement_key IN ( + {",".join("?" for _ in normalized)} + ) ORDER BY requirement_key""", + ( + proposal_id, + *(item["requirement_key"] for item in normalized), + ), + ).fetchall() + proposal = connection.execute( + """SELECT * FROM workspace_bundle_install_proposals + WHERE proposal_id = ?""", + (proposal_id,), + ).fetchone() + if proposal is None: + raise RunNotFoundError( + f"Bundle install proposal {proposal_id!r} does not exist" + ) + return ( + tuple( + self._workspace_bundle_secret_binding_from_row(row) + for row in rows + ), + self._workspace_bundle_install_proposal_from_row(proposal), + ) + proposal = connection.execute( + """SELECT * FROM workspace_bundle_install_proposals + WHERE proposal_id = ?""", + (proposal_id,), + ).fetchone() + if proposal is None: + raise RunNotFoundError( + f"Bundle install proposal {proposal_id!r} does not exist" + ) + if int(proposal["version"]) != expected_proposal_version: + raise OptimisticConcurrencyError( + f"Bundle install proposal {proposal_id!r} changed" + ) + if proposal["state"] not in { + "approved", + "needs_attention", + "materialized", + }: + raise InvalidRunTransitionError( + "Bundle values can only be bound after approval" + ) + changed = False + for item in normalized: + requirement_key = str(item["requirement_key"]) + existing = connection.execute( + """SELECT * FROM workspace_bundle_secret_bindings + WHERE proposal_id = ? AND requirement_key = ?""", + (proposal_id, requirement_key), + ).fetchone() + expected_binding_version = item["expected_binding_version"] + if existing is None: + if expected_binding_version is not None: + raise OptimisticConcurrencyError( + f"Bundle value {requirement_key!r} changed" + ) + binding_id = ( + "bundlesecret_" + + canonical_digest( + { + "proposal_id": proposal_id, + "requirement_key": requirement_key, + } + )[:32] + ) + connection.execute( + """INSERT INTO workspace_bundle_secret_bindings( + binding_id, proposal_id, requirement_key, + requirement_kind, binding_version, secret_ref, + account_scope_digest, authorized_by, + authorized_at, updated_at + ) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?)""", + ( + binding_id, + proposal_id, + requirement_key, + item["requirement_kind"], + item["secret_ref"], + item["account_scope_digest"], + authorized_by, + timestamp, + timestamp, + ), + ) + changed = True + continue + if ( + expected_binding_version is not None + and int(existing["binding_version"]) + != expected_binding_version + ): + raise OptimisticConcurrencyError( + f"Bundle value {requirement_key!r} changed" + ) + desired = ( + item["requirement_kind"], + item["secret_ref"], + item["account_scope_digest"], + ) + actual = ( + existing["requirement_kind"], + existing["secret_ref"], + existing["account_scope_digest"], + ) + if desired == actual: + continue + if expected_binding_version is None: + raise IdempotencyConflictError( + f"Bundle value {requirement_key!r} already exists" + ) + connection.execute( + """UPDATE workspace_bundle_secret_bindings + SET requirement_kind = ?, binding_version = binding_version + 1, + secret_ref = ?, + account_scope_digest = ?, authorized_by = ?, + authorized_at = ?, updated_at = ? + WHERE binding_id = ?""", + ( + item["requirement_kind"], + item["secret_ref"], + item["account_scope_digest"], + authorized_by, + timestamp, + timestamp, + existing["binding_id"], + ), + ) + changed = True + connection.execute( + """INSERT INTO workspace_bundle_secret_binding_requests( + client_request_id, proposal_id, request_digest, created_at + ) VALUES (?, ?, ?, ?)""", + ( + client_request_id, + proposal_id, + request_digest, + timestamp, + ), + ) + if changed: + connection.execute( + """UPDATE workspace_bundle_install_proposals + SET version = version + 1, updated_at = ? + WHERE proposal_id = ? AND version = ?""", + (timestamp, proposal_id, expected_proposal_version), + ) + rows = connection.execute( + f"""SELECT * FROM workspace_bundle_secret_bindings + WHERE proposal_id = ? AND requirement_key IN ( + {",".join("?" for _ in normalized)} + ) ORDER BY requirement_key""", + ( + proposal_id, + *(item["requirement_key"] for item in normalized), + ), + ).fetchall() + proposal = connection.execute( + """SELECT * FROM workspace_bundle_install_proposals + WHERE proposal_id = ?""", + (proposal_id,), + ).fetchone() + assert proposal is not None + return ( + tuple( + self._workspace_bundle_secret_binding_from_row(row) + for row in rows + ), + self._workspace_bundle_install_proposal_from_row(proposal), + ) + + def list_workspace_bundle_secret_bindings( + self, proposal_id: str + ) -> tuple[WorkspaceBundleSecretBindingRecord, ...]: + with self._lock: + rows = self._connection.execute( + """SELECT * FROM workspace_bundle_secret_bindings + WHERE proposal_id = ? ORDER BY requirement_key""", + (proposal_id,), + ).fetchall() + return tuple( + self._workspace_bundle_secret_binding_from_row(row) + for row in rows + ) + def transition_workspace_config_revision( self, revision_id: str, @@ -9362,6 +9796,8 @@ class SQLiteRunJournal: self._connection.executescript(_MIGRATION_V15) if version < 16: self._connection.executescript(_MIGRATION_V16) + if version < 17: + self._connection.executescript(_MIGRATION_V17) @contextmanager def _write_transaction(self) -> Iterator[sqlite3.Connection]: @@ -9711,6 +10147,23 @@ class SQLiteRunJournal: authorized_at=float(row["authorized_at"]), ) + @staticmethod + def _workspace_bundle_secret_binding_from_row( + row: sqlite3.Row, + ) -> WorkspaceBundleSecretBindingRecord: + return WorkspaceBundleSecretBindingRecord( + binding_id=row["binding_id"], + proposal_id=row["proposal_id"], + requirement_key=row["requirement_key"], + requirement_kind=row["requirement_kind"], + binding_version=int(row["binding_version"]), + secret_ref=row["secret_ref"], + account_scope_digest=row["account_scope_digest"], + authorized_by=row["authorized_by"], + authorized_at=float(row["authorized_at"]), + updated_at=float(row["updated_at"]), + ) + @staticmethod def _effective_environment_spec_from_row( row: sqlite3.Row, diff --git a/backend/app/workspace_bundle/__init__.py b/backend/app/workspace_bundle/__init__.py index 14de1dec..53b0edd8 100644 --- a/backend/app/workspace_bundle/__init__.py +++ b/backend/app/workspace_bundle/__init__.py @@ -1,3 +1,4 @@ +from app.workspace_bundle.authoring import WorkspaceBundleAuthoringService from app.workspace_bundle.cloud import ( HttpWorkspaceBundleCloudTransport, WorkspaceBundleCloudError, @@ -5,10 +6,15 @@ from app.workspace_bundle.cloud import ( ) from app.workspace_bundle.installer import ( WorkspaceBundleBindingsIncomplete, - WorkspaceBundleInstallError, WorkspaceBundleInstaller, + WorkspaceBundleInstallError, +) +from app.workspace_bundle.secrets import ( + WorkspaceSecretBroker, + WorkspaceSecretBrokerError, + WorkspaceSecretIdentity, + WorkspaceSecretVerification, ) -from app.workspace_bundle.authoring import WorkspaceBundleAuthoringService __all__ = [ "HttpWorkspaceBundleCloudTransport", @@ -18,4 +24,8 @@ __all__ = [ "WorkspaceBundleInstallError", "WorkspaceBundleInstaller", "WorkspaceBundleAuthoringService", + "WorkspaceSecretBroker", + "WorkspaceSecretBrokerError", + "WorkspaceSecretIdentity", + "WorkspaceSecretVerification", ] diff --git a/backend/app/workspace_bundle/installer.py b/backend/app/workspace_bundle/installer.py index 619ed94b..05b258a6 100644 --- a/backend/app/workspace_bundle/installer.py +++ b/backend/app/workspace_bundle/installer.py @@ -12,8 +12,14 @@ from app.run_journal import ( SQLiteRunJournal, WorkspaceBundleInstallProposalRecord, WorkspaceBundleLocalBindingRecord, + WorkspaceBundleSecretBindingRecord, ) from app.workspace_bundle.cloud import WorkspaceBundleCloudTransport +from app.workspace_bundle.secrets import ( + WorkspaceSecretBroker, + WorkspaceSecretBrokerError, + WorkspaceSecretIdentity, +) from app.workspace_config import ( ConfigPlacement, SecretValueInManifestError, @@ -51,10 +57,12 @@ class WorkspaceBundleInstaller: 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, @@ -229,6 +237,91 @@ class WorkspaceBundleInstaller: 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, @@ -250,7 +343,11 @@ class WorkspaceBundleInstaller: bindings = self.journal.list_workspace_bundle_local_bindings( proposal_id ) - self._require_complete_bindings(proposal, bindings) + 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, @@ -565,6 +662,36 @@ class WorkspaceBundleInstaller: } ), "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"], + ), "permission_profile": manifest.spec.permissions.profile, "git_policy": manifest.spec.git.model_dump( by_alias=True, mode="json" @@ -578,6 +705,7 @@ class WorkspaceBundleInstaller: def _require_complete_bindings( proposal: WorkspaceBundleInstallProposalRecord, bindings: tuple[WorkspaceBundleLocalBindingRecord, ...], + secret_bindings: tuple[WorkspaceBundleSecretBindingRecord, ...], ) -> None: present = {binding.slot_id for binding in bindings} required = { @@ -586,7 +714,58 @@ class WorkspaceBundleInstaller: } required.update(proposal.install_plan["local_path_slots"]) required.update(proposal.install_plan["script_actions"]) - missing = sorted(required - present) + 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) diff --git a/backend/app/workspace_bundle/secrets.py b/backend/app/workspace_bundle/secrets.py new file mode 100644 index 00000000..a86dd029 --- /dev/null +++ b/backend/app/workspace_bundle/secrets.py @@ -0,0 +1,258 @@ +"""Capability-authenticated client for the Electron local secret broker.""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import MutableMapping, Sequence +from dataclasses import dataclass +from http.client import HTTPConnection, HTTPException +from typing import Literal +from urllib.parse import urlsplit + + +def _capture_broker_environment( + environment: MutableMapping[str, str], +) -> tuple[str, str]: + """Take broker authority out of the environment inherited by agents.""" + + endpoint = environment.pop("EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT", "") + capability = environment.pop( + "EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY", "" + ) + legacy_endpoint = environment.pop( + "EIGENT_WORKFORCE_SECRET_BROKER_ENDPOINT", "" + ) + legacy_capability = environment.pop( + "EIGENT_WORKFORCE_SECRET_BROKER_CAPABILITY", "" + ) + if endpoint or capability: + return endpoint, capability + return legacy_endpoint, legacy_capability + + +_BROKER_ENDPOINT, _BROKER_CAPABILITY = _capture_broker_environment(os.environ) + + +class WorkspaceSecretBrokerError(RuntimeError): + """Raised when a local-only Bundle binding cannot be verified.""" + + +@dataclass(frozen=True) +class WorkspaceSecretIdentity: + secret_ref: str + account_scope_digest: str + space_id: str + revision_id: str + slot_id: str + + +@dataclass(frozen=True) +class WorkspaceSecretVerification: + identity: WorkspaceSecretIdentity + state: Literal["available", "missing", "needs_rebind"] + + +class WorkspaceSecretBroker: + """Verify opaque bindings through Electron without receiving secret values.""" + + MAX_BATCH_BINDINGS = 100 + MAX_REQUEST_BYTES = 16 * 1024 + MAX_RESPONSE_BYTES = 64 * 1024 + + def __init__( + self, + *, + endpoint: str, + capability: str, + timeout_seconds: float = 3.0, + ) -> None: + try: + parsed = urlsplit(endpoint) + except ValueError as exc: + raise WorkspaceSecretBrokerError( + "Workspace secret broker endpoint is invalid" + ) from exc + if ( + parsed.scheme != "http" + or parsed.hostname != "127.0.0.1" + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise WorkspaceSecretBrokerError( + "Workspace secret broker must use a loopback endpoint" + ) + try: + port = parsed.port + except ValueError as exc: + raise WorkspaceSecretBrokerError( + "Workspace secret broker endpoint is invalid" + ) from exc + if ( + port is None + or not 1 <= port <= 65535 + or re.fullmatch(r"[A-Za-z0-9_-]{32,256}", capability) is None + or not 0 < timeout_seconds <= 30 + ): + raise WorkspaceSecretBrokerError( + "Workspace secret broker configuration is invalid" + ) + self._port = port + self._capability = capability + self._timeout_seconds = timeout_seconds + + @classmethod + def from_environment(cls) -> WorkspaceSecretBroker: + if not _BROKER_ENDPOINT or not _BROKER_CAPABILITY: + raise WorkspaceSecretBrokerError( + "Workspace secret broker is unavailable" + ) + return cls( + endpoint=_BROKER_ENDPOINT, + capability=_BROKER_CAPABILITY, + ) + + def verify(self, identity: WorkspaceSecretIdentity) -> None: + verification = self.verify_many((identity,))[0] + if verification.state != "available": + raise WorkspaceSecretBrokerError("Workspace secret is unavailable") + + def verify_many( + self, + identities: Sequence[WorkspaceSecretIdentity], + ) -> tuple[WorkspaceSecretVerification, ...]: + requested = tuple(identities) + if not requested: + return () + if len(requested) > self.MAX_BATCH_BINDINGS: + raise WorkspaceSecretBrokerError( + "Workspace secret verification batch is too large" + ) + response = self._request( + "/v1/workspace-secrets/verify-batch", + { + "bindings": [ + self._identity_payload(identity) for identity in requested + ] + }, + ) + if set(response) != {"statuses"}: + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned an invalid verification" + ) + statuses = response.get("statuses") + if not isinstance(statuses, list) or len(statuses) != len(requested): + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned an invalid verification" + ) + allowed_status_keys = { + "secret_ref", + "account_scope_digest", + "space_id", + "revision_id", + "slot_id", + "state", + "created_at", + "updated_at", + } + result: list[WorkspaceSecretVerification] = [] + for identity, status in zip(requested, statuses, strict=True): + expected = self._identity_payload(identity) + if ( + not isinstance(status, dict) + or not set(status).issubset(allowed_status_keys) + or any( + status.get(key) != value for key, value in expected.items() + ) + or status.get("state") + not in {"available", "missing", "needs_rebind"} + ): + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned an invalid verification" + ) + result.append( + WorkspaceSecretVerification( + identity=identity, + state=status["state"], + ) + ) + return tuple(result) + + @staticmethod + def _identity_payload(identity: WorkspaceSecretIdentity) -> dict[str, str]: + return { + "secret_ref": identity.secret_ref, + "account_scope_digest": identity.account_scope_digest, + "space_id": identity.space_id, + "revision_id": identity.revision_id, + "slot_id": identity.slot_id, + } + + def _request(self, path: str, request: dict) -> dict: + encoded = json.dumps( + request, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + if len(encoded) > self.MAX_REQUEST_BYTES: + raise WorkspaceSecretBrokerError( + "Workspace secret broker request is too large" + ) + connection = HTTPConnection( + "127.0.0.1", + self._port, + timeout=self._timeout_seconds, + ) + try: + connection.request( + "POST", + path, + body=encoded, + headers={ + "Authorization": f"Bearer {self._capability}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + http_response = connection.getresponse() + content_length = http_response.getheader("Content-Length") + if content_length is not None: + try: + declared_length = int(content_length) + except ValueError as exc: + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned invalid metadata" + ) from exc + if declared_length > self.MAX_RESPONSE_BYTES: + raise WorkspaceSecretBrokerError( + "Workspace secret broker response is too large" + ) + raw = http_response.read(self.MAX_RESPONSE_BYTES + 1) + if len(raw) > self.MAX_RESPONSE_BYTES: + raise WorkspaceSecretBrokerError( + "Workspace secret broker response is too large" + ) + if http_response.status != 200: + raise WorkspaceSecretBrokerError( + "Workspace secret is unavailable " + f"(broker_status_{http_response.status})" + ) + except (OSError, TimeoutError, HTTPException) as exc: + raise WorkspaceSecretBrokerError( + "Workspace secret broker is unavailable" + ) from exc + finally: + connection.close() + try: + response = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned an invalid response" + ) from exc + if not isinstance(response, dict): + raise WorkspaceSecretBrokerError( + "Workspace secret broker returned an invalid response" + ) + return response diff --git a/backend/app/workspace_config/__init__.py b/backend/app/workspace_config/__init__.py index 8e9a6f2d..065a5dd3 100644 --- a/backend/app/workspace_config/__init__.py +++ b/backend/app/workspace_config/__init__.py @@ -32,6 +32,7 @@ from app.workspace_config.models import ( UnsafeCloudProjectionError, UnsupportedThinkingEffortError, WorkforceBundleManifest, + WorkspaceBundleReconfigurationPendingError, WorkspaceConfigError, WorkspaceLock, WorktreeMaterialization, @@ -60,6 +61,7 @@ __all__ = [ "UnsafeCloudProjectionError", "UnsupportedThinkingEffortError", "WorkforceBundleManifest", + "WorkspaceBundleReconfigurationPendingError", "WorkspaceLock", "WorkspaceConfigError", "WorktreeMaterialization", diff --git a/backend/app/workspace_config/admission.py b/backend/app/workspace_config/admission.py index 527def88..80e9c4c8 100644 --- a/backend/app/workspace_config/admission.py +++ b/backend/app/workspace_config/admission.py @@ -36,6 +36,7 @@ from app.workspace_config.models import ( ResolvedContextSource, ThinkingEffort, WorkforceBundleManifest, + WorkspaceBundleReconfigurationPendingError, canonical_digest, normalize_thinking_effort, ) @@ -300,10 +301,15 @@ class EnvironmentAdmissionService: installed_manifest = WorkforceBundleManifest.model_validate( revision.manifest ) - proposal = self.journal.get_materialized_workspace_bundle_proposal( + proposal = self.journal.get_active_workspace_bundle_proposal( space_id=space_id, revision_id=installed.revision_id, ) + if proposal is not None and proposal.state != "materialized": + raise WorkspaceBundleReconfigurationPendingError( + proposal_id=proposal.proposal_id, + state=proposal.state, + ) if proposal is not None: bindings = { item.slot_id: item diff --git a/backend/app/workspace_config/models.py b/backend/app/workspace_config/models.py index c1e4e1ef..7ae2cbb7 100644 --- a/backend/app/workspace_config/models.py +++ b/backend/app/workspace_config/models.py @@ -52,6 +52,20 @@ class UnsafeCloudProjectionError(WorkspaceConfigError): """Raised when a Cloud projection contains device-local identity.""" +class WorkspaceBundleReconfigurationPendingError(WorkspaceConfigError): + """Raised when an installed Bundle must be re-synced before Run admission.""" + + code = "workspace_bundle_reconfiguration_pending" + + def __init__(self, *, proposal_id: str, state: str) -> None: + self.proposal_id = proposal_id + self.state = state + super().__init__( + "Workspace Bundle local setup changed and must be synced before " + "starting another Run" + ) + + class ThinkingEffort(StrEnum): LOW = "low" MEDIUM = "medium" diff --git a/backend/tests/app/controller/test_workspace_bundle_controller.py b/backend/tests/app/controller/test_workspace_bundle_controller.py new file mode 100644 index 00000000..edf6cc3c --- /dev/null +++ b/backend/tests/app/controller/test_workspace_bundle_controller.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.controller import workspace_bundle_controller +from app.run_journal import SQLiteRunJournal +from app.workspace_bundle import WorkspaceSecretVerification + + +def test_electron_generated_secret_reference_matches_brain_contract(): + electron_generated_ref = f"wsvault_{'A' * 32}" + + binding = ( + workspace_bundle_controller.BundleLocalValueBinding.model_validate( + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": electron_generated_ref, + "account_scope_digest": "a" * 64, + } + ) + ) + + assert binding.secret_ref == electron_generated_ref + + +def test_brain_rejects_noncanonical_vault_references(): + with pytest.raises(ValidationError): + workspace_bundle_controller.BundleLocalValueBinding.model_validate( + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'A' * 31}", + "account_scope_digest": "a" * 64, + } + ) + + +def test_install_payload_masks_vault_references(tmp_path, monkeypatch): + journal = SQLiteRunJournal(tmp_path / "run-journal.sqlite3") + monkeypatch.setattr( + workspace_bundle_controller, + "get_default_run_journal", + lambda: journal, + ) + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-1", + request_id="proposal-request-1", + space_id="space-1", + bundle_id="bundle-1", + revision_id="bundle-1@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={ + "connector_slots": [], + "local_path_slots": [], + "script_actions": [], + "environment_requirements": [ + { + "requirement_key": "environment:API_TOKEN", + "name": "API_TOKEN", + "required": True, + "sensitive": True, + "description": "API token", + "example": None, + } + ], + "mcp_secret_requirements": [], + }, + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="approved", + decided_by="user-1", + ) + # Electron emits this exact opaque format: prefix plus 32 base64url chars. + secret_ref = f"wsvault_{'A' * 32}" + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="binding-request-1", + expected_proposal_version=proposal.version, + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": secret_ref, + "account_scope_digest": "a" * 64, + } + ], + authorized_by="user-1", + ) + + class AvailableBroker: + def __init__(self): + self.batches = [] + + def verify_many(self, identities): + self.batches.append(tuple(identities)) + return tuple( + WorkspaceSecretVerification( + identity=identity, + state="available", + ) + for identity in identities + ) + + available_broker = AvailableBroker() + + monkeypatch.setattr( + workspace_bundle_controller.WorkspaceSecretBroker, + "from_environment", + lambda: available_broker, + ) + + payload = workspace_bundle_controller._payload(proposal.proposal_id) + + assert payload["readiness"] == { + "ready": True, + "missing_requirements": [], + } + assert payload["value_requirements"][0]["configured"] is True + assert payload["value_requirements"][0]["available"] is True + assert payload["value_requirements"][0]["binding_version"] == 1 + assert secret_ref not in repr(payload) + assert "account_scope_digest" not in repr(payload) + assert len(available_broker.batches) == 1 + assert available_broker.batches[0][0].secret_ref == secret_ref + journal.close() + + +def test_install_payload_marks_missing_vault_value_unready( + tmp_path, monkeypatch +): + journal = SQLiteRunJournal(tmp_path / "run-journal.sqlite3") + monkeypatch.setattr( + workspace_bundle_controller, + "get_default_run_journal", + lambda: journal, + ) + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-missing", + request_id="proposal-request-missing", + space_id="space-1", + bundle_id="bundle-1", + revision_id="bundle-1@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={ + "connector_slots": [], + "local_path_slots": [], + "script_actions": [], + "environment_requirements": [ + { + "requirement_key": "environment:API_TOKEN", + "name": "API_TOKEN", + "required": True, + } + ], + "mcp_secret_requirements": [], + }, + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="approved", + decided_by="user-1", + ) + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="binding-request-missing", + expected_proposal_version=proposal.version, + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'M' * 32}", + "account_scope_digest": "a" * 64, + } + ], + authorized_by="user-1", + ) + + payload = workspace_bundle_controller._payload(proposal.proposal_id) + + assert payload["readiness"] == { + "ready": False, + "missing_requirements": ["environment:API_TOKEN"], + } + assert payload["value_requirements"][0]["configured"] is True + assert payload["value_requirements"][0]["available"] is False + journal.close() + + +def test_local_value_contract_rejects_plaintext_fields(): + with pytest.raises(ValidationError): + workspace_bundle_controller.BundleLocalValuesBody.model_validate( + { + "client_request_id": "request-1", + "expected_version": 1, + "actor_id": "user-1", + "bindings": [ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'P' * 32}", + "account_scope_digest": "a" * 64, + "value": "plaintext-must-never-enter-brain", + } + ], + } + ) + + +def test_space_installation_lookup_resumes_latest_non_rejected_proposal( + tmp_path, + monkeypatch, +): + journal = SQLiteRunJournal(tmp_path / "run-journal.sqlite3") + monkeypatch.setattr( + workspace_bundle_controller, + "get_default_run_journal", + lambda: journal, + ) + rejected = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-rejected", + request_id="request-rejected", + space_id="space-1", + bundle_id="bundle-old", + revision_id="bundle-old@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={}, + now=1, + ) + journal.transition_workspace_bundle_install_proposal( + rejected.proposal_id, + expected_version=rejected.version, + state="rejected", + decided_by="user-1", + now=2, + ) + active = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-active", + request_id="request-active", + space_id="space-1", + bundle_id="bundle-current", + revision_id="bundle-current@2", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={}, + now=3, + ) + + found = journal.get_latest_workspace_bundle_install_proposal( + space_id="space-1" + ) + + assert found is not None + assert found.proposal_id == active.proposal_id + journal.close() + + +@pytest.mark.asyncio +async def test_local_value_put_returns_only_the_exact_ref_replaced_by_cas( + tmp_path, + monkeypatch, +): + journal = SQLiteRunJournal(tmp_path / "run-journal.sqlite3") + monkeypatch.setattr( + workspace_bundle_controller, + "get_default_run_journal", + lambda: journal, + ) + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-cleanup", + request_id="proposal-cleanup-request", + space_id="space-1", + bundle_id="bundle-1", + revision_id="bundle-1@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={ + "connector_slots": [], + "local_path_slots": [], + "script_actions": [], + "environment_requirements": [], + "mcp_secret_requirements": [], + }, + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="approved", + decided_by="user-1", + ) + old_ref = f"wsvault_{'O' * 32}" + stored, proposal = journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="old-binding", + expected_proposal_version=proposal.version, + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": old_ref, + "account_scope_digest": "a" * 64, + } + ], + authorized_by="user-1", + ) + new_ref = f"wsvault_{'N' * 32}" + + class Installer: + def bind_local_values(self, proposal_id, **kwargs): + return journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal_id, + client_request_id=kwargs["client_request_id"], + expected_proposal_version=kwargs["expected_version"], + bindings=kwargs["bindings"], + authorized_by=kwargs["authorized_by"], + ) + + monkeypatch.setattr( + workspace_bundle_controller, + "_installer", + lambda: Installer(), + ) + response = await workspace_bundle_controller.bind_bundle_local_values( + proposal.proposal_id, + workspace_bundle_controller.BundleLocalValuesBody.model_validate( + { + "client_request_id": "replace-binding", + "expected_version": proposal.version, + "actor_id": "user-1", + "bindings": [ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": new_ref, + "account_scope_digest": "a" * 64, + "expected_binding_version": stored[0].binding_version, + } + ], + } + ), + ) + + assert response["cleanup_secret_refs"] == [old_ref] + assert new_ref not in response["cleanup_secret_refs"] + journal.close() diff --git a/backend/tests/app/run_journal/test_store.py b/backend/tests/app/run_journal/test_store.py index f5e5bf46..dc565427 100644 --- a/backend/tests/app/run_journal/test_store.py +++ b/backend/tests/app/run_journal/test_store.py @@ -832,6 +832,208 @@ def test_database_reopens_without_reapplying_or_losing_migration(tmp_path): assert reopened.get_run("run-1") is not None +def test_bundle_secret_bindings_persist_only_opaque_refs_and_replay_requests( + tmp_path, +): + path = tmp_path / "run-journal.sqlite3" + sentinel = "raw-secret-must-never-enter-journal" + with SQLiteRunJournal(path) as journal: + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-1", + request_id="proposal-request-1", + space_id="space-1", + bundle_id="bundle-1", + revision_id="bundle-1@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={}, + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="approved", + decided_by="user-1", + ) + first_ref = f"wsvault_{'A' * 32}" + second_ref = f"wsvault_{'B' * 32}" + bindings = [ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": first_ref, + "account_scope_digest": "a" * 64, + } + ] + stored, advanced = journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="bind-request-1", + expected_proposal_version=proposal.version, + bindings=bindings, + authorized_by="user-1", + ) + replay, replay_proposal = journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="bind-request-1", + expected_proposal_version=proposal.version, + bindings=bindings, + authorized_by="user-1", + ) + + assert replay == stored + assert replay_proposal == advanced + assert stored[0].secret_ref == first_ref + assert stored[0].binding_version == 1 + with pytest.raises(IdempotencyConflictError): + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="bind-request-1", + expected_proposal_version=advanced.version, + bindings=[ + { + **bindings[0], + "secret_ref": f"wsvault_{'C' * 32}", + } + ], + authorized_by="user-1", + ) + + replaced, replaced_proposal = ( + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="bind-request-2", + expected_proposal_version=advanced.version, + bindings=[ + { + **bindings[0], + "secret_ref": second_ref, + "expected_binding_version": 1, + } + ], + authorized_by="user-1", + ) + ) + assert replaced[0].binding_version == 2 + assert replaced[0].secret_ref == second_ref + assert replaced_proposal.version == advanced.version + 1 + with pytest.raises(OptimisticConcurrencyError): + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="bind-request-stale", + expected_proposal_version=replaced_proposal.version, + bindings=[ + { + **bindings[0], + "secret_ref": f"wsvault_{'D' * 32}", + "expected_binding_version": 1, + } + ], + authorized_by="user-1", + ) + + database_bytes = path.read_bytes() + wal = path.with_name(path.name + "-wal") + if wal.exists(): + database_bytes += wal.read_bytes() + assert sentinel.encode() not in database_bytes + + +def test_materialized_bundle_bindings_can_be_reconfigured_with_cas(tmp_path): + with SQLiteRunJournal(tmp_path / "run-journal.sqlite3") as journal: + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-installed", + request_id="proposal-installed-request", + space_id="space-1", + bundle_id="bundle-1", + revision_id="bundle-1@1", + config_placement="sidecar", + manifest={"spec": {}}, + assets=[], + install_plan={}, + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="approved", + decided_by="user-1", + ) + _, proposal = journal.put_workspace_bundle_local_binding( + proposal_id=proposal.proposal_id, + expected_proposal_version=proposal.version, + slot_id="docs", + binding_kind="local_path", + connector_id=None, + opaque_connection_id=None, + local_path="/first/docs", + required_grants=[], + authorized_by="user-1", + ) + secret_bindings, proposal = ( + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="secret-first", + expected_proposal_version=proposal.version, + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'A' * 32}", + "account_scope_digest": "a" * 64, + } + ], + authorized_by="user-1", + ) + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="materializing", + ) + proposal = journal.transition_workspace_bundle_install_proposal( + proposal.proposal_id, + expected_version=proposal.version, + state="materialized", + ) + + rebound_path, proposal = journal.put_workspace_bundle_local_binding( + proposal_id=proposal.proposal_id, + expected_proposal_version=proposal.version, + slot_id="docs", + binding_kind="local_path", + connector_id=None, + opaque_connection_id=None, + local_path="/replacement/docs", + required_grants=[], + authorized_by="user-2", + ) + rebound_secret, proposal = ( + journal.put_workspace_bundle_secret_bindings( + proposal_id=proposal.proposal_id, + client_request_id="secret-replacement", + expected_proposal_version=proposal.version, + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'B' * 32}", + "account_scope_digest": "a" * 64, + "expected_binding_version": secret_bindings[ + 0 + ].binding_version, + } + ], + authorized_by="user-2", + ) + ) + + assert proposal.state == "needs_attention" + assert proposal.error_code == "bundle_reconfiguration_pending" + assert rebound_path.local_path == "/replacement/docs" + assert rebound_path.authorized_by == "user-2" + assert rebound_secret[0].binding_version == 2 + assert rebound_secret[0].secret_ref == f"wsvault_{'B' * 32}" + + def test_v14_database_backfills_finite_expiry_for_pending_approval(tmp_path): path = tmp_path / "run-journal.sqlite3" with SQLiteRunJournal(path) as current: diff --git a/backend/tests/app/test_exception_handler_registration.py b/backend/tests/app/test_exception_handler_registration.py index 46328600..88e2b388 100644 --- a/backend/tests/app/test_exception_handler_registration.py +++ b/backend/tests/app/test_exception_handler_registration.py @@ -43,6 +43,29 @@ def test_user_exception_returns_api_error_instead_of_http_500(): } +def test_user_exception_can_return_a_stable_typed_error_code(): + test_api = FastAPI() + register_exception_handlers(test_api) + + @test_api.get("/workspace-bundle-pending") + async def workspace_bundle_pending(): + raise UserException( + 1, + "Sync local setup before starting another Run", + error_code="workspace_bundle_reconfiguration_pending", + ) + + with TestClient(test_api) as client: + response = client.get("/workspace-bundle-pending") + + assert response.status_code == 200 + assert response.json() == { + "code": 1, + "text": "Sync local setup before starting another Run", + "error_code": "workspace_bundle_reconfiguration_pending", + } + + def test_validation_handler_works_without_eager_translation_import(): test_api = FastAPI() register_exception_handlers(test_api) diff --git a/backend/tests/app/workspace_bundle/test_installer.py b/backend/tests/app/workspace_bundle/test_installer.py index 692e1df9..581a7758 100644 --- a/backend/tests/app/workspace_bundle/test_installer.py +++ b/backend/tests/app/workspace_bundle/test_installer.py @@ -10,6 +10,7 @@ from app.workspace_bundle import ( WorkspaceBundleBindingsIncomplete, WorkspaceBundleInstaller, WorkspaceBundleInstallError, + WorkspaceSecretVerification, ) from app.workspace_config import ( ConfigPlacement, @@ -239,6 +240,29 @@ class FakeCloud: return None +class FakeSecretBroker: + def __init__(self): + self.verified = [] + self.batches = [] + self.rejected: set[str] = set() + + def verify_many(self, identities): + batch = tuple(identities) + self.batches.append(batch) + self.verified.extend(batch) + return tuple( + WorkspaceSecretVerification( + identity=identity, + state=( + "needs_rebind" + if identity.secret_ref in self.rejected + else "available" + ), + ) + for identity in batch + ) + + @pytest.fixture def installer(tmp_path): journal = SQLiteRunJournal(tmp_path / "journal.sqlite3") @@ -518,6 +542,256 @@ async def test_materialize_rejects_secret_bearing_downloaded_script(installer): assert not (config_root / "skills/research.py").exists() +@pytest.mark.asyncio +async def test_required_local_values_block_before_cloud_and_optional_env_does_not( + installer, +): + service, journal, cloud, tmp_path = installer + manifest = _manifest() + manifest["spec"].update( + { + "instructions": {}, + "context": [], + "skills": [], + "connectors": [], + "environment": { + "variables": [ + { + "name": "API_TOKEN", + "required": True, + "sensitive": True, + }, + { + "name": "LOG_LEVEL", + "required": False, + "sensitive": False, + "example": "info", + }, + ] + }, + "mcpServers": [ + { + "id": "linear", + "definition": "registry://mcp/linear@1", + "secretSlots": ["LINEAR_API_TOKEN"], + "assignTo": ["lead"], + } + ], + } + ) + + async def get_revision(bundle_id, revision_id): + canonical = WorkforceBundleManifest.model_validate( + manifest + ).canonical_payload() + return { + "id": revision_id, + "bundle_id": bundle_id, + "status": "published", + "manifest": canonical, + "manifest_digest": canonical_digest(canonical), + "assets": [], + } + + cloud.get_revision = get_revision + broker = FakeSecretBroker() + service.secret_broker = broker + proposal = await service.propose( + proposal_id="proposal-values", + request_id="request-values", + space_id="space-1", + bundle_id="bundle-research", + revision_id="bundle-research@1", + config_placement=ConfigPlacement.SIDECAR, + ) + assert proposal.install_plan["environment_requirements"] == [ + { + "requirement_key": "environment:API_TOKEN", + "name": "API_TOKEN", + "required": True, + "sensitive": True, + "description": None, + "example": None, + }, + { + "requirement_key": "environment:LOG_LEVEL", + "name": "LOG_LEVEL", + "required": False, + "sensitive": False, + "description": None, + "example": "info", + }, + ] + assert proposal.install_plan["mcp_secret_requirements"] == [ + { + "requirement_key": "mcp_secret:linear:LINEAR_API_TOKEN", + "mcp_id": "linear", + "slot_id": "LINEAR_API_TOKEN", + "required": True, + } + ] + proposal = service.decide( + proposal.proposal_id, + expected_version=proposal.version, + approved=True, + decided_by="user-1", + ) + + with pytest.raises(WorkspaceBundleBindingsIncomplete) as missing: + await service.materialize( + proposal.proposal_id, + expected_version=proposal.version, + space_root=tmp_path, + actor_id="user-1", + ) + assert set(missing.value.missing_slots) == { + "environment:API_TOKEN", + "mcp.server.start:linear", + "mcp_secret:linear:LINEAR_API_TOKEN", + } + assert cloud.installed_bundle_id is None + + _, proposal = service.approve_script_action( + proposal.proposal_id, + expected_version=proposal.version, + action_id="mcp.server.start:linear", + authorized_by="user-1", + ) + + _, proposal = service.bind_local_values( + proposal.proposal_id, + client_request_id="bind-values-1", + expected_version=proposal.version, + authorized_by="user-1", + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'E' * 32}", + "account_scope_digest": "a" * 64, + }, + { + "requirement_key": "mcp_secret:linear:LINEAR_API_TOKEN", + "requirement_kind": "mcp_secret", + "secret_ref": f"wsvault_{'M' * 32}", + "account_scope_digest": "a" * 64, + }, + ], + ) + result = await service.materialize( + proposal.proposal_id, + expected_version=proposal.version, + space_root=tmp_path, + actor_id="user-1", + ) + + assert result.state == "materialized" + assert {item.slot_id for item in broker.verified} == { + "environment:API_TOKEN", + "mcp_secret:linear:LINEAR_API_TOKEN", + } + assert [len(batch) for batch in broker.batches] == [2, 2] + + +@pytest.mark.asyncio +async def test_unreadable_bound_value_blocks_all_cloud_side_effects(installer): + service, _, cloud, tmp_path = installer + manifest = _manifest() + manifest["spec"]["context"] = [] + manifest["spec"]["skills"] = [] + manifest["spec"]["connectors"] = [] + manifest["spec"]["environment"] = { + "variables": [{"name": "API_TOKEN", "required": True}] + } + + async def get_revision(bundle_id, revision_id): + canonical = WorkforceBundleManifest.model_validate( + manifest + ).canonical_payload() + return { + "id": revision_id, + "bundle_id": bundle_id, + "status": "published", + "manifest": canonical, + "manifest_digest": canonical_digest(canonical), + "assets": [], + } + + cloud.get_revision = get_revision + broker = FakeSecretBroker() + service.secret_broker = broker + proposal = await service.propose( + proposal_id="proposal-unreadable", + request_id="request-unreadable", + space_id="space-1", + bundle_id="bundle-research", + revision_id="bundle-research@1", + config_placement=ConfigPlacement.SIDECAR, + ) + proposal = service.decide( + proposal.proposal_id, + expected_version=proposal.version, + approved=True, + decided_by="user-1", + ) + _, proposal = service.bind_local_values( + proposal.proposal_id, + client_request_id="bind-unreadable", + expected_version=proposal.version, + authorized_by="user-1", + bindings=[ + { + "requirement_key": "environment:API_TOKEN", + "requirement_kind": "environment", + "secret_ref": f"wsvault_{'U' * 32}", + "account_scope_digest": "a" * 64, + } + ], + ) + broker.rejected.add(f"wsvault_{'U' * 32}") + + with pytest.raises(WorkspaceBundleBindingsIncomplete): + await service.materialize( + proposal.proposal_id, + expected_version=proposal.version, + space_root=tmp_path, + actor_id="user-1", + ) + + assert cloud.installed_bundle_id is None + assert cloud.projection is None + assert [len(batch) for batch in broker.batches] == [1, 1] + + +def test_legacy_install_plan_without_local_value_requirements_is_supported( + installer, +): + service, journal, _, _ = installer + proposal = journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-legacy-plan", + request_id="request-legacy-plan", + space_id="space-1", + bundle_id="bundle-research", + revision_id="bundle-research@1", + config_placement="sidecar", + manifest=_manifest(), + assets=[], + install_plan={ + "connector_slots": [], + "local_path_slots": [], + "script_actions": [], + }, + ) + proposal = service.decide( + proposal.proposal_id, + expected_version=proposal.version, + approved=True, + decided_by="user-1", + ) + + service._require_complete_bindings(proposal, (), ()) + + @pytest.mark.asyncio async def test_projection_response_loss_retries_without_duplicate_grants( tmp_path, @@ -634,6 +908,52 @@ async def test_upgrade_reviews_again_and_commits_new_configuration(installer): ) +@pytest.mark.asyncio +async def test_materialized_local_rebind_requires_and_completes_cloud_resync( + installer, +): + service, journal, cloud, tmp_path = installer + proposal = await _approved_and_bound(service, journal, tmp_path) + installed = await service.materialize( + proposal.proposal_id, + expected_version=proposal.version, + space_root=tmp_path, + actor_id="user-1", + ) + first_projection_id = cloud.projection["projection_id"] + replacement = tmp_path / "replacement-docs" + replacement.mkdir() + + _, pending = service.bind_connector( + installed.proposal_id, + expected_version=installed.version, + slot_id="github_readonly", + connector_id="github", + opaque_connection_id="connection-2", + authorized_by="user-1", + ) + _, pending = service.bind_local_path( + installed.proposal_id, + expected_version=pending.version, + slot_id="docs_folder", + local_path=replacement, + authorized_by="user-1", + ) + + assert pending.state == "needs_attention" + assert pending.error_code == "bundle_reconfiguration_pending" + resynced = await service.materialize( + pending.proposal_id, + expected_version=pending.version, + space_root=tmp_path, + actor_id="user-1", + ) + assert resynced.state == "materialized" + assert cloud.projection["projection_id"] != first_projection_id + assert len(cloud.projections) == 2 + assert cloud.binding == ("github_readonly", "connection-2") + + def test_startup_reconciliation_exposes_interrupted_materialization(tmp_path): path = tmp_path / "journal.sqlite3" with SQLiteRunJournal(path) as journal: diff --git a/backend/tests/app/workspace_bundle/test_secrets.py b/backend/tests/app/workspace_bundle/test_secrets.py new file mode 100644 index 00000000..064a80ba --- /dev/null +++ b/backend/tests/app/workspace_bundle/test_secrets.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from app.workspace_bundle import ( + WorkspaceSecretBroker, + WorkspaceSecretBrokerError, + WorkspaceSecretIdentity, +) +from app.workspace_bundle.secrets import _capture_broker_environment + + +def _serve_once( + response: dict, *, status_code: int = 200 +) -> tuple[str, list[dict], threading.Thread]: + requests: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length)) + requests.append( + { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "content_type": self.headers.get("Content-Type"), + "body": body, + } + ) + encoded = json.dumps(response).encode() + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format, *args): + return + + server = HTTPServer(("127.0.0.1", 0), Handler) + port = server.server_port + + def serve() -> None: + try: + server.handle_request() + finally: + server.server_close() + + thread = threading.Thread(target=serve) + thread.start() + return f"http://127.0.0.1:{port}", requests, thread + + +def _identity() -> WorkspaceSecretIdentity: + return WorkspaceSecretIdentity( + secret_ref="wsvault_example-reference", + account_scope_digest="a" * 64, + space_id="space-1", + revision_id="bundle-1@1", + slot_id="environment:API_TOKEN", + ) + + +def test_secret_broker_binds_capability_and_full_identity(): + identity = _identity() + endpoint, requests, thread = _serve_once( + { + "statuses": [ + { + **identity.__dict__, + "state": "available", + } + ] + } + ) + broker = WorkspaceSecretBroker( + endpoint=endpoint, + capability="x" * 43, + ) + + broker.verify(identity) + thread.join(timeout=2) + + assert requests == [ + { + "path": "/v1/workspace-secrets/verify-batch", + "authorization": "Bearer " + "x" * 43, + "content_type": "application/json", + "body": { + "bindings": [ + { + "secret_ref": "wsvault_example-reference", + "account_scope_digest": "a" * 64, + "space_id": "space-1", + "revision_id": "bundle-1@1", + "slot_id": "environment:API_TOKEN", + } + ] + }, + } + ] + + +def test_secret_broker_fails_closed_without_returning_error_payload(): + sentinel = "must-not-appear-in-error" + endpoint, _, thread = _serve_once( + {"error_code": "scope_mismatch", "value": sentinel}, + status_code=403, + ) + broker = WorkspaceSecretBroker( + endpoint=endpoint, + capability="x" * 43, + ) + + with pytest.raises(WorkspaceSecretBrokerError) as caught: + broker.verify(_identity()) + thread.join(timeout=2) + + assert sentinel not in str(caught.value) + assert not hasattr(broker, "resolve") + + +@pytest.mark.parametrize( + "endpoint", + [ + "http://0.0.0.0:1234", + "https://127.0.0.1:1234", + "http://localhost:1234", + "http://127.0.0.1:not-a-port", + "http://127.0.0.1:1234/path", + "http://[invalid", + ], +) +def test_secret_broker_rejects_non_loopback_or_invalid_endpoints(endpoint): + with pytest.raises(WorkspaceSecretBrokerError): + WorkspaceSecretBroker( + endpoint=endpoint, + capability="x" * 43, + ) + + +def test_secret_broker_rejects_mismatched_verification_identity(): + endpoint, _, thread = _serve_once( + { + "statuses": [ + { + **_identity().__dict__, + "space_id": "different-space", + "state": "available", + } + ] + } + ) + broker = WorkspaceSecretBroker(endpoint=endpoint, capability="x" * 43) + + with pytest.raises(WorkspaceSecretBrokerError) as caught: + broker.verify(_identity()) + thread.join(timeout=2) + + assert "invalid verification" in str(caught.value) + + +def test_secret_broker_batch_preserves_partial_states_without_values(): + identities = tuple( + WorkspaceSecretIdentity( + **{ + **_identity().__dict__, + "secret_ref": f"wsvault_{index:032d}", + "slot_id": f"environment:SLOT_{index}", + } + ) + for index in range(3) + ) + endpoint, requests, thread = _serve_once( + { + "statuses": [ + {**identities[0].__dict__, "state": "available"}, + {**identities[1].__dict__, "state": "missing"}, + {**identities[2].__dict__, "state": "needs_rebind"}, + ] + } + ) + broker = WorkspaceSecretBroker(endpoint=endpoint, capability="x" * 43) + + verifications = broker.verify_many(identities) + thread.join(timeout=2) + + assert [item.state for item in verifications] == [ + "available", + "missing", + "needs_rebind", + ] + assert len(requests) == 1 + assert requests[0]["path"] == "/v1/workspace-secrets/verify-batch" + assert "value" not in repr(verifications) + + +def test_secret_broker_rejects_value_bearing_batch_response(): + sentinel = "must-never-cross-broker" + identity = _identity() + endpoint, _, thread = _serve_once( + { + "statuses": [ + { + **identity.__dict__, + "state": "available", + "value": sentinel, + } + ] + } + ) + broker = WorkspaceSecretBroker(endpoint=endpoint, capability="x" * 43) + + with pytest.raises(WorkspaceSecretBrokerError) as caught: + broker.verify_many((identity,)) + thread.join(timeout=2) + + assert sentinel not in str(caught.value) + + +def test_secret_broker_rejects_more_than_100_without_network_io(): + broker = WorkspaceSecretBroker( + endpoint="http://127.0.0.1:1", + capability="x" * 43, + ) + + with pytest.raises(WorkspaceSecretBrokerError, match="batch is too large"): + broker.verify_many(tuple(_identity() for _ in range(101))) + + +def test_secret_broker_authority_is_removed_from_child_environment(): + environment = { + "PATH": "/usr/bin", + "EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT": "http://127.0.0.1:1234", + "EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY": "x" * 43, + "EIGENT_WORKFORCE_SECRET_BROKER_ENDPOINT": "legacy-endpoint", + "EIGENT_WORKFORCE_SECRET_BROKER_CAPABILITY": "legacy-capability", + } + + captured = _capture_broker_environment(environment) + + assert captured == ("http://127.0.0.1:1234", "x" * 43) + assert environment == {"PATH": "/usr/bin"} diff --git a/backend/tests/app/workspace_config/test_admission.py b/backend/tests/app/workspace_config/test_admission.py index acd2de39..d4e02031 100644 --- a/backend/tests/app/workspace_config/test_admission.py +++ b/backend/tests/app/workspace_config/test_admission.py @@ -3,8 +3,14 @@ from __future__ import annotations import hashlib import json +import pytest + from app.run_journal import SQLiteRunJournal -from app.workspace_config import ThinkingEffort, WorkforceBundleManifest +from app.workspace_config import ( + ThinkingEffort, + WorkforceBundleManifest, + WorkspaceBundleReconfigurationPendingError, +) from app.workspace_config.admission import ( EnvironmentAdmissionService, LegacyEnvironmentImporter, @@ -383,7 +389,7 @@ def test_materialized_bundle_replaces_legacy_template_for_new_run(tmp_path): expected_version=proposal.version, state="materializing", ) - journal.transition_workspace_bundle_install_proposal( + proposal = journal.transition_workspace_bundle_install_proposal( proposal.proposal_id, expected_version=proposal.version, state="materialized", @@ -425,3 +431,90 @@ def test_materialized_bundle_replaces_legacy_template_for_new_run(tmp_path): event_json = json.dumps(event.payload) assert "private-connection-id" not in event_json assert str(docs) not in event_json + + journal.put_workspace_bundle_install_proposal( + proposal_id="proposal-review-only", + request_id="install-review-only", + space_id="space-1", + bundle_id="bundle-team", + revision_id="bundle-team@1", + config_placement="sidecar", + manifest=manifest, + assets=[], + install_plan=proposal.install_plan, + ) + journal.ensure_run( + run_id="run-while-reviewing", + project_id="project-1", + status="pending", + ) + while_reviewing = EnvironmentAdmissionService(journal).persist_for_run( + run_id="run-while-reviewing", + space_id="space-1", + working_directory=tmp_path, + created_by="user-1", + template=legacy, + ) + assert while_reviewing.binding.bundle_revision_id == "bundle-team@1" + assert while_reviewing.spec.local_materialization.connector_bindings[ + 0 + ].local_binding_id == "private-connection-id" + + _, pending = journal.put_workspace_bundle_local_binding( + proposal_id=proposal.proposal_id, + expected_proposal_version=proposal.version, + slot_id="github_readonly", + binding_kind="connector", + connector_id="github", + opaque_connection_id="replacement-connection-id", + local_path=None, + required_grants=["repository.read"], + authorized_by="user-1", + ) + assert pending.state == "needs_attention" + assert pending.error_code == "bundle_reconfiguration_pending" + journal.ensure_run( + run_id="run-after-rebind", + project_id="project-1", + status="pending", + ) + + with pytest.raises( + WorkspaceBundleReconfigurationPendingError, + match="must be synced", + ) as error: + EnvironmentAdmissionService(journal).persist_for_run( + run_id="run-after-rebind", + space_id="space-1", + working_directory=tmp_path, + created_by="user-1", + template=legacy, + ) + + assert error.value.code == "workspace_bundle_reconfiguration_pending" + assert error.value.proposal_id == proposal.proposal_id + assert journal.list_events("run-after-rebind") == [] + + materializing = journal.transition_workspace_bundle_install_proposal( + pending.proposal_id, + expected_version=pending.version, + state="materializing", + ) + journal.ensure_run( + run_id="run-during-resync", + project_id="project-1", + status="pending", + ) + with pytest.raises( + WorkspaceBundleReconfigurationPendingError, + match="must be synced", + ) as resync_error: + EnvironmentAdmissionService(journal).persist_for_run( + run_id="run-during-resync", + space_id="space-1", + working_directory=tmp_path, + created_by="user-1", + template=legacy, + ) + assert resync_error.value.state == materializing.state + assert journal.list_events("run-during-resync") == [] diff --git a/electron/main/index.ts b/electron/main/index.ts index 3ac9e53c..cea8114a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -83,6 +83,12 @@ import { isBinaryExists, } from './utils/process'; import { WebViewManager } from './webview'; +import { + closeWorkspaceSecretBroker, + ensureWorkspaceSecretBroker, + getDefaultWorkspaceSecretVault, + registerWorkspaceSecretIpcHandlers, +} from './workspaceSecrets'; const userData = app.getPath('userData'); @@ -860,6 +866,11 @@ const checkManagerInstance = (manager: any, name: string) => { function registerIpcHandlers() { registerCodexSubscriptionAuthIpcHandlers(ipcMain); registerTerminalIpcHandlers(); + registerWorkspaceSecretIpcHandlers( + ipcMain, + getDefaultWorkspaceSecretVault(), + assertMainRendererSender + ); // ==================== auth callback ==================== ipcMain.handle('get-auth-callback-url', async () => { @@ -3377,6 +3388,7 @@ const checkAndStartBackend = async ( if (isToolInstalled.success) { log.info('Tool installed, starting backend service...'); const codexResolverEnv = await getCodexResolverEnv(); + const workspaceSecretBroker = await ensureWorkspaceSecretBroker(); const exampleSkillsDir = getExampleSkillsSourceDir(); // Start backend and wait for health check to pass @@ -3390,6 +3402,10 @@ const checkAndStartBackend = async ( EIGENT_EXAMPLE_SKILLS_DIR: exampleSkillsDir, EIGENT_LOCAL_CONTROL_CAPABILITY: localControlCapability, EIGENT_DESKTOP_INSTANCE_ID: resolveDesktopInstanceId(), + EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT: + workspaceSecretBroker.endpoint, + EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY: + workspaceSecretBroker.capability, } ); @@ -3735,6 +3751,7 @@ app.on('before-quit', async (event) => { // Wait for Python process cleanup await cleanupPythonProcess(); + await closeWorkspaceSecretBroker(); // Clean up file reader if exists if (fileReader) { diff --git a/electron/main/terminal.ts b/electron/main/terminal.ts index aee77490..595df3cc 100644 --- a/electron/main/terminal.ts +++ b/electron/main/terminal.ts @@ -76,7 +76,7 @@ function defaultShell(): { file: string; args: string[] } { * proxy configuration, and toolchain settings. */ const SENSITIVE_ENV_NAME = - /(?:^|_)(?:API_KEY|TOKEN|PASSWORD|PASSWD|SECRET|PRIVATE_KEY)(?:$|_)/i; + /(?:^|_)(?:API_KEY|TOKEN|PASSWORD|PASSWD|SECRET|PRIVATE_KEY|CAPABILITY)(?:$|_)/i; export function terminalEnvironment( environment: NodeJS.ProcessEnv = process.env diff --git a/electron/main/workspaceSecrets/broker.ts b/electron/main/workspaceSecrets/broker.ts new file mode 100644 index 00000000..9b68a71d --- /dev/null +++ b/electron/main/workspaceSecrets/broker.ts @@ -0,0 +1,247 @@ +// ========= 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. ========= + +import crypto from 'node:crypto'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import type { + WorkspaceSecretBrokerRuntime, + WorkspaceSecretLookup, +} from './types'; +import { + WorkspaceSecretBindingMismatchError, + WorkspaceSecretVault, + WorkspaceSecretVaultError, +} from './vault'; + +const LOOPBACK_HOST = '127.0.0.1'; +const MAX_REQUEST_BODY_BYTES = 16 * 1024; +const MAX_BATCH_BINDINGS = 100; +const REQUEST_TIMEOUT_MS = 5_000; + +function sendJson( + response: ServerResponse, + status: number, + body: Record +): void { + const value = JSON.stringify(body); + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(value), + pragma: 'no-cache', + }); + response.end(value); +} + +function isAuthorized(request: IncomingMessage, capability: string): boolean { + const value = request.headers.authorization; + if (typeof value !== 'string') return false; + const actual = Buffer.from(value, 'utf8'); + const expected = Buffer.from(`Bearer ${capability}`, 'utf8'); + return ( + actual.length === expected.length && + crypto.timingSafeEqual(actual, expected) + ); +} + +function parseLookup(value: unknown): WorkspaceSecretLookup { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new WorkspaceSecretVaultError('Invalid workspace secret request'); + } + const body = value as Record; + const values = [ + body.secret_ref, + body.account_scope_digest, + body.space_id, + body.revision_id, + body.slot_id, + ]; + if (values.some((item) => typeof item !== 'string')) { + throw new WorkspaceSecretVaultError('Invalid workspace secret request'); + } + return { + secret_ref: body.secret_ref as string, + account_scope_digest: body.account_scope_digest as string, + space_id: body.space_id as string, + revision_id: body.revision_id as string, + slot_id: body.slot_id as string, + }; +} + +function parseLookupBatch(value: unknown): WorkspaceSecretLookup[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new WorkspaceSecretVaultError('Invalid workspace secret request'); + } + const bindings = (value as Record).bindings; + if ( + !Array.isArray(bindings) || + bindings.length === 0 || + bindings.length > MAX_BATCH_BINDINGS + ) { + throw new WorkspaceSecretVaultError('Invalid workspace secret batch'); + } + return bindings.map(parseLookup); +} + +async function readJsonBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + request.setTimeout(REQUEST_TIMEOUT_MS, () => { + fail(new WorkspaceSecretVaultError('Workspace secret request timed out')); + request.destroy(); + }); + request.on('data', (chunk: Buffer | string) => { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_REQUEST_BODY_BYTES) { + fail( + new WorkspaceSecretVaultError('Workspace secret request too large') + ); + chunks.length = 0; + request.removeAllListeners('data'); + request.resume(); + return; + } + chunks.push(buffer); + }); + request.on('error', fail); + request.on('end', () => { + if (settled) return; + settled = true; + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new WorkspaceSecretVaultError('Invalid JSON request')); + } + }); + }); +} + +/** + * A process-local verification channel for the Brain. + * + * Deliberately does not expose decryption or secret values. Runtime secret + * injection requires a stronger process-bound design and must not be added to + * this loopback bearer service. + */ +export class WorkspaceSecretBroker { + private server: http.Server | null = null; + private runtime: WorkspaceSecretBrokerRuntime | null = null; + + constructor( + private readonly vault: WorkspaceSecretVault, + private readonly randomBytes: (size: number) => Buffer = crypto.randomBytes + ) {} + + async start(): Promise { + if (this.runtime) return this.runtime; + const capability = this.randomBytes(32).toString('base64url'); + const server = http.createServer((request, response) => { + void this.handle(request, response, capability); + }); + server.requestTimeout = REQUEST_TIMEOUT_MS; + server.headersTimeout = REQUEST_TIMEOUT_MS; + server.keepAliveTimeout = 1_000; + server.maxRequestsPerSocket = 100; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, LOOPBACK_HOST, () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Workspace secret broker did not bind a TCP port'); + } + this.server = server; + this.runtime = { + endpoint: `http://${LOOPBACK_HOST}:${address.port}`, + capability, + close: async () => this.close(), + }; + return this.runtime; + } + + async close(): Promise { + const server = this.server; + this.server = null; + this.runtime = null; + if (!server) return; + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections?.(); + }); + } + + private async handle( + request: IncomingMessage, + response: ServerResponse, + capability: string + ): Promise { + response.setHeader('connection', 'close'); + if (!isAuthorized(request, capability)) { + sendJson(response, 401, { error_code: 'unauthorized' }); + return; + } + if (request.method !== 'POST') { + sendJson(response, 405, { error_code: 'method_not_allowed' }); + return; + } + const isSingleVerify = request.url === '/v1/workspace-secrets/verify'; + const isBatchVerify = request.url === '/v1/workspace-secrets/verify-batch'; + if (!isSingleVerify && !isBatchVerify) { + sendJson(response, 404, { error_code: 'not_found' }); + return; + } + try { + const body = await readJsonBody(request); + if (isBatchVerify) { + const statuses = parseLookupBatch(body).map((lookup) => + this.vault.status(lookup) + ); + sendJson(response, 200, { statuses }); + } else { + const status = this.vault.status(parseLookup(body)); + const code = + status.state === 'available' + ? 200 + : status.state === 'needs_rebind' + ? 409 + : 404; + sendJson(response, code, { status }); + } + } catch (error) { + if (error instanceof WorkspaceSecretBindingMismatchError) { + sendJson(response, 403, { + error_code: 'binding_scope_mismatch', + }); + } else if (error instanceof WorkspaceSecretVaultError) { + const status = error.message.includes('too large') ? 413 : 400; + sendJson(response, status, { error_code: 'invalid_request' }); + } else { + sendJson(response, 500, { error_code: 'secret_broker_failed' }); + } + } + } +} diff --git a/electron/main/workspaceSecrets/index.ts b/electron/main/workspaceSecrets/index.ts new file mode 100644 index 00000000..84352459 --- /dev/null +++ b/electron/main/workspaceSecrets/index.ts @@ -0,0 +1,24 @@ +export { WorkspaceSecretBroker } from './broker'; +export { registerWorkspaceSecretIpcHandlers } from './ipc'; +export { + closeWorkspaceSecretBroker, + ensureWorkspaceSecretBroker, + getDefaultWorkspaceSecretVault, +} from './runtime'; +export type { + WorkspaceSecretBrokerRuntime, + WorkspaceSecretLookup, + WorkspaceSecretPutRequest, + WorkspaceSecretPutResult, + WorkspaceSecretScope, + WorkspaceSecretState, + WorkspaceSecretStatus, +} from './types'; +export { + MAX_WORKSPACE_SECRET_BYTES, + WorkspaceSecretBindingMismatchError, + WorkspaceSecretNeedsRebindError, + WorkspaceSecretNotFoundError, + WorkspaceSecretVault, + WorkspaceSecretVaultError, +} from './vault'; diff --git a/electron/main/workspaceSecrets/ipc.ts b/electron/main/workspaceSecrets/ipc.ts new file mode 100644 index 00000000..a18b6243 --- /dev/null +++ b/electron/main/workspaceSecrets/ipc.ts @@ -0,0 +1,47 @@ +// ========= 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. ========= + +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { WorkspaceSecretLookup, WorkspaceSecretPutRequest } from './types'; +import type { WorkspaceSecretVault } from './vault'; + +export type WorkspaceSecretIpcGuard = (event: IpcMainInvokeEvent) => void; + +export function registerWorkspaceSecretIpcHandlers( + ipcMain: IpcMain, + vault: WorkspaceSecretVault, + guard: WorkspaceSecretIpcGuard +): void { + ipcMain.handle( + 'workspace-secret:put', + (event, request: WorkspaceSecretPutRequest) => { + guard(event); + return vault.put(request); + } + ); + ipcMain.handle( + 'workspace-secret:status', + (event, request: WorkspaceSecretLookup) => { + guard(event); + return vault.status(request); + } + ); + ipcMain.handle( + 'workspace-secret:delete', + (event, request: WorkspaceSecretLookup) => { + guard(event); + return vault.delete(request); + } + ); +} diff --git a/electron/main/workspaceSecrets/runtime.ts b/electron/main/workspaceSecrets/runtime.ts new file mode 100644 index 00000000..4c96b192 --- /dev/null +++ b/electron/main/workspaceSecrets/runtime.ts @@ -0,0 +1,45 @@ +// ========= 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. ========= + +import { WorkspaceSecretBroker } from './broker'; +import type { WorkspaceSecretBrokerRuntime } from './types'; +import { WorkspaceSecretVault } from './vault'; + +let defaultVault: WorkspaceSecretVault | null = null; +let defaultBroker: WorkspaceSecretBroker | null = null; +let brokerStart: Promise | null = null; + +export function getDefaultWorkspaceSecretVault(): WorkspaceSecretVault { + defaultVault ??= new WorkspaceSecretVault(); + return defaultVault; +} + +export function ensureWorkspaceSecretBroker(): Promise { + if (!brokerStart) { + defaultBroker = new WorkspaceSecretBroker(getDefaultWorkspaceSecretVault()); + brokerStart = defaultBroker.start().catch((error) => { + defaultBroker = null; + brokerStart = null; + throw error; + }); + } + return brokerStart; +} + +export async function closeWorkspaceSecretBroker(): Promise { + const broker = defaultBroker; + defaultBroker = null; + brokerStart = null; + await broker?.close(); +} diff --git a/electron/main/workspaceSecrets/types.ts b/electron/main/workspaceSecrets/types.ts new file mode 100644 index 00000000..bd45bedd --- /dev/null +++ b/electron/main/workspaceSecrets/types.ts @@ -0,0 +1,46 @@ +// ========= 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. ========= + +export interface WorkspaceSecretScope { + account_scope_digest: string; + space_id: string; + revision_id: string; + slot_id: string; +} + +export interface WorkspaceSecretLookup extends WorkspaceSecretScope { + secret_ref: string; +} + +export interface WorkspaceSecretPutRequest extends WorkspaceSecretScope { + value: string; +} + +export type WorkspaceSecretState = 'available' | 'missing' | 'needs_rebind'; + +export interface WorkspaceSecretStatus extends WorkspaceSecretLookup { + state: WorkspaceSecretState; + created_at?: string; + updated_at?: string; +} + +export interface WorkspaceSecretPutResult extends WorkspaceSecretStatus { + state: 'available'; +} + +export interface WorkspaceSecretBrokerRuntime { + endpoint: string; + capability: string; + close: () => Promise; +} diff --git a/electron/main/workspaceSecrets/vault.ts b/electron/main/workspaceSecrets/vault.ts new file mode 100644 index 00000000..92d5bb7b --- /dev/null +++ b/electron/main/workspaceSecrets/vault.ts @@ -0,0 +1,426 @@ +// ========= 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. ========= + +import { safeStorage } from 'electron'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { + WorkspaceSecretLookup, + WorkspaceSecretPutRequest, + WorkspaceSecretPutResult, + WorkspaceSecretScope, + WorkspaceSecretStatus, +} from './types'; + +const FORMAT_VERSION = 1; +const VAULT_FILE_NAME = 'workforce-secret-vault.v1.json'; +const MAX_VAULT_BYTES = 8 * 1024 * 1024; +export const MAX_WORKSPACE_SECRET_BYTES = 64 * 1024; + +interface WorkspaceSecretCrypto { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; + getSelectedStorageBackend?(): string; +} + +interface EncryptedWorkspaceSecretRecord extends WorkspaceSecretScope { + secret_ref: string; + ciphertext: string; + created_at: string; + updated_at: string; +} + +interface VaultDocument { + version: 1; + records: Record; +} + +interface VaultReadResult { + document: VaultDocument | null; + corrupted: boolean; +} + +export interface WorkspaceSecretVaultOptions { + rootDir?: string; + crypto?: WorkspaceSecretCrypto; + platform?: NodeJS.Platform; + now?: () => Date; + randomBytes?: (size: number) => Buffer; +} + +export class WorkspaceSecretVaultError extends Error {} +export class WorkspaceSecretBindingMismatchError extends WorkspaceSecretVaultError {} +export class WorkspaceSecretNeedsRebindError extends WorkspaceSecretVaultError {} +export class WorkspaceSecretNotFoundError extends WorkspaceSecretVaultError {} + +function defaultCrypto(): WorkspaceSecretCrypto { + return { + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), + encryptString: (value) => safeStorage.encryptString(value), + decryptString: (value) => safeStorage.decryptString(value), + getSelectedStorageBackend: + typeof safeStorage.getSelectedStorageBackend === 'function' + ? () => safeStorage.getSelectedStorageBackend() + : undefined, + }; +} + +function hasOnlySafeIdentityCharacters(value: string): boolean { + return ( + value.length > 0 && + value.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +function assertScope(scope: WorkspaceSecretScope): void { + if (!/^[a-f0-9]{64}$/u.test(scope.account_scope_digest)) { + throw new WorkspaceSecretVaultError( + 'Workspace secret account scope digest is invalid' + ); + } + for (const [name, value] of Object.entries({ + space_id: scope.space_id, + revision_id: scope.revision_id, + slot_id: scope.slot_id, + })) { + if (!hasOnlySafeIdentityCharacters(value)) { + throw new WorkspaceSecretVaultError( + `Workspace secret ${name} is invalid` + ); + } + } +} + +function sameScope( + record: WorkspaceSecretScope, + expected: WorkspaceSecretScope +): boolean { + return ( + record.account_scope_digest === expected.account_scope_digest && + record.space_id === expected.space_id && + record.revision_id === expected.revision_id && + record.slot_id === expected.slot_id + ); +} + +function isEncryptedRecord( + value: unknown +): value is EncryptedWorkspaceSecretRecord { + if (!value || typeof value !== 'object') return false; + const record = value as Partial; + return ( + typeof record.secret_ref === 'string' && + typeof record.account_scope_digest === 'string' && + typeof record.space_id === 'string' && + typeof record.revision_id === 'string' && + typeof record.slot_id === 'string' && + typeof record.ciphertext === 'string' && + typeof record.created_at === 'string' && + typeof record.updated_at === 'string' + ); +} + +export class WorkspaceSecretVault { + readonly rootDir: string; + readonly filePath: string; + private readonly encryption: WorkspaceSecretCrypto; + private readonly platform: NodeJS.Platform; + private readonly now: () => Date; + private readonly randomBytes: (size: number) => Buffer; + + constructor(options: WorkspaceSecretVaultOptions = {}) { + this.rootDir = + options.rootDir ?? path.join(os.homedir(), '.eigent', 'secure'); + this.filePath = path.join(this.rootDir, VAULT_FILE_NAME); + this.encryption = options.crypto ?? defaultCrypto(); + this.platform = options.platform ?? process.platform; + this.now = options.now ?? (() => new Date()); + this.randomBytes = options.randomBytes ?? crypto.randomBytes; + } + + put(request: WorkspaceSecretPutRequest): WorkspaceSecretPutResult { + assertScope(request); + this.assertEncryptionAvailable(); + const valueBytes = Buffer.byteLength(request.value, 'utf8'); + if (valueBytes === 0 || valueBytes > MAX_WORKSPACE_SECRET_BYTES) { + throw new WorkspaceSecretVaultError( + 'Workspace secret value size is invalid' + ); + } + + const read = this.readDocument(); + if (read.corrupted) { + throw new WorkspaceSecretNeedsRebindError( + 'Workspace secret vault is corrupted and must be recovered' + ); + } + const document = read.document ?? { version: FORMAT_VERSION, records: {} }; + // References are immutable commit candidates. Reusing one would let a + // second renderer overwrite ciphertext before the Brain binding CAS wins. + const secretRef = this.newSecretRef(); + const timestamp = this.now().toISOString(); + const record: EncryptedWorkspaceSecretRecord = { + secret_ref: secretRef, + account_scope_digest: request.account_scope_digest, + space_id: request.space_id, + revision_id: request.revision_id, + slot_id: request.slot_id, + ciphertext: this.encryption + .encryptString(request.value) + .toString('base64'), + created_at: timestamp, + updated_at: timestamp, + }; + document.records[secretRef] = record; + this.atomicWrite(document); + return this.statusFromRecord(record, 'available'); + } + + status(lookup: WorkspaceSecretLookup): WorkspaceSecretStatus { + assertScope(lookup); + this.assertSecretRef(lookup.secret_ref); + const read = this.readDocument(); + if (read.corrupted) { + return { ...lookup, state: 'needs_rebind' }; + } + const record = read.document?.records[lookup.secret_ref]; + if (!record) return { ...lookup, state: 'missing' }; + this.assertMatchingScope(record, lookup); + if (!this.canDecrypt(record)) { + return this.statusFromRecord(record, 'needs_rebind'); + } + return this.statusFromRecord(record, 'available'); + } + + resolve(lookup: WorkspaceSecretLookup): string { + assertScope(lookup); + this.assertSecretRef(lookup.secret_ref); + const read = this.readDocument(); + if (read.corrupted) { + throw new WorkspaceSecretNeedsRebindError( + 'Workspace secret vault must be rebound' + ); + } + const record = read.document?.records[lookup.secret_ref]; + if (!record) { + throw new WorkspaceSecretNotFoundError('Workspace secret is missing'); + } + this.assertMatchingScope(record, lookup); + this.assertEncryptionAvailable(); + try { + return this.encryption.decryptString( + Buffer.from(record.ciphertext, 'base64') + ); + } catch { + throw new WorkspaceSecretNeedsRebindError( + 'Workspace secret must be rebound' + ); + } + } + + delete(lookup: WorkspaceSecretLookup): WorkspaceSecretStatus { + assertScope(lookup); + this.assertSecretRef(lookup.secret_ref); + const read = this.readDocument(); + if (read.corrupted) { + throw new WorkspaceSecretNeedsRebindError( + 'Workspace secret vault must be recovered before deleting records' + ); + } + const document = read.document; + const record = document?.records[lookup.secret_ref]; + if (!document || !record) return { ...lookup, state: 'missing' }; + this.assertMatchingScope(record, lookup); + delete document.records[lookup.secret_ref]; + this.atomicWrite(document); + return { ...lookup, state: 'missing' }; + } + + private assertEncryptionAvailable(): void { + if (!this.encryption.isEncryptionAvailable()) { + throw new WorkspaceSecretVaultError( + 'OS-level encryption is unavailable; refusing plaintext storage' + ); + } + if ( + this.platform === 'linux' && + this.encryption.getSelectedStorageBackend?.() === 'basic_text' + ) { + throw new WorkspaceSecretVaultError( + 'Linux safeStorage selected the insecure basic_text backend' + ); + } + } + + private canDecrypt(record: EncryptedWorkspaceSecretRecord): boolean { + try { + this.assertEncryptionAvailable(); + this.encryption.decryptString(Buffer.from(record.ciphertext, 'base64')); + return true; + } catch { + return false; + } + } + + private assertMatchingScope( + record: EncryptedWorkspaceSecretRecord, + lookup: WorkspaceSecretLookup + ): void { + if (!sameScope(record, lookup)) { + throw new WorkspaceSecretBindingMismatchError( + 'Workspace secret reference does not match its binding scope' + ); + } + } + + private assertSecretRef(value: string): void { + if (!/^wsvault_[A-Za-z0-9_-]{32}$/u.test(value)) { + throw new WorkspaceSecretVaultError( + 'Workspace secret reference is invalid' + ); + } + } + + private newSecretRef(): string { + return `wsvault_${this.randomBytes(24).toString('base64url')}`; + } + + private statusFromRecord( + record: EncryptedWorkspaceSecretRecord, + state: 'available' + ): WorkspaceSecretPutResult; + private statusFromRecord( + record: EncryptedWorkspaceSecretRecord, + state: 'needs_rebind' + ): WorkspaceSecretStatus; + private statusFromRecord( + record: EncryptedWorkspaceSecretRecord, + state: 'available' | 'needs_rebind' + ): WorkspaceSecretStatus { + return { + secret_ref: record.secret_ref, + account_scope_digest: record.account_scope_digest, + space_id: record.space_id, + revision_id: record.revision_id, + slot_id: record.slot_id, + state, + created_at: record.created_at, + updated_at: record.updated_at, + }; + } + + private readDocument(): VaultReadResult { + if (!fs.existsSync(this.filePath)) { + return { document: null, corrupted: false }; + } + try { + const stat = fs.lstatSync(this.filePath); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + stat.size > MAX_VAULT_BYTES + ) { + return { document: null, corrupted: true }; + } + const parsed = JSON.parse( + fs.readFileSync(this.filePath, 'utf8') + ) as Partial; + if ( + parsed.version !== FORMAT_VERSION || + !parsed.records || + typeof parsed.records !== 'object' + ) { + return { document: null, corrupted: true }; + } + for (const [secretRef, record] of Object.entries(parsed.records)) { + if ( + !isEncryptedRecord(record) || + record.secret_ref !== secretRef || + !/^wsvault_[A-Za-z0-9_-]{32}$/u.test(secretRef) + ) { + return { document: null, corrupted: true }; + } + assertScope(record); + } + return { document: parsed as VaultDocument, corrupted: false }; + } catch { + return { document: null, corrupted: true }; + } + } + + private ensureSecureDirectory(): void { + if (fs.existsSync(this.rootDir)) { + const stat = fs.lstatSync(this.rootDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new WorkspaceSecretVaultError( + 'Workspace secret vault directory is unsafe' + ); + } + } else { + fs.mkdirSync(this.rootDir, { recursive: true, mode: 0o700 }); + } + try { + fs.chmodSync(this.rootDir, 0o700); + } catch { + // Windows and some filesystems do not implement POSIX permissions. + } + } + + private atomicWrite(document: VaultDocument): void { + this.ensureSecureDirectory(); + const serialized = JSON.stringify(document); + if (Buffer.byteLength(serialized, 'utf8') > MAX_VAULT_BYTES) { + throw new WorkspaceSecretVaultError('Workspace secret vault is full'); + } + const temporaryPath = path.join( + this.rootDir, + `.${VAULT_FILE_NAME}.${process.pid}.${this.randomBytes(8).toString('hex')}.tmp` + ); + let descriptor: number | null = null; + try { + descriptor = fs.openSync(temporaryPath, 'wx', 0o600); + fs.writeFileSync(descriptor, serialized, 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + fs.renameSync(temporaryPath, this.filePath); + try { + fs.chmodSync(this.filePath, 0o600); + } catch { + // Best effort on filesystems without POSIX permissions. + } + try { + const directoryDescriptor = fs.openSync(this.rootDir, 'r'); + try { + fs.fsyncSync(directoryDescriptor); + } finally { + fs.closeSync(directoryDescriptor); + } + } catch { + // Directory fsync is unavailable on Windows and some filesystems. + } + } finally { + if (descriptor !== null) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporaryPath); + } catch { + // The successful rename already removed the temporary path. + } + } + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index e95f3d14..65e3e8d3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -13,6 +13,10 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import type { + WorkspaceSecretLookup, + WorkspaceSecretPutRequest, +} from '../main/workspaceSecrets/types'; contextBridge.exposeInMainWorld('ipcRenderer', { on(...args: Parameters) { @@ -103,6 +107,12 @@ contextBridge.exposeInMainWorld('electronAPI', { envRemove: (email: string, key: string) => ipcRenderer.invoke('env-remove', email, key), getEnvPath: (email: string) => ipcRenderer.invoke('get-env-path', email), + workspaceSecretPut: (request: WorkspaceSecretPutRequest) => + ipcRenderer.invoke('workspace-secret:put', request), + workspaceSecretStatus: (request: WorkspaceSecretLookup) => + ipcRenderer.invoke('workspace-secret:status', request), + workspaceSecretDelete: (request: WorkspaceSecretLookup) => + ipcRenderer.invoke('workspace-secret:delete', request), // command execution executeCommand: (command: string, email: string) => ipcRenderer.invoke('execute-command', command, email), diff --git a/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx b/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx new file mode 100644 index 00000000..4f5d5961 --- /dev/null +++ b/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx @@ -0,0 +1,470 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + fetchReview: vi.fn(), + fetchProposal: vi.fn(), + createProposal: vi.fn(), + decide: vi.fn(), + bindValues: vi.fn(), + bindPath: vi.fn(), + bindConnector: vi.fn(), + approveScript: vi.fn(), + materialize: vi.fn(), + digest: vi.fn(), + fetchConnected: vi.fn(), + secretPut: vi.fn(), + secretDelete: vi.fn(), + selectFile: vi.fn(), + createSpace: vi.fn(), + deleteSpace: vi.fn(), + ensureScratch: vi.fn(), + setActiveSpace: vi.fn(), + setActiveProject: vi.fn(), + setActiveWorkspaceTab: vi.fn(), +})); + +vi.mock('@/service/workspaceBundleInstallApi', () => ({ + parseWorkspaceBundleHandle: (value: string) => { + const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,79})@([1-9][0-9]*)$/.exec( + value + ); + return match ? { bundleId: match[1], revisionId: value } : null; + }, + fetchWorkspaceBundleInstallReview: mocks.fetchReview, + fetchWorkspaceBundleInstallProposal: mocks.fetchProposal, + createWorkspaceBundleInstallProposal: mocks.createProposal, + decideWorkspaceBundleInstall: mocks.decide, + bindWorkspaceBundleLocalValues: mocks.bindValues, + bindWorkspaceBundleLocalPath: mocks.bindPath, + bindWorkspaceBundleConnector: mocks.bindConnector, + approveWorkspaceBundleScript: mocks.approveScript, + materializeWorkspaceBundle: mocks.materialize, + workspaceBundleAccountScopeDigest: mocks.digest, +})); + +vi.mock('@/api/connectors', () => ({ + fetchConnectedProviders: mocks.fetchConnected, + providerLabel: (provider: { service: string }) => provider.service, +})); + +vi.mock('@/host', () => ({ + useHost: () => ({ + electronAPI: { + workspaceSecretPut: mocks.secretPut, + workspaceSecretDelete: mocks.secretDelete, + selectFile: mocks.selectFile, + }, + }), +})); + +vi.mock('@/lib/scratchSpaceWorkspace', () => ({ + ensureScratchSpaceWorkspaceBinding: mocks.ensureScratch, +})); + +vi.mock('@/store/authStore', () => ({ + useAuthStore: (selector: (state: object) => unknown) => + selector({ email: 'owner@example.com', user_id: 'user-1' }), +})); + +vi.mock('@/store/spaceStore', () => { + const state = { + createSpaceOnServer: mocks.createSpace, + deleteSpaceOnServer: mocks.deleteSpace, + setActiveSpace: mocks.setActiveSpace, + getSpaceById: (spaceId: string) => ({ + id: spaceId, + name: 'Imported', + sourceType: 'blank', + }), + }; + const useSpaceStore = Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { getState: () => state } + ); + return { useSpaceStore }; +}); +vi.mock('@/store/projectRuntimeStore', () => ({ + useProjectRuntimeStore: () => ({ setActiveProject: mocks.setActiveProject }), +})); +vi.mock('@/store/pageTabStore', () => ({ + usePageTabStore: (selector: (state: object) => unknown) => + selector({ setActiveWorkspaceTab: mocks.setActiveWorkspaceTab }), +})); + +import type { WorkspaceBundleInstallSnapshot } from '@/service/workspaceBundleInstallApi'; +import { WorkspaceBundleInstallWizard } from './WorkspaceBundleInstallWizard'; + +const manifest = { + apiVersion: 'eigent.ai/v1alpha1', + kind: 'WorkforceBundle', + metadata: { id: 'research', name: 'Research workforce', revision: 1 }, + spec: { + instructions: {}, + context: [], + skills: [], + connectors: [], + mcpServers: [], + environment: { + variables: [ + { + name: 'API_TOKEN', + required: true, + sensitive: true, + description: 'Research API token', + }, + ], + }, + agents: [], + models: { + default: { modelRef: 'provider://default', thinkingEffort: 'medium' }, + }, + permissions: { profile: 'request_approval', rules: [] }, + git: { + enabled: true, + checkpointPolicy: 'user_and_run_terminal', + agentIsolation: 'worktree', + remotePolicy: 'prompt', + }, + }, +} as const; + +const review = { + bundle: { + id: 'research', + workspace_id: 'author-space', + name: 'Research workforce', + visibility: 'public' as const, + latest_published_revision_id: 'research@1', + }, + revision: { + id: 'research@1', + bundle_id: 'research', + revision: 1, + manifest, + manifest_digest: 'a'.repeat(64), + status: 'published' as const, + assets: [], + }, +}; + +const snapshot = (configured = false): WorkspaceBundleInstallSnapshot => ({ + proposal: { + proposal_id: 'proposal-1', + request_id: 'request-1', + space_id: 'space-1', + bundle_id: 'research', + revision_id: 'research@1', + config_placement: 'sidecar', + state: 'approved', + version: configured ? 3 : 2, + manifest: + manifest as WorkspaceBundleInstallSnapshot['proposal']['manifest'], + manifest_digest: 'a'.repeat(64), + assets: [], + install_plan: { + connector_slots: [], + local_path_slots: [], + script_actions: [], + environment_requirements: [], + mcp_secret_requirements: [], + permission_profile: 'request_approval', + git_policy: {}, + asset_count: 0, + asset_bytes: 0, + }, + }, + bindings: [], + value_requirements: [ + { + requirement_key: 'environment:API_TOKEN', + requirement_kind: 'environment', + name: 'API_TOKEN', + required: true, + sensitive: true, + configured, + available: configured, + binding_version: configured ? 1 : null, + }, + ], + readiness: { + ready: configured, + missing_requirements: configured ? [] : ['environment:API_TOKEN'], + }, +}); + +function renderWizard(props: { + initialHandle?: string; + initialProposalId?: string; +}) { + return render( + + + + ); +} + +describe('WorkspaceBundleInstallWizard', () => { + beforeEach(() => { + window.localStorage.clear(); + Object.values(mocks).forEach((mock) => mock.mockReset()); + mocks.fetchConnected.mockResolvedValue([]); + mocks.digest.mockResolvedValue('b'.repeat(64)); + mocks.createSpace.mockResolvedValue('space-1'); + mocks.ensureScratch.mockResolvedValue('/tmp/space-1'); + mocks.createProposal.mockResolvedValue({ + ...snapshot(false), + proposal: { ...snapshot(false).proposal, state: 'proposed', version: 1 }, + }); + mocks.decide.mockResolvedValue(snapshot(false)); + }); + + it('shows the immutable review before creating or approving a Space', async () => { + mocks.fetchReview.mockResolvedValue(review); + const user = userEvent.setup(); + renderWizard({ initialHandle: 'research@1' }); + + expect(await screen.findByText('Research workforce')).toBeInTheDocument(); + expect(mocks.createSpace).not.toHaveBeenCalled(); + expect(mocks.decide).not.toHaveBeenCalled(); + + await user.click( + screen.getByRole('button', { name: /confirm and create/i }) + ); + + await waitFor(() => expect(mocks.decide).toHaveBeenCalledTimes(1)); + expect(mocks.createSpace).toHaveBeenCalledTimes(1); + expect(mocks.createProposal.mock.invocationCallOrder[0]).toBeLessThan( + mocks.decide.mock.invocationCallOrder[0] + ); + }); + + it('stores plaintext only through the Electron vault and binds an opaque ref', async () => { + mocks.fetchProposal.mockResolvedValue(snapshot(false)); + mocks.secretPut.mockResolvedValue({ + secret_ref: `wsvault_${'A'.repeat(32)}`, + account_scope_digest: 'b'.repeat(64), + space_id: 'space-1', + revision_id: 'research@1', + slot_id: 'environment:API_TOKEN', + state: 'available', + }); + mocks.bindValues.mockResolvedValue(snapshot(true)); + const user = userEvent.setup(); + renderWizard({ initialProposalId: 'proposal-1' }); + + const input = await screen.findByLabelText('Local value for API_TOKEN'); + await user.type(input, 'plaintext-do-not-send-to-brain'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mocks.bindValues).toHaveBeenCalledTimes(1)); + expect(mocks.secretPut).toHaveBeenCalledWith( + expect.objectContaining({ value: 'plaintext-do-not-send-to-brain' }) + ); + expect(JSON.stringify(mocks.bindValues.mock.calls[0][0])).not.toContain( + 'plaintext-do-not-send-to-brain' + ); + expect(mocks.bindValues).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [ + expect.objectContaining({ secret_ref: `wsvault_${'A'.repeat(32)}` }), + ], + }) + ); + }); + + it('repairs an unavailable local value with the safe binding version', async () => { + const unavailable = snapshot(false); + unavailable.value_requirements[0] = { + ...unavailable.value_requirements[0], + configured: true, + available: false, + binding_version: 4, + }; + mocks.fetchProposal.mockResolvedValue(unavailable); + mocks.secretPut.mockResolvedValue({ + secret_ref: `wsvault_${'B'.repeat(32)}`, + account_scope_digest: 'b'.repeat(64), + space_id: 'space-1', + revision_id: 'research@1', + slot_id: 'environment:API_TOKEN', + state: 'available', + }); + mocks.bindValues.mockResolvedValue({ + ...snapshot(true), + cleanup_secret_refs: [`wsvault_${'A'.repeat(32)}`], + }); + const user = userEvent.setup(); + renderWizard({ initialProposalId: 'proposal-1' }); + + expect( + await screen.findByText(/previous local value is unavailable/i) + ).toBeInTheDocument(); + await user.type( + screen.getByLabelText('Local value for API_TOKEN'), + 'replacement-value' + ); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(mocks.bindValues).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [expect.objectContaining({ expected_binding_version: 4 })], + }) + ) + ); + expect(mocks.secretDelete).toHaveBeenCalledWith( + expect.objectContaining({ secret_ref: `wsvault_${'A'.repeat(32)}` }) + ); + }); + + it('keeps local setup editable after the Workspace is installed', async () => { + const installed = snapshot(true); + installed.proposal = { + ...installed.proposal, + state: 'materialized', + version: 8, + }; + mocks.fetchProposal.mockResolvedValue(installed); + mocks.secretPut.mockResolvedValue({ + secret_ref: `wsvault_${'C'.repeat(32)}`, + account_scope_digest: 'b'.repeat(64), + space_id: 'space-1', + revision_id: 'research@1', + slot_id: 'environment:API_TOKEN', + state: 'available', + }); + mocks.bindValues.mockResolvedValue({ + ...installed, + proposal: { ...installed.proposal, version: 9 }, + }); + const user = userEvent.setup(); + renderWizard({ initialProposalId: 'proposal-1' }); + + expect( + await screen.findByText('Workspace files installed') + ).toBeInTheDocument(); + await waitFor(() => expect(mocks.fetchConnected).toHaveBeenCalledTimes(1)); + await user.type( + screen.getByLabelText('Local value for API_TOKEN'), + 'rotated-value' + ); + await user.click(screen.getByRole('button', { name: 'Replace' })); + + await waitFor(() => + expect(mocks.bindValues).toHaveBeenCalledWith( + expect.objectContaining({ + expectedVersion: 8, + bindings: [expect.objectContaining({ expected_binding_version: 1 })], + }) + ) + ); + }); + + it('reconciles the durable binding after an ambiguous response loss', async () => { + mocks.fetchProposal + .mockResolvedValueOnce(snapshot(false)) + .mockResolvedValueOnce(snapshot(true)); + mocks.secretPut.mockResolvedValue({ + secret_ref: `wsvault_${'D'.repeat(32)}`, + account_scope_digest: 'b'.repeat(64), + space_id: 'space-1', + revision_id: 'research@1', + slot_id: 'environment:API_TOKEN', + state: 'available', + }); + mocks.bindValues.mockRejectedValue( + new Error('Response lost after the durable commit') + ); + const user = userEvent.setup(); + renderWizard({ initialProposalId: 'proposal-1' }); + + await user.type( + await screen.findByLabelText('Local value for API_TOKEN'), + 'possibly-committed-value' + ); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect( + await screen.findByText('Response lost after the durable commit') + ).toBeInTheDocument(); + await waitFor(() => expect(mocks.fetchProposal).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument(); + }); + + it('offers a retry when the read-only review request fails', async () => { + mocks.fetchReview + .mockRejectedValueOnce(new Error('Cloud is temporarily unavailable')) + .mockResolvedValueOnce(review); + const user = userEvent.setup(); + renderWizard({ initialHandle: 'research@1' }); + + expect( + await screen.findByText('Cloud is temporarily unavailable') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByText('Research workforce')).toBeInTheDocument(); + expect(mocks.fetchReview).toHaveBeenCalledTimes(2); + }); + + it('retries a proposal failure without creating a second Space', async () => { + mocks.fetchReview.mockResolvedValue(review); + mocks.createProposal + .mockRejectedValueOnce(new Error('Brain response was interrupted')) + .mockResolvedValueOnce({ + ...snapshot(false), + proposal: { + ...snapshot(false).proposal, + state: 'proposed', + version: 1, + }, + }); + const user = userEvent.setup(); + renderWizard({ initialHandle: 'research@1' }); + + await user.click( + await screen.findByRole('button', { name: /confirm and create/i }) + ); + expect( + await screen.findByText('Brain response was interrupted') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => expect(mocks.decide).toHaveBeenCalledTimes(1)); + expect(mocks.createProposal).toHaveBeenCalledTimes(2); + expect(mocks.createSpace).toHaveBeenCalledTimes(1); + }); + + it('recovers the inactive Space seed after a renderer restart', async () => { + mocks.fetchReview.mockResolvedValue(review); + mocks.createProposal.mockRejectedValueOnce( + new Error('Brain stopped before the proposal commit') + ); + const user = userEvent.setup(); + const first = renderWizard({ initialHandle: 'research@1' }); + + await user.click( + await screen.findByRole('button', { name: /confirm and create/i }) + ); + expect( + await screen.findByText('Brain stopped before the proposal commit') + ).toBeInTheDocument(); + first.unmount(); + + mocks.createProposal.mockResolvedValueOnce({ + ...snapshot(false), + proposal: { ...snapshot(false).proposal, state: 'proposed', version: 1 }, + }); + renderWizard({ initialHandle: 'research@1' }); + await user.click( + await screen.findByRole('button', { name: /confirm and create/i }) + ); + + await waitFor(() => expect(mocks.decide).toHaveBeenCalledTimes(1)); + expect(mocks.createSpace).toHaveBeenCalledTimes(1); + expect(mocks.createProposal).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx b/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx new file mode 100644 index 00000000..6d353c1c --- /dev/null +++ b/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx @@ -0,0 +1,1105 @@ +import { fetchConnectedProviders, providerLabel } from '@/api/connectors'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { useHost } from '@/host'; +import { ensureScratchSpaceWorkspaceBinding } from '@/lib/scratchSpaceWorkspace'; +import { + approveWorkspaceBundleScript, + bindWorkspaceBundleConnector, + bindWorkspaceBundleLocalPath, + bindWorkspaceBundleLocalValues, + createWorkspaceBundleInstallProposal, + decideWorkspaceBundleInstall, + fetchWorkspaceBundleInstallProposal, + fetchWorkspaceBundleInstallReview, + materializeWorkspaceBundle, + parseWorkspaceBundleHandle, + workspaceBundleAccountScopeDigest, + type ParsedWorkspaceBundleHandle, + type WorkspaceBundleInstallPlan, + type WorkspaceBundleInstallReview, + type WorkspaceBundleInstallSnapshot, + type WorkspaceBundleValueRequirement, +} from '@/service/workspaceBundleInstallApi'; +import { useAuthStore } from '@/store/authStore'; +import { usePageTabStore } from '@/store/pageTabStore'; +import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; +import { useSpaceStore } from '@/store/spaceStore'; +import { + ArrowLeft, + Check, + ExternalLink, + FolderOpen, + KeyRound, + Loader2, + RefreshCw, + ShieldCheck, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +type RetryMode = 'review' | 'resume' | 'start' | 'materialize' | null; + +interface InstallSeed { + proposalId: string; + requestId: string; + spaceId: string; +} + +const installSeedKey = (revisionId: string, actorId: string): string => + `eigent:workforce-bundle-install-seed:v1:${actorId}:${revisionId}`; + +function readInstallSeed( + revisionId: string, + actorId: string +): InstallSeed | null { + try { + const value = JSON.parse( + window.localStorage.getItem(installSeedKey(revisionId, actorId)) || 'null' + ) as Partial | null; + if ( + !value || + !/^bundleinstall_[a-f0-9]{32}$/u.test(value.proposalId || '') || + !/^bundlerequest_[a-f0-9]{32}$/u.test(value.requestId || '') || + typeof value.spaceId !== 'string' || + !value.spaceId + ) { + return null; + } + return value as InstallSeed; + } catch { + return null; + } +} + +function writeInstallSeed( + revisionId: string, + actorId: string, + seed: InstallSeed +): void { + window.localStorage.setItem( + installSeedKey(revisionId, actorId), + JSON.stringify(seed) + ); +} + +function clearInstallSeed(revisionId: string, actorId: string): void { + window.localStorage.removeItem(installSeedKey(revisionId, actorId)); +} + +const errorMessage = (error: unknown): string => + error instanceof Error + ? error.message + : 'The installation could not continue.'; + +const newInstallId = (prefix: string): string => + `${prefix}_${crypto.randomUUID().replaceAll('-', '')}`; + +function requirementLabel(item: WorkspaceBundleValueRequirement): string { + if (item.requirement_kind === 'environment') { + return item.name || item.requirement_key.replace(/^environment:/, ''); + } + return ( + item.slot_id || + item.requirement_key.split(':').at(-1) || + item.requirement_key + ); +} + +function LocalValueRow({ + item, + busy, + onSave, +}: { + item: WorkspaceBundleValueRequirement; + busy: boolean; + onSave: (value: string) => Promise; +}) { + return ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const value = new FormData(form).get('local-value'); + if (typeof value !== 'string' || !value) return; + void onSave(value).then((saved) => { + if (saved) form.reset(); + }); + }} + > +
+
+

+ {requirementLabel(item)} +

+

+ {item.requirement_kind === 'mcp_secret' + ? `Secret for ${item.mcp_id || 'MCP server'}` + : item.description || 'Local environment value'} + {' ยท '} + {item.required ? 'Required' : 'Optional'} +

+
+ {item.configured && item.available ? ( + + Stored locally + + ) : null} +
+ {item.configured && !item.available ? ( +

+ The previous local value is unavailable. Re-enter it to repair this + binding. +

+ ) : null} +
+ + +
+
+ ); +} + +export interface WorkspaceBundleInstallWizardProps { + initialHandle?: string; + initialProposalId?: string; +} + +export function WorkspaceBundleInstallWizard({ + initialHandle = '', + initialProposalId = '', +}: WorkspaceBundleInstallWizardProps) { + const navigate = useNavigate(); + const host = useHost(); + const email = useAuthStore((state) => state.email); + const userId = useAuthStore((state) => state.user_id); + const actorId = String(userId ?? email ?? ''); + const createSpaceOnServer = useSpaceStore( + (state) => state.createSpaceOnServer + ); + const deleteSpaceOnServer = useSpaceStore( + (state) => state.deleteSpaceOnServer + ); + const setActiveSpace = useSpaceStore((state) => state.setActiveSpace); + const projectStore = useProjectRuntimeStore(); + const setActiveWorkspaceTab = usePageTabStore( + (state) => state.setActiveWorkspaceTab + ); + + const [handleInput, setHandleInput] = useState(initialHandle); + const [handle, setHandle] = useState( + null + ); + const [review, setReview] = useState( + null + ); + const [snapshot, setSnapshot] = + useState(null); + const [connectedProviders, setConnectedProviders] = useState< + Awaited> + >([]); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + const [retryMode, setRetryMode] = useState(null); + const [installSeed, setInstallSeed] = useState(null); + + const proposal = snapshot?.proposal; + const configuredSlots = useMemo( + () => new Set(snapshot?.bindings.map((item) => item.slot_id) ?? []), + [snapshot] + ); + const reviewedSetup = useMemo(() => { + const spec = review?.revision.manifest.spec; + if (!spec) return []; + return [ + ...(spec.environment?.variables.map( + (item) => + `${item.required ? 'Required' : 'Optional'} value: ${item.name}` + ) ?? []), + ...spec.mcpServers.flatMap((server) => + server.secretSlots.map((slot) => `Local secret: ${server.id} / ${slot}`) + ), + ...spec.context + .filter((source) => source.kind === 'local_path_slot' && source.slot) + .map((source) => `Local folder: ${source.slot}`), + ...spec.connectors.map( + (connector) => + `Connection: ${connector.connector} (${connector.connectionSlot})` + ), + ...spec.mcpServers.map( + (server) => `Approve local MCP start: ${server.id}` + ), + ...spec.skills + .filter((skill) => skill.ref.startsWith('bundle://')) + .map((skill) => `Approve bundled skill code: ${skill.ref}`), + ]; + }, [review]); + + const loadReview = useCallback( + async (rawHandle: string) => { + const parsed = parseWorkspaceBundleHandle(rawHandle); + if (!parsed) { + setError('Use a published handle such as my-workforce@1.'); + setRetryMode(null); + return; + } + setBusyKey('review'); + setError(null); + try { + const next = await fetchWorkspaceBundleInstallReview(parsed); + setHandle(parsed); + setReview(next); + setInstallSeed(readInstallSeed(parsed.revisionId, actorId)); + setRetryMode(null); + } catch (nextError) { + setError(errorMessage(nextError)); + setRetryMode('review'); + } finally { + setBusyKey(null); + } + }, + [actorId] + ); + + const resumeProposal = useCallback(async (proposalId: string) => { + setBusyKey('resume'); + setError(null); + try { + const next = await fetchWorkspaceBundleInstallProposal(proposalId); + setSnapshot(next); + setHandle(parseWorkspaceBundleHandle(next.proposal.revision_id)); + setRetryMode(null); + } catch (nextError) { + setError(errorMessage(nextError)); + setRetryMode('resume'); + } finally { + setBusyKey(null); + } + }, []); + + useEffect(() => { + if (initialProposalId) { + void resumeProposal(initialProposalId); + return; + } + if (initialHandle) void loadReview(initialHandle); + }, [initialHandle, initialProposalId, loadReview, resumeProposal]); + + useEffect(() => { + if ( + !proposal || + !['approved', 'needs_attention', 'materialized'].includes(proposal.state) + ) { + return; + } + void fetchConnectedProviders() + .then(setConnectedProviders) + .catch(() => setConnectedProviders([])); + }, [proposal]); + + const startInstall = useCallback(async () => { + if (!review || !handle || !email || !actorId) return; + setBusyKey('start'); + setError(null); + try { + let seed = installSeed; + if (!seed) { + const proposalId = newInstallId('bundleinstall'); + const requestId = newInstallId('bundlerequest'); + const name = + review.bundle?.name || + review.revision.manifest.metadata.name || + 'Imported workforce'; + const spaceId = await createSpaceOnServer({ + name, + sourceType: 'blank', + setActive: false, + metadata: { + createdFrom: 'workforce_bundle_install', + bundleRevision: handle.revisionId, + bundleInstallProposalId: proposalId, + bundleInstallRequestId: requestId, + }, + }); + seed = { proposalId, requestId, spaceId }; + setInstallSeed(seed); + try { + writeInstallSeed(handle.revisionId, actorId, seed); + } catch { + setInstallSeed(null); + await deleteSpaceOnServer(spaceId).catch(() => undefined); + throw new Error( + 'Eigent could not save the recoverable installation intent.' + ); + } + const space = useSpaceStore.getState().getSpaceById(spaceId); + const root = await ensureScratchSpaceWorkspaceBinding({ + email, + userId, + space, + }); + if (!root) { + clearInstallSeed(handle.revisionId, actorId); + setInstallSeed(null); + await deleteSpaceOnServer(spaceId).catch(() => undefined); + throw new Error( + 'Eigent could not create the local Workspace folder.' + ); + } + } + const proposed = await createWorkspaceBundleInstallProposal({ + proposalId: seed.proposalId, + requestId: seed.requestId, + spaceId: seed.spaceId, + bundleId: handle.bundleId, + revisionId: handle.revisionId, + }); + clearInstallSeed(handle.revisionId, actorId); + navigate( + `/workspace-bundles/install?proposal=${encodeURIComponent(seed.proposalId)}&handle=${encodeURIComponent(handle.revisionId)}`, + { replace: true } + ); + const approved = await decideWorkspaceBundleInstall({ + proposalId: seed.proposalId, + expectedVersion: proposed.proposal.version, + approved: true, + actorId, + }); + setSnapshot(approved); + setRetryMode(null); + } catch (nextError) { + setError(errorMessage(nextError)); + setRetryMode('start'); + } finally { + setBusyKey(null); + } + }, [ + actorId, + createSpaceOnServer, + deleteSpaceOnServer, + email, + handle, + installSeed, + navigate, + review, + userId, + ]); + + const storeLocalValue = useCallback( + async (item: WorkspaceBundleValueRequirement, value: string) => { + if (!proposal || !host?.electronAPI?.workspaceSecretPut || !actorId) { + setError('Secure local value storage is unavailable.'); + return false; + } + setBusyKey(item.requirement_key); + setError(null); + try { + const accountScopeDigest = + await workspaceBundleAccountScopeDigest(actorId); + const stored = await host.electronAPI.workspaceSecretPut({ + account_scope_digest: accountScopeDigest, + space_id: proposal.space_id, + revision_id: proposal.revision_id, + slot_id: item.requirement_key, + value, + }); + const next = await bindWorkspaceBundleLocalValues({ + proposalId: proposal.proposal_id, + clientRequestId: `local-value:${proposal.proposal_id}:${crypto.randomUUID()}`, + expectedVersion: proposal.version, + actorId, + bindings: [ + { + requirement_key: item.requirement_key, + requirement_kind: item.requirement_kind, + secret_ref: stored.secret_ref, + account_scope_digest: accountScopeDigest, + expected_binding_version: item.binding_version, + }, + ], + }); + setSnapshot(next); + await Promise.allSettled( + (next.cleanup_secret_refs ?? []).map((secretRef) => + host.electronAPI.workspaceSecretDelete({ + secret_ref: secretRef, + account_scope_digest: accountScopeDigest, + space_id: proposal.space_id, + revision_id: proposal.revision_id, + slot_id: item.requirement_key, + }) + ) + ); + return true; + } catch (nextError) { + setError(errorMessage(nextError)); + try { + setSnapshot( + await fetchWorkspaceBundleInstallProposal(proposal.proposal_id) + ); + } catch { + // Preserve the binding failure. Reopening Local setup will replay + // the durable proposal if the response was lost after commit. + } + return false; + } finally { + setBusyKey(null); + } + }, + [actorId, host, proposal] + ); + + const bindPath = useCallback( + async (slotId: string) => { + if (!proposal || !host?.electronAPI?.selectFile || !actorId) return; + const selected = await host.electronAPI.selectFile({ + properties: ['openDirectory'], + }); + const localPath = selected?.files?.[0]?.filePath; + if (!selected?.success || !localPath) return; + setBusyKey(slotId); + setError(null); + try { + setSnapshot( + await bindWorkspaceBundleLocalPath({ + proposalId: proposal.proposal_id, + expectedVersion: proposal.version, + slotId, + localPath, + actorId, + }) + ); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setBusyKey(null); + } + }, + [actorId, host, proposal] + ); + + const bindConnector = useCallback( + async (slot: WorkspaceBundleInstallPlan['connector_slots'][number]) => { + if (!proposal || !actorId) return; + const provider = connectedProviders.find( + (item) => item.service.toLowerCase() === slot.connector_id.toLowerCase() + ); + const connectionId = + provider?.connection?.id || provider?.connection?.connectionName; + if (!connectionId) { + setError(`Connect ${slot.connector_id} before binding this slot.`); + return; + } + setBusyKey(slot.slot_id); + setError(null); + try { + setSnapshot( + await bindWorkspaceBundleConnector({ + proposalId: proposal.proposal_id, + expectedVersion: proposal.version, + slotId: slot.slot_id, + connectorId: slot.connector_id, + connectionId, + actorId, + }) + ); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setBusyKey(null); + } + }, + [actorId, connectedProviders, proposal] + ); + + const approveScript = useCallback( + async (actionId: string) => { + if (!proposal || !actorId) return; + setBusyKey(actionId); + setError(null); + try { + setSnapshot( + await approveWorkspaceBundleScript({ + proposalId: proposal.proposal_id, + expectedVersion: proposal.version, + actionId, + actorId, + }) + ); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setBusyKey(null); + } + }, + [actorId, proposal] + ); + + const continueApproval = useCallback(async () => { + if (!proposal || proposal.state !== 'proposed' || !actorId) return; + setBusyKey('approval'); + setError(null); + try { + setSnapshot( + await decideWorkspaceBundleInstall({ + proposalId: proposal.proposal_id, + expectedVersion: proposal.version, + approved: true, + actorId, + }) + ); + setRetryMode(null); + } catch (nextError) { + setError(errorMessage(nextError)); + setRetryMode('resume'); + } finally { + setBusyKey(null); + } + }, [actorId, proposal]); + + const materialize = useCallback(async () => { + if (!proposal || !email || !actorId) return; + setBusyKey('materialize'); + setError(null); + try { + setSnapshot( + await materializeWorkspaceBundle({ + proposalId: proposal.proposal_id, + expectedVersion: proposal.version, + email, + userId, + actorId, + }) + ); + setRetryMode(null); + } catch (nextError) { + const message = errorMessage(nextError); + setError(message); + setRetryMode('materialize'); + try { + setSnapshot( + await fetchWorkspaceBundleInstallProposal(proposal.proposal_id) + ); + } catch { + // Preserve the materialization failure as the actionable error. + } + } finally { + setBusyKey(null); + } + }, [actorId, email, proposal, userId]); + + const openWorkspace = () => { + if (!proposal) return; + setActiveSpace(proposal.space_id); + projectStore.setActiveProject(null); + setActiveWorkspaceTab('workforce'); + navigate('/'); + }; + + const retry = () => { + if (retryMode === 'review') void loadReview(handleInput); + if (retryMode === 'resume' && initialProposalId) + void resumeProposal(initialProposalId); + if (retryMode === 'start') void startInstall(); + if (retryMode === 'materialize') void materialize(); + }; + + if (proposal?.state === 'rejected') { + return ( + + + Installation cancelled + + +

+ This durable proposal was rejected and cannot be reused. +

+ +
+
+ ); + } + + return ( +
+ + +
+

+ Install Workforce Bundle +

+

+ Review what the workforce can access, then configure required values + and connections locally. +

+
+ + {error ? ( +
+

{error}

+ {retryMode === 'start' && installSeed ? ( +

+ The inactive Workspace draft was kept for recovery. Retry reuses + the same draft and does not create another Workspace. +

+ ) : null} + {retryMode ? ( + + ) : null} +
+ ) : null} + + {!review && !snapshot ? ( + + + Import by share handle + + +
{ + event.preventDefault(); + void loadReview(handleInput); + }} + > + setHandleInput(event.target.value)} + placeholder="research-workforce@1" + aria-label="Workforce Bundle share handle" + /> + +
+
+
+ ) : null} + + {review && !snapshot ? ( + + + + {review.bundle?.name || review.revision.manifest.metadata.name} + +

+ {review.revision.id} +

+
+ +
+
+ Permission profile +

+ {review.revision.manifest.spec.permissions.profile} +

+
+
+ Assets +

+ {review.revision.assets.length} verified files +

+
+
+ Connectors +

+ {review.revision.manifest.spec.connectors.length} requested +

+
+
+ Local requirements +

+ {(review.revision.manifest.spec.environment?.variables + .length || 0) + + review.revision.manifest.spec.mcpServers.reduce( + (total, item) => total + item.secretSlots.length, + 0 + )}{' '} + values +

+
+
+ {reviewedSetup.length > 0 ? ( +
+

+ Required local setup and actions +

+
    + {reviewedSetup.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null} + {review.revision.manifest.spec.connectors.map((connector) => ( +
+

+ {connector.connector} +

+

+ Grants:{' '} + {connector.requiredGrants.join(', ') || + 'No additional grants declared'} +

+
+ ))} +
+ + Inspect manifest and verified assets + +
+

+ Manifest SHA-256: {review.revision.manifest_digest} +

+ {review.revision.assets.length > 0 ? ( +
    + {review.revision.assets.map((asset) => ( +
  • +

    + {asset.logical_path} +

    +

    + SHA-256 {asset.content_digest} ยท {asset.size_bytes}{' '} + bytes +

    +
  • + ))} +
+ ) : ( +

+ No packaged assets. +

+ )} +
+                  {JSON.stringify(review.revision.manifest, null, 2)}
+                
+
+
+
+

+ Secrets are not + part of this Bundle +

+

+ Required values are collected only after approval and stored + using the operating system encryption service. +

+
+ +
+
+ ) : null} + + {snapshot && proposal ? ( + <> + {proposal.state === 'materialized' ? ( + + + Workspace files installed + + +

+ {proposal.revision_id} is installed. Local bindings are + encrypted on this device and can be repaired or replaced + below. They are not exposed globally to unrelated tools. +

+ +
+
+ ) : null} + {proposal.state === 'proposed' ? ( + + + Finish confirming this installation + + +

+ The durable proposal was saved, but the approval response did + not finish. Continue without creating another Workspace. +

+ +
+
+ ) : null} + {proposal.state === 'materializing' ? ( + + + Checking installation progress + + +

+ The previous materialization was interrupted. Refresh the + durable proposal before retrying. +

+ +
+
+ ) : null} + {['approved', 'needs_attention', 'materialized'].includes( + proposal.state + ) ? ( + <> + + + 1. Local values + + + {snapshot.value_requirements.length === 0 ? ( +

+ No local values required. +

+ ) : ( + snapshot.value_requirements.map((item) => ( + storeLocalValue(item, value)} + /> + )) + )} +
+
+ + + + 2. Local folders and connections + + + {proposal.install_plan.local_path_slots.map((slotId) => ( +
+
+

{slotId}

+

+ Local folder access +

+
+ +
+ ))} + {proposal.install_plan.connector_slots.map((slot) => { + const provider = connectedProviders.find( + (item) => + item.service.toLowerCase() === + slot.connector_id.toLowerCase() + ); + const connected = Boolean( + provider?.connection?.configured && + !provider.connection.virtual + ); + return ( +
+
+
+

+ {provider + ? providerLabel(provider) + : slot.connector_id} +

+

+ Grants:{' '} + {slot.required_grants.join(', ') || 'None'} +

+
+ {connected ? ( + + ) : ( + + )} +
+
+ ); + })} + {proposal.install_plan.local_path_slots.length === 0 && + proposal.install_plan.connector_slots.length === 0 ? ( +

+ No folder or connector binding required. +

+ ) : null} +
+
+ + + + 3. Actions and readiness + + + {proposal.install_plan.script_actions.map((actionId) => ( +
+
+

{actionId}

+

+ This action may execute local code. +

+
+ {configuredSlots.has(actionId) ? ( + + ) : ( + + )} +
+ ))} + {!snapshot.readiness.ready ? ( +
+

Still required

+
    + {snapshot.readiness.missing_requirements.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : ( +
+ All declared local bindings are currently available. +
+ )} + {proposal.state === 'materialized' ? ( +

+ Changes on this screen are saved immediately. Runtime + access is consumer-specific; this installation step does + not inject values into the global process environment or + unrelated tools. +

+ ) : ( + + )} +
+
+ + ) : null} + + ) : null} +
+ ); +} diff --git a/src/pages/Home/components/HomeHubToolbar.tsx b/src/pages/Home/components/HomeHubToolbar.tsx index 476926a1..63f0bcaa 100644 --- a/src/pages/Home/components/HomeHubToolbar.tsx +++ b/src/pages/Home/components/HomeHubToolbar.tsx @@ -41,6 +41,7 @@ import { FolderOpen, LayoutGrid, List, + PackagePlus, PlusCircle, } from 'lucide-react'; import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; @@ -243,6 +244,18 @@ export default function HomeHubToolbar({ + + diff --git a/src/pages/WorkspaceBundleInstall.tsx b/src/pages/WorkspaceBundleInstall.tsx new file mode 100644 index 00000000..b872bfdb --- /dev/null +++ b/src/pages/WorkspaceBundleInstall.tsx @@ -0,0 +1,14 @@ +import { WorkspaceBundleInstallWizard } from '@/components/WorkspaceBundle/WorkspaceBundleInstallWizard'; +import { useSearchParams } from 'react-router-dom'; + +export default function WorkspaceBundleInstall() { + const [searchParams] = useSearchParams(); + return ( +
+ +
+ ); +} diff --git a/src/pages/WorkspaceConfiguration.tsx b/src/pages/WorkspaceConfiguration.tsx index c656c9d6..35e14f13 100644 --- a/src/pages/WorkspaceConfiguration.tsx +++ b/src/pages/WorkspaceConfiguration.tsx @@ -12,6 +12,10 @@ import { Textarea } from '@/components/ui/textarea'; import { EnvironmentRequirementsEditor } from '@/components/WorkspaceConfiguration/EnvironmentRequirementsEditor'; import { WorkspaceBundleSaveDialog } from '@/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog'; import { useWorkspaceConfiguration } from '@/hooks/useWorkspaceConfiguration'; +import { + fetchWorkspaceBundleInstallForSpace, + type WorkspaceBundleInstallProposal, +} from '@/service/workspaceBundleInstallApi'; import { workspaceEnvironmentVariables, type ThinkingEffort, @@ -27,11 +31,13 @@ import { KeyRound, Plus, RefreshCw, + Settings2, Share2, ShieldCheck, Trash2, } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; const csv = (value: string): string[] => value @@ -116,7 +122,10 @@ const selectClassName = 'h-10 w-full rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none focus:ring-1 focus:ring-ds-ring-brand-default-focus'; export default function WorkspaceConfiguration() { + const navigate = useNavigate(); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [installedBundle, setInstalledBundle] = + useState(null); const activeSpaceId = useSpaceStore((state) => state.activeSpaceId); const activeSpace = useSpaceStore((state) => state.activeSpaceId ? state.spaces[state.activeSpaceId] : null @@ -134,6 +143,23 @@ export default function WorkspaceConfiguration() { identity, }); + useEffect(() => { + let active = true; + setInstalledBundle(null); + if (!activeSpaceId) return () => undefined; + void fetchWorkspaceBundleInstallForSpace(activeSpaceId) + .then((snapshot) => { + if (active) setInstalledBundle(snapshot.proposal); + }) + .catch(() => { + // A 404 means this is a locally authored Workspace. The configuration + // editor remains fully usable without an installation proposal. + }); + return () => { + active = false; + }; + }, [activeSpaceId]); + const update = useCallback( (mutate: (current: WorkspaceConfigurationDocument) => void) => { setDocument((current) => { @@ -214,6 +240,21 @@ export default function WorkspaceConfiguration() { Retry ) : null} + {installedBundle ? ( + + ) : null}