From 10fc90211c7334d2554fa97160347f9384c1ed71 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Wed, 12 Aug 2026 11:14:21 +0800 Subject: [PATCH] support Qoder environment setup scripts Change-Id: I6396af78a9ea6408645bfa1cdcabafac0936b323 --- docs/guides/configure-an-agent.md | 6 ++ docs/guides/configure-an-agent.zh-CN.md | 12 +++ docs/guides/deploy-to-qoder.md | 8 ++ docs/reference/configuration.md | 6 ++ examples/qoder/full/agents.yaml | 4 + .../.openspec.yaml | 2 + .../qoder-environment-setup-script/design.md | 45 +++++++++ .../proposal.md | 27 ++++++ .../specs/environment-setup-script/spec.md | 61 ++++++++++++ .../qoder-environment-setup-script/tasks.md | 27 ++++++ .../sdk/src/internal/core/validate-config.ts | 35 +++++++ packages/sdk/src/internal/parser/schema.ts | 5 + .../src/internal/providers/qoder/adapter.ts | 19 ++-- .../src/internal/providers/qoder/mapper.ts | 17 +++- packages/sdk/src/internal/types/config.ts | 2 + packages/sdk/tests/e2e/drift-live.ts | 79 +++++++++++++++ .../e2e/qoder-adapter-pagination.test.ts | 97 ++++++++++++++++++- .../fixtures/qoder-drift-environment.json | 12 ++- .../sdk/tests/unit/drift-detection.test.ts | 27 ++++++ packages/sdk/tests/unit/parser.test.ts | 25 +++++ .../sdk/tests/unit/validate-config.test.ts | 64 ++++++++++++ 21 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 openspec/changes/qoder-environment-setup-script/.openspec.yaml create mode 100644 openspec/changes/qoder-environment-setup-script/design.md create mode 100644 openspec/changes/qoder-environment-setup-script/proposal.md create mode 100644 openspec/changes/qoder-environment-setup-script/specs/environment-setup-script/spec.md create mode 100644 openspec/changes/qoder-environment-setup-script/tasks.md diff --git a/docs/guides/configure-an-agent.md b/docs/guides/configure-an-agent.md index 20911d8..8c8d756 100644 --- a/docs/guides/configure-an-agent.md +++ b/docs/guides/configure-an-agent.md @@ -59,10 +59,16 @@ environments: packages: apt: [git, curl] npm: [typescript] + setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/ready || date -u > /data/workspace/.openagentpack/ready ``` Reference an environment from an agent with `environment: dev`. +Qoder supports `config.setup_script` for both cloud and self-hosted environments. It runs the script with `/bin/bash -lc` after declared packages are installed. The UTF-8 limit is 64 KB and the timeout is 10 minutes; a non-zero exit prevents the Session from starting. The script runs once per sandbox and runs again when that sandbox is rebuilt, so make it idempotent. Do not embed credentials—use vaults or environment-backed secret references. Other providers currently reject `setup_script` rather than silently ignoring it. + ## Instructions `instructions` accepts either an inline string or a path to a file: diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index 0b55a98..3610281 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -95,6 +95,10 @@ environments: packages: apt: [git, curl] npm: [typescript] + setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/ready || date -u > /data/workspace/.openagentpack/ready metadata: team: platform ``` @@ -122,6 +126,14 @@ packages: go: [golang.org/x/tools/gopls@latest] ``` +Qoder 请求目前只支持 `apt`、`npm` 和 `pip`。`cargo`、`gem`、`go` 是 Qoder 响应中的保留字段,不能在投向 Qoder 的配置中声明非空值;如有需要,可通过 `setup_script` 安装。 + +### 启动脚本(Qoder) + +Qoder 的 cloud 与 self-hosted Environment 都支持 `config.setup_script`。依赖包安装完成后,脚本会通过 `/bin/bash -lc` 执行;UTF-8 最大 64 KB,超时 10 分钟,非零退出会导致 Session 启动失败。脚本在同一 sandbox 中只执行一次,sandbox 重建后会再次执行,因此必须保持幂等。不要把令牌或密码写入脚本,应使用 Vault 或环境变量引用。其他 Provider 当前会明确拒绝 `setup_script`,不会静默忽略。 + +受管理的 Qoder `self_hosted` Environment,其 `config` 只能包含 `type` 和可选的 `setup_script`;网络与预装包配置仅适用于 cloud Environment。带 `environment_id` 的外部引用仍不由 OpenAgentPack 修改。 + --- ## 挂载技能包 diff --git a/docs/guides/deploy-to-qoder.md b/docs/guides/deploy-to-qoder.md index dd039b3..e1fc6f4 100644 --- a/docs/guides/deploy-to-qoder.md +++ b/docs/guides/deploy-to-qoder.md @@ -61,6 +61,12 @@ environments: type: cloud networking: type: unrestricted + packages: + npm: ["pnpm@9"] + setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/ready || printf 'ready\n' > /data/workspace/.openagentpack/ready agents: assistant: @@ -73,6 +79,8 @@ agents: builtin: [read, glob, grep, web_search, web_fetch] ``` +Qoder runs `setup_script` after package installation with `/bin/bash -lc`. Scripts are limited to 64 KB of UTF-8 text and 10 minutes, and a non-zero exit prevents Session startup. Make them idempotent because they run again whenever the sandbox is rebuilt. Use vaults for credentials; never place secrets directly in a script. Qoder package declarations accept `apt`, `npm`, and `pip` only. + ## What Qoder uniquely supports - **Memory stores** — persistent context for an agent. See [`examples/qoder/with-memory/`](../../examples/qoder/with-memory/). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f06101d..ee842f1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -133,6 +133,7 @@ environments: type: cloud | self_hosted networking: { ... } packages: { ... } + setup_script: metadata: { : } ``` @@ -145,8 +146,13 @@ environments: | `config.networking.allow_package_managers` | boolean | no | Allow package managers. | | `config.networking.allowed_hosts` | string[] | no | Allow-list for `limited` networks. | | `config.packages.apt` \| `pip` \| `npm` \| `cargo` \| `gem` \| `go` | string[] | no | Preinstalled packages. | +| `config.setup_script` | string | no | Sandbox setup script. Qoder runs it with `/bin/bash -lc` after package installation; maximum UTF-8 size is 64 KB. Other providers currently reject this field. | | `metadata` | map | no | Free-form metadata. | +Qoder accepts only `apt`, `npm`, and `pip` in package requests. Its API may return empty `cargo`, `gem`, and `go` arrays as reserved response fields, but declaring non-empty values for them is rejected locally. Setup scripts run while a new sandbox is prepared, time out after 10 minutes, and a non-zero exit prevents the Session from starting. Keep scripts idempotent and use vault-backed credentials instead of embedding secrets. + +For a managed Qoder `self_hosted` environment, `config` accepts only `type` and optional `setup_script`; networking and packages belong to cloud environments. External `environment_id` references remain unmanaged. + ## Tunnel (Qoder BYOC) ```yaml diff --git a/examples/qoder/full/agents.yaml b/examples/qoder/full/agents.yaml index 78877e5..db1f38f 100644 --- a/examples/qoder/full/agents.yaml +++ b/examples/qoder/full/agents.yaml @@ -32,6 +32,10 @@ environments: packages: apt: [git, curl] pip: [requests] + setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/ready || printf 'ready\n' > /data/workspace/.openagentpack/ready metadata: team: platform diff --git a/openspec/changes/qoder-environment-setup-script/.openspec.yaml b/openspec/changes/qoder-environment-setup-script/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/qoder-environment-setup-script/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/qoder-environment-setup-script/design.md b/openspec/changes/qoder-environment-setup-script/design.md new file mode 100644 index 0000000..63923b2 --- /dev/null +++ b/openspec/changes/qoder-environment-setup-script/design.md @@ -0,0 +1,45 @@ +## Context + +Environment declarations flow through the public TypeScript model and Zod parser, provider-aware validation, provider request mapping, and provider-specific reverse/normalization paths used by sync and drift detection. Qoder now exposes `config.setup_script` for both cloud and self-hosted environments, with a 64 KB UTF-8 limit, but every stage currently drops or rejects it. The same API documentation establishes POST update semantics, whole-config replacement, metadata patching, and a narrower writable package set than the shared model. + +## Goals / Non-Goals + +**Goals:** + +- Make setup scripts converge through create, update, sync, export, and drift detection. +- Fail locally for script-size and Qoder package-manager violations. +- Normalize response-only Qoder defaults without hiding declarative differences. +- Correct update and metadata-deletion behavior without changing BYOC ownership rules. +- Prove the contract with focused automated tests and a disposable live Qoder environment. + +**Non-Goals:** + +- Execute setup scripts locally or expose their Session-time logs through a new API. +- Add file-path indirection for script content; the declaration remains an inline YAML string. +- Claim setup-script support for providers whose current API contract has not been verified. +- Manage externally referenced environments. + +## Decisions + +1. Add `setup_script?: string` to the shared environment config, but gate actual use per provider. This keeps the declaration portable while preventing adapters from silently dropping unsupported behavior. A Qoder-only extension object was considered, but would make the common Environment model needlessly provider-shaped. +2. Enforce the documented maximum with `Buffer.byteLength(value, "utf8")`, not JavaScript string length, because the remote limit is byte-oriented and scripts may contain non-ASCII content. +3. Keep the shared package union for other providers, while Qoder validation rejects non-empty `cargo`, `gem`, and `go`. Qoder normalization retains only writable `apt`, `npm`, and `pip` values and removes empty response defaults plus `packages.type`. +4. Include setup scripts in both reverse mapping and comparable normalization. Absence and an empty string remain distinct because clearing a saved script must be representable and reconciled. +5. Change Qoder environment update to POST and construct metadata tombstones from the current remote object before updating. This matches Qoder's metadata patch semantics while still sending the complete desired config. +6. Exercise live behavior with a uniquely named disposable environment loaded from `.env`, verify create/get/update/readback, then delete it in a `finally` cleanup path. No secret or `.env` content is printed. + +## Risks / Trade-offs + +- [Provider documentation changes again] → Keep Qoder-specific validation and normalization isolated and back it with request-contract tests. +- [A live setup script can cause Session startup failure] → Use a harmless marker script in live testing and document idempotency, failure, and secret-handling guidance. +- [Metadata tombstones could target management metadata] → Diff only non-`agents.*` remote metadata and continue injecting management metadata normally. +- [Empty arrays from Qoder cause false drift] → Canonicalize package objects on both desired and remote sides before hashing. +- [Live cleanup fails] → Print only the disposable resource ID/name and an explicit cleanup command, leaving credentials undisclosed. + +## Migration Plan + +The field is optional, so existing declarations retain their current hashes after normalization. Deploy the parser, validation, mapper, adapter, tests, and documentation together. Rollback is code-only; environments already containing scripts continue to exist remotely, though an older client would no longer manage that field. + +## Open Questions + +None. The current Qoder documentation is explicit about accepted fields, execution semantics, and update behavior. diff --git a/openspec/changes/qoder-environment-setup-script/proposal.md b/openspec/changes/qoder-environment-setup-script/proposal.md new file mode 100644 index 0000000..3c209a8 --- /dev/null +++ b/openspec/changes/qoder-environment-setup-script/proposal.md @@ -0,0 +1,27 @@ +## Why + +OpenAgentPack cannot currently declare Qoder's `config.setup_script`, so environment initialization that cannot be expressed as packages is lost across create, update, sync, and drift reconciliation. The Qoder Environment adapter also diverges from the current API contract for updates and accepted package managers, making otherwise valid plans fail late or reconcile indefinitely. + +## What Changes + +- Add portable environment `setup_script` declarations with local UTF-8 64 KB validation. +- Support Qoder setup scripts across create, update, remote readback, sync/export, and drift comparison for cloud and self-hosted environments. +- Align Qoder Environment updates with the documented POST endpoint and full-config replacement behavior. +- Reject Qoder package-manager declarations that the API exposes only as response placeholders (`cargo`, `gem`, and `go`). +- Normalize Qoder response-only package fields and empty defaults so they do not create false drift. +- Make Qoder environment metadata deletion converge when a declared key is removed. +- Document execution semantics, failure behavior, security guidance, and examples in English and Chinese. + +## Capabilities + +### New Capabilities + +- `environment-setup-script`: Declarative environment setup scripts, provider capability validation, lifecycle reconciliation, and user-facing execution semantics. + +### Modified Capabilities + +None. + +## Impact + +This affects the SDK environment configuration types and parser, provider validation, Qoder environment mapper and adapter, sync/drift behavior, Qoder-focused tests and fixtures, configuration documentation, environment guides, and Qoder examples. It does not introduce new runtime dependencies or change external-resource ownership semantics. diff --git a/openspec/changes/qoder-environment-setup-script/specs/environment-setup-script/spec.md b/openspec/changes/qoder-environment-setup-script/specs/environment-setup-script/spec.md new file mode 100644 index 0000000..580d2ce --- /dev/null +++ b/openspec/changes/qoder-environment-setup-script/specs/environment-setup-script/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Declare an environment setup script +The system SHALL accept an optional inline `config.setup_script` string for an Environment and SHALL reject scripts whose UTF-8 representation exceeds 65,536 bytes. + +#### Scenario: Valid multiline script +- **WHEN** a cloud or self-hosted Environment declares a multiline setup script within the byte limit +- **THEN** configuration parsing succeeds and preserves the script exactly + +#### Scenario: Oversized Unicode script +- **WHEN** an Environment setup script exceeds 65,536 UTF-8 bytes +- **THEN** validation fails locally before any provider request is made + +### Requirement: Enforce provider setup-script support +The system SHALL send setup scripts only to providers that support them and SHALL report an actionable validation error for managed environments targeting an unsupported provider. + +#### Scenario: Qoder managed environment +- **WHEN** a managed Qoder cloud or self-hosted Environment declares a setup script +- **THEN** the script is included in the provider Environment config + +#### Scenario: Unsupported provider +- **WHEN** a managed Environment targeting another provider declares a setup script +- **THEN** validation reports that the provider does not support environment setup scripts + +#### Scenario: External environment reference +- **WHEN** an externally managed Environment declaration contains a provider ID +- **THEN** OpenAgentPack does not attempt to mutate that Environment + +### Requirement: Reconcile Qoder setup scripts +The system SHALL preserve Qoder setup scripts through create, update, remote readback, sync/export, and drift comparison. + +#### Scenario: Script changes +- **WHEN** a declared setup script differs from the current Qoder Environment +- **THEN** planning reports an Environment update and applying it sends the complete desired config + +#### Scenario: Script converges +- **WHEN** the remote Qoder Environment contains the declared setup script +- **THEN** subsequent planning reports no setup-script drift + +#### Scenario: Script removal +- **WHEN** a previously configured setup script is removed from the declaration +- **THEN** the Qoder Environment is updated so future Sessions no longer execute it + +### Requirement: Follow the Qoder Environment API contract +The system SHALL update Qoder Environments with the documented POST operation, SHALL converge metadata deletions, and SHALL reject writable package declarations not accepted by Qoder. + +#### Scenario: Environment update +- **WHEN** an owned Qoder Environment changes +- **THEN** OpenAgentPack sends POST to the Environment resource with a complete config + +#### Scenario: Metadata key removed +- **WHEN** a user metadata key previously present remotely is removed from the declaration +- **THEN** the update sends a null tombstone for that key and preserves management metadata + +#### Scenario: Unsupported Qoder package manager +- **WHEN** a Qoder Environment declares a non-empty `cargo`, `gem`, or `go` package list +- **THEN** validation fails before apply with an actionable diagnostic + +#### Scenario: Response defaults +- **WHEN** Qoder returns response-only package type fields, reserved package arrays, or empty writable arrays +- **THEN** normalization omits them from comparison unless they correspond to a declared writable package value diff --git a/openspec/changes/qoder-environment-setup-script/tasks.md b/openspec/changes/qoder-environment-setup-script/tasks.md new file mode 100644 index 0000000..547d343 --- /dev/null +++ b/openspec/changes/qoder-environment-setup-script/tasks.md @@ -0,0 +1,27 @@ +## 1. Configuration contract + +- [x] 1.1 Add `setup_script` to the environment type and parser with a UTF-8 64 KB limit +- [x] 1.2 Add provider-aware validation for setup-script support and Qoder writable package managers + +## 2. Qoder reconciliation + +- [x] 2.1 Map and reverse-map setup scripts and canonicalize Qoder package responses +- [x] 2.2 Include setup scripts in Qoder desired/remote comparable state and drift detection +- [x] 2.3 Correct Qoder Environment updates to POST and converge removed metadata keys + +## 3. Verification coverage + +- [x] 3.1 Add parser and provider validation boundary tests +- [x] 3.2 Add Qoder Environment create/update, sync, normalization, and drift regression tests +- [x] 3.3 Update the live drift fixture to cover setup scripts and response-only package fields + +## 4. Documentation + +- [x] 4.1 Update English and Chinese configuration/environment documentation with setup-script semantics and safety guidance +- [x] 4.2 Add a Qoder example using an idempotent multiline setup script + +## 5. Delivery verification + +- [x] 5.1 Run focused tests, SDK typecheck, scoped verification, and the full SDK suite +- [x] 5.2 Review the complete diff and fix all actionable findings +- [x] 5.3 Load `.env` and verify disposable Qoder Environment create, update, readback/drift, and cleanup against the live API diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 2bf9490..cb893cd 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -147,6 +147,41 @@ export function collectProviderCapabilities( } const caps = def.capabilities; + for (const [name, environment] of Object.entries(config.environments ?? {})) { + if (environment.provider && environment.provider !== providerName) continue; + if (environment.environment_id) continue; + const address: ResourceAddress = { type: "environment", name, provider: providerName }; + if (environment.config.setup_script !== undefined && providerName !== "qoder") { + diagnostics.error( + `${providerName}.environment.setup_script.unsupported`, + `environment.${name}: provider '${providerName}' does not support setup_script; remove it or pin this environment to qoder.`, + address, + ); + } + if (providerName === "qoder") { + if ( + environment.config.type === "self_hosted" && + (environment.config.networking !== undefined || environment.config.packages !== undefined) + ) { + diagnostics.error( + "qoder.environment.self_hosted.config.unsupported", + `environment.${name}: Qoder self_hosted environments accept only config.type and config.setup_script; remove networking and packages.`, + address, + ); + } + const unsupported = (["cargo", "gem", "go"] as const).filter( + (key) => (environment.config.packages?.[key]?.length ?? 0) > 0, + ); + if (unsupported.length > 0) { + diagnostics.error( + "qoder.environment.packages.unsupported", + `environment.${name}: Qoder accepts only apt, npm, and pip package declarations; remove ${unsupported.join(", ")} or install them from setup_script.`, + address, + ); + } + } + } + for (const [name, identity] of Object.entries(config.identities ?? {})) { if (identity.provider && identity.provider !== providerName) continue; if (!isSupported(caps, "identity")) { diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index f25a735..b559c30 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -17,6 +17,10 @@ const packagesSchema = z.object({ go: z.array(z.string()).optional(), }); +const setupScriptSchema = z.string().refine((value) => new TextEncoder().encode(value).byteLength <= 64 * 1024, { + message: "setup_script must not exceed 65536 UTF-8 bytes", +}); + const environmentSchema = z.object({ name: z.string().optional(), description: z.string().optional(), @@ -27,6 +31,7 @@ const environmentSchema = z.object({ type: z.enum(["cloud", "self_hosted"]), networking: networkingSchema.optional(), packages: packagesSchema.optional(), + setup_script: setupScriptSchema.optional(), }), metadata: z.record(z.string(), z.string()).optional(), }); diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 3ff31b2..269ed4b 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -316,14 +316,10 @@ export class QoderAdapter implements ProviderAdapter { private normalizeRemote(type: ResourceType, raw: Record): unknown { if (type === "environment") { - const config = (raw.config ?? {}) as Record; + const normalized = envToDecl(raw); return compactDeep({ description: raw.description, - config: { - type: config.type ?? "cloud", - networking: config.networking, - packages: config.packages, - }, + config: normalized.config, metadata: stripAgentsMetadata(raw.metadata), }); } @@ -386,8 +382,15 @@ export class QoderAdapter implements ProviderAdapter { } async updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise { - const body = mapEnvironment(name, decl, this.projectName); - const res = (await this.client.put(`/environments/${id}`, body)) as Record; + const body = mapEnvironment(name, decl, this.projectName) as Record; + const current = (await this.client.get(`/environments/${id}`)) as Record; + const currentMetadata = (current.metadata ?? {}) as Record; + const metadata = { ...((body.metadata ?? {}) as Record) }; + for (const key of Object.keys(currentMetadata)) { + if (!key.startsWith("agents.") && !(key in metadata)) metadata[key] = null; + } + body.metadata = metadata; + const res = (await this.client.post(`/environments/${id}`, body)) as Record; return toRemoteResource(res); } diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index a29a8be..f6f5e5f 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -43,12 +43,24 @@ export function normalizeToolNameFromQoder(name: string): string { .toLowerCase(); } +function normalizeEnvironmentPackages(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return undefined; + const raw = value as Record; + const packages: Record = {}; + for (const key of ["apt", "npm", "pip"] as const) { + if (Array.isArray(raw[key]) && raw[key].length > 0) packages[key] = raw[key] as string[]; + } + return Object.keys(packages).length > 0 ? packages : undefined; +} + export function mapEnvironment(name: string, decl: EnvironmentDecl, projectName: string): unknown { const envType = decl.config.type ?? "cloud"; const config: Record = { 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; + const packages = normalizeEnvironmentPackages(decl.config.packages); + if (packages) config.packages = packages; + if (decl.config.setup_script !== undefined) config.setup_script = decl.config.setup_script; return { name, description: decl.description ?? "", @@ -158,7 +170,8 @@ export function envToDecl(raw: Record): Record config: { type: config.type ?? "cloud", networking: config.networking, - packages: config.packages, + packages: normalizeEnvironmentPackages(config.packages), + setup_script: config.setup_script, }, metadata: stripAgentsMetadata(raw.metadata), }) as Record; diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 038db52..3528d00 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -61,6 +61,8 @@ export interface EnvironmentConfig { type: "cloud" | "self_hosted"; networking?: NetworkingConfig; packages?: PackagesConfig; + /** Shell script executed by supported providers while preparing a sandbox. */ + setup_script?: string; } export interface TunnelDecl { diff --git a/packages/sdk/tests/e2e/drift-live.ts b/packages/sdk/tests/e2e/drift-live.ts index 9a98d21..38cb336 100644 --- a/packages/sdk/tests/e2e/drift-live.ts +++ b/packages/sdk/tests/e2e/drift-live.ts @@ -88,6 +88,12 @@ environments: type: cloud networking: type: unrestricted + packages: + apt: [curl] + setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/live-ready || printf 'ready\\n' > /data/workspace/.openagentpack/live-ready metadata: cma_test: drift-validation @@ -113,6 +119,79 @@ agents: envId = state.resources.find((r: any) => r.address.type === "environment")?.remote_id; if (!agentId || !envId) throw new Error("missing qoder ids"); + const originalEnvironment = await api(base, `/environments/${envId}`, headers); + const originalConfig = originalEnvironment.config as Record; + const originalSetupScript = originalConfig.setup_script; + if (typeof originalSetupScript !== "string" || !originalSetupScript.includes("live-ready")) { + throw new Error(`qoder setup_script was not preserved after create: ${JSON.stringify(originalConfig)}`); + } + await api(base, `/environments/${envId}`, headers, { + method: "POST", + body: JSON.stringify({ + config: { + type: originalConfig.type, + networking: originalConfig.networking, + packages: { apt: ["curl"] }, + setup_script: "set -euo pipefail\necho drifted", + }, + metadata: { live_extra: "remove-me" }, + }), + }); + const environmentPlan = await runAgents(["plan", "-f", configPath, "--json"]); + if (environmentPlan.exitCode !== 0) throw new Error(environmentPlan.stderr || environmentPlan.stdout); + const environmentPlanJson = JSON.parse(environmentPlan.stdout); + const environmentAction = environmentPlanJson.actions.find((a: any) => a.address.type === "environment"); + if ( + environmentAction?.action !== "update" || + !environmentAction.changedPaths?.includes("config.setup_script") || + !environmentAction.changedPaths?.includes("metadata.live_extra") + ) { + throw new Error(`qoder environment drift was not detected: ${JSON.stringify(environmentAction)}`); + } + const reconcileEnvironment = await runAgents(["apply", "-f", configPath, "-y"]); + if (reconcileEnvironment.exitCode !== 0) { + throw new Error(reconcileEnvironment.stderr || reconcileEnvironment.stdout); + } + const reconciledEnvironment = await api(base, `/environments/${envId}`, headers); + const reconciledConfig = reconciledEnvironment.config as Record; + const reconciledMetadata = reconciledEnvironment.metadata as Record; + if (reconciledConfig.setup_script !== originalSetupScript || "live_extra" in reconciledMetadata) { + throw new Error("qoder setup_script or metadata did not converge after apply"); + } + const convergedPlan = await runAgents(["plan", "-f", configPath, "--json"]); + if (convergedPlan.exitCode !== 0) throw new Error(convergedPlan.stderr || convergedPlan.stdout); + const convergedPlanJson = JSON.parse(convergedPlan.stdout); + const remainingEnvironmentAction = convergedPlanJson.actions.find( + (a: any) => a.address.type === "environment" && a.action !== "no-op", + ); + if (remainingEnvironmentAction) { + throw new Error( + `qoder environment still drifted after reconciliation: ${JSON.stringify(remainingEnvironmentAction)}`, + ); + } + const originalYaml = await readFile(configPath, "utf8"); + const setupBlock = ` setup_script: | + set -euo pipefail + install -d /data/workspace/.openagentpack + test -f /data/workspace/.openagentpack/live-ready || printf 'ready\\n' > /data/workspace/.openagentpack/live-ready +`; + if (!originalYaml.includes(setupBlock)) throw new Error("live config setup_script block was not found"); + await Bun.write(configPath, originalYaml.replace(setupBlock, "")); + const removeSetupScript = await runAgents(["apply", "-f", configPath, "-y"]); + if (removeSetupScript.exitCode !== 0) throw new Error(removeSetupScript.stderr || removeSetupScript.stdout); + const withoutSetupScript = await api(base, `/environments/${envId}`, headers); + if ("setup_script" in (withoutSetupScript.config as Record)) { + throw new Error("qoder setup_script was not removed after apply"); + } + await Bun.write(configPath, originalYaml); + const restoreSetupScript = await runAgents(["apply", "-f", configPath, "-y"]); + if (restoreSetupScript.exitCode !== 0) throw new Error(restoreSetupScript.stderr || restoreSetupScript.stdout); + const restoredSetupScript = await api(base, `/environments/${envId}`, headers); + if ((restoredSetupScript.config as Record).setup_script !== originalSetupScript) { + throw new Error("qoder setup_script was not restored after apply"); + } + console.log("qoder live environment setup_script=create/read/update/drift/reconcile passed"); + const before = await api(base, `/agents/${agentId}`, headers); await api(base, `/agents/${agentId}`, headers, { method: "PUT", diff --git a/packages/sdk/tests/e2e/qoder-adapter-pagination.test.ts b/packages/sdk/tests/e2e/qoder-adapter-pagination.test.ts index 7613279..3346ced 100644 --- a/packages/sdk/tests/e2e/qoder-adapter-pagination.test.ts +++ b/packages/sdk/tests/e2e/qoder-adapter-pagination.test.ts @@ -8,6 +8,7 @@ import { QoderAdapter, toSessionInfo } from "../../src/internal/providers/qoder/ interface CapturedCall { url: string; method: string; + body?: unknown; } const BASE = "https://api.qoder.com/api/v1/cloud"; @@ -20,7 +21,7 @@ function mockFetch(responses: Array<{ status: number; body?: unknown }>) { globalThis.fetch = mock(async (input: string | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); const method = init?.method ?? "GET"; - calls.push({ url, method }); + calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : undefined }); const resp = responses[callIndex++]; if (!resp) throw new Error(`Unexpected fetch call #${callIndex}: ${method} ${url}`); @@ -130,6 +131,100 @@ describe("QoderAdapter pagination regressions", () => { }); }); +describe("QoderAdapter environment contract", () => { + let cleanup: (() => void) | undefined; + + afterEach(() => { + cleanup?.(); + cleanup = undefined; + }); + + test("creates an environment with setup_script and only writable package fields", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: { id: "env_1", type: "environment" } }]); + cleanup = restore; + + await makeAdapter().createEnvironment("dev", { + config: { + type: "cloud", + packages: { apt: ["curl"], npm: [], cargo: ["ignored-after-validation"] }, + setup_script: "set -euo pipefail\necho ready", + }, + }); + + expect(calls[0]).toMatchObject({ method: "POST", url: `${BASE}/environments` }); + expect(calls[0]?.body).toMatchObject({ + config: { + type: "cloud", + networking: { type: "unrestricted" }, + packages: { apt: ["curl"] }, + setup_script: "set -euo pipefail\necho ready", + }, + }); + }); + + test("updates with POST and tombstones removed user metadata", async () => { + const { calls, restore } = mockFetch([ + { + status: 200, + body: { metadata: { keep: "old", remove: "stale", "agents.project": "test-project" } }, + }, + { status: 200, body: { id: "env_1", type: "environment" } }, + ]); + cleanup = restore; + + await makeAdapter().updateEnvironment("env_1", "dev", { + config: { type: "self_hosted", setup_script: "echo updated" }, + metadata: { keep: "new" }, + }); + + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + `GET ${BASE}/environments/env_1`, + `POST ${BASE}/environments/env_1`, + ]); + expect(calls[1]?.body).toMatchObject({ + config: { type: "self_hosted", setup_script: "echo updated" }, + metadata: { + keep: "new", + remove: null, + "agents.project": "test-project", + "agents.resource": "dev", + }, + }); + }); + + test("exports normalized setup scripts and omits response-only package defaults", async () => { + const { restore } = mockFetch([ + { + status: 200, + body: { + data: [ + { + id: "env_1", + name: "dev", + config: { + type: "cloud", + packages: { type: "packages", apt: ["curl"], npm: [], pip: [], cargo: [] }, + setup_script: "echo ready", + }, + }, + ], + has_more: false, + }, + }, + ]); + cleanup = restore; + + const exported = await makeAdapter().exportResources("environment"); + + expect(exported).toEqual([ + { + name: "dev", + decl: { config: { type: "cloud", packages: { apt: ["curl"] }, setup_script: "echo ready" } }, + }, + ]); + }); +}); + describe("QoderAdapter readComparableResource id-404 no-fallback", () => { let cleanup: (() => void) | undefined; diff --git a/packages/sdk/tests/fixtures/qoder-drift-environment.json b/packages/sdk/tests/fixtures/qoder-drift-environment.json index 8dcf360..b7af598 100644 --- a/packages/sdk/tests/fixtures/qoder-drift-environment.json +++ b/packages/sdk/tests/fixtures/qoder-drift-environment.json @@ -6,7 +6,17 @@ "type": "cloud", "networking": { "type": "unrestricted" - } + }, + "packages": { + "type": "packages", + "apt": ["curl"], + "npm": [], + "pip": [], + "cargo": [], + "gem": [], + "go": [] + }, + "setup_script": "set -euo pipefail\necho ready" }, "metadata": { "agents.project": "tmp", diff --git a/packages/sdk/tests/unit/drift-detection.test.ts b/packages/sdk/tests/unit/drift-detection.test.ts index b9e757d..c8310f6 100644 --- a/packages/sdk/tests/unit/drift-detection.test.ts +++ b/packages/sdk/tests/unit/drift-detection.test.ts @@ -112,10 +112,37 @@ describe("Qoder comparable fixtures", () => { config: { type: "cloud", networking: { type: "unrestricted" }, + packages: { apt: ["curl"] }, + setup_script: "set -euo pipefail\necho ready", }, metadata: { cma_test: "drift-validation" }, }); }); + + test("setup_script converges and changes or removal remain visible to drift comparison", () => { + const adapter = new QoderAdapter("pt-test", undefined, "tmp") as any; + const desired = adapter.normalizeDesiredResource("environment", "dev", { + config: { type: "cloud", setup_script: "echo ready" }, + }); + const matching = adapter.normalizeRemote("environment", { + description: "", + config: { + type: "cloud", + networking: { type: "unrestricted" }, + packages: { type: "packages", apt: [], npm: [], pip: [], cargo: [] }, + setup_script: "echo ready", + }, + metadata: { "agents.project": "tmp", "agents.resource": "dev" }, + }); + const changed = adapter.normalizeRemote("environment", { + config: { type: "cloud", networking: { type: "unrestricted" }, setup_script: "echo changed" }, + }); + const removed = adapter.normalizeDesiredResource("environment", "dev", { config: { type: "cloud" } }); + + expect(matching).toEqual(desired); + expect(changed).not.toEqual(desired); + expect(removed).not.toEqual(matching); + }); }); describe("planner drift classification", () => { diff --git a/packages/sdk/tests/unit/parser.test.ts b/packages/sdk/tests/unit/parser.test.ts index 1a934fc..b236aac 100644 --- a/packages/sdk/tests/unit/parser.test.ts +++ b/packages/sdk/tests/unit/parser.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { resolve } from "node:path"; import { loadConfig } from "../../src/internal/parser/index.ts"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; const FIXTURES = resolve(import.meta.dir, "../fixtures"); @@ -54,3 +55,27 @@ test("loads official MCP server references without urls", async () => { }, ]); }); + +test("preserves environment setup scripts up to the 64 KiB UTF-8 limit", () => { + const setupScript = `${"界".repeat(21_845)}a`; + const result = projectConfigSchema.safeParse({ + version: "1", + providers: { qoder: {} }, + environments: { dev: { config: { type: "cloud", setup_script: setupScript } } }, + }); + + expect(Buffer.byteLength(setupScript, "utf8")).toBe(65_536); + expect(result.success).toBe(true); + if (result.success) expect(result.data.environments?.dev?.config.setup_script).toBe(setupScript); +}); + +test("rejects environment setup scripts over 64 KiB by UTF-8 byte length", () => { + const result = projectConfigSchema.safeParse({ + version: "1", + providers: { qoder: {} }, + environments: { dev: { config: { type: "self_hosted", setup_script: "界".repeat(21_846) } } }, + }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues[0]?.message).toContain("65536 UTF-8 bytes"); +}); diff --git a/packages/sdk/tests/unit/validate-config.test.ts b/packages/sdk/tests/unit/validate-config.test.ts index a52b9a0..90c81d2 100644 --- a/packages/sdk/tests/unit/validate-config.test.ts +++ b/packages/sdk/tests/unit/validate-config.test.ts @@ -137,3 +137,67 @@ test("rejects Claude GitHub Session mount paths outside /workspace", () => { expect(diagnostics.some((item) => item.code === "claude.agent.session_resource.mount_path.invalid")).toBe(true); }); + +test("allows setup_script on Qoder and rejects unsupported writable package declarations", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder" }, + environments: { + dev: { + config: { + type: "cloud", + setup_script: "echo ready", + packages: { apt: ["curl"], cargo: ["ripgrep"] }, + }, + }, + }, + }); + + expect(diagnostics.some((item) => item.code === "qoder.environment.setup_script.unsupported")).toBe(false); + expect(diagnostics.find((item) => item.code === "qoder.environment.packages.unsupported")?.message).toContain( + "cargo", + ); +}); + +test("rejects setup_script on unsupported managed providers but ignores external references", () => { + const managed = validateProjectConfig({ + version: "1", + providers: { claude: { api_key: "test" } }, + defaults: { provider: "claude" }, + environments: { dev: { config: { type: "cloud", setup_script: "echo ready" } } }, + }); + const external = validateProjectConfig({ + version: "1", + providers: { claude: { api_key: "test" } }, + defaults: { provider: "claude" }, + environments: { + dev: { environment_id: "env_external", config: { type: "cloud", setup_script: "echo inert" } }, + }, + }); + + expect(managed.some((item) => item.code === "claude.environment.setup_script.unsupported")).toBe(true); + expect(external.some((item) => item.code === "claude.environment.setup_script.unsupported")).toBe(false); +}); + +test("rejects networking and packages on managed Qoder self_hosted environments", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder" }, + environments: { + byoc: { + config: { + type: "self_hosted", + networking: { type: "unrestricted" }, + packages: { apt: ["curl"] }, + setup_script: "echo ready", + }, + }, + }, + }); + + expect( + diagnostics.find((item) => item.code === "qoder.environment.self_hosted.config.unsupported")?.message, + ).toContain("only config.type and config.setup_script"); +});