diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 101fa1d..c6ca75b 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -721,7 +721,9 @@ "agent", "template", "deployment", - "file" + "file", + "identity", + "channel" ] }, "name": { @@ -752,7 +754,9 @@ "agent", "template", "deployment", - "file" + "file", + "identity", + "channel" ] }, "name": { @@ -787,7 +791,9 @@ "agent", "template", "deployment", - "file" + "file", + "identity", + "channel" ] }, "name": { @@ -843,7 +849,9 @@ "agent", "template", "deployment", - "file" + "file", + "identity", + "channel" ] }, "name": { diff --git a/apps/webui/src/lib/api/generated/schema.d.ts b/apps/webui/src/lib/api/generated/schema.d.ts index 68919b0..4bc8673 100644 --- a/apps/webui/src/lib/api/generated/schema.d.ts +++ b/apps/webui/src/lib/api/generated/schema.d.ts @@ -283,7 +283,9 @@ export interface paths { | "agent" | "template" | "deployment" - | "file"; + | "file" + | "identity" + | "channel"; name: string; provider: string; }; @@ -298,7 +300,9 @@ export interface paths { | "agent" | "template" | "deployment" - | "file"; + | "file" + | "identity" + | "channel"; name: string; provider: string; }[]; @@ -315,7 +319,9 @@ export interface paths { | "agent" | "template" | "deployment" - | "file"; + | "file" + | "identity" + | "channel"; name: string; provider: string; }; @@ -341,7 +347,9 @@ export interface paths { | "agent" | "template" | "deployment" - | "file"; + | "file" + | "identity" + | "channel"; name: string; provider: string; }[]; diff --git a/docs/examples.md b/docs/examples.md index fbeffa5..08a60e3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -12,6 +12,7 @@ The [`examples/`](../examples) directory has runnable configs for every provider | Connect an MCP server + vault | [`examples/claude/with-mcp/`](../examples/claude/with-mcp/) · [`examples/qoder/with-mcp/`](../examples/qoder/with-mcp/) · [`examples/ark/with-mcp/`](../examples/ark/with-mcp/) | | Use an official MCP server | [`examples/bailian/with-mcp/`](../examples/bailian/with-mcp/) | | Use a vault | [`examples/bailian/with-vault/`](../examples/bailian/with-vault/) · [`examples/qoder/with-vault/`](../examples/qoder/with-vault/) | +| Connect a credential-based IM Channel | [`examples/qoder/with-channel/`](../examples/qoder/with-channel/) | | Use memory stores | [`examples/qoder/with-memory/`](../examples/qoder/with-memory/) · [`examples/ark/full/`](../examples/ark/full/) | | Upload local files (Files API) | [`examples/bailian/with-files/`](../examples/bailian/with-files/) · [`examples/ark/with-files/`](../examples/ark/with-files/) | | Coordinate multiple agents | [`examples/claude/multiagent/`](../examples/claude/multiagent/) · [`examples/ark/multiagent/`](../examples/ark/multiagent/) | diff --git a/docs/guides/deploy-to-qoder.md b/docs/guides/deploy-to-qoder.md index 6734e1a..dd039b3 100644 --- a/docs/guides/deploy-to-qoder.md +++ b/docs/guides/deploy-to-qoder.md @@ -22,7 +22,7 @@ providers: | Feature | Tier | |---------|:----:| -| Environment, Vault, Skill, Agent, MCP Server, Memory Store, Deployment, Session | native | +| Environment, Vault, Skill, Agent, MCP Server, Memory Store, Deployment, Session, Identity, Channel | native | | Multi-Agent | unsupported | A `deployment run` on Qoder creates a native Deployment Run and associated Session. Cron schedules run server-side. @@ -94,16 +94,18 @@ agents: `agents plan/apply/destroy` then manage a Qoder Forward Template (`tmpl_...`) instead of a Managed Agent (`agent_...`). The default remains Managed Agent delivery when `delivery` is omitted. -Identity is optional for local Forward Session testing. If omitted, OpenCMA finds the enabled Qoder Identity whose -`external_id` is `__qca_admin_identity__`, then sends its real `idn_...` id to the Session API. To run as a specific -user—or when the reserved Identity is unavailable—configure an existing Identity explicitly: +Forward Identity represents an end user in the integrating product. Declare it once by its stable business id and select +it as the project default: ```yaml defaults: provider: qoder - session: - qoder: - identity_id: idn_xxx + identity: chen + +identities: + chen: + external_id: user_456 + name: Chen ``` Then use the same session command to test a Forward-delivered agent: @@ -112,14 +114,25 @@ Then use the same session command to test a Forward-delivered agent: agents session run "Hello" --agent forward-assistant ``` -The CLI resolves the applied Template and optional configured Identity, creates a Forward Session, and routes create, send, -stream, get, and archive operations through the Forward gateway. It never creates or updates an Identity as a side effect -of starting a Session. Use `--identity-id idn_xxx` to override the YAML default for one invocation. Managed sessions keep -using the Cloud gateway. +`agents apply` manages the declared Identity. The CLI resolves `defaults.identity` through state, creates a Forward +Session, and routes create, send, stream, get, and archive operations through the Forward gateway. Use +`--identity-id idn_xxx` only as a runtime escape hatch. Managed sessions keep using the Cloud gateway. + +Credential-based messaging Channels are declarative too: + +```yaml +channels: + support-dingtalk: + agent: forward-assistant + type: dingtalk + credentials: + client_id: ${DINGTALK_CLIENT_ID} + client_secret: ${DINGTALK_CLIENT_SECRET} +``` -Production messaging delivery is a separate Forward Channel concern. An IM Channel explicitly binds a business Identity -and Template; do not treat the CLI's local testing Identity as an end-user Identity. Qoder -recommends QR-code authorization for Channel binding so channel credentials do not need to be stored locally. +The Channel inherits `defaults.identity`; the Qoder adapter resolves both remote ids and maps the generic declaration to +the Forward Channel request. Feishu uses `app_id`/`app_secret`, and WeCom uses `bot_id`/`secret`. Personal WeChat is QR-only +and is not supported by credential-based apply. Forward-delivered agents still cannot be referenced by OpenAgentPack deployments; scheduled Managed Deployment runs require an Agent resource. diff --git a/docs/guides/run-sessions.md b/docs/guides/run-sessions.md index 710b981..16cbd51 100644 --- a/docs/guides/run-sessions.md +++ b/docs/guides/run-sessions.md @@ -12,7 +12,7 @@ A **session** is a runtime conversation started from a managed agent. Sessions a agents session run "Summarize the repo structure" --agent assistant ``` -`session run` creates a session, sends the prompt, and polls until the response completes. Pass `--stream` to receive live events over SSE. When only one agent is configured, `--agent` is auto-detected. For a Qoder agent with `delivery.qoder.type: forward`, Identity is optional: without one OpenCMA looks up the enabled Identity whose `external_id` is `__qca_admin_identity__` and sends its real `idn_...` id. Configure `defaults.session.qoder.identity_id` or pass `--identity-id` to select an existing business Identity. OpenCMA never creates or updates Identity resources implicitly. +`session run` creates a session, sends the prompt, and polls until the response completes. Pass `--stream` to receive live events over SSE. When only one agent is configured, `--agent` is auto-detected. A Qoder agent with `delivery.qoder.type: forward` requires a declared Identity selected through `defaults.identity`; `agents apply` creates or resolves it before sessions run. Pass `--identity-id` only to override the resolved default with an existing provider Identity for one invocation. Options: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5723136..261216e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -111,7 +111,7 @@ Manage runtime agent sessions. | `session events ` | List event history for a session. | | `session delete ` | Delete a session. | -`session create` / `session run` accept `--agent`, `--identity-id`, `--environment`, `--vault`, `--memory-stores`, `--title`, and `--provider`. `--identity-id` selects an existing Qoder Forward Identity and overrides `defaults.session.qoder.identity_id`; when neither is provided, OpenCMA resolves the Identity whose `external_id` is `__qca_admin_identity__`. OpenCMA never creates or updates an Identity. `session run` and `session send` use polling by default and accept `--stream` to opt into SSE streaming, plus `--json` for JSON output. `session list` accepts `--agent` and `--all`; `session events` accepts `--limit`, `--all`, `--json`. +`session create` / `session run` accept `--agent`, `--identity-id`, `--environment`, `--vault`, `--memory-stores`, `--title`, and `--provider`. Forward Sessions resolve the declared logical `defaults.identity`; `--identity-id` overrides it with an existing provider id for one invocation. `session run` and `session send` use polling by default and accept `--stream` to opt into SSE streaming, plus `--json` for JSON output. `session list` accepts `--agent` and `--all`; `session events` accepts `--limit`, `--all`, `--json`. ## `agents deployment` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 46b31f0..cf6d874 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -9,15 +9,16 @@ version: "1" providers: { : } defaults: provider: | "all" - session: - qoder: { identity_id: } + identity: environments: { : EnvironmentDecl } tunnels: { : TunnelDecl } vaults: { : VaultDecl } memory_stores:{ : MemoryStoreDecl } skills: { : SkillDecl } files: { : FileDecl } +identities: { : IdentityDecl } agents: { : AgentDecl } +channels: { : ChannelDecl } deployments: { : DeploymentDecl } ``` @@ -26,18 +27,67 @@ deployments: { : DeploymentDecl } | `version` | string | yes | Schema version. Currently `"1"`. | | `providers` | map | yes | One block per provider; each holds its credentials. | | `defaults.provider` | string | no | Default target for `plan`/`apply`. `all` targets every declared provider. | -| `defaults.session.qoder.identity_id` | string | no | Existing Qoder Forward Identity used as the local Session default. If omitted, OpenCMA looks up the enabled Identity whose `external_id` is `__qca_admin_identity__` and sends its real `idn_...` id. Never created or managed by `apply`. | +| `defaults.identity` | string | no | Logical name of the default declared Identity used by identity-aware resources and Forward Sessions. | | `environments` | map | no | Cloud runtimes. | | `tunnels` | map | no | Existing Qoder BYOC tunnels referenced by sessions; OpenCMA does not manage their lifecycle. | | `vaults` | map | no | Credential stores. | | `memory_stores` | map | no | Persistent agent context (Qoder, Volcengine Ark). | | `skills` | map | no | Reusable capability modules. | | `files` | map | no | Local files uploaded to the Files API (Bailian, Volcengine Ark). | +| `identities` | map | no | Stable end-user identities. Provider support is capability-gated. | | `agents` | map | no | The core managed-agent resources. | +| `channels` | map | no | External messaging channels bound to an Identity and Agent. Provider support is capability-gated. | | `deployments` | map | no | Repeatable run units. | Secrets use `${VAR_NAME}` and resolve from `.env` (walking up to the project root). `agents init` appends `agents.state.json` and `.env` to `.gitignore`. +## Identity + +Managed identities use the integrating product's stable end-user id: + +```yaml +identities: + chen: + provider: qoder + external_id: user_456 + name: Chen + enabled: true + metadata: + department: engineering +``` + +`agents apply` creates or updates the remote Identity and stores its provider id in state. To reference an Identity managed outside this project, use the mutually exclusive external-reference form: + +```yaml +identities: + chen: + provider: qoder + identity_id: idn_019eabc123 +``` + +External references are verified and recorded but never updated or deleted. + +## Channel + +```yaml +channels: + support-dingtalk: + provider: qoder # optional; inherits defaults.provider + agent: support-agent + identity: chen # optional; inherits defaults.identity + type: dingtalk + name: Support DingTalk # optional; defaults to the YAML key + enabled: true # optional; defaults to true + credentials: + client_id: ${DINGTALK_CLIENT_ID} + client_secret: ${DINGTALK_CLIENT_SECRET} + options: + include_tool_calls: false + include_thinking: false +``` + +The declaration intentionally uses logical `agent` and `identity` references. Provider adapters resolve remote ids and map `type`, `credentials`, and `options` to provider wire fields. Qoder Channels require the referenced Agent to use Forward delivery. Credential-based Qoder support currently covers DingTalk, Feishu, and WeCom; personal WeChat remains QR-only. + ## Provider configuration Each provider under `providers` is validated by the provider's own config schema. diff --git a/examples/qoder/byoc/agents.yaml b/examples/qoder/byoc/agents.yaml new file mode 100644 index 0000000..df0a60c --- /dev/null +++ b/examples/qoder/byoc/agents.yaml @@ -0,0 +1,88 @@ +# Qoder BYOC 端到端测试配置示例 +# +# 用途:验证 Qoder BYOC 接入(工具调用)是否打通。 +# 使用前请确保已设置环境变量: +# - QODER_PAT Qoder API 个人访问令牌(https://qoder.com/account/integrations) +# - BYOC_ENVIRONMENT_ID 已创建的 BYOC environment id +# - BYOC_TUNNEL_ID 已创建的 BYOC tunnel id +# - DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 钉钉渠道凭证(可选) +# +# 关键约束: +# - environment_id / tunnel_id 指向的 BYOC 资源由平台侧开通,OpenCMA 只引用不修改。 +# - tunnel 只是 agents.yaml 内的命名别名,直接引用 Qoder 下发的 tunnel_id。 + +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + +defaults: + provider: qoder + identity: byoc + +# Forward Identity 表示集成侧的终端用户。OpenCMA 按 external_id 创建/复用并管理远端 Identity。 +identities: + byoc: + external_id: byoc + name: "byoc" + enabled: true + metadata: + purpose: byoc-assistant + +channels: + byoc-dingtalk: + name: "byoc Agent DingTalk" + agent: byoc-agent + type: dingtalk + enabled: true + credentials: + client_id: ${DINGTALK_CLIENT_ID} + client_secret: ${DINGTALK_CLIENT_SECRET} + options: + include_tool_calls: false + include_thinking: false + +# 已存在的 BYOC 环境,通过 environment_id 直接引用,不需要 state import。 +environments: + byoc: + description: "BYOC self-hosted environment" + environment_id: ${BYOC_ENVIRONMENT_ID} + config: + type: self_hosted + metadata: + purpose: byoc-test + +# 已存在的 BYOC tunnel。直接引用 Qoder 下发的 tunnel_id,OpenCMA 不会尝试创建或修改它。 +tunnels: + internal: + description: "BYOC tunnel" + tunnel_id: ${BYOC_TUNNEL_ID} + +agents: + byoc-agent: + description: "Assistant running inside BYOC" + model: + qoder: auto + instructions: | + You are a BYOC end-to-end test assistant running in a self-hosted environment. + Your goal is to verify that Qoder BYOC tool calling is working. + Answer user questions concisely. Use the available builtin tools when helpful. + If a tool call fails, report the error clearly so connectivity issues can be diagnosed. + environment: byoc + tunnel: internal + tools: + builtin: [Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, DeliverArtifacts] + permissions: + Bash: allow + Read: allow + Write: allow + Edit: allow + Glob: allow + Grep: allow + WebFetch: allow + WebSearch: allow + DeliverArtifacts: allow + delivery: + qoder: + type: forward diff --git a/examples/qoder/with-channel/README.md b/examples/qoder/with-channel/README.md new file mode 100644 index 0000000..0d257be --- /dev/null +++ b/examples/qoder/with-channel/README.md @@ -0,0 +1,12 @@ +# Qoder credential-based Channel + +This example declares a Forward Identity, Forward Template, and DingTalk Channel without copying remote Qoder ids. + +Set `QODER_PAT`, `SUPPORT_USER_ID`, `DINGTALK_CLIENT_ID`, and `DINGTALK_CLIENT_SECRET`, then run: + +```bash +agents plan +agents apply +``` + +For Feishu, set `type: feishu` and use `app_id` / `app_secret`. For WeCom, set `type: wecom` and use `bot_id` / `secret`. Personal WeChat requires QR binding and is outside this credential-based workflow. diff --git a/examples/qoder/with-channel/agents.yaml b/examples/qoder/with-channel/agents.yaml new file mode 100644 index 0000000..119a0e8 --- /dev/null +++ b/examples/qoder/with-channel/agents.yaml @@ -0,0 +1,43 @@ +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + +defaults: + provider: qoder + identity: support-user + +identities: + support-user: + external_id: ${SUPPORT_USER_ID} + name: "Support User" + +environments: + runtime: + config: + type: cloud + networking: + type: unrestricted + +agents: + support-agent: + model: + qoder: auto + instructions: | + You are a concise support assistant. + environment: runtime + delivery: + qoder: + type: forward + +channels: + support-dingtalk: + agent: support-agent + type: dingtalk + credentials: + client_id: ${DINGTALK_CLIENT_ID} + client_secret: ${DINGTALK_CLIENT_SECRET} + options: + include_tool_calls: false + include_thinking: false diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index a89ebb6..6a988bc 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -47,13 +47,15 @@ export interface DestroyProjectResult extends DestroyPlanResult { const destroyOrder: Record = { deployment: 0, + channel: 0, agent: 1, template: 1, - skill: 2, - memory_store: 3, - vault: 4, - file: 5, - environment: 6, + identity: 2, + skill: 3, + memory_store: 4, + vault: 5, + file: 6, + environment: 7, }; export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPlanResult { @@ -100,7 +102,7 @@ async function destroyOneResource( // BYOC environments are provisioned and owned by QCA. `environment_id` means // this project only references that environment, so destroy must never make a // remote lifecycle call for it. - if (isExternalEnvironment(ctx, resource)) { + if (isExternalReference(ctx, resource)) { ctx.state.removeResource(resource.address); return successResult(resource, "reference_removed"); } @@ -170,11 +172,15 @@ async function destroyOneResource( } } -function isExternalEnvironment(ctx: ProjectRuntimeContext, resource: ResourceState): boolean { - return ( - resource.address.type === "environment" && - (resource.externally_managed || Boolean(ctx.config.environments?.[resource.address.name]?.environment_id)) - ); +function isExternalReference(ctx: ProjectRuntimeContext, resource: ResourceState): boolean { + if (resource.externally_managed) return true; + if (resource.address.type === "environment") { + return Boolean(ctx.config.environments?.[resource.address.name]?.environment_id); + } + if (resource.address.type === "identity") { + return Boolean(ctx.config.identities?.[resource.address.name]?.identity_id); + } + return false; } function successResult( @@ -225,6 +231,17 @@ async function deleteRemoteResource( case "deployment": await provider.deleteDeployment(id); return; + case "identity": + if (!provider.deleteIdentity) throw new UserError(`Provider does not support identities`); + await provider.deleteIdentity(id); + return; + case "channel": + if (!provider.deleteChannel) throw new UserError(`Provider does not support channels`); + await provider.deleteChannel(id); + return; + case "file": + await provider.deleteFile(id); + return; } } diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index cdba10d..840a843 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -90,6 +90,8 @@ const IMPORTABLE_RESOURCE_TYPES = new Set([ "skill", "agent", "template", + "identity", + "channel", ]); // Adopt an existing remote resource into state: derive its content hash from the diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 3cff1ba..ad10dcc 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -51,6 +51,14 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: const vaultNames = new Set(Object.keys(config.vaults ?? {})); const memoryNames = new Set(Object.keys(config.memory_stores ?? {})); const agentNames = new Set(Object.keys(config.agents ?? {})); + const identityNames = new Set(Object.keys(config.identities ?? {})); + + if (config.defaults?.identity && !identityNames.has(config.defaults.identity)) { + diagnostics.error( + "config.defaults.identity.unknown", + `defaults.identity references unknown identity '${config.defaults.identity}'`, + ); + } for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.environment && !envNames.has(agent.environment)) { @@ -102,6 +110,24 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: ); } } + + for (const [name, channel] of Object.entries(config.channels ?? {})) { + if (!agentNames.has(channel.agent)) { + diagnostics.error("config.channel.agent.unknown", `channel.${name}: references unknown agent '${channel.agent}'`); + } + const identity = channel.identity ?? config.defaults?.identity; + if (!identity) { + diagnostics.error( + "config.channel.identity.required", + `channel.${name}: declare identity or configure defaults.identity`, + ); + } else if (!identityNames.has(identity)) { + diagnostics.error( + "config.channel.identity.unknown", + `channel.${name}: references unknown identity '${identity}'`, + ); + } + } } export function collectProviderCapabilities( @@ -120,6 +146,83 @@ export function collectProviderCapabilities( } const caps = def.capabilities; + for (const [name, identity] of Object.entries(config.identities ?? {})) { + if (identity.provider && identity.provider !== providerName) continue; + if (!isSupported(caps, "identity")) { + diagnostics.error( + `${providerName}.identity.unsupported`, + `${caps.identity.reason}. ${caps.identity.remediation ?? ""}`.trim(), + { type: "identity", name, provider: providerName }, + ); + } + } + + for (const [name, channel] of Object.entries(config.channels ?? {})) { + if (channel.provider && channel.provider !== providerName) continue; + if (!isSupported(caps, "channel")) { + diagnostics.error( + `${providerName}.channel.unsupported`, + `${caps.channel.reason}. ${caps.channel.remediation ?? ""}`.trim(), + { type: "channel", name, provider: providerName }, + ); + continue; + } + + if (providerName === "qoder") { + const agent = config.agents?.[channel.agent]; + if (agent?.provider && agent.provider !== providerName) { + diagnostics.error( + "config.channel.agent.provider_mismatch", + `channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`, + { type: "channel", name, provider: providerName }, + ); + } + const identityName = channel.identity ?? config.defaults?.identity; + const identity = identityName ? config.identities?.[identityName] : undefined; + if (identity?.provider && identity.provider !== providerName) { + diagnostics.error( + "config.channel.identity.provider_mismatch", + `channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`, + { type: "channel", name, provider: providerName }, + ); + } + if (agent && agent.delivery?.qoder?.type !== "forward") { + diagnostics.error( + "qoder.channel.forward_template.required", + `channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`, + { type: "channel", name, provider: providerName }, + ); + } + const requiredCredentials: Record = { + dingtalk: ["client_id", "client_secret"], + feishu: ["app_id", "app_secret"], + wecom: ["bot_id", "secret"], + }; + if (channel.type === "wechat") { + diagnostics.error( + "qoder.channel.wechat.credentials.unsupported", + `channel.${name}: personal WeChat supports QR binding only; credential-based apply is unavailable.`, + { type: "channel", name, provider: providerName }, + ); + } else if (!requiredCredentials[channel.type]) { + diagnostics.error( + "qoder.channel.type.unsupported", + `channel.${name}: unsupported Qoder channel type '${channel.type}'.`, + { type: "channel", name, provider: providerName }, + ); + } else { + const missing = requiredCredentials[channel.type]!.filter((key) => !channel.credentials?.[key]); + if (missing.length) { + diagnostics.error( + "qoder.channel.credentials.required", + `channel.${name}: '${channel.type}' requires credentials: ${missing.join(", ")}.`, + { type: "channel", name, provider: providerName }, + ); + } + } + } + } + for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.provider && agent.provider !== providerName) continue; const delivery = agent.delivery?.[providerName]?.type ?? "managed"; diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 2c0fca7..5c44e47 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -16,7 +16,7 @@ import { addressKey } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; import { skillNameFromFiles } from "../utils/skill-manifest.ts"; import type { ExecContext } from "./context.ts"; -import { resolveAgentRefs, resolveDeploymentRefs, resolveTemplateRefs } from "./resolver.ts"; +import { resolveAgentRefs, resolveChannelRefs, resolveDeploymentRefs, resolveTemplateRefs } from "./resolver.ts"; import { resolveSkillFiles } from "./skill-resolver.ts"; export interface ActionResult { @@ -65,10 +65,13 @@ export async function executePlan( // environment. This upgrades state written before the marker existed, so a // later removal from config still cannot delete the provider-owned resource. for (const action of plan.actions) { - if (action.address.type !== "environment") continue; - const decl = ctx.config.environments?.[action.address.name]; + if (action.address.type !== "environment" && action.address.type !== "identity") continue; const existing = ctx.state.getResource(action.address); - if (decl?.environment_id && existing && !existing.externally_managed) { + const externalId = + action.address.type === "environment" + ? ctx.config.environments?.[action.address.name]?.environment_id + : ctx.config.identities?.[action.address.name]?.identity_id; + if (externalId && existing && !existing.externally_managed) { ctx.state.setResource({ ...existing, externally_managed: true }); stateUpdated = true; } @@ -247,9 +250,12 @@ async function executeActionInner( // External-reference environments are owned outside OpenCMA; deleting them // here would only remove the local state entry. - if (type === "environment") { - const decl = ctx.config.environments?.[name]; - if (existing.externally_managed || decl?.environment_id) { + if (type === "environment" || type === "identity") { + const externalReference = + type === "environment" + ? ctx.config.environments?.[name]?.environment_id + : ctx.config.identities?.[name]?.identity_id; + if (existing.externally_managed || externalReference) { ctx.state.removeResource(address); return false; } @@ -285,6 +291,16 @@ async function executeActionInner( case "file": await provider.deleteFile(id); break; + case "identity": + if (!provider.deleteIdentity) + throw new UserError(`Provider '${address.provider}' does not support identities`); + await provider.deleteIdentity(id); + break; + case "channel": + if (!provider.deleteChannel) + throw new UserError(`Provider '${address.provider}' does not support channels`); + await provider.deleteChannel(id); + break; } } catch (err) { if (!ApiError.isNotFound(err)) throw err; @@ -492,6 +508,63 @@ async function executeActionInner( } break; } + case "identity": { + const createIdentity = provider.createIdentity?.bind(provider); + const updateIdentity = provider.updateIdentity?.bind(provider); + if (!createIdentity || !updateIdentity) { + throw new UserError(`Provider '${address.provider}' does not support identities`); + } + const decl = ctx.config.identities![name]!; + if (decl.identity_id) { + const remote = await provider.findResource("identity", name, decl.identity_id); + if (!remote?.id) { + throw new UserError( + `External identity.${name} '${decl.identity_id}' was not found on provider '${address.provider}'.`, + ); + } + result = remote; + break; + } + if (isUpdate) { + if (ctx.state.getResource(address)?.externally_managed) { + throw new UserError(`identity.${name} is recorded as an external reference; refusing to modify it remotely.`); + } + result = await updateIdentity(existingId!, name, decl); + } else { + try { + result = await createIdentity(name, decl); + } catch (err) { + if (!(err instanceof ConflictError)) throw err; + const existing = await provider.findResource("identity", decl.external_id!); + if (!existing?.id) throw err; + result = await updateIdentity(existing.id, name, decl); + adopted = true; + } + } + break; + } + case "channel": { + const createChannel = provider.createChannel?.bind(provider); + const updateChannel = provider.updateChannel?.bind(provider); + if (!createChannel || !updateChannel) { + throw new UserError(`Provider '${address.provider}' does not support channels`); + } + const decl = ctx.config.channels![name]!; + const refs = resolveChannelRefs(name, ctx.config, address.provider, ctx.state); + if (isUpdate) { + result = await updateChannel(existingId!, name, decl, refs); + } else { + try { + result = await createChannel(name, decl, refs); + } catch (err) { + result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + onExisting: (existing) => updateChannel(existing.id!, name, decl, refs), + }); + adopted = true; + } + } + break; + } case "deployment": { const decl = ctx.config.deployments![name]!; const refs = resolveDeploymentRefs(name, ctx.config, address.provider, ctx.state); @@ -556,7 +629,9 @@ async function executeActionInner( address, remote_id: result.id, externally_managed: - priorResource?.externally_managed || (type === "environment" && ctx.config.environments?.[name]?.environment_id) + priorResource?.externally_managed || + (type === "environment" && ctx.config.environments?.[name]?.environment_id) || + (type === "identity" && ctx.config.identities?.[name]?.identity_id) ? true : undefined, version: result.version, diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index 89e37aa..00d4969 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -1,5 +1,10 @@ import { UserError } from "../errors.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs } from "../providers/interface.ts"; +import type { + ResolvedAgentRefs, + ResolvedChannelRefs, + ResolvedDeploymentRefs, + ResolvedTemplateRefs, +} from "../providers/interface.ts"; import type { IStateManager } from "../state/state-manager.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { ResourceAddress } from "../types/state.ts"; @@ -165,6 +170,28 @@ export function resolveDeploymentRefs( }; } +export function resolveChannelRefs( + channelName: string, + config: ProjectConfig, + provider: string, + state: IStateManager, +): ResolvedChannelRefs { + const channel = config.channels?.[channelName]; + if (!channel) throw new UserError(`Channel '${channelName}' not found in config`); + + const agent = config.agents?.[channel.agent]; + if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`); + const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent"; + const agent_id = requireRef(state, { type: agentType, name: channel.agent, provider }); + + const identityName = channel.identity ?? config.defaults?.identity; + if (!identityName) { + throw new UserError(`Channel '${channelName}' must declare identity or use defaults.identity.`); + } + const identity_id = requireRef(state, { type: "identity", name: identityName, provider }); + return { identity_id, agent_id }; +} + function resolveTunnelIdFromConfig(config: ProjectConfig, tunnelName: string, provider: string): string { if (provider !== "qoder") { throw new UserError("Tunnels are supported only by Qoder BYOC sessions."); diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index a47ef5e..61dcaea 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -72,6 +72,14 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str } } + if (config.identities && isSupported(caps, "identity")) { + for (const name of Object.keys(config.identities)) { + const decl = config.identities[name]!; + if (decl.provider && decl.provider !== provider) continue; + addNode({ type: "identity", name, provider }); + } + } + if (config.agents) { for (const name of Object.keys(config.agents)) { const decl = config.agents[name]!; @@ -174,6 +182,26 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str } } } + + if (config.channels && isSupported(caps, "channel")) { + for (const name of Object.keys(config.channels)) { + const decl = config.channels[name]!; + if (decl.provider && decl.provider !== provider) continue; + const channelAddr: ResourceAddress = { type: "channel", name, provider }; + addNode(channelAddr); + + const agentDecl = config.agents?.[decl.agent]; + const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent"; + const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider }; + if (nodes.has(addressKey(agentAddr))) addEdge(channelAddr, agentAddr); + + const identityName = decl.identity ?? config.defaults?.identity; + if (identityName) { + const identityAddr: ResourceAddress = { type: "identity", name: identityName, provider }; + if (nodes.has(addressKey(identityAddr))) addEdge(channelAddr, identityAddr); + } + } + } } return { nodes, edges }; diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 73399a4..528f5e6 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -95,6 +95,26 @@ const fileSchema = z.object({ provider: z.string().optional(), }); +const managedIdentitySchema = z.object({ + provider: z.string().optional(), + external_id: z.string().trim().min(1), + name: z.string().trim().min(1).optional(), + enabled: z.boolean().optional(), + metadata: z.record(z.string(), z.string()).optional(), + identity_id: z.never().optional(), +}); + +const externalIdentitySchema = z.object({ + provider: z.string().optional(), + identity_id: z.string().trim().min(1), + external_id: z.never().optional(), + name: z.never().optional(), + enabled: z.never().optional(), + metadata: z.never().optional(), +}); + +const identitySchema = z.union([managedIdentitySchema, externalIdentitySchema]); + const urlMcpServerSchema = z .object({ name: z.string(), @@ -196,6 +216,17 @@ const agentSchema = z.object({ delivery: z.record(z.string(), agentDeliverySchema).optional(), }); +const channelSchema = z.object({ + provider: z.string().optional(), + agent: z.string().min(1), + identity: z.string().min(1).optional(), + type: z.string().min(1), + name: z.string().trim().min(1).optional(), + enabled: z.boolean().optional(), + credentials: z.record(z.string(), coerceString).optional(), + options: z.record(z.string(), z.unknown()).optional(), +}); + const deploymentFileResourceSchema = z.object({ type: z.literal("file"), file_id: z.string().optional(), @@ -264,15 +295,7 @@ export const projectConfigSchema = z.object({ defaults: z .object({ provider: z.string().optional(), - session: z - .object({ - qoder: z - .object({ - identity_id: z.string().min(1).optional(), - }) - .optional(), - }) - .optional(), + identity: z.string().min(1).optional(), }) .optional(), environments: z.record(z.string(), environmentSchema).optional(), @@ -281,7 +304,9 @@ export const projectConfigSchema = z.object({ memory_stores: z.record(z.string(), memoryStoreSchema).optional(), skills: z.record(z.string(), skillSchema).optional(), files: z.record(z.string(), fileSchema).optional(), + identities: z.record(z.string(), identitySchema).optional(), agents: z.record(z.string(), agentSchema).optional(), + channels: z.record(z.string(), channelSchema).optional(), deployments: z.record(z.string(), deploymentSchema).optional(), }); diff --git a/packages/sdk/src/internal/planner/declaration.ts b/packages/sdk/src/internal/planner/declaration.ts index c1ab180..1a8ca60 100644 --- a/packages/sdk/src/internal/planner/declaration.ts +++ b/packages/sdk/src/internal/planner/declaration.ts @@ -17,6 +17,10 @@ export function getResourceDeclaration(address: ResourceAddress, config: Project return config.agents?.[name] ?? null; case "file": return config.files?.[name] ?? null; + case "identity": + return config.identities?.[name] ?? null; + case "channel": + return config.channels?.[name] ?? null; case "deployment": return config.deployments?.[name] ?? null; default: diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index a854bc6..9a2781e 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -38,9 +38,34 @@ export async function computeResourceHash( return contentHash({ decl, refs }); } + if (address.type === "channel") { + const refs = resolveChannelReferenceIds( + decl as { agent: string; identity?: string }, + config, + address.provider, + state, + ); + return contentHash({ decl, refs }); + } + return contentHash(decl); } +function resolveChannelReferenceIds( + decl: { agent: string; identity?: string }, + config: ProjectConfig, + provider: string, + state?: HashStateLookup, +): Record { + const agent = config.agents?.[decl.agent]; + const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent"; + const identity = decl.identity ?? config.defaults?.identity; + return { + agent_id: state?.getResource({ type: agentType, name: decl.agent, provider })?.remote_id, + identity_id: identity ? state?.getResource({ type: "identity", name: identity, provider })?.remote_id : undefined, + }; +} + interface DeploymentRefDecl { agent: string; environment?: string; diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 27d3543..065a21c 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -87,14 +87,42 @@ export async function buildPlan( ); } } + if (address.type === "identity" && existing) { + const identityDecl = config.identities?.[address.name]; + if (existing.externally_managed && identityDecl && !identityDecl.identity_id) { + diagnostics.error( + "plan.identity.ownership_transition", + `identity.${address.name} is recorded as an external reference (${existing.remote_id ?? "unknown id"}); ` + + `replacing identity_id with a managed declaration would modify and eventually delete an Identity this project does not own. ` + + `Restore identity_id or release the state reference first.`, + address, + ); + stateIndex.delete(key); + continue; + } + if ( + !existing.externally_managed && + existing.remote_id && + identityDecl?.identity_id && + identityDecl.identity_id !== existing.remote_id + ) { + diagnostics.warning( + "plan.identity.ownership_orphan", + `identity.${address.name}: switching to external reference '${identityDecl.identity_id}' orphans the previously ` + + `managed Identity '${existing.remote_id}'.`, + address, + ); + } + } - // Reference-only environments are recorded, never mutated remotely — say so. - const isExternalEnv = - address.type === "environment" && Boolean(config.environments?.[address.name]?.environment_id); - const createReason = isExternalEnv - ? "Record external environment reference (no remote mutation)" + // Reference-only resources are recorded, never mutated remotely — say so. + const isExternalReference = + (address.type === "environment" && Boolean(config.environments?.[address.name]?.environment_id)) || + (address.type === "identity" && Boolean(config.identities?.[address.name]?.identity_id)); + const createReason = isExternalReference + ? `Record external ${address.type} reference (no remote mutation)` : "Resource does not exist in state"; - const updateSuffix = isExternalEnv ? " — external reference, no remote mutation" : ""; + const updateSuffix = isExternalReference ? " — external reference, no remote mutation" : ""; if (!existing) { actions.push({ diff --git a/packages/sdk/src/internal/providers/ark/capabilities.ts b/packages/sdk/src/internal/providers/ark/capabilities.ts index 99decd9..337b78b 100644 --- a/packages/sdk/src/internal/providers/ark/capabilities.ts +++ b/packages/sdk/src/internal/providers/ark/capabilities.ts @@ -14,4 +14,6 @@ export const ARK_CAPABILITIES: ProviderCapabilities = { reason: "no deployment primitive on Ark; expanded into a session at run time", }, session: { tier: "native", reason: "sessions API" }, + identity: { tier: "unsupported", reason: "no mapped Identity primitive on Ark" }, + channel: { tier: "unsupported", reason: "no mapped messaging Channel primitive on Ark" }, }; diff --git a/packages/sdk/src/internal/providers/bailian/capabilities.ts b/packages/sdk/src/internal/providers/bailian/capabilities.ts index 63f942e..713bf76 100644 --- a/packages/sdk/src/internal/providers/bailian/capabilities.ts +++ b/packages/sdk/src/internal/providers/bailian/capabilities.ts @@ -23,4 +23,6 @@ export const BAILIAN_CAPABILITIES: ProviderCapabilities = { "scheduling and outcome rubrics are not enforced server-side — use external cron/CI for always-on or scheduled runs", }, session: { tier: "native", reason: "sessions API" }, + identity: { tier: "unsupported", reason: "no mapped Identity primitive on Bailian" }, + channel: { tier: "unsupported", reason: "no mapped messaging Channel primitive on Bailian" }, }; diff --git a/packages/sdk/src/internal/providers/capabilities.ts b/packages/sdk/src/internal/providers/capabilities.ts index eb661df..998898f 100644 --- a/packages/sdk/src/internal/providers/capabilities.ts +++ b/packages/sdk/src/internal/providers/capabilities.ts @@ -8,7 +8,9 @@ export type ResourceKind = | "mcp_server" | "multiagent" | "deployment" - | "session"; + | "session" + | "identity" + | "channel"; export type SupportTier = "native" | "emulated" | "unsupported"; @@ -55,4 +57,6 @@ export const REQUIRED_METHODS_BY_KIND: Partial; } +export interface ResolvedChannelRefs { + identity_id: string; + agent_id: string; +} + export interface DeploymentContext { id: string | null; name: string; @@ -162,6 +169,14 @@ export interface ProviderAdapter { /** Remove the template from desired state. Qoder implements this as a soft archive. */ archiveTemplate?(id: string): Promise; + createIdentity?(name: string, decl: IdentityDecl): Promise; + updateIdentity?(id: string, name: string, decl: IdentityDecl): Promise; + deleteIdentity?(id: string): Promise; + + createChannel?(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise; + updateChannel?(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise; + deleteChannel?(id: string): Promise; + createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; deleteMemoryStore?(id: string): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 2bd2c37..3677655 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -4,8 +4,10 @@ import JSZip from "jszip"; import { UserError } from "../../errors.ts"; import type { AgentDecl, + ChannelDecl, DeploymentDecl, EnvironmentDecl, + IdentityDecl, MemoryStoreDecl, SkillDecl, VaultDecl, @@ -41,6 +43,7 @@ import type { ProviderAdapter, RemoteResource, ResolvedAgentRefs, + ResolvedChannelRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs, } from "../interface.ts"; @@ -82,8 +85,6 @@ function deriveForwardGateway(cloudGateway?: string): string { return trimmed.endsWith("/cloud") ? `${trimmed.slice(0, -"/cloud".length)}/forward` : `${trimmed}/forward`; } -const QODER_DEFAULT_IDENTITY_EXTERNAL_ID = "__qca_admin_identity__"; - export class QoderAdapter implements ProviderAdapter { readonly name = "qoder" as const; readonly eventResume = true; @@ -91,7 +92,6 @@ export class QoderAdapter implements ProviderAdapter { private forwardClient: QoderClient; private projectName: string; private forwardSessionIds = new Set(); - private defaultForwardIdentityId?: string; constructor(apiKey: string, gateway?: string, projectName?: string, forwardGateway?: string) { this.client = new QoderClient({ apiKey, gateway }); @@ -121,6 +121,23 @@ export class QoderAdapter implements ProviderAdapter { const raw = await locateRemote(this.forwardClient, "/templates", name, id, (item) => item.status !== "archived"); return raw ? toRemoteResource(raw) : null; } + if (type === "identity") { + try { + if (id) return toRemoteResource((await this.forwardClient.get(`/identities/${id}`)) as Record); + const res = (await this.forwardClient.get(`/identities?external_id=${encodeURIComponent(name)}&limit=100`)) as { + data?: Record[]; + }; + const raw = (res.data ?? []).find((item) => item.external_id === name); + return raw ? toRemoteResource(raw) : null; + } catch (err) { + if (ApiError.isNotFound(err)) return null; + throw err; + } + } + if (type === "channel") { + const raw = await locateRemote(this.forwardClient, "/channels", name, id, () => true); + return raw ? toRemoteResource(raw) : null; + } const raw = await locateRemote(this.client, QoderAdapter.ENDPOINT_MAP[type], name, id, notArchived); return raw ? toRemoteResource(raw) : null; } @@ -181,7 +198,8 @@ export class QoderAdapter implements ProviderAdapter { } getDriftSupport(type: ResourceType): DriftSupport { - if (type === "agent" || type === "environment" || type === "template") return "full"; + if (type === "agent" || type === "environment" || type === "template" || type === "identity" || type === "channel") + return "full"; if (type === "deployment") return "unsupported"; return QoderAdapter.ENDPOINT_MAP[type] ? "existence" : "unsupported"; } @@ -191,7 +209,17 @@ export class QoderAdapter implements ProviderAdapter { id: string | null, name: string, ): Promise { - if (type !== "agent" && type !== "environment" && type !== "template") return null; + if (type !== "agent" && type !== "environment" && type !== "template" && type !== "identity" && type !== "channel") + return null; + if (type === "identity" || type === "channel") { + const remote = await this.findResource(type, name, id); + if (!remote?.id) return null; + const raw = (await this.forwardClient.get( + `/${type === "identity" ? "identities" : "channels"}/${remote.id}`, + )) as Record; + const comparable = this.normalizeRemote(type, raw); + return { id: remote.id, type, comparable, snapshot: comparable }; + } const isTemplate = type === "template"; const endpoint = type === "agent" ? "/agents" : type === "environment" ? "/environments" : "/templates"; const raw = await locateRemote( @@ -227,6 +255,16 @@ export class QoderAdapter implements ProviderAdapter { ); } if (type === "template") return null; + if (type === "identity") { + const identity = decl as IdentityDecl; + if (identity.identity_id) return null; + return this.normalizeRemote(type, { + external_id: identity.external_id, + name: identity.name ?? name, + enabled: identity.enabled ?? true, + metadata: identity.metadata ?? {}, + }); + } return null; } @@ -263,6 +301,27 @@ export class QoderAdapter implements ProviderAdapter { metadata: stripAgentsMetadata(raw.metadata), }); } + if (type === "identity") { + return compactDeep({ + external_id: raw.external_id, + name: raw.name, + enabled: raw.enabled, + metadata: raw.metadata ?? {}, + }); + } + if (type === "channel") { + const channelConfig = (raw.channel_config ?? {}) as Record; + return compactDeep({ + identity_id: raw.identity_id, + template_id: raw.template_id, + channel_type: raw.channel_type, + name: raw.name, + enabled: raw.enabled, + channel_config: { + response_options: channelConfig.response_options ?? {}, + }, + }); + } return compactDeep({ description: raw.description, @@ -412,6 +471,81 @@ export class QoderAdapter implements ProviderAdapter { await this.forwardClient.post(`/templates/${id}/archive`, {}); } + async createIdentity(name: string, decl: IdentityDecl): Promise { + if (decl.identity_id) return { id: decl.identity_id, type: "identity" }; + const res = (await this.forwardClient.post("/identities", { + external_id: decl.external_id, + name: decl.name ?? name, + enabled: decl.enabled ?? true, + metadata: decl.metadata ?? {}, + })) as Record; + return toRemoteResource(res); + } + + async updateIdentity(id: string, name: string, decl: IdentityDecl): Promise { + if (decl.identity_id) return { id: decl.identity_id, type: "identity" }; + const current = (await this.forwardClient.get(`/identities/${id}`)) as Record; + const currentMetadata = (current.metadata ?? {}) as Record; + const desiredMetadata = decl.metadata ?? {}; + const metadata: Record = { ...desiredMetadata }; + for (const key of Object.keys(currentMetadata)) { + if (!(key in desiredMetadata)) metadata[key] = ""; + } + const res = (await this.forwardClient.post(`/identities/${id}`, { + external_id: decl.external_id, + name: decl.name ?? name, + enabled: decl.enabled ?? true, + metadata, + })) as Record; + return toRemoteResource(res); + } + + async deleteIdentity(id: string): Promise { + await this.forwardClient.delete(`/identities/${id}`); + } + + async createChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise { + const res = (await this.forwardClient.post("/channels", this.mapChannel(name, decl, refs))) as Record< + string, + unknown + >; + return toRemoteResource(res); + } + + async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise { + const current = (await this.forwardClient.get(`/channels/${id}`)) as Record; + if (current.channel_type !== decl.type) { + await this.deleteChannel(id); + return this.createChannel(name, decl, refs); + } + const body = this.mapChannel(name, decl, refs); + delete body.channel_type; + const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record; + return toRemoteResource(res); + } + + async deleteChannel(id: string): Promise { + await this.forwardClient.delete(`/channels/${id}`); + } + + private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record { + return { + identity_id: refs.identity_id, + template_id: refs.agent_id, + channel_type: decl.type, + name: decl.name ?? name, + enabled: decl.enabled ?? true, + channel_config: { + credentials: decl.credentials, + response_options: { + include_tool_calls: false, + include_thinking: false, + ...(decl.options ?? {}), + }, + }, + }; + } + private async registerForwardVaults(vaultIds: string[]): Promise { for (const id of vaultIds) { await this.forwardClient.post("/resources/registry", { @@ -525,9 +659,11 @@ export class QoderAdapter implements ProviderAdapter { async createSession(bindings: SessionBindings): Promise { if (bindings.delivery === "forward") { - const identityId = bindings.identity_id ?? (await this.resolveDefaultForwardIdentityId()); + if (!bindings.identity_id) { + throw new UserError("Qoder Forward sessions require an explicit resolved identity_id."); + } const body: Record = { - identity_id: identityId, + identity_id: bindings.identity_id, template_id: bindings.template_id, incremental_streaming_enabled: false, }; @@ -550,38 +686,6 @@ export class QoderAdapter implements ProviderAdapter { return toSessionInfo(res); } - private async resolveDefaultForwardIdentityId(): Promise { - if (this.defaultForwardIdentityId) return this.defaultForwardIdentityId; - - let afterId: string | undefined; - do { - const params = new URLSearchParams({ limit: "100" }); - if (afterId) params.set("after_id", afterId); - const res = (await this.forwardClient.get(`/identities?${params}`)) as Record; - const identities = (res.data ?? []) as Record[]; - const match = identities.find( - (identity) => - identity.external_id === QODER_DEFAULT_IDENTITY_EXTERNAL_ID && - identity.enabled !== false && - identity.archived !== true, - ); - if (typeof match?.id === "string") { - this.defaultForwardIdentityId = match.id; - return match.id; - } - - const hasMore = (res.has_more as boolean | undefined) ?? false; - const nextId = hasMore ? ((res.last_id as string | null | undefined) ?? undefined) : undefined; - if (!nextId || nextId === afterId) break; - afterId = nextId; - } while (afterId); - - throw new UserError( - `Qoder default Forward Identity '${QODER_DEFAULT_IDENTITY_EXTERNAL_ID}' was not found. ` + - `Ask Qoder to provision it, set defaults.session.qoder.identity_id, or pass --identity-id.`, - ); - } - async listSessions(filter?: SessionFilter): Promise { if (filter?.agent_id?.startsWith("tmpl_")) { const params = new URLSearchParams({ template_id: filter.agent_id }); diff --git a/packages/sdk/src/internal/providers/qoder/capabilities.ts b/packages/sdk/src/internal/providers/qoder/capabilities.ts index 000c2f7..8d2b49e 100644 --- a/packages/sdk/src/internal/providers/qoder/capabilities.ts +++ b/packages/sdk/src/internal/providers/qoder/capabilities.ts @@ -18,4 +18,6 @@ export const QODER_CAPABILITIES: ProviderCapabilities = { reason: "deployments API with scheduled and manual runs", }, session: { tier: "native", reason: "sessions API" }, + identity: { tier: "native", reason: "Forward Identities API" }, + channel: { tier: "native", reason: "Forward Channels API" }, }; diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index a7ea6d5..7305d1c 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -1,7 +1,9 @@ import type { AgentDecl, + ChannelDecl, DeploymentDecl, EnvironmentDecl, + IdentityDecl, MemoryStoreDecl, SkillDecl, VaultDecl, @@ -17,6 +19,7 @@ import type { DriftSupport, RemoteResource, ResolvedAgentRefs, + ResolvedChannelRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs, } from "./interface.ts"; @@ -51,6 +54,14 @@ export interface ResourceCrudAdapter { updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; archiveTemplate?(id: string): Promise; + createIdentity?(name: string, decl: IdentityDecl): Promise; + updateIdentity?(id: string, name: string, decl: IdentityDecl): Promise; + deleteIdentity?(id: string): Promise; + + createChannel?(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise; + updateChannel?(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise; + deleteChannel?(id: string): Promise; + // Optional: only providers whose capability matrix marks `memory_store` supported // implement these. The registry validates the matrix↔method match; unsupported // providers omit them entirely (no throw-stubs). diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index dd050f4..dc9a872 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -6,7 +6,7 @@ import type { AgentDecl, ProjectConfig } from "../types/config.ts"; import type { SessionBindings } from "../types/session.ts"; export interface SessionCreateOptions { - /** Explicit Qoder Forward Identity id. Overrides defaults.session.qoder.identity_id. */ + /** Explicit provider Identity id. Overrides the declared defaults.identity reference. */ identityId?: string; environment?: string; /** @@ -69,11 +69,19 @@ export function buildSessionBindings( } if (resolveAgentMaterialization(provider, agent).resourceType === "template") { const templateId = requireRef(state, { type: "template", name: agentName, provider }); - const identityId = options.identityId ?? config.defaults?.session?.qoder?.identity_id; + const defaultIdentity = config.defaults?.identity; + const identityId = + options.identityId ?? + (defaultIdentity ? requireRef(state, { type: "identity", name: defaultIdentity, provider }) : undefined); + if (!identityId) { + throw new UserError( + `Forward session for '${agentName}' requires an Identity. Configure defaults.identity or pass --identity-id.`, + ); + } return { delivery: "forward", template_id: templateId, - ...(identityId ? { identity_id: identityId } : {}), + identity_id: identityId, files: (options.files ?? []).map((file) => ({ file_id: file.fileId, mount_path: file.mountPath })), title: options.title, metadata: options.metadata, diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 82c5e8f..679f5f1 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -10,18 +10,39 @@ export interface ProjectConfig { memory_stores?: Record; skills?: Record; files?: Record; + identities?: Record; agents?: Record; + channels?: Record; deployments?: Record; } export interface DefaultsConfig { provider?: string; - session?: { - qoder?: { - /** Existing Qoder Forward Identity used only as the CLI/session runtime default. */ - identity_id?: string; - }; - }; + /** Logical name of the default declared Identity used by identity-aware resources and runtimes. */ + identity?: string; +} + +// --- Identity --- + +export type IdentityDecl = ManagedIdentityDecl | ExternalIdentityDecl; + +export interface ManagedIdentityDecl { + provider?: ProviderName; + external_id: string; + name?: string; + enabled?: boolean; + metadata?: Record; + identity_id?: never; +} + +export interface ExternalIdentityDecl { + provider?: ProviderName; + /** Pre-existing provider Identity id. External references are never mutated or deleted. */ + identity_id: string; + external_id?: never; + name?: never; + enabled?: never; + metadata?: never; } // --- Environment --- @@ -157,6 +178,21 @@ export interface AgentDeliveryDecl { type: "managed" | "forward"; } +// --- Channel --- + +export interface ChannelDecl { + provider?: ProviderName; + /** Logical Agent name. The provider adapter resolves its materialized remote resource. */ + agent: string; + /** Logical Identity name. Falls back to defaults.identity. */ + identity?: string; + type: string; + name?: string; + enabled?: boolean; + credentials?: Record; + options?: Record; +} + export type AgentSkillDecl = string | AgentSkillRefDecl; export interface AgentSkillRefDecl { diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index bbae310..d550307 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -9,6 +9,8 @@ export const ResourceTypeSchema = z.enum([ "template", "deployment", "file", + "identity", + "channel", ]); export type ResourceType = z.infer; diff --git a/packages/sdk/tests/unit/provider-conformance.test.ts b/packages/sdk/tests/unit/provider-conformance.test.ts index 8909051..6c5dbf2 100644 --- a/packages/sdk/tests/unit/provider-conformance.test.ts +++ b/packages/sdk/tests/unit/provider-conformance.test.ts @@ -9,6 +9,9 @@ const RESOURCE_KIND_METHODS: Record { ]); }); - test("resolves Qoder's system Identity external_id to its real id and caches it", async () => { - const calls: Array<{ method: string; path: string; body?: Record }> = []; + test("requires the core runtime to resolve an explicit Identity", async () => { const adapter = new QoderAdapter("pt-test") as any; adapter.forwardClient = { - get: async (path: string) => { - calls.push({ method: "GET", path }); - if (path === "/identities?limit=100") { - return { - data: [{ id: "idn_other", external_id: "other" }], - has_more: true, - last_id: "idn_other", - }; - } - if (path === "/identities?limit=100&after_id=idn_other") { - return { - data: [{ id: "idn_admin", external_id: "__qca_admin_identity__", enabled: true }], - has_more: false, - }; - } - throw new Error(`unexpected GET ${path}`); - }, - post: async (path: string, body: Record) => { - expect(path).toBe("/sessions"); - calls.push({ method: "POST", path, body }); - return { - id: `sess_default_identity_${calls.length}`, - status: "idle", - template: { id: "tmpl_1" }, - identity_id: "idn_admin", - created_at: "2026-01-01T00:00:00Z", - }; - }, - }; - - await adapter.createSession({ delivery: "forward", template_id: "tmpl_1" }); - await adapter.createSession({ delivery: "forward", template_id: "tmpl_1" }); - - expect(calls.map(({ method, path }) => `${method} ${path}`)).toEqual([ - "GET /identities?limit=100", - "GET /identities?limit=100&after_id=idn_other", - "POST /sessions", - "POST /sessions", - ]); - expect(calls.filter((call) => call.method === "POST").map((call) => call.body?.identity_id)).toEqual([ - "idn_admin", - "idn_admin", - ]); - }); - - test("fails clearly when Qoder's system Identity is not provisioned", async () => { - const adapter = new QoderAdapter("pt-test") as any; - adapter.forwardClient = { - get: async () => ({ data: [], has_more: false }), post: async () => { throw new Error("session must not be created"); }, }; await expect(adapter.createSession({ delivery: "forward", template_id: "tmpl_1" })).rejects.toThrow( - /__qca_admin_identity__.*--identity-id/, + /explicit resolved identity_id/, ); }); }); @@ -414,16 +364,19 @@ describe("Forward delivery validation and runtime isolation", () => { test("builds Forward session bindings from the explicit YAML identity default", () => { const config = forwardConfig(); - config.defaults = { - provider: "qoder", - session: { qoder: { identity_id: "idn_zhang" } }, - } as ProjectConfig["defaults"]; + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; const state = StateManager.initialize(tmpPath("forward-session")); state.setResource({ address: { type: "template", name: "assistant", provider: "qoder" }, remote_id: "tmpl_1", content_hash: "h", }); + state.setResource({ + address: { type: "identity", name: "zhang", provider: "qoder" }, + remote_id: "idn_zhang", + content_hash: "h", + }); expect(buildSessionBindings("assistant", config, "qoder", state)).toMatchObject({ delivery: "forward", template_id: "tmpl_1", @@ -433,16 +386,19 @@ describe("Forward delivery validation and runtime isolation", () => { test("uses different caller Identities with the same applied Template", () => { const config = forwardConfig(); - config.defaults = { - provider: "qoder", - session: { qoder: { identity_id: "idn_zhang" } }, - }; + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; const state = StateManager.initialize(tmpPath("forward-multi-user")); state.setResource({ address: { type: "template", name: "assistant", provider: "qoder" }, remote_id: "tmpl_shared", content_hash: "h", }); + state.setResource({ + address: { type: "identity", name: "zhang", provider: "qoder" }, + remote_id: "idn_zhang", + content_hash: "h", + }); const zhang = buildSessionBindings("assistant", config, "qoder", state); const li = buildSessionBindings("assistant", config, "qoder", state, { identityId: "idn_li" }); @@ -451,7 +407,7 @@ describe("Forward delivery validation and runtime isolation", () => { expect(li).toMatchObject({ template_id: "tmpl_shared", identity_id: "idn_li" }); }); - test("allows Qoder to use its default Identity when none is configured", () => { + test("rejects Forward sessions when no declared Identity is configured", () => { const config = forwardConfig(); const state = StateManager.initialize(tmpPath("forward-missing-identity")); state.setResource({ @@ -460,12 +416,6 @@ describe("Forward delivery validation and runtime isolation", () => { content_hash: "h", }); - expect(buildSessionBindings("assistant", config, "qoder", state)).toEqual({ - delivery: "forward", - template_id: "tmpl_1", - files: [], - title: undefined, - metadata: undefined, - }); + expect(() => buildSessionBindings("assistant", config, "qoder", state)).toThrow(/defaults.identity/); }); }); diff --git a/packages/sdk/tests/unit/qoder-identity-channel.test.ts b/packages/sdk/tests/unit/qoder-identity-channel.test.ts new file mode 100644 index 0000000..6663221 --- /dev/null +++ b/packages/sdk/tests/unit/qoder-identity-channel.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { executePlan } from "../../src/internal/executor/executor.ts"; +import { buildDependencyGraph } from "../../src/internal/graph/dependency.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; +import "../../src/internal/providers/all.ts"; + +function config(): ProjectConfig { + return { + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder", identity: "chen" }, + identities: { + chen: { + external_id: "user_456", + name: "Chen", + metadata: { team: "engineering" }, + }, + }, + environments: { + byoc: { environment_id: "env_byoc", config: { type: "self_hosted" } }, + }, + agents: { + assistant: { + model: { qoder: "auto" }, + instructions: "Help the user.", + environment: "byoc", + delivery: { qoder: { type: "forward" } }, + }, + }, + channels: { + dingtalk: { + agent: "assistant", + type: "dingtalk", + credentials: { client_id: "client", client_secret: "secret" }, + }, + }, + }; +} + +describe("Identity and Channel declarations", () => { + test("plans Identity and Template before Channel", async () => { + const desired = config(); + const graph = buildDependencyGraph(desired, ["qoder"]); + expect([...graph.edges.get("qoder.channel.dingtalk")!].sort()).toEqual([ + "qoder.identity.chen", + "qoder.template.assistant", + ]); + + const plan = await buildPlan(desired, { resources: [] }); + expect(plan.diagnostics).toEqual([]); + const actions = plan.actions.map((action) => `${action.address.type}.${action.address.name}`); + expect(actions.indexOf("identity.chen")).toBeLessThan(actions.indexOf("channel.dingtalk")); + expect(actions.indexOf("template.assistant")).toBeLessThan(actions.indexOf("channel.dingtalk")); + }); + + test("keeps unsupported Provider capabilities isolated", async () => { + const desired = config(); + desired.providers = { claude: {} }; + desired.defaults = { provider: "claude", identity: "chen" }; + const plan = await buildPlan(desired, { resources: [] }); + expect(plan.diagnostics.some((item) => item.code === "claude.identity.unsupported")).toBe(true); + expect(plan.diagnostics.some((item) => item.code === "claude.channel.unsupported")).toBe(true); + expect(plan.actions.some((item) => item.address.type === "identity" || item.address.type === "channel")).toBe( + false, + ); + }); + + test("records an external Identity reference without mutating or deleting it", async () => { + const desired: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder", identity: "chen" }, + identities: { chen: { identity_id: "idn_external" } }, + }; + const calls: string[] = []; + const provider = { + name: "qoder", + findResource: async () => ({ id: "idn_external", type: "identity" }), + createIdentity: async () => { + calls.push("create"); + return { id: "unexpected", type: "identity" }; + }, + updateIdentity: async () => { + calls.push("update"); + return { id: "unexpected", type: "identity" }; + }, + deleteIdentity: async () => calls.push("delete"), + } as unknown as ProviderAdapter; + const state = StateManager.initialize(join(tmpdir(), `identity-ref-${crypto.randomUUID()}.json`)); + const ctx = { + config: desired, + configPath: "/tmp/agents.yaml", + providers: new Map([["qoder", provider]]), + state, + }; + + const createPlan = await buildPlan(desired, state.getStateFile()); + await executePlan(createPlan, ctx); + expect(state.getResource({ type: "identity", name: "chen", provider: "qoder" })).toMatchObject({ + remote_id: "idn_external", + externally_managed: true, + }); + + ctx.config = { ...desired, defaults: { provider: "qoder" }, identities: undefined }; + const deletePlan = await buildPlan(ctx.config, state.getStateFile()); + await executePlan(deletePlan, ctx); + expect(calls).toEqual([]); + }); +}); + +describe("Qoder Identity and Channel adapter", () => { + test("maps managed Identity lifecycle to Forward endpoints", async () => { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async (path: string) => { + calls.push({ method: "GET", path }); + return { id: "idn_1", metadata: { old: "value" } }; + }, + post: async (path: string, body: unknown) => { + calls.push({ method: "POST", path, body }); + return { id: "idn_1", type: "identity" }; + }, + delete: async (path: string) => calls.push({ method: "DELETE", path }), + }; + + const decl = { external_id: "user_456", name: "Chen", metadata: { team: "engineering" } } as const; + await adapter.createIdentity("chen", decl); + await adapter.updateIdentity("idn_1", "chen", decl); + await adapter.deleteIdentity("idn_1"); + + expect(calls[0]).toMatchObject({ + method: "POST", + path: "/identities", + body: { external_id: "user_456", name: "Chen", enabled: true, metadata: { team: "engineering" } }, + }); + expect(calls[2]).toMatchObject({ + method: "POST", + path: "/identities/idn_1", + body: { metadata: { team: "engineering", old: "" } }, + }); + expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/identities/idn_1" }); + }); + + test("maps logical references and generic Channel fields to Qoder wire fields", async () => { + const calls: Array<{ method: string; path: string; body?: any }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + post: async (path: string, body: unknown) => { + calls.push({ method: "POST", path, body }); + return { id: "channel_1", type: "channel" }; + }, + delete: async (path: string) => calls.push({ method: "DELETE", path }), + }; + + await adapter.createChannel( + "dingtalk", + { + agent: "assistant", + type: "dingtalk", + credentials: { client_id: "client", client_secret: "secret" }, + options: { include_thinking: true }, + }, + { identity_id: "idn_1", agent_id: "tmpl_1" }, + ); + + expect(calls[0]).toMatchObject({ + method: "POST", + path: "/channels", + body: { + identity_id: "idn_1", + template_id: "tmpl_1", + channel_type: "dingtalk", + name: "dingtalk", + enabled: true, + channel_config: { + credentials: { client_id: "client", client_secret: "secret" }, + response_options: { include_tool_calls: false, include_thinking: true }, + }, + }, + }); + }); +});