Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ CREDENTIAL_ENCRYPTION_KEY=
# WS 反向通道(/api/v1/nodes/ws、/api/v1/browsers/agents/ws)两条握手路径。
API_AUTH_TOKEN=

# 组织身份验证(标准 OpenID Connect;提供方可为 Gitea、Keycloak、Authentik、
# Azure AD 或任何兼容 OIDC 的企业身份源)。
OIDC_ISSUER=
OIDC_AUDIENCE=
# 可选;留空时后端通过 <OIDC_ISSUER>/.well-known/openid-configuration 发现 jwks_uri。
OIDC_JWKS_URL=
# 仅用于首次部署或紧急恢复,不替代正式 OIDC 登录。
BOOTSTRAP_ADMIN_TOKEN=

# 服务端口(两种启动模式均生效)
API_PORT=8031 # API 服务对外端口
# Frontend lives in C:\c\Users\Administrator\projects\open-cli-admin.
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,22 @@
### Domain docs

采用 single-context:根目录 `CONTEXT.md` 是领域词汇表,`docs/adr/` 保存架构决策。See `docs/agents/domain.md`.

## Skill routing

When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.

Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec
81 changes: 80 additions & 1 deletion DESIGN.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions backend/api/v1/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ async def read_identity(
"subject": identity.subject,
"email": identity.email,
"name": identity.name,
"username": identity.username,
"picture": identity.picture,
"is_platform_admin": identity.is_platform_admin,
"auth_method": identity.auth_method,
}
Expand Down
13 changes: 13 additions & 0 deletions backend/api/v1/workflows.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""WorkflowProject compile and runtime endpoints."""

import uuid
from typing import Literal

from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -132,6 +133,13 @@ def get_opencli_adapter_nodes(
site: str | None = None,
q: str | None = None,
include_write: bool = Query(True, alias="includeWrite"),
access: Literal["read", "write"] | None = None,
capability: Literal["fetch", "store"] | None = None,
browser: bool | None = None,
preset_kind: workflow_schemas.WorkflowOpenCLIAdapterPresetKind
| None = Query(None, alias="presetKind"),
runtime_readiness: workflow_schemas.WorkflowOpenCLIAdapterReadiness
| None = Query(None, alias="runtimeReadiness"),
limit: int = Query(2000, ge=1, le=5000),
refresh: bool = False,
) -> ApiResponse[workflow_schemas.WorkflowOpenCLIAdapterNodesResponse]:
Expand All @@ -142,6 +150,11 @@ def get_opencli_adapter_nodes(
site=site,
q=q,
include_write=include_write,
access=access,
capability=capability,
browser=browser,
preset_kind=preset_kind,
runtime_readiness=runtime_readiness,
limit=limit,
refresh=refresh,
)
Expand Down
55 changes: 55 additions & 0 deletions backend/api/v1/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from backend.database import get_db
from backend.models.identity import User, Workspace, WorkspaceMembership, WorkspaceRole
from backend.models.workflow import Project
from backend.schemas.common import ApiResponse
from backend.schemas.workflow_asset import ProjectRead
from backend.schemas.workspace import (
WorkspaceCreate,
WorkspaceCreatedRead,
Expand Down Expand Up @@ -53,6 +55,59 @@ async def _get_or_create_user(
return user


@router.get(
"/governance/workspaces",
response_model=ApiResponse[list[WorkspaceRead]],
)
async def list_accessible_workspaces(
identity: RequestIdentity = Depends(get_request_identity),
db: AsyncSession = Depends(get_db),
) -> ApiResponse:
rows = (
(
await db.execute(
select(Workspace)
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
.join(User, User.id == WorkspaceMembership.user_id)
.where(
User.subject == identity.subject,
User.disabled.is_(False),
Workspace.active.is_(True),
)
.order_by(Workspace.name)
)
)
.scalars()
.all()
)
return ApiResponse.ok([WorkspaceRead.model_validate(row) for row in rows])


@router.get(
"/governance/workspaces/{workspace_id}/projects",
response_model=ApiResponse[list[ProjectRead]],
)
async def list_governance_projects(
workspace_id: str,
identity: RequestIdentity = Depends(get_request_identity),
db: AsyncSession = Depends(get_db),
) -> ApiResponse:
access = await get_workspace_access(db, workspace_id, identity)
require_permission(access, WorkspacePermission.READ)
rows = (
(
await db.execute(
select(Project)
.where(Project.workspace_id == workspace_id, Project.archived.is_(False))
.order_by(Project.updated_at.desc())
)
)
.scalars()
.all()
)
return ApiResponse.ok([ProjectRead.model_validate(row) for row in rows])


@router.post(
"/platform/workspaces",
response_model=ApiResponse[WorkspaceCreatedRead],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""repair a missing plugin installation registry table

Revision ID: g4h5i6j7k8l9
Revises: f3g4h5i6j7k8
Create Date: 2026-07-26
"""

