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..691e415 --- /dev/null +++ b/docs/guides/use-byoc-environments.md @@ -0,0 +1,97 @@ +# Use BYOC environments + +Use a bring-your-own-cloud (BYOC) environment when an administrator has already provisioned a self-hosted Qoder environment and, optionally, a tunnel to private services. OpenCMA references those resources; it does not own their lifecycle. + +## Prerequisites + +Ask the environment administrator for: + +- an environment ID, such as `env_00xxxx`; +- a tunnel ID, such as `tnl_00xxxx`, when the agent needs access to private services; +- the Qoder credentials needed to manage the agent and start sessions. + +Do not commit real IDs, private hostnames, or credentials. Use environment variables for credentials and keep live deployment configurations outside the repository. + +## Configure the external resources + +Declare the provisioned environment with `environment_id` and `self_hosted`. Declare a tunnel by its existing ID, then reference both from the agent. + +```yaml +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + +defaults: + provider: qoder + +environments: + byoc-environment: + environment_id: env_00xxxx + config: + type: self_hosted + +tunnels: + internal-network: + tunnel_id: tnl_00xxxx + +agents: + private-service-assistant: + model: qmodel_latest + instructions: You can use the configured private services. + environment: byoc-environment + tunnel: internal-network + tools: + builtin: [Bash, Read] +``` + +`tunnel` is supported only for Qoder BYOC sessions and deployments. Omit the entire `tunnels` section and the agent's `tunnel` field when no private-network tunnel is needed. + +## Apply and run + +Apply the configuration to provision or update managed resources such as the agent. The declared environment and tunnel are only recorded as references. + +```bash +agents apply -f agents.yaml +agents session run "Check the private service status" --agent private-service-assistant -f agents.yaml +``` + +You can override the configured IDs for a one-off session without changing the YAML: + +```bash +agents session run "Check the private service status" \ + --agent private-service-assistant \ + --environment-id env_00xxxx \ + --tunnel-id tnl_00xxxx \ + -f agents.yaml +``` + +## Lifecycle and cleanup + +When `environment_id` is present, OpenCMA never creates, updates, or remotely deletes that environment. Tunnels are always references and are never managed by OpenCMA. + +OpenCMA records external ownership in its local state, and the record is sticky: + +- Remove the environment declaration entirely and run `agents apply` or `agents destroy`: only the local state record is removed; the administrator-managed environment remains intact. +- Remove only the `environment_id` line while keeping the environment block: `plan`/`apply` fail with an `plan.environment.ownership_transition` error. Silently converting the reference into a managed resource would let OpenCMA modify — and eventually delete — a remote environment it does not own. To take over management deliberately, release the reference first with `agents state rm environment.` and adopt the remote with `agents state import`. +- Point `environment_id` at a different existing environment: the reference is re-recorded and any deployments using it are updated. If the environment was previously managed by OpenCMA under a different remote ID, `plan` warns that the old remote is no longer tracked. + +Deleting an agent, session, vault, or other managed resource still follows its normal lifecycle. Use the provider's administrator tooling to modify or delete BYOC environments and tunnels. + +## Deployments and tunnels + +A deployment that references the BYOC environment runs in that environment, and changing the referenced `environment_id` value updates the deployment on the next `apply`. Qoder's deployment API currently rejects `tunnel_id`, however, so a declared (or agent-inherited) tunnel is not sent with the deployment: scheduled and triggered runs execute without the tunnel, and `validate`/`plan` emits a `qoder.deployment.tunnel.unsupported` warning. Use sessions for workloads that need private-network MCP access. + +## Troubleshooting + +| Symptom | Check | +|--------|-------| +| Session cannot reach a private service | Confirm the supplied tunnel ID is enabled for the environment and that the service hostname is reachable from the private network. | +| `Tunnel '...' is not defined in config` | Add the name under `tunnels`, or pass `--tunnel-id` for a one-off session. | +| Tunnel unsupported diagnostic | Ensure the agent or deployment targets Qoder; tunnels are not sent to other providers. | +| Environment cannot be resolved | Verify the administrator-provided `environment_id` and the agent's `environment` reference. | +| `plan.environment.ownership_transition` error | Restore `environment_id`, or release the reference with `agents state rm environment.` 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 40dcd90..a20b996 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. Removing this line later is blocked as an ownership error — release first with `agents state rm` (see [Use BYOC environments](../guides/use-byoc-environments.md)). | +| `config.type` | `"cloud"` \| `"self_hosted"` | yes | Environment type. `self_hosted` is used for Qoder BYOC. | | `config.networking.type` | `"unrestricted"` \| `"limited"` | no | Network policy. | | `config.networking.allow_mcp_servers` | boolean | no | Allow outbound MCP. | | `config.networking.allow_package_managers` | boolean | no | Allow package managers. | @@ -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 (see note below) vaults: [ ] memory_stores: [ ] resources: [ DeploymentResource ] @@ -226,6 +243,8 @@ deployments: `initial_events` is a discriminated union; `schedule.expression` must be a 5-field cron expression. +> **Deployment `tunnel` caveat:** Qoder's deployment API does not accept `tunnel_id`, so the tunnel is dropped from the deployment payload and server-side runs execute without it (`validate`/`plan` emits a warning). Use sessions for private-network MCP access; see [Use BYOC environments](../guides/use-byoc-environments.md). + ### Initial events | Type | Fields | 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 | 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/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 211f9ba..7e984ec 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -194,6 +194,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") @@ -229,6 +232,9 @@ sessionCmd .addOption(configFileOption()) .option("--agent <name>", "Agent name (auto-detected when only one agent is configured)") .option("--environment <name>", "Override agent's declared environment") + .option("--environment-id <id>", "Use an explicit remote environment id instead of the configured one") + .option("--tunnel <name>", "Override agent's declared tunnel") + .option("--tunnel-id <id>", "Use an explicit remote tunnel id instead of the configured one") .option("--vault <name>", "Override agent's declared vault") .option("--memory-stores <names>", "Override agent's declared memory stores (comma-separated)") .option("--title <title>", "Session title") diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index b8cee29..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" @@ -95,6 +96,14 @@ async function destroyOneResource( resource: ResourceState, options: DestroyProjectOptions, ): Promise<DestroyResourceResult> { + // BYOC environments are provisioned and owned by QCA. `environment_id` means + // this project only references that environment, so destroy must never make a + // remote lifecycle call for it. + if (isExternalEnvironment(ctx, resource)) { + ctx.state.removeResource(resource.address); + return successResult(resource, "reference_removed"); + } + let provider: ProviderAdapter; try { provider = getRuntimeProvider(ctx, resource.address.provider); @@ -160,7 +169,17 @@ async function destroyOneResource( } } -function successResult(resource: ResourceState, reason: "destroyed" | "already_gone"): DestroyResourceResult { +function isExternalEnvironment(ctx: ProjectRuntimeContext, resource: ResourceState): boolean { + return ( + resource.address.type === "environment" && + (resource.externally_managed || Boolean(ctx.config.environments?.[resource.address.name]?.environment_id)) + ); +} + +function successResult( + resource: ResourceState, + reason: "destroyed" | "reference_removed" | "already_gone", +): DestroyResourceResult { return { resource, status: "success", reason }; } diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 3579e49..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,13 +124,27 @@ export async function importResource( throw new UserError(`Planned ${address.type}.${address.name} is missing a content hash.`); } + // Read back the actual remote state as the drift baseline. Without it the + // first plan after import would compare the remote against the *desired* + // comparable and report a one-time phantom "Remote drift detected" — most + // visibly for external-reference environments OpenCMA never configured. + const provider = ctx.providers.get(address.provider); + const remote = provider ? await readComparableIfSupported(provider, address.type, remoteId, address.name) : null; + const remoteHash = remote ? stableContentHash(remote.comparable) : undefined; + const resource: ResourceState = { address, remote_id: remoteId, - version: options.resourceVersion, + externally_managed: + address.type === "environment" && ctx.config.environments?.[address.name]?.environment_id ? true : undefined, + version: options.resourceVersion ?? remote?.version, content_hash: contentHash, desired_hash: contentHash, + desired_comparable_hash: remoteHash, desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)), + remote_hash: remoteHash, + remote_snapshot: remote ? (remote.snapshot ?? remote.comparable) : undefined, + drift_status: remoteHash ? "in_sync" : undefined, }; ctx.state.setResource(resource); await ctx.state.save(); 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..d89b30c 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,58 @@ 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( + `${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..41afcf3 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"); @@ -196,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; @@ -206,6 +245,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 +304,30 @@ 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) { + // 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 { @@ -434,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 @@ -449,9 +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: + 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/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..e3b1612 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -20,14 +20,24 @@ 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(), + /** 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 +180,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 +242,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 +262,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/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/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/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 8fb9ee2..02c6a52 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,9 @@ export function mapDeployment( initial_events: mapDeploymentInitialEvents(decl.initial_events), }; + // 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); @@ -520,6 +524,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 +546,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..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, @@ -56,6 +66,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..b73b14e 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,26 @@ 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; + + 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..78a0f43 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,26 @@ 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; + /** 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 +134,7 @@ export interface AgentDecl { model: string | Record<ProviderName, ModelSpec>; instructions: string; environment?: string; + tunnel?: string; provider?: ProviderName; tools?: AgentToolsDecl; mcp_servers?: McpServerDecl[]; @@ -171,6 +183,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/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/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..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"; @@ -112,6 +114,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", () => { @@ -216,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 3f7a19e..d3b0c72 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -148,6 +148,34 @@ 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(result.results[0]?.reason).toBe("reference_removed"); + 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/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 b54e5c6..45e7c52 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,208 @@ 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); + }); +}); + +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 4c0b933..0255085 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,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"); + // 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" }] }, 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); +});