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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Task-oriented, with steps and verification.
| [Configure an agent](./guides/configure-an-agent.md) | Build an `agents.yaml` from minimal to full stack. |
| [Deploy to Bailian](./guides/deploy-to-bailian.md) | Bailian (Aliyun AgentStudio) setup and notes. |
| [Deploy to Qoder](./guides/deploy-to-qoder.md) | Qoder-specific setup and notes. |
| [Use BYOC environments](./guides/use-byoc-environments.md) | Connect Qoder sessions to administrator-provisioned self-hosted environments and private-network tunnels. |
| [Deploy to Claude](./guides/deploy-to-claude.md) | Claude-specific setup and notes. |
| [Deploy to Volcengine Ark](./guides/deploy-to-ark.md) | Volcengine Ark (Managed Agents) setup and notes. |
| [Use skills](./guides/use-skills.md) | Author and attach reusable capability modules. |
Expand Down
97 changes: 97 additions & 0 deletions docs/guides/use-byoc-environments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Use BYOC environments

Use a bring-your-own-cloud (BYOC) environment when an administrator has already provisioned a self-hosted Qoder environment and, optionally, a tunnel to private services. OpenCMA references those resources; it does not own their lifecycle.

## Prerequisites

Ask the environment administrator for:

- an environment ID, such as `env_00xxxx`;
- a tunnel ID, such as `tnl_00xxxx`, when the agent needs access to private services;
- the Qoder credentials needed to manage the agent and start sessions.

Do not commit real IDs, private hostnames, or credentials. Use environment variables for credentials and keep live deployment configurations outside the repository.

## Configure the external resources

Declare the provisioned environment with `environment_id` and `self_hosted`. Declare a tunnel by its existing ID, then reference both from the agent.

```yaml
version: "1"

providers:
qoder:
api_key: ${QODER_PAT}

defaults:
provider: qoder

environments:
byoc-environment:
environment_id: env_00xxxx
config:
type: self_hosted

tunnels:
internal-network:
tunnel_id: tnl_00xxxx

agents:
private-service-assistant:
model: qmodel_latest
instructions: You can use the configured private services.
environment: byoc-environment
tunnel: internal-network
tools:
builtin: [Bash, Read]
```

`tunnel` is supported only for Qoder BYOC sessions and deployments. Omit the entire `tunnels` section and the agent's `tunnel` field when no private-network tunnel is needed.

## Apply and run

Apply the configuration to provision or update managed resources such as the agent. The declared environment and tunnel are only recorded as references.

```bash
agents apply -f agents.yaml
agents session run "Check the private service status" --agent private-service-assistant -f agents.yaml
```

You can override the configured IDs for a one-off session without changing the YAML:

```bash
agents session run "Check the private service status" \
--agent private-service-assistant \
--environment-id env_00xxxx \
--tunnel-id tnl_00xxxx \
-f agents.yaml
```

## Lifecycle and cleanup

When `environment_id` is present, OpenCMA never creates, updates, or remotely deletes that environment. Tunnels are always references and are never managed by OpenCMA.

OpenCMA records external ownership in its local state, and the record is sticky:

- Remove the environment declaration entirely and run `agents apply` or `agents destroy`: only the local state record is removed; the administrator-managed environment remains intact.
- Remove only the `environment_id` line while keeping the environment block: `plan`/`apply` fail with an `plan.environment.ownership_transition` error. Silently converting the reference into a managed resource would let OpenCMA modify — and eventually delete — a remote environment it does not own. To take over management deliberately, release the reference first with `agents state rm environment.<name>` and adopt the remote with `agents state import`.
- Point `environment_id` at a different existing environment: the reference is re-recorded and any deployments using it are updated. If the environment was previously managed by OpenCMA under a different remote ID, `plan` warns that the old remote is no longer tracked.

Deleting an agent, session, vault, or other managed resource still follows its normal lifecycle. Use the provider's administrator tooling to modify or delete BYOC environments and tunnels.

## Deployments and tunnels

A deployment that references the BYOC environment runs in that environment, and changing the referenced `environment_id` value updates the deployment on the next `apply`. Qoder's deployment API currently rejects `tunnel_id`, however, so a declared (or agent-inherited) tunnel is not sent with the deployment: scheduled and triggered runs execute without the tunnel, and `validate`/`plan` emits a `qoder.deployment.tunnel.unsupported` warning. Use sessions for workloads that need private-network MCP access.

## Troubleshooting

| Symptom | Check |
|--------|-------|
| Session cannot reach a private service | Confirm the supplied tunnel ID is enabled for the environment and that the service hostname is reachable from the private network. |
| `Tunnel '...' is not defined in config` | Add the name under `tunnels`, or pass `--tunnel-id` for a one-off session. |
| Tunnel unsupported diagnostic | Ensure the agent or deployment targets Qoder; tunnels are not sent to other providers. |
| Environment cannot be resolved | Verify the administrator-provided `environment_id` and the agent's `environment` reference. |
| `plan.environment.ownership_transition` error | Restore `environment_id`, or release the reference with `agents state rm environment.<name>` before managing the environment with OpenCMA. |
| `qoder.deployment.tunnel.unsupported` warning | Expected: Qoder deployments cannot carry a tunnel today. Use sessions for private-network MCP access. |

For field definitions, see the [configuration reference](../reference/configuration.md).
23 changes: 21 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ version: "1"
providers: { <name>: <provider-config> }
defaults: { provider: <name> | "all" }
environments: { <name>: EnvironmentDecl }
tunnels: { <name>: TunnelDecl }
vaults: { <name>: VaultDecl }
memory_stores:{ <name>: MemoryStoreDecl }
skills: { <name>: SkillDecl }
Expand All @@ -23,6 +24,7 @@ deployments: { <name>: DeploymentDecl }
| `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. |
| `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. |
Expand Down Expand Up @@ -72,23 +74,35 @@ environments:
name: <string> # optional
description: <string> # optional
provider: <string> # optional; pin to one provider
environment_id: <string> # optional; reference an existing provider environment without managing it
config:
type: cloud
type: cloud | self_hosted
networking: { ... }
packages: { ... }
metadata: { <key>: <string> }
```

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `config.type` | `"cloud"` | yes | Environment type. |
| `environment_id` | string | no | Existing environment ID. When present, OpenCMA never creates, updates, or deletes the remote environment. Removing this line later is blocked as an ownership error — release first with `agents state rm` (see [Use BYOC environments](../guides/use-byoc-environments.md)). |
| `config.type` | `"cloud"` \| `"self_hosted"` | yes | Environment type. `self_hosted` is used for Qoder BYOC. |
| `config.networking.type` | `"unrestricted"` \| `"limited"` | no | Network policy. |
| `config.networking.allow_mcp_servers` | boolean | no | Allow outbound MCP. |
| `config.networking.allow_package_managers` | boolean | no | Allow package managers. |
| `config.networking.allowed_hosts` | string[] | no | Allow-list for `limited` networks. |
| `config.packages.apt` \| `pip` \| `npm` \| `cargo` \| `gem` \| `go` | string[] | no | Preinstalled packages. |
| `metadata` | map<string,string> | no | Free-form metadata. |

## Tunnel (Qoder BYOC)

```yaml
tunnels:
internal-network:
tunnel_id: tnl_00xxxx
```

Tunnels are existing Qoder resources allocated by the BYOC administrator. They are passed only when a Qoder session/deployment is created and are never created, updated, or deleted by OpenCMA. See [Use BYOC environments](../guides/use-byoc-environments.md) for a complete setup and lifecycle guide.

## Vault

```yaml
Expand Down Expand Up @@ -166,6 +180,7 @@ agents:
model: <string> | { <provider>: <string> }
instructions: <string> | <path>
environment: <string>
tunnel: <string> # optional; Qoder BYOC tunnel name
provider: <string>
tools: { builtin: [...], mcp: [...], permissions: {...} }
mcp_servers: [ { name, type?, url? } ]
Expand All @@ -181,6 +196,7 @@ agents:
| `model` | string \| map<provider,string> | yes | Single model or a per-provider map. |
| `instructions` | string | yes | Inline text or a path to a file (resolved relative to the config). |
| `environment` | string | no | Environment name. |
| `tunnel` | string | no | Qoder BYOC tunnel name from `tunnels`; unsupported for other providers. |
| `provider` | string | no | Pin the agent to one provider. |
| `tools.builtin` | string[] | yes (in `tools`) | Lowercase tool names. |
| `tools.permissions` | map<string,`"allow"`\|`"ask"`> | no | Per-tool permission policy. |
Expand Down Expand Up @@ -214,6 +230,7 @@ deployments:
agent: <string>
agent_version: <number> # optional
environment: <string> # optional
tunnel: <string> # optional; Qoder BYOC only (see note below)
vaults: [ <string> ]
memory_stores: [ <string> ]
resources: [ DeploymentResource ]
Expand All @@ -226,6 +243,8 @@ deployments:

`initial_events` is a discriminated union; `schedule.expression` must be a 5-field cron expression.

> **Deployment `tunnel` caveat:** Qoder's deployment API does not accept `tunnel_id`, so the tunnel is dropped from the deployment payload and server-side runs execute without it (`validate`/`plan` emits a warning). Use sessions for private-network MCP access; see [Use BYOC environments](../guides/use-byoc-environments.md).

### Initial events

| Type | Fields |
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ function stopResourceSpinner(spinner: ReturnType<typeof p.spinner> | undefined,
}

if (result.status === "success") {
if (result.reason === "already_gone") {
if (result.reason === "reference_removed") {
spinner.stop(chalk.green(`✓ ${label} — local reference removed (remote left intact)`));
} else if (result.reason === "already_gone") {
spinner.stop(chalk.yellow(`⊘ ${label} — already deleted remotely, cleaned up state`));
} else if (result.cascaded) {
spinner.stop(chalk.green(`✓ ${label} — destroyed (cascaded)`));
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ interface SessionCreateOpts {
file: string;
agent?: string;
environment?: string;
environmentId?: string;
tunnel?: string;
tunnelId?: string;
vault?: string;
memoryStores?: string;
title?: string;
Expand All @@ -67,6 +70,9 @@ export async function sessionCreateCommand(
agent: positionalAgent ?? options.agent,
provider: options.provider,
environment: options.environment,
environmentId: options.environmentId,
tunnel: options.tunnel,
tunnelId: options.tunnelId,
vault: options.vault,
memoryStores: parseMemoryStores(options.memoryStores),
title: options.title,
Expand All @@ -75,6 +81,7 @@ export async function sessionCreateCommand(
log.success(`Session created: ${chalk.bold(session.id)}`);
console.log(` Agent: ${agentName}`);
console.log(` Environment: ${session.environment_id}`);
if (session.tunnel_id) console.log(` Tunnel: ${session.tunnel_id}`);
console.log(` Status: ${session.status}`);
if (session.vault_ids.length) console.log(` Vaults: ${session.vault_ids.join(", ")}`);
if (session.memory_store_ids.length) console.log(` Memory: ${session.memory_store_ids.join(", ")}`);
Expand Down Expand Up @@ -163,6 +170,7 @@ export async function sessionGetCommand(sessionId: string, options: SessionGetOp
console.log(` ID: ${chalk.bold(session.id)}`);
console.log(` Agent: ${session.agent_id}`);
console.log(` Environment: ${session.environment_id}`);
if (session.tunnel_id) console.log(` Tunnel: ${session.tunnel_id}`);
console.log(` Status: ${session.status}`);
if (session.title) console.log(` Title: ${session.title}`);
if (session.vault_ids.length) console.log(` Vaults: ${session.vault_ids.join(", ")}`);
Expand Down Expand Up @@ -248,6 +256,9 @@ interface SessionRunOpts {
file: string;
agent?: string;
environment?: string;
environmentId?: string;
tunnel?: string;
tunnelId?: string;
vault?: string;
memoryStores?: string;
title?: string;
Expand All @@ -273,6 +284,9 @@ export async function sessionRunCommand(
agent: positionalAgent ?? options.agent,
provider: options.provider,
environment: options.environment,
environmentId: options.environmentId,
tunnel: options.tunnel,
tunnelId: options.tunnelId,
vault: options.vault,
memoryStores: parseMemoryStores(options.memoryStores),
title: options.title,
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ sessionCmd
.addOption(configFileOption())
.option("--agent <name>", "Agent name (auto-detected when only one agent is configured)")
.option("--environment <name>", "Override agent's declared environment")
.option("--environment-id <id>", "Use an explicit remote environment id instead of the configured one")
.option("--tunnel <name>", "Override agent's declared tunnel")
.option("--tunnel-id <id>", "Use an explicit remote tunnel id instead of the configured one")
.option("--vault <name>", "Override agent's declared vault")
.option("--memory-stores <names>", "Override agent's declared memory stores (comma-separated)")
.option("--title <title>", "Session title")
Expand Down Expand Up @@ -229,6 +232,9 @@ sessionCmd
.addOption(configFileOption())
.option("--agent <name>", "Agent name (auto-detected when only one agent is configured)")
.option("--environment <name>", "Override agent's declared environment")
.option("--environment-id <id>", "Use an explicit remote environment id instead of the configured one")
.option("--tunnel <name>", "Override agent's declared tunnel")
.option("--tunnel-id <id>", "Use an explicit remote tunnel id instead of the configured one")
.option("--vault <name>", "Override agent's declared vault")
.option("--memory-stores <names>", "Override agent's declared memory stores (comma-separated)")
.option("--title <title>", "Session title")
Expand Down
21 changes: 20 additions & 1 deletion packages/sdk/src/internal/core/destroy-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type DestroyResourceStatus = "success" | "failed" | "blocked" | "skipped"

export type DestroyResourceResultReason =
| "destroyed"
| "reference_removed"
| "already_gone"
| "cascade_required"
| "provider_missing"
Expand Down Expand Up @@ -95,6 +96,14 @@ async function destroyOneResource(
resource: ResourceState,
options: DestroyProjectOptions,
): Promise<DestroyResourceResult> {
// 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)) {
ctx.state.removeResource(resource.address);
return successResult(resource, "reference_removed");
}

let provider: ProviderAdapter;
try {
provider = getRuntimeProvider(ctx, resource.address.provider);
Expand Down Expand Up @@ -160,7 +169,17 @@ async function destroyOneResource(
}
}

function successResult(resource: ResourceState, reason: "destroyed" | "already_gone"): DestroyResourceResult {
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 successResult(
resource: ResourceState,
reason: "destroyed" | "reference_removed" | "already_gone",
): DestroyResourceResult {
return { resource, status: "success", reason };
}

Expand Down
18 changes: 17 additions & 1 deletion packages/sdk/src/internal/core/resource-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { getResourceDeclaration } from "../planner/declaration.ts";
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
import { buildPlan } from "../planner/planner.ts";
import { type RefreshResult, refreshState } from "../planner/refresh.ts";
import { readComparableIfSupported } from "../providers/drift-support.ts";
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts";
import type { ResourceAddress, ResourceState, ResourceType } from "../types/state.ts";
import { contentHash as stableContentHash } from "../utils/hash.ts";
import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts";
import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";

Expand Down Expand Up @@ -122,13 +124,27 @@ export async function importResource(
throw new UserError(`Planned ${address.type}.${address.name} is missing a content hash.`);
}

// Read back the actual remote state as the drift baseline. Without it the
// first plan after import would compare the remote against the *desired*
// comparable and report a one-time phantom "Remote drift detected" — most
// visibly for external-reference environments OpenCMA never configured.
const provider = ctx.providers.get(address.provider);
const remote = provider ? await readComparableIfSupported(provider, address.type, remoteId, address.name) : null;
const remoteHash = remote ? stableContentHash(remote.comparable) : undefined;

const resource: ResourceState = {
address,
remote_id: remoteId,
version: options.resourceVersion,
externally_managed:
address.type === "environment" && ctx.config.environments?.[address.name]?.environment_id ? true : undefined,
version: options.resourceVersion ?? remote?.version,
content_hash: contentHash,
desired_hash: contentHash,
desired_comparable_hash: remoteHash,
desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)),
remote_hash: remoteHash,
remote_snapshot: remote ? (remote.snapshot ?? remote.comparable) : undefined,
drift_status: remoteHash ? "in_sync" : undefined,
};
ctx.state.setResource(resource);
await ctx.state.save();
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export async function createSessionForAgent(
const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, {
environment: options.environment,
environmentId: options.environmentId,
tunnel: options.tunnel,
tunnelId: options.tunnelId,
vault: options.vault,
vaultIds: options.vaultIds,
memoryStores: options.memoryStores,
Expand All @@ -162,6 +164,8 @@ export async function startSessionRun(
const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, {
environment: options.environment,
environmentId: options.environmentId,
tunnel: options.tunnel,
tunnelId: options.tunnelId,
vault: options.vault,
vaultIds: options.vaultIds,
memoryStores: options.memoryStores,
Expand Down
Loading