import sqlalchemy as sa
from alembic import context, op

revision = "g4h5i6j7k8l9"
down_revision = "f3g4h5i6j7k8"
branch_labels = None
depends_on = None


def upgrade() -> None:
if context.is_offline_mode():
return

if sa.inspect(op.get_bind()).has_table("plugin_installations"):
return

op.create_table(
"plugin_installations",
sa.Column("provider_key", sa.String(length=257), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("author", sa.String(length=128), nullable=False),
sa.Column("version", sa.String(length=64), nullable=False),
sa.Column("source_kind", sa.String(length=32), nullable=False),
sa.Column("source_digest", sa.String(length=64), nullable=False),
sa.Column("manifest_spec_version", sa.String(length=32), nullable=False),
sa.Column("signature_state", sa.String(length=32), nullable=False),
sa.Column("manifest_json", sa.JSON(), nullable=False),
sa.Column("capabilities_json", sa.JSON(), nullable=False),
sa.Column("permissions_json", sa.JSON(), nullable=False),
sa.Column("runtime_status", sa.String(length=32), nullable=False),
sa.Column("blockers_json", sa.JSON(), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"provider_key",
"version",
"source_digest",
name="uq_plugin_installations_provider_version_digest",
),
)
op.create_index(
"ix_plugin_installations_provider_key",
"plugin_installations",
["provider_key"],
unique=False,
)


def downgrade() -> None:
# The table belongs to z5f6g7h8i9j0; this repair revision never owns its removal.
pass
21 changes: 21 additions & 0 deletions backend/schemas/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ def _legacy_workflow_node_location(node_path: list[str]) -> tuple[str | None, st
"resource",
]
WorkflowCapabilityStatus = Literal["runnable", "blocked", "preview_only", "design_only"]
WorkflowOpenCLIAdapterPresetKind = Literal["source_slot", "tool_capability"]
WorkflowOpenCLIAdapterReadiness = Literal[
"source_slot_ready",
"source_slot_requires_params",
"tool_capability_review_required",
]


class WorkflowSourceAnchor(BaseModel):
Expand Down Expand Up @@ -467,16 +473,31 @@ class WorkflowOpenCLIAdapterNode(BaseModel):
catalogId: str = Field(..., min_length=1)
kind: WorkflowNodeKind
capability: WorkflowCapability
presetKind: WorkflowOpenCLIAdapterPresetKind
runtimeReadiness: WorkflowOpenCLIAdapterReadiness
requiredArgs: list[str] = Field(default_factory=list)
args: list[WorkflowOpenCLIAdapterNodeArg] = Field(default_factory=list)
adapter: dict[str, Any] = Field(default_factory=dict)
params: dict[str, Any] = Field(default_factory=dict)
manifest: dict[str, Any] = Field(default_factory=dict)


class WorkflowOpenCLIAdapterNodeFacets(BaseModel):
site: dict[str, int] = Field(default_factory=dict)
capability: dict[str, int] = Field(default_factory=dict)
access: dict[str, int] = Field(default_factory=dict)
browser: dict[str, int] = Field(default_factory=dict)
status: dict[str, int] = Field(default_factory=dict)
presetKind: dict[str, int] = Field(default_factory=dict)
runtimeReadiness: dict[str, int] = Field(default_factory=dict)


class WorkflowOpenCLIAdapterNodesResponse(BaseModel):
total: int = Field(..., ge=0)
summary: dict[str, Any] = Field(default_factory=dict)
facets: WorkflowOpenCLIAdapterNodeFacets = Field(
default_factory=WorkflowOpenCLIAdapterNodeFacets
)
nodes: list[WorkflowOpenCLIAdapterNode] = Field(default_factory=list)


Expand Down
38 changes: 30 additions & 8 deletions backend/security/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class RequestIdentity:
subject: str
email: str | None = None
name: str | None = None
username: str | None = None
picture: str | None = None
is_platform_admin: bool = False
auth_method: str = "oidc"
claims: Mapping[str, Any] | None = None
Expand Down Expand Up @@ -76,23 +78,38 @@ async def verify(self, token: str) -> RequestIdentity:
subject = claims.get("sub")
if not isinstance(subject, str) or not subject:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token has no subject")
username = _string_claim(claims, "preferred_username")
return RequestIdentity(
subject=subject,
email=claims.get("email"),
name=claims.get("name") or claims.get("preferred_username"),
email=_string_claim(claims, "email"),
name=_string_claim(claims, "name") or username,
username=username,
picture=_string_claim(claims, "picture"),
claims=claims,
)

async def _get_jwks(self) -> dict[str, Any]:
if self._jwks is None:
url = self.settings.jwks_url or f"{self.settings.issuer}/.well-known/jwks.json"
if self._client is not None:
response = await self._client.get(url)
client = self._client
else:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
self._jwks = response.json()
client = httpx.AsyncClient(timeout=5.0)
try:
url = self.settings.jwks_url
if not url:
discovery = await client.get(
f"{self.settings.issuer}/.well-known/openid-configuration"
)
discovery.raise_for_status()
url = discovery.json().get("jwks_uri", "")
if not isinstance(url, str) or not url:
raise httpx.HTTPError("OIDC discovery has no jwks_uri")
response = await client.get(url)
response.raise_for_status()
self._jwks = response.json()
finally:
if self._client is None:
await client.aclose()
return self._jwks


Expand Down Expand Up @@ -127,3 +144,8 @@ async def get_request_identity(request: Request) -> RequestIdentity:


get_request_identity = identity_dependency()


def _string_claim(claims: Mapping[str, Any], key: str) -> str | None:
value = claims.get(key)
return value if isinstance(value, str) and value else None
24 changes: 24 additions & 0 deletions backend/workflow/capability_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,30 @@ def _resource_capabilities() -> list[WorkflowRuntimeCapability]:
"endpoint": "/api/v1/workflows/opencli-adapter-nodes",
"summary": opencli_adapter_summary,
"canvas": {"node": False},
"catalogModel": {
"kind": "core_nodes_plus_presets",
"coreNodes": {
"endpoint": "/api/v1/workflows/capabilities",
"role": "node_definition",
},
"adapterCommands": {
"endpoint": "/api/v1/workflows/opencli-adapter-nodes",
"role": "node_preset",
"presetKinds": ["source_slot", "tool_capability"],
},
},
"query": {
"filters": [
"site",
"q",
"access",
"capability",
"browser",
"presetKind",
"runtimeReadiness",
],
"grouping": "facets",
},
"materialization": {
"readNoRequiredArgs": "intelligence.source.opencli-slot",
"readRequiredArgs": "intelligence.source.opencli-slot with params",
Expand Down
Loading