From ddf14bdfded67f5c748070da3cf18f49f35592d0 Mon Sep 17 00:00:00 2001 From: Jun Luo Date: Wed, 12 Aug 2026 09:35:27 +0800 Subject: [PATCH] Add Agent Registry and CSGClaw agent type --- .../agent-registry/agent_registry_design.md | 419 ++++++++++++++++++ .../component/mock_SandboxComponent.go | 151 +++++++ .../builder/rpc/mock_UserSvcClient.go | 58 +++ .../store/database/mock_AgentShareStore.go | 214 +++++++++ .../store/database/mock_AgentTemplateStore.go | 60 +++ .../callback/mock_GitCallbackComponent.go | 47 ++ api/handler/runtime_architecture.go | 4 + api/workflow/activity/handle_push.go | 6 + api/workflow/handle_push.go | 6 + api/workflow/workflow_test.go | 31 ++ builder/rpc/user_svc_client.go | 18 + builder/store/database/agent_instance.go | 31 +- builder/store/database/agent_share.go | 68 +++ builder/store/database/agent_share_test.go | 39 ++ builder/store/database/agent_template_test.go | 7 + .../20260804011549_create_agent_shares.go | 35 ++ ...e_index_agent_templates_type_name.down.sql | 5 + ...que_index_agent_templates_type_name.up.sql | 7 + common/errorx/error_agent.go | 138 ++++++ common/i18n/en-US/err_agent.json | 18 + common/i18n/zh-CN/err_agent.json | 18 + common/i18n/zh-HK/err_agent.json | 18 + common/tests/stores.go | 262 +++++------ common/types/agent.go | 16 + common/types/agentfile.go | 54 +++ common/types/agentfile_test.go | 61 +++ component/agent_config.go | 158 +++++++ component/agent_runtime_profile.go | 133 ++++++ component/agent_runtime_profile_ce.go | 9 + component/callback/agent_deploy_watcher_ce.go | 14 + component/callback/git_callback.go | 1 + component/code.go | 43 ++ component/code_test.go | 30 ++ component/wireset.go | 1 + configs/agent_runtime/csgclaw.json | 18 + docs/error_codes_en.md | 49 ++ docs/error_codes_zh.md | 49 ++ 37 files changed, 2166 insertions(+), 130 deletions(-) create mode 100644 .specs/agent-registry/agent_registry_design.md create mode 100644 _mocks/opencsg.com/csghub-server/aigateway/component/mock_SandboxComponent.go create mode 100644 _mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentShareStore.go create mode 100644 api/workflow/workflow_test.go create mode 100644 builder/store/database/agent_share.go create mode 100644 builder/store/database/agent_share_test.go create mode 100644 builder/store/database/migrations/20260804011549_create_agent_shares.go create mode 100644 builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.down.sql create mode 100644 builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.up.sql create mode 100644 common/types/agentfile.go create mode 100644 common/types/agentfile_test.go create mode 100644 component/agent_config.go create mode 100644 component/agent_runtime_profile.go create mode 100644 component/agent_runtime_profile_ce.go create mode 100644 component/callback/agent_deploy_watcher_ce.go create mode 100644 configs/agent_runtime/csgclaw.json diff --git a/.specs/agent-registry/agent_registry_design.md b/.specs/agent-registry/agent_registry_design.md new file mode 100644 index 000000000..cdaa7c58c --- /dev/null +++ b/.specs/agent-registry/agent_registry_design.md @@ -0,0 +1,419 @@ +# Agent Registry Design + +## Overview + +The Agent Registry uses existing `AgentTemplate` and `AgentInstance` records. + +```text + Owner: publish CSGClaw Agent Template to Code repository + │ + ▼ + agent.toml committed to Code repository + │ + ▼ + parse agent.toml and validate runtime_kind + (supported runtime kind: codex) + │ + ▼ + EE/SaaS Git callback ─────────────► AgentTemplate + │ - name = Code repository repo_path + │ - description = Code repository description + │ - metadata.repo_path + │ - metadata.agent_file + │ - public mirrors repository visibility + │ + │ (Template only; never deploys a Sandbox) + │ + ▼ + Owner: POST /agent/instances + - template_id, or + - metadata.provision_request + (repo_path, resource_id, env, llm) + │ + ▼ + resolve repository-managed Template ◄── template_id / repo_path + │ + ▼ + CSGClaw AgentInstance adapter ◄──── approved runtime profile + │ configs/agent_runtime/csgclaw.json + │ │ + │ ▼ + │ runtime profile initializer + │ │ content-hash synchronization + │ ▼ + │ agent_configs.sandbox_runtime.csgclaw + │ + ├────► LLM environment + │ base URL / API key from AIGateway (platform-derived) + │ default models: eligible AIGateway text-generation model + │ model pin: metadata.provision_request.llm.model + │ → CSGCLAW_LLM_BASE_URL / API_KEY / MODELS + │ + ▼ + Sandbox resource selection + resource_id supplied ───────────────► use the requested resource + resource_id omitted ────────────────► auto-select a Sandbox-scenario resource + that satisfies CPU and memory requirements + │ + ▼ + Sandbox create ─────────────────────────► Sandbox + │ image, env, selected resource, PVC mount + ▼ + AgentInstance + - content_id = Sandbox name + - metadata.runtime_snapshot + - public controls share eligibility + │ + ▼ + Owner: POST /agent/instances/{id}/share + │ + ▼ + AgentShare + - share_uuid = management / display lookup id + - share_name = short Sandbox proxy alias + │ + ▼ + AIGateway Sandbox proxy + Owner: authenticated request ─► owner Sandbox lookup + Anonymous user: request ─► agent_shares.share_name lookup + → public CSGClaw instance → target Sandbox + → all Sandbox paths +``` + +A repository synchronizes an Agent Template only. It never creates, updates, stops, or restarts an Agent Instance or Sandbox. An Agent Instance is created explicitly by its owner and owns the Sandbox lifecycle. + +`AgentTemplate.public` controls Template visibility. `AgentInstance.public` controls whether the CSGClaw instance can be shared and whether an existing share remains valid. The two flags have separate purposes. + +An owner can create an agent share only for a public CSGClaw instance. A share does not expose the real Sandbox name: `agent_shares.share_name` is a generated, unique proxy alias. Anonymous Sandbox access must use that `share_name`, not the AgentInstance `content_id`. If the owner later makes the instance private, both the shared-instance API and the share alias return `404`. + +## Repository-managed Templates + +The EE/SaaS Git callback observes `agent.toml` changes. + +| Repository event | Result | +| --- | --- | +| Add or modify `agent.toml` | Create or update the matching Agent Template. | +| Delete `agent.toml` | Delete the matching Agent Template only. Existing instances continue running. | +| Delete Code repository | Delete matching repository-managed Agent Templates only. Existing instances continue running. | +| Code repository visibility update | Synchronize the public flag of Templates linked to that repository. | +| Repository-managed Template public update | Update the linked Code repository visibility through `CodeComponent`. | + +Repository-managed Templates use the Code repository `repo_path` as their name and its description as their description. `AgentTemplate.user_uuid` is the Code repository owner's user UUID, including when the repository path belongs to an organization; it is not the organization namespace UUID and it is not the last user who updated the repository. Template lookups use `type` and `name`; `metadata.repo_path` remains provenance and preserves the user or organization namespace path. `metadata.owner_type` records whether that namespace is `user` or `org`. Repository synchronization records the parsed manifest, namespace owner type, and runtime kind: + +```json +{ + "repo_path": "namespace/repository", + "owner_type": "org", + "runtime_kind": "codex", + "agent_file": { + "name": "assistant", + "runtime_kind": "codex" + } +} +``` + +Repository-managed CSGClaw Templates keep `content` empty. The parsed `agent.toml` is stored in `metadata.agent_file`; Git remains the source of the raw manifest. + +Private repository-managed Templates are visible through the Agent Template API only to `AgentTemplate.user_uuid`. Public repository-managed Templates are visible and usable by everyone. Organization-backed Templates do not grant private visibility to all organization members in this design; the organization identity is represented by `metadata.repo_path` and `metadata.owner_type`. + +The visibility synchronization is limited to Code repositories and repository-managed Templates. It is implemented by `CodeComponent` and `AgentTemplateComponent`, not by the generic `RepoComponent`. + +CE keeps the Git callback as a no-op for Agent Templates. + +## CSGClaw Instances + +`csgclaw` is the Sandbox-backed Agent Instance type. It replaces the unreleased `general` type. + +Creating a CSGClaw instance requires a repository-managed Template. The request supplies `template_id`, or `metadata.provision_request.repo_path` resolves the Template by its repository path. When both are supplied, they must identify the same Template. The resolved Template ID is stored on the instance. The adapter extracts the repository name from `template.metadata.repo_path` and creates a Sandbox name in this form: + +```text +-<12-character-lowercase-nanoid> +``` + +The random suffix is generated before Sandbox deployment and is stored as the instance `ContentID`. The instance display name is not used as the Sandbox name. The generated name is constrained to the Kubernetes DNS-label length limit. + +The adapter creates the Sandbox, then the Agent Instance record stores the Sandbox name, user metadata, and a runtime snapshot. The provisioning metadata (environment, resource, LLM settings, and the runtime snapshot) is baked into the Sandbox at creation time and is immutable afterwards: there is no Sandbox update or recreate path. Updating an instance is limited to the registry fields `name`, `description`, and `public`; supplying `metadata` in an update request is rejected with `400`. + +Deleting an instance stops its Sandbox. Deleting the source Template does not affect an existing instance. + +## Runtime Profile + +CSGClaw runtime configuration is Git-managed in [csgclaw.json](../../configs/agent_runtime/csgclaw.json). + +```text +configs/agent_runtime/csgclaw.json + │ + ▼ +agent_configs: sandbox_runtime.csgclaw +``` + +API runtime initialization reads the profile and synchronizes it to `agent_configs` by content hash. The profile contains the CSGClaw image, version, Sandbox port, container command, HTTP health check, and `default_env`. Ordinary Agent Config API operations cannot modify `sandbox_runtime.*` records. + +The current profile supplies the complete image reference: + +```text +opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbox:2026080504 +``` + +When an instance is created, the adapter records the selected image, version, port, command, health check, and `default_env` as `metadata.runtime_snapshot`. The snapshot is fixed at creation and cannot be changed, and there is no Sandbox update or recreate path. New profile versions affect future instances only. + +The CSGClaw profile declares its image command as an argv array and its HTTP liveness endpoint as `GET /healthz`; the health check uses the runtime `port`. The current Sandbox create API does not yet accept command or health-check fields, so the adapter stores them in the runtime snapshot and will forward them when that API supports them. + +`CSGCLAW_PVC_MOUNT_PATH` and `SKILLS_POLL_INTERVAL` are runtime-profile `default_env` values. The adapter uses the profile mount path for its PVC volume and does not overwrite either value with hard-coded defaults. + +## Sandbox Provisioning + +The adapter merges Sandbox environment values in this order: + +```text +runtime snapshot default_env + → metadata.provision_request.env + → protected platform environment +``` + +Protected CSGClaw, CSGHub, OpenCSG, and port variables cannot be overridden by user custom environment values. + +An optional Sandbox resource can be supplied in the same metadata convention used by OpenClaw: + +```json +{ + "provision_request": { + "resource_id": 123 + } +} +``` + +`resource_id` is optional. When present it must be a positive integer and is forwarded to `SandboxCreateRequest`. The metadata is persisted with the instance at creation and, like all provisioning metadata, cannot be changed later. + +## LLM Configuration + +The CSGClaw adapter always derives the LLM environment from the platform: `CSGCLAW_LLM_BASE_URL` is the configured AIGateway OpenAI-compatible URL and `CSGCLAW_LLM_API_KEY` is the owner's built-in AIGateway API key. The owner cannot override these two values. + +By default, the adapter discovers models through `LLMServiceComponent` instead of using a hard-coded model name. It selects the first LLM configuration that satisfies all of the following: + +- The configuration has the AIGateway LLM type. +- The configuration is enabled. +- The configuration is currently available. +- `metadata.task` or `metadata.tasks` contains `text-generation`. + +The selected model is encoded into `CSGCLAW_LLM_MODELS` as a JSON array. If no configuration matches, the adapter logs a warning and starts CSGClaw with an empty model list (`[]`); the owner can configure a model in CSGClaw later. + +Owners can pin a single model through typed provision metadata: + +```json +{ + "provision_request": { + "llm": { + "model": "qwen-plus" + } + } +} +``` + +The pinned model must be a non-empty string and be in the available AIGateway text-generation catalog. Omitting `llm.model` (or the whole `llm` object) keeps the discovered default; supplying a non-string, empty, or `null` `model` — such as `123`, `""`, or `null` — fails instance creation with `400`, as does an unknown model name. When pinned, `CSGCLAW_LLM_MODELS` is set to `["model"]`; otherwise it keeps the discovered default. + +| Sandbox environment | Value | +| --- | --- | +| `CSGCLAW_LLM_BASE_URL` | AIGateway OpenAI-compatible URL, always platform-derived. | +| `CSGCLAW_LLM_API_KEY` | Owner's built-in AIGateway API key, always platform-derived. | +| `CSGCLAW_LLM_MODELS` | `provision_request.llm.model` when pinned; otherwise the first eligible AIGateway text-generation model. | + +Raw `CSGCLAW_*`, `CSGHUB_*`, `OPENCSG_*`, `PORT`, and `TEMPLATE_ID` values remain protected and cannot be supplied through `metadata.provision_request.env`. + +## Sandbox Proxy + +The AIGateway splits Sandbox proxy access into two routes: + +```text +/v1/sandboxes/{sandbox_name} +/v1/sandboxes/{sandbox_name}/* +/v1/shared/sandboxes/{share_name}/* +``` + +`/v1/sandboxes/{sandbox_name}` requires login and resolves only the caller's own Sandbox; anonymous requests are rejected with `401`. `/v1/shared/sandboxes/{share_name}/*` is unauthenticated: AIGateway resolves `agent_shares.share_name=share_name`, verifies that the target remains a public CSGClaw instance, and uses the target's internal `content_id` for the Runner request. Only Sandbox API sub-paths are proxied — the Sandbox root is not accessible anonymously. Anonymous requests cannot use the real AgentInstance `content_id`. Missing, private, non-CSGClaw, or unavailable Sandboxes return `404`. + +All Sandbox paths are forwarded. AIGateway removes caller-supplied internal authorization headers and injects the platform authorization used to access the Sandbox proxy target. + +## CSGClaw Agent Chat Proxy + +Authenticated CSGClaw chat uses the existing generic AIGateway Agent proxy route: + +```text +POST /v1/agent/csgclaw/agents/{sandbox_name}/sessions/{session_id}/responses +``` + +`sandbox_name` is the CSGClaw instance `content_id`, which identifies the Sandbox. AIGateway resolves the visible instance and its owner Sandbox proxy URL, then replaces that path segment with `metadata.template_metadata.agent_file.name` when forwarding to CSGClaw: + +```text +POST {sandbox proxy URL}/api/v1/agents/{agent.toml.name}/sessions/{session_id}/responses +``` + +The request body, response status, response body, and Responses-compatible SSE stream are passed through unchanged. Before forwarding, AIGateway creates the corresponding CSGHub AgentInstance session and replaces caller authorization headers with the platform Sandbox-proxy credential. + +## API + +All Agent Registry management APIs are available in EE and SaaS under `/api/v1/agent`. `GET /agent/templates` is public and returns only public Templates to an anonymous caller. Other read APIs require the normal authenticated API session; create, update, and delete operations also require phone verification. + +### Templates + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/v1/agent/templates` | List Templates. An anonymous caller receives public Templates only; an authenticated caller also receives its own Templates. Supports `search`, `type`, `per`, and `page`. | +| `POST` | `/api/v1/agent/templates` | Create a user-managed Template. | +| `GET` | `/api/v1/agent/templates/{id}` | Get a Template. | +| `PUT` | `/api/v1/agent/templates/{id}` | Update a Template owned by the current user. Updating `public` on a repository-managed Template also updates its linked Code repository visibility. | +| `DELETE` | `/api/v1/agent/templates/{id}` | Delete an owned Template. | + +`GET /api/v1/agent/templates` intentionally has no login middleware. The store applies `public = true` when the request has no authenticated user; authenticated callers receive both public Templates and Templates they own. The list response contains template metadata and display fields but omits `content`. It is filtered by `search` (name or description), `type`, `per`, and `page`. + +Example anonymous CSGClaw Template list item: + +```json +{ + "id": 42, + "type": "csgclaw", + "name": "namespace/repository", + "description": "Repository description", + "public": true, + "metadata": { + "repo_path": "namespace/repository", + "runtime_kind": "codex", + "agent_file": { + "name": "assistant", + "runtime_kind": "codex" + } + } +} +``` + +`POST /api/v1/agent/templates` creates a user-managed Template. It requires phone verification; `type`, `name`, and `content` are required. The server always assigns the current user as owner. Example: + +```json +{ + "type": "langflow", + "name": "My flow", + "description": "Optional description", + "content": "{\"nodes\":[]}", + "public": false, + "metadata": { + "tags": ["example"] + } +} +``` + +`GET /api/v1/agent/templates/{id}` requires login and returns `content`; a private Template is available only to its owner. `PUT` and `DELETE` require phone verification and ownership. A `PUT` may update supplied `name`, `description`, `content`, `metadata`, and `public` fields. Changing `public` on a repository-managed Template updates the linked Code repository visibility first, then persists the Template flag. Deleting a repository-managed CSGClaw Template deletes its linked Code repository; the Code repository deletion deletes matching repository-managed Templates. Neither direction stops or deletes existing Agent Instances. + +Repository-managed Templates are created and updated by the Git callback. The ordinary `POST /api/v1/agent/templates` API rejects `metadata.repo_path`, so it cannot create repository-managed Templates. Their `content` is empty, and their `name`, `description`, `metadata.agent_file`, and `public` are synchronized from the Code repository and `agent.toml` on a later callback. + +### CSGClaw Instances + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/v1/agent/instances` | Create an explicit CSGClaw instance and its Sandbox. | +| `GET` | `/api/v1/agent/instances` | List instances. Supports `search`, `type`, `public`, `built_in`, `editable`, `per`, and `page`. | +| `GET` | `/api/v1/agent/instances/{id}` | Get an instance. | +| `PUT` | `/api/v1/agent/instances/{id}` | Update registry fields only: `name`, `description`, `public`. Provisioning metadata is immutable; supplying `metadata` returns `400`. | +| `DELETE` | `/api/v1/agent/instances/{id}` | Stop and delete an instance Sandbox. | +| `GET` | `/api/v1/agent/instances/status` | Get the status of instances. | + +`POST /api/v1/agent/instances` requires phone verification and Agent access. The server sets the owner from the authenticated request, creates and starts the Sandbox, and then creates the AgentInstance record. Clients must not supply `content_id`; CSGHub generates it as the Sandbox name. + +#### Create request + +| Field | Required | Description | +| --- | --- | --- | +| `type` | Yes | Must be `"csgclaw"`. | +| `name` | Yes | Owner-visible instance name. It must be unique for the owner. It is not the Sandbox name. | +| `description` | No | Instance description. | +| `public` | No | Defaults to `false`. A public CSGClaw instance may be shared through the instance-share API. | +| `template_id` | One of `template_id` or `metadata.provision_request.repo_path` | ID of a repository-managed CSGClaw Template. | +| `metadata.provision_request.repo_path` | One of `template_id` or `repo_path` | Code repository path, such as `namespace/repository`, used to resolve the CSGClaw Template. When both identifiers are supplied, they must resolve to the same Template. | +| `metadata.provision_request.resource_id` | No | Positive integer Sandbox resource ID. If omitted, Sandbox chooses a compatible resource from the Sandbox scenario. | +| `metadata.provision_request.env` | No | String-to-string CSGClaw environment overrides. Platform-managed `CSGCLAW_*`, `CSGHUB_*`, `OPENCSG_*`, `PORT`, and `TEMPLATE_ID` names are rejected. | +| `metadata.provision_request.llm.model` | No | Pin a single available AIGateway text-generation model, encoded as `CSGCLAW_LLM_MODELS=["model"]`. When supplied, `model` must be a non-empty string; a non-string, empty, or `null` value (e.g. `123`, `""`, or `null`) fails with `400`. An unknown model also fails with `400`. `llm.base_url` and `llm.api_key` are always platform-derived and are ignored if supplied. | + +Create with a repository path: + +```json +{ + "type": "csgclaw", + "name": "My CSGClaw", + "description": "Optional description", + "public": false, + "metadata": { + "provision_request": { + "repo_path": "namespace/repository", + "resource_id": 123, + "env": { + "LOG_LEVEL": "debug" + }, + "llm": { + "model": "qwen-plus" + } + } + } +} +``` + +Create with a Template ID instead: + +```json +{ + "type": "csgclaw", + "name": "My CSGClaw", + "template_id": 42, + "public": false +} +``` + +CSGHub always supplies the configured AIGateway URL and the owner’s built-in AIGateway API key. `CSGCLAW_LLM_MODELS` uses the pinned model when supplied; otherwise it is the discovered eligible model list, or `[]` if no eligible model exists — CSGClaw can then be configured later by its owner. + +The response contains the generated instance `id` and `content_id`. For CSGClaw, `content_id` is the generated Sandbox name and is the stable identifier used by the Sandbox proxy. + +The list (`GET /api/v1/agent/instances`) and detail (`GET /api/v1/agent/instances/{id}`) responses include `is_shared`, a boolean that is `true` when an `agent_shares` record references the instance (`agent_shares.instance_id`). It is derived from the share table, not stored on the instance, so clients can reliably display the shared state without re-deriving it from a prior `POST /api/v1/agent/instances/{id}/share` response. + +#### Update request + +`PUT /api/v1/agent/instances/{id}` (and the by-content-id variant) updates only `name`, `description`, and `public`. CSGClaw provisioning metadata — `metadata.provision_request`, `metadata.runtime_snapshot`, and any other keys — was baked into the Sandbox at creation and cannot be changed. An update request that includes `metadata` fails with `400`; the Sandbox is never reconfigured or recreated. + +### CSGClaw Chat + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/v1/agent/csgclaw/agents/{sandbox_name}/sessions/{session_id}/responses` | Run a CSGClaw agent turn through the instance Sandbox. | + +`sandbox_name` is the CSGClaw instance `content_id`; AIGateway resolves the `agent.toml` name retained in instance metadata and uses it as the upstream CSGClaw agent selector. `session_id` is also stored as the CSGHub AgentInstance session ID. The endpoint accepts and forwards CSGClaw Responses request bodies, including `stream: true` SSE requests. + +### Anonymous Sandbox Access + +The following AIGateway endpoint does not require login: + +```text +ANY /v1/shared/sandboxes/{share_name}/* +``` + +`/v1/shared/sandboxes/{share_name}/*` is accessible when `share_name` is an `agent_shares.share_name` alias for a public `csgclaw` instance. It is not accessible by the real AgentInstance `content_id` for anonymous callers. Only Sandbox API sub-paths are proxied; the Sandbox root path (bare `/v1/shared/sandboxes/{share_name}` or a bare trailing slash) returns `404`. The proxy returns `404` for private or unknown Sandboxes. The caller must not send platform authorization headers; AIGateway supplies its internal authorization to the Sandbox target. + +The owner route `ANY /v1/sandboxes/{sandbox_name}` requires login and resolves only the caller's own Sandbox; anonymous requests are rejected with `401`. + +### Instance Shares + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/v1/agent/instances/{id}/share` | Create an anonymous share for an owner-owned public CSGClaw instance. | +| `GET` | `/api/v1/agent/shared/instance?share_uuid={share_uuid}` | Get shared instance display data and its short Sandbox proxy alias. | + +`agent_shares` stores the share target and access alias: + +| Field | Purpose | +| --- | --- | +| `share_uuid` | Opaque management/display lookup id used by `/agent/shared/instance`. | +| `share_name` | Short anonymous Sandbox proxy alias used as `{share_name}` in `/v1/shared/sandboxes/{share_name}`. | +| `instance_id` | Internal target AgentInstance id. | +| `type` | Share type. Currently `instance`. | + +The create response contains the opaque management identifier `share_uuid` and generated `share_name`. The public response contains basic instance information plus `shared_sandbox_name`, whose value is `agent_shares.share_name`; Portal uses that value with the AIGateway shared Sandbox proxy route `/v1/shared/sandboxes/{share_name}`. The real CSGClaw `content_id` is never returned by the shared-instance API. + +## Edition Boundaries + +CSGClaw Sandbox deployment and anonymous AIGateway proxy behavior are built for EE and SaaS. Runtime-profile synchronization is initialized in CE, EE, and SaaS so `sandbox_runtime.csgclaw` is available consistently, but CE does not register the CSGClaw Sandbox adapter or anonymous proxy route. diff --git a/_mocks/opencsg.com/csghub-server/aigateway/component/mock_SandboxComponent.go b/_mocks/opencsg.com/csghub-server/aigateway/component/mock_SandboxComponent.go new file mode 100644 index 000000000..11a5dce3d --- /dev/null +++ b/_mocks/opencsg.com/csghub-server/aigateway/component/mock_SandboxComponent.go @@ -0,0 +1,151 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package component + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockSandboxComponent is an autogenerated mock type for the SandboxComponent type +type MockSandboxComponent struct { + mock.Mock +} + +type MockSandboxComponent_Expecter struct { + mock *mock.Mock +} + +func (_m *MockSandboxComponent) EXPECT() *MockSandboxComponent_Expecter { + return &MockSandboxComponent_Expecter{mock: &_m.Mock} +} + +// GetProxyURL provides a mock function with given fields: ctx, uuid, deployName +func (_m *MockSandboxComponent) GetProxyURL(ctx context.Context, uuid string, deployName string) (string, error) { + ret := _m.Called(ctx, uuid, deployName) + + if len(ret) == 0 { + panic("no return value specified for GetProxyURL") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { + return rf(ctx, uuid, deployName) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) string); ok { + r0 = rf(ctx, uuid, deployName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, uuid, deployName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSandboxComponent_GetProxyURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProxyURL' +type MockSandboxComponent_GetProxyURL_Call struct { + *mock.Call +} + +// GetProxyURL is a helper method to define mock.On call +// - ctx context.Context +// - uuid string +// - deployName string +func (_e *MockSandboxComponent_Expecter) GetProxyURL(ctx interface{}, uuid interface{}, deployName interface{}) *MockSandboxComponent_GetProxyURL_Call { + return &MockSandboxComponent_GetProxyURL_Call{Call: _e.mock.On("GetProxyURL", ctx, uuid, deployName)} +} + +func (_c *MockSandboxComponent_GetProxyURL_Call) Run(run func(ctx context.Context, uuid string, deployName string)) *MockSandboxComponent_GetProxyURL_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockSandboxComponent_GetProxyURL_Call) Return(_a0 string, _a1 error) *MockSandboxComponent_GetProxyURL_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSandboxComponent_GetProxyURL_Call) RunAndReturn(run func(context.Context, string, string) (string, error)) *MockSandboxComponent_GetProxyURL_Call { + _c.Call.Return(run) + return _c +} + +// GetSharedSandboxProxyURL provides a mock function with given fields: ctx, shareName +func (_m *MockSandboxComponent) GetSharedSandboxProxyURL(ctx context.Context, shareName string) (string, error) { + ret := _m.Called(ctx, shareName) + + if len(ret) == 0 { + panic("no return value specified for GetSharedSandboxProxyURL") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return rf(ctx, shareName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(ctx, shareName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, shareName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSandboxComponent_GetSharedSandboxProxyURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSharedSandboxProxyURL' +type MockSandboxComponent_GetSharedSandboxProxyURL_Call struct { + *mock.Call +} + +// GetSharedSandboxProxyURL is a helper method to define mock.On call +// - ctx context.Context +// - shareName string +func (_e *MockSandboxComponent_Expecter) GetSharedSandboxProxyURL(ctx interface{}, shareName interface{}) *MockSandboxComponent_GetSharedSandboxProxyURL_Call { + return &MockSandboxComponent_GetSharedSandboxProxyURL_Call{Call: _e.mock.On("GetSharedSandboxProxyURL", ctx, shareName)} +} + +func (_c *MockSandboxComponent_GetSharedSandboxProxyURL_Call) Run(run func(ctx context.Context, shareName string)) *MockSandboxComponent_GetSharedSandboxProxyURL_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockSandboxComponent_GetSharedSandboxProxyURL_Call) Return(_a0 string, _a1 error) *MockSandboxComponent_GetSharedSandboxProxyURL_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSandboxComponent_GetSharedSandboxProxyURL_Call) RunAndReturn(run func(context.Context, string) (string, error)) *MockSandboxComponent_GetSharedSandboxProxyURL_Call { + _c.Call.Return(run) + return _c +} + +// NewMockSandboxComponent creates a new instance of MockSandboxComponent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSandboxComponent(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSandboxComponent { + mock := &MockSandboxComponent{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/_mocks/opencsg.com/csghub-server/builder/rpc/mock_UserSvcClient.go b/_mocks/opencsg.com/csghub-server/builder/rpc/mock_UserSvcClient.go index fa02af9f6..db9c237c6 100644 --- a/_mocks/opencsg.com/csghub-server/builder/rpc/mock_UserSvcClient.go +++ b/_mocks/opencsg.com/csghub-server/builder/rpc/mock_UserSvcClient.go @@ -518,6 +518,64 @@ func (_c *MockUserSvcClient_GetNameSpaceInfoByUUID_Call) RunAndReturn(run func(c return _c } +// GetOrCreateBuiltinAPIKey provides a mock function with given fields: ctx, userName, nsUUID +func (_m *MockUserSvcClient) GetOrCreateBuiltinAPIKey(ctx context.Context, userName string, nsUUID string) (string, error) { + ret := _m.Called(ctx, userName, nsUUID) + + if len(ret) == 0 { + panic("no return value specified for GetOrCreateBuiltinAPIKey") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { + return rf(ctx, userName, nsUUID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) string); ok { + r0 = rf(ctx, userName, nsUUID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, userName, nsUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrCreateBuiltinAPIKey' +type MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call struct { + *mock.Call +} + +// GetOrCreateBuiltinAPIKey is a helper method to define mock.On call +// - ctx context.Context +// - userName string +// - nsUUID string +func (_e *MockUserSvcClient_Expecter) GetOrCreateBuiltinAPIKey(ctx interface{}, userName interface{}, nsUUID interface{}) *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call { + return &MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call{Call: _e.mock.On("GetOrCreateBuiltinAPIKey", ctx, userName, nsUUID)} +} + +func (_c *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call) Run(run func(ctx context.Context, userName string, nsUUID string)) *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call) Return(_a0 string, _a1 error) *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call) RunAndReturn(run func(context.Context, string, string) (string, error)) *MockUserSvcClient_GetOrCreateBuiltinAPIKey_Call { + _c.Call.Return(run) + return _c +} + // GetOrCreateFirstAvaiTokens provides a mock function with given fields: ctx, userName, visitorName, app, tokenName func (_m *MockUserSvcClient) GetOrCreateFirstAvaiTokens(ctx context.Context, userName string, visitorName string, app string, tokenName string) (string, error) { ret := _m.Called(ctx, userName, visitorName, app, tokenName) diff --git a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentShareStore.go b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentShareStore.go new file mode 100644 index 000000000..004af47cf --- /dev/null +++ b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentShareStore.go @@ -0,0 +1,214 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package database + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + database "opencsg.com/csghub-server/builder/store/database" +) + +// MockAgentShareStore is an autogenerated mock type for the AgentShareStore type +type MockAgentShareStore struct { + mock.Mock +} + +type MockAgentShareStore_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAgentShareStore) EXPECT() *MockAgentShareStore_Expecter { + return &MockAgentShareStore_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, share +func (_m *MockAgentShareStore) Create(ctx context.Context, share *database.AgentShare) (*database.AgentShare, error) { + ret := _m.Called(ctx, share) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *database.AgentShare + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *database.AgentShare) (*database.AgentShare, error)); ok { + return rf(ctx, share) + } + if rf, ok := ret.Get(0).(func(context.Context, *database.AgentShare) *database.AgentShare); ok { + r0 = rf(ctx, share) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*database.AgentShare) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *database.AgentShare) error); ok { + r1 = rf(ctx, share) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAgentShareStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type MockAgentShareStore_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - share *database.AgentShare +func (_e *MockAgentShareStore_Expecter) Create(ctx interface{}, share interface{}) *MockAgentShareStore_Create_Call { + return &MockAgentShareStore_Create_Call{Call: _e.mock.On("Create", ctx, share)} +} + +func (_c *MockAgentShareStore_Create_Call) Run(run func(ctx context.Context, share *database.AgentShare)) *MockAgentShareStore_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*database.AgentShare)) + }) + return _c +} + +func (_c *MockAgentShareStore_Create_Call) Return(_a0 *database.AgentShare, _a1 error) *MockAgentShareStore_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAgentShareStore_Create_Call) RunAndReturn(run func(context.Context, *database.AgentShare) (*database.AgentShare, error)) *MockAgentShareStore_Create_Call { + _c.Call.Return(run) + return _c +} + +// FindByShareName provides a mock function with given fields: ctx, shareName +func (_m *MockAgentShareStore) FindByShareName(ctx context.Context, shareName string) (*database.AgentShare, error) { + ret := _m.Called(ctx, shareName) + + if len(ret) == 0 { + panic("no return value specified for FindByShareName") + } + + var r0 *database.AgentShare + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*database.AgentShare, error)); ok { + return rf(ctx, shareName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *database.AgentShare); ok { + r0 = rf(ctx, shareName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*database.AgentShare) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, shareName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAgentShareStore_FindByShareName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByShareName' +type MockAgentShareStore_FindByShareName_Call struct { + *mock.Call +} + +// FindByShareName is a helper method to define mock.On call +// - ctx context.Context +// - shareName string +func (_e *MockAgentShareStore_Expecter) FindByShareName(ctx interface{}, shareName interface{}) *MockAgentShareStore_FindByShareName_Call { + return &MockAgentShareStore_FindByShareName_Call{Call: _e.mock.On("FindByShareName", ctx, shareName)} +} + +func (_c *MockAgentShareStore_FindByShareName_Call) Run(run func(ctx context.Context, shareName string)) *MockAgentShareStore_FindByShareName_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockAgentShareStore_FindByShareName_Call) Return(_a0 *database.AgentShare, _a1 error) *MockAgentShareStore_FindByShareName_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAgentShareStore_FindByShareName_Call) RunAndReturn(run func(context.Context, string) (*database.AgentShare, error)) *MockAgentShareStore_FindByShareName_Call { + _c.Call.Return(run) + return _c +} + +// FindByShareUUID provides a mock function with given fields: ctx, shareUUID +func (_m *MockAgentShareStore) FindByShareUUID(ctx context.Context, shareUUID string) (*database.AgentShare, error) { + ret := _m.Called(ctx, shareUUID) + + if len(ret) == 0 { + panic("no return value specified for FindByShareUUID") + } + + var r0 *database.AgentShare + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*database.AgentShare, error)); ok { + return rf(ctx, shareUUID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *database.AgentShare); ok { + r0 = rf(ctx, shareUUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*database.AgentShare) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, shareUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAgentShareStore_FindByShareUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByShareUUID' +type MockAgentShareStore_FindByShareUUID_Call struct { + *mock.Call +} + +// FindByShareUUID is a helper method to define mock.On call +// - ctx context.Context +// - shareUUID string +func (_e *MockAgentShareStore_Expecter) FindByShareUUID(ctx interface{}, shareUUID interface{}) *MockAgentShareStore_FindByShareUUID_Call { + return &MockAgentShareStore_FindByShareUUID_Call{Call: _e.mock.On("FindByShareUUID", ctx, shareUUID)} +} + +func (_c *MockAgentShareStore_FindByShareUUID_Call) Run(run func(ctx context.Context, shareUUID string)) *MockAgentShareStore_FindByShareUUID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockAgentShareStore_FindByShareUUID_Call) Return(_a0 *database.AgentShare, _a1 error) *MockAgentShareStore_FindByShareUUID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAgentShareStore_FindByShareUUID_Call) RunAndReturn(run func(context.Context, string) (*database.AgentShare, error)) *MockAgentShareStore_FindByShareUUID_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAgentShareStore creates a new instance of MockAgentShareStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAgentShareStore(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAgentShareStore { + mock := &MockAgentShareStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentTemplateStore.go b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentTemplateStore.go index b0fba6cd2..7721053e4 100644 --- a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentTemplateStore.go +++ b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_AgentTemplateStore.go @@ -189,6 +189,66 @@ func (_c *MockAgentTemplateStore_FindByID_Call) RunAndReturn(run func(context.Co return _c } +// FindByTypeAndName provides a mock function with given fields: ctx, agentType, name +func (_m *MockAgentTemplateStore) FindByTypeAndName(ctx context.Context, agentType string, name string) ([]database.AgentTemplate, error) { + ret := _m.Called(ctx, agentType, name) + + if len(ret) == 0 { + panic("no return value specified for FindByTypeAndName") + } + + var r0 []database.AgentTemplate + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]database.AgentTemplate, error)); ok { + return rf(ctx, agentType, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []database.AgentTemplate); ok { + r0 = rf(ctx, agentType, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.AgentTemplate) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, agentType, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAgentTemplateStore_FindByTypeAndName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByTypeAndName' +type MockAgentTemplateStore_FindByTypeAndName_Call struct { + *mock.Call +} + +// FindByTypeAndName is a helper method to define mock.On call +// - ctx context.Context +// - agentType string +// - name string +func (_e *MockAgentTemplateStore_Expecter) FindByTypeAndName(ctx interface{}, agentType interface{}, name interface{}) *MockAgentTemplateStore_FindByTypeAndName_Call { + return &MockAgentTemplateStore_FindByTypeAndName_Call{Call: _e.mock.On("FindByTypeAndName", ctx, agentType, name)} +} + +func (_c *MockAgentTemplateStore_FindByTypeAndName_Call) Run(run func(ctx context.Context, agentType string, name string)) *MockAgentTemplateStore_FindByTypeAndName_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockAgentTemplateStore_FindByTypeAndName_Call) Return(_a0 []database.AgentTemplate, _a1 error) *MockAgentTemplateStore_FindByTypeAndName_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAgentTemplateStore_FindByTypeAndName_Call) RunAndReturn(run func(context.Context, string, string) ([]database.AgentTemplate, error)) *MockAgentTemplateStore_FindByTypeAndName_Call { + _c.Call.Return(run) + return _c +} + // ListByUserUUID provides a mock function with given fields: ctx, userUUID, filter, per, page func (_m *MockAgentTemplateStore) ListByUserUUID(ctx context.Context, userUUID string, filter types.AgentTemplateFilter, per int, page int) ([]database.AgentTemplate, int, error) { ret := _m.Called(ctx, userUUID, filter, per, page) diff --git a/_mocks/opencsg.com/csghub-server/component/callback/mock_GitCallbackComponent.go b/_mocks/opencsg.com/csghub-server/component/callback/mock_GitCallbackComponent.go index 0e499e65b..9adb2d0a8 100644 --- a/_mocks/opencsg.com/csghub-server/component/callback/mock_GitCallbackComponent.go +++ b/_mocks/opencsg.com/csghub-server/component/callback/mock_GitCallbackComponent.go @@ -337,6 +337,53 @@ func (_c *MockGitCallbackComponent_UpdateRepoInfos_Call) RunAndReturn(run func(c return _c } +// WatchAgentChange provides a mock function with given fields: ctx, req +func (_m *MockGitCallbackComponent) WatchAgentChange(ctx context.Context, req *types.GiteaCallbackPushReq) error { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for WatchAgentChange") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *types.GiteaCallbackPushReq) error); ok { + r0 = rf(ctx, req) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitCallbackComponent_WatchAgentChange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WatchAgentChange' +type MockGitCallbackComponent_WatchAgentChange_Call struct { + *mock.Call +} + +// WatchAgentChange is a helper method to define mock.On call +// - ctx context.Context +// - req *types.GiteaCallbackPushReq +func (_e *MockGitCallbackComponent_Expecter) WatchAgentChange(ctx interface{}, req interface{}) *MockGitCallbackComponent_WatchAgentChange_Call { + return &MockGitCallbackComponent_WatchAgentChange_Call{Call: _e.mock.On("WatchAgentChange", ctx, req)} +} + +func (_c *MockGitCallbackComponent_WatchAgentChange_Call) Run(run func(ctx context.Context, req *types.GiteaCallbackPushReq)) *MockGitCallbackComponent_WatchAgentChange_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.GiteaCallbackPushReq)) + }) + return _c +} + +func (_c *MockGitCallbackComponent_WatchAgentChange_Call) Return(_a0 error) *MockGitCallbackComponent_WatchAgentChange_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitCallbackComponent_WatchAgentChange_Call) RunAndReturn(run func(context.Context, *types.GiteaCallbackPushReq) error) *MockGitCallbackComponent_WatchAgentChange_Call { + _c.Call.Return(run) + return _c +} + // WatchRepoRelation provides a mock function with given fields: ctx, req func (_m *MockGitCallbackComponent) WatchRepoRelation(ctx context.Context, req *types.GiteaCallbackPushReq) error { ret := _m.Called(ctx, req) diff --git a/api/handler/runtime_architecture.go b/api/handler/runtime_architecture.go index cd93eb478..86a73a786 100644 --- a/api/handler/runtime_architecture.go +++ b/api/handler/runtime_architecture.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "fmt" "log/slog" @@ -33,6 +34,9 @@ func NewRuntimeArchitectureHandler(config *config.Config) (*RuntimeArchitectureH if err != nil { slog.Error("Failed to initialize runtime framework and architectures", slog.Any("error", err)) } + if err := component.InitAgentRuntimeProfiles(context.Background()); err != nil { + slog.Error("Failed to initialize agent runtime profiles", slog.Any("error", err)) + } return &RuntimeArchitectureHandler{ repo: nrc, diff --git a/api/workflow/activity/handle_push.go b/api/workflow/activity/handle_push.go index ef52c8930..5ef84c632 100644 --- a/api/workflow/activity/handle_push.go +++ b/api/workflow/activity/handle_push.go @@ -15,6 +15,12 @@ func (a *Activities) WatchSpaceChange(ctx context.Context, req *types.GiteaCallb return a.callback.WatchSpaceChange(ctx, req) } +func (a *Activities) WatchAgentChange(ctx context.Context, req *types.GiteaCallbackPushReq) error { + logger := activity.GetLogger(ctx) + logger.Info("[git_callback] watch agent change start", slog.Any("req", req)) + return a.callback.WatchAgentChange(ctx, req) +} + func (a *Activities) WatchRepoRelation(ctx context.Context, req *types.GiteaCallbackPushReq) error { logger := activity.GetLogger(ctx) logger.Info("[git_callback] watch repo relation start", slog.Any("req", req)) diff --git a/api/workflow/handle_push.go b/api/workflow/handle_push.go index 5abd4f6a0..c2533edbd 100644 --- a/api/workflow/handle_push.go +++ b/api/workflow/handle_push.go @@ -31,6 +31,12 @@ func HandlePushWorkflow(ctx workflow.Context, req *types.GiteaCallbackPushReq) e return err } + // Watch agent change: agent deploy failure should not block other callback activities + err = workflow.ExecuteActivity(actCtx, activities.WatchAgentChange, req).Get(ctx, nil) + if err != nil { + logger.Error("[git_callback] failed to watch agent change", slog.Any("error", err), slog.Any("req", req)) + } + // Watch repo relation err = workflow.ExecuteActivity(actCtx, activities.WatchRepoRelation, req).Get(ctx, nil) if err != nil { diff --git a/api/workflow/workflow_test.go b/api/workflow/workflow_test.go new file mode 100644 index 000000000..1d7a59836 --- /dev/null +++ b/api/workflow/workflow_test.go @@ -0,0 +1,31 @@ +package workflow_test + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "opencsg.com/csghub-server/api/workflow" + "opencsg.com/csghub-server/common/types" +) + +func TestWorkflow_HandlePushWorkflow(t *testing.T) { + tester, err := newWorkflowTester(t) + require.NoError(t, err) + + tester.mocks.callback.EXPECT().SetRepoVisibility(true).Return() + tester.mocks.callback.EXPECT().WatchSpaceChange(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().WatchAgentChange(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().WatchRepoRelation(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().GenSyncVersion(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().SetRepoUpdateTime(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().UpdateRepoInfos(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().SensitiveCheck(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().MCPScan(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + tester.mocks.callback.EXPECT().CalculateRepoSize(mock.Anything, &types.GiteaCallbackPushReq{}).Return(nil) + + tester.env.ExecuteWorkflow(workflow.HandlePushWorkflow, &types.GiteaCallbackPushReq{}) + require.True(t, tester.env.IsWorkflowCompleted()) + require.NoError(t, tester.env.GetWorkflowError()) + +} diff --git a/builder/rpc/user_svc_client.go b/builder/rpc/user_svc_client.go index 8420d26b0..b1b38a7f7 100644 --- a/builder/rpc/user_svc_client.go +++ b/builder/rpc/user_svc_client.go @@ -19,6 +19,8 @@ type UserSvcClient interface { GetNameSpaceInfoByUUID(ctx context.Context, uuid string) (*Namespace, error) GetUserInfo(ctx context.Context, userName, visitorName string) (*User, error) GetOrCreateFirstAvaiTokens(ctx context.Context, userName, visitorName, app, tokenName string) (string, error) + // GetOrCreateBuiltinAPIKey returns the builtin (aigateway) API key token for a user namespace. + GetOrCreateBuiltinAPIKey(ctx context.Context, userName, nsUUID string) (string, error) VerifyByAccessToken(ctx context.Context, token string) (*types.CheckAccessTokenResp, error) // OAuthExchangeToken exchanges an OAuth access token for a locally issued JWT. OAuthExchangeToken(ctx context.Context, req *types.OAuthExchangeTokenReq) (*types.OAuthExchangeTokenResp, error) @@ -140,6 +142,22 @@ func (c *UserSvcHttpClient) GetOrCreateFirstAvaiTokens(ctx context.Context, user return r.Data.(string), nil } +func (c *UserSvcHttpClient) GetOrCreateBuiltinAPIKey(ctx context.Context, userName, nsUUID string) (string, error) { + url := fmt.Sprintf("/api/v1/namespaces/%s/apikeys/builtin?current_user=%s", nsUUID, url.QueryEscape(userName)) + var r httpbase.R + r.Data = &types.CheckAccessTokenResp{} + err := c.hc.Get(ctx, url, &r) + if err != nil { + slog.ErrorContext(ctx, "call user service failed", slog.String("error", err.Error())) + return "", errorx.RemoteSvcFail(err, + errorx.Ctx(). + Set("service", "user service"). + Set("action", "get or create builtin api key"). + Set("userName", userName)) + } + return r.Data.(*types.CheckAccessTokenResp).Token, nil +} + func (c *UserSvcHttpClient) VerifyByAccessToken(ctx context.Context, token string) (*types.CheckAccessTokenResp, error) { url := fmt.Sprintf("/api/v1/token/%s", token) var r httpbase.R diff --git a/builder/store/database/agent_instance.go b/builder/store/database/agent_instance.go index 4fd47c842..625fe5527 100644 --- a/builder/store/database/agent_instance.go +++ b/builder/store/database/agent_instance.go @@ -42,6 +42,7 @@ type AgentInstance struct { IsPinned bool `bun:",scanonly" json:"is_pinned"` // Whether the instance is pinned (from LEFT JOIN, scanonly means not used in INSERT/UPDATE) PinnedAt *time.Time `bun:",scanonly" json:"pinned_at"` // When the instance was pinned (from LEFT JOIN) Config map[string]any `bun:",scanonly" json:"config"` // Per-user config from agent_user_preferences (config-instance action) + IsShared bool `bun:",scanonly" json:"is_shared"` // Whether the instance has an agent_shares record (from EXISTS subquery) times } @@ -49,6 +50,7 @@ type AgentInstance struct { type AgentTemplateStore interface { Create(ctx context.Context, template *AgentTemplate) (*AgentTemplate, error) FindByID(ctx context.Context, id int64) (*AgentTemplate, error) + FindByTypeAndName(ctx context.Context, agentType, name string) ([]AgentTemplate, error) ListByUserUUID(ctx context.Context, userUUID string, filter types.AgentTemplateFilter, per int, page int) ([]AgentTemplate, int, error) Update(ctx context.Context, template *AgentTemplate) error Delete(ctx context.Context, id int64) error @@ -130,6 +132,17 @@ func (s *agentTemplateStoreImpl) FindByID(ctx context.Context, id int64) (*Agent return template, nil } +func (s *agentTemplateStoreImpl) FindByTypeAndName(ctx context.Context, agentType, name string) ([]AgentTemplate, error) { + var templates []AgentTemplate + err := s.db.Core.NewSelect().Model(&templates). + Where("type = ? AND name = ?", agentType, name). + Scan(ctx) + if err != nil { + return nil, errorx.HandleDBError(err, map[string]any{"agent_type": agentType, "name": name}) + } + return templates, nil +} + func (s *agentTemplateStoreImpl) applyAgentTemplateFilters(query *bun.SelectQuery, filter types.AgentTemplateFilter) *bun.SelectQuery { filter.Search = strings.TrimSpace(filter.Search) if filter.Search != "" { @@ -224,7 +237,13 @@ func (s *agentInstanceStoreImpl) Create(ctx context.Context, instance *AgentInst // FindByID retrieves an AgentInstance by its ID func (s *agentInstanceStoreImpl) FindByID(ctx context.Context, id int64) (*AgentInstance, error) { instance := &AgentInstance{} - err := s.db.Core.NewSelect().Model(instance).Where("id = ?", id).Scan(ctx, instance) + err := s.db.Core.NewSelect(). + TableExpr("agent_instances AS ai"). + ColumnExpr("ai.*"). + ColumnExpr("(EXISTS(SELECT 1 FROM agent_shares ash WHERE ash.instance_id = ai.id)) AS is_shared"). + Where("ai.deleted_at IS NULL"). + Where("ai.id = ?", id). + Scan(ctx, instance) if err != nil { return nil, errorx.HandleDBError(err, map[string]any{ "instance_id": id, @@ -248,7 +267,14 @@ func (s *agentInstanceStoreImpl) FindByIDs(ctx context.Context, ids []int64) ([] // FindByContentID retrieves an AgentInstance by its content ID func (s *agentInstanceStoreImpl) FindByContentID(ctx context.Context, instanceType string, contentID string) (*AgentInstance, error) { instance := &AgentInstance{} - err := s.db.Core.NewSelect().Model(instance).Where("type = ? AND content_id = ?", instanceType, contentID).Limit(1).Scan(ctx, instance) + err := s.db.Core.NewSelect(). + TableExpr("agent_instances AS ai"). + ColumnExpr("ai.*"). + ColumnExpr("(EXISTS(SELECT 1 FROM agent_shares ash WHERE ash.instance_id = ai.id)) AS is_shared"). + Where("ai.deleted_at IS NULL"). + Where("ai.type = ? AND ai.content_id = ?", instanceType, contentID). + Limit(1). + Scan(ctx, instance) if err != nil { return nil, errorx.HandleDBError(err, map[string]any{ "instance_type": instanceType, @@ -317,6 +343,7 @@ func (s *agentInstanceStoreImpl) ListByUserUUID(ctx context.Context, userUUID st ColumnExpr("(pin_pref.id IS NOT NULL) AS is_pinned"). ColumnExpr("pin_pref.created_at AS pinned_at"). ColumnExpr("config_pref.value AS config"). + ColumnExpr("(EXISTS(SELECT 1 FROM agent_shares ash WHERE ash.instance_id = ai.id)) AS is_shared"). Join(` LEFT JOIN agent_user_preferences pin_pref ON pin_pref.user_uuid = ? diff --git a/builder/store/database/agent_share.go b/builder/store/database/agent_share.go new file mode 100644 index 000000000..6dd989f48 --- /dev/null +++ b/builder/store/database/agent_share.go @@ -0,0 +1,68 @@ +package database + +import ( + "context" + + "opencsg.com/csghub-server/common/errorx" +) + +type AgentShare struct { + ID int64 `bun:",pk,autoincrement" json:"id"` + ShareUUID string `bun:",notnull,unique" json:"share_uuid"` + ShareName string `bun:",notnull,unique" json:"share_name"` + Type string `bun:",notnull" json:"type"` + UserUUID string `bun:",notnull" json:"user_uuid"` + InstanceID int64 `bun:",notnull" json:"instance_id"` + times +} + +type AgentShareStore interface { + Create(ctx context.Context, share *AgentShare) (*AgentShare, error) + FindByShareUUID(ctx context.Context, shareUUID string) (*AgentShare, error) + FindByShareName(ctx context.Context, shareName string) (*AgentShare, error) +} + +type agentShareStoreImpl struct { + db *DB +} + +func NewAgentShareStore() AgentShareStore { + return &agentShareStoreImpl{db: defaultDB} +} + +func NewAgentShareStoreWithDB(db *DB) AgentShareStore { + return &agentShareStoreImpl{db: db} +} + +func (s *agentShareStoreImpl) Create(ctx context.Context, share *AgentShare) (*AgentShare, error) { + res, err := s.db.Core.NewInsert().Model(share).Exec(ctx, share) + if err = assertAffectedOneRow(res, err); err != nil { + return nil, errorx.HandleDBError(err, map[string]any{ + "share_uuid": share.ShareUUID, + "share_name": share.ShareName, + "instance_id": share.InstanceID, + "user_uuid": share.UserUUID, + "operation": "create", + }) + } + return share, nil +} + +func (s *agentShareStoreImpl) FindByShareUUID(ctx context.Context, shareUUID string) (*AgentShare, error) { + return s.find(ctx, "share_uuid", shareUUID) +} + +func (s *agentShareStoreImpl) FindByShareName(ctx context.Context, shareName string) (*AgentShare, error) { + return s.find(ctx, "share_name", shareName) +} + +func (s *agentShareStoreImpl) find(ctx context.Context, column string, value string) (*AgentShare, error) { + share := &AgentShare{} + err := s.db.Core.NewSelect().Model(share).Where(column+" = ?", value).Scan(ctx, share) + if err != nil { + return nil, errorx.HandleDBError(err, map[string]any{ + "operation": "find_by_" + column, + }) + } + return share, nil +} diff --git a/builder/store/database/agent_share_test.go b/builder/store/database/agent_share_test.go new file mode 100644 index 000000000..0eb347fc1 --- /dev/null +++ b/builder/store/database/agent_share_test.go @@ -0,0 +1,39 @@ +package database_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/common/tests" +) + +func TestAgentShareStore_CRUD(t *testing.T) { + db := tests.InitTestDB() + defer db.Close() + ctx := context.TODO() + store := database.NewAgentShareStoreWithDB(db) + + share := &database.AgentShare{ + ShareUUID: uuid.NewString(), + ShareName: "s-" + uuid.NewString()[:16], + Type: "instance", + UserUUID: uuid.NewString(), + InstanceID: 123, + } + created, err := store.Create(ctx, share) + require.NoError(t, err) + require.NotZero(t, created.ID) + + byUUID, err := store.FindByShareUUID(ctx, share.ShareUUID) + require.NoError(t, err) + require.Equal(t, created.ID, byUUID.ID) + require.Equal(t, share.ShareName, byUUID.ShareName) + + byName, err := store.FindByShareName(ctx, share.ShareName) + require.NoError(t, err) + require.Equal(t, created.ID, byName.ID) + require.Equal(t, share.ShareUUID, byName.ShareUUID) +} diff --git a/builder/store/database/agent_template_test.go b/builder/store/database/agent_template_test.go index f1fde4fe7..c99b7fd09 100644 --- a/builder/store/database/agent_template_test.go +++ b/builder/store/database/agent_template_test.go @@ -148,6 +148,13 @@ func TestAgentTemplateStore_ListByUserUUID_WithPublicTemplates(t *testing.T) { require.NoError(t, err) require.Len(t, templates, 2) // public template from user1 + private template from user2 require.Equal(t, 2, total) + + // Anonymous callers receive public templates only. + templates, total, err = store.ListByUserUUID(ctx, "", types.AgentTemplateFilter{}, 10, 1) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, 1, total) + require.Equal(t, publicTemplate.ID, templates[0].ID) } func TestAgentTemplateStore_NotFound(t *testing.T) { diff --git a/builder/store/database/migrations/20260804011549_create_agent_shares.go b/builder/store/database/migrations/20260804011549_create_agent_shares.go new file mode 100644 index 000000000..c96cb2459 --- /dev/null +++ b/builder/store/database/migrations/20260804011549_create_agent_shares.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +type AgentShare struct { + ID int64 `bun:",pk,autoincrement" json:"id"` + ShareUUID string `bun:",notnull,unique" json:"share_uuid"` + ShareName string `bun:",notnull,unique" json:"share_name"` + Type string `bun:",notnull" json:"type"` + UserUUID string `bun:",notnull" json:"user_uuid"` + InstanceID int64 `bun:",notnull" json:"instance_id"` + times +} + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + err := createTables(ctx, db, &AgentShare{}) + if err != nil { + return err + } + + _, err = db.NewCreateIndex().Model(&AgentShare{}). + Index("idx_agent_shares_instance_id"). + Column("instance_id"). + IfNotExists(). + Exec(ctx) + return err + }, func(ctx context.Context, db *bun.DB) error { + return dropTables(ctx, db, &AgentShare{}) + }) +} diff --git a/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.down.sql b/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.down.sql new file mode 100644 index 000000000..c962a65f2 --- /dev/null +++ b/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.down.sql @@ -0,0 +1,5 @@ +SET statement_timeout = 0; + +--bun:split + +DROP INDEX IF EXISTS idx_agent_templates_csgclaw_type_name_active_unique; diff --git a/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.up.sql b/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.up.sql new file mode 100644 index 000000000..5cae75af8 --- /dev/null +++ b/builder/store/database/migrations/20260805021926_add_unique_index_agent_templates_type_name.up.sql @@ -0,0 +1,7 @@ +SET statement_timeout = 0; + +--bun:split + +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_templates_csgclaw_type_name_active_unique + ON agent_templates (type, name) + WHERE deleted_at IS NULL AND type = 'csgclaw'; diff --git a/common/errorx/error_agent.go b/common/errorx/error_agent.go index fd1396cae..7c24d852b 100644 --- a/common/errorx/error_agent.go +++ b/common/errorx/error_agent.go @@ -19,6 +19,12 @@ const ( credentialVerifyURLInvalid credentialVerifyTokenInvalid credentialVerifyFailed + instanceProvisioningMetadataImmutable + csgclawTemplateCreationForbidden + agentProvisionRequestFieldNull + agentProvisionRequestFieldType + agentProvisionRequestFieldEmpty + agentProvisionRequestModelUnavailable ) var ( @@ -229,6 +235,84 @@ var ( // // zh-HK: 憑證驗證失敗 ErrCredentialVerifyFailed error = CustomError{prefix: errAgentPrefix, code: credentialVerifyFailed} + + // instance provisioning metadata is immutable after creation + // + // Description: Agent instance provisioning metadata cannot be updated after creation. Sandbox provisioning is fixed at instance creation and there is no live sandbox update path. The error message includes the agent instance type. + // + // Description_ZH: 智能体实例的部署元数据在创建后无法更新。沙箱部署在实例创建时确定,且没有实时沙箱更新路径。错误消息中包含智能体实例类型。 + // + // en-US: {{.instance_type}} instance provisioning metadata cannot be updated after creation + // + // zh-CN: {{.instance_type}} 实例的部署元数据在创建后无法更新 + // + // zh-HK: {{.instance_type}} 實例的部署元數據在創建後無法更新 + ErrInstanceProvisioningMetadataImmutable error = CustomError{prefix: errAgentPrefix, code: instanceProvisioningMetadataImmutable} + + // csgclaw agent template creation is forbidden via the API + // + // Description: csgclaw agent templates are managed by the code repository and cannot be created via the API. The error message includes the template type. + // + // Description_ZH: csgclaw 智能体模板由代码仓库托管,不能通过 API 创建。错误消息中包含模板类型。 + // + // en-US: {{.template_type}} agent templates are managed by the code repository and cannot be created via the API + // + // zh-CN: {{.template_type}} 智能体模板由代码仓库托管,不能通过 API 创建 + // + // zh-HK: {{.template_type}} 智能體模板由程式碼倉庫托管,不能通過 API 建立 + ErrCSGClawTemplateCreationForbidden error = CustomError{prefix: errAgentPrefix, code: csgclawTemplateCreationForbidden} + + // an agent provision request field is null but must not be + // + // Description: A field in the agent instance provision request metadata is null. The error message includes the agent instance type and the offending field. + // + // Description_ZH: 智能体实例部署请求元数据中的字段为 null。错误消息中包含智能体实例类型和出错的字段。 + // + // en-US: {{.instance_type}} provision request field {{.field}} must not be null + // + // zh-CN: {{.instance_type}} 部署请求字段 {{.field}} 不能为 null + // + // zh-HK: {{.instance_type}} 部署請求欄位 {{.field}} 不能為 null + ErrAgentProvisionRequestFieldNull error = CustomError{prefix: errAgentPrefix, code: agentProvisionRequestFieldNull} + + // an agent provision request field has an invalid type + // + // Description: A field in the agent instance provision request metadata has an invalid type. The error message includes the agent instance type and the offending field. + // + // Description_ZH: 智能体实例部署请求元数据中的字段类型无效。错误消息中包含智能体实例类型和出错的字段。 + // + // en-US: {{.instance_type}} provision request field {{.field}} has an invalid type + // + // zh-CN: {{.instance_type}} 部署请求字段 {{.field}} 类型无效 + // + // zh-HK: {{.instance_type}} 部署請求欄位 {{.field}} 類型無效 + ErrAgentProvisionRequestFieldType error = CustomError{prefix: errAgentPrefix, code: agentProvisionRequestFieldType} + + // an agent provision request field is empty but must not be + // + // Description: A field in the agent instance provision request metadata is empty. The error message includes the agent instance type and the offending field. + // + // Description_ZH: 智能体实例部署请求元数据中的字段为空。错误消息中包含智能体实例类型和出错的字段。 + // + // en-US: {{.instance_type}} provision request field {{.field}} must not be empty + // + // zh-CN: {{.instance_type}} 部署请求字段 {{.field}} 不能为空 + // + // zh-HK: {{.instance_type}} 部署請求欄位 {{.field}} 不能為空 + ErrAgentProvisionRequestFieldEmpty error = CustomError{prefix: errAgentPrefix, code: agentProvisionRequestFieldEmpty} + + // a pinned llm model is not available for the agent provision request + // + // Description: The model pinned in the agent instance provision request is not in the available llm model catalog. The error message includes the model name and the agent instance type. + // + // Description_ZH: 智能体实例部署请求中指定的模型不在可用 llm 模型目录中。错误消息中包含模型名称和智能体实例类型。 + // + // en-US: llm model {{.model}} is not available for {{.instance_type}} + // + // zh-CN: llm 模型 {{.model}} 不适用于 {{.instance_type}} + // + // zh-HK: llm 模型 {{.model}} 不適用於 {{.instance_type}} + ErrAgentProvisionRequestModelUnavailable error = CustomError{prefix: errAgentPrefix, code: agentProvisionRequestModelUnavailable} ) func InstanceQuotaExceeded(err error, ctx context) error { @@ -352,3 +436,57 @@ func CredentialVerifyFailed(err error, ctx context) error { code: int(credentialVerifyFailed), } } + +func InstanceProvisioningMetadataImmutable(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(instanceProvisioningMetadataImmutable), + } +} + +func CSGClawTemplateCreationForbidden(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(csgclawTemplateCreationForbidden), + } +} + +func AgentProvisionRequestFieldNull(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(agentProvisionRequestFieldNull), + } +} + +func AgentProvisionRequestFieldType(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(agentProvisionRequestFieldType), + } +} + +func AgentProvisionRequestFieldEmpty(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(agentProvisionRequestFieldEmpty), + } +} + +func AgentProvisionRequestModelUnavailable(err error, ctx context) error { + return CustomError{ + prefix: errAgentPrefix, + context: ctx, + err: err, + code: int(agentProvisionRequestModelUnavailable), + } +} diff --git a/common/i18n/en-US/err_agent.json b/common/i18n/en-US/err_agent.json index bd32f5cf1..9bb18387e 100644 --- a/common/i18n/en-US/err_agent.json +++ b/common/i18n/en-US/err_agent.json @@ -23,9 +23,27 @@ "error.AGENT-ERR-15": { "other": "Credential verification failed" }, + "error.AGENT-ERR-16": { + "other": "{{.instance_type}} instance provisioning metadata cannot be updated after creation" + }, + "error.AGENT-ERR-17": { + "other": "{{.template_type}} agent templates are managed by the code repository and cannot be created via the API" + }, + "error.AGENT-ERR-18": { + "other": "{{.instance_type}} provision request field {{.field}} must not be null" + }, + "error.AGENT-ERR-19": { + "other": "{{.instance_type}} provision request field {{.field}} has an invalid type" + }, "error.AGENT-ERR-2": { "other": "You have a knowledge base with the same name: {{.knowledge_base_name}}" }, + "error.AGENT-ERR-20": { + "other": "{{.instance_type}} provision request field {{.field}} must not be empty" + }, + "error.AGENT-ERR-21": { + "other": "llm model {{.model}} is not available for {{.instance_type}}" + }, "error.AGENT-ERR-3": { "other": "You have an MCP server with the same name: {{.server_name}}" }, diff --git a/common/i18n/zh-CN/err_agent.json b/common/i18n/zh-CN/err_agent.json index f2c619f8b..ce2bdd66d 100644 --- a/common/i18n/zh-CN/err_agent.json +++ b/common/i18n/zh-CN/err_agent.json @@ -23,9 +23,27 @@ "error.AGENT-ERR-15": { "other": "凭证验证失败" }, + "error.AGENT-ERR-16": { + "other": "{{.instance_type}} 实例的部署元数据在创建后无法更新" + }, + "error.AGENT-ERR-17": { + "other": "{{.template_type}} 智能体模板由代码仓库托管,不能通过 API 创建" + }, + "error.AGENT-ERR-18": { + "other": "{{.instance_type}} 部署请求字段 {{.field}} 不能为 null" + }, + "error.AGENT-ERR-19": { + "other": "{{.instance_type}} 部署请求字段 {{.field}} 类型无效" + }, "error.AGENT-ERR-2": { "other": "您已存在相同名称的知识库: {{.knowledge_base_name}}" }, + "error.AGENT-ERR-20": { + "other": "{{.instance_type}} 部署请求字段 {{.field}} 不能为空" + }, + "error.AGENT-ERR-21": { + "other": "llm 模型 {{.model}} 不适用于 {{.instance_type}}" + }, "error.AGENT-ERR-3": { "other": "您已存在相同名称的MCP服务器: {{.server_name}}" }, diff --git a/common/i18n/zh-HK/err_agent.json b/common/i18n/zh-HK/err_agent.json index 8aaef84eb..9054b385a 100644 --- a/common/i18n/zh-HK/err_agent.json +++ b/common/i18n/zh-HK/err_agent.json @@ -23,9 +23,27 @@ "error.AGENT-ERR-15": { "other": "憑證驗證失敗" }, + "error.AGENT-ERR-16": { + "other": "{{.instance_type}} 實例的部署元數據在創建後無法更新" + }, + "error.AGENT-ERR-17": { + "other": "{{.template_type}} 智能體模板由程式碼倉庫托管,不能通過 API 建立" + }, + "error.AGENT-ERR-18": { + "other": "{{.instance_type}} 部署請求欄位 {{.field}} 不能為 null" + }, + "error.AGENT-ERR-19": { + "other": "{{.instance_type}} 部署請求欄位 {{.field}} 類型無效" + }, "error.AGENT-ERR-2": { "other": "您已存在相同名稱的知識庫: {{.knowledge_base_name}}" }, + "error.AGENT-ERR-20": { + "other": "{{.instance_type}} 部署請求欄位 {{.field}} 不能為空" + }, + "error.AGENT-ERR-21": { + "other": "llm 模型 {{.model}} 不適用於 {{.instance_type}}" + }, "error.AGENT-ERR-3": { "other": "您已存在相同名稱的MCP服務器: {{.server_name}}" }, diff --git a/common/tests/stores.go b/common/tests/stores.go index 7210b4f96..5eae044cc 100644 --- a/common/tests/stores.go +++ b/common/tests/stores.go @@ -7,70 +7,71 @@ import ( ) type MockStores struct { - User database.UserStore - UserLikes database.UserLikesStore - Repo database.RepoStore - RepoRelation database.RepoRelationsStore - Model database.ModelStore - SpaceResource database.SpaceResourceStore - ScenarioConstraint database.ScenarioConstraintStore - Tag database.TagStore - TagRule database.TagRuleStore - Dataset database.DatasetStore - PromptConversation database.PromptConversationStore - PromptPrefix database.PromptPrefixStore - LLMConfig database.LLMConfigStore - Prompt database.PromptStore - Namespace database.NamespaceStore - LfsMetaObject database.LfsMetaObjectStore - LfsLock database.LfsLockStore - Mirror database.MirrorStore - MirrorSource database.MirrorSourceStore - AccessToken database.AccessTokenStore - SyncVersion database.SyncVersionStore - SyncClientSetting database.SyncClientSettingStore - RuntimeFramework database.RuntimeFrameworksStore - DeployTask database.DeployTaskStore - UserResources database.UserResourcesStore - ClusterInfo database.ClusterInfoStore - Code database.CodeStore - Collection database.CollectionStore - Workflow database.ArgoWorkFlowStore - Space database.SpaceStore - SpaceSdk database.SpaceSdkStore - Recom database.RecomStore - RepoRuntimeFramework database.RepositoriesRuntimeFrameworkStore - Discussion database.DiscussionStore - RuntimeArch database.RuntimeArchitecturesStore - ResourceModel database.ResourceModelStore - GitServerAccessToken database.GitServerAccessTokenStore - Org database.OrgStore - MultiSync database.MultiSyncStore - File database.FileStore - SSH database.SSHKeyStore - Telemetry database.TelemetryStore - RepoFile database.RepoFileStore - Event database.EventStore - License database.LicenseStore - AccountSyncQuota database.AccountSyncQuotaStore - Broadcast database.BroadcastStore - ViewerStore database.DataviewerStore - SpaceTemplate database.SpaceTemplateStore - RuleStore database.RuleStore - MCPServerStore database.MCPServerStore - StatSnapStore database.StatSnapStore - MirrorTaskStore database.MirrorTaskStore - MirrorNamespaceMapping database.MirrorNamespaceMappingStore - Skill database.SkillStore - SkillVersion database.SkillVersionStore - Member database.MemberStore - RepositoryStatistics database.RepositoryStatisticsStore - InferenceArch database.InferenceArchStore - Upstream database.UpstreamStore - DatasetApplication database.DatasetApplicationStore - Metadata database.MetadataStore - AccountSyncQuotaStatement database.AccountSyncQuotaStatementStore - AccountPrice database.AccountPriceStore + User database.UserStore + UserLikes database.UserLikesStore + Repo database.RepoStore + RepoRelation database.RepoRelationsStore + Model database.ModelStore + SpaceResource database.SpaceResourceStore + ScenarioConstraint database.ScenarioConstraintStore + Tag database.TagStore + TagRule database.TagRuleStore + Dataset database.DatasetStore + PromptConversation database.PromptConversationStore + PromptPrefix database.PromptPrefixStore + LLMConfig database.LLMConfigStore + Prompt database.PromptStore + Namespace database.NamespaceStore + LfsMetaObject database.LfsMetaObjectStore + LfsLock database.LfsLockStore + Mirror database.MirrorStore + MirrorSource database.MirrorSourceStore + AccessToken database.AccessTokenStore + SyncVersion database.SyncVersionStore + SyncClientSetting database.SyncClientSettingStore + RuntimeFramework database.RuntimeFrameworksStore + DeployTask database.DeployTaskStore + UserResources database.UserResourcesStore + ClusterInfo database.ClusterInfoStore + Code database.CodeStore + Collection database.CollectionStore + Workflow database.ArgoWorkFlowStore + Space database.SpaceStore + SpaceSdk database.SpaceSdkStore + Recom database.RecomStore + RepoRuntimeFramework database.RepositoriesRuntimeFrameworkStore + Discussion database.DiscussionStore + RuntimeArch database.RuntimeArchitecturesStore + ResourceModel database.ResourceModelStore + GitServerAccessToken database.GitServerAccessTokenStore + Org database.OrgStore + MultiSync database.MultiSyncStore + File database.FileStore + SSH database.SSHKeyStore + Telemetry database.TelemetryStore + RepoFile database.RepoFileStore + Event database.EventStore + License database.LicenseStore + AccountSyncQuota database.AccountSyncQuotaStore + Broadcast database.BroadcastStore + ViewerStore database.DataviewerStore + SpaceTemplate database.SpaceTemplateStore + RuleStore database.RuleStore + MCPServerStore database.MCPServerStore + StatSnapStore database.StatSnapStore + MirrorTaskStore database.MirrorTaskStore + MirrorNamespaceMapping database.MirrorNamespaceMappingStore + Skill database.SkillStore + SkillVersion database.SkillVersionStore + Member database.MemberStore + RepositoryStatistics database.RepositoryStatisticsStore + InferenceArch database.InferenceArchStore + Upstream database.UpstreamStore + DatasetApplication database.DatasetApplicationStore + Metadata database.MetadataStore + AccountSyncQuotaStatement database.AccountSyncQuotaStatementStore + AccountPrice database.AccountPriceStore + AgentTemplate database.AgentTemplateStore } func NewMockStores(t interface { @@ -78,73 +79,78 @@ func NewMockStores(t interface { mock.TestingT }) *MockStores { return &MockStores{ - User: mockdb.NewMockUserStore(t), - UserLikes: mockdb.NewMockUserLikesStore(t), - Repo: mockdb.NewMockRepoStore(t), - RepoRelation: mockdb.NewMockRepoRelationsStore(t), - Model: mockdb.NewMockModelStore(t), - SpaceResource: mockdb.NewMockSpaceResourceStore(t), - ScenarioConstraint: mockdb.NewMockScenarioConstraintStore(t), - Tag: mockdb.NewMockTagStore(t), - Dataset: mockdb.NewMockDatasetStore(t), - PromptConversation: mockdb.NewMockPromptConversationStore(t), - PromptPrefix: mockdb.NewMockPromptPrefixStore(t), - LLMConfig: mockdb.NewMockLLMConfigStore(t), - Prompt: mockdb.NewMockPromptStore(t), - Namespace: mockdb.NewMockNamespaceStore(t), - LfsMetaObject: mockdb.NewMockLfsMetaObjectStore(t), - LfsLock: mockdb.NewMockLfsLockStore(t), - Mirror: mockdb.NewMockMirrorStore(t), - MirrorSource: mockdb.NewMockMirrorSourceStore(t), - AccessToken: mockdb.NewMockAccessTokenStore(t), - SyncVersion: mockdb.NewMockSyncVersionStore(t), - SyncClientSetting: mockdb.NewMockSyncClientSettingStore(t), - RuntimeFramework: mockdb.NewMockRuntimeFrameworksStore(t), - DeployTask: mockdb.NewMockDeployTaskStore(t), - UserResources: mockdb.NewMockUserResourcesStore(t), - ClusterInfo: mockdb.NewMockClusterInfoStore(t), - Code: mockdb.NewMockCodeStore(t), - Collection: mockdb.NewMockCollectionStore(t), - Workflow: mockdb.NewMockArgoWorkFlowStore(t), - Space: mockdb.NewMockSpaceStore(t), - SpaceSdk: mockdb.NewMockSpaceSdkStore(t), - Recom: mockdb.NewMockRecomStore(t), - RepoRuntimeFramework: mockdb.NewMockRepositoriesRuntimeFrameworkStore(t), - Discussion: mockdb.NewMockDiscussionStore(t), - RuntimeArch: mockdb.NewMockRuntimeArchitecturesStore(t), - ResourceModel: mockdb.NewMockResourceModelStore(t), - GitServerAccessToken: mockdb.NewMockGitServerAccessTokenStore(t), - Org: mockdb.NewMockOrgStore(t), - MultiSync: mockdb.NewMockMultiSyncStore(t), - File: mockdb.NewMockFileStore(t), - SSH: mockdb.NewMockSSHKeyStore(t), - Telemetry: mockdb.NewMockTelemetryStore(t), - RepoFile: mockdb.NewMockRepoFileStore(t), - Event: mockdb.NewMockEventStore(t), - License: mockdb.NewMockLicenseStore(t), - TagRule: mockdb.NewMockTagRuleStore(t), - AccountSyncQuota: mockdb.NewMockAccountSyncQuotaStore(t), - Broadcast: mockdb.NewMockBroadcastStore(t), - ViewerStore: mockdb.NewMockDataviewerStore(t), - SpaceTemplate: mockdb.NewMockSpaceTemplateStore(t), - RuleStore: mockdb.NewMockRuleStore(t), - MCPServerStore: mockdb.NewMockMCPServerStore(t), - StatSnapStore: mockdb.NewMockStatSnapStore(t), - MirrorTaskStore: mockdb.NewMockMirrorTaskStore(t), - MirrorNamespaceMapping: mockdb.NewMockMirrorNamespaceMappingStore(t), - Skill: mockdb.NewMockSkillStore(t), - SkillVersion: mockdb.NewMockSkillVersionStore(t), - Member: mockdb.NewMockMemberStore(t), - RepositoryStatistics: mockdb.NewMockRepositoryStatisticsStore(t), - InferenceArch: mockdb.NewMockInferenceArchStore(t), - Upstream: mockdb.NewMockUpstreamStore(t), - DatasetApplication: mockdb.NewMockDatasetApplicationStore(t), - Metadata: mockdb.NewMockMetadataStore(t), - AccountSyncQuotaStatement: mockdb.NewMockAccountSyncQuotaStatementStore(t), - AccountPrice: mockdb.NewMockAccountPriceStore(t), + User: mockdb.NewMockUserStore(t), + UserLikes: mockdb.NewMockUserLikesStore(t), + Repo: mockdb.NewMockRepoStore(t), + RepoRelation: mockdb.NewMockRepoRelationsStore(t), + Model: mockdb.NewMockModelStore(t), + SpaceResource: mockdb.NewMockSpaceResourceStore(t), + ScenarioConstraint: mockdb.NewMockScenarioConstraintStore(t), + Tag: mockdb.NewMockTagStore(t), + Dataset: mockdb.NewMockDatasetStore(t), + PromptConversation: mockdb.NewMockPromptConversationStore(t), + PromptPrefix: mockdb.NewMockPromptPrefixStore(t), + LLMConfig: mockdb.NewMockLLMConfigStore(t), + Prompt: mockdb.NewMockPromptStore(t), + Namespace: mockdb.NewMockNamespaceStore(t), + LfsMetaObject: mockdb.NewMockLfsMetaObjectStore(t), + LfsLock: mockdb.NewMockLfsLockStore(t), + Mirror: mockdb.NewMockMirrorStore(t), + MirrorSource: mockdb.NewMockMirrorSourceStore(t), + AccessToken: mockdb.NewMockAccessTokenStore(t), + SyncVersion: mockdb.NewMockSyncVersionStore(t), + SyncClientSetting: mockdb.NewMockSyncClientSettingStore(t), + RuntimeFramework: mockdb.NewMockRuntimeFrameworksStore(t), + DeployTask: mockdb.NewMockDeployTaskStore(t), + UserResources: mockdb.NewMockUserResourcesStore(t), + ClusterInfo: mockdb.NewMockClusterInfoStore(t), + Code: mockdb.NewMockCodeStore(t), + Collection: mockdb.NewMockCollectionStore(t), + Workflow: mockdb.NewMockArgoWorkFlowStore(t), + Space: mockdb.NewMockSpaceStore(t), + SpaceSdk: mockdb.NewMockSpaceSdkStore(t), + Recom: mockdb.NewMockRecomStore(t), + RepoRuntimeFramework: mockdb.NewMockRepositoriesRuntimeFrameworkStore(t), + Discussion: mockdb.NewMockDiscussionStore(t), + RuntimeArch: mockdb.NewMockRuntimeArchitecturesStore(t), + ResourceModel: mockdb.NewMockResourceModelStore(t), + GitServerAccessToken: mockdb.NewMockGitServerAccessTokenStore(t), + Org: mockdb.NewMockOrgStore(t), + MultiSync: mockdb.NewMockMultiSyncStore(t), + File: mockdb.NewMockFileStore(t), + SSH: mockdb.NewMockSSHKeyStore(t), + Telemetry: mockdb.NewMockTelemetryStore(t), + RepoFile: mockdb.NewMockRepoFileStore(t), + Event: mockdb.NewMockEventStore(t), + License: mockdb.NewMockLicenseStore(t), + TagRule: mockdb.NewMockTagRuleStore(t), + AccountSyncQuota: mockdb.NewMockAccountSyncQuotaStore(t), + Broadcast: mockdb.NewMockBroadcastStore(t), + ViewerStore: mockdb.NewMockDataviewerStore(t), + SpaceTemplate: mockdb.NewMockSpaceTemplateStore(t), + RuleStore: mockdb.NewMockRuleStore(t), + MCPServerStore: mockdb.NewMockMCPServerStore(t), + StatSnapStore: mockdb.NewMockStatSnapStore(t), + MirrorTaskStore: mockdb.NewMockMirrorTaskStore(t), + MirrorNamespaceMapping: mockdb.NewMockMirrorNamespaceMappingStore(t), + Skill: mockdb.NewMockSkillStore(t), + SkillVersion: mockdb.NewMockSkillVersionStore(t), + Member: mockdb.NewMockMemberStore(t), + RepositoryStatistics: mockdb.NewMockRepositoryStatisticsStore(t), + InferenceArch: mockdb.NewMockInferenceArchStore(t), + Upstream: mockdb.NewMockUpstreamStore(t), + DatasetApplication: mockdb.NewMockDatasetApplicationStore(t), + Metadata: mockdb.NewMockMetadataStore(t), + AccountSyncQuotaStatement: mockdb.NewMockAccountSyncQuotaStatementStore(t), + AccountPrice: mockdb.NewMockAccountPriceStore(t), + AgentTemplate: mockdb.NewMockAgentTemplateStore(t), } } +func (s *MockStores) AgentTemplateMock() *mockdb.MockAgentTemplateStore { + return s.AgentTemplate.(*mockdb.MockAgentTemplateStore) +} + func (s *MockStores) ViewerMock() *mockdb.MockDataviewerStore { return s.ViewerStore.(*mockdb.MockDataviewerStore) } diff --git a/common/types/agent.go b/common/types/agent.go index d927fa7fd..c2c0f8977 100644 --- a/common/types/agent.go +++ b/common/types/agent.go @@ -72,6 +72,7 @@ type AgentInstance struct { IsRunning bool `json:"is_running"` // Whether the instance is running BuiltIn bool `json:"built_in"` // Whether the instance is built-in IsPinned bool `json:"is_pinned"` // Whether the instance is pinned by the user + IsShared bool `json:"is_shared"` // Whether the instance has been shared via agent_shares Config map[string]any `json:"config,omitempty"` // Per-user instance configuration from user preferences Metadata *map[string]any `json:"metadata,omitempty"` // Instance metadata Data json.RawMessage `json:"-"` // Request-only flow data; excluded from responses @@ -267,6 +268,21 @@ type AgentSessionShareTokenResponse struct { Token string `json:"token"` } +type AgentInstanceShareResponse struct { + ShareUUID string `json:"share_uuid"` + ShareName string `json:"share_name"` + Type string `json:"type"` +} + +type AgentSharedInstanceResponse struct { + ID int64 `json:"id"` + InstanceID int64 `json:"instance_id"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + SharedSandboxName string `json:"shared_sandbox_name"` +} + // AgentSharedSessionResponse represents the public shared session response. type AgentSharedSessionResponse struct { Session *AgentSharedSession `json:"session"` diff --git a/common/types/agentfile.go b/common/types/agentfile.go new file mode 100644 index 000000000..79ad2e1ad --- /dev/null +++ b/common/types/agentfile.go @@ -0,0 +1,54 @@ +package types + +import ( + "fmt" + + "github.com/naoina/toml" +) + +const AgentFileName = "agent.toml" + +// AgentFileEnv describes one [[image.env]] entry in agentfile/v1. +type AgentFileEnv struct { + Name string `toml:"name" json:"name"` + Required bool `toml:"required" json:"required"` + Secret bool `toml:"secret" json:"secret"` + Default string `toml:"default" json:"default"` + Description string `toml:"description" json:"description"` +} + +// AgentFileImage is the [image] section of agentfile/v1. +type AgentFileImage struct { + Ref string `toml:"ref" json:"ref"` + Env []AgentFileEnv `toml:"env" json:"env"` +} + +// AgentFile is the schema_version "agentfile/v1" manifest committed to code repos. +type AgentFile struct { + Name string `toml:"name" json:"name"` + Role string `toml:"role" json:"role"` + Description string `toml:"description" json:"description"` + RuntimeKind string `toml:"runtime_kind" json:"runtime_kind"` + UpdatedAt string `toml:"updated_at" json:"updated_at"` + Version string `toml:"version" json:"version"` + SchemaVersion string `toml:"schema_version" json:"schema_version"` + Tags []string `toml:"tags" json:"tags"` + Image AgentFileImage `toml:"image" json:"image"` +} + +// SupportedSandboxRuntimeKinds lists runtime_kind values that can be auto-deployed +// as sandboxes from code repo pushes. Extend when new runtimes are supported. +var SupportedSandboxRuntimeKinds = map[string]bool{ + "codex": true, +} + +func ParseAgentFile(content string) (*AgentFile, error) { + var agentFile AgentFile + if err := toml.Unmarshal([]byte(content), &agentFile); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", AgentFileName, err) + } + if agentFile.Name == "" { + return nil, fmt.Errorf("%s missing required field: name", AgentFileName) + } + return &agentFile, nil +} diff --git a/common/types/agentfile_test.go b/common/types/agentfile_test.go new file mode 100644 index 000000000..45b4e70b1 --- /dev/null +++ b/common/types/agentfile_test.go @@ -0,0 +1,61 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const testAgentFileContent = ` +name = 'gitlab-assistant' +role = 'worker' +description = 'GitLab assistant' +runtime_kind = 'openclaw' +updated_at = '2026-06-29T02:13:29Z' +version = '2026.6.29.0' +schema_version = 'agentfile/v1' +tags = ['self-hosted'] + +[image] +ref = 'registry.example.com/opencsghq/openclaw-glab:2026.7.3.0' +[[image.env]] +name = 'GITLAB_TOKEN' +required = true +secret = true +description = 'GitLab personal access token' +[[image.env]] +name = 'GITLAB_BASE_URL' +required = true +default = 'https://git-devops.opencsg.com' +` + +func TestParseAgentFile(t *testing.T) { + agentFile, err := ParseAgentFile(testAgentFileContent) + require.NoError(t, err) + require.Equal(t, "gitlab-assistant", agentFile.Name) + require.Equal(t, "worker", agentFile.Role) + require.Equal(t, "openclaw", agentFile.RuntimeKind) + require.Equal(t, "agentfile/v1", agentFile.SchemaVersion) + require.Equal(t, []string{"self-hosted"}, agentFile.Tags) + require.Equal(t, "registry.example.com/opencsghq/openclaw-glab:2026.7.3.0", agentFile.Image.Ref) + require.Len(t, agentFile.Image.Env, 2) + require.Equal(t, "GITLAB_TOKEN", agentFile.Image.Env[0].Name) + require.True(t, agentFile.Image.Env[0].Required) + require.True(t, agentFile.Image.Env[0].Secret) + require.Equal(t, "https://git-devops.opencsg.com", agentFile.Image.Env[1].Default) +} + +func TestParseAgentFile_Invalid(t *testing.T) { + _, err := ParseAgentFile("not = [valid") + require.Error(t, err) +} + +func TestParseAgentFile_MissingName(t *testing.T) { + _, err := ParseAgentFile("runtime_kind = 'codex'") + require.ErrorContains(t, err, "name") +} + +func TestSupportedSandboxRuntimeKinds(t *testing.T) { + require.True(t, SupportedSandboxRuntimeKinds["codex"]) + require.False(t, SupportedSandboxRuntimeKinds["openclaw"]) +} diff --git a/component/agent_config.go b/component/agent_config.go new file mode 100644 index 000000000..28ecae37c --- /dev/null +++ b/component/agent_config.go @@ -0,0 +1,158 @@ +package component + +import ( + "context" + "fmt" + "strings" + + "opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" +) + +type AgentConfigComponent interface { + GetByName(ctx context.Context, name string) (*types.AgentConfig, error) + GetByID(ctx context.Context, id int64) (*types.AgentConfig, error) + List(ctx context.Context) ([]*types.AgentConfig, error) + Create(ctx context.Context, req *types.CreateAgentConfigReq) (*types.AgentConfig, error) + Update(ctx context.Context, id int64, req *types.UpdateAgentConfigReq) (*types.AgentConfig, error) + Delete(ctx context.Context, id int64) error +} + +type agentConfigComponentImpl struct { + agentConfigStore database.AgentConfigStore +} + +func NewAgentConfigComponent(config *config.Config) (AgentConfigComponent, error) { + agentConfigStore := database.NewAgentConfigStore() + return &agentConfigComponentImpl{ + agentConfigStore: agentConfigStore, + }, nil +} + +func (c *agentConfigComponentImpl) GetByName(ctx context.Context, name string) (*types.AgentConfig, error) { + dbConfig, err := c.agentConfigStore.GetByName(ctx, name) + if err != nil { + return nil, fmt.Errorf("failed to get agent config by name: %w", err) + } + if dbConfig == nil { + return nil, fmt.Errorf("agent config not found") + } + + return &types.AgentConfig{ + ID: dbConfig.ID, + Name: dbConfig.Name, + Config: dbConfig.Config, + CreatedAt: dbConfig.CreatedAt, + UpdatedAt: dbConfig.UpdatedAt, + }, nil +} + +func (c *agentConfigComponentImpl) GetByID(ctx context.Context, id int64) (*types.AgentConfig, error) { + dbConfig, err := c.agentConfigStore.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get agent config by id: %w", err) + } + if dbConfig == nil { + return nil, fmt.Errorf("agent config not found") + } + + return &types.AgentConfig{ + ID: dbConfig.ID, + Name: dbConfig.Name, + Config: dbConfig.Config, + CreatedAt: dbConfig.CreatedAt, + UpdatedAt: dbConfig.UpdatedAt, + }, nil +} + +func (c *agentConfigComponentImpl) List(ctx context.Context) ([]*types.AgentConfig, error) { + dbConfigs, err := c.agentConfigStore.List(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list agent configs: %w", err) + } + + configs := make([]*types.AgentConfig, len(dbConfigs)) + for i, dbConfig := range dbConfigs { + configs[i] = &types.AgentConfig{ + ID: dbConfig.ID, + Name: dbConfig.Name, + Config: dbConfig.Config, + CreatedAt: dbConfig.CreatedAt, + UpdatedAt: dbConfig.UpdatedAt, + } + } + + return configs, nil +} + +func (c *agentConfigComponentImpl) Create(ctx context.Context, req *types.CreateAgentConfigReq) (*types.AgentConfig, error) { + if strings.HasPrefix(req.Name, "sandbox_runtime.") { + return nil, fmt.Errorf("sandbox runtime profiles are managed from Git configuration") + } + dbConfig := &database.AgentConfig{ + Name: req.Name, + Config: req.Config, + } + + err := c.agentConfigStore.Create(ctx, dbConfig) + if err != nil { + return nil, fmt.Errorf("failed to create agent config: %w", err) + } + + return &types.AgentConfig{ + ID: dbConfig.ID, + Name: dbConfig.Name, + Config: dbConfig.Config, + CreatedAt: dbConfig.CreatedAt, + UpdatedAt: dbConfig.UpdatedAt, + }, nil +} + +func (c *agentConfigComponentImpl) Delete(ctx context.Context, id int64) error { + config, err := c.agentConfigStore.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get agent config: %w", err) + } + if config != nil && strings.HasPrefix(config.Name, "sandbox_runtime.") { + return fmt.Errorf("sandbox runtime profiles are managed from Git configuration") + } + err = c.agentConfigStore.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete agent config: %w", err) + } + return nil +} + +func (c *agentConfigComponentImpl) Update(ctx context.Context, id int64, req *types.UpdateAgentConfigReq) (*types.AgentConfig, error) { + dbConfig, err := c.agentConfigStore.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get agent config: %w", err) + } + if dbConfig == nil { + return nil, fmt.Errorf("agent config not found") + } + if strings.HasPrefix(dbConfig.Name, "sandbox_runtime.") { + return nil, fmt.Errorf("sandbox runtime profiles are managed from Git configuration") + } + + if req.Name != nil && *req.Name != "" { + dbConfig.Name = *req.Name + } + if req.Config != nil { + dbConfig.Config = *req.Config + } + + err = c.agentConfigStore.Update(ctx, dbConfig) + if err != nil { + return nil, fmt.Errorf("failed to update agent config: %w", err) + } + + return &types.AgentConfig{ + ID: dbConfig.ID, + Name: dbConfig.Name, + Config: dbConfig.Config, + CreatedAt: dbConfig.CreatedAt, + UpdatedAt: dbConfig.UpdatedAt, + }, nil +} diff --git a/component/agent_runtime_profile.go b/component/agent_runtime_profile.go new file mode 100644 index 000000000..5c73e960b --- /dev/null +++ b/component/agent_runtime_profile.go @@ -0,0 +1,133 @@ +package component + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "opencsg.com/csghub-server/builder/store/database" +) + +const csgclawRuntimeProfileName = "sandbox_runtime.csgclaw" + +type CSGClawRuntimeProfile struct { + AgentType string `json:"agent_type"` + Version string `json:"version"` + Image string `json:"image"` + Port int `json:"port"` + Command []string `json:"command"` + HealthCheck SandboxRuntimeHealthCheck `json:"health_check"` + DefaultEnv map[string]string `json:"default_env"` + ContentSHA string `json:"content_sha"` +} + +type SandboxRuntimeHealthCheck struct { + Protocol string `json:"protocol"` + Path string `json:"path"` +} + +func initAgentRuntimeProfiles(ctx context.Context) error { + path, err := agentRuntimeProfilePath("csgclaw.json") + if err != nil { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read csgclaw runtime profile: %w", err) + } + var profile CSGClawRuntimeProfile + if err := json.Unmarshal(raw, &profile); err != nil { + return fmt.Errorf("parse csgclaw runtime profile: %w", err) + } + if err := validateCSGClawRuntimeProfile(&profile); err != nil { + return err + } + hash := sha256.Sum256(raw) + profile.ContentSHA = hex.EncodeToString(hash[:]) + + config := map[string]any{} + encoded, _ := json.Marshal(profile) + if err := json.Unmarshal(encoded, &config); err != nil { + return err + } + store := database.NewAgentConfigStore() + existing, err := store.GetByName(ctx, csgclawRuntimeProfileName) + if err != nil { + return fmt.Errorf("get csgclaw runtime profile: %w", err) + } + if existing == nil { + return store.Create(ctx, &database.AgentConfig{Name: csgclawRuntimeProfileName, Config: config}) + } + if existing.Config["content_sha"] == profile.ContentSHA { + return nil + } + existing.Config = config + return store.Update(ctx, existing) +} + +func GetCSGClawRuntimeProfile(ctx context.Context, store database.AgentConfigStore) (*CSGClawRuntimeProfile, error) { + config, err := store.GetByName(ctx, csgclawRuntimeProfileName) + if err != nil { + return nil, err + } + if config == nil { + return nil, fmt.Errorf("csgclaw runtime profile is not initialized") + } + raw, err := json.Marshal(config.Config) + if err != nil { + return nil, err + } + var profile CSGClawRuntimeProfile + if err := json.Unmarshal(raw, &profile); err != nil { + return nil, fmt.Errorf("parse stored csgclaw runtime profile: %w", err) + } + if err := validateCSGClawRuntimeProfile(&profile); err != nil { + return nil, err + } + return &profile, nil +} + +func validateCSGClawRuntimeProfile(profile *CSGClawRuntimeProfile) error { + if profile.AgentType != "csgclaw" || strings.TrimSpace(profile.Image) == "" || strings.TrimSpace(profile.Version) == "" || profile.Port <= 0 { + return fmt.Errorf("invalid csgclaw runtime profile") + } + for _, arg := range profile.Command { + if strings.TrimSpace(arg) == "" { + return fmt.Errorf("csgclaw runtime profile command cannot contain empty arguments") + } + } + if profile.HealthCheck.Protocol == "" && profile.HealthCheck.Path == "" { + return nil + } + if profile.HealthCheck.Protocol != "http" { + return fmt.Errorf("csgclaw runtime profile health_check.protocol must be http") + } + if !strings.HasPrefix(profile.HealthCheck.Path, "/") { + return fmt.Errorf("csgclaw runtime profile health_check.path must start with /") + } + return nil +} + +func agentRuntimeProfilePath(name string) (string, error) { + dir, err := filepath.Abs(".") + if err != nil { + return "", err + } + for { + path := filepath.Join(dir, "configs", "agent_runtime", name) + if _, err := os.Stat(path); err == nil { + return path, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("agent runtime profile %s not found", name) +} diff --git a/component/agent_runtime_profile_ce.go b/component/agent_runtime_profile_ce.go new file mode 100644 index 000000000..1d497e4d1 --- /dev/null +++ b/component/agent_runtime_profile_ce.go @@ -0,0 +1,9 @@ +//go:build !ee && !saas + +package component + +import "context" + +func InitAgentRuntimeProfiles(ctx context.Context) error { + return initAgentRuntimeProfiles(ctx) +} diff --git a/component/callback/agent_deploy_watcher_ce.go b/component/callback/agent_deploy_watcher_ce.go new file mode 100644 index 000000000..f925fb64d --- /dev/null +++ b/component/callback/agent_deploy_watcher_ce.go @@ -0,0 +1,14 @@ +//go:build !(ee || saas) + +package callback + +import ( + "context" + + "opencsg.com/csghub-server/common/types" +) + +// WatchAgentChange is a no-op in CE: agent sandbox deploy requires EE/saas components. +func (c *gitCallbackComponentImpl) WatchAgentChange(ctx context.Context, req *types.GiteaCallbackPushReq) error { + return nil +} diff --git a/component/callback/git_callback.go b/component/callback/git_callback.go index 28d651635..558a5fae2 100644 --- a/component/callback/git_callback.go +++ b/component/callback/git_callback.go @@ -23,6 +23,7 @@ import ( type GitCallbackComponent interface { SetRepoVisibility(yes bool) WatchSpaceChange(ctx context.Context, req *types.GiteaCallbackPushReq) error + WatchAgentChange(ctx context.Context, req *types.GiteaCallbackPushReq) error WatchRepoRelation(ctx context.Context, req *types.GiteaCallbackPushReq) error GenSyncVersion(ctx context.Context, req *types.GiteaCallbackPushReq) error SetRepoUpdateTime(ctx context.Context, req *types.GiteaCallbackPushReq) error diff --git a/component/code.go b/component/code.go index 695bbf2ea..1d1efd07c 100644 --- a/component/code.go +++ b/component/code.go @@ -56,6 +56,7 @@ func NewCodeComponent(config *config.Config) (CodeComponent, error) { } c.codeStore = database.NewCodeStore() c.repoStore = database.NewRepoStore() + c.templateStore = database.NewAgentTemplateStore() c.recomStore = database.NewRecomStore() gs, err := git.NewGitServer(config) if err != nil { @@ -81,6 +82,7 @@ type codeComponentImpl struct { mirrorComponent MirrorComponent codeStore database.CodeStore repoStore database.RepoStore + templateStore database.AgentTemplateStore userLikesStore database.UserLikesStore gitServer gitserver.GitServer userSvcClient rpc.UserSvcClient @@ -342,6 +344,11 @@ func (c *codeComponentImpl) Update(ctx context.Context, req *types.UpdateCodeReq if err != nil { return nil, err } + if req.Private != nil { + if err := c.syncAgentTemplateVisibility(ctx, dbRepo, !dbRepo.Private); err != nil { + return nil, err + } + } code, err := c.codeStore.ByRepoID(ctx, dbRepo.ID) if err != nil { @@ -371,6 +378,23 @@ func (c *codeComponentImpl) Update(ctx context.Context, req *types.UpdateCodeReq return resCode, nil } +func (c *codeComponentImpl) syncAgentTemplateVisibility(ctx context.Context, repo *database.Repository, public bool) error { + templates, err := c.templateStore.FindByTypeAndName(ctx, "csgclaw", repo.Path) + if err != nil { + return fmt.Errorf("find csgclaw templates by name: %w", err) + } + for i := range templates { + if templates[i].Metadata["repo_path"] != repo.Path || templates[i].Public == public { + continue + } + templates[i].Public = public + if err := c.templateStore.Update(ctx, &templates[i]); err != nil { + return fmt.Errorf("update agent template visibility: %w", err) + } + } + return nil +} + func (c *codeComponentImpl) Delete(ctx context.Context, namespace, name, currentUser string) error { code, err := c.codeStore.FindByPath(ctx, namespace, name) if err != nil { @@ -392,6 +416,9 @@ func (c *codeComponentImpl) Delete(ctx context.Context, namespace, name, current if err != nil { return fmt.Errorf("failed to delete database code, error: %w", err) } + if err := c.deleteRepositoryManagedAgentTemplates(ctx, repo.Path); err != nil { + return err + } go func() { notificationCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -410,6 +437,22 @@ func (c *codeComponentImpl) Delete(ctx context.Context, namespace, name, current return nil } +func (c *codeComponentImpl) deleteRepositoryManagedAgentTemplates(ctx context.Context, repoPath string) error { + templates, err := c.templateStore.FindByTypeAndName(ctx, "csgclaw", repoPath) + if err != nil { + return fmt.Errorf("find csgclaw templates by name: %w", err) + } + for i := range templates { + if templates[i].Metadata["repo_path"] != repoPath { + continue + } + if err := c.templateStore.Delete(ctx, templates[i].ID); err != nil { + return fmt.Errorf("delete repository-managed agent template: %w", err) + } + } + return nil +} + func (c *codeComponentImpl) Show(ctx context.Context, namespace, name, currentUser string, needOpWeight bool, needMultiSync bool) (*types.Code, error) { var ( tags []types.RepoTag diff --git a/component/code_test.go b/component/code_test.go index a4c841b92..e61273104 100644 --- a/component/code_test.go +++ b/component/code_test.go @@ -175,6 +175,32 @@ func TestCodeComponent_Update(t *testing.T) { } +func TestCodeComponent_Update_SyncsRepositoryManagedTemplateVisibility(t *testing.T) { + ctx := context.TODO() + cc := initializeTestCodeComponent(ctx, t) + private := false + req := &types.UpdateCodeReq{UpdateRepoReq: types.UpdateRepoReq{ + Namespace: "owner", + Name: "agent", + RepoType: types.CodeRepo, + Private: &private, + }} + dbRepo := &database.Repository{ID: 1, UserID: 2, Path: "owner/agent", Name: "agent", Private: false} + cc.mocks.components.repo.EXPECT().UpdateRepo(ctx, req.UpdateRepoReq).Return(dbRepo, nil) + cc.mocks.stores.AgentTemplateMock().EXPECT().FindByTypeAndName(ctx, "csgclaw", "owner/agent").Return([]database.AgentTemplate{ + {ID: 10, UserUUID: "owner-uuid", Public: false, Metadata: map[string]any{"repo_path": "owner/agent"}}, + {ID: 11, UserUUID: "owner-uuid", Public: false, Metadata: map[string]any{"repo_path": "owner/other"}}, + }, nil) + cc.mocks.stores.AgentTemplateMock().EXPECT().Update(ctx, mock.MatchedBy(func(template *database.AgentTemplate) bool { + return template.ID == 10 && template.Public + })).Return(nil) + cc.mocks.stores.CodeMock().EXPECT().ByRepoID(ctx, int64(1)).Return(&database.Code{ID: 1}, nil) + cc.mocks.stores.CodeMock().EXPECT().Update(ctx, database.Code{ID: 1}).Return(nil) + + _, err := cc.Update(ctx, req) + require.NoError(t, err) +} + func TestCodeComponent_Delete(t *testing.T) { ctx := context.TODO() cc := initializeTestCodeComponent(ctx, t) @@ -206,6 +232,10 @@ func TestCodeComponent_Delete(t *testing.T) { }).Return(repo, nil) cc.mocks.stores.CodeMock().EXPECT().Delete(ctx, *code).Return(nil) + cc.mocks.stores.AgentTemplateMock().EXPECT().FindByTypeAndName(ctx, "csgclaw", "ns/n").Return([]database.AgentTemplate{{ + ID: 9, Type: "csgclaw", Metadata: map[string]any{"repo_path": "ns/n"}, + }}, nil) + cc.mocks.stores.AgentTemplateMock().EXPECT().Delete(ctx, int64(9)).Return(nil) var wg sync.WaitGroup wg.Add(1) cc.mocks.components.repo.EXPECT(). diff --git a/component/wireset.go b/component/wireset.go index e8c201895..3801f8a95 100644 --- a/component/wireset.go +++ b/component/wireset.go @@ -410,6 +410,7 @@ func NewTestCodeComponent(config *config.Config, stores *tests.MockStores, repoC repoComponent: repoComponent, codeStore: stores.Code, repoStore: stores.Repo, + templateStore: stores.AgentTemplate, userLikesStore: stores.UserLikes, gitServer: gitServer, userSvcClient: userSvcClient, diff --git a/configs/agent_runtime/csgclaw.json b/configs/agent_runtime/csgclaw.json new file mode 100644 index 000000000..0b93312d1 --- /dev/null +++ b/configs/agent_runtime/csgclaw.json @@ -0,0 +1,18 @@ +{ + "agent_type": "csgclaw", + "version": "v0.4.5", + "image": "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbox:2026080603", + "port": 18080, + "command": [ + "/sbin/tini", + "--", + "/usr/local/bin/docker-entrypoint.sh" + ], + "health_check": {}, + "default_env": { + "OPENCSG_SANDBOX_TYPE": "csgclaw", + "CSGCLAW_ROLE": "server", + "CSGCLAW_PVC_MOUNT_PATH": "/home/picoclaw/.csgclaw", + "SKILLS_POLL_INTERVAL": "600" + } +} diff --git a/docs/error_codes_en.md b/docs/error_codes_en.md index 4cd8b0f68..98db96f94 100644 --- a/docs/error_codes_en.md +++ b/docs/error_codes_en.md @@ -170,6 +170,54 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `credentialVerifyFailed` - **Description:** Credential verification failed. +--- + +### `AGENT-ERR-16` + +- **Error Code:** `AGENT-ERR-16` +- **Error Name:** `instanceProvisioningMetadataImmutable` +- **Description:** Agent instance provisioning metadata cannot be updated after creation. Sandbox provisioning is fixed at instance creation and there is no live sandbox update path. The error message includes the agent instance type. + +--- + +### `AGENT-ERR-17` + +- **Error Code:** `AGENT-ERR-17` +- **Error Name:** `csgclawTemplateCreationForbidden` +- **Description:** csgclaw agent templates are managed by the code repository and cannot be created via the API. The error message includes the template type. + +--- + +### `AGENT-ERR-18` + +- **Error Code:** `AGENT-ERR-18` +- **Error Name:** `agentProvisionRequestFieldNull` +- **Description:** A field in the agent instance provision request metadata is null. The error message includes the agent instance type and the offending field. + +--- + +### `AGENT-ERR-19` + +- **Error Code:** `AGENT-ERR-19` +- **Error Name:** `agentProvisionRequestFieldType` +- **Description:** A field in the agent instance provision request metadata has an invalid type. The error message includes the agent instance type and the offending field. + +--- + +### `AGENT-ERR-20` + +- **Error Code:** `AGENT-ERR-20` +- **Error Name:** `agentProvisionRequestFieldEmpty` +- **Description:** A field in the agent instance provision request metadata is empty. The error message includes the agent instance type and the offending field. + +--- + +### `AGENT-ERR-21` + +- **Error Code:** `AGENT-ERR-21` +- **Error Name:** `agentProvisionRequestModelUnavailable` +- **Description:** The model pinned in the agent instance provision request is not in the available llm model catalog. The error message includes the model name and the agent instance type. + ## Auth Errors ### `AUTH-ERR-0` @@ -1769,3 +1817,4 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Code:** `USER-ERR-20` - **Error Name:** `namespaceAlreadyExists` - **Description:** The namespace already exists in the system. + diff --git a/docs/error_codes_zh.md b/docs/error_codes_zh.md index 3403d0cdc..be4df4d5d 100644 --- a/docs/error_codes_zh.md +++ b/docs/error_codes_zh.md @@ -170,6 +170,54 @@ - **错误名:** `credentialVerifyFailed` - **描述:** 凭证验证失败。 +--- + +### `AGENT-ERR-16` + +- **错误代码:** `AGENT-ERR-16` +- **错误名:** `instanceProvisioningMetadataImmutable` +- **描述:** 智能体实例的部署元数据在创建后无法更新。沙箱部署在实例创建时确定,且没有实时沙箱更新路径。错误消息中包含智能体实例类型。 + +--- + +### `AGENT-ERR-17` + +- **错误代码:** `AGENT-ERR-17` +- **错误名:** `csgclawTemplateCreationForbidden` +- **描述:** csgclaw 智能体模板由代码仓库托管,不能通过 API 创建。错误消息中包含模板类型。 + +--- + +### `AGENT-ERR-18` + +- **错误代码:** `AGENT-ERR-18` +- **错误名:** `agentProvisionRequestFieldNull` +- **描述:** 智能体实例部署请求元数据中的字段为 null。错误消息中包含智能体实例类型和出错的字段。 + +--- + +### `AGENT-ERR-19` + +- **错误代码:** `AGENT-ERR-19` +- **错误名:** `agentProvisionRequestFieldType` +- **描述:** 智能体实例部署请求元数据中的字段类型无效。错误消息中包含智能体实例类型和出错的字段。 + +--- + +### `AGENT-ERR-20` + +- **错误代码:** `AGENT-ERR-20` +- **错误名:** `agentProvisionRequestFieldEmpty` +- **描述:** 智能体实例部署请求元数据中的字段为空。错误消息中包含智能体实例类型和出错的字段。 + +--- + +### `AGENT-ERR-21` + +- **错误代码:** `AGENT-ERR-21` +- **错误名:** `agentProvisionRequestModelUnavailable` +- **描述:** 智能体实例部署请求中指定的模型不在可用 llm 模型目录中。错误消息中包含模型名称和智能体实例类型。 + ## Auth 错误 ### `AUTH-ERR-0` @@ -1769,3 +1817,4 @@ - **错误代码:** `USER-ERR-20` - **错误名:** `namespaceAlreadyExists` - **描述:** 命名空间已存在于系统中。 +