mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-16 04:03:32 +00:00
feat: add secure workforce bundle installation
This commit is contained in:
parent
485c397b57
commit
6ffb54cd01
40 changed files with 5892 additions and 62 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
258
backend/app/workspace_bundle/secrets.py
Normal file
258
backend/app/workspace_bundle/secrets.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
357
backend/tests/app/controller/test_workspace_bundle_controller.py
Normal file
357
backend/tests/app/controller/test_workspace_bundle_controller.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
247
backend/tests/app/workspace_bundle/test_secrets.py
Normal file
247
backend/tests/app/workspace_bundle/test_secrets.py
Normal file
|
|
@ -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"}
|
||||
|
|
@ -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") == []
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
247
electron/main/workspaceSecrets/broker.ts
Normal file
247
electron/main/workspaceSecrets/broker.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
): 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<string, unknown>;
|
||||
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<string, unknown>).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<unknown> {
|
||||
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<WorkspaceSecretBrokerRuntime> {
|
||||
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<void>((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<void> {
|
||||
const server = this.server;
|
||||
this.server = null;
|
||||
this.runtime = null;
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
}
|
||||
|
||||
private async handle(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
capability: string
|
||||
): Promise<void> {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
electron/main/workspaceSecrets/index.ts
Normal file
24
electron/main/workspaceSecrets/index.ts
Normal file
|
|
@ -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';
|
||||
47
electron/main/workspaceSecrets/ipc.ts
Normal file
47
electron/main/workspaceSecrets/ipc.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
45
electron/main/workspaceSecrets/runtime.ts
Normal file
45
electron/main/workspaceSecrets/runtime.ts
Normal file
|
|
@ -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<WorkspaceSecretBrokerRuntime> | null = null;
|
||||
|
||||
export function getDefaultWorkspaceSecretVault(): WorkspaceSecretVault {
|
||||
defaultVault ??= new WorkspaceSecretVault();
|
||||
return defaultVault;
|
||||
}
|
||||
|
||||
export function ensureWorkspaceSecretBroker(): Promise<WorkspaceSecretBrokerRuntime> {
|
||||
if (!brokerStart) {
|
||||
defaultBroker = new WorkspaceSecretBroker(getDefaultWorkspaceSecretVault());
|
||||
brokerStart = defaultBroker.start().catch((error) => {
|
||||
defaultBroker = null;
|
||||
brokerStart = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return brokerStart;
|
||||
}
|
||||
|
||||
export async function closeWorkspaceSecretBroker(): Promise<void> {
|
||||
const broker = defaultBroker;
|
||||
defaultBroker = null;
|
||||
brokerStart = null;
|
||||
await broker?.close();
|
||||
}
|
||||
46
electron/main/workspaceSecrets/types.ts
Normal file
46
electron/main/workspaceSecrets/types.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
426
electron/main/workspaceSecrets/vault.ts
Normal file
426
electron/main/workspaceSecrets/vault.ts
Normal file
|
|
@ -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<string, EncryptedWorkspaceSecretRecord>;
|
||||
}
|
||||
|
||||
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<EncryptedWorkspaceSecretRecord>;
|
||||
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<VaultDocument>;
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof ipcRenderer.on>) {
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MemoryRouter>
|
||||
<WorkspaceBundleInstallWizard {...props} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
1105
src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx
Normal file
1105
src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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({
|
|||
</span>
|
||||
</TooltipSimple>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
buttonContent="text"
|
||||
buttonRadius="full"
|
||||
onClick={() => navigate('/workspace-bundles/install')}
|
||||
>
|
||||
<PackagePlus className="h-4 w-4 shrink-0" aria-hidden />
|
||||
Import Workforce Bundle
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<TooltipSimple content={sortLabel} variant="instant">
|
||||
<span className="inline-flex">
|
||||
|
|
|
|||
14
src/pages/WorkspaceBundleInstall.tsx
Normal file
14
src/pages/WorkspaceBundleInstall.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { WorkspaceBundleInstallWizard } from '@/components/WorkspaceBundle/WorkspaceBundleInstallWizard';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function WorkspaceBundleInstall() {
|
||||
const [searchParams] = useSearchParams();
|
||||
return (
|
||||
<main className="h-full overflow-y-auto bg-ds-bg-neutral-muted-default px-6 py-8">
|
||||
<WorkspaceBundleInstallWizard
|
||||
initialHandle={searchParams.get('handle') || ''}
|
||||
initialProposalId={searchParams.get('proposal') || ''}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<WorkspaceBundleInstallProposal | null>(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
|
||||
</Button>
|
||||
) : null}
|
||||
{installedBundle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/workspace-bundles/install?proposal=${encodeURIComponent(installedBundle.proposal_id)}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" aria-hidden />
|
||||
Local setup
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ const RemoteControl = lazy(() => import('@/pages/RemoteControl'));
|
|||
const WorkspaceConfiguration = lazy(
|
||||
() => import('@/pages/WorkspaceConfiguration')
|
||||
);
|
||||
const WorkspaceBundleInstall = lazy(
|
||||
() => import('@/pages/WorkspaceBundleInstall')
|
||||
);
|
||||
|
||||
const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true';
|
||||
const ENABLE_DESKTOP_REMOTE_CONTROL_FALLBACK = isDesktop();
|
||||
|
|
@ -171,6 +174,10 @@ const AppRoutes = () => (
|
|||
path="/workspace-configuration"
|
||||
element={<WorkspaceConfiguration />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace-bundles/install"
|
||||
element={<WorkspaceBundleInstall />}
|
||||
/>
|
||||
<Route
|
||||
path="/setting"
|
||||
element={<Navigate to="/history?tab=settings" replace />}
|
||||
|
|
|
|||
145
src/service/workspaceBundleInstallApi.test.ts
Normal file
145
src/service/workspaceBundleInstallApi.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchGet: vi.fn(),
|
||||
fetchPost: vi.fn(),
|
||||
fetchPut: vi.fn(),
|
||||
findBundle: vi.fn(),
|
||||
getRevision: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/http', () => ({
|
||||
fetchGet: mocks.fetchGet,
|
||||
fetchPost: mocks.fetchPost,
|
||||
fetchPut: mocks.fetchPut,
|
||||
}));
|
||||
|
||||
vi.mock('./workspaceBundleAuthoringApi', () => ({
|
||||
findWorkspaceBundle: mocks.findBundle,
|
||||
getWorkspaceBundleRevision: mocks.getRevision,
|
||||
}));
|
||||
|
||||
import {
|
||||
bindWorkspaceBundleLocalValues,
|
||||
createWorkspaceBundleInstallProposal,
|
||||
fetchWorkspaceBundleInstallForSpace,
|
||||
fetchWorkspaceBundleInstallReview,
|
||||
parseWorkspaceBundleHandle,
|
||||
} from './workspaceBundleInstallApi';
|
||||
|
||||
describe('workspace Bundle install API', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
it('accepts only a canonical immutable share handle', () => {
|
||||
expect(parseWorkspaceBundleHandle('research-workforce@12')).toEqual({
|
||||
bundleId: 'research-workforce',
|
||||
revisionId: 'research-workforce@12',
|
||||
});
|
||||
expect(parseWorkspaceBundleHandle('research-workforce')).toBeNull();
|
||||
expect(parseWorkspaceBundleHandle('research-workforce@0')).toBeNull();
|
||||
});
|
||||
|
||||
it('loads the published revision before creating a local proposal', async () => {
|
||||
mocks.findBundle.mockResolvedValue({ id: 'research-workforce' });
|
||||
mocks.getRevision.mockResolvedValue({
|
||||
id: 'research-workforce@1',
|
||||
bundle_id: 'research-workforce',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
await fetchWorkspaceBundleInstallReview({
|
||||
bundleId: 'research-workforce',
|
||||
revisionId: 'research-workforce@1',
|
||||
});
|
||||
|
||||
expect(mocks.getRevision).toHaveBeenCalledWith(
|
||||
'research-workforce',
|
||||
'research-workforce@1'
|
||||
);
|
||||
expect(mocks.fetchPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a draft revision during the review-first read', async () => {
|
||||
mocks.findBundle.mockResolvedValue({ id: 'research-workforce' });
|
||||
mocks.getRevision.mockResolvedValue({
|
||||
id: 'research-workforce@1',
|
||||
bundle_id: 'research-workforce',
|
||||
status: 'validated',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceBundleInstallReview({
|
||||
bundleId: 'research-workforce',
|
||||
revisionId: 'research-workforce@1',
|
||||
})
|
||||
).rejects.toThrow('Only published');
|
||||
});
|
||||
|
||||
it('creates the durable proposal with sidecar placement', async () => {
|
||||
mocks.fetchPost.mockResolvedValue({ proposal: { proposal_id: 'p-1' } });
|
||||
|
||||
await createWorkspaceBundleInstallProposal({
|
||||
proposalId: 'p-1',
|
||||
requestId: 'r-1',
|
||||
spaceId: 'space-1',
|
||||
bundleId: 'research-workforce',
|
||||
revisionId: 'research-workforce@1',
|
||||
});
|
||||
|
||||
expect(mocks.fetchPost).toHaveBeenCalledWith(
|
||||
'/api/v1/workspace-bundles/install-proposals',
|
||||
expect.objectContaining({
|
||||
proposal_id: 'p-1',
|
||||
config_placement: 'sidecar',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('loads the durable installation attached to a Space', async () => {
|
||||
mocks.fetchGet.mockResolvedValue({
|
||||
proposal: { proposal_id: 'proposal-1' },
|
||||
});
|
||||
|
||||
await fetchWorkspaceBundleInstallForSpace('space / one');
|
||||
|
||||
expect(mocks.fetchGet).toHaveBeenCalledWith(
|
||||
'/api/v1/spaces/space%20%2F%20one/workspace-bundle-installation'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends only opaque vault references to Brain, never plaintext', async () => {
|
||||
mocks.fetchPut.mockResolvedValue({ proposal: { proposal_id: 'p-1' } });
|
||||
const plaintext = 'secret-value-that-must-not-cross-ipc';
|
||||
|
||||
await bindWorkspaceBundleLocalValues({
|
||||
proposalId: 'p-1',
|
||||
clientRequestId: 'bind-1',
|
||||
expectedVersion: 3,
|
||||
actorId: 'user-1',
|
||||
bindings: [
|
||||
{
|
||||
requirement_key: 'environment:API_TOKEN',
|
||||
requirement_kind: 'environment',
|
||||
secret_ref: 'wsvault_opaque-reference',
|
||||
account_scope_digest: 'a'.repeat(64),
|
||||
expected_binding_version: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const serializedPayload = JSON.stringify(mocks.fetchPut.mock.calls[0][1]);
|
||||
expect(serializedPayload).not.toContain(plaintext);
|
||||
expect(serializedPayload).toContain('wsvault_opaque-reference');
|
||||
expect(mocks.fetchPut.mock.calls[0][1].bindings[0]).not.toHaveProperty(
|
||||
'value'
|
||||
);
|
||||
expect(mocks.fetchPut).toHaveBeenCalledWith(
|
||||
'/api/v1/workspace-bundles/install-proposals/p-1/local-values',
|
||||
expect.objectContaining({
|
||||
bindings: [expect.objectContaining({ expected_binding_version: null })],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
286
src/service/workspaceBundleInstallApi.ts
Normal file
286
src/service/workspaceBundleInstallApi.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { fetchGet, fetchPost, fetchPut } from '@/api/http';
|
||||
import {
|
||||
findWorkspaceBundle,
|
||||
getWorkspaceBundleRevision,
|
||||
type CloudWorkspaceBundle,
|
||||
type CloudWorkspaceBundleRevision,
|
||||
} from './workspaceBundleAuthoringApi';
|
||||
import type { WorkspaceConfigurationDocument } from './workspaceConfigurationApi';
|
||||
|
||||
export type WorkspaceBundleInstallState =
|
||||
| 'proposed'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'materializing'
|
||||
| 'materialized'
|
||||
| 'needs_attention';
|
||||
|
||||
export interface WorkspaceBundleValueRequirement {
|
||||
requirement_key: string;
|
||||
requirement_kind: 'environment' | 'mcp_secret';
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
binding_version: number | null;
|
||||
required: boolean;
|
||||
name?: string;
|
||||
mcp_id?: string;
|
||||
slot_id?: string;
|
||||
sensitive?: boolean;
|
||||
description?: string | null;
|
||||
example?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceBundleInstallPlan {
|
||||
connector_slots: Array<{
|
||||
slot_id: string;
|
||||
connector_id: string;
|
||||
required_grants: string[];
|
||||
}>;
|
||||
local_path_slots: string[];
|
||||
script_actions: string[];
|
||||
environment_requirements: Array<Record<string, unknown>>;
|
||||
mcp_secret_requirements: Array<Record<string, unknown>>;
|
||||
permission_profile: string;
|
||||
git_policy: Record<string, unknown>;
|
||||
asset_count: number;
|
||||
asset_bytes: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceBundleInstallProposal {
|
||||
proposal_id: string;
|
||||
request_id: string;
|
||||
space_id: string;
|
||||
bundle_id: string;
|
||||
revision_id: string;
|
||||
config_placement: 'in_repo' | 'sidecar';
|
||||
state: WorkspaceBundleInstallState;
|
||||
version: number;
|
||||
manifest: WorkspaceConfigurationDocument;
|
||||
manifest_digest: string;
|
||||
assets: Array<{
|
||||
id: string;
|
||||
logical_path: string;
|
||||
content_digest: string;
|
||||
media_type: string;
|
||||
size_bytes: number;
|
||||
}>;
|
||||
install_plan: WorkspaceBundleInstallPlan;
|
||||
error_code?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceBundleInstallBinding {
|
||||
slot_id: string;
|
||||
binding_kind: 'connector' | 'local_path' | 'script_approval';
|
||||
connector_id?: string | null;
|
||||
opaque_connection_id?: string | null;
|
||||
local_path?: string | null;
|
||||
required_grants: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceBundleInstallSnapshot {
|
||||
proposal: WorkspaceBundleInstallProposal;
|
||||
bindings: WorkspaceBundleInstallBinding[];
|
||||
value_requirements: WorkspaceBundleValueRequirement[];
|
||||
readiness: {
|
||||
ready: boolean;
|
||||
missing_requirements: string[];
|
||||
};
|
||||
/** Exact prior refs replaced by this CAS; present only on local-value PUT. */
|
||||
cleanup_secret_refs?: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceBundleInstallReview {
|
||||
bundle: CloudWorkspaceBundle | null;
|
||||
revision: CloudWorkspaceBundleRevision;
|
||||
}
|
||||
|
||||
export interface ParsedWorkspaceBundleHandle {
|
||||
bundleId: string;
|
||||
revisionId: string;
|
||||
}
|
||||
|
||||
export function parseWorkspaceBundleHandle(
|
||||
input: string
|
||||
): ParsedWorkspaceBundleHandle | null {
|
||||
const value = input.trim();
|
||||
const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,79})@([1-9][0-9]*)$/.exec(value);
|
||||
if (!match) return null;
|
||||
return { bundleId: match[1], revisionId: value };
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceBundleInstallReview(
|
||||
handle: ParsedWorkspaceBundleHandle
|
||||
): Promise<WorkspaceBundleInstallReview> {
|
||||
const revision = await getWorkspaceBundleRevision(
|
||||
handle.bundleId,
|
||||
handle.revisionId
|
||||
);
|
||||
if (revision.status !== 'published') {
|
||||
throw new Error(
|
||||
'Only published Workforce Bundle versions can be installed.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
revision.bundle_id !== handle.bundleId ||
|
||||
revision.id !== handle.revisionId
|
||||
) {
|
||||
throw new Error('The Workforce Bundle version identity does not match.');
|
||||
}
|
||||
// Public install remains usable even if mutable owner metadata is not
|
||||
// readable by this account. The immutable revision is the authority.
|
||||
const bundle = await findWorkspaceBundle(handle.bundleId).catch(() => null);
|
||||
return { bundle, revision };
|
||||
}
|
||||
|
||||
export const createWorkspaceBundleInstallProposal = async (input: {
|
||||
proposalId: string;
|
||||
requestId: string;
|
||||
spaceId: string;
|
||||
bundleId: string;
|
||||
revisionId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost('/api/v1/workspace-bundles/install-proposals', {
|
||||
proposal_id: input.proposalId,
|
||||
request_id: input.requestId,
|
||||
space_id: input.spaceId,
|
||||
bundle_id: input.bundleId,
|
||||
revision_id: input.revisionId,
|
||||
config_placement: 'sidecar',
|
||||
});
|
||||
|
||||
export const fetchWorkspaceBundleInstallProposal = async (
|
||||
proposalId: string
|
||||
): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchGet(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(proposalId)}`
|
||||
);
|
||||
|
||||
export const fetchWorkspaceBundleInstallForSpace = async (
|
||||
spaceId: string
|
||||
): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchGet(
|
||||
`/api/v1/spaces/${encodeURIComponent(spaceId)}/workspace-bundle-installation`
|
||||
);
|
||||
|
||||
export const decideWorkspaceBundleInstall = async (input: {
|
||||
proposalId: string;
|
||||
expectedVersion: number;
|
||||
approved: boolean;
|
||||
actorId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/decision`,
|
||||
{
|
||||
expected_version: input.expectedVersion,
|
||||
approved: input.approved,
|
||||
actor_id: input.actorId,
|
||||
}
|
||||
);
|
||||
|
||||
export const bindWorkspaceBundleConnector = async (input: {
|
||||
proposalId: string;
|
||||
expectedVersion: number;
|
||||
slotId: string;
|
||||
connectorId: string;
|
||||
connectionId: string;
|
||||
actorId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/connector-bindings`,
|
||||
{
|
||||
expected_version: input.expectedVersion,
|
||||
slot_id: input.slotId,
|
||||
connector_id: input.connectorId,
|
||||
connection_id: input.connectionId,
|
||||
actor_id: input.actorId,
|
||||
}
|
||||
);
|
||||
|
||||
export const bindWorkspaceBundleLocalPath = async (input: {
|
||||
proposalId: string;
|
||||
expectedVersion: number;
|
||||
slotId: string;
|
||||
localPath: string;
|
||||
actorId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/local-path-bindings`,
|
||||
{
|
||||
expected_version: input.expectedVersion,
|
||||
slot_id: input.slotId,
|
||||
local_path: input.localPath,
|
||||
actor_id: input.actorId,
|
||||
}
|
||||
);
|
||||
|
||||
export const approveWorkspaceBundleScript = async (input: {
|
||||
proposalId: string;
|
||||
expectedVersion: number;
|
||||
actionId: string;
|
||||
actorId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/script-approvals`,
|
||||
{
|
||||
expected_version: input.expectedVersion,
|
||||
action_id: input.actionId,
|
||||
actor_id: input.actorId,
|
||||
}
|
||||
);
|
||||
|
||||
export interface WorkspaceBundleOpaqueValueBinding {
|
||||
requirement_key: string;
|
||||
requirement_kind: 'environment' | 'mcp_secret';
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
expected_binding_version: number | null;
|
||||
}
|
||||
|
||||
export const bindWorkspaceBundleLocalValues = async (input: {
|
||||
proposalId: string;
|
||||
clientRequestId: string;
|
||||
expectedVersion: number;
|
||||
actorId: string;
|
||||
bindings: WorkspaceBundleOpaqueValueBinding[];
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPut(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/local-values`,
|
||||
{
|
||||
client_request_id: input.clientRequestId,
|
||||
expected_version: input.expectedVersion,
|
||||
actor_id: input.actorId,
|
||||
bindings: input.bindings,
|
||||
}
|
||||
);
|
||||
|
||||
export const materializeWorkspaceBundle = async (input: {
|
||||
proposalId: string;
|
||||
expectedVersion: number;
|
||||
email: string;
|
||||
userId?: string | number | null;
|
||||
actorId: string;
|
||||
}): Promise<WorkspaceBundleInstallSnapshot> =>
|
||||
fetchPost(
|
||||
`/api/v1/workspace-bundles/install-proposals/${encodeURIComponent(input.proposalId)}/materialize`,
|
||||
{
|
||||
expected_version: input.expectedVersion,
|
||||
email: input.email,
|
||||
...(input.userId === undefined || input.userId === null
|
||||
? {}
|
||||
: { user_id: input.userId }),
|
||||
actor_id: input.actorId,
|
||||
allow_content_repository_init: false,
|
||||
}
|
||||
);
|
||||
|
||||
export async function workspaceBundleAccountScopeDigest(
|
||||
actorId: string
|
||||
): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(`eigent-account:${actorId.trim().toLowerCase()}`)
|
||||
);
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('');
|
||||
}
|
||||
46
src/types/electron.d.ts
vendored
46
src/types/electron.d.ts
vendored
|
|
@ -116,6 +116,52 @@ interface ElectronAPI {
|
|||
envWrite: (email: string, kv: { key: string; value: string }) => Promise<any>;
|
||||
envRemove: (email: string, key: string) => Promise<any>;
|
||||
getEnvPath: (email: string) => Promise<string>;
|
||||
workspaceSecretPut: (request: {
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
value: string;
|
||||
}) => Promise<{
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
state: 'available';
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
workspaceSecretStatus: (request: {
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
}) => Promise<{
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
state: 'available' | 'missing' | 'needs_rebind';
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
workspaceSecretDelete: (request: {
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
}) => Promise<{
|
||||
secret_ref: string;
|
||||
account_scope_digest: string;
|
||||
space_id: string;
|
||||
revision_id: string;
|
||||
slot_id: string;
|
||||
state: 'missing';
|
||||
}>;
|
||||
executeCommand: (
|
||||
command: string,
|
||||
email: string
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ describe('terminal IPC lifecycle', () => {
|
|||
GH_TOKEN: 'secret',
|
||||
AWS_SECRET_ACCESS_KEY: 'secret',
|
||||
CODEX_RESOLVER_SECRET: 'secret',
|
||||
EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY: 'secret',
|
||||
EIGENT_WORKFORCE_SECRET_BROKER_CAPABILITY: 'legacy-secret',
|
||||
})
|
||||
).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
|
|
|
|||
27
test/unit/electron/main/workspaceSecretWiring.test.ts
Normal file
27
test/unit/electron/main/workspaceSecretWiring.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Workspace secret Electron wiring', () => {
|
||||
it('guards IPC with the main renderer and supplies only broker coordinates to Brain', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'electron/main/index.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(source).toMatch(
|
||||
/registerWorkspaceSecretIpcHandlers\([\s\S]*?assertMainRendererSender[\s\S]*?\)/u
|
||||
);
|
||||
expect(source).toContain('EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT');
|
||||
expect(source).toContain('EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY');
|
||||
expect(source).not.toMatch(
|
||||
/EIGENT_WORKSPACE_SECRET_(?:VALUE|TOKEN|PLAINTEXT)/u
|
||||
);
|
||||
|
||||
const terminalSource = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'electron/main/terminal.ts'),
|
||||
'utf8'
|
||||
);
|
||||
expect(terminalSource).toMatch(/PRIVATE_KEY\|CAPABILITY/u);
|
||||
});
|
||||
});
|
||||
450
test/unit/electron/main/workspaceSecrets.test.ts
Normal file
450
test/unit/electron/main/workspaceSecrets.test.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// ========= 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 fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
getSelectedStorageBackend: () => 'kwallet6',
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf8'),
|
||||
},
|
||||
}));
|
||||
|
||||
import { WorkspaceSecretBroker } from '../../../../electron/main/workspaceSecrets/broker';
|
||||
import { registerWorkspaceSecretIpcHandlers } from '../../../../electron/main/workspaceSecrets/ipc';
|
||||
import type {
|
||||
WorkspaceSecretLookup,
|
||||
WorkspaceSecretPutRequest,
|
||||
} from '../../../../electron/main/workspaceSecrets/types';
|
||||
import {
|
||||
WorkspaceSecretBindingMismatchError,
|
||||
WorkspaceSecretVault,
|
||||
} from '../../../../electron/main/workspaceSecrets/vault';
|
||||
|
||||
function fakeCrypto(
|
||||
options: {
|
||||
available?: boolean;
|
||||
backend?: string;
|
||||
failDecrypt?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
isEncryptionAvailable: () => options.available ?? true,
|
||||
getSelectedStorageBackend: () => options.backend ?? 'kwallet6',
|
||||
encryptString: (value: string) =>
|
||||
Buffer.from(`cipher:${Buffer.from(value).toString('base64')}`, 'utf8'),
|
||||
decryptString: (value: Buffer) => {
|
||||
if (options.failDecrypt) throw new Error('rotated keychain');
|
||||
const encoded = value.toString('utf8').replace(/^cipher:/u, '');
|
||||
return Buffer.from(encoded, 'base64').toString('utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const scope = {
|
||||
account_scope_digest: 'a'.repeat(64),
|
||||
space_id: 'space-1',
|
||||
revision_id: 'bundle@1',
|
||||
slot_id: 'mcp.github.env.GITHUB_TOKEN',
|
||||
};
|
||||
|
||||
describe('WorkspaceSecretVault', () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-vault-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function vault(
|
||||
options: Parameters<typeof fakeCrypto>[0] = {},
|
||||
platform: NodeJS.Platform = 'darwin'
|
||||
) {
|
||||
return new WorkspaceSecretVault({
|
||||
rootDir: path.join(rootDir, 'secure'),
|
||||
crypto: fakeCrypto(options),
|
||||
platform,
|
||||
now: () => new Date('2026-08-11T00:00:00.000Z'),
|
||||
});
|
||||
}
|
||||
|
||||
it('encrypts each value and writes only scoped metadata with secure permissions', () => {
|
||||
const store = vault();
|
||||
const fsync = vi.spyOn(fs, 'fsyncSync');
|
||||
const result = store.put({ ...scope, value: 'sentinel-super-secret' });
|
||||
const disk = fs.readFileSync(store.filePath, 'utf8');
|
||||
|
||||
expect(result.state).toBe('available');
|
||||
expect(result.secret_ref).toMatch(/^wsvault_[A-Za-z0-9_-]{32}$/u);
|
||||
expect(result).not.toHaveProperty('value');
|
||||
expect(disk).not.toContain('sentinel-super-secret');
|
||||
expect(disk).not.toContain(
|
||||
Buffer.from('sentinel-super-secret').toString('base64')
|
||||
);
|
||||
expect(disk).toContain(scope.slot_id);
|
||||
expect(fs.statSync(store.rootDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(store.filePath).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readdirSync(store.rootDir)).toEqual([
|
||||
'workforce-secret-vault.v1.json',
|
||||
]);
|
||||
expect(fsync).toHaveBeenCalledTimes(2);
|
||||
expect(store.resolve(result)).toBe('sentinel-super-secret');
|
||||
});
|
||||
|
||||
it('creates an immutable candidate reference when the tuple is rebound', () => {
|
||||
const store = vault();
|
||||
const first = store.put({ ...scope, value: 'first-value' });
|
||||
const second = store.put({ ...scope, value: 'second-value' });
|
||||
|
||||
expect(second.secret_ref).not.toBe(first.secret_ref);
|
||||
expect(store.resolve(first)).toBe('first-value');
|
||||
expect(store.resolve(second)).toBe('second-value');
|
||||
});
|
||||
|
||||
it('rejects Linux basic_text rather than creating a plaintext fallback', () => {
|
||||
const store = vault({ backend: 'basic_text' }, 'linux');
|
||||
|
||||
expect(() => store.put({ ...scope, value: 'must-not-be-written' })).toThrow(
|
||||
/basic_text/
|
||||
);
|
||||
expect(fs.existsSync(store.filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when OS encryption is unavailable', () => {
|
||||
const store = vault({ available: false });
|
||||
|
||||
expect(() =>
|
||||
store.put({ ...scope, value: 'must-remain-memory-only' })
|
||||
).toThrow(/encryption is unavailable/);
|
||||
expect(fs.existsSync(store.filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unreadable ciphertext as needs_rebind without returning a value', () => {
|
||||
const writer = vault();
|
||||
const created = writer.put({ ...scope, value: 'rotating-secret' });
|
||||
const reader = vault({ failDecrypt: true });
|
||||
|
||||
const result = reader.status(created);
|
||||
expect(result.state).toBe('needs_rebind');
|
||||
expect(result).not.toHaveProperty('value');
|
||||
expect(() => reader.resolve(created)).toThrow(/rebound/);
|
||||
});
|
||||
|
||||
it('rejects a secret_ref replayed under a different binding tuple', () => {
|
||||
const store = vault();
|
||||
const created = store.put({ ...scope, value: 'scoped-secret' });
|
||||
|
||||
expect(() => store.status({ ...created, space_id: 'other-space' })).toThrow(
|
||||
WorkspaceSecretBindingMismatchError
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the previous durable record if the atomic rename fails', () => {
|
||||
const store = vault();
|
||||
const created = store.put({ ...scope, value: 'durable-old-value' });
|
||||
vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
|
||||
throw new Error('simulated crash before rename');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
store.put({ ...scope, value: 'uncommitted-new-value' })
|
||||
).toThrow(/simulated crash/);
|
||||
expect(store.resolve(created)).toBe('durable-old-value');
|
||||
expect(
|
||||
fs.readdirSync(store.rootDir).filter((name) => name.endsWith('.tmp'))
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns a malformed vault into needs_rebind and refuses destructive overwrite', () => {
|
||||
const store = vault();
|
||||
fs.mkdirSync(store.rootDir, { recursive: true });
|
||||
fs.writeFileSync(store.filePath, '{truncated');
|
||||
const lookup: WorkspaceSecretLookup = {
|
||||
...scope,
|
||||
secret_ref: `wsvault_${'A'.repeat(32)}`,
|
||||
};
|
||||
|
||||
expect(store.status(lookup).state).toBe('needs_rebind');
|
||||
expect(() => store.put({ ...scope, value: 'new-value' })).toThrow(
|
||||
/corrupted/
|
||||
);
|
||||
expect(fs.readFileSync(store.filePath, 'utf8')).toBe('{truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace secret IPC', () => {
|
||||
it('runs the main-renderer guard before every vault operation', () => {
|
||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
};
|
||||
const vault = {
|
||||
put: vi.fn(),
|
||||
status: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
const guard = vi.fn(() => {
|
||||
throw new Error('untrusted sender');
|
||||
});
|
||||
registerWorkspaceSecretIpcHandlers(ipcMain as any, vault as any, guard);
|
||||
|
||||
for (const channel of [
|
||||
'workspace-secret:put',
|
||||
'workspace-secret:status',
|
||||
'workspace-secret:delete',
|
||||
]) {
|
||||
expect(() => handlers.get(channel)!({ sender: { id: 99 } }, {})).toThrow(
|
||||
/untrusted sender/
|
||||
);
|
||||
}
|
||||
expect(guard).toHaveBeenCalledTimes(3);
|
||||
expect(vault.put).not.toHaveBeenCalled();
|
||||
expect(vault.status).not.toHaveBeenCalled();
|
||||
expect(vault.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceSecretBroker', () => {
|
||||
let rootDir: string;
|
||||
let broker: WorkspaceSecretBroker | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-broker-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await broker?.close();
|
||||
broker = null;
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('requires its random capability and only reveals binding status', async () => {
|
||||
const store = new WorkspaceSecretVault({
|
||||
rootDir: path.join(rootDir, 'secure'),
|
||||
crypto: fakeCrypto(),
|
||||
});
|
||||
const request: WorkspaceSecretPutRequest = {
|
||||
...scope,
|
||||
value: 'broker-must-not-return-this',
|
||||
};
|
||||
const created = store.put(request);
|
||||
broker = new WorkspaceSecretBroker(store, () => Buffer.alloc(32, 7));
|
||||
const runtime = await broker.start();
|
||||
expect(runtime.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/u);
|
||||
|
||||
const unauthorized = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(created),
|
||||
}
|
||||
);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const authorized = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${runtime.capability}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(created),
|
||||
}
|
||||
);
|
||||
expect(authorized.status).toBe(200);
|
||||
const body = await authorized.text();
|
||||
expect(body).toContain('available');
|
||||
expect(body).not.toContain('broker-must-not-return-this');
|
||||
|
||||
const resolveAttempt = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/resolve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${runtime.capability}` },
|
||||
body: JSON.stringify(created),
|
||||
}
|
||||
);
|
||||
expect(resolveAttempt.status).toBe(404);
|
||||
});
|
||||
|
||||
it('verifies up to 100 bindings in one ordered response without values', async () => {
|
||||
const lookups: WorkspaceSecretLookup[] = [
|
||||
{
|
||||
...scope,
|
||||
secret_ref: `wsvault_${'A'.repeat(32)}`,
|
||||
slot_id: 'available-slot',
|
||||
},
|
||||
{
|
||||
...scope,
|
||||
secret_ref: `wsvault_${'B'.repeat(32)}`,
|
||||
slot_id: 'missing-slot',
|
||||
},
|
||||
{
|
||||
...scope,
|
||||
secret_ref: `wsvault_${'C'.repeat(32)}`,
|
||||
slot_id: 'rebind-slot',
|
||||
},
|
||||
];
|
||||
const store = {
|
||||
status: vi.fn((lookup: WorkspaceSecretLookup) => ({
|
||||
...lookup,
|
||||
state:
|
||||
lookup.slot_id === 'available-slot'
|
||||
? 'available'
|
||||
: lookup.slot_id === 'missing-slot'
|
||||
? 'missing'
|
||||
: 'needs_rebind',
|
||||
})),
|
||||
};
|
||||
broker = new WorkspaceSecretBroker(
|
||||
store as unknown as WorkspaceSecretVault
|
||||
);
|
||||
const runtime = await broker.start();
|
||||
|
||||
const response = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${runtime.capability}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ bindings: lookups }),
|
||||
}
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.statuses.map((item: { state: string }) => item.state)).toEqual([
|
||||
'available',
|
||||
'missing',
|
||||
'needs_rebind',
|
||||
]);
|
||||
expect(JSON.stringify(body)).not.toContain('value');
|
||||
expect(store.status).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects batches larger than 100 before consulting the vault', async () => {
|
||||
const store = { status: vi.fn() };
|
||||
broker = new WorkspaceSecretBroker(
|
||||
store as unknown as WorkspaceSecretVault
|
||||
);
|
||||
const runtime = await broker.start();
|
||||
const response = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${runtime.capability}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bindings: Array.from({ length: 101 }, () => ({
|
||||
secret_ref: 'x',
|
||||
account_scope_digest: 'x',
|
||||
space_id: 'x',
|
||||
revision_id: 'x',
|
||||
slot_id: 'x',
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a valid reference presented with different metadata', async () => {
|
||||
const store = new WorkspaceSecretVault({
|
||||
rootDir: path.join(rootDir, 'secure'),
|
||||
crypto: fakeCrypto(),
|
||||
});
|
||||
const created = store.put({ ...scope, value: 'tuple-bound' });
|
||||
broker = new WorkspaceSecretBroker(store);
|
||||
const runtime = await broker.start();
|
||||
|
||||
const response = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${runtime.capability}` },
|
||||
body: JSON.stringify({ ...created, revision_id: 'bundle@2' }),
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
error_code: 'binding_scope_mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails the whole batch when any binding tuple is mismatched', async () => {
|
||||
const store = new WorkspaceSecretVault({
|
||||
rootDir: path.join(rootDir, 'secure'),
|
||||
crypto: fakeCrypto(),
|
||||
});
|
||||
const created = store.put({ ...scope, value: 'tuple-bound-batch' });
|
||||
broker = new WorkspaceSecretBroker(store);
|
||||
const runtime = await broker.start();
|
||||
|
||||
const response = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${runtime.capability}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bindings: [created, { ...created, slot_id: 'different-slot' }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
error_code: 'binding_scope_mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects oversized request bodies before parsing them', async () => {
|
||||
const store = new WorkspaceSecretVault({
|
||||
rootDir: path.join(rootDir, 'secure'),
|
||||
crypto: fakeCrypto(),
|
||||
});
|
||||
broker = new WorkspaceSecretBroker(store);
|
||||
const runtime = await broker.start();
|
||||
|
||||
const response = await fetch(
|
||||
`${runtime.endpoint}/v1/workspace-secrets/verify`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${runtime.capability}` },
|
||||
body: JSON.stringify({ padding: 'x'.repeat(17 * 1024) }),
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(413);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue