From f9fbebeb861f3b4a94c6370712a1db319d1bb273 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Sat, 18 Jul 2026 23:00:46 +0800 Subject: [PATCH 1/2] add Qoder BYOC tunnel support Change-Id: I62f1808fe82cde450a11e477b40109575b22485b --- docs/README.md | 1 + docs/guides/use-byoc-environments.md | 87 ++++++++++++ docs/reference/configuration.md | 21 ++- packages/cli/src/commands/session.ts | 14 ++ packages/cli/src/program.ts | 6 + .../sdk/src/internal/core/destroy-runtime.ts | 15 ++ .../sdk/src/internal/core/resource-runtime.ts | 2 + .../sdk/src/internal/core/session-runtime.ts | 4 + .../sdk/src/internal/core/validate-config.ts | 34 +++++ .../sdk/src/internal/executor/executor.ts | 39 +++++- .../sdk/src/internal/executor/resolver.ts | 31 ++++- packages/sdk/src/internal/parser/schema.ts | 16 ++- .../sdk/src/internal/providers/interface.ts | 2 + .../src/internal/providers/qoder/mapper.ts | 14 +- packages/sdk/src/internal/providers/shared.ts | 1 + .../src/internal/session/session-manager.ts | 33 ++++- .../sdk/src/internal/state/state-manager.ts | 1 + packages/sdk/src/internal/types/config.ts | 16 ++- packages/sdk/src/internal/types/session.ts | 3 + packages/sdk/src/internal/types/state.ts | 5 + .../tests/unit/core-session-runtime.test.ts | 19 +++ packages/sdk/tests/unit/deployment.test.ts | 44 ++++++ .../sdk/tests/unit/destroy-runtime.test.ts | 27 ++++ .../unit/executor-conflict-adopt.test.ts | 131 +++++++++++++++++- .../sdk/tests/unit/map-deployment.test.ts | 4 + packages/sdk/tests/unit/map-session.test.ts | 8 ++ .../sdk/tests/unit/session-manager.test.ts | 78 +++++++++++ packages/sdk/tests/unit/slim-state.test.ts | 1 + .../sdk/tests/unit/validate-config.test.ts | 15 ++ 29 files changed, 653 insertions(+), 19 deletions(-) create mode 100644 docs/guides/use-byoc-environments.md diff --git a/docs/README.md b/docs/README.md index 00d0d7f..4383de5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. | diff --git a/docs/guides/use-byoc-environments.md b/docs/guides/use-byoc-environments.md new file mode 100644 index 0000000..aeacbe3 --- /dev/null +++ b/docs/guides/use-byoc-environments.md @@ -0,0 +1,87 @@ +# 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. If you later remove the environment declaration and run `agents apply`, it removes only the local state record; the administrator-managed environment remains intact. The same protection applies to `agents destroy`. + +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. + +## 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. | + +For field definitions, see the [configuration reference](../reference/configuration.md). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 40dcd90..8a34f9c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -9,6 +9,7 @@ version: "1" providers: { : } defaults: { provider: | "all" } environments: { : EnvironmentDecl } +tunnels: { : TunnelDecl } vaults: { : VaultDecl } memory_stores:{ : MemoryStoreDecl } skills: { : SkillDecl } @@ -23,6 +24,7 @@ deployments: { : 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. | @@ -72,8 +74,9 @@ environments: name: # optional description: # optional provider: # optional; pin to one provider + environment_id: # optional; reference an existing provider environment without managing it config: - type: cloud + type: cloud | self_hosted networking: { ... } packages: { ... } metadata: { : } @@ -81,7 +84,8 @@ environments: | 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. | +| `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. | @@ -89,6 +93,16 @@ environments: | `config.packages.apt` \| `pip` \| `npm` \| `cargo` \| `gem` \| `go` | string[] | no | Preinstalled packages. | | `metadata` | map | 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 @@ -166,6 +180,7 @@ agents: model: | { : } instructions: | environment: + tunnel: # optional; Qoder BYOC tunnel name provider: tools: { builtin: [...], mcp: [...], permissions: {...} } mcp_servers: [ { name, type?, url? } ] @@ -181,6 +196,7 @@ agents: | `model` | string \| map | 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 | no | Per-tool permission policy. | @@ -214,6 +230,7 @@ deployments: agent: agent_version: # optional environment: # optional + tunnel: # optional; Qoder BYOC only vaults: [ ] memory_stores: [ ] resources: [ DeploymentResource ] diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 9dc2726..da310a3 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -46,6 +46,9 @@ interface SessionCreateOpts { file: string; agent?: string; environment?: string; + environmentId?: string; + tunnel?: string; + tunnelId?: string; vault?: string; memoryStores?: string; title?: string; @@ -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, @@ -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(", ")}`); @@ -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(", ")}`); @@ -248,6 +256,9 @@ interface SessionRunOpts { file: string; agent?: string; environment?: string; + environmentId?: string; + tunnel?: string; + tunnelId?: string; vault?: string; memoryStores?: string; title?: string; @@ -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, diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 4117ea9..dc546f7 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -193,6 +193,9 @@ sessionCmd .addOption(configFileOption()) .option("--agent ", "Agent name (auto-detected when only one agent is configured)") .option("--environment ", "Override agent's declared environment") + .option("--environment-id ", "Use an explicit remote environment id instead of the configured one") + .option("--tunnel ", "Override agent's declared tunnel") + .option("--tunnel-id ", "Use an explicit remote tunnel id instead of the configured one") .option("--vault ", "Override agent's declared vault") .option("--memory-stores ", "Override agent's declared memory stores (comma-separated)") .option("--title ", "Session title") @@ -228,6 +231,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") diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index b8cee29..afad75b 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -95,6 +95,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, "destroyed"); + } + let provider: ProviderAdapter; try { provider = getRuntimeProvider(ctx, resource.address.provider); @@ -160,6 +168,13 @@ 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 successResult(resource: ResourceState, reason: "destroyed" | "already_gone"): DestroyResourceResult { return { resource, status: "success", reason }; } diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 3579e49..3bc1d24 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -125,6 +125,8 @@ export async function importResource( const resource: ResourceState = { address, remote_id: remoteId, + externally_managed: + address.type === "environment" && ctx.config.environments?.[address.name]?.environment_id ? true : undefined, version: options.resourceVersion, content_hash: contentHash, desired_hash: contentHash, diff --git a/packages/sdk/src/internal/core/session-runtime.ts b/packages/sdk/src/internal/core/session-runtime.ts index c32210c..9485c31 100644 --- a/packages/sdk/src/internal/core/session-runtime.ts +++ b/packages/sdk/src/internal/core/session-runtime.ts @@ -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, @@ -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, diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 5b20423..00861bd 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -46,6 +46,7 @@ export function resolveTargetProviders(config: ProjectConfig): string[] { export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: DiagnosticCollector): void { const envNames = new Set(Object.keys(config.environments ?? {})); + const tunnelNames = new Set(Object.keys(config.tunnels ?? {})); const skillNames = new Set(Object.keys(config.skills ?? {})); const vaultNames = new Set(Object.keys(config.vaults ?? {})); const memoryNames = new Set(Object.keys(config.memory_stores ?? {})); @@ -58,6 +59,9 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: `agent.${name}: references unknown environment '${agent.environment}'`, ); } + if (agent.tunnel && !tunnelNames.has(agent.tunnel)) { + diagnostics.error("config.agent.tunnel.unknown", `agent.${name}: references unknown tunnel '${agent.tunnel}'`); + } for (const skill of agent.skills ?? []) { if (typeof skill !== "string") continue; if (!skillNames.has(skill)) { @@ -89,6 +93,15 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: } } } + + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { + if (deployment.tunnel && !tunnelNames.has(deployment.tunnel)) { + diagnostics.error( + "config.deployment.tunnel.unknown", + `deployment.${name}: references unknown tunnel '${deployment.tunnel}'`, + ); + } + } } export function collectProviderCapabilities( @@ -107,6 +120,27 @@ export function collectProviderCapabilities( } const caps = def.capabilities; + if (providerName !== "qoder") { + for (const [name, agent] of Object.entries(config.agents ?? {})) { + if (agent.tunnel && (!agent.provider || agent.provider === providerName)) { + diagnostics.error( + `${providerName}.agent.tunnel.unsupported`, + "Tunnels are supported only by Qoder BYOC sessions.", + { type: "agent", name, provider: providerName }, + ); + } + } + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { + if (deployment.tunnel && (!deployment.provider || deployment.provider === providerName)) { + diagnostics.error( + `${providerName}.deployment.tunnel.unsupported`, + "Tunnels are supported only by Qoder BYOC sessions.", + { type: "deployment", name, provider: providerName }, + ); + } + } + } + for (const missing of findMissingBailianMcpToolConfigs(config, providerName)) { diagnostics.error( `${providerName}.agent.mcp_toolkit_missing`, diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 69e6e1c..bd4cd11 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -59,6 +59,21 @@ export async function executePlan( const concurrency = clampConcurrency(options.concurrency); const resultsByKey = new Map<string, ActionResult>(); const failed = new Set<string>(); + let stateUpdated = false; + + // Persist the ownership boundary even for an already-converged external + // 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]; + const existing = ctx.state.getResource(action.address); + if (decl?.environment_id && existing && !existing.externally_managed) { + ctx.state.setResource({ ...existing, externally_managed: true }); + stateUpdated = true; + } + } + if (stateUpdated) await ctx.state.save(); const actionable = plan.actions.filter((a) => a.action !== "no-op"); const mutations = actionable.filter((a) => a.action !== "delete"); @@ -206,6 +221,16 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte if (!existing) return false; const id = existing.remote_id; + // 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) { + ctx.state.removeResource(address); + return false; + } + } + if (id !== null) { try { switch (type) { @@ -255,7 +280,18 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte case "environment": { const decl = ctx.config.environments![name]!; const remoteName = decl.name ?? name; - if (isUpdate) { + // External-reference environments are owned outside OpenCMA. + // Skip remote mutations and just record the pre-existing id in state. + if (decl.environment_id) { + result = { id: decl.environment_id, type: "environment" }; + emitRuntimeFeedback(ctx.onFeedback, { + type: "resource_action_success", + level: "info", + action, + resource: action.address, + message: `${action.action} ${action.address.type}.${action.address.name} (${action.address.provider}) — external reference, no remote mutation`, + }); + } else if (isUpdate) { result = await provider.updateEnvironment(existingId!, remoteName, decl); } else { try { @@ -452,6 +488,7 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte ctx.state.setResource({ address, remote_id: result.id, + externally_managed: type === "environment" && ctx.config.environments?.[name]?.environment_id ? true : undefined, version: result.version, content_hash: hash, desired_hash: hash, diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index 3478933..0097a60 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -93,11 +93,20 @@ export function resolveDeploymentRefs( `Deployment '${deploymentName}' has no environment and agent '${dep.agent}' does not declare one`, ); } - const environment_id = requireRef(state, { - type: "environment", - name: envName, - provider, - }); + const envDecl = config.environments?.[envName]; + if (!envDecl) { + throw new UserError(`Environment '${envName}' is not defined in config.`); + } + const environment_id = + envDecl.environment_id ?? + requireRef(state, { + type: "environment", + name: envName, + provider, + }); + + const tunnelName = dep.tunnel ?? agent.tunnel; + const tunnel_id = tunnelName ? resolveTunnelIdFromConfig(config, tunnelName, provider) : undefined; const vaultNames = dep.vaults ?? (agent.vault ? [agent.vault] : []); const vault_ids = vaultNames.map((v) => requireRef(state, { type: "vault", name: v, provider })); @@ -126,7 +135,19 @@ export function resolveDeploymentRefs( agent_id, agent_version: dep.agent_version, environment_id, + tunnel_id, vault_ids, memory_store_ids, }; } + +function resolveTunnelIdFromConfig(config: ProjectConfig, tunnelName: string, provider: string): string { + if (provider !== "qoder") { + throw new UserError("Tunnels are supported only by Qoder BYOC sessions."); + } + const tunnel = config.tunnels?.[tunnelName]; + if (!tunnel) { + throw new UserError(`Tunnel '${tunnelName}' is not defined in config. Declare it under the 'tunnels:' section.`); + } + return tunnel.tunnel_id; +} diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index a073507..e6ad918 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -20,14 +20,25 @@ const environmentSchema = z.object({ name: z.string().optional(), description: z.string().optional(), provider: z.string().optional(), + /** Pre-existing provider environment id (e.g. env_00xxxx). When set, the environment is treated as an external reference and will not be created/updated/deleted by OpenCMA. */ + environment_id: z.string().optional(), config: z.object({ - type: z.literal("cloud"), + type: z.enum(["cloud", "self_hosted"]), networking: networkingSchema.optional(), packages: packagesSchema.optional(), }), metadata: z.record(z.string(), z.string()).optional(), }); +const tunnelSchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + provider: z.string().optional(), + /** Pre-existing Qoder tunnel id (e.g. tnl_00xxxx). Tunnels are allocated by Qoder BYOC admin and referenced, not created. */ + tunnel_id: z.string(), + metadata: z.record(z.string(), z.string()).optional(), +}); + const coerceString = z.union([z.string(), z.number()]).transform(String); const staticBearerCredentialSchema = z.object({ @@ -170,6 +181,7 @@ const agentSchema = z.object({ model: z.union([z.string(), z.record(z.string(), z.string())]), instructions: z.string(), environment: z.string().optional(), + tunnel: z.string().optional(), provider: z.string().optional(), tools: toolsSchema.optional(), mcp_servers: z.array(mcpServerSchema).optional(), @@ -231,6 +243,7 @@ const deploymentSchema = z.object({ agent: z.string(), agent_version: z.number().int().optional(), environment: z.string().optional(), + tunnel: z.string().optional(), vaults: z.array(z.string()).optional(), memory_stores: z.array(z.string()).optional(), resources: z.array(deploymentResourceSchema).optional(), @@ -250,6 +263,7 @@ export const projectConfigSchema = z.object({ }) .optional(), environments: z.record(z.string(), environmentSchema).optional(), + tunnels: z.record(z.string(), tunnelSchema).optional(), vaults: z.record(z.string(), vaultSchema).optional(), memory_stores: z.record(z.string(), memoryStoreSchema).optional(), skills: z.record(z.string(), skillSchema).optional(), diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index aeb5a1c..aeafb02 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -65,6 +65,8 @@ export interface ResolvedDeploymentRefs { agent_id: string; agent_version?: number; environment_id: string; + /** Qoder BYOC tunnel id. Passed through only when the deployment targets Qoder BYOC. */ + tunnel_id?: string; vault_ids: string[]; memory_store_ids: Record<string, string>; } diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 8fb9ee2..202c637 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -42,14 +42,15 @@ export function normalizeToolNameFromQoder(name: string): string { } export function mapEnvironment(name: string, decl: EnvironmentDecl, projectName: string): unknown { + const envType = decl.config.type ?? "cloud"; + const config: Record<string, unknown> = { type: envType }; + if (decl.config.networking) config.networking = decl.config.networking; + else if (envType === "cloud") config.networking = { type: "unrestricted" }; + if (decl.config.packages) config.packages = decl.config.packages; return { name, description: decl.description ?? "", - config: { - type: "cloud", - networking: decl.config.networking ?? { type: "unrestricted" }, - packages: decl.config.packages, - }, + config, metadata: injectMetadata(decl.metadata, projectName, name), }; } @@ -242,6 +243,7 @@ export function mapDeployment( initial_events: mapDeploymentInitialEvents(decl.initial_events), }; + if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id; if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; const resources = mapDeploymentResources(decl, refs, uploadedFiles); @@ -520,6 +522,7 @@ export function mapSession(bindings: SessionBindings): unknown { environment_id: bindings.environment_id, }; + if (bindings.tunnel_id) body.tunnel_id = bindings.tunnel_id; if (bindings.title) body.title = bindings.title; if (bindings.metadata) body.metadata = bindings.metadata; if (bindings.vault_ids.length) body.vault_ids = bindings.vault_ids; @@ -541,6 +544,7 @@ export function mapDeploymentToSession(decl: DeploymentDecl, refs: ResolvedDeplo environment_id: refs.environment_id, }; + if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id; if (decl.description) body.title = decl.description; if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; diff --git a/packages/sdk/src/internal/providers/shared.ts b/packages/sdk/src/internal/providers/shared.ts index 2ac6548..3532704 100644 --- a/packages/sdk/src/internal/providers/shared.ts +++ b/packages/sdk/src/internal/providers/shared.ts @@ -56,6 +56,7 @@ export function buildSessionInfo( id: res.id as string, agent_id: (agent?.id as string) ?? (res.agent_id as string) ?? "", environment_id: res.environment_id as string, + tunnel_id: (res.tunnel_id as string) ?? undefined, status: res.status as string, title: res.title as string | undefined, vault_ids: extractVaultIds(res), diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index 7ea1a9c..666586a 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -1,7 +1,7 @@ import { UserError } from "../errors.ts"; import { requireRef } from "../executor/resolver.ts"; import type { IStateManager } from "../state/state-manager.ts"; -import type { ProjectConfig } from "../types/config.ts"; +import type { AgentDecl, ProjectConfig } from "../types/config.ts"; import type { SessionBindings } from "../types/session.ts"; export interface SessionCreateOptions { @@ -13,6 +13,10 @@ export interface SessionCreateOptions { * Takes precedence over `environment` / the agent's declared environment. */ environmentId?: string; + /** Tunnel name referencing `tunnels.<name>` in config. */ + tunnel?: string; + /** Explicit remote tunnel id. Takes precedence over `tunnel` / the agent's declared tunnel. */ + tunnelId?: string; vault?: string; /** * Explicit remote vault ids. When set, they are bound directly (bypassing the @@ -75,9 +79,12 @@ export function buildSessionBindings( throw new UserError(`Agent '${agentName}' has no environment declared and --environment was not specified.`); } validateResourceInConfig(envName, "environment", config.environments); - environmentId = requireRef(state, { type: "environment", name: envName, provider }); + const envDecl = config.environments![envName]!; + environmentId = envDecl.environment_id ?? requireRef(state, { type: "environment", name: envName, provider }); } + const tunnelId = resolveTunnelId(agent, config, options, provider); + let vaultIds: string[]; if (options.vaultIds) { // Caller supplied concrete remote ids — bind them as-is, skipping the @@ -103,6 +110,7 @@ export function buildSessionBindings( agent_id: agentId, agent_version: agentState?.version, environment_id: environmentId, + tunnel_id: tunnelId, vault_ids: vaultIds, memory_store_ids: memoryStoreIds, files: (options.files ?? []).map((f) => ({ file_id: f.fileId, mount_path: f.mountPath })), @@ -111,6 +119,27 @@ export function buildSessionBindings( }; } +function resolveTunnelId( + agent: AgentDecl, + config: ProjectConfig, + options: SessionCreateOptions, + provider: string, +): string | undefined { + const tunnelName = options.tunnel ?? agent.tunnel; + if (!options.tunnelId && !tunnelName) return undefined; + if (provider !== "qoder") { + throw new UserError("Tunnels are supported only by Qoder BYOC sessions."); + } + if (options.tunnelId) return options.tunnelId; + if (!tunnelName) return undefined; + + const tunnel = config.tunnels?.[tunnelName]; + if (!tunnel) { + throw new UserError(`Tunnel '${tunnelName}' is not defined in config. Declare it under the 'tunnels:' section.`); + } + return tunnel.tunnel_id; +} + function validateResourceInConfig(name: string, type: string, resources?: Record<string, unknown>): void { if (!resources?.[name]) { throw new UserError(`${type} '${name}' is not defined in config.`); diff --git a/packages/sdk/src/internal/state/state-manager.ts b/packages/sdk/src/internal/state/state-manager.ts index 793c89f..d4b404c 100644 --- a/packages/sdk/src/internal/state/state-manager.ts +++ b/packages/sdk/src/internal/state/state-manager.ts @@ -39,6 +39,7 @@ export class StateManager implements IStateManager { const resources: ResourceState[] = raw.map((r) => ({ address: r.address as ResourceState["address"], remote_id: r.remote_id as string | null, + externally_managed: r.externally_managed === true ? true : undefined, version: r.version as number | undefined, content_hash: ((r.content_hash ?? r.desired_hash) as string) ?? "", desired_hash: ((r.desired_hash ?? r.content_hash) as string) ?? "", diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 84b64f2..27d3d0d 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -5,6 +5,7 @@ export interface ProjectConfig { providers: Record<string, unknown>; defaults?: DefaultsConfig; environments?: Record<string, EnvironmentDecl>; + tunnels?: Record<string, TunnelDecl>; vaults?: Record<string, VaultDecl>; memory_stores?: Record<string, MemoryStoreDecl>; skills?: Record<string, SkillDecl>; @@ -23,16 +24,27 @@ export interface EnvironmentDecl { name?: string; description?: string; provider?: ProviderName; + /** Pre-existing provider environment id. When set, OpenCMA treats the environment as an external reference and will not create/update/delete it remotely. */ + environment_id?: string; config: EnvironmentConfig; metadata?: Record<string, string>; } export interface EnvironmentConfig { - type: "cloud"; + type: "cloud" | "self_hosted"; networking?: NetworkingConfig; packages?: PackagesConfig; } +export interface TunnelDecl { + name?: string; + description?: string; + provider?: ProviderName; + /** Pre-existing Qoder tunnel id (e.g. tnl_00xxxx). Tunnels are allocated by Qoder BYOC admin and referenced, not created. */ + tunnel_id: string; + metadata?: Record<string, string>; +} + export interface NetworkingConfig { type: "unrestricted" | "limited"; allow_mcp_servers?: boolean; @@ -123,6 +135,7 @@ export interface AgentDecl { model: string | Record<ProviderName, ModelSpec>; instructions: string; environment?: string; + tunnel?: string; provider?: ProviderName; tools?: AgentToolsDecl; mcp_servers?: McpServerDecl[]; @@ -171,6 +184,7 @@ export interface DeploymentDecl { agent: string; agent_version?: number; environment?: string; + tunnel?: string; vaults?: string[]; memory_stores?: string[]; resources?: DeploymentResourceDecl[]; diff --git a/packages/sdk/src/internal/types/session.ts b/packages/sdk/src/internal/types/session.ts index 3bd9562..0e364c7 100644 --- a/packages/sdk/src/internal/types/session.ts +++ b/packages/sdk/src/internal/types/session.ts @@ -10,6 +10,8 @@ export interface SessionBindings { agent_version?: number; /** Cloud sandbox id. Every provider runs sessions inside an environment. */ environment_id: string; + /** Qoder BYOC tunnel id. Required to reach internal MCP servers from the sandbox. */ + tunnel_id?: string; vault_ids: string[]; memory_store_ids: string[]; files?: SessionFileResource[]; @@ -21,6 +23,7 @@ export interface ProviderSessionInfo { id: string; agent_id: string; environment_id: string; + tunnel_id?: string; status: string; title?: string; vault_ids: string[]; diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index 2e18a0b..ab5a5a9 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -5,6 +5,11 @@ export type { ResourceAddress, ResourceType } from "./dto.ts"; export interface ResourceState { address: ResourceAddress; remote_id: string | null; + /** + * This resource is owned by the provider or another system and is only + * referenced by this project. It must never be deleted remotely. + */ + externally_managed?: boolean; version?: number; /** * Backward-compatible alias for desired_hash. Kept while older state files diff --git a/packages/sdk/tests/unit/core-session-runtime.test.ts b/packages/sdk/tests/unit/core-session-runtime.test.ts index eba8024..5cb7e1b 100644 --- a/packages/sdk/tests/unit/core-session-runtime.test.ts +++ b/packages/sdk/tests/unit/core-session-runtime.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; import { + createSessionForAgent, sendSessionMessageAndCollectEvents, startSessionRun, streamMessageEvents, @@ -143,6 +144,24 @@ describe("core session runtime", () => { expect(calls).toEqual(["createSession", "send:do work", "stream:evt_user"]); }); + test("forwards explicit tunnel overrides when creating a session", async () => { + let tunnelId: string | undefined; + const provider = { + ...adapter("qoder", [], true), + createSession: async (bindings: { tunnel_id?: string }) => { + tunnelId = bindings.tunnel_id; + return session(); + }, + }; + + await createSessionForAgent(ctx(provider), { + agent: "assistant", + tunnelId: "tnl_override", + }); + + expect(tunnelId).toBe("tnl_override"); + }); + test("eventResume=true streams after the created event id", async () => { const calls: string[] = []; const provider = adapter("p", calls, true); diff --git a/packages/sdk/tests/unit/deployment.test.ts b/packages/sdk/tests/unit/deployment.test.ts index 0a8f938..78a63f8 100644 --- a/packages/sdk/tests/unit/deployment.test.ts +++ b/packages/sdk/tests/unit/deployment.test.ts @@ -112,6 +112,50 @@ describe("resolveDeploymentRefs", () => { }; expect(() => resolveDeploymentRefs("broken", config, "qoder", makeState())).toThrow(/references unknown agent/); }); + + test("prefers environment.environment_id over state resolution", () => { + const config = makeConfig(); + config.environments! = { + ...config.environments, + byoc: { environment_id: "env_byoc_xyz", config: { type: "self_hosted" } }, + }; + config.agents! = { + ...config.agents, + byocAgent: { model: "gpt-4", instructions: "byoc", environment: "byoc" }, + }; + config.deployments! = { + ...config.deployments, + byocDep: { agent: "byocAgent", initial_events: [{ type: "user.message", content: "run" }] }, + }; + + const state = StateManager.initialize("/tmp/dep-test-byoc.json"); + state.setResource({ + address: { type: "agent", name: "byocAgent", provider: "qoder" }, + remote_id: "agent_byoc", + content_hash: "h", + }); + // No environment entry in state — environment_id should be used directly. + const refs = resolveDeploymentRefs("byocDep", config, "qoder", state); + expect(refs.environment_id).toBe("env_byoc_xyz"); + }); + + test("rejects tunnels when the deployment provider is not Qoder", () => { + const config = makeConfig(); + config.tunnels = { internal: { tunnel_id: "tnl_internal" } }; + config.agents!.researcher!.tunnel = "internal"; + const state = makeState(); + state.setResource({ + address: { type: "agent", name: "researcher", provider: "claude" }, + remote_id: "agent_claude", + content_hash: "h", + }); + state.setResource({ + address: { type: "environment", name: "dev", provider: "claude" }, + remote_id: "env_claude", + content_hash: "h", + }); + expect(() => resolveDeploymentRefs("daily", config, "claude", state)).toThrow(/only by Qoder/); + }); }); describe("Qoder native deployment CRUD", () => { diff --git a/packages/sdk/tests/unit/destroy-runtime.test.ts b/packages/sdk/tests/unit/destroy-runtime.test.ts index 3f7a19e..5398b52 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -148,6 +148,33 @@ describe("destroy runtime", () => { expect(runtime.state.listResources()).toEqual([]); }); + test("removes an external environment from state without deleting it remotely", async () => { + const calls: string[] = []; + const runtime = await ctx([resource("environment", "byoc", "env_byoc")], adapter(calls)); + runtime.config.environments = { + byoc: { environment_id: "env_byoc", config: { type: "self_hosted" } }, + }; + + const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime)); + + expect(result.destroyed).toBe(1); + expect(calls).toEqual([]); + expect(runtime.state.listResources()).toEqual([]); + }); + + test("removes a formerly declared external environment from state without deleting it remotely", async () => { + const calls: string[] = []; + const external = resource("environment", "byoc", "env_byoc"); + external.externally_managed = true; + const runtime = await ctx([external], adapter(calls)); + + const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime)); + + expect(result.destroyed).toBe(1); + expect(calls).toEqual([]); + expect(runtime.state.listResources()).toEqual([]); + }); + test("treats remote 404 as successful state cleanup", async () => { const calls: string[] = []; const runtime = await ctx( diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index b54e5c6..a48c0d7 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -5,6 +5,7 @@ import type { ExecContext } from "../../src/internal/executor/context.ts"; import { executePlan } from "../../src/internal/executor/executor.ts"; import { ApiError, ConflictError } from "../../src/internal/providers/base-client.ts"; import type { ProviderAdapter, RemoteResource } from "../../src/internal/providers/interface.ts"; +import type { IStateManager } from "../../src/internal/state/state-manager.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; import type { ProjectConfig } from "../../src/internal/types/config.ts"; import type { ExecutionPlan } from "../../src/internal/types/plan.ts"; @@ -40,9 +41,13 @@ function createPlan(): ExecutionPlan { }; } -function makeCtx(provider: ProviderAdapter, state: IStateManager = StateManager.initialize(tmpPath())): ExecContext { +function makeCtx( + provider: ProviderAdapter, + state: IStateManager = StateManager.initialize(tmpPath()), + ctxConfig: ProjectConfig = config, +): ExecContext { return { - config, + config: ctxConfig, configPath: "/tmp/agents.yaml", providers: new Map([["bailian", provider]]), state, @@ -187,3 +192,125 @@ describe("executor conflict-adopt", () => { expect(saved.remote_id).toBe("skill_remote"); }); }); + +describe("executor external-reference environment", () => { + const byocConfig: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + environments: { + "byoc-env": { + environment_id: "env_byoc_xyz", + config: { type: "self_hosted" }, + }, + }, + }; + + test("create action skips remote mutation and records environment_id in state", async () => { + const calls: string[] = []; + const provider = { + name: "bailian", + validate: async () => {}, + findResource: async () => null, + createEnvironment: async () => { + calls.push("createEnvironment"); + return { id: "should_not_be_used", type: "environment" }; + }, + updateEnvironment: async () => { + calls.push("updateEnvironment"); + return { id: "should_not_be_used", type: "environment" }; + }, + deleteEnvironment: async () => { + calls.push("deleteEnvironment"); + }, + } as unknown as ProviderAdapter; + + const plan: ExecutionPlan = { + actions: [ + { + action: "create", + address: { type: "environment", name: "byoc-env", provider: "bailian" }, + reason: "Resource does not exist in state", + after: { content_hash: "h" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + + const state = StateManager.initialize(tmpPath()); + const result = await executePlan(plan, makeCtx(provider, state, byocConfig)); + + expect(result.partial).toBe(false); + expect(result.results[0].status).toBe("success"); + expect(calls).toEqual([]); + const saved = state.getResource({ type: "environment", name: "byoc-env", provider: "bailian" })!; + expect(saved.remote_id).toBe("env_byoc_xyz"); + expect(saved.externally_managed).toBe(true); + }); + + test("removing an external environment from config only removes its state", async () => { + const calls: string[] = []; + const provider = { + name: "bailian", + validate: async () => {}, + findResource: async () => null, + createEnvironment: async () => ({ id: "x", type: "environment" }), + updateEnvironment: async () => ({ id: "x", type: "environment" }), + deleteEnvironment: async () => { + calls.push("deleteEnvironment"); + }, + } as unknown as ProviderAdapter; + + const plan: ExecutionPlan = { + actions: [ + { + action: "delete", + address: { type: "environment", name: "byoc-env", provider: "bailian" }, + reason: "Resource removed from configuration", + before: { content_hash: "h" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + + const state = StateManager.initialize(tmpPath()); + state.setResource({ + address: { type: "environment", name: "byoc-env", provider: "bailian" }, + remote_id: "env_byoc_xyz", + externally_managed: true, + content_hash: "h", + }); + + const configWithoutExternalEnvironment: ProjectConfig = { + ...byocConfig, + environments: undefined, + }; + const result = await executePlan(plan, makeCtx(provider, state, configWithoutExternalEnvironment)); + + expect(result.partial).toBe(false); + expect(result.results[0].status).toBe("success"); + expect(calls).toEqual([]); + expect(state.getResource({ type: "environment", name: "byoc-env", provider: "bailian" })).toBeUndefined(); + }); + + test("marks an existing external environment during a no-op apply", async () => { + const provider = { + name: "bailian", + validate: async () => {}, + findResource: async () => null, + } as unknown as ProviderAdapter; + const state = StateManager.initialize(tmpPath()); + const address = { type: "environment" as const, name: "byoc-env", provider: "bailian" }; + state.setResource({ address, remote_id: "env_byoc_xyz", content_hash: "h" }); + const plan: ExecutionPlan = { + actions: [{ action: "no-op", address, reason: "No changes detected", dependencies: [] }], + diagnostics: [], + }; + + await executePlan(plan, makeCtx(provider, state, byocConfig)); + + expect(state.getResource(address)?.externally_managed).toBe(true); + }); +}); diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index 4c0b933..3b3f9cb 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -13,6 +13,7 @@ function fullRefs(): ResolvedDeploymentRefs { agent_id: "agent_123", agent_version: 3, environment_id: "env_456", + tunnel_id: "tnl_789", vault_ids: ["vault_a"], memory_store_ids: { notes: "ms_1", archive: "ms_2" }, }; @@ -162,6 +163,7 @@ describe("Qoder mapDeploymentToSession", () => { expect(body.agent).toBe("agent_123"); expect(body.environment_id).toBe("env_456"); + expect(body.tunnel_id).toBe("tnl_789"); expect(body.title).toBe("Daily"); expect(body.vault_ids).toEqual(["vault_a"]); expect(body.memory_store_ids).toBeUndefined(); @@ -181,6 +183,7 @@ describe("Qoder mapDeploymentToSession", () => { expect(body.agent).toBe("agent_min"); expect(body.environment_id).toBe("env_min"); + expect(body.tunnel_id).toBeUndefined(); expect(body.title).toBeUndefined(); expect(body.vault_ids).toBeUndefined(); expect(body.memory_store_ids).toBeUndefined(); @@ -229,6 +232,7 @@ describe("Qoder mapDeployment", () => { expect(body.name).toBe("daily-report"); expect(body.agent).toEqual({ id: "agent_123", type: "agent", version: 3 }); expect(body.environment_id).toBe("env_456"); + expect(body.tunnel_id).toBe("tnl_789"); expect(body.initial_events).toEqual([ { type: "user.message", content: [{ type: "text", text: "Run the daily report" }] }, { type: "system.message", content: [{ type: "text", text: "You are punctual" }] }, diff --git a/packages/sdk/tests/unit/map-session.test.ts b/packages/sdk/tests/unit/map-session.test.ts index d9b4789..820e8dc 100644 --- a/packages/sdk/tests/unit/map-session.test.ts +++ b/packages/sdk/tests/unit/map-session.test.ts @@ -11,6 +11,7 @@ function fullBindings(): SessionBindings { agent_id: "agent_123", agent_version: 2, environment_id: "env_456", + tunnel_id: "tnl_789", vault_ids: ["vault_a", "vault_b"], memory_store_ids: ["ms_1", "ms_2"], title: "Research session", @@ -33,6 +34,7 @@ describe("Qoder mapSession", () => { expect(body.agent).toBe("agent_123"); expect(body.environment_id).toBe("env_456"); + expect(body.tunnel_id).toBe("tnl_789"); expect(body.vault_ids).toEqual(["vault_a", "vault_b"]); expect(body.resources).toEqual([ { type: "memory_store", memory_store_id: "ms_1" }, @@ -43,6 +45,12 @@ describe("Qoder mapSession", () => { expect(body.metadata).toEqual({ team: "eng" }); }); + test("tunnel_id is omitted when not provided", () => { + const bindings = minimalBindings(); + const body = mapQoderSession(bindings) as Record<string, unknown>; + expect(body.tunnel_id).toBeUndefined(); + }); + test("minimal bindings omit optional fields", () => { const body = mapQoderSession(minimalBindings()) as Record<string, unknown>; diff --git a/packages/sdk/tests/unit/session-manager.test.ts b/packages/sdk/tests/unit/session-manager.test.ts index d9fbb07..80ff351 100644 --- a/packages/sdk/tests/unit/session-manager.test.ts +++ b/packages/sdk/tests/unit/session-manager.test.ts @@ -11,6 +11,10 @@ function makeConfig(overrides: Partial<ProjectConfig> = {}): ProjectConfig { dev: { config: { type: "cloud" } }, staging: { config: { type: "cloud" } }, }, + tunnels: { + internal: { tunnel_id: "tnl_internal" }, + staging: { tunnel_id: "tnl_staging" }, + }, vaults: { secrets: { display_name: "Secrets", credentials: [] }, other: { display_name: "Other", credentials: [] }, @@ -24,6 +28,7 @@ function makeConfig(overrides: Partial<ProjectConfig> = {}): ProjectConfig { model: "gpt-4", instructions: "You are a researcher.", environment: "dev", + tunnel: "internal", vault: "secrets", memory_stores: ["docs"], }, @@ -86,17 +91,66 @@ describe("buildSessionBindings", () => { const state = makeState(); const bindings = buildSessionBindings("researcher", config, "qoder", state, { environment: "staging", + tunnel: "staging", vault: "other", memoryStores: ["logs"], title: "Test session", }); expect(bindings.environment_id).toBe("env_staging"); + expect(bindings.tunnel_id).toBe("tnl_staging"); expect(bindings.vault_ids).toEqual(["vault_o1"]); expect(bindings.memory_store_ids).toEqual(["ms_logs"]); expect(bindings.title).toBe("Test session"); }); + test("inherits tunnel_id from agent declaration", () => { + const config = makeConfig(); + const state = makeState(); + const bindings = buildSessionBindings("researcher", config, "qoder", state); + + expect(bindings.tunnel_id).toBe("tnl_internal"); + }); + + test("explicit tunnelId binds directly, bypassing config resolution", () => { + const config = makeConfig(); + const state = makeState(); + const bindings = buildSessionBindings("researcher", config, "qoder", state, { + tunnelId: "tnl_remote_xyz", + }); + + expect(bindings.tunnel_id).toBe("tnl_remote_xyz"); + }); + + test("throws when tunnel name not defined in config", () => { + const config = makeConfig(); + const state = makeState(); + expect(() => + buildSessionBindings("researcher", config, "qoder", state, { + tunnel: "prod", + }), + ).toThrow(/not defined in config/); + }); + + test("rejects tunnels when the session provider is not Qoder", () => { + const config = makeConfig(); + const state = makeState(); + state.setResource({ + address: { type: "agent", name: "researcher", provider: "claude" }, + remote_id: "agent_claude", + content_hash: "h", + }); + state.setResource({ + address: { type: "environment", name: "dev", provider: "claude" }, + remote_id: "env_claude", + content_hash: "h", + }); + expect(() => buildSessionBindings("researcher", config, "claude", state)).toThrow(/only by Qoder/); + expect(() => buildSessionBindings("researcher", config, "claude", state, { tunnelId: "tnl_remote_xyz" })).toThrow( + /only by Qoder/, + ); + }); + test("explicit environmentId binds directly, bypassing config/state resolution", () => { const config = makeConfig(); const state = makeState(); @@ -108,6 +162,30 @@ describe("buildSessionBindings", () => { expect(bindings.environment_id).toBe("env_remote_xyz"); }); + test("prefers environment.environment_id over state resolution", () => { + const config = makeConfig({ + environments: { + byoc: { environment_id: "env_byoc_xyz", config: { type: "self_hosted" } }, + }, + agents: { + byocAgent: { + model: "gpt-4", + instructions: "byoc", + environment: "byoc", + }, + }, + }); + const state = makeState(); + state.setResource({ + address: { type: "agent", name: "byocAgent", provider: "qoder" }, + remote_id: "agent_byoc", + content_hash: "h", + }); + + const bindings = buildSessionBindings("byocAgent", config, "qoder", state); + expect(bindings.environment_id).toBe("env_byoc_xyz"); + }); + test("agent with no vault or memory_stores produces empty arrays", () => { const config = makeConfig(); const state = makeState(); diff --git a/packages/sdk/tests/unit/slim-state.test.ts b/packages/sdk/tests/unit/slim-state.test.ts index 8a18930..a5357ff 100644 --- a/packages/sdk/tests/unit/slim-state.test.ts +++ b/packages/sdk/tests/unit/slim-state.test.ts @@ -137,6 +137,7 @@ describe("Qoder metadata injection", () => { const body = mapQoderEnv("dev", envDecl, "my-project") as Record<string, any>; expect(body.metadata["agents.project"]).toBe("my-project"); expect(body.metadata["agents.resource"]).toBe("dev"); + expect(body.config.networking).toEqual({ type: "unrestricted" }); }); test("mapAgent injects agents.project and agents.resource", () => { diff --git a/packages/sdk/tests/unit/validate-config.test.ts b/packages/sdk/tests/unit/validate-config.test.ts index f6a1119..83f3967 100644 --- a/packages/sdk/tests/unit/validate-config.test.ts +++ b/packages/sdk/tests/unit/validate-config.test.ts @@ -42,3 +42,18 @@ test("collectConfigReferences omits provider capability checks", () => { // server-only MCP that this rule would wrongly flag). expect(diagnostics.some((d) => d.code === "bailian.agent.mcp_toolkit_missing")).toBe(false); }); + +test("validates tunnel references and limits tunnels to Qoder", () => { + const config: ProjectConfig = { + version: "1", + providers: { claude: {} }, + defaults: { provider: "claude" }, + tunnels: { byoc: { tunnel_id: "tnl_1" } }, + agents: { + assistant: { model: "claude", instructions: "test", tunnel: "byoc" }, + }, + }; + + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((d) => d.code === "claude.agent.tunnel.unsupported")).toBe(true); +}); From b230ab0a6b240d25e1cb8f9c389e7eb109b3b89a Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Sun, 19 Jul 2026 11:09:12 +0800 Subject: [PATCH 2/2] harden external environment ownership Change-Id: I464f314bc786dd488092eb09eb631c07b4274403 --- docs/guides/use-byoc-environments.md | 12 +- docs/reference/configuration.md | 6 +- packages/cli/src/commands/destroy.ts | 4 +- .../sdk/src/internal/core/destroy-runtime.ts | 8 +- .../sdk/src/internal/core/resource-runtime.ts | 16 +- .../sdk/src/internal/core/validate-config.ts | 31 +++ .../sdk/src/internal/executor/executor.ts | 46 ++- packages/sdk/src/internal/parser/schema.ts | 1 - packages/sdk/src/internal/planner/hasher.ts | 46 ++- packages/sdk/src/internal/planner/planner.ts | 65 ++++- .../src/internal/providers/claude/adapter.ts | 5 +- .../src/internal/providers/drift-support.ts | 8 +- .../src/internal/providers/qoder/adapter.ts | 5 +- .../src/internal/providers/qoder/mapper.ts | 4 +- packages/sdk/src/internal/providers/shared.ts | 10 + .../src/internal/session/session-manager.ts | 3 +- packages/sdk/src/internal/types/config.ts | 1 - .../sdk/tests/unit/byoc-ownership.test.ts | 262 ++++++++++++++++++ .../sdk/tests/unit/claude-archived.test.ts | 38 +++ packages/sdk/tests/unit/deployment.test.ts | 14 +- .../sdk/tests/unit/destroy-runtime.test.ts | 1 + .../sdk/tests/unit/drift-detection.test.ts | 39 +++ packages/sdk/tests/unit/drift-support.test.ts | 38 +++ .../unit/executor-conflict-adopt.test.ts | 83 ++++++ .../sdk/tests/unit/import-resource.test.ts | 24 ++ .../sdk/tests/unit/map-deployment.test.ts | 3 +- 26 files changed, 743 insertions(+), 30 deletions(-) create mode 100644 packages/sdk/tests/unit/byoc-ownership.test.ts create mode 100644 packages/sdk/tests/unit/claude-archived.test.ts create mode 100644 packages/sdk/tests/unit/drift-support.test.ts diff --git a/docs/guides/use-byoc-environments.md b/docs/guides/use-byoc-environments.md index aeacbe3..691e415 100644 --- a/docs/guides/use-byoc-environments.md +++ b/docs/guides/use-byoc-environments.md @@ -71,10 +71,18 @@ agents session run "Check the private service status" \ 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. If you later remove the environment declaration and run `agents apply`, it removes only the local state record; the administrator-managed environment remains intact. The same protection applies to `agents destroy`. +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 | @@ -83,5 +91,7 @@ Deleting an agent, session, vault, or other managed resource still follows its n | `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). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8a34f9c..a20b996 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -84,7 +84,7 @@ environments: | Field | Type | Required | Description | |-------|------|:--------:|-------------| -| `environment_id` | string | no | Existing environment ID. When present, OpenCMA never creates, updates, or deletes the remote environment. | +| `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. | @@ -230,7 +230,7 @@ deployments: agent: <string> agent_version: <number> # optional environment: <string> # optional - tunnel: <string> # optional; Qoder BYOC only + tunnel: <string> # optional; Qoder BYOC only (see note below) vaults: [ <string> ] memory_stores: [ <string> ] resources: [ DeploymentResource ] @@ -243,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 | diff --git a/packages/cli/src/commands/destroy.ts b/packages/cli/src/commands/destroy.ts index fb12f7f..8af11c7 100644 --- a/packages/cli/src/commands/destroy.ts +++ b/packages/cli/src/commands/destroy.ts @@ -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)`)); diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index afad75b..17c3d81 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -11,6 +11,7 @@ export type DestroyResourceStatus = "success" | "failed" | "blocked" | "skipped" export type DestroyResourceResultReason = | "destroyed" + | "reference_removed" | "already_gone" | "cascade_required" | "provider_missing" @@ -100,7 +101,7 @@ async function destroyOneResource( // remote lifecycle call for it. if (isExternalEnvironment(ctx, resource)) { ctx.state.removeResource(resource.address); - return successResult(resource, "destroyed"); + return successResult(resource, "reference_removed"); } let provider: ProviderAdapter; @@ -175,7 +176,10 @@ function isExternalEnvironment(ctx: ProjectRuntimeContext, resource: ResourceSta ); } -function successResult(resource: ResourceState, reason: "destroyed" | "already_gone"): DestroyResourceResult { +function successResult( + resource: ResourceState, + reason: "destroyed" | "reference_removed" | "already_gone", +): DestroyResourceResult { return { resource, status: "success", reason }; } diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 3bc1d24..58ea9fd 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -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"; @@ -122,15 +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, externally_managed: address.type === "environment" && ctx.config.environments?.[address.name]?.environment_id ? true : undefined, - version: options.resourceVersion, + 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(); diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 00861bd..d89b30c 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -120,7 +120,38 @@ export function collectProviderCapabilities( } const caps = def.capabilities; + if (providerName === "qoder") { + // Qoder's /deployments API rejects tunnel_id (HTTP 400 "unknown field"), so + // a declared/inherited tunnel is dropped from the deployment payload and + // server-side runs execute without it. Surface that degradation loudly. + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { + if (deployment.provider && deployment.provider !== providerName) continue; + const tunnel = deployment.tunnel ?? config.agents?.[deployment.agent]?.tunnel; + if (tunnel) { + diagnostics.warning( + `${providerName}.deployment.tunnel.unsupported`, + `deployment.${name}: Qoder's deployment API does not accept tunnel_id; scheduled and triggered runs ` + + `execute in the deployment's environment but without the BYOC tunnel. Create sessions directly for private-network MCP access.`, + { type: "deployment", name, provider: providerName }, + ); + } + } + } + if (providerName !== "qoder") { + for (const [name, env] of Object.entries(config.environments ?? {})) { + // External references are never sent to the provider API, so a + // self_hosted type on them is inert; only managed environments matter. + if (env.environment_id) continue; + if (env.config.type === "self_hosted" && (!env.provider || env.provider === providerName)) { + diagnostics.error( + `${providerName}.environment.self_hosted.unsupported`, + `environment.${name}: self_hosted environments are supported only by Qoder BYOC; ` + + `use type 'cloud' or pin this environment to the qoder provider.`, + { type: "environment", name, provider: providerName }, + ); + } + } for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.tunnel && (!agent.provider || agent.provider === providerName)) { diagnostics.error( diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index bd4cd11..41afcf3 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -211,7 +211,31 @@ function buildActionLevels(actions: PlannedAction[]): PlannedAction[][] { type ResourceExecAdapter = ResourceCrudAdapter & DriftReadAdapter; +// The remote object can vanish after refresh planned an update (deleted +// out-of-band, or soft-deleted in a way refresh cannot see). Failing the whole +// apply on a 404 update is wrong — recreate the resource instead. async function executeAction(action: PlannedAction, provider: ResourceExecAdapter, ctx: ExecContext): Promise<boolean> { + try { + return await executeActionInner(action, provider, ctx); + } catch (err) { + if (action.action !== "update" || !ApiError.isNotFound(err)) throw err; + emitRuntimeFeedback(ctx.onFeedback, { + type: "resource_already_gone", + level: "warning", + action, + resource: action.address, + message: `update ${action.address.type}.${action.address.name} (${action.address.provider}) — not found remotely, recreating`, + }); + ctx.state.removeResource(action.address); + return executeActionInner({ ...action, action: "create" }, provider, ctx); + } +} + +async function executeActionInner( + action: PlannedAction, + provider: ResourceExecAdapter, + ctx: ExecContext, +): Promise<boolean> { const { address } = action; const { type, name } = address; let adopted = false; @@ -292,6 +316,18 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte message: `${action.action} ${action.address.type}.${action.address.name} (${action.address.provider}) — external reference, no remote mutation`, }); } else if (isUpdate) { + // Defense in depth: ownership is a state-level fact. Never push the + // local config onto an environment recorded as externally managed — + // the transition back to managed requires an explicit release + // (`agents state rm` + `agents state import`). + const prior = ctx.state.getResource(address); + if (prior?.externally_managed) { + throw new UserError( + `environment.${name} is recorded as an external reference (${prior.remote_id ?? "unknown id"}); ` + + `refusing to modify it remotely. Restore 'environment_id' to keep it as a reference, or release it first ` + + `with 'agents state rm environment.${name}' (then 'agents state import' to adopt it as a managed resource).`, + ); + } result = await provider.updateEnvironment(existingId!, remoteName, decl); } else { try { @@ -470,7 +506,7 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte throw new UserError(`Unknown resource type: ${type}`); } - const hash = await computeResourceHash(address, ctx.config, ctx.configPath); + const hash = await computeResourceHash(address, ctx.config, ctx.configPath, ctx.state); const comparableHash = computeComparableDesiredHash(address, ctx.config, provider); // After apply, read back the actual remote state to establish the drift @@ -485,10 +521,16 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte remoteSnapshot = remote.snapshot ?? remote.comparable; } + // The externally-managed marker is sticky: it survives applies and is only + // cleared by removing the resource from state (`agents state rm` / destroy). + const priorResource = ctx.state.getResource(address); ctx.state.setResource({ address, remote_id: result.id, - externally_managed: type === "environment" && ctx.config.environments?.[name]?.environment_id ? true : undefined, + externally_managed: + priorResource?.externally_managed || (type === "environment" && ctx.config.environments?.[name]?.environment_id) + ? true + : undefined, version: result.version, content_hash: hash, desired_hash: hash, diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index e6ad918..e3b1612 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -33,7 +33,6 @@ const environmentSchema = z.object({ const tunnelSchema = z.object({ name: z.string().optional(), description: z.string().optional(), - provider: z.string().optional(), /** Pre-existing Qoder tunnel id (e.g. tnl_00xxxx). Tunnels are allocated by Qoder BYOC admin and referenced, not created. */ tunnel_id: z.string(), metadata: z.record(z.string(), z.string()).optional(), diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index 0d16e9b..9c8ed80 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -1,15 +1,21 @@ import { readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { ProjectConfig } from "../types/config.ts"; -import type { ResourceAddress } from "../types/state.ts"; +import type { ResourceAddress, ResourceState } from "../types/state.ts"; import { collectFiles } from "../utils/collect-files.ts"; import { contentHash } from "../utils/hash.ts"; import { getResourceDeclaration } from "./declaration.ts"; +/** Minimal state view the hasher needs to resolve managed reference ids. */ +export interface HashStateLookup { + getResource(address: ResourceAddress): Pick<ResourceState, "remote_id"> | undefined; +} + export async function computeResourceHash( address: ResourceAddress, config: ProjectConfig, basePath?: string, + state?: HashStateLookup, ): Promise<string> { const decl = getDeclaration(address, config); if (!decl) return ""; @@ -22,9 +28,47 @@ export async function computeResourceHash( } } + if (address.type === "deployment") { + const refs = resolveDeploymentReferenceIds(decl as DeploymentRefDecl, config, address.provider, state); + if (refs) return contentHash({ decl, refs }); + } + return contentHash(decl); } +interface DeploymentRefDecl { + agent: string; + environment?: string; +} + +// A deployment's identity includes the *resolved* ids of its reference-type +// inputs that actually reach the wire (the environment id). Those values can +// change while the referenced names stay the same, and a name-only hash would +// never produce an update for the new id. Best-effort: ids that only exist +// after an apply (managed environment remote ids) resolve via state when +// available and stay undefined until then — they are stable once created, so +// no false diffs. The tunnel id is excluded on purpose: Qoder's deployment API +// does not accept tunnel_id, so a changed value would trigger no-op updates. +function resolveDeploymentReferenceIds( + decl: DeploymentRefDecl, + config: ProjectConfig, + provider: string, + state?: HashStateLookup, +): Record<string, string | undefined> | undefined { + const agent = config.agents?.[decl.agent]; + const envName = decl.environment ?? agent?.environment; + if (!envName) return undefined; + + const envDecl = config.environments?.[envName]; + return { + environment_id: + envDecl?.environment_id ?? + (envDecl + ? (state?.getResource({ type: "environment", name: envName, provider })?.remote_id ?? undefined) + : undefined), + }; +} + function getDeclaration(address: ResourceAddress, config: ProjectConfig): unknown | null { return getResourceDeclaration(address, config); } diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 4dbefc3..2a04e4f 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -38,20 +38,71 @@ export async function buildPlan( stateIndex.set(addressKey(res.address), res); } + // Remote-id lookup that is NOT mutated during the loop (stateIndex entries are + // deleted as they are consumed), so deployment hashing can always resolve the + // remote ids of managed reference inputs. + const remoteIdLookup = new Map<string, (typeof state.resources)[number]>(); + for (const res of state.resources) { + remoteIdLookup.set(addressKey(res.address), res); + } + const hashStateLookup = { getResource: (addr: ResourceAddress) => remoteIdLookup.get(addressKey(addr)) }; + // Desired resources: create or update for (const address of sorted) { const key = addressKey(address); - const desiredHash = await computeResourceHash(address, config, options.configPath); + const desiredHash = await computeResourceHash(address, config, options.configPath, hashStateLookup); const existing = stateIndex.get(key); const deps = getDependencies(address, graph); + if (address.type === "environment" && existing) { + const envDecl = config.environments?.[address.name]; + // Ownership is a state-level fact: once an environment is recorded as + // externally managed, removing `environment_id` from the config must NOT + // silently convert it back into a managed resource (apply would push the + // local config onto a remote object OpenCMA never created, and destroy + // would delete it). Require an explicit release instead. + if (existing.externally_managed && envDecl && !envDecl.environment_id) { + diagnostics.error( + "plan.environment.ownership_transition", + `environment.${address.name} is recorded as an external reference (${existing.remote_id ?? "unknown id"}); ` + + `removing 'environment_id' would make OpenCMA modify and eventually delete a remote environment it does not own. ` + + `Restore 'environment_id' to keep it as a reference, or release it first with 'agents state rm environment.${address.name}' ` + + `(then 'agents state import' to adopt the remote as a managed resource).`, + address, + ); + stateIndex.delete(key); + continue; + } + if ( + !existing.externally_managed && + existing.remote_id && + envDecl?.environment_id && + envDecl.environment_id !== existing.remote_id + ) { + diagnostics.warning( + "plan.environment.ownership_orphan", + `environment.${address.name}: switching to external reference '${envDecl.environment_id}' orphans the previously ` + + `managed remote environment '${existing.remote_id}' — it will no longer be tracked or deletable by OpenCMA.`, + 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)" + : "Resource does not exist in state"; + const updateSuffix = isExternalEnv ? " — external reference, no remote mutation" : ""; + if (!existing) { actions.push({ action: "create", address, driftKind: "none", readinessImpact: "blocking", - reason: "Resource does not exist in state", + reason: createReason, after: { content_hash: desiredHash }, dependencies: deps, }); @@ -66,7 +117,7 @@ export async function buildPlan( driftKind: "both", readinessImpact: classifyReadinessImpact("update", changedPaths), changedPaths, - reason: "Local config changed and remote drift detected", + reason: `Local config changed and remote drift detected${updateSuffix}`, before: { content_hash: existing.desired_hash ?? existing.content_hash, remote_hash: existing.remote_hash, @@ -83,7 +134,7 @@ export async function buildPlan( driftKind: "local", readinessImpact: classifyReadinessImpact("update", changedPaths), changedPaths, - reason: "Local config changed", + reason: `Local config changed${updateSuffix}`, before: { content_hash: existing.desired_hash ?? existing.content_hash }, after: { content_hash: desiredHash }, dependencies: deps, @@ -96,7 +147,7 @@ export async function buildPlan( driftKind: "remote", readinessImpact: classifyReadinessImpact("update", changedPaths), changedPaths, - reason: "Remote drift detected", + reason: `Remote drift detected${updateSuffix}`, before: { content_hash: existing.desired_hash ?? existing.content_hash, remote_hash: existing.remote_hash, @@ -130,7 +181,9 @@ export async function buildPlan( address: res.address, driftKind: "none", readinessImpact: "blocking", - reason: "Resource removed from configuration", + reason: res.externally_managed + ? "Remove local reference only — externally managed remote resource is left intact" + : "Resource removed from configuration", before: { content_hash: res.desired_hash ?? res.content_hash }, dependencies: [], }); diff --git a/packages/sdk/src/internal/providers/claude/adapter.ts b/packages/sdk/src/internal/providers/claude/adapter.ts index e6b2cb2..b0dfcdf 100644 --- a/packages/sdk/src/internal/providers/claude/adapter.ts +++ b/packages/sdk/src/internal/providers/claude/adapter.ts @@ -33,6 +33,7 @@ import { buildSessionInfo, exportRemoteResources, locateRemote, + notArchived, toCloudAgent, toCloudEnvironment, toCloudVault, @@ -81,7 +82,9 @@ export class ClaudeAdapter implements ProviderAdapter { }; async findResource(type: ResourceType, name: string, id?: string | null): Promise<RemoteResource | null> { - const raw = await locateRemote(this.client, ClaudeAdapter.ENDPOINT_MAP[type], name, id); + // Claude archives agents (POST /agents/{id}/archive) instead of hard-deleting + // them; an archived ghost must not count as existing for refresh/adoption. + const raw = await locateRemote(this.client, ClaudeAdapter.ENDPOINT_MAP[type], name, id, notArchived); return raw ? toRemoteResource(raw) : null; } diff --git a/packages/sdk/src/internal/providers/drift-support.ts b/packages/sdk/src/internal/providers/drift-support.ts index 8d680b3..a5f3471 100644 --- a/packages/sdk/src/internal/providers/drift-support.ts +++ b/packages/sdk/src/internal/providers/drift-support.ts @@ -22,10 +22,12 @@ export async function readComparableIfSupported( id: string | null, name: string, ): Promise<ComparableRemoteResource | null> { - const read = adapter.readComparableResource; - if (typeof read !== "function" || !supportsFullDrift(adapter, type)) return null; + if (!supportsFullDrift(adapter, type)) return null; + // Invoke as a method on the adapter — extracting it into a local first would + // drop the `this` binding and silently fail for class-based adapters. + if (typeof adapter.readComparableResource !== "function") return null; try { - return await read(type, id, name); + return await adapter.readComparableResource(type, id, name); } catch { return null; } diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 00e4cf3..9792227 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -42,6 +42,7 @@ import { buildSessionInfo, exportRemoteResources, locateRemote, + notArchived, toCloudAgent, toCloudEnvironment, toCloudVault, @@ -93,7 +94,7 @@ export class QoderAdapter implements ProviderAdapter { }; async findResource(type: ResourceType, name: string, id?: string | null): Promise<RemoteResource | null> { - const raw = await locateRemote(this.client, QoderAdapter.ENDPOINT_MAP[type], name, id); + const raw = await locateRemote(this.client, QoderAdapter.ENDPOINT_MAP[type], name, id, notArchived); return raw ? toRemoteResource(raw) : null; } @@ -165,7 +166,7 @@ export class QoderAdapter implements ProviderAdapter { ): Promise<ComparableRemoteResource | null> { if (type !== "agent" && type !== "environment") return null; const endpoint = type === "agent" ? "/agents" : "/environments"; - const raw = await locateRemote(this.client, endpoint, name, id); + const raw = await locateRemote(this.client, endpoint, name, id, notArchived); if (!raw) return null; const comparable = this.normalizeRemote(type, raw); diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 202c637..02c6a52 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -243,7 +243,9 @@ export function mapDeployment( initial_events: mapDeploymentInitialEvents(decl.initial_events), }; - if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id; + // NOTE: tunnel_id is intentionally NOT sent — Qoder's /deployments API rejects + // it (HTTP 400 "unknown field"). Server-side deployment runs cannot carry a + // BYOC tunnel today; validate-config warns when a deployment declares one. if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; const resources = mapDeploymentResources(decl, refs, uploadedFiles); diff --git a/packages/sdk/src/internal/providers/shared.ts b/packages/sdk/src/internal/providers/shared.ts index 3532704..148b178 100644 --- a/packages/sdk/src/internal/providers/shared.ts +++ b/packages/sdk/src/internal/providers/shared.ts @@ -17,6 +17,16 @@ import { resourceNameFromMetadata } from "./sync-mapping.ts"; * object so callers can shape it (RemoteResource vs comparable). `endpoint` * undefined means the type is unsupported on this provider → null. */ +/** + * Some providers soft-delete: DELETE archives the object and GET keeps returning + * it with `archived_at` set, while updates on it fail. For lifecycle purposes an + * archived resource is gone — pass this as locateRemote's `accept` so refresh and + * adoption never treat archived ghosts as existing. + */ +export function notArchived(raw: Record<string, unknown>): boolean { + return raw.archived_at === null || raw.archived_at === undefined; +} + export async function locateRemote( client: BaseApiClient, endpoint: string | undefined, diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index 666586a..b73b14e 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -131,9 +131,8 @@ function resolveTunnelId( throw new UserError("Tunnels are supported only by Qoder BYOC sessions."); } if (options.tunnelId) return options.tunnelId; - if (!tunnelName) return undefined; - const tunnel = config.tunnels?.[tunnelName]; + const tunnel = config.tunnels?.[tunnelName!]; if (!tunnel) { throw new UserError(`Tunnel '${tunnelName}' is not defined in config. Declare it under the 'tunnels:' section.`); } diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 27d3d0d..78a0f43 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -39,7 +39,6 @@ export interface EnvironmentConfig { export interface TunnelDecl { name?: string; description?: string; - provider?: ProviderName; /** Pre-existing Qoder tunnel id (e.g. tnl_00xxxx). Tunnels are allocated by Qoder BYOC admin and referenced, not created. */ tunnel_id: string; metadata?: Record<string, string>; diff --git a/packages/sdk/tests/unit/byoc-ownership.test.ts b/packages/sdk/tests/unit/byoc-ownership.test.ts new file mode 100644 index 0000000..0ea643a --- /dev/null +++ b/packages/sdk/tests/unit/byoc-ownership.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test"; +import { validateProjectConfig } from "../../src/internal/core/validate-config.ts"; +import type { ExecContext } from "../../src/internal/executor/context.ts"; +import { executePlan } from "../../src/internal/executor/executor.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; +import type { ExecutionPlan } from "../../src/internal/types/plan.ts"; +import type { ResourceAddress, StateFile } from "../../src/internal/types/state.ts"; +import { addressKey } from "../../src/internal/types/state.ts"; +import "../../src/internal/providers/all.ts"; + +const emptyState: StateFile = { resources: [] }; + +function byocConfig(): ProjectConfig { + return { + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder" }, + environments: { + byoc: { environment_id: "env_byoc_1", config: { type: "self_hosted" } }, + }, + tunnels: { + internal: { tunnel_id: "tnl_1" }, + }, + agents: { + assistant: { + model: "qmodel_latest", + instructions: "run", + environment: "byoc", + tunnel: "internal", + }, + }, + deployments: { + daily: { agent: "assistant" }, + }, + }; +} + +function managedByocConfig(): ProjectConfig { + // Same environment block but WITHOUT `environment_id` — i.e. the user deleted + // the one line that marks it as an external reference. + const config = byocConfig(); + config.environments = { byoc: { config: { type: "self_hosted" } } }; + return config; +} + +function markedEnvState(): StateFile { + return { + resources: [ + { + address: { type: "environment", name: "byoc", provider: "qoder" }, + remote_id: "env_byoc_1", + externally_managed: true, + content_hash: "h_old", + }, + ], + }; +} + +describe("planner environment ownership", () => { + test("blocks converting an external environment back to managed", async () => { + const plan = await buildPlan(managedByocConfig(), markedEnvState()); + + const errors = plan.diagnostics.filter((d) => d.severity === "error"); + expect(errors.some((d) => d.code === "plan.environment.ownership_transition")).toBe(true); + expect(errors[0]?.message).toMatch(/agents state rm/); + // No update action may be generated for the environment at all. + expect(plan.actions.some((a) => a.address.type === "environment")).toBe(false); + }); + + test("still allows the reference to be released by removing the whole block", async () => { + const config = managedByocConfig(); + config.environments = undefined; + config.agents = undefined; + config.deployments = undefined; + + const plan = await buildPlan(config, markedEnvState()); + + expect(plan.diagnostics.filter((d) => d.severity === "error")).toEqual([]); + const del = plan.actions.find((a) => a.action === "delete"); + expect(del?.address.type).toBe("environment"); + expect(del?.reason).toMatch(/left intact/); + }); + + test("warns when switching a managed environment to a different external id", async () => { + const state: StateFile = { + resources: [ + { + address: { type: "environment", name: "byoc", provider: "qoder" }, + remote_id: "env_old_managed", + content_hash: "h_old", + }, + ], + }; + + const plan = await buildPlan(byocConfig(), state); + + const warnings = plan.diagnostics.filter((d) => d.severity === "warning"); + const orphan = warnings.find((d) => d.code === "plan.environment.ownership_orphan"); + expect(orphan).toBeDefined(); + expect(orphan?.message).toContain("env_old_managed"); + // The reference switch itself is allowed: an update records the new id. + expect(plan.actions.some((a) => a.action === "update" && a.address.type === "environment")).toBe(true); + }); + + test("labels external environment create as reference-only", async () => { + const plan = await buildPlan(byocConfig(), emptyState); + const create = plan.actions.find((a) => a.action === "create" && a.address.type === "environment"); + expect(create?.reason).toMatch(/no remote mutation/); + }); + + test("deployment does NOT update when only the tunnel id value changes", async () => { + // Qoder's deployment API rejects tunnel_id, so the value never reaches the + // wire; changing it must not churn no-op deployment updates. + const config = byocConfig(); + const plan1 = await buildPlan(config, emptyState); + const creates = plan1.actions.filter((a) => a.action === "create"); + + const state: StateFile = { + resources: creates.map((a) => ({ + address: a.address, + remote_id: `fake_${a.address.name}`, + content_hash: "", + })), + }; + const lookup = { + getResource: (addr: ResourceAddress) => state.resources.find((r) => addressKey(r.address) === addressKey(addr)), + }; + for (const res of state.resources) { + res.content_hash = await computeResourceHash(res.address, config, undefined, lookup); + } + + const changed = byocConfig(); + changed.tunnels = { internal: { tunnel_id: "tnl_2" } }; + const plan2 = await buildPlan(changed, state); + + expect(plan2.actions.filter((a) => a.action !== "no-op")).toEqual([]); + }); + + test("deployment updates when the external environment id value changes", async () => { + const config = byocConfig(); + const plan1 = await buildPlan(config, emptyState); + const creates = plan1.actions.filter((a) => a.action === "create"); + + const state: StateFile = { + resources: creates.map((a) => ({ + address: a.address, + remote_id: `fake_${a.address.name}`, + content_hash: "", + })), + }; + const lookup = { + getResource: (addr: ResourceAddress) => state.resources.find((r) => addressKey(r.address) === addressKey(addr)), + }; + for (const res of state.resources) { + res.content_hash = await computeResourceHash(res.address, config, undefined, lookup); + } + + const changed = byocConfig(); + changed.environments = { byoc: { environment_id: "env_byoc_2", config: { type: "self_hosted" } } }; + const plan2 = await buildPlan(changed, state); + + const updatedTypes = plan2.actions + .filter((a) => a.action === "update") + .map((a) => a.address.type) + .sort(); + // Environment re-records the reference; deployment is rebound to the new id. + expect(updatedTypes).toEqual(["deployment", "environment"]); + }); +}); + +describe("executor environment ownership defense", () => { + test("refuses to update a marked environment when config dropped environment_id", async () => { + const calls: string[] = []; + const provider = { + name: "qoder", + validate: async () => {}, + findResource: async () => null, + updateEnvironment: async () => { + calls.push("updateEnvironment"); + return { id: "env_byoc_1", type: "environment" }; + }, + deleteEnvironment: async () => { + calls.push("deleteEnvironment"); + }, + } as unknown as ProviderAdapter; + + const address = { type: "environment" as const, name: "byoc", provider: "qoder" }; + const plan: ExecutionPlan = { + actions: [ + { + action: "update", + address, + reason: "Local config changed", + before: { content_hash: "h_old" }, + after: { content_hash: "h_new" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + + const state = StateManager.initialize("/tmp/byoc-ownership-exec.json"); + state.setResource({ address, remote_id: "env_byoc_1", externally_managed: true, content_hash: "h_old" }); + + const ctx: ExecContext = { + config: managedByocConfig(), + configPath: "/tmp/agents.yaml", + providers: new Map([["qoder", provider]]), + state, + }; + + const result = await executePlan(plan, ctx); + + expect(calls).toEqual([]); + expect(result.results[0]?.status).toBe("failed"); + expect(result.results[0]?.error?.message).toMatch(/external reference/); + // The marker must survive the refused apply. + expect(state.getResource(address)?.externally_managed).toBe(true); + }); +}); + +describe("validate self_hosted capability", () => { + test("errors for a managed self_hosted environment on a non-qoder provider", () => { + const config: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + environments: { + wrong: { config: { type: "self_hosted" } }, + }, + }; + + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((d) => d.code === "bailian.environment.self_hosted.unsupported")).toBe(true); + }); + + test("warns that qoder deployments cannot carry a tunnel", () => { + const diagnostics = validateProjectConfig(byocConfig()); + const warning = diagnostics.find((d) => d.code === "qoder.deployment.tunnel.unsupported"); + expect(warning?.severity).toBe("warning"); + expect(warning?.message).toMatch(/does not accept tunnel_id/); + }); + + test("ignores self_hosted on external references and on qoder", () => { + const external: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + environments: { + ref: { environment_id: "env_x", config: { type: "self_hosted" } }, + }, + }; + expect(validateProjectConfig(external).filter((d) => d.code.includes("self_hosted"))).toEqual([]); + + const qoder = byocConfig(); + expect(validateProjectConfig(qoder).filter((d) => d.code.includes("self_hosted"))).toEqual([]); + }); +}); diff --git a/packages/sdk/tests/unit/claude-archived.test.ts b/packages/sdk/tests/unit/claude-archived.test.ts new file mode 100644 index 0000000..d5bd488 --- /dev/null +++ b/packages/sdk/tests/unit/claude-archived.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { ClaudeAdapter } from "../../src/internal/providers/claude/adapter.ts"; + +// Claude soft-deletes agents via POST /agents/{id}/archive; the object keeps +// showing up in GETs with archived_at set. findResource must treat archived +// resources as gone, or refresh plans updates against ghosts instead of +// recreating them. +describe("Claude archived resources are treated as gone", () => { + function adapterWith(getImpl: (path: string) => Promise<unknown>, paged: Record<string, unknown>[] = []) { + const adapter = new ClaudeAdapter("sk-test", undefined, "tmp") as any; + adapter.client = { get: getImpl, getAllPaged: async () => paged }; + return adapter; + } + + test("findResource returns null for an archived agent looked up by id", async () => { + const adapter = adapterWith(async () => ({ + id: "agent_1", + name: "a", + archived_at: "2026-07-19T00:00:00Z", + })); + expect(await adapter.findResource("agent", "a", "agent_1")).toBeNull(); + }); + + test("findResource returns active resources", async () => { + const adapter = adapterWith(async () => ({ id: "agent_1", name: "a", archived_at: null })); + expect((await adapter.findResource("agent", "a", "agent_1"))?.id).toBe("agent_1"); + }); + + test("name scan skips archived entries and matches active ones", async () => { + const adapter = adapterWith(async () => { + throw new Error("id path must not be used"); + }, [ + { id: "agent_archived", name: "a", archived_at: "2026-07-19T00:00:00Z" }, + { id: "agent_active", name: "a" }, + ]); + expect((await adapter.findResource("agent", "a"))?.id).toBe("agent_active"); + }); +}); diff --git a/packages/sdk/tests/unit/deployment.test.ts b/packages/sdk/tests/unit/deployment.test.ts index 78a63f8..c9a898a 100644 --- a/packages/sdk/tests/unit/deployment.test.ts +++ b/packages/sdk/tests/unit/deployment.test.ts @@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { resolveDeploymentRefs } from "../../src/internal/executor/resolver.ts"; import { loadConfig } from "../../src/internal/parser/index.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; import { buildPlan } from "../../src/internal/planner/planner.ts"; import type { ResolvedDeploymentRefs } 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 { DeploymentDecl, ProjectConfig } from "../../src/internal/types/config.ts"; -import type { StateFile } from "../../src/internal/types/state.ts"; +import type { ResourceAddress, StateFile } from "../../src/internal/types/state.ts"; +import { addressKey } from "../../src/internal/types/state.ts"; import "../../src/internal/providers/claude/index.ts"; import "../../src/internal/providers/qoder/index.ts"; @@ -260,9 +262,17 @@ describe("deployment plan / diff", () => { resources: creates.map((a) => ({ address: a.address, remote_id: `fake_${a.address.name}_${a.address.provider}`, - content_hash: (a.after as { content_hash?: string })?.content_hash ?? "", + content_hash: "", })), }; + // Mirror executor semantics: the stored hash is recomputed post-apply, when + // reference ids (e.g. the environment's remote id) resolve from state. + const lookup = { + getResource: (addr: ResourceAddress) => state.resources.find((r) => addressKey(r.address) === addressKey(addr)), + }; + for (const res of state.resources) { + res.content_hash = await computeResourceHash(res.address, config, undefined, lookup); + } const plan2 = await buildPlan(config, state); expect(plan2.actions.filter((a) => a.action !== "no-op").length).toBe(0); diff --git a/packages/sdk/tests/unit/destroy-runtime.test.ts b/packages/sdk/tests/unit/destroy-runtime.test.ts index 5398b52..d3b0c72 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -158,6 +158,7 @@ describe("destroy runtime", () => { const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime)); expect(result.destroyed).toBe(1); + expect(result.results[0]?.reason).toBe("reference_removed"); expect(calls).toEqual([]); expect(runtime.state.listResources()).toEqual([]); }); diff --git a/packages/sdk/tests/unit/drift-detection.test.ts b/packages/sdk/tests/unit/drift-detection.test.ts index 06ba207..b9e757d 100644 --- a/packages/sdk/tests/unit/drift-detection.test.ts +++ b/packages/sdk/tests/unit/drift-detection.test.ts @@ -231,3 +231,42 @@ describe("planner drift classification", () => { expect(plan.actions[0]!.reason).toBe("Local config changed and remote drift detected"); }); }); + +describe("Qoder archived resources are treated as gone", () => { + function adapterWith(getImpl: (path: string) => Promise<unknown>, paged: Record<string, unknown>[] = []) { + const adapter = new QoderAdapter("pt-test", undefined, "tmp") as any; + adapter.client = { get: getImpl, getAllPaged: async () => paged }; + return adapter; + } + + test("readComparableResource returns null for an archived agent", async () => { + const adapter = adapterWith(async () => ({ + id: "agent_1", + name: "a", + archived_at: "2026-07-19T00:00:00Z", + })); + expect(await adapter.readComparableResource("agent", "agent_1", "a")).toBeNull(); + }); + + test("findResource returns null for an archived resource, found when active", async () => { + const archived = adapterWith(async () => ({ + id: "env_1", + name: "e", + archived_at: "2026-07-19T00:00:00Z", + })); + expect(await archived.findResource("environment", "e", "env_1")).toBeNull(); + + const active = adapterWith(async () => ({ id: "env_1", name: "e", archived_at: null })); + expect((await active.findResource("environment", "e", "env_1"))?.id).toBe("env_1"); + }); + + test("name scan skips archived entries and matches active ones", async () => { + const adapter = adapterWith(async () => { + throw new Error("id path must not be used"); + }, [ + { id: "agent_archived", name: "a", archived_at: "2026-07-19T00:00:00Z" }, + { id: "agent_active", name: "a", archived_at: null }, + ]); + expect((await adapter.findResource("agent", "a"))?.id).toBe("agent_active"); + }); +}); diff --git a/packages/sdk/tests/unit/drift-support.test.ts b/packages/sdk/tests/unit/drift-support.test.ts new file mode 100644 index 0000000..b33f828 --- /dev/null +++ b/packages/sdk/tests/unit/drift-support.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { readComparableIfSupported } from "../../src/internal/providers/drift-support.ts"; +import type { ComparableRemoteResource } from "../../src/internal/providers/interface.ts"; +import type { DriftReadAdapter } from "../../src/internal/providers/resource-workflow.ts"; +import type { ResourceType } from "../../src/internal/types/state.ts"; + +// Regression: readComparableIfSupported must invoke readComparableResource as a +// method on the adapter. Extracting it into a local drops `this`, so class-based +// adapters (qoder, bailian) silently returned null and the post-apply drift +// baseline fell back to the desired hash — producing phantom "Remote drift +// detected" on every subsequent plan. +class ClassBasedAdapter { + readonly name = "classy"; + private readonly comparable = { config: { type: "cloud" } }; + + getDriftSupport(type: ResourceType): "full" | "unsupported" { + return type === "environment" ? "full" : "unsupported"; + } + + async readComparableResource(type: ResourceType, id: string | null): Promise<ComparableRemoteResource | null> { + // Touch `this` like the real class adapters do (this.client, ...). + return { id: id ?? "env_1", type, comparable: this.comparable, snapshot: this.comparable }; + } +} + +describe("readComparableIfSupported", () => { + test("works with class-based adapters whose methods rely on `this`", async () => { + const adapter = new ClassBasedAdapter() as unknown as DriftReadAdapter; + const remote = await readComparableIfSupported(adapter, "environment", "env_1", "any"); + expect(remote?.id).toBe("env_1"); + }); + + test("returns null when drift is unsupported for the resource type", async () => { + const adapter = new ClassBasedAdapter() as unknown as DriftReadAdapter; + const remote = await readComparableIfSupported(adapter, "vault", "vault_1", "any"); + expect(remote).toBeNull(); + }); +}); diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index a48c0d7..45e7c52 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -314,3 +314,86 @@ describe("executor external-reference environment", () => { expect(state.getResource(address)?.externally_managed).toBe(true); }); }); + +describe("executor recreate-on-vanished-remote", () => { + test("falls back to create when an update fails with 404", async () => { + const calls: string[] = []; + const provider = { + name: "bailian", + validate: async () => {}, + findResource: async () => null, + updateEnvironment: async () => { + calls.push("updateEnvironment"); + throw new ApiError(404, "Environment 'env_old' was not found.", "test"); + }, + createEnvironment: async () => { + calls.push("createEnvironment"); + return { id: "env_new", type: "environment" }; + }, + } as unknown as ProviderAdapter; + + const address = { type: "environment" as const, name: "my-env", provider: "bailian" }; + const plan: ExecutionPlan = { + actions: [ + { + action: "update", + address, + reason: "Local config changed", + before: { content_hash: "h_old" }, + after: { content_hash: "h_new" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + + const state = StateManager.initialize(tmpPath()); + state.setResource({ address, remote_id: "env_old", content_hash: "h_old" }); + + const result = await executePlan(plan, makeCtx(provider, state)); + + expect(calls).toEqual(["updateEnvironment", "createEnvironment"]); + expect(result.results[0]?.status).toBe("success"); + expect(state.getResource(address)?.remote_id).toBe("env_new"); + }); + + test("does not retry non-404 update failures", async () => { + const calls: string[] = []; + const provider = { + name: "bailian", + validate: async () => {}, + findResource: async () => null, + updateEnvironment: async () => { + calls.push("updateEnvironment"); + throw new ApiError(500, "boom", "test"); + }, + createEnvironment: async () => { + calls.push("createEnvironment"); + return { id: "env_new", type: "environment" }; + }, + } as unknown as ProviderAdapter; + + const address = { type: "environment" as const, name: "my-env", provider: "bailian" }; + const plan: ExecutionPlan = { + actions: [ + { + action: "update", + address, + reason: "Local config changed", + before: { content_hash: "h_old" }, + after: { content_hash: "h_new" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + + const state = StateManager.initialize(tmpPath()); + state.setResource({ address, remote_id: "env_old", content_hash: "h_old" }); + + const result = await executePlan(plan, makeCtx(provider, state)); + + expect(calls).toEqual(["updateEnvironment"]); + expect(result.results[0]?.status).toBe("failed"); + }); +}); diff --git a/packages/sdk/tests/unit/import-resource.test.ts b/packages/sdk/tests/unit/import-resource.test.ts index cf5e4d0..bb57593 100644 --- a/packages/sdk/tests/unit/import-resource.test.ts +++ b/packages/sdk/tests/unit/import-resource.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; import { importResource } from "../../src/internal/core/resource-runtime.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; import type { ProjectConfig } from "../../src/internal/types/config.ts"; import type { ResourceAddress } from "../../src/internal/types/state.ts"; @@ -61,4 +62,27 @@ describe("importResource", () => { await expect(importResource(ctx(state), address, "dep_1")).rejects.toThrow(/Invalid resource type/); expect(state.listResources()).toEqual([]); }); + + test("records the remote comparable as the drift baseline when readable", async () => { + const state = StateManager.initialize("/tmp/import-resource-baseline.json"); + const address: ResourceAddress = { type: "environment", name: "bailian-cli", provider: "bailian" }; + const provider = { + name: "bailian", + getDriftSupport: (type: string) => (type === "environment" ? "full" : "unsupported"), + readComparableResource: async () => ({ + id: "env_remote_1", + type: "environment", + comparable: { config: { type: "cloud" } }, + snapshot: { config: { type: "cloud" } }, + }), + } as unknown as ProviderAdapter; + + const runtime = ctx(state); + runtime.providers = new Map([["bailian", provider]]); + const recorded = await importResource(runtime, address, "env_remote_1"); + + expect(recorded.desired_comparable_hash).toBeTruthy(); + expect(recorded.remote_hash).toBe(recorded.desired_comparable_hash); + expect(recorded.drift_status).toBe("in_sync"); + }); }); diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index 3b3f9cb..0255085 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -232,7 +232,8 @@ describe("Qoder mapDeployment", () => { expect(body.name).toBe("daily-report"); expect(body.agent).toEqual({ id: "agent_123", type: "agent", version: 3 }); expect(body.environment_id).toBe("env_456"); - expect(body.tunnel_id).toBe("tnl_789"); + // Qoder's /deployments API rejects tunnel_id (HTTP 400) — never sent. + expect(body.tunnel_id).toBeUndefined(); expect(body.initial_events).toEqual([ { type: "user.message", content: [{ type: "text", text: "Run the daily report" }] }, { type: "system.message", content: [{ type: "text", text: "You are punctual" }] },