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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/guides/configure-an-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions docs/guides/configure-an-agent.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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 修改。

---

## 挂载技能包
Expand Down
8 changes: 8 additions & 0 deletions docs/guides/deploy-to-qoder.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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:
Expand All @@ -90,6 +96,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/).
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ environments:
type: cloud | self_hosted
networking: { ... }
packages: { ... }
setup_script: <string>
metadata: { <key>: <string> }
```

Expand All @@ -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<string,string> | 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
Expand Down
4 changes: 4 additions & 0 deletions examples/qoder/full/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-12
45 changes: 45 additions & 0 deletions openspec/changes/qoder-environment-setup-script/design.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions openspec/changes/qoder-environment-setup-script/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions openspec/changes/qoder-environment-setup-script/tasks.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/internal/parser/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
});
Expand Down
19 changes: 11 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,14 +316,10 @@ export class QoderAdapter implements ProviderAdapter {

private normalizeRemote(type: ResourceType, raw: Record<string, unknown>): unknown {
if (type === "environment") {
const config = (raw.config ?? {}) as Record<string, unknown>;
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),
});
}
Expand Down Expand Up @@ -386,8 +382,15 @@ export class QoderAdapter implements ProviderAdapter {
}

async updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise<RemoteResource> {
const body = mapEnvironment(name, decl, this.projectName);
const res = (await this.client.put(`/environments/${id}`, body)) as Record<string, unknown>;
const body = mapEnvironment(name, decl, this.projectName) as Record<string, unknown>;
const current = (await this.client.get(`/environments/${id}`)) as Record<string, unknown>;
const currentMetadata = (current.metadata ?? {}) as Record<string, unknown>;
const metadata = { ...((body.metadata ?? {}) as Record<string, string | null>) };
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<string, unknown>;
return toRemoteResource(res);
}

Expand Down
Loading