Skip to content

Commit 660a187

Browse files
authored
feat(qoder): support BYOC environments and tunnels (#31)
* add Qoder BYOC tunnel support Change-Id: I62f1808fe82cde450a11e477b40109575b22485b * harden external environment ownership Change-Id: I464f314bc786dd488092eb09eb631c07b4274403
1 parent 974e447 commit 660a187

40 files changed

Lines changed: 1385 additions & 38 deletions

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Task-oriented, with steps and verification.
2828
| [Configure an agent](./guides/configure-an-agent.md) | Build an `agents.yaml` from minimal to full stack. |
2929
| [Deploy to Bailian](./guides/deploy-to-bailian.md) | Bailian (Aliyun AgentStudio) setup and notes. |
3030
| [Deploy to Qoder](./guides/deploy-to-qoder.md) | Qoder-specific setup and notes. |
31+
| [Use BYOC environments](./guides/use-byoc-environments.md) | Connect Qoder sessions to administrator-provisioned self-hosted environments and private-network tunnels. |
3132
| [Deploy to Claude](./guides/deploy-to-claude.md) | Claude-specific setup and notes. |
3233
| [Deploy to Volcengine Ark](./guides/deploy-to-ark.md) | Volcengine Ark (Managed Agents) setup and notes. |
3334
| [Use skills](./guides/use-skills.md) | Author and attach reusable capability modules. |
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Use BYOC environments
2+
3+
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.
4+
5+
## Prerequisites
6+
7+
Ask the environment administrator for:
8+
9+
- an environment ID, such as `env_00xxxx`;
10+
- a tunnel ID, such as `tnl_00xxxx`, when the agent needs access to private services;
11+
- the Qoder credentials needed to manage the agent and start sessions.
12+
13+
Do not commit real IDs, private hostnames, or credentials. Use environment variables for credentials and keep live deployment configurations outside the repository.
14+
15+
## Configure the external resources
16+
17+
Declare the provisioned environment with `environment_id` and `self_hosted`. Declare a tunnel by its existing ID, then reference both from the agent.
18+
19+
```yaml
20+
version: "1"
21+
22+
providers:
23+
qoder:
24+
api_key: ${QODER_PAT}
25+
26+
defaults:
27+
provider: qoder
28+
29+
environments:
30+
byoc-environment:
31+
environment_id: env_00xxxx
32+
config:
33+
type: self_hosted
34+
35+
tunnels:
36+
internal-network:
37+
tunnel_id: tnl_00xxxx
38+
39+
agents:
40+
private-service-assistant:
41+
model: qmodel_latest
42+
instructions: You can use the configured private services.
43+
environment: byoc-environment
44+
tunnel: internal-network
45+
tools:
46+
builtin: [Bash, Read]
47+
```
48+
49+
`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.
50+
51+
## Apply and run
52+
53+
Apply the configuration to provision or update managed resources such as the agent. The declared environment and tunnel are only recorded as references.
54+
55+
```bash
56+
agents apply -f agents.yaml
57+
agents session run "Check the private service status" --agent private-service-assistant -f agents.yaml
58+
```
59+
60+
You can override the configured IDs for a one-off session without changing the YAML:
61+
62+
```bash
63+
agents session run "Check the private service status" \
64+
--agent private-service-assistant \
65+
--environment-id env_00xxxx \
66+
--tunnel-id tnl_00xxxx \
67+
-f agents.yaml
68+
```
69+
70+
## Lifecycle and cleanup
71+
72+
When `environment_id` is present, OpenCMA never creates, updates, or remotely deletes that environment. Tunnels are always references and are never managed by OpenCMA.
73+
74+
OpenCMA records external ownership in its local state, and the record is sticky:
75+
76+
- 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.
77+
- 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`.
78+
- 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.
79+
80+
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.
81+
82+
## Deployments and tunnels
83+
84+
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.
85+
86+
## Troubleshooting
87+
88+
| Symptom | Check |
89+
|--------|-------|
90+
| 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. |
91+
| `Tunnel '...' is not defined in config` | Add the name under `tunnels`, or pass `--tunnel-id` for a one-off session. |
92+
| Tunnel unsupported diagnostic | Ensure the agent or deployment targets Qoder; tunnels are not sent to other providers. |
93+
| Environment cannot be resolved | Verify the administrator-provided `environment_id` and the agent's `environment` reference. |
94+
| `plan.environment.ownership_transition` error | Restore `environment_id`, or release the reference with `agents state rm environment.<name>` before managing the environment with OpenCMA. |
95+
| `qoder.deployment.tunnel.unsupported` warning | Expected: Qoder deployments cannot carry a tunnel today. Use sessions for private-network MCP access. |
96+
97+
For field definitions, see the [configuration reference](../reference/configuration.md).

docs/reference/configuration.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ version: "1"
99
providers: { <name>: <provider-config> }
1010
defaults: { provider: <name> | "all" }
1111
environments: { <name>: EnvironmentDecl }
12+
tunnels: { <name>: TunnelDecl }
1213
vaults: { <name>: VaultDecl }
1314
memory_stores:{ <name>: MemoryStoreDecl }
1415
skills: { <name>: SkillDecl }
@@ -23,6 +24,7 @@ deployments: { <name>: DeploymentDecl }
2324
| `providers` | map | yes | One block per provider; each holds its credentials. |
2425
| `defaults.provider` | string | no | Default target for `plan`/`apply`. `all` targets every declared provider. |
2526
| `environments` | map | no | Cloud runtimes. |
27+
| `tunnels` | map | no | Existing Qoder BYOC tunnels referenced by sessions; OpenCMA does not manage their lifecycle. |
2628
| `vaults` | map | no | Credential stores. |
2729
| `memory_stores` | map | no | Persistent agent context (Qoder, Volcengine Ark). |
2830
| `skills` | map | no | Reusable capability modules. |
@@ -72,23 +74,35 @@ environments:
7274
name: <string> # optional
7375
description: <string> # optional
7476
provider: <string> # optional; pin to one provider
77+
environment_id: <string> # optional; reference an existing provider environment without managing it
7578
config:
76-
type: cloud
79+
type: cloud | self_hosted
7780
networking: { ... }
7881
packages: { ... }
7982
metadata: { <key>: <string> }
8083
```
8184

8285
| Field | Type | Required | Description |
8386
|-------|------|:--------:|-------------|
84-
| `config.type` | `"cloud"` | yes | Environment type. |
87+
| `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)). |
88+
| `config.type` | `"cloud"` \| `"self_hosted"` | yes | Environment type. `self_hosted` is used for Qoder BYOC. |
8589
| `config.networking.type` | `"unrestricted"` \| `"limited"` | no | Network policy. |
8690
| `config.networking.allow_mcp_servers` | boolean | no | Allow outbound MCP. |
8791
| `config.networking.allow_package_managers` | boolean | no | Allow package managers. |
8892
| `config.networking.allowed_hosts` | string[] | no | Allow-list for `limited` networks. |
8993
| `config.packages.apt` \| `pip` \| `npm` \| `cargo` \| `gem` \| `go` | string[] | no | Preinstalled packages. |
9094
| `metadata` | map<string,string> | no | Free-form metadata. |
9195

96+
## Tunnel (Qoder BYOC)
97+
98+
```yaml
99+
tunnels:
100+
internal-network:
101+
tunnel_id: tnl_00xxxx
102+
```
103+
104+
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.
105+
92106
## Vault
93107

94108
```yaml
@@ -166,6 +180,7 @@ agents:
166180
model: <string> | { <provider>: <string> }
167181
instructions: <string> | <path>
168182
environment: <string>
183+
tunnel: <string> # optional; Qoder BYOC tunnel name
169184
provider: <string>
170185
tools: { builtin: [...], mcp: [...], permissions: {...} }
171186
mcp_servers: [ { name, type?, url? } ]
@@ -181,6 +196,7 @@ agents:
181196
| `model` | string \| map<provider,string> | yes | Single model or a per-provider map. |
182197
| `instructions` | string | yes | Inline text or a path to a file (resolved relative to the config). |
183198
| `environment` | string | no | Environment name. |
199+
| `tunnel` | string | no | Qoder BYOC tunnel name from `tunnels`; unsupported for other providers. |
184200
| `provider` | string | no | Pin the agent to one provider. |
185201
| `tools.builtin` | string[] | yes (in `tools`) | Lowercase tool names. |
186202
| `tools.permissions` | map<string,`"allow"`\|`"ask"`> | no | Per-tool permission policy. |
@@ -214,6 +230,7 @@ deployments:
214230
agent: <string>
215231
agent_version: <number> # optional
216232
environment: <string> # optional
233+
tunnel: <string> # optional; Qoder BYOC only (see note below)
217234
vaults: [ <string> ]
218235
memory_stores: [ <string> ]
219236
resources: [ DeploymentResource ]
@@ -226,6 +243,8 @@ deployments:
226243

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

246+
> **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).
247+
229248
### Initial events
230249

231250
| Type | Fields |

packages/cli/src/commands/destroy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ function stopResourceSpinner(spinner: ReturnType<typeof p.spinner> | undefined,
8787
}
8888

8989
if (result.status === "success") {
90-
if (result.reason === "already_gone") {
90+
if (result.reason === "reference_removed") {
91+
spinner.stop(chalk.green(`✓ ${label} — local reference removed (remote left intact)`));
92+
} else if (result.reason === "already_gone") {
9193
spinner.stop(chalk.yellow(`⊘ ${label} — already deleted remotely, cleaned up state`));
9294
} else if (result.cascaded) {
9395
spinner.stop(chalk.green(`✓ ${label} — destroyed (cascaded)`));

packages/cli/src/commands/session.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ interface SessionCreateOpts {
4646
file: string;
4747
agent?: string;
4848
environment?: string;
49+
environmentId?: string;
50+
tunnel?: string;
51+
tunnelId?: string;
4952
vault?: string;
5053
memoryStores?: string;
5154
title?: string;
@@ -67,6 +70,9 @@ export async function sessionCreateCommand(
6770
agent: positionalAgent ?? options.agent,
6871
provider: options.provider,
6972
environment: options.environment,
73+
environmentId: options.environmentId,
74+
tunnel: options.tunnel,
75+
tunnelId: options.tunnelId,
7076
vault: options.vault,
7177
memoryStores: parseMemoryStores(options.memoryStores),
7278
title: options.title,
@@ -75,6 +81,7 @@ export async function sessionCreateCommand(
7581
log.success(`Session created: ${chalk.bold(session.id)}`);
7682
console.log(` Agent: ${agentName}`);
7783
console.log(` Environment: ${session.environment_id}`);
84+
if (session.tunnel_id) console.log(` Tunnel: ${session.tunnel_id}`);
7885
console.log(` Status: ${session.status}`);
7986
if (session.vault_ids.length) console.log(` Vaults: ${session.vault_ids.join(", ")}`);
8087
if (session.memory_store_ids.length) console.log(` Memory: ${session.memory_store_ids.join(", ")}`);
@@ -163,6 +170,7 @@ export async function sessionGetCommand(sessionId: string, options: SessionGetOp
163170
console.log(` ID: ${chalk.bold(session.id)}`);
164171
console.log(` Agent: ${session.agent_id}`);
165172
console.log(` Environment: ${session.environment_id}`);
173+
if (session.tunnel_id) console.log(` Tunnel: ${session.tunnel_id}`);
166174
console.log(` Status: ${session.status}`);
167175
if (session.title) console.log(` Title: ${session.title}`);
168176
if (session.vault_ids.length) console.log(` Vaults: ${session.vault_ids.join(", ")}`);
@@ -248,6 +256,9 @@ interface SessionRunOpts {
248256
file: string;
249257
agent?: string;
250258
environment?: string;
259+
environmentId?: string;
260+
tunnel?: string;
261+
tunnelId?: string;
251262
vault?: string;
252263
memoryStores?: string;
253264
title?: string;
@@ -273,6 +284,9 @@ export async function sessionRunCommand(
273284
agent: positionalAgent ?? options.agent,
274285
provider: options.provider,
275286
environment: options.environment,
287+
environmentId: options.environmentId,
288+
tunnel: options.tunnel,
289+
tunnelId: options.tunnelId,
276290
vault: options.vault,
277291
memoryStores: parseMemoryStores(options.memoryStores),
278292
title: options.title,

packages/cli/src/program.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ sessionCmd
194194
.addOption(configFileOption())
195195
.option("--agent <name>", "Agent name (auto-detected when only one agent is configured)")
196196
.option("--environment <name>", "Override agent's declared environment")
197+
.option("--environment-id <id>", "Use an explicit remote environment id instead of the configured one")
198+
.option("--tunnel <name>", "Override agent's declared tunnel")
199+
.option("--tunnel-id <id>", "Use an explicit remote tunnel id instead of the configured one")
197200
.option("--vault <name>", "Override agent's declared vault")
198201
.option("--memory-stores <names>", "Override agent's declared memory stores (comma-separated)")
199202
.option("--title <title>", "Session title")
@@ -229,6 +232,9 @@ sessionCmd
229232
.addOption(configFileOption())
230233
.option("--agent <name>", "Agent name (auto-detected when only one agent is configured)")
231234
.option("--environment <name>", "Override agent's declared environment")
235+
.option("--environment-id <id>", "Use an explicit remote environment id instead of the configured one")
236+
.option("--tunnel <name>", "Override agent's declared tunnel")
237+
.option("--tunnel-id <id>", "Use an explicit remote tunnel id instead of the configured one")
232238
.option("--vault <name>", "Override agent's declared vault")
233239
.option("--memory-stores <names>", "Override agent's declared memory stores (comma-separated)")
234240
.option("--title <title>", "Session title")

packages/sdk/src/internal/core/destroy-runtime.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type DestroyResourceStatus = "success" | "failed" | "blocked" | "skipped"
1111

1212
export type DestroyResourceResultReason =
1313
| "destroyed"
14+
| "reference_removed"
1415
| "already_gone"
1516
| "cascade_required"
1617
| "provider_missing"
@@ -95,6 +96,14 @@ async function destroyOneResource(
9596
resource: ResourceState,
9697
options: DestroyProjectOptions,
9798
): Promise<DestroyResourceResult> {
99+
// BYOC environments are provisioned and owned by QCA. `environment_id` means
100+
// this project only references that environment, so destroy must never make a
101+
// remote lifecycle call for it.
102+
if (isExternalEnvironment(ctx, resource)) {
103+
ctx.state.removeResource(resource.address);
104+
return successResult(resource, "reference_removed");
105+
}
106+
98107
let provider: ProviderAdapter;
99108
try {
100109
provider = getRuntimeProvider(ctx, resource.address.provider);
@@ -160,7 +169,17 @@ async function destroyOneResource(
160169
}
161170
}
162171

163-
function successResult(resource: ResourceState, reason: "destroyed" | "already_gone"): DestroyResourceResult {
172+
function isExternalEnvironment(ctx: ProjectRuntimeContext, resource: ResourceState): boolean {
173+
return (
174+
resource.address.type === "environment" &&
175+
(resource.externally_managed || Boolean(ctx.config.environments?.[resource.address.name]?.environment_id))
176+
);
177+
}
178+
179+
function successResult(
180+
resource: ResourceState,
181+
reason: "destroyed" | "reference_removed" | "already_gone",
182+
): DestroyResourceResult {
164183
return { resource, status: "success", reason };
165184
}
166185

packages/sdk/src/internal/core/resource-runtime.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { getResourceDeclaration } from "../planner/declaration.ts";
44
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
55
import { buildPlan } from "../planner/planner.ts";
66
import { type RefreshResult, refreshState } from "../planner/refresh.ts";
7+
import { readComparableIfSupported } from "../providers/drift-support.ts";
78
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
89
import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts";
910
import type { ResourceAddress, ResourceState, ResourceType } from "../types/state.ts";
11+
import { contentHash as stableContentHash } from "../utils/hash.ts";
1012
import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts";
1113
import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
1214

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

127+
// Read back the actual remote state as the drift baseline. Without it the
128+
// first plan after import would compare the remote against the *desired*
129+
// comparable and report a one-time phantom "Remote drift detected" — most
130+
// visibly for external-reference environments OpenCMA never configured.
131+
const provider = ctx.providers.get(address.provider);
132+
const remote = provider ? await readComparableIfSupported(provider, address.type, remoteId, address.name) : null;
133+
const remoteHash = remote ? stableContentHash(remote.comparable) : undefined;
134+
125135
const resource: ResourceState = {
126136
address,
127137
remote_id: remoteId,
128-
version: options.resourceVersion,
138+
externally_managed:
139+
address.type === "environment" && ctx.config.environments?.[address.name]?.environment_id ? true : undefined,
140+
version: options.resourceVersion ?? remote?.version,
129141
content_hash: contentHash,
130142
desired_hash: contentHash,
143+
desired_comparable_hash: remoteHash,
131144
desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)),
145+
remote_hash: remoteHash,
146+
remote_snapshot: remote ? (remote.snapshot ?? remote.comparable) : undefined,
147+
drift_status: remoteHash ? "in_sync" : undefined,
132148
};
133149
ctx.state.setResource(resource);
134150
await ctx.state.save();

packages/sdk/src/internal/core/session-runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export async function createSessionForAgent(
142142
const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, {
143143
environment: options.environment,
144144
environmentId: options.environmentId,
145+
tunnel: options.tunnel,
146+
tunnelId: options.tunnelId,
145147
vault: options.vault,
146148
vaultIds: options.vaultIds,
147149
memoryStores: options.memoryStores,
@@ -162,6 +164,8 @@ export async function startSessionRun(
162164
const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, {
163165
environment: options.environment,
164166
environmentId: options.environmentId,
167+
tunnel: options.tunnel,
168+
tunnelId: options.tunnelId,
165169
vault: options.vault,
166170
vaultIds: options.vaultIds,
167171
memoryStores: options.memoryStores,

0 commit comments

Comments
 (0)