diff --git a/.changeset/bailian-memory-id-identity.md b/.changeset/bailian-memory-id-identity.md new file mode 100644 index 0000000..fffd186 --- /dev/null +++ b/.changeset/bailian-memory-id-identity.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": patch +--- + +Identify Bailian Memory Stores by their remote ID rather than their non-unique display name. Disable name-based conflict adoption, create a new store when no remote ID is recorded, and exclude archived stores from ID-based lifecycle lookups. diff --git a/.changeset/bailian-native-memory-lifecycle.md b/.changeset/bailian-native-memory-lifecycle.md new file mode 100644 index 0000000..fa00285 --- /dev/null +++ b/.changeset/bailian-native-memory-lifecycle.md @@ -0,0 +1,9 @@ +--- +"@openagentpack/sdk": minor +--- + +Add native Bailian Memory Store, memory file, and historical version APIs, including store archive, file rename, content SHA preconditions, version redaction, and opaque `page`/`next_page` pagination. Preserve native absolute paths, response fields, operation names, and service errors. + +Enable declarative Bailian Memory Stores with ID-only identity, explicit import, declared-key metadata reconciliation, and recoverable initialization. Persist the created store ID and pending initializer hashes before writing content; retries resume missing pending paths without overwriting existing files. Existing and imported stores preserve runtime content by default, while explicit `entries_mode: sync` manages declared paths. Removing a store and applying, or destroying it, irreversibly archives it. + +Resolve logical Memory references into native Session resources with access settings, instructions, deduplication, and an eight-store limit. Block Session mounting until initialization completes and reject Memory mounts in file-only Bailian Deployments. diff --git a/.changeset/fix-memory-store-conflict-name.md b/.changeset/fix-memory-store-conflict-name.md new file mode 100644 index 0000000..da04cab --- /dev/null +++ b/.changeset/fix-memory-store-conflict-name.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": patch +--- + +Use the actual creation name when looking up a memory store after a creation conflict, preventing adoption of a different store named after the configuration key. diff --git a/.changeset/memory-entry-initialization-default.md b/.changeset/memory-entry-initialization-default.md new file mode 100644 index 0000000..9e216e7 --- /dev/null +++ b/.changeset/memory-entry-initialization-default.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": minor +--- + +Default declarative memory entries to initialization of newly created stores only. Existing, imported, or adopted stores retain their runtime content, and initialization-only entry changes no longer schedule updates. Add entries_mode: sync as an explicit opt-in to the previous continuous YAML synchronization behavior. diff --git a/.changeset/optional-mcp-tool-overrides.md b/.changeset/optional-mcp-tool-overrides.md new file mode 100644 index 0000000..1e0997e --- /dev/null +++ b/.changeset/optional-mcp-tool-overrides.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": patch +--- + +Allow MCP toolkit configs and agent-builder tool overrides to be omitted or empty. Bailian requests normalize missing overrides to an empty array, so default_config.enabled can enable all MCP tools without placeholder names. Preserve the disabled default and explicit per-tool overrides. diff --git a/.changeset/preserve-environment-yaml-anchors.md b/.changeset/preserve-environment-yaml-anchors.md new file mode 100644 index 0000000..a47018b --- /dev/null +++ b/.changeset/preserve-environment-yaml-anchors.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": patch +--- + +Preserve YAML anchors when redacting environment variable values so valid aliases remain usable in version previews and version snapshot checks. Stored and restored configuration retains its original values. diff --git a/.changeset/secure-build-dependencies.md b/.changeset/secure-build-dependencies.md new file mode 100644 index 0000000..a3e3dca --- /dev/null +++ b/.changeset/secure-build-dependencies.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/playground": patch +--- + +Update the bundled editor to a patched ProseMirror view version and refresh vulnerable build dependencies. Backport bounded nesting and AST traversal checks for the workspace's braces dependency while an official fix is unavailable. diff --git a/.changeset/tidy-webhook-context.md b/.changeset/tidy-webhook-context.md new file mode 100644 index 0000000..6962fd5 --- /dev/null +++ b/.changeset/tidy-webhook-context.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": minor +--- + +Add workspace-level Bailian Webhook endpoint management and delivery-event queries through a provider-only API context. Expose endpoint URL normalization and cancellable requests while preserving one-time signing-secret responses, opaque pagination cursors, request IDs, and provider errors. These APIs do not read project files or write project state. diff --git a/README.md b/README.md index dc5306c..43f5015 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | native | native | +| Memory Store | native | native | native | native | | Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5cdad02..6515e65 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,7 +148,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | native | native | +| Memory Store | native | native | native | native | | Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/apps/server/openapi.json b/apps/server/openapi.json index d3f0e13..0ba12f1 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -1568,6 +1568,12 @@ "type": "string", "nullable": true }, + "environment_variables": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "created_at": { "type": "string" }, diff --git a/apps/server/src/services/project-manager.ts b/apps/server/src/services/project-manager.ts index 0fbe40b..880445f 100644 --- a/apps/server/src/services/project-manager.ts +++ b/apps/server/src/services/project-manager.ts @@ -326,7 +326,7 @@ function projectAgentDetails( memory_stores: agent?.memory_stores ?? [], resources: (agent?.resources ?? []).map((resource) => ({ type: resource.type, - mount_path: resource.mount_path, + mount_path: resource.type === "github_repository" ? resource.mount_path : undefined, })), }; } diff --git a/apps/webui/src/lib/api/generated/schema.d.ts b/apps/webui/src/lib/api/generated/schema.d.ts index c8d8174..4623aa2 100644 --- a/apps/webui/src/lib/api/generated/schema.d.ts +++ b/apps/webui/src/lib/api/generated/schema.d.ts @@ -3383,6 +3383,9 @@ export interface components { [key: string]: unknown; }; environment_id?: string | null; + environment_variables?: { + [key: string]: string; + }; created_at?: string; updated_at?: string; metadata?: { diff --git a/biome.json b/biome.json index 1da8210..6ac4b84 100644 --- a/biome.json +++ b/biome.json @@ -14,6 +14,7 @@ "!**/out", "!**/coverage", "!**/bun.lock", + "!vendor/braces", "!**/*.svg", "!**/apps/webui/.agents-webui", "!**/apps/webui/outputs", diff --git a/bun.lock b/bun.lock index 68a1bc5..a788673 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@biomejs/biome": "^2.5.3", "@changesets/cli": "^2.31.0", "@types/bun": "^1.3.14", + "braces": "file:vendor/braces", "dependency-cruiser": "^17.4.3", "js-yaml": "4.3.2", "tsup": "^8.5.1", @@ -17,7 +18,7 @@ }, "apps/server": { "name": "@openagentpack/server", - "version": "0.0.12", + "version": "0.0.14", "dependencies": { "@hono/zod-openapi": "^1.4.0", "@openagentpack/sdk": "workspace:*", @@ -67,7 +68,7 @@ }, "packages/cli": { "name": "@openagentpack/cli", - "version": "0.7.2", + "version": "0.8.0", "bin": { "agents": "dist/bin/agents.js", }, @@ -94,7 +95,7 @@ }, "packages/playground": { "name": "@openagentpack/playground", - "version": "0.7.2", + "version": "0.8.0", "bin": { "agents-playground": "dist/bin/playground.js", }, @@ -117,7 +118,7 @@ }, "packages/sdk": { "name": "@openagentpack/sdk", - "version": "0.7.2", + "version": "0.8.0", "dependencies": { "jszip": "^3.10.1", "yaml": "^2.9.0", @@ -135,13 +136,16 @@ "overrides": { "@tiptap/extension-bubble-menu": "3.30.5", "@tiptap/extension-floating-menu": "3.30.5", - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", + "braces": "file:vendor/braces", "esbuild": "0.28.1", "fast-equals": "5.3.3", "js-yaml": "4.3.2", "nanoid": "3.3.18", "postcss": "8.5.23", "prosemirror-model": "1.25.11", + "prosemirror-view": "1.42.3", + "source-map-js": "1.2.2", }, "packages": { "@ast-grep/cli": ["@ast-grep/cli@0.44.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.1", "@ast-grep/cli-darwin-x64": "0.44.1", "@ast-grep/cli-linux-arm64-gnu": "0.44.1", "@ast-grep/cli-linux-x64-gnu": "0.44.1", "@ast-grep/cli-win32-arm64-msvc": "0.44.1", "@ast-grep/cli-win32-ia32-msvc": "0.44.1", "@ast-grep/cli-win32-x64-msvc": "0.44.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-wSjtu0/9xSYzzo/EW31w5pf1qB33tejYKmKXb2nBAlZ5PpYtOqwFmk9H1nywhjzO913beugsKAZYxrwciqW9AQ=="], @@ -496,9 +500,9 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "braces": ["@openagentpack/braces@file:vendor/braces", { "dependencies": { "fill-range": "^7.1.1" } }], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -932,7 +936,7 @@ "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], - "prosemirror-view": ["prosemirror-view@1.42.0", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A=="], + "prosemirror-view": ["prosemirror-view@1.42.3", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -1006,7 +1010,7 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -1126,6 +1130,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "micromatch/braces": ["braces@file:vendor/braces", {}], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], diff --git a/docs/contributing/provider-development.md b/docs/contributing/provider-development.md index 5a5f4e0..0b738eb 100644 --- a/docs/contributing/provider-development.md +++ b/docs/contributing/provider-development.md @@ -33,6 +33,12 @@ import ".//index.ts"; The registry validates a provider against `REQUIRED_METHODS_BY_KIND` (in `capabilities.ts`) at build time — so an unsupported kind needs no stub methods, and a supported kind must implement its lifecycle + session methods. Read/list methods are an orthogonal, à-la-carte facet and are soft-degraded at their call sites. +## Resource identity + +Resource state binds a logical config address to a remote ID. When display names are not unique, implement `supportsResourceNameLookup(type)` with `false` for that resource kind and return `null` from `findResource` when no ID is provided. The executor then preserves create errors instead of adopting a same-name resource. + +Bailian Memory Stores use this ID-only lookup rule. Without a recorded ID, create a new store and persist its returned ID; with an ID, read and update that exact store. Reusing an existing store requires an explicit ID import. This lookup contract does not enable a resource's lifecycle capability by itself. + ## Verify ```sh diff --git a/docs/examples.md b/docs/examples.md index 14efed1..02a09d3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -51,7 +51,7 @@ agents destroy | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | native | native | +| Memory Store | native | native | native | native | | Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/docs/guides/manage-deployments.md b/docs/guides/manage-deployments.md index 3b3ed16..0b220f2 100644 --- a/docs/guides/manage-deployments.md +++ b/docs/guides/manage-deployments.md @@ -30,6 +30,7 @@ deployments: | `agent` | yes | Agent name to run. | | `agent_version` | no | Pin a versioned agent. | | `environment` | no | Override the agent's environment. | +| `environment_variables` | no | Bailian: string-valued map. Qoder: its existing `KEY=VALUE` string format. | | `vaults` | no | Vault bindings. | | `memory_stores` | no | Memory store bindings. | | `resources` | no | `file`, `memory_store`, or `github_repository` resources to mount. | @@ -65,7 +66,27 @@ agents deployment unpause # resume scheduled runs (native provider agents deployment run # trigger a run ``` -Qoder deployments may also declare `environment_variables` as a semicolon- or newline-separated `KEY=VALUE` string. Qoder copies these variables into every Session created by that deployment; Claude does not support this extension. +## Environment variables + +Bailian deployments accept `environment_variables` as a string-valued map. The variables are injected into the sandbox for deployment runs and can be read by name: + +```yaml +deployments: + daily-report: + provider: bailian + agent: reporter + environment_variables: + REPORT_REGION: "cn-hangzhou" + REPORT_FORMAT: "markdown" + FEATURE_ENABLED: "true" + initial_events: + - type: user.message + content: "Generate the daily report." +``` + +All values must be strings; quote numeric or boolean-looking values. The reserved map key `__proto__` is not supported. Run `agents plan` and `agents apply` after changing the map. When a Bailian deployment declaration removes `environment_variables` or sets it to `{}`, the next update sends an empty variable map to clear the deployment's variables. + +Qoder continues to use a semicolon- or newline-separated `KEY=VALUE` string, for example `environment_variables: "REPORT_REGION=cn-hangzhou;REPORT_FORMAT=markdown"`. Qoder copies these variables into every Session created by the deployment. Claude and Ark do not support deployment environment variables. ## Native vs. emulated diff --git a/docs/guides/use-mcp-and-vaults.md b/docs/guides/use-mcp-and-vaults.md index f6777c9..7bd38f7 100644 --- a/docs/guides/use-mcp-and-vaults.md +++ b/docs/guides/use-mcp-and-vaults.md @@ -88,16 +88,13 @@ agents: - type: mcp_toolkit mcpServerName: WebSearch defaultConfig: - enabled: false - configs: - - name: bailian_web_search - enabled: true + enabled: true mcp_servers: - type: official name: WebSearch ``` -The `mcp_toolkit` block (under `tools.mcp[]`) selects which tools the official server exposes. It accepts either snake_case (`mcp_server_name`, `default_config`) or camelCase (`mcpServerName`, `defaultConfig`) keys. +The `mcp_toolkit` block (under `tools.mcp[]`) selects which tools the official server exposes. It accepts either snake_case (`mcp_server_name`, `default_config`) or camelCase (`mcpServerName`, `defaultConfig`) keys. The example enables all tools without listing tool names. `configs` may be omitted or set to `[]`; add entries only to override individual tools, such as `{ name: bailian_web_search, enabled: false }`. If `default_config` is omitted, tools remain disabled unless explicitly enabled in `configs`. ## Network policy diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 475226d..514687f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -31,7 +31,7 @@ deployments: { : DeploymentDecl } | `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). | +| `memory_stores` | map | no | Persistent agent context (Bailian, Qoder, Claude beta, Volcengine Ark). | | `skills` | map | no | Reusable capability modules. | | `files` | map | no | Local files uploaded to the Files API (Bailian, Volcengine Ark). | | `identities` | map | no | Stable end-user identities. Provider support is capability-gated. | @@ -240,17 +240,59 @@ identity and metadata fields; a different policy is not treated as an exact matc ```yaml memory_stores: : + name: # optional display name; defaults to the logical key description: provider: # optional - metadata: { : } # optional + metadata: { : } # optional; Bailian preserves JSON values + entries_mode: initialize # optional; initialize (default) | sync entries: [ { key: , content: } ] ``` -Supported on **Qoder**, **Claude (beta)**, and **Volcengine Ark** (**Bailian**: `unsupported`). +Supported on **Bailian**, **Qoder**, **Claude (beta)**, and **Volcengine Ark**. -Declarative `entries` are managed seeds: apply creates or updates those paths but -preserves additional memories written by agents. Runtime CRUD and version commands -are documented in [`examples/memory/`](../../examples/memory/README.md). +By default, `entries` initialize a newly created store only. Existing, imported, +or adopted stores keep their memory content: later apply runs update store +attributes without creating, updating, or deleting memory files. Changing the +initial entries alone does not schedule a store update. + +Set `entries_mode: sync` explicitly to keep declared paths managed by YAML. +Sync creates or updates those paths while preserving additional memories written +by agents; removing an entry does not delete its remote file. Runtime CRUD and +version commands are documented in [`examples/memory/`](../../examples/memory/README.md). + +默认只在新库创建时写入 `entries`;已有、导入或接管的库保留正文。仅修改初始化内容不会触发库更新。 +需要持续同步声明路径时,显式设置 `entries_mode: sync`。 + +### Bailian lifecycle + +The YAML key is a project reference; `name` is a display label, not a unique remote +identity. A missing State ID always creates a new store, even if another store has +the same display name. To manage an existing store, explicitly import its remote +ID. Import validates that the store exists and is active, rejects an ID already +bound to another State address, and does not initialize its contents. + +Bailian manages the store's display name, description, and only the metadata keys +declared in YAML. Before updating metadata, apply reads the store and merges the +declared keys into its current metadata, preserving keys added remotely. Runtime +file contents and server-generated counters are not treated as store-attribute drift. +When `entries_mode: sync` is explicitly enabled, refresh also compares the SHA-256 +of declared paths so remote edits or deletions schedule reconciliation. Other paths +are ignored, and State stores only hashes, not memory bodies. + +For a new store, apply saves the returned ID and each pending initializer's path +and content SHA-256 to State before writing any `entries`; it does not save the +entry bodies in State. If initialization fails, rerun apply with the original +pending entries. It resumes only unfinished paths in the same store, preserves +any path that already exists, and checkpoints each completed path. Changing or +removing a pending initializer is rejected; restore its original content to +resume. Sessions cannot mount a store while this initialization is incomplete. + +Removing a Bailian store from YAML and applying, or running destroy, archives +that store. **Archiving is irreversible**; it is not an unbind operation. Reusing +the logical name after removal creates a new remote store. + +百炼通过 State 中的远端 ID 识别库,不按同名接管;新库初始化失败后沿用已保存的 ID, +只补未完成且尚不存在的路径。移除声明后 apply 或执行 destroy 会不可逆地归档记忆库。 ## Skill @@ -372,7 +414,7 @@ If the preflight cannot resolve the Identity, Template, or Store lookup, destroy | `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. | | `vault` | string | no | Vault name. | | `files` | string[] | no | File declarations inherited by a Qoder Forward Template. These files are created through the Forward File API. | -| `memory_stores` | string[] | no | Bound memory stores. | +| `memory_stores` | string[] | no | Logical names of bound memory stores. Bailian resolves them through State and mounts them read-write in each Session. | | `default_memory_store.name` | string | yes (with `default_memory_store`) | Display name for Qoder Forward's writable system-managed Store; 1–255 characters. | | `default_memory_store.description` | string | no | Display description for the system-managed Store; up to 1024 characters. | | `default_memory_store.delete_on_destroy` | boolean | no | Permanently delete the Store during destroy. Defaults to `false` (retain). | @@ -437,6 +479,30 @@ Bailian child agents run in **parallel over a shared file system**: files writte ### Session resources +Bailian managed Sessions support Memory Store resources. Both forms below +reference top-level `memory_stores` by their logical YAML key: + +```yaml +agents: + assistant: + # ... + memory_stores: [notes] # shorthand: read_write + resources: + - type: memory_store + memory_store: notes + access: read_only # optional; read_write by default + instructions: Use prior project decisions when answering. # optional +``` + +The explicit resource overrides shorthand settings for the same remote store, +so the example mounts `notes` once with read-only access. Duplicate explicit +mounts are rejected, and a Bailian Session can mount at most eight distinct +stores. Each reference must resolve to an applied or imported State ID for the +same provider. `instructions` is limited to 4096 characters. The server chooses +the mount path; do not declare `mount_path` on a Memory resource. The SDK sends +`resources[].memory_store_id`, `access`, and optional `instructions`, and preserves +server-returned resources and mount paths in Session details. + Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource: ```yaml @@ -457,8 +523,14 @@ Mount roots are provider invariants: Qoder uses `/data`, Claude uses `/workspace ### MCP toolkit (`tools.mcp[]`) +`configs` is optional. On Bailian, omit it (or use `configs: []`) to apply +`default_config.enabled` to every tool in the server. Set it to `true` to enable +all tools without listing tool names. If `default_config` is also omitted, the +default remains `false`. Use `configs` only for per-tool overrides: + ```yaml tools: + builtin: [read] mcp: - type: mcp_toolkit mcp_server_name: WebSearch # mcpServerName also accepted @@ -480,6 +552,7 @@ deployments: vaults: [ ] memory_stores: [ ] resources: [ DeploymentResource ] + environment_variables: { : } # optional; Bailian string map initial_events: [ InitialEvent ] # 1..50 schedule: { expression: , timezone: } description: @@ -489,6 +562,8 @@ deployments: `initial_events` is a discriminated union; `schedule.expression` must be a 5-field cron expression. +For Bailian, `environment_variables` is a map of string keys and string values, such as `{ REPORT_REGION: "cn-hangzhou", FEATURE_ENABLED: "true" }`. The reserved map key `__proto__` is not supported. These variables are available by name in the deployment-run sandbox. Changes are reconciled by `plan`/`apply`; removing the field or declaring `{}` sends an empty map on the next update to clear the deployment variables. For Qoder, the field retains its existing string format, such as `"REPORT_REGION=cn-hangzhou;FEATURE_ENABLED=true"`. Claude and Ark do not support this field. + > **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 @@ -506,3 +581,10 @@ deployments: | `file` | `file_id?`, `source?`, `mount_path?` | | `memory_store` | `memory_store`, `access?` (`read_write`\|`read_only`), `instructions?` | | `github_repository` | `url`, `checkout?` (`branch`/`commit`), `mount_path?`, `authorization_token?` | + +This table describes the cross-provider schema. **Bailian Deployment resources +accept files only**, with a required `mount_path`. Memory mounting through +Deployment `memory_stores`, explicit `memory_store` resources, or the referenced +Agent's inherited Memory bindings is a validation error. Create a Session to +mount Memory Stores. GitHub repository resources produce a warning and are not +sent in the Bailian Deployment payload. diff --git a/docs/reference/providers.md b/docs/reference/providers.md index 7b93254..5224258 100644 --- a/docs/reference/providers.md +++ b/docs/reference/providers.md @@ -13,7 +13,7 @@ OpenAgentPack targets multiple agent platforms behind one declarative config. Ea | Skill | native | native | native | native | Claude uploads via `files[]`; the other providers upload zip archives. Volcengine Ark is create + attach only. | | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | -| Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Ark adapters implement the complete upstream lifecycle. | +| Memory Store | native | native | native | native | All four providers support Memory Stores. Bailian supports Session mounts and archives stores on destroy. | | Multi-Agent | native | native | native | native | Coordinator topology is available on all four providers. | | Deployment | native | native | native | emulated | Bailian, Qoder, and Claude use native deployments; Ark expands a deployment into a session at `run` time. | | Session | native | native | native | native | Runtime sessions are native on every provider. | diff --git a/docs/reference/providers.zh-CN.md b/docs/reference/providers.zh-CN.md index 2d07a91..e71a44a 100644 --- a/docs/reference/providers.zh-CN.md +++ b/docs/reference/providers.zh-CN.md @@ -32,7 +32,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Skill | native | native | native | native | Claude 使用 files[];其余 Provider 上传 zip | | Agent | native | native | native | native | 核心资源 | | MCP Server | native | native | native | native | 通过 Agent 的 MCP 配置挂载 | -| Memory Store | unsupported | native | native | native | Qoder、Claude(beta)、方舟均已接入 | +| Memory Store | native | native | native | native | 四家均已接入;百炼支持 Session 挂载,销毁时归档记忆库 | | Multi-Agent | native | native | native | native | 四家 Provider 均支持 coordinator 拓扑 | | Deployment | native | native | native | emulated | 百炼、Qoder 和 Claude 使用原生 Deployment;火山方舟在 `run` 时展开为 Session | | Session | native | native | native | native | 四者均原生支持 | @@ -112,7 +112,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Agent | full | full | 待验证 | existence | 火山方舟 当前执行存在性检查 | | Skill | existence | existence | existence | existence | 可发现缺失/删除,不比较包内容 | | Vault | existence | existence | existence | existence | 凭证内容通常不可读回,不比较内容 | -| Memory Store | unsupported | existence | existence | existence | 可发现资源缺失 | +| Memory Store | full | existence | existence | existence | 百炼比较库属性;仅显式 `entries_mode: sync` 比较声明的正文路径 | | Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼 Deployment 为原生资源但不比较内容;火山方舟的 emulated Deployment 仅为本地记录 | Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 因 Anthropic API 账号余额不足未完成 Agent 创建验证。 @@ -125,13 +125,7 @@ Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline - `plan` 阶段会跳过该资源,不生成操作 - 诊断信息中包含替代方案建议(`remediation`) -示例诊断输出: - -``` -bailian.memory_store.unsupported: - no memory store primitive on Bailian - use skill knowledge or MCP for persistent context -``` +百炼 Memory Store 已原生支持,Session 可挂载记忆库;百炼 Deployment 当前仍只支持文件资源。 ### 模拟(emulated)资源的能力降级 diff --git a/examples/README.md b/examples/README.md index c26f848..894906a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -66,7 +66,7 @@ extensions, and live-test commands. | Skill | native | native | native | native | Claude uploads via `files[]`; others upload zip; Volcengine Ark is create + attach only. | | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | -| Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Volcengine Ark. | +| Memory Store | native | native | native | native | Bailian, Qoder, Claude (beta), and Volcengine Ark. | | Multi-Agent | native | native | native | native | All four providers support the coordinator topology. | | Deployment | native | native | native | emulated | Bailian, Qoder, and Claude schedule server-side; Ark expands into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | diff --git a/examples/memory/README.md b/examples/memory/README.md index 2891404..87f8e0c 100644 --- a/examples/memory/README.md +++ b/examples/memory/README.md @@ -3,6 +3,156 @@ The `memory-store` resource is declarative, while `agents memory*` commands manage runtime content without editing `agents.yaml`. +YAML `entries` initialize new stores only by default. Applying changes to store +attributes or initialization entries does not overwrite content in an existing +store. Use `entries_mode: sync` explicitly when those paths should continue to +follow YAML; runtime-created paths remain preserved. + +## Bailian IaC and Session mounting + +Save this as `agents.yaml`, with the two credential variables supplied by your +environment or `.env`: + +```yaml +version: "1" +providers: + bailian: + api_key: ${DASHSCOPE_API_KEY} + workspace_id: ${BAILIAN_WORKSPACE_ID} +defaults: + provider: bailian +environments: + dev: + config: { type: cloud } +memory_stores: + notes: + name: Project notes + description: Shared project decisions + metadata: + team: platform + settings: { reviewed: true } + entries_mode: initialize + entries: + - key: /notes/decision.md + content: Use a modular monolith. +agents: + assistant: + model: qwen-plus + instructions: Use project memory when answering. + environment: dev + resources: + - type: memory_store + memory_store: notes + access: read_write + instructions: Keep durable project decisions here. +``` + +```bash +agents plan -f agents.yaml +agents apply -f agents.yaml +``` + +`notes` is the logical reference; `Project notes` is a non-unique display name. +Without a recorded remote ID, apply creates a new store. To reuse an existing +store, explicitly import its ID before apply; there is no adoption by name. +Imported and existing stores keep their contents in the default `initialize` +mode. Store-attribute updates read and merge metadata, changing only declared +keys and preserving keys added remotely. + +For a new Bailian store, State records its ID and the pending initializer paths +and SHA-256 values before any file is written. A failed apply can resume against +that ID: only pending, absent paths are created, and existing files are preserved. +Keep the original pending content until initialization completes; changed or +removed pending sources are rejected. State contains hashes, not initializer +bodies. Session mounting is blocked until this initialization completes. + +Set `entries_mode: sync` only when YAML should keep managing declared paths. +Sync creates or updates those files without deleting other files, including +files whose declarations were removed. Removing the whole store declaration and +applying, or destroying the project, **irreversibly archives the Bailian store**. + +Session resources resolve `memory_store: notes` through State. The shorthand +`memory_stores: [notes]` mounts it read-write; an explicit resource can override +that mount's access and instructions. A Session accepts at most eight distinct +stores. Do not specify a Memory mount path; the server returns it. Bailian +Deployments accept files only and reject explicit or inherited Memory mounts. + +## Bailian native Memory APIs + +The SDK's runtime APIs also work with a provider-only context, without loading +YAML or changing project State: + +```ts +import { + createManagedApiContext, + getMemoryVersion, + listMemories, + listMemoryVersions, + updateMemory, +} from "@openagentpack/sdk"; + +const context = createManagedApiContext({ + providers: { + bailian: { + api_key: process.env.DASHSCOPE_API_KEY, + workspace_id: process.env.BAILIAN_WORKSPACE_ID, + }, + }, +}); +const storeId = "memstore_..."; +const memoryId = "mem_..."; +const firstPage = await listMemories(context.providers, "bailian", storeId, { + path_prefix: "/notes/", + depth: 1, + view: "full", + limit: 20, +}); +if (firstPage.next_page) { + const nextPage = await listMemories(context.providers, "bailian", storeId, { + path_prefix: "/notes/", + depth: 1, + view: "full", + limit: 20, + page: firstPage.next_page, + }); + console.log(nextPage.data); +} + +await updateMemory(context.providers, "bailian", storeId, memoryId, { + content: "Keep modules independent.", + precondition: { type: "content_sha256", content_sha256: "" }, +}); +const history = await listMemoryVersions(context.providers, "bailian", storeId, { + memory_id: memoryId, + operation: "modified", + view: "full", +}); +if (history.data[0]) { + const version = await getMemoryVersion(context.providers, "bailian", storeId, history.data[0].id); + console.log(version); +} +``` + +Bailian preserves its native response fields, including unknown service fields: + +- Paths retain their leading `/`. Relative create paths are normalized to an + absolute path. Directory listings can return a separate `prefixes` array. +- Lists return `data` and opaque `next_page`; pass that value as `page` for the + next request. A null or missing `next_page` ends pagination; do not assume + `has_more` or `next_cursor` is present. +- Memory updates accept an atomic path rename and an optional content SHA + precondition. `expected_content_sha256` is also accepted and maps to the native + `precondition` object. Conflict errors are passed through without retrying the + write. Update responses may omit `content`; use `getMemory` to read the body. +- Version lists support `memory_id`, `operation`, `created_at.gte`, + `created_at.lte`, `view`, `limit`, and `page`. The native operation `modified` + is preserved. `getMemoryVersion` reads a snapshot and `redactMemoryVersion` + redacts one; fields absent from a redacted response remain absent. Redaction + is irreversible and does not provide a way to restore an archived store. +- `deleteMemory` returns the native `memory_deleted` receipt. Bailian does not + support a delete SHA precondition, per-file metadata, or batch creation. + `archiveMemoryStore` archives the container irreversibly. + ## Portable lifecycle (Qoder, Claude, Ark) ```bash @@ -22,9 +172,10 @@ agents memory update \ -f examples/qoder/with-memory/agents.yaml --provider qoder ``` -The same commands work with `--provider claude` and `--provider ark`. Paths are -always written as portable relative paths; the Claude adapter converts them to -Claude's required leading-slash form. +The same commands work with `--provider claude` and `--provider ark`. For these +portable adapters, use relative paths; the Claude adapter converts them to +Claude's required leading-slash form. Bailian retains native absolute paths as +described above. ## Provider-specific extensions diff --git a/package.json b/package.json index 9394ad4..8c848cc 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@biomejs/biome": "^2.5.3", "@changesets/cli": "^2.31.0", "@types/bun": "^1.3.14", + "braces": "file:vendor/braces", "dependency-cruiser": "^17.4.3", "js-yaml": "4.3.2", "tsup": "^8.5.1", @@ -74,13 +75,16 @@ "overrides": { "@tiptap/extension-bubble-menu": "3.30.5", "@tiptap/extension-floating-menu": "3.30.5", - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", + "braces": "file:vendor/braces", "esbuild": "0.28.1", "fast-equals": "5.3.3", "js-yaml": "4.3.2", "nanoid": "3.3.18", "postcss": "8.5.23", - "prosemirror-model": "1.25.11" + "prosemirror-model": "1.25.11", + "prosemirror-view": "1.42.3", + "source-map-js": "1.2.2" }, "trustedDependencies": [ "@ast-grep/cli" diff --git a/packages/sdk/docs/providers/bailian-webhooks.md b/packages/sdk/docs/providers/bailian-webhooks.md new file mode 100644 index 0000000..d6bdd80 --- /dev/null +++ b/packages/sdk/docs/providers/bailian-webhooks.md @@ -0,0 +1,69 @@ +# Bailian workspace Webhooks + +Webhook endpoints belong to the workspace. The SDK exposes their operation-level APIs without requiring an `agents.yaml` file, a project name, or a state backend. They do not participate in project `plan`, `apply`, `sync`, or `destroy`. + +The provider owns the wire contract: + +- [Webhook introduction](https://docs.agent.bailian.aliyun.com/zh/managed-agents/webhooks/introduction) +- [Create endpoint](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/create) +- [Callback envelope and signature](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/callback) +- [Test delivery](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/test) +- [Delivery-event history](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/list-events) + +## Initialize a workspace context + +```ts +import { + createManagedApiContext, + listRemoteWebhookEndpoints, + resolveBailianApiBaseUrl, +} from "@openagentpack/sdk"; + +const context = createManagedApiContext({ + providers: { + bailian: { + api_key: process.env.DASHSCOPE_API_KEY, + base_url: resolveBailianApiBaseUrl({ + workspaceId: process.env.BAILIAN_WORKSPACE_ID, + }), + }, + }, +}); + +const endpoints = await listRemoteWebhookEndpoints(context, { provider: "bailian" }); +``` + +The host resolves credentials and passes the resulting provider config in memory. `createManagedApiContext` validates it and creates adapters through the existing provider registry; it does not bootstrap environment credentials or perform file/network I/O. An existing `ProjectRuntimeContext` is also accepted by the Webhook functions. + +`resolveBailianApiBaseUrl({ baseUrl, workspaceId })` accepts an explicit HTTP(S) origin or an AgentStudio API base URL, normalizes its trailing slash, and appends the API path exactly once. Without `baseUrl`, it constructs the Beijing workspace endpoint. Other regions and custom gateways use an explicit endpoint. This helper does not change the existing Bailian provider's verbatim `base_url` behavior. + +## Available operations + +| SDK function | Result | +| --- | --- | +| `createRemoteWebhookEndpoint(context, input, options?)` | Endpoint with the one-time signing secret | +| `listRemoteWebhookEndpoints(context, options?)` | Complete workspace list with `data` and `request_id` | +| `getRemoteWebhookEndpoint(context, id, options?)` | Endpoint | +| `updateRemoteWebhookEndpoint(context, id, input, options?)` | Updated endpoint | +| `deleteRemoteWebhookEndpoint(context, id, options?)` | JSON deletion response containing `request_id` | +| `enableRemoteWebhookEndpoint(context, id, options?)` | Enabled endpoint | +| `disableRemoteWebhookEndpoint(context, id, options?)` | Disabled endpoint | +| `testRemoteWebhookEndpoint(context, id, options?)` | Test event and `request_id` | +| `resetRemoteWebhookSecret(context, id, options?)` | ID, one-time signing secret, update time, and `request_id` | +| `listRemoteWebhookEvents(context, id, options?)` | Delivery events, nullable `next_page`, and `request_id` | + +All operations accept `{ provider?, signal? }`. Event listing additionally accepts `{ limit?, page? }`; cursors remain opaque and the SDK does not synthesize `has_more`. Endpoint listing has no pagination. + +Updates preserve omitted fields. An explicitly supplied `events` array replaces the complete subscription array; `description: null` or `description: ""` clears the description. The SDK preserves provider responses and error bodies rather than translating provider error codes or inferring delivery success from undocumented fields. + +Creation and secret reset return a secret that cannot subsequently be retrieved. The host must deliver it to the caller or to an explicitly selected secret destination. SDK requests do not log responses, persist secrets, or automatically retry writes. Host-injected transports must preserve that no-replay property, especially when a creation or reset request has an uncertain outcome. Cancellation is passed to the injected fetch implementation as an `AbortSignal`. + +These APIs configure the provider and query deliveries. A callback receiver and signature verification run in the receiving application; refer to the provider callback documentation for that protocol. + +Implementation and protocol tests: + +- `packages/sdk/src/internal/core/managed-api-context.ts` +- `packages/sdk/src/internal/core/webhook-runtime.ts` +- `packages/sdk/src/internal/types/webhook.ts` +- `packages/sdk/src/internal/providers/bailian/adapter.ts` +- `packages/sdk/tests/unit/bailian-webhook.test.ts` diff --git a/packages/sdk/docs/providers/bailian.md b/packages/sdk/docs/providers/bailian.md index 4c8da42..4efd6ee 100644 --- a/packages/sdk/docs/providers/bailian.md +++ b/packages/sdk/docs/providers/bailian.md @@ -12,5 +12,6 @@ Implementation: - `packages/sdk/src/internal/providers/bailian/` - `packages/sdk/tests/e2e/bailian-adapter.test.ts` - `packages/sdk/docs/providers/bailian-vault-integration.md` +- [Workspace Webhook APIs](./bailian-webhooks.md) Do not copy the full upstream API documentation into this repository. Link to the canonical provider pages so authentication, fields, limits, and examples remain current. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 34ea8cb..510fab8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -13,6 +13,43 @@ export { ApiError, ConflictError } from "./internal/providers/base-client.ts"; // globalThis.fetch. Unset → provider clients use the global fetch as before. export { setDefaultFetch, type FetchLike } from "./internal/transport.ts"; +export { createManagedApiContext } from "./internal/core/managed-api-context.ts"; +export type { + CreateManagedApiContextInput, + ManagedApiContext, +} from "./internal/core/managed-api-context.ts"; +export { resolveBailianApiBaseUrl } from "./internal/providers/bailian/api-base-url.ts"; +export type { ResolveBailianApiBaseUrlOptions } from "./internal/providers/bailian/api-base-url.ts"; +export { + createRemoteWebhookEndpoint, + deleteRemoteWebhookEndpoint, + disableRemoteWebhookEndpoint, + enableRemoteWebhookEndpoint, + getRemoteWebhookEndpoint, + listRemoteWebhookEndpoints, + listRemoteWebhookEvents, + resetRemoteWebhookSecret, + testRemoteWebhookEndpoint, + updateRemoteWebhookEndpoint, +} from "./internal/core/webhook-runtime.ts"; +export type { + CreateWebhookEndpointInput, + UpdateWebhookEndpointInput, + WebhookDeleteResult, + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointList, + WebhookEndpointWithSecret, + WebhookEvent, + WebhookEventData, + WebhookEventListOptions, + WebhookEventPage, + WebhookEventWithDelivery, + WebhookRequestOptions, + WebhookSecretResetResult, + WebhookTestResult, +} from "./internal/types/webhook.ts"; + export type { BackendRuntimeInput, ProjectRuntimeContext, @@ -29,8 +66,11 @@ export type { CredentialNetworking, DeploymentDecl, EnvironmentDecl, + MemoryStoreDecl, ProjectConfig, ResolvedProjectConfig, + SessionMemoryStoreResourceDecl, + SessionResourceDecl, SkillDecl, VaultDecl, } from "./internal/types/config.ts"; @@ -208,6 +248,9 @@ export type { CreateMemoryInput, CreateMemoryStoreInput, MemoryInfo, + MemoryDeleteResult, + MemoryContentPrecondition, + MemoryMetadata, MemoryListOptions, MemoryListItem, MemoryPrefixInfo, @@ -216,6 +259,7 @@ export type { MemoryStoreInfo, MemoryStoreListOptions, MemoryVersionInfo, + MemoryVersionOperation, MemoryVersionListOptions, UpdateMemoryInput, UpdateMemoryStoreInput, @@ -273,7 +317,10 @@ export { streamSessionEvents, uploadFile, } from "./internal/core/session-runtime.ts"; -export { resolveSessionProvider } from "./internal/session/session-manager.ts"; +export { + parseSessionResources, + resolveSessionProvider, +} from "./internal/session/session-manager.ts"; export { prependFileHint, prepareInitialSessionPrompt, @@ -297,7 +344,13 @@ export { type ProviderConfig, type ProviderConfigProvider, } from "./internal/provider-config.ts"; -export type { ProviderSessionInfo, SessionFilter, SessionListResult } from "./internal/types/session.ts"; +export type { + ProviderSessionInfo, + SessionFilter, + SessionListResult, + SessionMemoryStoreResource, + SessionResourceInfo, +} from "./internal/types/session.ts"; export type { EventListOptions, EventStreamOptions, diff --git a/packages/sdk/src/internal/core/agent-builder.ts b/packages/sdk/src/internal/core/agent-builder.ts index ce4b710..de1e6d2 100644 --- a/packages/sdk/src/internal/core/agent-builder.ts +++ b/packages/sdk/src/internal/core/agent-builder.ts @@ -16,7 +16,8 @@ export interface AgentMcpBuildInput { serverType: "official" | "url" | "http"; serverUrl?: string; defaultEnabled?: boolean; - tools: Array<{ name: string; enabled?: boolean }>; + /** Per-tool overrides; omitted or empty uses defaultEnabled for every tool. */ + tools?: Array<{ name: string; enabled?: boolean }>; } export interface AgentBuildInput { @@ -59,7 +60,7 @@ export function buildAgentDecl(base: AgentDecl | undefined, input: AgentBuildInp type: "mcp_toolkit", mcp_server_name: binding.serverName, default_config: { enabled: binding.defaultEnabled ?? false }, - configs: binding.tools.map((tool) => ({ name: tool.name, enabled: tool.enabled ?? true })), + configs: (binding.tools ?? []).map((tool) => ({ name: tool.name, enabled: tool.enabled ?? true })), })); const mcpServers: McpServerDecl[] = mcp.map((binding) => ({ diff --git a/packages/sdk/src/internal/core/managed-api-context.ts b/packages/sdk/src/internal/core/managed-api-context.ts new file mode 100644 index 0000000..b1eacf5 --- /dev/null +++ b/packages/sdk/src/internal/core/managed-api-context.ts @@ -0,0 +1,39 @@ +import { UserError } from "../errors.ts"; +import type { ProviderAdapter } from "../providers/interface.ts"; +import { buildProviders } from "../providers/registry.ts"; +import type { ProjectRuntimeContext } from "./project-runtime.ts"; +// The lightweight context also works without loading a project runtime first. +import "../providers/all.ts"; + +/** Provider-only context for workspace APIs; contains no project config or state. */ +export type ManagedApiContext = Pick; + +export interface CreateManagedApiContextInput { + /** Already-resolved provider configuration; credentials are supplied by the host. */ + providers: Record; +} + +/** Validate provider configuration and create adapters without file or network I/O. */ +export function createManagedApiContext(input: CreateManagedApiContextInput): ManagedApiContext { + return { providers: buildProviders(input.providers) }; +} + +export function resolveManagedApiAdapter(ctx: ManagedApiContext, provider?: string): ProviderAdapter { + if (provider) { + const adapter = ctx.providers.get(provider); + if (!adapter) throw new UserError(`Provider '${provider}' not configured.`); + return adapter; + } + if (ctx.providers.size === 0) throw new UserError("No providers configured."); + if (ctx.providers.size === 1) return ctx.providers.values().next().value!; + throw new UserError("Multiple providers configured. Use --provider to specify one."); +} + +export function requireManagedApiMethod( + value: Method | undefined, + provider: string, + operation: string, +): Method { + if (value) return value; + throw new UserError(`Provider '${provider}' does not support ${operation}.`); +} diff --git a/packages/sdk/src/internal/core/managed-api-runtime.ts b/packages/sdk/src/internal/core/managed-api-runtime.ts index ba18850..04ddb6b 100644 --- a/packages/sdk/src/internal/core/managed-api-runtime.ts +++ b/packages/sdk/src/internal/core/managed-api-runtime.ts @@ -1,11 +1,9 @@ -import { UserError } from "../errors.ts"; import { ApiError } from "../providers/base-client.ts"; import type { DeploymentInfo, DeploymentListFilter, DeploymentListResult, DeploymentRunResult, - ProviderAdapter, } from "../providers/interface.ts"; import { resolveFetch } from "../transport.ts"; import type { @@ -34,25 +32,16 @@ import type { } from "../types/managed-api.ts"; import type { ProviderSessionInfo } from "../types/session.ts"; import type { ProviderSkillInfo } from "../types/skill-info.ts"; +import { + requireManagedApiMethod as requiredMethod, + resolveManagedApiAdapter as resolveAdapter, +} from "./managed-api-context.ts"; import type { ProjectRuntimeContext } from "./project-runtime.ts"; -import { getRuntimeProvider } from "./project-runtime.ts"; export interface ManagedApiTarget { provider?: string; } -function resolveAdapter(ctx: ProjectRuntimeContext, provider?: string): ProviderAdapter { - if (provider) return getRuntimeProvider(ctx, provider); - const configured = Array.from(ctx.providers.keys()); - if (configured.length === 1) return getRuntimeProvider(ctx, configured[0]!); - throw new UserError("Multiple providers configured. Use --provider to specify one."); -} - -function requiredMethod(value: T | undefined, provider: string, operation: string): T { - if (value) return value; - throw new UserError(`Provider '${provider}' does not support ${operation}.`); -} - export async function listRemoteAgents( ctx: ProjectRuntimeContext, options: ManagedApiTarget & AgentListOptions = {}, @@ -312,6 +301,16 @@ const UNSUPPORTED_OAUTH: ManagedAgentOperationCapability = { }; const BAILIAN_OPERATIONS: Record = { + "webhook.create": API_KEY, + "webhook.list": API_KEY, + "webhook.get": API_KEY, + "webhook.update": API_KEY, + "webhook.delete": API_KEY, + "webhook.enable": API_KEY, + "webhook.disable": API_KEY, + "webhook.test": API_KEY, + "webhook.reset_secret": API_KEY, + "webhook.events": API_KEY, "agent.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, "agent.list": API_KEY, "agent.get": API_KEY, diff --git a/packages/sdk/src/internal/core/project-source-security.ts b/packages/sdk/src/internal/core/project-source-security.ts index fcb4629..25db802 100644 --- a/packages/sdk/src/internal/core/project-source-security.ts +++ b/packages/sdk/src/internal/core/project-source-security.ts @@ -1,5 +1,5 @@ import { basename, dirname } from "node:path"; -import { isScalar, parseDocument, visit } from "yaml"; +import { isMap, isPair, isScalar, parseDocument, Scalar, visit } from "yaml"; import { resolveProjectConfigFromObject } from "../parser/resolve-project-config.ts"; import type { Diagnostic } from "../types/dto.ts"; import { validateProjectConfig } from "./validate-config.ts"; @@ -109,10 +109,15 @@ function redactDocumentSource( document: ReturnType, secrets: Set, ): string { - if (secrets.size === 0) return source; const redactedDocument = document.clone(); + const environmentRedaction = redactEnvironmentVariableNodes(redactedDocument); + if (secrets.size === 0 && !environmentRedaction.changed) return source; visit(redactedDocument, (_key, node) => { - if (isScalar(node) && (typeof node.value === "string" || typeof node.value === "number")) { + if ( + isScalar(node) && + !environmentRedaction.references.has(node) && + (typeof node.value === "string" || typeof node.value === "number") + ) { const value = String(node.value); const redacted = redactText(value, secrets); if (redacted !== value) { @@ -127,6 +132,47 @@ function redactDocumentSource( return redactedDocument.toString(); } +/** Mask environment values at their YAML nodes so short values never rewrite keys or unrelated text. */ +function redactEnvironmentVariableNodes(document: ReturnType): { + changed: boolean; + references: WeakSet; +} { + let changed = false; + const references = new WeakSet(); + visit(document, (_key, node) => { + if (!isPair(node) || !isScalar(node.key) || node.key.value !== "environment_variables") return; + if (isMap(node.value)) { + for (const entry of node.value.items) { + if ( + isScalar(entry.value) && + typeof entry.value.value === "string" && + ENVIRONMENT_REFERENCE.test(entry.value.value) + ) { + references.add(entry.value); + continue; + } + entry.value = redactEnvironmentValue(entry.value); + changed = true; + } + return; + } + if (isScalar(node.value) && typeof node.value.value === "string" && ENVIRONMENT_REFERENCE.test(node.value.value)) { + references.add(node.value); + return; + } + node.value = redactEnvironmentValue(node.value); + changed = true; + }); + return { changed, references }; +} + +function redactEnvironmentValue(value: unknown): Scalar { + const redacted = new Scalar(REDACTED); + // References elsewhere in the preview must still resolve to the masked value. + if (isScalar(value)) redacted.anchor = value.anchor; + return redacted; +} + function redactText(source: string, secrets: Set): string { let redacted = source; for (const secret of [...secrets].sort((left, right) => right.length - left.length)) { diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 165b82c..50717c2 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -2,10 +2,11 @@ import { UserError } from "../errors.ts"; import { type ExecutionResult, executePlan } from "../executor/executor.ts"; import { buildDependencyGraph, collectDependencyClosure } from "../graph/dependency.ts"; import { getResourceDeclaration } from "../planner/declaration.ts"; -import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; +import { buildReadinessBaseline, diffChangedPaths } 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 { IStateManager } from "../state/state-manager.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"; @@ -126,11 +127,35 @@ export async function importResource( `Invalid resource type: ${address.type}. Valid types: ${[...IMPORTABLE_RESOURCE_TYPES].join(", ")}`, ); } - if (ctx.state.getResource(address)) { + const memoryImport = address.provider === "bailian" && address.type === "memory_store"; + const existing = ctx.state.getResource(address); + if (existing && !(memoryImport && !existing.remote_id)) { throw new UserError( `Resource ${address.provider}.${address.type}.${address.name} already exists in state. Remove it before re-importing.`, ); } + if (memoryImport) { + if (!remoteId.trim()) throw new UserError("Memory Store ID must not be empty. / 记忆库 ID 不能为空。"); + const duplicate = ctx.state + .listResources() + .find( + (resource) => + resource.address.provider === address.provider && + resource.address.type === "memory_store" && + resource.remote_id === remoteId, + ); + if (duplicate) { + throw new UserError( + `Memory Store '${remoteId}' is already bound to '${addressKey(duplicate.address)}'. / 记忆库已绑定到其他 State 地址。`, + ); + } + const provider = ctx.providers.get(address.provider); + if (!provider?.getMemoryStore) throw new UserError("Bailian Memory Store API is not configured."); + const store = await provider.getMemoryStore(remoteId); + if (store.archived_at || store.status === "archived") { + throw new UserError(`Cannot import archived Memory Store '${remoteId}'. / 无法导入已归档的记忆库。`); + } + } const planned = await planProjectContext(ctx, { provider: address.provider, @@ -157,16 +182,22 @@ export async function importResource( // 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, - getResourceDeclaration(address, ctx.config) ?? undefined, - ) - : null; + const declaration = getResourceDeclaration(address, ctx.config) ?? undefined; + const remote = + memoryImport && provider?.readComparableResource + ? await provider.readComparableResource(address.type, remoteId, address.name, declaration) + : provider + ? await readComparableIfSupported(provider, address.type, remoteId, address.name, declaration) + : null; const remoteHash = remote ? stableContentHash(remote.comparable) : undefined; + if (memoryImport && !remote) { + throw new UserError(`Memory Store '${remoteId}' is unavailable for import. / 记忆库当前不可导入。`); + } + const desiredComparable = memoryImport + ? provider?.normalizeDesiredResource?.(address.type, address.name, declaration) + : undefined; + const desiredComparableHash = desiredComparable != null ? stableContentHash(desiredComparable) : remoteHash; + const memoryDrift = memoryImport && remoteHash !== undefined && desiredComparableHash !== remoteHash; const resource: ResourceState = { address, @@ -176,11 +207,12 @@ export async function importResource( version: options.resourceVersion ?? remote?.version, content_hash: contentHash, desired_hash: contentHash, - desired_comparable_hash: remoteHash, + desired_comparable_hash: memoryImport ? desiredComparableHash : 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, + drift_status: memoryDrift ? "drifted" : remoteHash ? "in_sync" : undefined, + ...(memoryDrift && remote ? { drift_paths: diffChangedPaths(desiredComparable, remote.comparable) } : {}), }; ctx.state.setResource(resource); await ctx.state.save(); @@ -217,7 +249,7 @@ export async function planProjectContext( resourceAddresses: selectedAddresses, }); if (options.mode === "create-only" && options.scope) { - plan = enforceCreateOnlyPlan(plan, options.scope, toResourceRefreshResult(refreshResult)); + plan = enforceCreateOnlyPlan(plan, options.scope, toResourceRefreshResult(refreshResult), ctx.state); } return { @@ -235,6 +267,7 @@ function enforceCreateOnlyPlan( plan: ExecutionPlan, scope: ResourcePlanScope, refreshResult: ResourceRefreshResult | undefined, + state: IStateManager, ): ExecutionPlan { const reasons: string[] = []; const rootKeys = new Set(scope.roots.map(addressKey)); @@ -250,7 +283,14 @@ function enforceCreateOnlyPlan( const rootAction = plan.actions.find((action) => addressKey(action.address) === rootKey); if (!rootAction) { reasons.push(`Scoped plan did not contain target resource ${rootKey}.`); - } else if (rootAction.action !== "create") { + } else if ( + rootAction.action !== "create" && + !( + root.type === "memory_store" && + rootAction.action === "update" && + state.getResource(root)?.memory_initialization + ) + ) { reasons.push(`Target resource ${rootKey} must be new, but the scoped plan requires '${rootAction.action}'.`); } } diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 2d3e9b6..acce0b9 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -12,6 +12,7 @@ import type { Diagnostic } from "../types/plan.ts"; import type { ResourceAddress } from "../types/state.ts"; import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts"; import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts"; +import { getDeploymentEnvironmentVariablesIssue } from "../validation/deployment-environment.ts"; import { resolveAgentMaterialization } from "./agent-materialization.ts"; export interface ValidateProjectConfigOptions { @@ -89,7 +90,18 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: diagnostics.error("config.agent.file.unknown", `agent.${name}: references unknown file '${fileName}'`); } } - for (const memory of agent.memory_stores ?? []) { + const explicitMemoryResources = (agent.resources ?? []).filter((resource) => resource.type === "memory_store"); + const explicitMemoryNames = new Set(); + for (const resource of explicitMemoryResources) { + if (explicitMemoryNames.has(resource.memory_store)) { + diagnostics.error( + "config.agent.memory_store.duplicate", + `agent.${name}: duplicate Memory Store '${resource.memory_store}' in resources / 会话资源不能重复声明同一记忆库。`, + ); + } + explicitMemoryNames.add(resource.memory_store); + } + for (const memory of new Set([...(agent.memory_stores ?? []), ...explicitMemoryNames])) { if (!memoryNames.has(memory)) { diagnostics.error( "config.agent.memory_store.unknown", @@ -387,6 +399,7 @@ export function collectProviderCapabilities( address, ); } + if (resource.type === "memory_store") continue; const mountPrefix = providerMountPrefix(providerName); if ( mountPrefix && @@ -401,6 +414,21 @@ export function collectProviderCapabilities( ); } } + if (providerName === "bailian") { + const effectiveMemoryStores = new Set([ + ...(agent.memory_stores ?? []), + ...(agent.resources ?? []).flatMap((resource) => + resource.type === "memory_store" ? [resource.memory_store] : [], + ), + ]); + if (effectiveMemoryStores.size > 8) { + diagnostics.error( + "bailian.agent.memory_store.limit", + `agent.${name}: a Session can mount at most 8 Memory Stores / 百炼会话最多挂载 8 个记忆库。`, + address, + ); + } + } if (delivery === "forward" && agent.resources?.length) { diagnostics.error( `${providerName}.template.session_resources.unsupported`, @@ -546,6 +574,26 @@ export function collectProviderCapabilities( name, provider: providerName, }; + const effectiveMemoryStores = new Set([ + ...(deployment.memory_stores ?? []), + ...(deployment.resources ?? []).flatMap((resource) => + resource.type === "memory_store" ? [resource.memory_store] : [], + ), + ]); + if (!effectiveMemoryStores.size) { + const deployedAgent = config.agents?.[deployment.agent]; + for (const memoryStore of deployedAgent?.memory_stores ?? []) effectiveMemoryStores.add(memoryStore); + for (const resource of deployedAgent?.resources ?? []) { + if (resource.type === "memory_store") effectiveMemoryStores.add(resource.memory_store); + } + } + if (effectiveMemoryStores.size) { + diagnostics.error( + "bailian.deployment.memory_store.unsupported", + `deployment.${name}: Bailian Deployment resources accept files only; create a Session to mount Memory Stores / 百炼 Deployment 仅支持文件资源,请通过 Session 挂载记忆库。`, + addr, + ); + } if (deployment.initial_events?.some((event) => event.type === "user.define_outcome")) { diagnostics.warning( @@ -612,6 +660,18 @@ export function collectProviderCapabilities( } } + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { + if (deployment.provider && deployment.provider !== providerName) continue; + const issue = getDeploymentEnvironmentVariablesIssue(providerName, deployment.environment_variables); + if (issue) { + diagnostics.error( + `${providerName}.deployment.environment_variables.${issue.kind}`, + `deployment.${name}: ${issue.message}`, + { 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 @@ -651,14 +711,6 @@ export function collectProviderCapabilities( } for (const [name, deployment] of Object.entries(config.deployments ?? {})) { if (deployment.provider && deployment.provider !== providerName) continue; - if (deployment.environment_variables !== undefined) { - diagnostics.error( - `${providerName}.deployment.environment_variables.unsupported`, - `deployment.${name}: environment_variables is supported only by Qoder deployments; ` + - `remove it or pin this deployment to the qoder provider.`, - { type: "deployment", name, provider: providerName }, - ); - } if (deployment.tunnel && (!deployment.provider || deployment.provider === providerName)) { diagnostics.error( `${providerName}.deployment.tunnel.unsupported`, diff --git a/packages/sdk/src/internal/core/webhook-runtime.ts b/packages/sdk/src/internal/core/webhook-runtime.ts new file mode 100644 index 0000000..e3e4937 --- /dev/null +++ b/packages/sdk/src/internal/core/webhook-runtime.ts @@ -0,0 +1,114 @@ +import type { + CreateWebhookEndpointInput, + UpdateWebhookEndpointInput, + WebhookDeleteResult, + WebhookEndpoint, + WebhookEndpointList, + WebhookEndpointWithSecret, + WebhookEventListOptions, + WebhookEventPage, + WebhookRequestOptions, + WebhookSecretResetResult, + WebhookTestResult, +} from "../types/webhook.ts"; +import { type ManagedApiContext, requireManagedApiMethod, resolveManagedApiAdapter } from "./managed-api-context.ts"; + +export async function createRemoteWebhookEndpoint( + ctx: ManagedApiContext, + input: CreateWebhookEndpointInput, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.createWebhookEndpoint, adapter.name, "webhook creation"); + return method.call(adapter, input, options); +} + +export async function listRemoteWebhookEndpoints( + ctx: ManagedApiContext, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.listWebhookEndpoints, adapter.name, "webhook listing"); + return method.call(adapter, options); +} + +export async function getRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.getWebhookEndpoint, adapter.name, "webhook lookup"); + return method.call(adapter, id, options); +} + +export async function updateRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + input: UpdateWebhookEndpointInput, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.updateWebhookEndpoint, adapter.name, "webhook update"); + return method.call(adapter, id, input, options); +} + +export async function deleteRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.deleteWebhookEndpoint, adapter.name, "webhook deletion"); + return method.call(adapter, id, options); +} + +export async function enableRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.enableWebhookEndpoint, adapter.name, "webhook enabling"); + return method.call(adapter, id, options); +} + +export async function disableRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.disableWebhookEndpoint, adapter.name, "webhook disabling"); + return method.call(adapter, id, options); +} + +export async function testRemoteWebhookEndpoint( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.testWebhookEndpoint, adapter.name, "webhook testing"); + return method.call(adapter, id, options); +} + +export async function resetRemoteWebhookSecret( + ctx: ManagedApiContext, + id: string, + options: WebhookRequestOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.resetWebhookSecret, adapter.name, "webhook secret reset"); + return method.call(adapter, id, options); +} + +export async function listRemoteWebhookEvents( + ctx: ManagedApiContext, + id: string, + options: WebhookEventListOptions = {}, +): Promise { + const adapter = resolveManagedApiAdapter(ctx, options.provider); + const method = requireManagedApiMethod(adapter.listWebhookEvents, adapter.name, "webhook event listing"); + return method.call(adapter, id, options); +} diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index e982a6d..6f503fd 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -17,6 +17,7 @@ import { addressKey } from "../types/state.ts"; import { contentHash, sha256 } from "../utils/hash.ts"; import { skillNameFromFiles } from "../utils/skill-manifest.ts"; import type { ExecContext } from "./context.ts"; +import { initializeBailianMemories, memoryInitializationEntries } from "./memory-initialization.ts"; import { resolveAgentRefs, resolveChannelRefs, resolveDeploymentRefs, resolveTemplateRefs } from "./resolver.ts"; import { resolveSkillFiles } from "./skill-resolver.ts"; @@ -266,8 +267,21 @@ type ResourceExecAdapter = ResourceCrudAdapter & DriftReadAdapter; async function executeAction(action: PlannedAction, provider: ResourceExecAdapter, ctx: ExecContext): Promise { try { return await executeActionInner(action, provider, ctx); - } catch (err) { - if (action.action !== "update" || !ApiError.isNotFound(err)) throw err; + } catch (error) { + if (action.action !== "update" || !ApiError.isNotFound(error)) throw error; + if (action.address.provider === "bailian" && action.address.type === "memory_store") { + // A Memory file can disappear during sync while its owning Store still exists. + const existing = ctx.state.getResource(action.previousAddress ?? action.address); + if (!existing?.remote_id || !provider.getMemoryStore) throw error; + let storeMissing = false; + try { + await provider.getMemoryStore(existing.remote_id, existing.api_mode); + } catch (lookupError) { + if (!ApiError.isNotFound(lookupError)) throw lookupError; + storeMissing = true; + } + if (!storeMissing) throw error; + } emitRuntimeFeedback(ctx.onFeedback, { type: "resource_already_gone", level: "warning", @@ -521,34 +535,81 @@ async function executeActionInner( const deleteMemoryStore = provider.deleteMemoryStore?.bind(provider); if (!createMemoryStore || !deleteMemoryStore) throw memoryStoreUnsupported(address.provider); const decl = ctx.config.memory_stores![name]!; - if (!provider.updateMemoryStore || !provider.listMemories || !provider.createMemory || !provider.updateMemory) { + const remoteName = decl.name ?? name; + if (!provider.updateMemoryStore) throw memoryStoreUnsupported(address.provider); + if (address.provider === "bailian" && !existingId) { + const entries = memoryInitializationEntries(decl); + if (entries.length && (!provider.listMemories || !provider.createMemory)) { + throw memoryStoreUnsupported(address.provider); + } + result = await createMemoryStore(remoteName, decl, apiMode); + if (!result.id) throw new UserError("Memory Store creation returned no ID. / 记忆库创建未返回 ID。"); + const hash = await computeResourceHash(address, ctx.config, ctx.configPath, ctx.state); + ctx.state.setResource({ + address, + remote_id: result.id, + content_hash: hash, + desired_hash: hash, + ...(entries.length + ? { + memory_initialization: { + pending_entries: entries.map((entry) => ({ + path: entry.path, + content_sha256: sha256(entry.content), + })), + }, + } + : {}), + }); + await ctx.state.save(); + await initializeBailianMemories(ctx, address, provider, decl, apiMode); + break; + } + if ( + decl.entries_mode === "sync" && + (!provider.listMemories || !provider.createMemory || !provider.updateMemory) + ) { throw memoryStoreUnsupported(address.provider); } const reconcile = async (storeId: string): Promise => { + const metadata = + address.provider === "bailian" && provider.getMemoryStore + ? { ...(await provider.getMemoryStore(storeId, apiMode)).metadata, ...decl.metadata } + : (decl.metadata ?? {}); const store = await provider.updateMemoryStore!( storeId, { - name, + name: remoteName, description: decl.description, - metadata: decl.metadata ?? {}, + metadata, }, apiMode, ); - // Declarative entries are managed seeds. Update/create those paths in place, - // while preserving memories learned by agents at runtime. + // Existing and imported stores retain runtime-written content unless sync is explicitly requested. + if (decl.entries_mode !== "sync") return store; + + // Explicit sync manages declared paths while preserving additional runtime memories. const current = new Map(); + const seenCursors = new Set(); + // Bailian's Memory-file listing has a lower limit than the other providers. + const pageSize = address.provider === "bailian" ? 20 : 100; let cursor: string | undefined; do { - const page = await provider.listMemories!(storeId, { limit: 100, cursor, view: "basic" }, apiMode); + const page = await provider.listMemories!(storeId, { limit: pageSize, cursor, view: "basic" }, apiMode); for (const memory of page.data) { if (memory.type === "memory") current.set(memory.path, memory); } - cursor = page.has_more ? page.next_cursor : undefined; + cursor = page.next_page ?? (page.has_more ? page.next_cursor : undefined); + if (cursor && seenCursors.has(cursor)) { + throw new UserError("Memory pagination returned a repeated cursor. / 记忆分页返回了重复游标。"); + } + if (cursor) seenCursors.add(cursor); } while (cursor); for (const entry of decl.entries ?? []) { - const existing = current.get(entry.key.replace(/^\/+/, "")); + const key = entry.key.replace(/^\/+/, ""); + const existing = current.get(address.provider === "bailian" ? `/${key}` : key); if (existing) { if (existing.content_sha256 !== sha256(entry.content)) { await provider.updateMemory!( @@ -568,15 +629,19 @@ async function executeActionInner( return store; }; if (apiModeChanged) { - result = await createMemoryStore(name, decl, apiMode); + result = await createMemoryStore(remoteName, decl, apiMode); if (existingId) await deleteMemoryStore(existingId, priorApiMode).catch(() => undefined); - } else if (isUpdate) { - result = await reconcile(existingId!); + } else if (existingId) { + if (address.provider === "bailian") { + await initializeBailianMemories(ctx, address, provider, decl, apiMode); + } + result = await reconcile(existingId); } else { try { - result = await createMemoryStore(name, decl, apiMode); + result = await createMemoryStore(remoteName, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + searchNames: [remoteName], mode: apiMode, createOnly: ctx.createOnly, onExisting: async (existing) => reconcile(existing.id!), @@ -884,7 +949,7 @@ async function findExistingByNames( async function adoptOnConflict( err: unknown, address: ResourceAddress, - provider: Pick, + provider: Pick, onFeedback: RuntimeFeedbackSink | undefined, opts: { searchNames?: string[]; @@ -893,7 +958,7 @@ async function adoptOnConflict( onExisting: (existing: RemoteResource) => Promise; }, ): Promise { - if (!(err instanceof ConflictError)) throw err; + if (!(err instanceof ConflictError) || provider.supportsResourceNameLookup?.(address.type) === false) throw err; const candidates = opts.searchNames?.length ? opts.searchNames : [address.name]; if (opts.createOnly) throw createOnlyExistingResourceError(address, candidates.join('" / "')); diff --git a/packages/sdk/src/internal/executor/memory-initialization.ts b/packages/sdk/src/internal/executor/memory-initialization.ts new file mode 100644 index 0000000..704195a --- /dev/null +++ b/packages/sdk/src/internal/executor/memory-initialization.ts @@ -0,0 +1,100 @@ +import { UserError } from "../errors.ts"; +import type { ProviderResourceMode } from "../providers/interface.ts"; +import type { ResourceCrudAdapter } from "../providers/resource-workflow.ts"; +import type { MemoryStoreDecl } from "../types/config.ts"; +import type { ResourceAddress } from "../types/state.ts"; +import { sha256 } from "../utils/hash.ts"; +import type { ExecContext } from "./context.ts"; + +export function memoryInitializationEntries(declaration: MemoryStoreDecl) { + const entries = (declaration.entries ?? []).map((entry) => ({ + path: `/${entry.key.replace(/^\/+/, "")}`, + content: entry.content, + })); + const paths = new Set(); + for (const entry of entries) { + if (paths.has(entry.path)) { + throw new UserError(`Duplicate memory initialization path '${entry.path}'. / 重复的记忆初始化路径。`); + } + if (Buffer.byteLength(entry.content, "utf8") > 102400) { + throw new UserError(`Memory '${entry.path}' exceeds 102400 UTF-8 bytes. / 记忆正文超过 102400 UTF-8 字节。`); + } + paths.add(entry.path); + } + return entries; +} + +type MemoryInitializerAdapter = Pick; + +async function existingMemoryPaths(provider: MemoryInitializerAdapter, storeId: string, mode?: ProviderResourceMode) { + if (!provider.listMemories) throw new UserError("Provider does not support Memory initialization."); + const paths = new Set(); + const seenCursors = new Set(); + let cursor: string | undefined; + do { + // Bailian's Memory-file listing accepts at most 20 entries per page. + const page = await provider.listMemories(storeId, { limit: 20, cursor, view: "basic" }, mode); + for (const memory of page.data) { + if (memory.type === "memory") paths.add(memory.path); + } + cursor = page.next_page ?? (page.has_more ? page.next_cursor : undefined); + if (cursor && seenCursors.has(cursor)) { + throw new UserError("Memory pagination returned a repeated cursor. / 记忆分页返回了重复游标。"); + } + if (cursor) seenCursors.add(cursor); + } while (cursor); + return paths; +} + +/** Resume only the frozen initializer of this newly-created store; never update existing files. */ +export async function initializeBailianMemories( + ctx: ExecContext, + address: ResourceAddress, + provider: MemoryInitializerAdapter, + declaration: MemoryStoreDecl, + mode?: ProviderResourceMode, +): Promise { + const resource = ctx.state.getResource(address); + const initialization = resource?.memory_initialization; + if (!resource?.remote_id || !initialization) return; + if (!provider.createMemory || !provider.listMemories) { + throw new UserError("Provider does not support Memory initialization."); + } + const entries = new Map(memoryInitializationEntries(declaration).map((entry) => [entry.path, entry])); + for (const pending of initialization.pending_entries) { + const entry = entries.get(pending.path); + if (!entry || sha256(entry.content) !== pending.content_sha256) { + throw new UserError( + `Memory initializer '${pending.path}' changed after the store was created. Restore its original content to resume. / 记忆库创建后初始化源发生变更,请恢复原始正文后重试。`, + ); + } + } + let existingPaths = initialization.pending_entries.length + ? await existingMemoryPaths(provider, resource.remote_id, mode) + : new Set(); + for (const pending of [...initialization.pending_entries]) { + const entry = entries.get(pending.path)!; + if (!existingPaths.has(pending.path)) { + try { + await provider.createMemory(resource.remote_id, entry, mode); + } catch (error) { + // A lost response may follow a successful create. Preserve the original + // service error unless a read confirms that the path now exists. + try { + existingPaths = await existingMemoryPaths(provider, resource.remote_id, mode); + } catch { + throw error; + } + if (!existingPaths.has(pending.path)) throw error; + } + existingPaths.add(pending.path); + } + initialization.pending_entries = initialization.pending_entries.filter((entry) => entry.path !== pending.path); + ctx.state.setResource({ ...resource, memory_initialization: initialization }); + await ctx.state.save(); + } + const completed = { ...resource }; + delete completed.memory_initialization; + ctx.state.setResource(completed); + await ctx.state.save(); +} diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index 8397b61..a18cb29 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -151,8 +151,14 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str } } - if (decl.memory_stores) { - for (const msName of decl.memory_stores) { + const memoryStoreNames = [ + ...(decl.memory_stores ?? []), + ...(decl.resources ?? []).flatMap((resource) => + resource.type === "memory_store" ? [resource.memory_store] : [], + ), + ]; + if (memoryStoreNames.length) { + for (const msName of memoryStoreNames) { const msAddr: ResourceAddress = { type: "memory_store", name: msName, diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index f1e3632..422b153 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -93,9 +93,11 @@ const memoryEntrySchema = z.object({ }); const memoryStoreSchema = z.object({ + name: z.string().min(1).max(255).optional(), description: z.string(), provider: z.string().optional(), - metadata: z.record(z.string(), z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + entries_mode: z.enum(["initialize", "sync"]).optional(), entries: z.array(memoryEntrySchema).optional(), }); @@ -175,7 +177,7 @@ const mcpToolkitSchema = z mcpServerName: z.string().optional(), default_config: toolDefaultConfigSchema.optional(), defaultConfig: toolDefaultConfigSchema.optional(), - configs: z.array(toolEnabledSchema).min(1), + configs: z.array(toolEnabledSchema).default([]), }) .refine((t) => t.mcp_server_name !== undefined || t.mcpServerName !== undefined, { message: "Either mcp_server_name or mcpServerName is required", @@ -282,6 +284,22 @@ const sessionGithubRepoResourceSchema = z.object({ authorization_token: z.string().min(1), }); +const sessionMemoryStoreResourceSchema = z + .object({ + type: z.literal("memory_store"), + memory_store: z.string().min(1), + access: z.enum(["read_write", "read_only"]).optional(), + instructions: z.string().max(4096).optional(), + }) + .strict(); + +const sessionResourceSchema = z.discriminatedUnion("type", [ + sessionGithubRepoResourceSchema, + sessionMemoryStoreResourceSchema, +]); + +export const sessionResourcesSchema = z.array(sessionResourceSchema); + const agentSchema = z.object({ name: z.string().optional(), description: z.string().optional(), @@ -303,7 +321,7 @@ const agentSchema = z.object({ delete_on_destroy: z.boolean().optional().default(false), }) .optional(), - resources: z.array(sessionGithubRepoResourceSchema).optional(), + resources: sessionResourcesSchema.optional(), multiagent: multiagentSchema.optional(), metadata: z.record(z.string(), z.string()).optional(), environment_variables: z.record(z.string().min(1), z.string()).optional(), @@ -330,13 +348,6 @@ const deploymentFileResourceSchema = z.object({ mount_path: z.string().optional(), }); -const deploymentMemoryStoreResourceSchema = z.object({ - type: z.literal("memory_store"), - memory_store: z.string(), - access: z.enum(["read_write", "read_only"]).optional(), - instructions: z.string().optional(), -}); - const deploymentGithubRepoResourceSchema = z.object({ type: z.literal("github_repository"), url: z.string(), @@ -347,7 +358,7 @@ const deploymentGithubRepoResourceSchema = z.object({ const deploymentResourceSchema = z.discriminatedUnion("type", [ deploymentFileResourceSchema, - deploymentMemoryStoreResourceSchema, + sessionMemoryStoreResourceSchema, deploymentGithubRepoResourceSchema, ]); @@ -370,6 +381,24 @@ const scheduleSchema = z.object({ timezone: z.string(), }); +// Check reserved keys before z.record can discard them from its parsed result. +const deploymentEnvironmentVariablesSchema = z + .unknown() + .superRefine((environmentVariables, context) => { + if ( + environmentVariables !== null && + typeof environmentVariables === "object" && + Object.hasOwn(environmentVariables, "__proto__") + ) { + context.addIssue({ + code: "custom", + message: + "Deployment environment_variables does not support the reserved key '__proto__' / Deployment environment_variables 不支持保留键 '__proto__'。", + }); + } + }) + .pipe(z.union([z.string(), z.record(z.string(), z.string())])); + const deploymentSchema = z.object({ name: z.string().optional(), agent: z.string(), @@ -384,7 +413,7 @@ const deploymentSchema = z.object({ description: z.string().optional(), provider: z.string().optional(), metadata: z.record(z.string(), z.string()).optional(), - environment_variables: z.string().optional(), + environment_variables: deploymentEnvironmentVariablesSchema.optional(), }); export const projectConfigSchema = z.object({ diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index 20b38ab..e9c654d 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -1,6 +1,6 @@ import { readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import type { ProjectConfig } from "../types/config.ts"; +import type { MemoryStoreDecl, ProjectConfig } from "../types/config.ts"; import type { ResourceAddress, ResourceState } from "../types/state.ts"; import { collectFiles } from "../utils/collect-files.ts"; import { contentHash } from "../utils/hash.ts"; @@ -30,7 +30,20 @@ export async function computeResourceHash( return contentHash({ decl, apiMode }); } - if (address.type === "environment" || address.type === "vault" || address.type === "memory_store") { + if (address.type === "memory_store") { + const { entries, entries_mode, ...storeProperties } = decl as MemoryStoreDecl; + const entriesMode = entries_mode ?? "initialize"; + return contentHash({ + decl: { + ...storeProperties, + entries_mode: entriesMode, + ...(entriesMode === "sync" ? { entries } : {}), + }, + apiMode: resolveQoderApiMode(address.type, address.name, address.provider, config), + }); + } + + if (address.type === "environment" || address.type === "vault") { return contentHash({ decl, apiMode: resolveQoderApiMode(address.type, address.name, address.provider, config) }); } diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 4e48336..86a837a 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -135,13 +135,24 @@ export async function buildPlan( : "Resource does not exist in state"; const updateSuffix = isExternalReference ? " — external reference, no remote mutation" : ""; - if (!existing) { + if (!existing || (address.type === "memory_store" && !existing.remote_id)) { actions.push({ action: "create", address, driftKind: "none", readinessImpact: "blocking", - reason: createReason, + reason: existing ? "Memory Store has no remote ID in state / State 未记录记忆库远端 ID" : createReason, + after: { content_hash: desiredHash }, + dependencies: deps, + }); + } else if (address.type === "memory_store" && existing.memory_initialization) { + actions.push({ + action: "update", + address, + driftKind: "none", + readinessImpact: "blocking", + reason: "Continue new Memory Store initialization / 继续初始化新记忆库", + before: { content_hash: existing.desired_hash ?? existing.content_hash }, after: { content_hash: desiredHash }, dependencies: deps, }); @@ -234,7 +245,9 @@ export async function buildPlan( readinessImpact: "blocking", reason: res.externally_managed ? "Remove local reference only — externally managed remote resource is left intact" - : "Resource removed from configuration", + : res.address.provider === "bailian" && res.address.type === "memory_store" + ? "Archive Memory Store (irreversible) / 归档记忆库(不可逆)" + : "Resource removed from configuration", before: { content_hash: res.desired_hash ?? res.content_hash }, dependencies: replacement ? [replacement] : [], }); diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index 6f9131b..96a4a73 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -18,6 +18,7 @@ import type { CredentialDecl, DeploymentDecl, EnvironmentDecl, + MemoryStoreDecl, SkillDecl, VaultDecl, } from "../../types/config.ts"; @@ -47,6 +48,14 @@ import type { VaultListOptions, VaultPage, } from "../../types/managed-api.ts"; +import type { + CreateMemoryInput, + MemoryListOptions, + MemoryStoreListOptions, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../../types/memory.ts"; import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../../types/session.ts"; import type { EventListOptions, @@ -57,7 +66,22 @@ import type { import type { SkillFile } from "../../types/skill-file.ts"; import type { ProviderSkillInfo } from "../../types/skill-info.ts"; import type { ResourceType } from "../../types/state.ts"; +import type { + CreateWebhookEndpointInput, + UpdateWebhookEndpointInput, + WebhookDeleteResult, + WebhookEndpoint, + WebhookEndpointList, + WebhookEndpointWithSecret, + WebhookEventListOptions, + WebhookEventPage, + WebhookRequestOptions, + WebhookSecretResetResult, + WebhookTestResult, +} from "../../types/webhook.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; +import { sha256 } from "../../utils/hash.ts"; +import { isDeploymentEnvironmentVariableMap } from "../../validation/deployment-environment.ts"; import { toRemoteResource } from "../base-client.ts"; import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts"; import type { @@ -74,11 +98,13 @@ import type { ResolvedAgentRefs, ResolvedDeploymentRefs, } from "../interface.ts"; +import { ProviderMemoryApi } from "../memory-api.ts"; import { extractCreatedEventId, listSessionEventsPaged } from "../session-event-response.ts"; import { buildSessionInfo, exportRemoteResources, locateRemote, + notArchived, toCloudAgent, toCloudEnvironment, toCloudVault, @@ -105,11 +131,32 @@ import { export class BailianAdapter implements ProviderAdapter { readonly name = "bailian" as const; readonly eventResume = false; + readonly memoryCapabilities = { + archive_store: true, + batch_create: false, + versions: true, + optimistic_concurrency: true, + memory_metadata: false, + } as const; private client: BailianClient; + private memoryApi: ProviderMemoryApi; private projectName: string; constructor(apiKey: string, workspaceId?: string, baseUrl?: string, projectName?: string) { this.client = new BailianClient({ apiKey, workspaceId, baseUrl }); + this.memoryApi = new ProviderMemoryApi(this.client, { + pathStyle: "absolute", + cursorParam: "page", + updatePrecondition: "precondition", + prefixParam: "path_prefix", + supportsView: true, + supportsSingleView: false, + supportsMemoryMetadata: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: true, + supportsVersionFilters: true, + preserveRawResponse: true, + }); this.projectName = projectName ?? ""; } @@ -117,6 +164,75 @@ export class BailianAdapter implements ProviderAdapter { await this.client.get("/agents?limit=1"); } + async createWebhookEndpoint( + input: CreateWebhookEndpointInput, + options?: WebhookRequestOptions, + ): Promise { + return this.client.post("/webhook_endpoints", input, options) as Promise; + } + + async listWebhookEndpoints(options?: WebhookRequestOptions): Promise { + return this.client.get("/webhook_endpoints", options) as Promise; + } + + async getWebhookEndpoint(id: string, options?: WebhookRequestOptions): Promise { + return this.client.get(`/webhook_endpoints/${encodeURIComponent(id)}`, options) as Promise; + } + + async updateWebhookEndpoint( + id: string, + input: UpdateWebhookEndpointInput, + options?: WebhookRequestOptions, + ): Promise { + return this.client.put(`/webhook_endpoints/${encodeURIComponent(id)}`, input, options) as Promise; + } + + async deleteWebhookEndpoint(id: string, options?: WebhookRequestOptions): Promise { + return this.client.deleteJson( + `/webhook_endpoints/${encodeURIComponent(id)}`, + options, + ) as Promise; + } + + async enableWebhookEndpoint(id: string, options?: WebhookRequestOptions): Promise { + return this.client.post( + `/webhook_endpoints/${encodeURIComponent(id)}/enable`, + undefined, + options, + ) as Promise; + } + + async disableWebhookEndpoint(id: string, options?: WebhookRequestOptions): Promise { + return this.client.post( + `/webhook_endpoints/${encodeURIComponent(id)}/disable`, + undefined, + options, + ) as Promise; + } + + async testWebhookEndpoint(id: string, options?: WebhookRequestOptions): Promise { + return this.client.post( + `/webhook_endpoints/${encodeURIComponent(id)}/test`, + undefined, + options, + ) as Promise; + } + + async resetWebhookSecret(id: string, options?: WebhookRequestOptions): Promise { + return this.client.post( + `/webhook_endpoints/${encodeURIComponent(id)}/reset_secret`, + undefined, + options, + ) as Promise; + } + + async listWebhookEvents(id: string, options?: WebhookEventListOptions): Promise { + return this.client.get( + withQuery(`/webhook_endpoints/${encodeURIComponent(id)}/events`, cursorParams(options)), + options, + ) as Promise; + } + private static readonly ENDPOINT_MAP: Partial> = { environment: "/environments", agent: "/agents", @@ -124,16 +240,27 @@ export class BailianAdapter implements ProviderAdapter { vault: "/vaults", file: "/files", deployment: "/deployments", + memory_store: "/memory_stores", }; + supportsResourceNameLookup(type: ResourceType): boolean { + return type !== "memory_store"; + } + async findResource(type: ResourceType, name: string, id?: string | null): Promise { - const accept = type === "agent" ? (r: Record) => !isArchivedAgent(r) : undefined; + if (!id && !this.supportsResourceNameLookup(type)) return null; + const accept = + type === "agent" + ? (r: Record) => !isArchivedAgent(r) + : type === "memory_store" + ? notArchived + : undefined; const raw = await locateRemote(this.client, BailianAdapter.ENDPOINT_MAP[type], name, id, accept); return raw ? toRemoteResource(raw) : null; } getDriftSupport(type: ResourceType): DriftSupport { - if (type === "agent" || type === "environment") return "full"; + if (type === "agent" || type === "environment" || type === "memory_store") return "full"; if (type === "skill" || type === "vault" || type === "file") return "existence"; return "unsupported"; } @@ -142,14 +269,26 @@ export class BailianAdapter implements ProviderAdapter { type: ResourceType, id: string | null, name: string, + decl?: unknown, ): Promise { - if (type !== "agent" && type !== "environment") return null; - const endpoint = type === "agent" ? "/agents" : "/environments"; - const accept = type === "agent" ? (r: Record) => !isArchivedAgent(r) : undefined; + if (type !== "agent" && type !== "environment" && type !== "memory_store") return null; + if (type === "memory_store" && !id) return null; + const endpoint = BailianAdapter.ENDPOINT_MAP[type]; + const accept = + type === "agent" + ? (r: Record) => !isArchivedAgent(r) + : type === "memory_store" + ? notArchived + : undefined; const raw = await locateRemote(this.client, endpoint, name, id, accept); if (!raw) return null; - const comparable = this.normalizeRemote(type, raw); + const store = type === "memory_store" ? (decl as MemoryStoreDecl | undefined) : undefined; + const comparable = this.normalizeRemote( + type, + store?.entries_mode === "sync" ? { ...raw, entry_hashes: await this.readMemoryEntryHashes(id!, store) } : raw, + decl, + ); return { id: (raw.id as string | undefined) ?? id, type, @@ -159,7 +298,53 @@ export class BailianAdapter implements ProviderAdapter { }; } + private async readMemoryEntryHashes( + storeId: string, + declaration: MemoryStoreDecl, + ): Promise> { + const hashes = new Map( + (declaration.entries ?? []).map((entry) => [`/${entry.key.replace(/^\/+/, "")}`, null]), + ); + if (hashes.size === 0) return {}; + const seenCursors = new Set(); + let cursor: string | undefined; + do { + // Memory-file listing accepts at most 20 entries per page. + const page = await this.listMemories(storeId, { limit: 20, page: cursor, view: "basic" }); + for (const memory of page.data) { + if (memory.type === "memory" && hashes.has(memory.path)) { + hashes.set(memory.path, memory.content_sha256); + } + } + cursor = page.next_page ?? (page.has_more ? page.next_cursor : undefined); + if (cursor && seenCursors.has(cursor)) { + throw new UserError("Memory pagination returned a repeated cursor. / 记忆分页返回了重复游标。"); + } + if (cursor) seenCursors.add(cursor); + } while (cursor); + return Object.fromEntries(hashes); + } + normalizeDesiredResource(type: ResourceType, name: string, decl: unknown, refs?: ResolvedAgentRefs): unknown | null { + if (type === "memory_store") { + const store = decl as MemoryStoreDecl; + return this.normalizeRemote( + type, + { + name: store.name ?? name, + description: store.description, + metadata: store.metadata ?? {}, + ...(store.entries_mode === "sync" + ? { + entry_hashes: Object.fromEntries( + (store.entries ?? []).map((entry) => [`/${entry.key.replace(/^\/+/, "")}`, sha256(entry.content)]), + ), + } + : {}), + }, + store, + ); + } if (type === "environment") { return this.normalizeRemote( type, @@ -178,7 +363,21 @@ export class BailianAdapter implements ProviderAdapter { return null; } - private normalizeRemote(type: ResourceType, raw: Record): unknown { + private normalizeRemote(type: ResourceType, raw: Record, decl?: unknown): unknown { + if (type === "memory_store") { + const metadata = (raw.metadata ?? {}) as Record; + const store = decl as MemoryStoreDecl | undefined; + const declaredMetadata = store?.metadata; + return compactDeep({ + name: raw.name, + description: raw.description ?? "", + metadata: + decl === undefined + ? metadata + : Object.fromEntries(Object.keys(declaredMetadata ?? {}).map((key) => [key, metadata[key]])), + ...(store?.entries_mode === "sync" ? { entry_hashes: raw.entry_hashes } : {}), + }); + } if (type === "environment") { const config = (raw.config ?? {}) as Record; return compactDeep({ @@ -564,6 +763,70 @@ export class BailianAdapter implements ProviderAdapter { } } + // --- Memory Store --- + + async createMemoryStore(name: string, decl: MemoryStoreDecl): Promise { + const response = await this.client.post("/memory_stores", { + name, + description: decl.description, + ...(decl.metadata !== undefined ? { metadata: decl.metadata } : {}), + }); + // The executor persists this identity before initializing declared entries. + return toRemoteResource(response as Record); + } + + async deleteMemoryStore(id: string): Promise { + await this.memoryApi.archiveStore(id); + } + + listMemoryStores(options?: MemoryStoreListOptions) { + return this.memoryApi.listStores(options); + } + + getMemoryStore(id: string) { + return this.memoryApi.getStore(id); + } + + updateMemoryStore(id: string, input: UpdateMemoryStoreInput) { + return this.memoryApi.updateStore(id, input); + } + + archiveMemoryStore(id: string) { + return this.memoryApi.archiveStore(id); + } + + createMemory(storeId: string, input: CreateMemoryInput) { + return this.memoryApi.createMemory(storeId, input); + } + + listMemories(storeId: string, options?: MemoryListOptions) { + return this.memoryApi.listMemories(storeId, options); + } + + getMemory(storeId: string, memoryId: string) { + return this.memoryApi.getMemory(storeId, memoryId); + } + + updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput) { + return this.memoryApi.updateMemory(storeId, memoryId, input); + } + + deleteMemory(storeId: string, memoryId: string) { + return this.memoryApi.deleteMemory(storeId, memoryId); + } + + listMemoryVersions(storeId: string, options?: MemoryVersionListOptions) { + return this.memoryApi.listVersions(storeId, options); + } + + getMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.getVersion(storeId, versionId); + } + + redactMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.redactVersion(storeId, versionId); + } + // --- Vault (Vaults API) --- // // `createVault` / `deleteVault` satisfy the cross-provider ProviderAdapter @@ -942,7 +1205,9 @@ export class BailianAdapter implements ProviderAdapter { } async sendSessionEvents(sessionId: string, events: SessionEventInput[]): Promise { - const res = (await this.client.post(`/sessions/${sessionId}/events`, { input: events })) as Record; + const res = (await this.client.post(`/sessions/${sessionId}/events`, { + input: events, + })) as Record; return { event_ids: extractCreatedEventIds(res), attributes: res, @@ -1024,6 +1289,9 @@ function toDeploymentInfo(res: Record): DeploymentInfo { return { id: (res.id as string | undefined) ?? null, status: (res.status as string) ?? "unknown", + ...(isDeploymentEnvironmentVariableMap(res.environment_variables) + ? { environment_variables: res.environment_variables } + : {}), paused_reason: (res.paused_reason as DeploymentInfo["paused_reason"] | null | undefined) ?? undefined, schedule: schedule ? { @@ -1036,7 +1304,21 @@ function toDeploymentInfo(res: Record): DeploymentInfo { } export function toSessionInfo(res: Record): ProviderSessionInfo { - return buildSessionInfo(res, () => []); + const sessionInfo = buildSessionInfo(res, (session) => + ((session.resources ?? []) as Array>) + .filter((resource) => resource.type === "memory_store" && typeof resource.memory_store_id === "string") + .map((resource) => resource.memory_store_id as string), + ); + const environmentVariables = res.environment_variables; + if ( + environmentVariables !== null && + typeof environmentVariables === "object" && + !Array.isArray(environmentVariables) && + Object.values(environmentVariables).every((value) => typeof value === "string") + ) { + sessionInfo.environment_variables = environmentVariables as Record; + } + return sessionInfo; } // Raw Bailian `/skills` item → neutral ProviderSkillInfo. Kept provider-local (not the diff --git a/packages/sdk/src/internal/providers/bailian/api-base-url.ts b/packages/sdk/src/internal/providers/bailian/api-base-url.ts new file mode 100644 index 0000000..be703f3 --- /dev/null +++ b/packages/sdk/src/internal/providers/bailian/api-base-url.ts @@ -0,0 +1,41 @@ +import { UserError } from "../../errors.ts"; + +const AGENTSTUDIO_API_PATH = "/api/v1/agentstudio"; + +export interface ResolveBailianApiBaseUrlOptions { + /** An HTTP(S) origin or a URL already ending in /api/v1/agentstudio. */ + baseUrl?: string; + /** Used to construct the Beijing workspace endpoint when baseUrl is absent. */ + workspaceId?: string; +} + +/** Normalize workspace API endpoints without changing BailianClient's verbatim baseUrl contract. */ +export function resolveBailianApiBaseUrl(options: ResolveBailianApiBaseUrlOptions): string { + if (options.baseUrl !== undefined) { + let endpoint: URL; + try { + endpoint = new URL(options.baseUrl); + } catch { + throw new UserError("Bailian API base URL must be an HTTP(S) origin or AgentStudio API URL."); + } + const pathname = endpoint.pathname.replace(/\/+$/, ""); + if ( + !/^https?:$/.test(endpoint.protocol) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + (pathname !== "" && pathname !== AGENTSTUDIO_API_PATH) + ) { + throw new UserError( + "Bailian API base URL must be an HTTP(S) origin or AgentStudio API URL without credentials, query, or fragment.", + ); + } + return `${endpoint.origin}${AGENTSTUDIO_API_PATH}`; + } + const workspaceId = options.workspaceId; + if (!workspaceId || !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(workspaceId)) { + throw new UserError("Bailian workspace API requires a base URL or a valid workspace ID."); + } + return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${AGENTSTUDIO_API_PATH}`; +} diff --git a/packages/sdk/src/internal/providers/bailian/capabilities.ts b/packages/sdk/src/internal/providers/bailian/capabilities.ts index edef3b8..6a5544b 100644 --- a/packages/sdk/src/internal/providers/bailian/capabilities.ts +++ b/packages/sdk/src/internal/providers/bailian/capabilities.ts @@ -7,8 +7,8 @@ export const BAILIAN_CAPABILITIES: ProviderCapabilities = { agent: { tier: "native", reason: "agents API with versioned updates" }, template: { tier: "unsupported", reason: "no Forward Template equivalent on Bailian" }, memory_store: { - tier: "unsupported", - reason: "no memory store primitive on Bailian", + tier: "native", + reason: "memory stores, file memories and historical versions API; stores are archived on destroy", }, mcp_server: { tier: "native", reason: "mcp_servers field on agent (official servers)" }, multiagent: { tier: "native", reason: "coordinator + roster topology" }, diff --git a/packages/sdk/src/internal/providers/bailian/index.ts b/packages/sdk/src/internal/providers/bailian/index.ts index 60d41bc..a125bb0 100644 --- a/packages/sdk/src/internal/providers/bailian/index.ts +++ b/packages/sdk/src/internal/providers/bailian/index.ts @@ -7,7 +7,7 @@ registerProvider({ name: "bailian", configSchema: bailianConfigSchema, capabilities: BAILIAN_CAPABILITIES, - features: { tool_permissions: false, session_resources: [] }, + features: { tool_permissions: false, session_resources: ["memory_store"] }, createAdapter: (config, projectName) => { const c = config as BailianConfig; return new BailianAdapter(c.api_key, c.workspace_id, c.base_url, projectName); diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index acc10e7..56df948 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -14,6 +14,7 @@ import type { ManagedSessionBindings } from "../../types/session.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; import { resolveBuiltinTools } from "../../utils/tool-permissions.ts"; +import { assertDeploymentEnvironmentVariables } from "../../validation/deployment-environment.ts"; import type { MappingOperation, ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; @@ -302,7 +303,7 @@ export function mapAgent( // AgentStudio REST validates snake_case. mcp_server_name: s.name, default_config: mcpTool.default_config ?? { enabled: false }, - configs: mcpTool.configs, + configs: mcpTool.configs ?? [], }); } } @@ -342,6 +343,7 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { if (bindings.title) body.title = bindings.title; if (bindings.metadata) body.metadata = bindings.metadata; + if (bindings.environment_variables !== undefined) body.environment_variables = bindings.environment_variables; // Bind the credential vault(s) via the top-level `vault_ids` array — the shape the // real console (bailian-managed-agents createSession) sends and the downstream // vault→sandbox injection reads. The actual injection happens downstream (user-owned). @@ -355,8 +357,21 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { mount_path: resolveSandboxMountPath("bailian", f.mount_path), }); } + const memoryResources = new Map>(); + for (const storeId of bindings.memory_store_ids) { + memoryResources.set(storeId, { type: "memory_store", memory_store_id: storeId, access: "read_write" }); + } + for (const resource of bindings.resources ?? []) { + if (resource.type !== "memory_store") continue; + memoryResources.set(resource.memory_store_id, { + type: "memory_store", + memory_store_id: resource.memory_store_id, + ...(resource.access !== undefined ? { access: resource.access } : {}), + ...(resource.instructions !== undefined ? { instructions: resource.instructions } : {}), + }); + } + resources.push(...memoryResources.values()); if (resources.length) body.resources = resources; - if (bindings.memory_store_ids.length) body.memory_store_ids = bindings.memory_store_ids; return body; } @@ -368,6 +383,7 @@ export function mapDeployment( projectName?: string, uploadedFiles?: Map, ): unknown { + assertDeploymentEnvironmentVariables("bailian", decl.environment_variables); // `agent` is always an object here: the API's required shape is `{ id, version? }`, // unlike the sessions API which takes a bare agent id string. const agent: Record = { id: refs.agent_id }; @@ -381,6 +397,7 @@ export function mapDeployment( }; if (decl.description) body.description = decl.description; + if (decl.environment_variables !== undefined) body.environment_variables = decl.environment_variables; if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; const resources = mapDeploymentResources(decl, uploadedFiles); @@ -418,6 +435,8 @@ export function mapDeploymentUpdate( ): unknown { const body = mapDeployment(name, decl, refs, projectName, uploadedFiles) as Record; body.description = decl.description ?? ""; + // Update replaces the variable map; an absent declaration sends the empty map. + body.environment_variables = decl.environment_variables ?? {}; body.vault_ids = refs.vault_ids; body.resources = mapDeploymentResources(decl, uploadedFiles); if (!decl.schedule) body.schedule = null; diff --git a/packages/sdk/src/internal/providers/base-client.ts b/packages/sdk/src/internal/providers/base-client.ts index 42ee5c0..1516223 100644 --- a/packages/sdk/src/internal/providers/base-client.ts +++ b/packages/sdk/src/internal/providers/base-client.ts @@ -17,6 +17,11 @@ export class ApiError extends Error { export class ConflictError extends ApiError {} +export interface ApiRequestOptions { + headers?: Record; + signal?: AbortSignal; +} + export abstract class BaseApiClient { protected abstract baseUrl: string; protected abstract headers(): Record; @@ -34,21 +39,23 @@ export abstract class BaseApiClient { throw new ApiError(res.status, body, this.errorPrefix); } - async post(path: string, body: unknown, options?: { headers?: Record }): Promise { + async post(path: string, body: unknown, options?: ApiRequestOptions): Promise { const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "POST", headers: { ...this.headers(), ...options?.headers }, body: JSON.stringify(body), + ...(options?.signal ? { signal: options.signal } : {}), }); await this.throwIfError(res); return res.json(); } - async put(path: string, body: unknown): Promise { + async put(path: string, body: unknown, options?: ApiRequestOptions): Promise { const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "PUT", - headers: this.headers(), + headers: { ...this.headers(), ...options?.headers }, body: JSON.stringify(body), + ...(options?.signal ? { signal: options.signal } : {}), }); await this.throwIfError(res); return res.json(); @@ -62,10 +69,22 @@ export abstract class BaseApiClient { await this.throwIfError(res); } - async get(path: string): Promise { + /** DELETE endpoints with JSON responses; legacy delete() remains void for 204 endpoints. */ + async deleteJson(path: string, options?: ApiRequestOptions): Promise { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { + method: "DELETE", + headers: { ...this.headers(), ...options?.headers }, + ...(options?.signal ? { signal: options.signal } : {}), + }); + await this.throwIfError(res); + return res.json(); + } + + async get(path: string, options?: ApiRequestOptions): Promise { const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "GET", - headers: this.headers(), + headers: { ...this.headers(), ...options?.headers }, + ...(options?.signal ? { signal: options.signal } : {}), }); await this.throwIfError(res); return res.json(); diff --git a/packages/sdk/src/internal/providers/capabilities.ts b/packages/sdk/src/internal/providers/capabilities.ts index 7e906a3..5d6aeee 100644 --- a/packages/sdk/src/internal/providers/capabilities.ts +++ b/packages/sdk/src/internal/providers/capabilities.ts @@ -25,7 +25,7 @@ export type ProviderCapabilities = Record; /** Fine-grained behavior nested inside resource kinds, used for early config validation. */ export interface ProviderFeatures { tool_permissions: boolean; - session_resources: readonly "github_repository"[]; + session_resources: readonly ("github_repository" | "memory_store")[]; } /** diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 0cf3c10..9022f49 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -564,6 +564,7 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { mount_path: resolveSandboxMountPath("claude", f.mount_path), }); for (const resource of bindings.resources ?? []) { + if (resource.type !== "github_repository") continue; resources.push( mapGithubRepositorySessionResource(resource, { mapUrl: normalizeGithubRepositoryUrlForClaude, diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 16d890e..a8cacc2 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -41,6 +41,7 @@ import type { BatchCreateMemoryInput, BatchCreateMemoryResult, CreateMemoryInput, + MemoryDeleteResult, MemoryInfo, MemoryListItem, MemoryListOptions, @@ -63,6 +64,19 @@ import type { import type { SkillFile } from "../types/skill-file.ts"; import type { ProviderSkillInfo } from "../types/skill-info.ts"; import type { ResourceType } from "../types/state.ts"; +import type { + CreateWebhookEndpointInput, + UpdateWebhookEndpointInput, + WebhookDeleteResult, + WebhookEndpoint, + WebhookEndpointList, + WebhookEndpointWithSecret, + WebhookEventListOptions, + WebhookEventPage, + WebhookRequestOptions, + WebhookSecretResetResult, + WebhookTestResult, +} from "../types/webhook.ts"; export interface RemoteResource { id: string | null; @@ -164,6 +178,8 @@ export interface DeploymentRunResult { export interface DeploymentInfo { id: string | null; status: string; + /** Deployment variables echoed as a string map by Bailian. */ + environment_variables?: Record; paused_reason?: { type: string; error?: { type: string } }; schedule?: { expression: string; timezone?: string }; attributes?: Record; @@ -197,11 +213,14 @@ export interface ProviderAdapter { readonly memoryCapabilities?: MemoryProviderCapabilities; validate(): Promise; + /** False for resource kinds whose non-unique names cannot identify an existing remote resource. */ + supportsResourceNameLookup?(type: ResourceType): boolean; /** * Locate a remote resource. When `id` is provided, the resource is verified * precisely via its detail endpoint (`GET /{endpoint}/{id}`), which avoids the * ambiguity of name matching when names are not unique. Without `id`, falls - * back to listing and matching by `name` (used by conflict adoption). + * back to listing and matching by `name` (used by conflict adoption), unless + * supportsResourceNameLookup returns false for that resource kind. */ findResource( type: ResourceType, @@ -254,6 +273,25 @@ export interface ProviderAdapter { */ exportResources?(type: ResourceType): Promise; + /** Workspace-level APIs do not participate in project declaration or state. */ + createWebhookEndpoint?( + input: CreateWebhookEndpointInput, + options?: WebhookRequestOptions, + ): Promise; + listWebhookEndpoints?(options?: WebhookRequestOptions): Promise; + getWebhookEndpoint?(id: string, options?: WebhookRequestOptions): Promise; + updateWebhookEndpoint?( + id: string, + input: UpdateWebhookEndpointInput, + options?: WebhookRequestOptions, + ): Promise; + deleteWebhookEndpoint?(id: string, options?: WebhookRequestOptions): Promise; + enableWebhookEndpoint?(id: string, options?: WebhookRequestOptions): Promise; + disableWebhookEndpoint?(id: string, options?: WebhookRequestOptions): Promise; + testWebhookEndpoint?(id: string, options?: WebhookRequestOptions): Promise; + resetWebhookSecret?(id: string, options?: WebhookRequestOptions): Promise; + listWebhookEvents?(id: string, options?: WebhookEventListOptions): Promise; + createEnvironment(name: string, decl: EnvironmentDecl, mode?: ProviderResourceMode): Promise; updateEnvironment( id: string, @@ -322,7 +360,8 @@ export interface ProviderAdapter { input: UpdateMemoryInput, mode?: ProviderResourceMode, ): Promise; - deleteMemory?(storeId: string, memoryId: string, expectedContentSha256?: string): Promise; + // biome-ignore lint/suspicious/noConfusingVoidType: Preserve legacy providers returning void alongside native receipts. + deleteMemory?(storeId: string, memoryId: string, expectedContentSha256?: string): Promise; listMemoryVersions?(storeId: string, options?: MemoryVersionListOptions): Promise>; getMemoryVersion?(storeId: string, versionId: string): Promise; redactMemoryVersion?(storeId: string, versionId: string): Promise; diff --git a/packages/sdk/src/internal/providers/memory-api.ts b/packages/sdk/src/internal/providers/memory-api.ts index d926895..9c6df2d 100644 --- a/packages/sdk/src/internal/providers/memory-api.ts +++ b/packages/sdk/src/internal/providers/memory-api.ts @@ -2,6 +2,7 @@ import type { BatchCreateMemoryInput, BatchCreateMemoryResult, CreateMemoryInput, + MemoryDeleteResult, MemoryInfo, MemoryListItem, MemoryListOptions, @@ -29,6 +30,11 @@ export interface MemoryApiDialect { supportsPathUpdate?: boolean; supportsDeletePrecondition: boolean; supportsIncludeArchived: boolean; + /** Keep the provider's resource fields and native pagination envelope intact. */ + preserveRawResponse?: boolean; + /** Some APIs support view only on list endpoints. */ + supportsSingleView?: boolean; + supportsVersionFilters?: boolean; } function query(path: string, values: Record): string { @@ -135,19 +141,35 @@ export class ProviderMemoryApi { private readonly dialect: MemoryApiDialect, ) {} + private response(raw: unknown, map: (item: Record) => T): T { + return this.dialect.preserveRawResponse ? (raw as T) : map(raw as Record); + } + + private responsePage(raw: unknown, map: (item: Record) => T): MemoryPage { + return this.dialect.preserveRawResponse ? (raw as MemoryPage) : page(raw, map); + } + + private resourcePath(storeId: string, segment?: string, resourceId?: string): string { + return `/memory_stores/${encodeURIComponent(storeId)}${segment ? `/${segment}` : ""}${resourceId !== undefined ? `/${encodeURIComponent(resourceId)}` : ""}`; + } + + private singleView(): string { + return this.dialect.supportsView && this.dialect.supportsSingleView !== false ? "?view=full" : ""; + } + async listStores(options: MemoryStoreListOptions = {}): Promise> { const raw = await this.client.get( query("/memory_stores", { limit: options.limit, - [this.dialect.cursorParam]: options.cursor, + [this.dialect.cursorParam]: options.page ?? options.cursor, include_archived: this.dialect.supportsIncludeArchived ? options.include_archived : undefined, }), ); - return page(raw, mapMemoryStore); + return this.responsePage(raw, mapMemoryStore); } async getStore(id: string): Promise { - return mapMemoryStore((await this.client.get(`/memory_stores/${id}`)) as Record); + return this.response(await this.client.get(this.resourcePath(id)), mapMemoryStore); } async updateStore(id: string, input: UpdateMemoryStoreInput): Promise { @@ -162,11 +184,14 @@ export class ProviderMemoryApi { }, }; } - return mapMemoryStore((await this.client.post(`/memory_stores/${id}`, body)) as Record); + return this.response(await this.client.post(this.resourcePath(id), body), mapMemoryStore); } async archiveStore(id: string): Promise { - return mapMemoryStore((await this.client.post(`/memory_stores/${id}/archive`, {})) as Record); + return this.response( + await this.client.post(`${this.resourcePath(id)}/archive`, this.dialect.preserveRawResponse ? undefined : {}), + mapMemoryStore, + ); } async createMemory(storeId: string, input: CreateMemoryInput): Promise { @@ -175,29 +200,32 @@ export class ProviderMemoryApi { content: input.content, ...(this.dialect.supportsMemoryMetadata && input.metadata ? { metadata: input.metadata } : {}), }; - return mapMemory((await this.client.post(`/memory_stores/${storeId}/memories`, body)) as Record); + return this.response(await this.client.post(this.resourcePath(storeId, "memories"), body), mapMemory); } async listMemories(storeId: string, options: MemoryListOptions = {}): Promise> { const raw = await this.client.get( - query(`/memory_stores/${storeId}/memories`, { + query(this.resourcePath(storeId, "memories"), { limit: options.limit, - [this.dialect.cursorParam]: options.cursor, - [this.dialect.prefixParam]: options.prefix ? providerPath(options.prefix, this.dialect.pathStyle) : undefined, + [this.dialect.cursorParam]: options.page ?? options.cursor, + [this.dialect.prefixParam]: + (options.path_prefix ?? options.prefix) !== undefined + ? providerPath(options.path_prefix ?? options.prefix ?? "", this.dialect.pathStyle) + : undefined, depth: options.depth, view: this.dialect.supportsView ? options.view : undefined, }), ); - return page(raw, mapMemoryListItem); + return this.responsePage(raw, mapMemoryListItem); } async getMemory(storeId: string, memoryId: string): Promise { - const path = `/memory_stores/${storeId}/memories/${memoryId}${this.dialect.supportsView ? "?view=full" : ""}`; - return mapMemory((await this.client.get(path)) as Record); + const path = `${this.resourcePath(storeId, "memories", memoryId)}${this.singleView()}`; + return this.response(await this.client.get(path), mapMemory); } async updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise { - const { expected_content_sha256, ...values } = input; + const { expected_content_sha256, precondition, ...values } = input; const body: Record = { ...(values.content !== undefined ? { content: values.content } : {}), ...(this.dialect.supportsMemoryMetadata && values.metadata ? { metadata: values.metadata } : {}), @@ -205,55 +233,60 @@ export class ProviderMemoryApi { ? { path: providerPath(values.path, this.dialect.pathStyle) } : {}), }; - if (expected_content_sha256 && this.dialect.updatePrecondition !== "none") { + if (precondition && this.dialect.updatePrecondition === "precondition") body.precondition = precondition; + else if (expected_content_sha256 && this.dialect.updatePrecondition !== "none") { if (this.dialect.updatePrecondition === "precondition") { body.precondition = { type: "content_sha256", content_sha256: expected_content_sha256 }; } else { body[this.dialect.updatePrecondition] = expected_content_sha256; } } - const path = `/memory_stores/${storeId}/memories/${memoryId}${this.dialect.supportsView ? "?view=full" : ""}`; + const path = `${this.resourcePath(storeId, "memories", memoryId)}${this.singleView()}`; const raw = await this.client.post(path, body); - return mapMemory(raw as Record); + return this.response(raw, mapMemory); } - async deleteMemory(storeId: string, memoryId: string, expected?: string): Promise { - await this.client.delete( - query(`/memory_stores/${storeId}/memories/${memoryId}`, { - expected_content_sha256: this.dialect.supportsDeletePrecondition ? expected : undefined, - }), - ); + // biome-ignore lint/suspicious/noConfusingVoidType: Preserve the legacy void result for providers with empty DELETE responses. + async deleteMemory(storeId: string, memoryId: string, expected?: string): Promise { + const path = query(this.resourcePath(storeId, "memories", memoryId), { + expected_content_sha256: this.dialect.supportsDeletePrecondition ? expected : undefined, + }); + if (this.dialect.preserveRawResponse) return (await this.client.deleteJson(path)) as MemoryDeleteResult; + await this.client.delete(path); } async listVersions(storeId: string, options: MemoryVersionListOptions = {}): Promise> { const segment = this.dialect.versionsSegment ?? "memory_versions"; const raw = await this.client.get( - query(`/memory_stores/${storeId}/${segment}`, { + query(this.resourcePath(storeId, segment), { limit: options.limit, - [this.dialect.cursorParam]: options.cursor, + [this.dialect.cursorParam]: options.page ?? options.cursor, memory_id: options.memory_id, + operation: this.dialect.supportsVersionFilters ? options.operation : undefined, + "created_at.gte": this.dialect.supportsVersionFilters ? options["created_at.gte"] : undefined, + "created_at.lte": this.dialect.supportsVersionFilters ? options["created_at.lte"] : undefined, view: this.dialect.supportsView ? options.view : undefined, }), ); - return page(raw, mapMemoryVersion); + return this.responsePage(raw, mapMemoryVersion); } async getVersion(storeId: string, versionId: string): Promise { const segment = this.dialect.versionsSegment ?? "memory_versions"; - return mapMemoryVersion( - (await this.client.get( - `/memory_stores/${storeId}/${segment}/${versionId}${this.dialect.supportsView ? "?view=full" : ""}`, - )) as Record, + return this.response( + await this.client.get(`${this.resourcePath(storeId, segment, versionId)}${this.singleView()}`), + mapMemoryVersion, ); } async redactVersion(storeId: string, versionId: string): Promise { const segment = this.dialect.versionsSegment ?? "memory_versions"; - return mapMemoryVersion( - (await this.client.post(`/memory_stores/${storeId}/${segment}/${versionId}/redact`, {})) as Record< - string, - unknown - >, + return this.response( + await this.client.post( + `${this.resourcePath(storeId, segment, versionId)}/redact`, + this.dialect.preserveRawResponse ? undefined : {}, + ), + mapMemoryVersion, ); } diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 9429bc1..2e69e5c 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -17,6 +17,7 @@ import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; import { permissionOverridesFromWire, resolveBuiltinTools, toPermissionPolicy } from "../../utils/tool-permissions.ts"; +import { assertDeploymentEnvironmentVariables } from "../../validation/deployment-environment.ts"; import type { MappingOperation, ResolvedAgentRefs, @@ -277,6 +278,7 @@ export function mapDeployment( projectName?: string, uploadedFiles?: Map, ): unknown { + assertDeploymentEnvironmentVariables("qoder", decl.environment_variables); const body: Record = { name: decl.name ?? name, agent: @@ -726,6 +728,7 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { for (const f of bindings.files ?? []) resources.push({ type: "file", file_id: f.file_id, mount_path: resolveSandboxMountPath("qoder", f.mount_path) }); for (const resource of bindings.resources ?? []) { + if (resource.type !== "github_repository") continue; resources.push( mapGithubRepositorySessionResource(resource, { mapMountPath: (item) => resolveGithubRepositoryMountPath("qoder", item), diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index cd24c0b..337ac5a 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -43,6 +43,8 @@ import type { */ export interface ResourceCrudAdapter { readonly name: string; + /** False disables name-only lookups and conflict adoption for this resource kind. */ + supportsResourceNameLookup?(type: ResourceType): boolean; findResource( type: ResourceType, @@ -93,6 +95,7 @@ export interface ResourceCrudAdapter { // implement these. The registry validates the matrix↔method match; unsupported // providers omit them entirely (no throw-stubs). createMemoryStore?(name: string, decl: MemoryStoreDecl, mode?: ProviderResourceMode): Promise; + getMemoryStore?(id: string, mode?: ProviderResourceMode): Promise; deleteMemoryStore?(id: string, mode?: ProviderResourceMode): Promise; updateMemoryStore?(id: string, input: UpdateMemoryStoreInput, mode?: ProviderResourceMode): Promise; createMemory?(storeId: string, input: CreateMemoryInput, mode?: ProviderResourceMode): Promise; diff --git a/packages/sdk/src/internal/providers/shared.ts b/packages/sdk/src/internal/providers/shared.ts index f89e85a..f339b0f 100644 --- a/packages/sdk/src/internal/providers/shared.ts +++ b/packages/sdk/src/internal/providers/shared.ts @@ -2,7 +2,7 @@ import { buildReverseIndex, memberDeclResolver } from "../multiagent/reverse-ind import type { MultiagentMemberDecl } from "../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../types/dto.ts"; import type { ProviderFileInfo } from "../types/file.ts"; -import type { ProviderSessionInfo } from "../types/session.ts"; +import type { ProviderSessionInfo, SessionResourceInfo } from "../types/session.ts"; import type { ProviderSkillInfo } from "../types/skill-info.ts"; import type { ResourceType } from "../types/state.ts"; import { ApiError, type BaseApiClient } from "./base-client.ts"; @@ -73,6 +73,7 @@ export function buildSessionInfo( title: res.title as string | undefined, vault_ids: extractVaultIds(res), memory_store_ids: memoryStoreIds(res), + ...(Array.isArray(res.resources) ? { resources: res.resources as SessionResourceInfo[] } : {}), created_at: res.created_at as string, updated_at: res.updated_at as string, attributes: res, diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index e1bad65..2ded08a 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -1,10 +1,11 @@ import { resolveAgentMaterialization } from "../core/agent-materialization.ts"; import { UserError } from "../errors.ts"; import { requireRef } from "../executor/resolver.ts"; +import { sessionResourcesSchema } from "../parser/schema.ts"; import { getProvider } from "../providers/registry.ts"; import type { IStateManager } from "../state/state-manager.ts"; import type { AgentDecl, ProjectConfig, SessionResourceDecl } from "../types/config.ts"; -import type { SessionBindings } from "../types/session.ts"; +import type { SessionBindings, SessionMemoryStoreResource, SessionResource } from "../types/session.ts"; import { resolveSandboxMountPath } from "../utils/sandbox-mount.ts"; export interface SessionCreateOptions { @@ -38,10 +39,20 @@ export interface SessionCreateOptions { title?: string; provider?: string; metadata?: Record; - /** Session-level environment variables. Currently supported by Qoder. */ + /** Session-level environment variables. Supported by Bailian and Qoder. */ environmentVariables?: Record; } +/** Validate user-provided Session resources without loading a project or credentials. */ +export function parseSessionResources(input: unknown): SessionResourceDecl[] { + const parsed = sessionResourcesSchema.safeParse(input); + if (!parsed.success) { + const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "); + throw new UserError(`Invalid Session resources / 会话资源配置无效: ${details}`); + } + return parsed.data; +} + export function resolveSessionProvider(agentName: string, config: ProjectConfig, overrideProvider?: string): string { const agent = config.agents?.[agentName]; if (!agent) { @@ -74,10 +85,22 @@ export function buildSessionBindings( throw new UserError(`Agent '${agentName}' not found in config. Available agents: ${available || "(none)"}`); } const sessionResources = options.resources ?? agent.resources; + if (sessionResources) { + // Keep the existing Git runtime contract, including scp-like SSH remotes. + parseSessionResources(sessionResources.filter((resource) => resource.type === "memory_store")); + } const sessionFiles = resolveSessionFiles(agentName, agent, provider, state, options.files ?? []); + // Agent defaults remain Qoder-only; Bailian variables must be supplied for this Session. + if (options.environmentVariables === undefined && agent.environment_variables !== undefined && provider !== "qoder") { + throw new UserError( + "Agent environment_variables defaults are supported only by Qoder; pass explicit Session environmentVariables instead. / Agent 的 environment_variables 默认值仅支持 Qoder;请在创建 Session 时显式传入环境变量。", + ); + } const environmentVariables = options.environmentVariables ?? agent.environment_variables; - if (environmentVariables && provider !== "qoder") { - throw new UserError("Session environment variables are supported only by Qoder."); + if (environmentVariables && provider !== "qoder" && provider !== "bailian") { + throw new UserError( + "Session environment variables are supported only by Bailian and Qoder / 会话环境变量仅支持百炼和 Qoder。", + ); } const providerFeatures = getProvider(provider)?.features; for (const resource of sessionResources ?? []) { @@ -148,12 +171,13 @@ export function buildSessionBindings( } } - const msNames = options.memoryStores ?? agent.memory_stores ?? []; - const memoryStoreIds: string[] = []; - for (const msName of msNames) { - validateResourceInConfig(msName, "memory_store", config.memory_stores); - memoryStoreIds.push(requireRef(state, { type: "memory_store", name: msName, provider })); - } + const memoryBindings = resolveSessionMemoryStores( + config, + provider, + state, + options.memoryStores ?? agent.memory_stores ?? [], + sessionResources, + ); return { agent_id: agentId, @@ -161,15 +185,90 @@ export function buildSessionBindings( environment_id: environmentId, tunnel_id: tunnelId, vault_ids: vaultIds, - memory_store_ids: memoryStoreIds, + memory_store_ids: memoryBindings.memoryStoreIds, files: sessionFiles, - resources: sessionResources, + resources: memoryBindings.resources, title: options.title, metadata: options.metadata, environment_variables: environmentVariables, }; } +function resolveSessionMemoryStores( + config: ProjectConfig, + provider: string, + state: IStateManager, + legacyNames: string[], + declaredResources?: SessionResourceDecl[], +): { memoryStoreIds: string[]; resources?: SessionResource[] } { + const resolveStoreId = (name: string): string => { + validateResourceInConfig(name, "memory_store", config.memory_stores); + const declaration = config.memory_stores![name]!; + if (declaration.provider && declaration.provider !== provider) { + throw new UserError( + `Memory Store '${name}' belongs to provider '${declaration.provider}', not '${provider}' / 记忆库与会话的服务商不一致。`, + ); + } + const address = { type: "memory_store" as const, name, provider }; + if (state.getResource(address)?.memory_initialization) { + throw new UserError( + `Memory Store '${name}' initialization must complete before creating a Session / 记忆库初始化尚未完成,请先完成 apply。`, + ); + } + return requireRef(state, address); + }; + const memoryStoreIds = legacyNames.map(resolveStoreId); + if (provider !== "bailian") { + return { + memoryStoreIds, + resources: declaredResources?.filter((resource) => resource.type === "github_repository"), + }; + } + + const mounts = new Map(); + for (const storeId of memoryStoreIds) { + mounts.set(storeId, { type: "memory_store", memory_store_id: storeId, access: "read_write" }); + } + const explicitIds = new Set(); + for (const resource of declaredResources ?? []) { + if (resource.type !== "memory_store") continue; + if (resource.access !== undefined && resource.access !== "read_only" && resource.access !== "read_write") { + throw new UserError( + "Memory Store access must be read_only or read_write / 记忆库访问权限必须是 read_only 或 read_write。", + ); + } + if (resource.instructions !== undefined && resource.instructions.length > 4096) { + throw new UserError( + "Memory Store instructions must not exceed 4096 characters / 记忆库挂载说明不能超过 4096 字符。", + ); + } + const storeId = resolveStoreId(resource.memory_store); + if (explicitIds.has(storeId)) { + throw new UserError( + `Memory Store '${resource.memory_store}' is declared more than once in Session resources / 会话资源中不能重复声明同一记忆库。`, + ); + } + explicitIds.add(storeId); + mounts.set(storeId, { + type: "memory_store", + memory_store_id: storeId, + access: resource.access ?? "read_write", + ...(resource.instructions !== undefined ? { instructions: resource.instructions } : {}), + }); + } + if (mounts.size > 8) { + throw new UserError("A Bailian Session can mount at most 8 Memory Stores / 百炼会话最多挂载 8 个记忆库。"); + } + const resolvedResources: SessionResource[] = [ + ...(declaredResources?.filter((resource) => resource.type === "github_repository") ?? []), + ...mounts.values(), + ]; + return { + memoryStoreIds: [...mounts.keys()], + ...(declaredResources !== undefined || resolvedResources.length ? { resources: resolvedResources } : {}), + }; +} + function resolveSessionFiles( agentName: string, agent: AgentDecl, diff --git a/packages/sdk/src/internal/state/state-manager.ts b/packages/sdk/src/internal/state/state-manager.ts index d53ebd6..937c286 100644 --- a/packages/sdk/src/internal/state/state-manager.ts +++ b/packages/sdk/src/internal/state/state-manager.ts @@ -17,6 +17,7 @@ export class StateManager implements IStateManager { private state: StateFile; private path: string; private index: Map; + private pendingSave: Promise = Promise.resolve(); private constructor(state: StateFile, path: string) { this.state = state; @@ -41,6 +42,7 @@ export class StateManager implements IStateManager { remote_id: r.remote_id as string | null, externally_managed: r.externally_managed === true ? true : undefined, api_mode: r.api_mode === "forward" ? "forward" : r.api_mode === "managed" ? "managed" : undefined, + memory_initialization: r.memory_initialization as ResourceState["memory_initialization"], 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) ?? "", @@ -118,10 +120,17 @@ export class StateManager implements IStateManager { } async save(): Promise { + const snapshot = `${JSON.stringify(this.state, null, 2)}\n`; + const pending = this.pendingSave.catch(() => {}).then(() => this.writeSnapshot(snapshot)); + this.pendingSave = pending; + await pending; + } + + private async writeSnapshot(snapshot: string): Promise { await mkdir(dirname(this.path), { recursive: true }); const tmpPath = `${this.path}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; try { - await writeFile(tmpPath, `${JSON.stringify(this.state, null, 2)}\n`); + await writeFile(tmpPath, snapshot); await rename(tmpPath, this.path); } catch (error) { await unlink(tmpPath).catch(() => {}); diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 519a898..2966dfd 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -129,9 +129,12 @@ export interface CredentialDecl { // --- Memory Store --- export interface MemoryStoreDecl { + name?: string; description: string; provider?: ProviderName; - metadata?: Record; + metadata?: Record; + /** Initialize entries only on creation by default; sync explicitly keeps declared paths managed. */ + entries_mode?: "initialize" | "sync"; entries?: MemoryEntryDecl[]; } @@ -261,7 +264,8 @@ export interface AgentMcpToolkitDecl { type: "mcp_toolkit"; mcp_server_name: string; default_config?: { enabled: boolean }; - configs: Array<{ name: string; enabled: boolean }>; + /** Per-tool overrides; omitted or empty uses default_config for every tool. */ + configs?: Array<{ name: string; enabled: boolean }>; } export interface McpServerDecl { @@ -294,8 +298,8 @@ export interface DeploymentDecl { description?: string; provider?: ProviderName; metadata?: Record; - /** Qoder-only deployment-level variables copied into each created session. */ - environment_variables?: string; + /** Variables copied into Deployment sessions: Bailian string maps or Qoder's existing string format. */ + environment_variables?: string | Record; } export type DeploymentResourceDecl = @@ -309,7 +313,15 @@ export interface SessionGithubRepoResourceDecl extends Omit; export const CreateSessionResponseSchema = z.object({ session_id: z.string(), created_at: z.string().optional(), + environment_variables: z.record(z.string(), z.string()).optional(), }); export type CreateSessionResponse = z.infer; diff --git a/packages/sdk/src/internal/types/memory.ts b/packages/sdk/src/internal/types/memory.ts index 7fd1e97..7b1bf38 100644 --- a/packages/sdk/src/internal/types/memory.ts +++ b/packages/sdk/src/internal/types/memory.ts @@ -1,6 +1,7 @@ -export type MemoryMetadata = Record; +export type MemoryMetadata = Record; export interface MemoryStoreInfo { + [key: string]: unknown; id: string; type: "memory_store"; name: string; @@ -17,6 +18,7 @@ export interface MemoryStoreInfo { } export interface MemoryInfo { + [key: string]: unknown; id: string; type: "memory"; memory_store_id: string; @@ -26,7 +28,7 @@ export interface MemoryInfo { content_sha256: string; version?: number; memory_version_id?: string; - metadata: MemoryMetadata; + metadata?: MemoryMetadata; created_by?: MemoryActor; created_at: string; updated_at: string; @@ -49,6 +51,7 @@ export interface MemoryActor { } export interface MemoryVersionInfo { + [key: string]: unknown; id: string; type: "memory_version"; memory_store_id: string; @@ -66,9 +69,26 @@ export interface MemoryVersionInfo { } export interface MemoryPage { + [key: string]: unknown; data: T[]; - has_more: boolean; + /** Portable pagination used by existing providers. Native APIs may omit it. */ + has_more?: boolean; next_cursor?: string; + /** Opaque native page cursor. Null or absence indicates the last page. */ + next_page?: string | null; + /** Child directory prefixes returned by native depth=1 listings. */ + prefixes?: string[]; +} + +export interface MemoryDeleteResult { + [key: string]: unknown; + id: string; + type: "memory_deleted"; +} + +export interface MemoryContentPrecondition { + type: "content_sha256"; + content_sha256: string; } export interface MemoryProviderCapabilities { @@ -82,13 +102,16 @@ export interface MemoryProviderCapabilities { export interface MemoryStoreListOptions { limit?: number; cursor?: string; + page?: string; include_archived?: boolean; } export interface MemoryListOptions { limit?: number; cursor?: string; + page?: string; prefix?: string; + path_prefix?: string; depth?: number; view?: "basic" | "full"; } @@ -96,7 +119,11 @@ export interface MemoryListOptions { export interface MemoryVersionListOptions { limit?: number; cursor?: string; + page?: string; memory_id?: string; + operation?: MemoryVersionOperation; + "created_at.gte"?: string; + "created_at.lte"?: string; view?: "basic" | "full"; } @@ -138,4 +165,5 @@ export interface UpdateMemoryInput { content?: string; metadata?: MemoryMetadata; expected_content_sha256?: string; + precondition?: MemoryContentPrecondition; } diff --git a/packages/sdk/src/internal/types/session.ts b/packages/sdk/src/internal/types/session.ts index 32cc36c..c01a687 100644 --- a/packages/sdk/src/internal/types/session.ts +++ b/packages/sdk/src/internal/types/session.ts @@ -13,7 +13,24 @@ export interface SessionGithubRepositoryResource { authorization_token: string; } -export type SessionResource = SessionGithubRepositoryResource; +export interface SessionMemoryStoreResource { + type: "memory_store"; + memory_store_id: string; + access?: "read_write" | "read_only"; + instructions?: string; +} + +export type SessionResource = SessionGithubRepositoryResource | SessionMemoryStoreResource; + +/** Provider response resource, including server-assigned sandbox mount paths. */ +export interface SessionResourceInfo extends Record { + type: string; + mount_path?: string; + memory_store_id?: string; + access?: "read_write" | "read_only"; + instructions?: string; + file_id?: string; +} interface CommonSessionBindings { /** Uploaded files to mount in the session sandbox. */ @@ -22,7 +39,7 @@ interface CommonSessionBindings { resources?: SessionResource[]; title?: string; metadata?: Record; - /** Provider-neutral representation; Qoder maps it to each Session API's wire shape. */ + /** Provider-neutral representation; supported providers map it to their Session API's wire shape. */ environment_variables?: Record; } @@ -56,8 +73,11 @@ export interface ProviderSessionInfo { tunnel_id?: string; status: string; title?: string; + /** Session sandbox environment variables echoed by supported providers. */ + environment_variables?: Record; vault_ids: string[]; memory_store_ids: string[]; + resources?: SessionResourceInfo[]; created_at: string; updated_at: string; attributes: Record; diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index d89b4df..9e60d20 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -12,6 +12,10 @@ export interface ResourceState { externally_managed?: boolean; /** Provider API domain that owns the remote resource (currently relevant to Qoder). */ api_mode?: "managed" | "forward" | "auto"; + /** New-store initialization checkpoint; existing and imported stores never acquire it. */ + memory_initialization?: { + pending_entries: Array<{ path: string; content_sha256: string }>; + }; version?: number; /** * Backward-compatible alias for desired_hash. Kept while older state files diff --git a/packages/sdk/src/internal/types/webhook.ts b/packages/sdk/src/internal/types/webhook.ts new file mode 100644 index 0000000..6b1494e --- /dev/null +++ b/packages/sdk/src/internal/types/webhook.ts @@ -0,0 +1,106 @@ +/** Fields returned for a workspace Webhook endpoint. Signing secrets are separate. */ +export interface WebhookEndpoint { + id: string; + description: string | null; + url: string; + events: string[]; + status: "ACTIVE" | "DISABLED"; + disabled_reason: string | null; + consecutive_fail: number; + last_success_at: string | null; + last_failure_at: string | null; + created_at: string; + updated_at: string; + /** Present on individual endpoint responses, absent on list entries. */ + request_id?: string; +} + +export interface CreateWebhookEndpointInput { + url: string; + events: string[]; + description?: string; +} + +/** Omitted fields are unchanged; events replaces the full subscription array. */ +export interface UpdateWebhookEndpointInput { + url?: string; + events?: string[]; + /** null and the empty string both clear the description. */ + description?: string | null; +} + +export interface WebhookEndpointWithSecret extends WebhookEndpoint { + /** Returned only once, on creation. Hosts must deliver it without logging or persisting it implicitly. */ + signing_secret: string; + request_id: string; +} + +/** The endpoint list is not paginated. */ +export interface WebhookEndpointList { + data: WebhookEndpoint[]; + request_id: string; +} + +export interface WebhookDeleteResult { + request_id: string; +} + +export interface WebhookSecretResetResult { + id: string; + /** Returned only once; the previous secret is invalidated by the server. */ + signing_secret: string; + updated_at: string; + request_id: string; +} + +export interface WebhookEventData { + id: string; + type: string; + workspace_id: string; + session_thread_id?: string; + vault_id?: string; + [field: string]: unknown; +} + +export interface WebhookEvent { + type: "event"; + id: string; + created_at: string; + data: WebhookEventData; +} + +export interface WebhookTestResult extends WebhookEvent { + request_id: string; +} + +export interface WebhookDelivery { + webhook_id: string; + /** The provider owns delivery states; do not assume a closed set of values. */ + status: string; + attempt_count: number; + delivery_at: string | null; + finish_at: string | null; + failure_reason: string | null; +} + +export interface WebhookEventWithDelivery extends WebhookEvent { + delivery: WebhookDelivery; +} + +/** Preserve the opaque nullable cursor and request ID, without synthesizing has_more. */ +export interface WebhookEventPage { + data: WebhookEventWithDelivery[]; + next_page: string | null; + request_id: string; +} + +export interface WebhookRequestOptions { + provider?: string; + signal?: AbortSignal; +} + +export interface WebhookEventListOptions extends WebhookRequestOptions { + /** Provider default is 20; supported values are 1 through 100. */ + limit?: number; + page?: string; +} diff --git a/packages/sdk/src/internal/validation/deployment-environment.ts b/packages/sdk/src/internal/validation/deployment-environment.ts new file mode 100644 index 0000000..462cc76 --- /dev/null +++ b/packages/sdk/src/internal/validation/deployment-environment.ts @@ -0,0 +1,51 @@ +import { UserError } from "../errors.ts"; + +export function isDeploymentEnvironmentVariableMap(value: unknown): value is Record { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !Object.hasOwn(value, "__proto__") && + Object.values(value).every((variableValue) => typeof variableValue === "string") + ); +} + +export function getDeploymentEnvironmentVariablesIssue( + provider: string, + value: unknown, +): { kind: "invalid" | "unsupported"; message: string } | undefined { + if (value === undefined) return undefined; + if (value !== null && typeof value === "object" && Object.hasOwn(value, "__proto__")) { + return { + kind: "invalid", + message: + "Deployment environment_variables does not support the reserved key '__proto__' / Deployment environment_variables 不支持保留键 '__proto__'。", + }; + } + if (provider === "bailian") { + if (isDeploymentEnvironmentVariableMap(value)) return undefined; + return { + kind: "invalid", + message: + "Bailian Deployment environment_variables must be a string-valued object / 百炼 Deployment 的 environment_variables 必须为字符串键值对象。", + }; + } + if (provider === "qoder") { + if (typeof value === "string") return undefined; + return { + kind: "invalid", + message: + "Qoder Deployment environment_variables must use the provider's string format / Qoder Deployment 的 environment_variables 必须使用服务商的字符串格式。", + }; + } + return { + kind: "unsupported", + message: + "Deployment environment_variables are supported only by Bailian and Qoder / Deployment 环境变量仅支持百炼和 Qoder。", + }; +} + +export function assertDeploymentEnvironmentVariables(provider: string, value: unknown): void { + const issue = getDeploymentEnvironmentVariablesIssue(provider, value); + if (issue) throw new UserError(issue.message); +} diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index 53f844c..59dce9f 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -191,10 +191,80 @@ describe("BailianAdapter e2e", () => { }); test("returns null for unmapped resource types", async () => { - const result = await makeAdapter().findResource("memory_store", "any"); + const result = await makeAdapter().findResource("channel", "any"); expect(result).toBeNull(); }); + test.each([undefined, null, ""])("does not look up a memory store by name when id is %p", async (storeId) => { + const { calls, restore } = mockFetch([ + { + status: 200, + body: { + data: [ + { id: "memstore_first", type: "memory_store", name: "notes" }, + { id: "memstore_second", type: "memory_store", name: "notes" }, + ], + }, + }, + ]); + cleanup = restore; + const adapter = makeAdapter(); + const result = + storeId === undefined + ? await adapter.findResource("memory_store", "notes") + : await adapter.findResource("memory_store", "notes", storeId); + + expect(result).toBeNull(); + expect(calls).toHaveLength(0); + }); + + test("looks up the exact memory store id without requesting a same-name list", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "memstore_selected", type: "memory_store", name: "notes", archived_at: null } }, + { + status: 200, + body: { data: [{ id: "memstore_first", type: "memory_store", name: "notes" }] }, + }, + ]); + cleanup = restore; + + const result = await makeAdapter().findResource("memory_store", "notes", "memstore_selected"); + + expect(result?.id).toBe("memstore_selected"); + expect(calls).toEqual([{ url: `${BASE}/memory_stores/memstore_selected`, method: "GET" }]); + }); + + test("does not fall back to a same-name memory store when the selected id returns 404", async () => { + const { calls, restore } = mockFetch([ + { status: 404, body: { error: "not found" } }, + { + status: 200, + body: { data: [{ id: "memstore_same_name", type: "memory_store", name: "notes" }] }, + }, + ]); + cleanup = restore; + + const result = await makeAdapter().findResource("memory_store", "notes", "memstore_missing"); + + expect(result).toBeNull(); + expect(calls).toEqual([{ url: `${BASE}/memory_stores/memstore_missing`, method: "GET" }]); + }); + + test("treats an archived memory store id as absent from the managed lifecycle", async () => { + const { calls, restore } = mockFetch([ + { + status: 200, + body: { id: "memstore_archived", type: "memory_store", name: "notes", archived_at: "2026-10-13T00:00:00Z" }, + }, + ]); + cleanup = restore; + + const result = await makeAdapter().findResource("memory_store", "notes", "memstore_archived"); + + expect(result).toBeNull(); + expect(calls).toEqual([{ url: `${BASE}/memory_stores/memstore_archived`, method: "GET" }]); + }); + test("verifies by id via the detail endpoint when id is provided", async () => { const { calls, restore } = mockFetch([{ status: 200, body: ENV_RESPONSE }]); cleanup = restore; @@ -649,6 +719,51 @@ describe("BailianAdapter e2e", () => { // ---- Session CRUD ---- describe("Session CRUD", () => { + test.each([{ FEATURE_FLAG: "on", COMPLEX_VALUE: "alpha=beta;中文", EMPTY_VALUE: "" }, {}] satisfies Record< + string, + string + >[])("createSession sends environment variables as an exact JSON object %j", async (environmentVariables) => { + const response = { ...SESSION_RESPONSE, environment_variables: environmentVariables }; + const { calls, restore } = mockFetch([{ status: 200, body: response }]); + cleanup = restore; + const result = await makeAdapter().createSession({ + agent_id: "agent_01HKDPNVR8YZ7VBHKPK9CYNVTA", + environment_id: "env_xxx", + vault_ids: [], + memory_store_ids: [], + environment_variables: environmentVariables, + }); + expect(calls).toEqual([ + { + url: `${BASE}/sessions`, + method: "POST", + body: { + agent: "agent_01HKDPNVR8YZ7VBHKPK9CYNVTA", + environment_id: "env_xxx", + environment_variables: environmentVariables, + }, + }, + ]); + expect(result.environment_variables).toEqual(environmentVariables); + expect(result.attributes).toEqual(response); + }); + + test("createSession omits environment variables when not supplied", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: SESSION_RESPONSE }]); + cleanup = restore; + const result = await makeAdapter().createSession({ + agent_id: "agent_01HKDPNVR8YZ7VBHKPK9CYNVTA", + environment_id: "env_xxx", + vault_ids: [], + memory_store_ids: [], + }); + expect(calls[0]?.body).toEqual({ + agent: "agent_01HKDPNVR8YZ7VBHKPK9CYNVTA", + environment_id: "env_xxx", + }); + expect(result).not.toHaveProperty("environment_variables"); + }); + test("createSession sends correct body", async () => { const { calls, restore } = mockFetch([{ status: 200, body: SESSION_RESPONSE }]); cleanup = restore; @@ -1440,13 +1555,13 @@ describe("BailianAdapter e2e", () => { }); }); - // ---- Unsupported operations ---- + // ---- Memory operations ---- - describe("Unsupported operations", () => { - test("memory_store methods are omitted (not stubbed)", () => { + describe("Memory operations", () => { + test("exposes native memory_store lifecycle methods", () => { const adapter = makeAdapter() as unknown as Record; - expect(typeof adapter.createMemoryStore).not.toBe("function"); - expect(typeof adapter.deleteMemoryStore).not.toBe("function"); + expect(typeof adapter.createMemoryStore).toBe("function"); + expect(typeof adapter.deleteMemoryStore).toBe("function"); }); }); diff --git a/packages/sdk/tests/unit/bailian-memory-api.test.ts b/packages/sdk/tests/unit/bailian-memory-api.test.ts new file mode 100644 index 0000000..a60a049 --- /dev/null +++ b/packages/sdk/tests/unit/bailian-memory-api.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + ApiError, + archiveMemoryStore, + createMemory, + createMemoryStore, + deleteMemory, + getMemory, + getMemoryStore, + getMemoryVersion, + listMemories, + listMemoryStores, + listMemoryVersions, + redactMemoryVersion, + setDefaultFetch, + updateMemory, + updateMemoryStore, +} from "../../src/index.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import { BAILIAN_CAPABILITIES } from "../../src/internal/providers/bailian/capabilities.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; + +const BASE_URL = "https://workspace.example/api/v1/agentstudio"; +const store = { + id: "memstore_notes", + type: "memory_store", + name: "Project notes", + description: "Shared project memory", + metadata: { team: "data", nested: { enabled: true }, revision: 2 }, + created_at: "2026-10-13T00:00:00Z", + updated_at: "2026-10-13T00:00:00Z", + service_extension: { enabled: true }, +}; +const memory = { + id: "mem_notes", + type: "memory", + memory_store_id: store.id, + path: "/notes/project.md", + content: "", + content_sha256: "old-sha", + content_size_bytes: 0, + memory_version_id: "memver_created", + created_at: "2026-10-13T00:00:00Z", + updated_at: "2026-10-13T00:00:00Z", + service_extension: "preserved", +}; +const version = { + id: "memver_modified", + type: "memory_version", + memory_store_id: store.id, + memory_id: memory.id, + operation: "modified", + path: memory.path, + content: "updated", + content_sha256: "new-sha", + content_size_bytes: 7, + created_at: "2026-10-13T00:30:00Z", +}; + +function adapter() { + return new BailianAdapter("sk-test", "workspace", BASE_URL, "project"); +} + +function providers(value: BailianAdapter): ReadonlyMap { + return new Map([["bailian", value]]); +} + +function responses(values: unknown[]) { + const calls: Request[] = []; + let responseIndex = 0; + setDefaultFetch(async (input, init) => { + calls.push(new Request(input, init)); + return Response.json(values[responseIndex++]); + }); + return calls; +} + +afterEach(() => setDefaultFetch(undefined)); + +describe("Bailian Memory Store official API", () => { + test("creates only the container and preserves metadata objects through the public API", async () => { + const calls = responses([store, store]); + const provider = adapter(); + const created = await createMemoryStore(providers(provider), "bailian", { + name: store.name, + description: store.description, + metadata: store.metadata, + }); + expect(created).toEqual(store); + expect(calls.map((call) => [call.method, new URL(call.url).pathname])).toEqual([ + ["POST", "/api/v1/agentstudio/memory_stores"], + ["GET", `/api/v1/agentstudio/memory_stores/${store.id}`], + ]); + expect(await calls[0]?.json()).toEqual({ + name: store.name, + description: store.description, + metadata: store.metadata, + }); + const seedCalls = responses([store]); + await provider.createMemoryStore(store.name, { + description: store.description, + entries: [{ key: "notes/project.md", content: "seed" }], + }); + expect(seedCalls).toHaveLength(1); + expect(await seedCalls[0]?.json()).not.toHaveProperty("entries"); + }); + + test("keeps native list envelopes, null cursors and unknown Store fields", async () => { + const rawPage = { data: [store], next_page: "opaque/+page", service_extension: { count: 1 } }; + const calls = responses([rawPage, store, { data: [], next_page: null }]); + const registry = providers(adapter()); + expect( + await listMemoryStores(registry, "bailian", { limit: 5, page: "first/+page", include_archived: true }), + ).toEqual(rawPage); + expect(await getMemoryStore(registry, "bailian", store.id)).toEqual(store); + expect(await listMemoryStores(registry, "bailian")).toEqual({ data: [], next_page: null }); + const query = new URL(calls[0]!.url).searchParams; + expect(query.get("page")).toBe("first/+page"); + expect(query.get("include_archived")).toBe("true"); + expect(query.get("limit")).toBe("5"); + }); + + test("sends Store metadata as supplied and archives for IaC destroy without a delete route", async () => { + const archived = { ...store, archived_at: "2026-10-13T01:00:00Z" }; + const calls = responses([store, archived, archived]); + const provider = adapter(); + expect( + await updateMemoryStore(providers(provider), "bailian", store.id, { description: "", metadata: {} }), + ).toEqual(store); + expect(await calls[0]?.json()).toEqual({ description: "", metadata: {} }); + expect(await archiveMemoryStore(providers(provider), "bailian", store.id)).toEqual(archived); + await provider.deleteMemoryStore(store.id); + expect(calls.slice(1).map((call) => [call.method, call.url])).toEqual([ + ["POST", `${BASE_URL}/memory_stores/${store.id}/archive`], + ["POST", `${BASE_URL}/memory_stores/${store.id}/archive`], + ]); + expect(await calls[1]?.text()).toBe(""); + expect(await calls[2]?.text()).toBe(""); + }); +}); + +describe("Bailian Memory file and version contracts", () => { + test("preserves absolute paths, empty bodies and separate directory prefixes", async () => { + const page = { data: [memory], prefixes: ["/notes/archive/"], next_page: "next", service_extension: 1 }; + const calls = responses([memory, page, memory]); + const registry = providers(adapter()); + expect(await createMemory(registry, "bailian", store.id, { path: memory.path, content: "" })).toEqual(memory); + expect(await calls[0]?.json()).toEqual({ path: memory.path, content: "" }); + expect( + await listMemories(registry, "bailian", store.id, { + path_prefix: "/notes/", + depth: 1, + view: "full", + limit: 20, + page: "current", + }), + ).toEqual(page); + const query = new URL(calls[1]!.url).searchParams; + expect(Object.fromEntries(query)).toEqual({ + limit: "20", + page: "current", + path_prefix: "/notes/", + depth: "1", + view: "full", + }); + expect(await getMemory(registry, "bailian", store.id, memory.id)).toEqual(memory); + expect(calls[2]?.url).toBe(`${BASE_URL}/memory_stores/${store.id}/memories/${memory.id}`); + }); + + test("maps SHA conditions and atomic renames without inventing update response content", async () => { + const { content: _content, ...updated } = { ...memory, path: "/notes/renamed.md", memory_version_id: version.id }; + const calls = responses([updated, updated]); + const registry = providers(adapter()); + const result = await updateMemory(registry, "bailian", store.id, memory.id, { + path: "/notes/renamed.md", + content: "", + expected_content_sha256: "old-sha", + }); + expect(result).toEqual(updated); + expect(result).not.toHaveProperty("content"); + expect(await calls[0]?.json()).toEqual({ + path: "/notes/renamed.md", + content: "", + precondition: { type: "content_sha256", content_sha256: "old-sha" }, + }); + expect(new URL(calls[0]!.url).search).toBe(""); + await updateMemory(registry, "bailian", store.id, memory.id, { + content: "next", + precondition: { type: "content_sha256", content_sha256: "native-sha" }, + }); + expect(await calls[1]?.json()).toEqual({ + content: "next", + precondition: { type: "content_sha256", content_sha256: "native-sha" }, + }); + }); + + test("returns the delete receipt without sending an unsupported delete precondition", async () => { + const deleted = { id: memory.id, type: "memory_deleted", service_extension: "kept" }; + const calls = responses([deleted]); + expect( + await deleteMemory(providers(adapter()), "bailian", store.id, memory.id, "ignored-native-delete-sha"), + ).toEqual(deleted); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toBe(`${BASE_URL}/memory_stores/${store.id}/memories/${memory.id}`); + expect(await calls[0]?.text()).toBe(""); + }); + + test("uses native version filters and preserves modified and redacted field absence", async () => { + const page = { data: [version], next_page: "versions-next" }; + const redacted = { + id: version.id, + type: "memory_version", + memory_store_id: store.id, + memory_id: memory.id, + operation: "modified", + path: memory.path, + created_at: version.created_at, + redacted_at: "2026-10-13T01:00:00Z", + }; + const calls = responses([page, version, redacted]); + const registry = providers(adapter()); + expect( + await listMemoryVersions(registry, "bailian", store.id, { + memory_id: memory.id, + operation: "modified", + "created_at.gte": "2026-10-13T00:00:00Z", + "created_at.lte": "2026-10-13T01:00:00Z", + view: "full", + limit: 20, + page: "versions-current", + }), + ).toEqual(page); + expect(Object.fromEntries(new URL(calls[0]!.url).searchParams)).toEqual({ + limit: "20", + page: "versions-current", + memory_id: memory.id, + operation: "modified", + "created_at.gte": "2026-10-13T00:00:00Z", + "created_at.lte": "2026-10-13T01:00:00Z", + view: "full", + }); + expect(await getMemoryVersion(registry, "bailian", store.id, version.id)).toEqual(version); + expect(new URL(calls[1]!.url).search).toBe(""); + expect(await redactMemoryVersion(registry, "bailian", store.id, version.id)).toEqual(redacted); + expect(calls[2]?.url).toBe(`${BASE_URL}/memory_stores/${store.id}/memory_versions/${version.id}/redact`); + expect(await calls[2]?.text()).toBe(""); + }); + + test("retains the exact service error body and does not retry conflicting updates", async () => { + const body = JSON.stringify({ + type: "error", + error: { + code: "memory_precondition_failed_error", + message: "Content was changed by another session.", + }, + request_id: "req-conflict", + }); + const calls: Request[] = []; + setDefaultFetch(async (input, init) => { + calls.push(new Request(input, init)); + return new Response(body, { status: 409 }); + }); + let failure: unknown; + try { + await adapter().updateMemory(store.id, memory.id, { content: "new", expected_content_sha256: "old" }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ApiError); + expect((failure as ApiError).responseBody).toBe(body); + expect((failure as ApiError).statusCode).toBe(409); + expect(calls).toHaveLength(1); + }); +}); + +describe("Bailian Memory lifecycle comparison", () => { + test("compares display attributes and declared metadata only, preserving ID-only identity", async () => { + const provider = adapter(); + const calls = responses([{ ...store, entry_count: 900, entries: [{ content: "runtime" }] }]); + const declaration = { name: store.name, description: store.description, metadata: { team: "data" } }; + expect(provider.getDriftSupport("memory_store")).toBe("full"); + expect(BAILIAN_CAPABILITIES.memory_store.tier).toBe("native"); + expect(await provider.readComparableResource("memory_store", null, store.name, declaration)).toBeNull(); + expect(calls).toHaveLength(0); + const remote = await provider.readComparableResource("memory_store", store.id, "logical-key", declaration); + const desired = provider.normalizeDesiredResource("memory_store", "logical-key", declaration); + expect(remote?.comparable).toEqual(desired); + expect(remote?.comparable).toEqual({ + name: store.name, + description: store.description, + metadata: { team: "data" }, + }); + }); + + test("treats archived stores as absent for drift while direct API reads remain available", async () => { + const archived = { ...store, archived_at: "2026-10-13T01:00:00Z" }; + responses([archived, archived]); + const provider = adapter(); + expect(await provider.readComparableResource("memory_store", store.id, store.name)).toBeNull(); + expect(await provider.getMemoryStore(store.id)).toEqual(archived); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-memory-iac.test.ts b/packages/sdk/tests/unit/bailian-memory-iac.test.ts new file mode 100644 index 0000000..cb3fc44 --- /dev/null +++ b/packages/sdk/tests/unit/bailian-memory-iac.test.ts @@ -0,0 +1,366 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ApiError, setDefaultFetch } from "../../src/index.ts"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { importResource, planProjectContext } from "../../src/internal/core/resource-runtime.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 { refreshState } from "../../src/internal/planner/refresh.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { MemoryStoreDecl, ResolvedProjectConfig } from "../../src/internal/types/config.ts"; +import { sha256 } from "../../src/internal/utils/hash.ts"; + +const directories: string[] = []; +const address = { provider: "bailian", type: "memory_store" as const, name: "notes" }; +const originalEntries = [ + { key: "notes/first.md", content: "Original initializer one" }, + { key: "/notes/second.md", content: "Original initializer two" }, +]; + +afterEach(async () => { + setDefaultFetch(undefined); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function fixture( + declaration: MemoryStoreDecl = { name: "Shared notes", description: "Notes", entries: originalEntries }, +) { + const directory = await mkdtemp(join(tmpdir(), "bailian-memory-iac-")); + directories.push(directory); + const statePath = join(directory, "state.json"); + const state = StateManager.initialize(statePath); + const calls: Array<{ + method: string; + path: string; + body?: Record; + limit: string | null; + page: string | null; + }> = []; + const memories = new Map(); + let storeCount = 0; + const remoteStore = { + id: "memstore_1", + type: "memory_store", + name: "Shared notes", + description: "Notes", + metadata: {} as Record, + archived_at: null as string | null, + created_at: "2026-10-13T00:00:00Z", + updated_at: "2026-10-13T00:00:00Z", + }; + const control = { + failPath: undefined as string | undefined, + failArchive: false, + imported: false, + missingStoreIds: new Set(), + deleteMemoryOnUpdate: false, + memoryDeleted: false, + storeReadStatusAfterMemoryDelete: undefined as number | undefined, + }; + setDefaultFetch(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const path = url.pathname.replace("/api/v1/agentstudio", ""); + const rawBody = await request.text(); + const body = rawBody ? (JSON.parse(rawBody) as Record) : undefined; + calls.push({ + method: request.method, + path, + body, + limit: url.searchParams.get("limit"), + page: url.searchParams.get("page"), + }); + if (path === "/memory_stores" && request.method === "POST") { + storeCount++; + Object.assign(remoteStore, body, { id: `memstore_${storeCount}`, archived_at: null }); + return Response.json(remoteStore); + } + if (path.endsWith("/archive")) { + if (control.failArchive) return Response.json({ error: { message: "archive unavailable" } }, { status: 503 }); + remoteStore.archived_at = "2026-10-13T01:00:00Z"; + return Response.json(remoteStore); + } + if (path.match(/^\/memory_stores\/[^/]+$/)) { + if ((!storeCount && !control.imported) || control.missingStoreIds.has(path.split("/").at(-1)!)) { + return Response.json({ error: { message: "missing" } }, { status: 404 }); + } + if (request.method === "GET" && control.memoryDeleted && control.storeReadStatusAfterMemoryDelete) { + return Response.json( + { error: { message: "store lookup unavailable" } }, + { status: control.storeReadStatusAfterMemoryDelete }, + ); + } + if (request.method === "POST") Object.assign(remoteStore, body); + return Response.json(remoteStore); + } + if (path.endsWith("/memories") && request.method === "POST") { + const memoryPath = body!.path as string; + if (control.failPath === memoryPath) + return Response.json({ error: { message: "seed unavailable" } }, { status: 503 }); + memories.set(memoryPath, { id: `mem_${memories.size + 1}`, content: body!.content as string }); + return Response.json({ id: memories.get(memoryPath)!.id, type: "memory", path: memoryPath }); + } + if (path.endsWith("/memories")) { + const limit = Number(url.searchParams.get("limit")); + if (!Number.isInteger(limit) || limit < 1 || limit > 20) { + return Response.json({ message: "limit must be between 1 and 20" }, { status: 400 }); + } + const all = [...memories.entries()].map(([memoryPath, memory]) => ({ + id: memory.id, + type: "memory", + path: memoryPath, + content_sha256: sha256(memory.content), + })); + const secondPage = url.searchParams.get("page") === "second-page"; + return Response.json({ + data: secondPage ? all.slice(1) : all.slice(0, 1), + next_page: !secondPage && all.length > 1 ? "second-page" : null, + }); + } + const memoryId = path.split("/").at(-1); + for (const [memoryPath, memory] of memories) { + if (memory.id === memoryId && body) { + if (control.deleteMemoryOnUpdate) { + memories.delete(memoryPath); + control.memoryDeleted = true; + return Response.json({ error: { message: "memory disappeared before update" } }, { status: 404 }); + } + memory.content = body.content as string; + return Response.json({ + id: memoryId, + type: "memory", + path: memoryPath, + content_sha256: sha256(memory.content), + }); + } + } + throw new Error(`Unexpected request ${request.method} ${path}`); + }); + const provider = new BailianAdapter( + "test-key", + "workspace", + "https://workspace.example/api/v1/agentstudio", + "project", + ); + const config: ResolvedProjectConfig = { + version: "1", + _resolved: true, + providers: { bailian: { api_key: "test-key", workspace_id: "workspace" } }, + defaults: { provider: "bailian" }, + memory_stores: { notes: structuredClone(declaration) }, + }; + const runtime: ProjectRuntimeContext = { + configPath: join(directory, "agents.yaml"), + projectName: "project", + config, + state, + providers: new Map([["bailian", provider]]), + }; + async function apply() { + const plan = await buildPlan(config, runtime.state.getStateFile()); + return executePlan(plan, runtime); + } + return { runtime, apply, calls, memories, control, remoteStore, statePath, provider, createdCount: () => storeCount }; +} + +describe("Bailian Memory Store IaC", () => { + test("saves the new ID before a seed request and resumes missing paths without overwriting Agent content", async () => { + const current = await fixture(); + current.control.failPath = "/notes/second.md"; + const first = await current.apply(); + expect(first.partial).toBe(true); + expect(first.results[0]?.error?.message).toContain("seed unavailable"); + const checkpoint = await StateManager.load(current.statePath); + expect(checkpoint.getResource(address)?.remote_id).toBe("memstore_1"); + expect(checkpoint.getResource(address)?.memory_initialization?.pending_entries).toEqual([ + { path: "/notes/second.md", content_sha256: sha256(originalEntries[1]!.content) }, + ]); + expect(await readFile(current.statePath, "utf8")).not.toContain(originalEntries[0]!.content); + current.memories.get("/notes/first.md")!.content = "Agent's newer content"; + current.memories.set("/notes/second.md", { id: "mem_agent", content: "Agent wrote the pending path" }); + current.control.failPath = undefined; + current.runtime.state = checkpoint; + const scoped = await planProjectContext(current.runtime, { + refresh: false, + mode: "create-only", + scope: { roots: [address] }, + }); + expect(scoped.plan.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + expect(scoped.plan.actions[0]?.action).toBe("update"); + expect((await current.apply()).partial).toBe(false); + expect(current.createdCount()).toBe(1); + expect(current.memories.get("/notes/first.md")?.content).toBe("Agent's newer content"); + expect(current.memories.get("/notes/second.md")?.content).toBe("Agent wrote the pending path"); + expect(current.runtime.state.getResource(address)?.memory_initialization).toBeUndefined(); + const memoryReads = current.calls.filter((call) => call.method === "GET" && call.path.endsWith("/memories")); + expect(memoryReads.every((call) => call.limit === "20")).toBe(true); + expect(memoryReads.some((call) => call.page === "second-page")).toBe(true); + current.memories.delete("/notes/first.md"); + current.runtime.config.memory_stores!.notes!.entries!.push({ key: "new.md", content: "Later initializer" }); + await current.apply(); + expect(current.memories.has("/notes/first.md")).toBe(false); + expect(current.memories.has("/new.md")).toBe(false); + }); + + test("refuses changed pending initializer content without creating another store", async () => { + const current = await fixture({ description: "Notes", entries: originalEntries.map((entry) => ({ ...entry })) }); + current.control.failPath = "/notes/second.md"; + await current.apply(); + current.runtime.config.memory_stores!.notes!.entries![1]!.content = "Changed pending body"; + current.control.failPath = undefined; + const result = await current.apply(); + expect(result.partial).toBe(true); + expect(result.results[0]?.error?.message).toContain("Restore its original content"); + expect(current.createdCount()).toBe(1); + expect(current.memories.has("/notes/second.md")).toBe(false); + }); + + test("updates properties while preserving undeclared metadata and runtime files", async () => { + const current = await fixture({ description: "Notes", metadata: { owned: "old" }, entries: [] }); + await current.apply(); + current.remoteStore.metadata.external = { retained: true }; + current.memories.set("/runtime.md", { id: "mem_runtime", content: "Learned" }); + current.runtime.config.memory_stores!.notes = { + name: "Renamed display", + description: "New description", + metadata: { owned: "new" }, + entries: [{ key: "runtime.md", content: "Old seed" }], + }; + const result = await current.apply(); + expect(result.partial).toBe(false); + expect(current.remoteStore.name).toBe("Renamed display"); + expect(current.remoteStore.metadata).toEqual({ owned: "new", external: { retained: true } }); + expect(current.memories.get("/runtime.md")?.content).toBe("Learned"); + }); + + test("explicit sync uses absolute paths and SHA preconditions", async () => { + const current = await fixture({ description: "Notes", entries: [{ key: "note.md", content: "Original" }] }); + await current.apply(); + current.memories.get("/note.md")!.content = "Agent changed"; + current.runtime.config.memory_stores!.notes = { + description: "Notes", + entries_mode: "sync", + entries: [{ key: "note.md", content: "Managed" }], + }; + expect((await current.apply()).partial).toBe(false); + expect(current.memories.get("/note.md")?.content).toBe("Managed"); + expect(current.calls.find((call) => call.path.endsWith("/memories/mem_1"))?.body).toEqual({ + content: "Managed", + precondition: { type: "content_sha256", content_sha256: sha256("Agent changed") }, + }); + }); + + test.each([ + undefined, + 503, + ])("preserves the Store ID after a concurrent file deletion with Store confirmation status %p", async (storeReadStatus) => { + const current = await fixture({ description: "Notes", entries: [{ key: "note.md", content: "Original" }] }); + await current.apply(); + const previousState = structuredClone(current.runtime.state.getResource(address)); + current.runtime.config.memory_stores!.notes = { + description: "Notes", + entries_mode: "sync", + entries: [{ key: "note.md", content: "Managed" }], + }; + current.control.deleteMemoryOnUpdate = true; + current.control.storeReadStatusAfterMemoryDelete = storeReadStatus; + + const result = await current.apply(); + + expect(result.partial).toBe(true); + expect(result.results[0]?.error).toBeInstanceOf(ApiError); + expect(result.results[0]?.error).toMatchObject({ statusCode: storeReadStatus ?? 404 }); + expect(result.results[0]?.error?.message).toContain( + storeReadStatus ? "store lookup unavailable" : "memory disappeared before update", + ); + expect(current.control.memoryDeleted).toBe(true); + expect(current.createdCount()).toBe(1); + expect(current.runtime.state.getResource(address)).toEqual(previousState); + expect((await StateManager.load(current.statePath)).getResource(address)).toEqual(previousState); + expect(current.calls.at(-1)).toMatchObject({ method: "GET", path: "/memory_stores/memstore_1" }); + }); + + test("recreates a Store only after its own lookup confirms the missing ID", async () => { + const current = await fixture({ description: "Notes" }); + await current.apply(); + current.control.missingStoreIds.add("memstore_1"); + current.runtime.config.memory_stores!.notes!.description = "Changed"; + const callOffset = current.calls.length; + + const result = await current.apply(); + + expect(result.partial).toBe(false); + expect(current.createdCount()).toBe(2); + expect(current.runtime.state.getResource(address)?.remote_id).toBe("memstore_2"); + expect((await StateManager.load(current.statePath)).getResource(address)?.remote_id).toBe("memstore_2"); + expect(current.calls.slice(callOffset, callOffset + 3).map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "GET", path: "/memory_stores/memstore_1" }, + { method: "GET", path: "/memory_stores/memstore_1" }, + { method: "POST", path: "/memory_stores" }, + ]); + }); + + test("imports by ID without seeds and plans only property differences", async () => { + const current = await fixture({ name: "Desired name", description: "Desired", entries: originalEntries }); + current.control.imported = true; + const imported = await importResource(current.runtime, address, "memstore_1"); + expect(imported.memory_initialization).toBeUndefined(); + expect(imported.drift_status).toBe("drifted"); + expect(current.calls.every((call) => call.method === "GET")).toBe(true); + const plan = await buildPlan(current.runtime.config, current.runtime.state.getStateFile()); + expect(plan.actions[0]?.action).toBe("update"); + await current.apply(); + expect(current.memories.size).toBe(0); + expect(current.createdCount()).toBe(0); + }); + + test("rejects missing, archived and duplicate-ID imports without writing the target state", async () => { + const current = await fixture({ description: "Notes" }); + await expect(importResource(current.runtime, address, "missing")).rejects.toThrow("404"); + current.control.imported = true; + current.remoteStore.archived_at = "2026-10-13T00:00:00Z"; + await expect(importResource(current.runtime, address, "memstore_1")).rejects.toThrow("archived"); + current.remoteStore.archived_at = null; + current.runtime.state.setResource({ + address: { ...address, name: "other" }, + remote_id: "memstore_1", + content_hash: "old", + }); + await expect(importResource(current.runtime, address, "memstore_1")).rejects.toThrow("already bound"); + expect(current.runtime.state.getResource(address)).toBeUndefined(); + }); + + test("archives on delete, retaining State when archive fails", async () => { + const current = await fixture({ description: "Notes" }); + await current.apply(); + delete current.runtime.config.memory_stores!.notes; + const plan = await buildPlan(current.runtime.config, current.runtime.state.getStateFile()); + expect(plan.actions[0]?.reason).toContain("Archive Memory Store"); + current.control.failArchive = true; + expect((await executePlan(plan, current.runtime)).partial).toBe(true); + expect(current.runtime.state.getResource(address)?.remote_id).toBe("memstore_1"); + current.control.failArchive = false; + expect((await executePlan(plan, current.runtime)).partial).toBe(false); + expect(current.runtime.state.getResource(address)).toBeUndefined(); + expect(current.remoteStore.archived_at).not.toBeNull(); + }); + + test("refresh ignores runtime content and undeclared metadata changes", async () => { + const current = await fixture({ description: "Notes", metadata: { owned: "same" } }); + await current.apply(); + current.remoteStore.metadata.extra = "Unmanaged"; + current.memories.set("/runtime.md", { id: "mem_runtime", content: "Learned" }); + await refreshState(current.runtime.state, current.runtime.providers, { + config: current.runtime.config, + quiet: true, + }); + const plan = await buildPlan(current.runtime.config, current.runtime.state.getStateFile()); + expect(plan.actions[0]?.action).toBe("no-op"); + const hash = await computeResourceHash(address, current.runtime.config); + expect(current.runtime.state.getResource(address)?.desired_hash).toBe(hash); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-memory-sync-drift.test.ts b/packages/sdk/tests/unit/bailian-memory-sync-drift.test.ts new file mode 100644 index 0000000..83e148a --- /dev/null +++ b/packages/sdk/tests/unit/bailian-memory-sync-drift.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { setDefaultFetch } from "../../src/index.ts"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { importResource } from "../../src/internal/core/resource-runtime.ts"; +import { executePlan } from "../../src/internal/executor/executor.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import { refreshState } from "../../src/internal/planner/refresh.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import { InMemoryStateManager } from "../../src/internal/state/in-memory-state-manager.ts"; +import type { MemoryStoreDecl, ResolvedProjectConfig } from "../../src/internal/types/config.ts"; +import { sha256 } from "../../src/internal/utils/hash.ts"; + +const address = { provider: "bailian", type: "memory_store" as const, name: "notes" }; +const entries = [ + { key: "notes/first.md", content: "Managed first body, never persist in State" }, + { key: "/notes/second.md", content: "Managed second body, never persist in State" }, +]; + +afterEach(() => setDefaultFetch(undefined)); + +function fixture(entriesMode: MemoryStoreDecl["entries_mode"] = "sync") { + const calls: Array<{ method: string; path: string; page: string | null; view: string | null; limit: string | null }> = + []; + const memories = new Map(); + const control = { repeatCursor: false }; + const remoteStore = { + id: "memstore_notes", + type: "memory_store", + name: "notes", + description: "Notes", + metadata: {}, + archived_at: null, + }; + let memorySequence = 0; + setDefaultFetch(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const path = url.pathname.replace("/api/v1/agentstudio", ""); + const rawBody = await request.text(); + const body = rawBody ? (JSON.parse(rawBody) as Record) : undefined; + calls.push({ + method: request.method, + path, + page: url.searchParams.get("page"), + view: url.searchParams.get("view"), + limit: url.searchParams.get("limit"), + }); + if (path === "/memory_stores" && request.method === "POST") return Response.json(remoteStore); + if (path === "/memory_stores/memstore_notes") { + if (request.method === "POST") Object.assign(remoteStore, body); + return Response.json(remoteStore); + } + if (path === "/memory_stores/memstore_notes/memories") { + if (request.method === "POST") { + const memoryPath = body!.path as string; + memories.set(memoryPath, { id: `mem_${++memorySequence}`, content: body!.content as string }); + return Response.json({ id: memories.get(memoryPath)!.id, type: "memory", path: memoryPath }); + } + const limit = Number(url.searchParams.get("limit")); + if (!Number.isInteger(limit) || limit < 1 || limit > 20) { + return Response.json({ message: "limit must be between 1 and 20" }, { status: 400 }); + } + const all = [...memories.entries()].map(([memoryPath, memory]) => ({ + id: memory.id, + type: "memory", + path: memoryPath, + content_sha256: sha256(memory.content), + // Even an unexpectedly verbose basic view must not leak bodies into State. + content: memory.content, + })); + const offset = Number(url.searchParams.get("page")?.replace("opaque page +/", "") ?? 0); + return Response.json({ + data: all.slice(offset, offset + 1), + next_page: control.repeatCursor + ? "opaque page +/1" + : offset + 1 < all.length + ? `opaque page +/${offset + 1}` + : null, + }); + } + const memoryId = path.split("/").at(-1); + for (const [memoryPath, memory] of memories) { + if (request.method === "POST" && memory.id === memoryId && body) { + memory.content = body.content as string; + return Response.json({ + id: memory.id, + type: "memory", + path: memoryPath, + content_sha256: sha256(memory.content), + }); + } + } + throw new Error(`Unexpected mocked request: ${request.method} ${path}`); + }); + const provider = new BailianAdapter("test-key", "workspace", "https://workspace.example/api/v1/agentstudio"); + const config: ResolvedProjectConfig = { + version: "1", + _resolved: true, + providers: { bailian: { api_key: "test-key", workspace_id: "workspace" } }, + defaults: { provider: "bailian" }, + memory_stores: { notes: { description: "Notes", entries_mode: entriesMode, entries: structuredClone(entries) } }, + }; + const runtime: ProjectRuntimeContext = { + config, + configPath: "/tmp/memory-sync-drift-agents.yaml", + projectName: "memory-sync-drift", + providers: new Map([["bailian", provider]]), + state: InMemoryStateManager.empty(), + }; + const plan = () => buildPlan(config, runtime.state.getStateFile()); + async function apply() { + return executePlan(await plan(), runtime); + } + async function refresh() { + const result = await refreshState(runtime.state, runtime.providers, { config, quiet: true }); + expect(result.errors).toEqual([]); + return plan(); + } + return { runtime, provider, calls, memories, control, plan, apply, refresh }; +} + +describe("Bailian explicit Memory sync drift", () => { + test.each([ + "modified", + "deleted", + ])("restores a declared path remotely %s and follows native pagination", async (change) => { + const current = fixture(); + expect((await current.apply()).partial).toBe(false); + if (change === "modified") current.memories.get("/notes/second.md")!.content = "Remote edit"; + else current.memories.delete("/notes/second.md"); + current.calls.length = 0; + const plan = await current.refresh(); + expect(plan.actions[0]?.action).toBe("update"); + expect(plan.actions[0]?.driftKind).toBe("remote"); + expect((await current.apply()).partial).toBe(false); + expect(current.memories.get("/notes/second.md")?.content).toBe(entries[1]!.content); + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + const memoryReads = current.calls.filter((call) => call.method === "GET" && call.path.endsWith("/memories")); + expect(memoryReads.every((call) => call.view === "basic")).toBe(true); + expect(memoryReads.every((call) => call.limit === "20")).toBe(true); + expect(memoryReads.some((call) => call.page === "opaque page +/1")).toBe(true); + const serialized = JSON.stringify(current.runtime.state.getStateFile()); + for (const entry of entries) expect(serialized).not.toContain(entry.content); + expect(serialized).not.toContain("Remote edit"); + expect(current.runtime.state.getResource(address)?.remote_snapshot).toMatchObject({ + entry_hashes: { + "/notes/first.md": sha256(entries[0]!.content), + "/notes/second.md": sha256(entries[1]!.content), + }, + }); + }); + + test("imports matching store attributes, then syncs missing declared paths on apply", async () => { + const current = fixture(); + const imported = await importResource(current.runtime, address, "memstore_notes"); + expect(imported.drift_status).toBe("drifted"); + expect(imported.remote_snapshot).toMatchObject({ + entry_hashes: { "/notes/first.md": null, "/notes/second.md": null }, + }); + expect(current.calls.every((call) => call.method === "GET")).toBe(true); + expect(current.memories.size).toBe(0); + expect((await current.refresh()).actions[0]?.action).toBe("update"); + expect((await current.apply()).partial).toBe(false); + expect(current.memories.get("/notes/first.md")?.content).toBe(entries[0]!.content); + expect(current.memories.get("/notes/second.md")?.content).toBe(entries[1]!.content); + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + }); + + test("ignores creation, changes and deletion of undeclared paths", async () => { + const current = fixture(); + await current.apply(); + current.memories.set("/runtime.md", { id: "mem_runtime", content: "Runtime-only body" }); + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + current.memories.get("/runtime.md")!.content = "Changed runtime body"; + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + expect(JSON.stringify(current.runtime.state.getStateFile())).not.toContain("runtime.md"); + current.memories.delete("/runtime.md"); + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + }); + + test("does not list bodies when refreshing or importing in initialize mode", async () => { + const current = fixture("initialize"); + await current.apply(); + current.calls.length = 0; + current.memories.get("/notes/second.md")!.content = "Runtime edited initializer"; + expect((await current.refresh()).actions[0]?.action).toBe("no-op"); + current.runtime.state.removeResource(address); + const imported = await importResource(current.runtime, address, "memstore_notes"); + expect(imported.drift_status).toBe("in_sync"); + expect(current.calls.some((call) => call.path.endsWith("/memories"))).toBe(false); + expect(imported.remote_snapshot).not.toHaveProperty("entry_hashes"); + }); + + test("rejects repeated native page cursors before importing state", async () => { + const current = fixture(); + current.control.repeatCursor = true; + await expect(importResource(current.runtime, address, "memstore_notes")).rejects.toThrow("repeated cursor"); + expect(current.runtime.state.getResource(address)).toBeUndefined(); + expect(current.calls.filter((call) => call.path.endsWith("/memories"))).toHaveLength(2); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-session-memory.test.ts b/packages/sdk/tests/unit/bailian-session-memory.test.ts new file mode 100644 index 0000000..8925541 --- /dev/null +++ b/packages/sdk/tests/unit/bailian-session-memory.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test"; +import { collectConfigReferences, validateProjectConfig } from "../../src/internal/core/validate-config.ts"; +import { buildDependencyGraph } from "../../src/internal/graph/dependency.ts"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; +import { toSessionInfo } from "../../src/internal/providers/bailian/adapter.ts"; +import { mapSession } from "../../src/internal/providers/bailian/mapper.ts"; +import { buildSessionBindings, parseSessionResources } from "../../src/internal/session/session-manager.ts"; +import { InMemoryStateManager } from "../../src/internal/state/in-memory-state-manager.ts"; +import type { ProjectConfig, SessionResourceDecl } from "../../src/internal/types/config.ts"; +import type { ManagedSessionBindings } from "../../src/internal/types/session.ts"; +import "../../src/internal/providers/all.ts"; + +function fixture() { + const config: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "workspace" } }, + defaults: { provider: "bailian" }, + environments: { dev: { config: { type: "cloud" } } }, + memory_stores: { + notes: { name: "Shared notes", description: "Notes", metadata: { nested: { enabled: true }, count: 2 } }, + facts: { description: "Facts" }, + }, + agents: { + assistant: { model: "qwen-plus", instructions: "Help", environment: "dev", memory_stores: ["notes"] }, + }, + }; + const state = InMemoryStateManager.empty(); + for (const resource of [ + { type: "agent" as const, name: "assistant", remote_id: "agent_assistant" }, + { type: "environment" as const, name: "dev", remote_id: "env_dev" }, + { type: "memory_store" as const, name: "notes", remote_id: "memstore_notes" }, + { type: "memory_store" as const, name: "facts", remote_id: "memstore_facts" }, + ]) { + state.setResource({ + address: { type: resource.type, name: resource.name, provider: "bailian" }, + remote_id: resource.remote_id, + content_hash: "hash", + }); + } + return { config, state }; +} + +describe("Bailian Session Memory resources", () => { + test("resolves shorthand names through State and sends resources instead of top-level ids", () => { + const { config, state } = fixture(); + const bindings = buildSessionBindings("assistant", config, "bailian", state) as ManagedSessionBindings; + const body = mapSession(bindings) as Record; + + expect(bindings.memory_store_ids).toEqual(["memstore_notes"]); + expect(body.resources).toEqual([{ type: "memory_store", memory_store_id: "memstore_notes", access: "read_write" }]); + expect(body.memory_store_ids).toBeUndefined(); + }); + + test("explicit resources override shorthand defaults and deduplicate the same store", () => { + const { config, state } = fixture(); + config.agents!.assistant!.resources = [ + { type: "memory_store", memory_store: "notes", access: "read_only", instructions: "Use past decisions" }, + ]; + const bindings = buildSessionBindings("assistant", config, "bailian", state) as ManagedSessionBindings; + + expect(bindings.resources).toEqual([ + { + type: "memory_store", + memory_store_id: "memstore_notes", + access: "read_only", + instructions: "Use past decisions", + }, + ]); + expect(bindings.memory_store_ids).toEqual(["memstore_notes"]); + }); + + test("CLI resource overrides replace Agent resources while preserving the selected shorthand", () => { + const { config, state } = fixture(); + config.agents!.assistant!.resources = [{ type: "memory_store", memory_store: "notes", access: "read_only" }]; + const bindings = buildSessionBindings("assistant", config, "bailian", state, { + memoryStores: ["facts"], + resources: [{ type: "memory_store", memory_store: "facts", instructions: "Use these facts" }], + }) as ManagedSessionBindings; + + expect(bindings.resources).toEqual([ + { + type: "memory_store", + memory_store_id: "memstore_facts", + access: "read_write", + instructions: "Use these facts", + }, + ]); + expect(bindings.memory_store_ids).toEqual(["memstore_facts"]); + }); + + test("rejects duplicate explicit mounts even when two logical keys point to one id", () => { + const { config, state } = fixture(); + state.setResource({ + address: { type: "memory_store", name: "facts", provider: "bailian" }, + remote_id: "memstore_notes", + content_hash: "hash", + }); + expect(() => + buildSessionBindings("assistant", config, "bailian", state, { + resources: [ + { type: "memory_store", memory_store: "notes" }, + { type: "memory_store", memory_store: "facts" }, + ], + }), + ).toThrow(/more than once/); + }); + + test("rejects an unknown or unapplied logical Memory Store", () => { + const { config, state } = fixture(); + expect(() => + buildSessionBindings("assistant", config, "bailian", state, { + resources: [{ type: "memory_store", memory_store: "unknown" }], + }), + ).toThrow(/not defined in config/); + state.removeResource({ type: "memory_store", name: "facts", provider: "bailian" }); + expect(() => + buildSessionBindings("assistant", config, "bailian", state, { + resources: [{ type: "memory_store", memory_store: "facts" }], + }), + ).toThrow(/not found in state/); + }); + + test.each(["shorthand", "explicit"])("blocks %s mounting until pending initialization completes", (mountKind) => { + const { config, state } = fixture(); + const address = { type: "memory_store" as const, name: "notes", provider: "bailian" }; + state.setResource({ + ...state.getResource(address)!, + memory_initialization: { pending_entries: [{ path: "/seed.md", content_sha256: "sha" }] }, + }); + const resources: SessionResourceDecl[] | undefined = + mountKind === "explicit" ? [{ type: "memory_store", memory_store: "notes" }] : undefined; + expect(() => buildSessionBindings("assistant", config, "bailian", state, { resources })).toThrow( + /initialization must complete/, + ); + }); + + test("limits the merged mounts to eight distinct stores", () => { + const { config, state } = fixture(); + const names = Array.from({ length: 9 }, (_, storeIndex) => `store-${storeIndex}`); + for (const name of names) { + config.memory_stores![name] = { description: name }; + state.setResource({ + address: { type: "memory_store", name, provider: "bailian" }, + remote_id: `remote-${name}`, + content_hash: "hash", + }); + } + expect(() => buildSessionBindings("assistant", config, "bailian", state, { memoryStores: names })).toThrow( + /at most 8/, + ); + }); + + test("retains response resources and server mount paths while extracting Memory Store ids", () => { + const resources = [ + { + type: "memory_store", + memory_store_id: "memstore_notes", + access: "read_only", + instructions: "Read context", + mount_path: "/mnt/memory/notes", + }, + { type: "file", file_id: "file_context", mount_path: "/mnt/context.md" }, + ]; + const session = toSessionInfo({ + id: "session", + status: "idle", + agent: { id: "agent_assistant" }, + environment_id: "env_dev", + resources, + }); + expect(session.memory_store_ids).toEqual(["memstore_notes"]); + expect(session.resources).toEqual(resources); + }); +}); + +describe("Session Memory declaration validation", () => { + test("preserves Store display name, JSON metadata and Session Memory settings", () => { + const { config } = fixture(); + config.agents!.assistant!.resources = [ + { type: "memory_store", memory_store: "notes", access: "read_only", instructions: "Instructions" }, + ]; + const parsed = projectConfigSchema.parse(config); + expect(parsed.memory_stores?.notes).toEqual(config.memory_stores!.notes); + expect(parsed.agents?.assistant?.resources).toEqual(config.agents!.assistant!.resources); + }); + + test.each([ + { invalidField: "type", resources: [{ type: "unknown" }] }, + { invalidField: "logical key", resources: [{ type: "memory_store", memory_store: "" }] }, + { invalidField: "access", resources: [{ type: "memory_store", memory_store: "notes", access: "write_only" }] }, + { + invalidField: "instructions", + resources: [{ type: "memory_store", memory_store: "notes", instructions: "x".repeat(4097) }], + }, + { + invalidField: "mount_path", + resources: [{ type: "memory_store", memory_store: "notes", mount_path: "/mnt/custom" }], + }, + ])("rejects invalid Session resource $invalidField", ({ resources }) => { + expect(() => parseSessionResources(resources)).toThrow(/Invalid Session resources/); + }); + + test("reports explicit unknown and duplicate references without conflating Store display names", () => { + const { config } = fixture(); + config.agents!.assistant!.resources = [ + { type: "memory_store", memory_store: "Shared notes" }, + { type: "memory_store", memory_store: "notes" }, + { type: "memory_store", memory_store: "notes" }, + ]; + const diagnostics = collectConfigReferences(config); + expect(diagnostics.filter((diagnostic) => diagnostic.code === "config.agent.memory_store.unknown")).toHaveLength(1); + expect(diagnostics.filter((diagnostic) => diagnostic.code === "config.agent.memory_store.duplicate")).toHaveLength( + 1, + ); + }); + + test("adds explicit Memory references to the Agent dependency graph", () => { + const { config } = fixture(); + config.agents!.assistant!.memory_stores = []; + config.agents!.assistant!.resources = [{ type: "memory_store", memory_store: "facts" }]; + const graph = buildDependencyGraph(config, ["bailian"]); + expect(graph.edges.get("bailian.agent.assistant")?.has("bailian.memory_store.facts")).toBe(true); + }); + + test.each([ + "legacy", + "explicit", + "inherited-legacy", + "inherited-resource", + ])("rejects effective Deployment Memory mounting via %s", (mountKind) => { + const { config } = fixture(); + config.deployments = { + scheduled: { agent: "assistant", initial_events: [{ type: "user.message", content: "go" }] }, + }; + if (mountKind === "legacy") config.deployments.scheduled!.memory_stores = ["facts"]; + if (mountKind === "explicit") + config.deployments.scheduled!.resources = [{ type: "memory_store", memory_store: "facts" }]; + if (mountKind === "inherited-resource") { + config.agents!.assistant!.memory_stores = []; + config.agents!.assistant!.resources = [{ type: "memory_store", memory_store: "notes" }]; + } + const diagnostics = validateProjectConfig(config); + expect( + diagnostics.some( + (diagnostic) => + diagnostic.code === "bailian.deployment.memory_store.unsupported" && diagnostic.severity === "error", + ), + ).toBe(true); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-webhook.test.ts b/packages/sdk/tests/unit/bailian-webhook.test.ts new file mode 100644 index 0000000..f33902e --- /dev/null +++ b/packages/sdk/tests/unit/bailian-webhook.test.ts @@ -0,0 +1,368 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + ApiError, + createManagedApiContext, + createRemoteWebhookEndpoint, + deleteRemoteWebhookEndpoint, + disableRemoteWebhookEndpoint, + enableRemoteWebhookEndpoint, + getManagedAgentProviderCapabilities, + getRemoteWebhookEndpoint, + listRemoteWebhookEndpoints, + listRemoteWebhookEvents, + type ManagedApiContext, + resetRemoteWebhookSecret, + resolveBailianApiBaseUrl, + setDefaultFetch, + testRemoteWebhookEndpoint, + updateRemoteWebhookEndpoint, + type WebhookEndpoint, + type WebhookRequestOptions, +} from "../../src/index.ts"; +import { BailianClient } from "../../src/internal/providers/bailian/client.ts"; + +const BASE_URL = "https://workspace.example/api/v1/agentstudio"; +const WEBHOOK_ID = "wep_test"; + +const endpoint: WebhookEndpoint = { + id: WEBHOOK_ID, + description: null, + url: "https://callback.example/webhook", + events: ["session.updated"], + status: "ACTIVE", + disabled_reason: null, + consecutive_fail: 0, + last_success_at: null, + last_failure_at: null, + created_at: "2026-08-06T10:00:00Z", + updated_at: "2026-08-06T10:00:00Z", +}; + +const testEvent = { + type: "event", + id: "whe_test", + created_at: "2026-08-06T10:00:00.001Z", + data: { id: WEBHOOK_ID, type: "webhook.test", workspace_id: "workspace" }, + request_id: "request-test", +}; + +function context(): ManagedApiContext { + return createManagedApiContext({ + providers: { bailian: { api_key: "sk-test", base_url: BASE_URL } }, + }); +} + +function installResponse(response: unknown, status = 200) { + const calls: Array<{ url: string; init?: RequestInit }> = []; + setDefaultFetch(async (input, init) => { + calls.push({ url: String(input), init }); + return new Response(JSON.stringify(response), { status, headers: { "Content-Type": "application/json" } }); + }); + return calls; +} + +afterEach(() => { + setDefaultFetch(undefined); +}); + +describe("workspace API context and endpoint resolution", () => { + test("creates only provider adapters without making a request", () => { + const calls = installResponse({}); + const runtime = context(); + expect(Object.keys(runtime)).toEqual(["providers"]); + expect([...runtime.providers.keys()]).toEqual(["bailian"]); + expect(calls).toHaveLength(0); + }); + + test("validates supplied config without consulting ambient credentials", () => { + expect(() => createManagedApiContext({ providers: { bailian: { base_url: BASE_URL } } })).toThrow("api_key"); + }); + + test("rejects missing, ambiguous, and unsupported providers before HTTP", async () => { + const calls = installResponse({}); + await expect(listRemoteWebhookEndpoints(createManagedApiContext({ providers: {} }))).rejects.toThrow( + "No providers", + ); + await expect(listRemoteWebhookEndpoints(context(), { provider: "missing" })).rejects.toThrow("not configured"); + const multiple = createManagedApiContext({ + providers: { bailian: { api_key: "sk-test", base_url: BASE_URL }, claude: { api_key: "sk-test" } }, + }); + await expect(listRemoteWebhookEndpoints(multiple)).rejects.toThrow("Multiple providers"); + await expect(listRemoteWebhookEndpoints(multiple, { provider: "claude" })).rejects.toThrow( + "does not support webhook listing", + ); + expect(calls).toHaveLength(0); + }); + + test("normalizes origins and API suffixes, with explicit endpoints taking precedence", () => { + for (const baseUrl of ["https://workspace.example", "https://workspace.example/", `${BASE_URL}/`, BASE_URL]) { + expect(resolveBailianApiBaseUrl({ baseUrl, workspaceId: "ignored" })).toBe(BASE_URL); + } + expect(resolveBailianApiBaseUrl({ workspaceId: "workspace-123" })).toBe( + "https://workspace-123.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio", + ); + expect(resolveBailianApiBaseUrl({ baseUrl: "http://localhost:1234/" })).toBe( + "http://localhost:1234/api/v1/agentstudio", + ); + }); + + test("rejects invalid endpoint input without echoing secret-bearing URLs", () => { + for (const baseUrl of [ + "", + "invalid", + "ftp://workspace.example", + "https://user:password@workspace.example", + `${BASE_URL}?token=secret`, + `${BASE_URL}#fragment`, + "https://workspace.example/unrelated", + ]) { + expect(() => resolveBailianApiBaseUrl({ baseUrl })).toThrow("Bailian API base URL"); + } + expect(() => resolveBailianApiBaseUrl({})).toThrow("workspace ID"); + expect(() => resolveBailianApiBaseUrl({ workspaceId: "workspace/invalid" })).toThrow("workspace ID"); + }); + + test("keeps existing BailianClient custom base URL behavior unchanged", async () => { + const calls = installResponse({}); + await new BailianClient({ apiKey: "sk-test", baseUrl: "https://gateway.example/custom" }).get("/resource"); + expect(calls[0]?.url).toBe("https://gateway.example/custom/resource"); + }); +}); + +describe("Bailian webhook wire contract", () => { + const cases: Array<{ + name: string; + method: string; + path: string; + body?: unknown; + status?: number; + response: unknown; + run: (runtime: ManagedApiContext, options: WebhookRequestOptions) => Promise; + }> = [ + { + name: "create", + method: "POST", + path: "/webhook_endpoints", + body: { url: endpoint.url, events: endpoint.events }, + status: 201, + response: { ...endpoint, signing_secret: "whsec_test_create", request_id: "request-create" }, + run: (runtime, options) => + createRemoteWebhookEndpoint(runtime, { url: endpoint.url, events: endpoint.events }, options), + }, + { + name: "list without pagination", + method: "GET", + path: "/webhook_endpoints", + response: { data: [endpoint], request_id: "request-list" }, + run: listRemoteWebhookEndpoints, + }, + { + name: "get", + method: "GET", + path: `/webhook_endpoints/${WEBHOOK_ID}`, + response: { ...endpoint, request_id: "request-get" }, + run: (runtime, options) => getRemoteWebhookEndpoint(runtime, WEBHOOK_ID, options), + }, + { + name: "partial PUT preserves null", + method: "PUT", + path: `/webhook_endpoints/${WEBHOOK_ID}`, + body: { description: null }, + response: { ...endpoint, request_id: "request-update" }, + run: (runtime, options) => + updateRemoteWebhookEndpoint(runtime, WEBHOOK_ID, { description: null, events: undefined }, options), + }, + { + name: "delete preserves response request ID", + method: "DELETE", + path: `/webhook_endpoints/${WEBHOOK_ID}`, + response: { request_id: "request-delete" }, + run: (runtime, options) => deleteRemoteWebhookEndpoint(runtime, WEBHOOK_ID, options), + }, + { + name: "enable", + method: "POST", + path: `/webhook_endpoints/${WEBHOOK_ID}/enable`, + response: { ...endpoint, request_id: "request-enable" }, + run: (runtime, options) => enableRemoteWebhookEndpoint(runtime, WEBHOOK_ID, options), + }, + { + name: "disable", + method: "POST", + path: `/webhook_endpoints/${WEBHOOK_ID}/disable`, + response: { + ...endpoint, + status: "DISABLED", + disabled_reason: "future-server-reason", + request_id: "request-disable", + }, + run: (runtime, options) => disableRemoteWebhookEndpoint(runtime, WEBHOOK_ID, options), + }, + { + name: "test returns an event", + method: "POST", + path: `/webhook_endpoints/${WEBHOOK_ID}/test`, + response: testEvent, + run: (runtime, options) => testRemoteWebhookEndpoint(runtime, WEBHOOK_ID, options), + }, + { + name: "reset secret preserves the one-time result", + method: "POST", + path: `/webhook_endpoints/${WEBHOOK_ID}/reset_secret`, + response: { + id: WEBHOOK_ID, + signing_secret: "whsec_test_reset", + updated_at: endpoint.updated_at, + request_id: "request-reset", + }, + run: (runtime, options) => resetRemoteWebhookSecret(runtime, WEBHOOK_ID, options), + }, + { + name: "events preserve delivery details and nullable cursor", + method: "GET", + path: `/webhook_endpoints/${WEBHOOK_ID}/events?limit=100&page=opaque%2B%2F%3D`, + response: { + data: [ + { + ...testEvent, + data: { + id: "sess_test", + type: "session.updated", + workspace_id: "workspace", + session_thread_id: "thread_test", + vault_id: "vault_test", + future_field: "preserved", + }, + delivery: { + webhook_id: WEBHOOK_ID, + status: "FUTURE_STATUS", + attempt_count: 2, + delivery_at: null, + finish_at: null, + failure_reason: null, + }, + }, + ], + next_page: null, + request_id: "request-events", + }, + run: (runtime, options) => + listRemoteWebhookEvents(runtime, WEBHOOK_ID, { ...options, limit: 100, page: "opaque+/=" }), + }, + ]; + + for (const scenario of cases) { + test(scenario.name, async () => { + const calls = installResponse(scenario.response, scenario.status); + const controller = new AbortController(); + const result = await scenario.run(context(), { provider: "bailian", signal: controller.signal }); + expect(result).toEqual(scenario.response); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${BASE_URL}${scenario.path}`); + expect(calls[0]?.init?.method).toBe(scenario.method); + expect(calls[0]?.init?.headers).toEqual({ "Content-Type": "application/json", Authorization: "Bearer sk-test" }); + expect(calls[0]?.init?.signal).toBe(controller.signal); + expect(calls[0]?.init?.body).toBe(scenario.body === undefined ? undefined : JSON.stringify(scenario.body)); + }); + } + + test("PUT preserves empty description and replaces the exact events array", async () => { + const calls = installResponse(endpoint); + await updateRemoteWebhookEndpoint(context(), WEBHOOK_ID, { description: "", events: ["session.status_idled"] }); + expect(JSON.parse(calls[0]?.init?.body as string)).toEqual({ description: "", events: ["session.status_idled"] }); + }); + + test("event history omits defaults and preserves opaque non-null cursors", async () => { + const response = { data: [], next_page: "opaque-next+/=", request_id: "request-page" }; + const calls = installResponse(response); + expect(await listRemoteWebhookEvents(context(), WEBHOOK_ID)).toEqual(response); + expect(calls[0]?.url).toBe(`${BASE_URL}/webhook_endpoints/${WEBHOOK_ID}/events`); + }); + + test("escapes endpoint IDs as one path segment", async () => { + const calls = installResponse(endpoint); + await getRemoteWebhookEndpoint(context(), "wep_id/with?query#fragment"); + expect(calls[0]?.url).toBe(`${BASE_URL}/webhook_endpoints/wep_id%2Fwith%3Fquery%23fragment`); + }); + + test("retains additional test response fields without interpreting them", async () => { + const response = { ...testEvent, success: false, error: { message: "provider-owned detail" } }; + installResponse(response); + expect(await testRemoteWebhookEndpoint(context(), WEBHOOK_ID)).toEqual(response); + }); + + for (const status of [400, 401, 404, 409, 502]) { + test(`preserves HTTP ${status} and the exact server error without retrying`, async () => { + const response = { + type: "error", + request_id: "request-failure", + error: { code: "11800016", message: "provider-owned detail" }, + }; + const calls = installResponse(response, status); + try { + await testRemoteWebhookEndpoint(context(), WEBHOOK_ID); + throw new Error("Expected provider error"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).statusCode).toBe(status); + expect((error as ApiError).responseBody).toBe(JSON.stringify(response)); + } + expect(calls).toHaveLength(1); + }); + } + + test("does not replay create or reset after a transport failure", async () => { + for (const operation of [ + () => createRemoteWebhookEndpoint(context(), { url: endpoint.url, events: endpoint.events }), + () => resetRemoteWebhookSecret(context(), WEBHOOK_ID), + ]) { + let attempts = 0; + const failure = new TypeError("transport unavailable"); + setDefaultFetch(async () => { + attempts++; + throw failure; + }); + await expect(operation()).rejects.toBe(failure); + expect(attempts).toBe(1); + } + }); + + test("propagates cancellation to an in-flight HTTP request", async () => { + const controller = new AbortController(); + setDefaultFetch( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }), + ); + const operation = getRemoteWebhookEndpoint(context(), WEBHOOK_ID, { signal: controller.signal }); + const reason = new Error("cancel webhook request"); + controller.abort(reason); + await expect(operation).rejects.toBe(reason); + }); + + test("legacy DELETE still accepts a 204 response", async () => { + setDefaultFetch(async () => new Response(null, { status: 204 })); + const result = await new BailianClient({ apiKey: "sk-test", baseUrl: BASE_URL }).delete("/agents/agent-test"); + expect(result).toBeUndefined(); + }); + + test("declares all ten Bailian webhook operations with API-key auth", () => { + const operations = getManagedAgentProviderCapabilities("bailian").operations; + for (const action of [ + "create", + "list", + "get", + "update", + "delete", + "enable", + "disable", + "test", + "reset_secret", + "events", + ]) { + expect(operations[`webhook.${action}`]).toEqual({ supported: true, auth: "api_key" }); + } + expect(getManagedAgentProviderCapabilities("claude").operations["webhook.create"]).toBeUndefined(); + }); +}); diff --git a/packages/sdk/tests/unit/core-session-runtime.test.ts b/packages/sdk/tests/unit/core-session-runtime.test.ts index b45b883..c4cf266 100644 --- a/packages/sdk/tests/unit/core-session-runtime.test.ts +++ b/packages/sdk/tests/unit/core-session-runtime.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { describe, expect, mock, test } from "bun:test"; +import { createProjectRuntime, type ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; import { createSessionForAgent, listSessionEvents, sendSessionMessageAndCollectEvents, startSessionRun, + startSessionRunPolling, streamMessageEvents, streamSessionEvents, } from "../../src/internal/core/session-runtime.ts"; @@ -14,7 +15,7 @@ import type { ProviderAdapter } 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 { ProjectConfig } from "../../src/internal/types/config.ts"; -import type { ProviderSessionInfo } from "../../src/internal/types/session.ts"; +import type { ProviderSessionInfo, SessionBindings } from "../../src/internal/types/session.ts"; import type { ProviderSessionEvent } from "../../src/internal/types/session-event.ts"; function session(id = "sess_1", status = "idle"): ProviderSessionInfo { @@ -131,6 +132,116 @@ function ctx(provider: ProviderAdapter): ProjectRuntimeContext { } describe("core session runtime", () => { + for (const runtimeMethod of ["create", "stream", "poll"] as const) { + test(`rejects Bailian Agent defaults before HTTP through ${runtimeMethod}`, async () => { + const projectConfig = config(); + projectConfig.providers = { bailian: { api_key: "test", base_url: "https://bailian.test" } }; + projectConfig.defaults = { provider: "bailian" }; + projectConfig.agents!.assistant!.environment_variables = { AGENT_DEFAULT: "not-injected" }; + const sessionState = state(); + sessionState.setResource({ + address: { type: "agent", name: "assistant", provider: "bailian" }, + remote_id: "agent_1", + content_hash: "h", + }); + const runtime = createProjectRuntime({ + projectName: "test", + config: { ...projectConfig, _resolved: true }, + state: sessionState, + }); + let requestCount = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(async () => { + requestCount += 1; + throw new Error("Unexpected HTTP request while rejecting Agent environment defaults"); + }) as unknown as typeof fetch; + try { + const options = { agent: "assistant", environmentId: "env_1" }; + const run = + runtimeMethod === "create" + ? createSessionForAgent(runtime, options) + : runtimeMethod === "stream" + ? startSessionRun(runtime, "do work", options) + : startSessionRunPolling(runtime, "do work", options); + await expect(run).rejects.toThrow("Agent environment_variables defaults are supported only by Qoder"); + expect(requestCount).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test(`passes explicit Bailian Session environment variables through ${runtimeMethod}`, async () => { + let receivedBindings: SessionBindings | undefined; + const provider: ProviderAdapter = { + ...adapter("bailian", [], true), + createSession: async (bindings) => { + receivedBindings = bindings; + return session(); + }, + }; + const runtime = ctx(provider); + runtime.config.providers = { bailian: {} }; + runtime.config.defaults = { provider: "bailian" }; + runtime.config.agents!.assistant!.environment_variables = { AGENT_DEFAULT: "not-injected" }; + runtime.state.setResource({ + address: { type: "agent", name: "assistant", provider: "bailian" }, + remote_id: "agent_1", + content_hash: "h", + }); + const options = { + agent: "assistant", + environmentId: "env_1", + environmentVariables: { FEATURE_FLAG: "on", EMPTY_VALUE: "" }, + }; + if (runtimeMethod === "create") await createSessionForAgent(runtime, options); + else if (runtimeMethod === "stream") await startSessionRun(runtime, "do work", options); + else await startSessionRunPolling(runtime, "do work", options); + expect(receivedBindings?.environment_variables).toEqual(options.environmentVariables); + }); + } + + test("sends only explicit Bailian variables through the real project runtime and adapter", async () => { + const projectConfig = config(); + projectConfig.providers = { bailian: { api_key: "test", base_url: "https://bailian.test" } }; + projectConfig.defaults = { provider: "bailian" }; + projectConfig.agents!.assistant!.environment_variables = { AGENT_DEFAULT: "not-injected" }; + const sessionState = state(); + sessionState.setResource({ + address: { type: "agent", name: "assistant", provider: "bailian" }, + remote_id: "agent_1", + content_hash: "h", + }); + const runtime = createProjectRuntime({ + projectName: "test", + config: { ...projectConfig, _resolved: true }, + state: sessionState, + }); + const calls: Array<{ url: string; method?: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(async (input: string | URL, init?: RequestInit) => { + calls.push({ url: String(input), method: init?.method, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ id: "sess_1", agent: { id: "agent_1" }, status: "idle" }), { + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + try { + await createSessionForAgent(runtime, { + agent: "assistant", + environmentId: "env_1", + environmentVariables: { SESSION_ONLY: "value" }, + }); + expect(calls).toEqual([ + { + url: "https://bailian.test/sessions", + method: "POST", + body: { agent: "agent_1", environment_id: "env_1", environment_variables: { SESSION_ONLY: "value" } }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("starts a Forward session with the caller's explicit Identity", async () => { const forwardConfig = config(); forwardConfig.agents!.assistant!.delivery = { qoder: { type: "forward" } }; diff --git a/packages/sdk/tests/unit/deployment-environment.test.ts b/packages/sdk/tests/unit/deployment-environment.test.ts new file mode 100644 index 0000000..1cd82f0 --- /dev/null +++ b/packages/sdk/tests/unit/deployment-environment.test.ts @@ -0,0 +1,311 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { validateProjectConfig } from "../../src/internal/core/validate-config.ts"; +import { executePlan } from "../../src/internal/executor/executor.ts"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import { refreshState } from "../../src/internal/planner/refresh.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import { + mapDeployment as mapBailianDeployment, + mapDeploymentUpdate as mapBailianDeploymentUpdate, +} from "../../src/internal/providers/bailian/mapper.ts"; +import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; +import { mapDeployment as mapQoderDeployment } from "../../src/internal/providers/qoder/mapper.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { DeploymentDecl, ProjectConfig } from "../../src/internal/types/config.ts"; +import type { ResourceAddress } from "../../src/internal/types/state.ts"; +import { addressKey } from "../../src/internal/types/state.ts"; + +const BASE = "https://bailian.test"; +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function deployment(environmentVariables?: DeploymentDecl["environment_variables"]): DeploymentDecl { + return { + agent: "assistant", + initial_events: [{ type: "user.message", content: "run" }], + ...(environmentVariables !== undefined ? { environment_variables: environmentVariables } : {}), + }; +} + +function config(provider = "bailian", environmentVariables?: DeploymentDecl["environment_variables"]): ProjectConfig { + return { + version: "1", + providers: { [provider]: {} }, + defaults: { provider }, + environments: { sandbox: { environment_id: "env_1", config: { type: "cloud" } } }, + agents: { assistant: { model: "qwen3", instructions: "help", environment: "sandbox" } }, + deployments: { task: deployment(environmentVariables) }, + }; +} + +function refs(): ResolvedDeploymentRefs { + return { agent_id: "agent_1", environment_id: "env_1", vault_ids: [], memory_store_ids: {} }; +} + +interface CapturedCall { + url: string; + method: string; + body?: unknown; +} + +function mockResponses(responses: unknown[]): CapturedCall[] { + const calls: CapturedCall[] = []; + globalThis.fetch = mock(async (input: string | URL, init?: RequestInit) => { + const response = responses[calls.length]; + if (response === undefined) throw new Error("Unexpected mocked Deployment HTTP request"); + calls.push({ + url: String(input), + method: init?.method ?? "GET", + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }); + return Response.json(response); + }) as unknown as typeof fetch; + return calls; +} + +const validCases: Array<{ provider: string; variables: DeploymentDecl["environment_variables"] }> = [ + { provider: "bailian", variables: { MODE: "on", COMPLEX: "alpha=beta;中文", EMPTY: "" } }, + { provider: "bailian", variables: {} }, + { provider: "qoder", variables: "MODE=on;EMPTY=" }, + { provider: "qoder", variables: "" }, +]; + +describe("Deployment environment variable declarations", () => { + test("rejects an own __proto__ YAML key before schema parsing or mapper transmission", () => { + const variables = parseYaml("__proto__: value\nNORMAL: value\n") as Record; + expect(Object.hasOwn(variables, "__proto__")).toBe(true); + const project = config("bailian", variables); + const parsed = projectConfigSchema.safeParse(project); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.message.includes("__proto__"))).toBe(true); + } + expect(validateProjectConfig(project)).toContainEqual( + expect.objectContaining({ code: "bailian.deployment.environment_variables.invalid" }), + ); + expect(() => mapBailianDeployment("task", deployment(variables), refs())).toThrow("__proto__"); + expect(() => mapBailianDeploymentUpdate("task", deployment(variables), refs())).toThrow("__proto__"); + }); + + test("preserves normal constructor and prototype map keys", () => { + const variables = { constructor: "constructor-value", prototype: "prototype-value", NORMAL: "value" }; + expect(projectConfigSchema.parse(config("bailian", variables)).deployments?.task?.environment_variables).toEqual( + variables, + ); + expect(mapBailianDeployment("task", deployment(variables), refs())).toHaveProperty( + "environment_variables", + variables, + ); + }); + test.each(validCases)("accepts the provider's existing wire shape %j", ({ provider, variables }) => { + const project = config(provider, variables); + const parsed = projectConfigSchema.parse(project); + expect(parsed.deployments?.task?.environment_variables).toEqual(variables); + expect(validateProjectConfig(project).filter((issue) => issue.code.includes("environment_variables"))).toEqual([]); + }); + + test.each([ + { variables: null }, + { variables: ["MODE=on"] }, + { variables: { MODE: 1 } }, + ])("rejects values outside string or string-map schema %j", ({ variables }) => { + const project = config(); + const parsed = projectConfigSchema.safeParse({ + ...project, + deployments: { task: { ...deployment(), environment_variables: variables } }, + }); + expect(parsed.success).toBe(false); + }); + + test.each([ + { provider: "bailian", variables: "MODE=on", kind: "invalid" }, + { provider: "qoder", variables: { MODE: "on" }, kind: "invalid" }, + { provider: "claude", variables: { MODE: "on" }, kind: "unsupported" }, + { provider: "ark", variables: "MODE=on", kind: "unsupported" }, + ])("rejects incompatible provider declarations %j", ({ provider, variables, kind }) => { + const diagnostics = validateProjectConfig(config(provider, variables)); + expect(diagnostics).toContainEqual( + expect.objectContaining({ severity: "error", code: `${provider}.deployment.environment_variables.${kind}` }), + ); + }); + + test("Bailian and Qoder mappers reject the other provider's shape", () => { + expect(() => mapBailianDeployment("task", deployment("MODE=on"), refs())).toThrow("string-valued object"); + expect(() => mapQoderDeployment("task", deployment({ MODE: "on" }), refs())).toThrow("string format"); + expect(mapQoderDeployment("task", deployment("MODE=on;EMPTY="), refs())).toMatchObject({ + environment_variables: "MODE=on;EMPTY=", + }); + }); +}); + +describe("Bailian Deployment environment variable mapping", () => { + test.each([ + { variables: { MODE: "on", COMPLEX: "alpha=beta;中文", EMPTY: "" } }, + { variables: {} }, + ])("create sends the exact object without string serialization %j", async ({ variables }) => { + const calls = mockResponses([{ id: "depl_1", type: "deployment", environment_variables: variables }]); + await new BailianAdapter("test", undefined, BASE).createDeployment( + "task", + deployment(variables), + refs(), + "/tmp/agents.yaml", + ); + expect(calls).toEqual([ + { + url: `${BASE}/deployments`, + method: "POST", + body: { + name: "task", + agent: { id: "agent_1" }, + environment_id: "env_1", + initial_events: [{ role: "user", type: "message", content: [{ type: "text", text: "run" }] }], + environment_variables: variables, + }, + }, + ]); + }); + + test("create omits absent variables and update sends an empty map for removed variables", () => { + expect(mapBailianDeployment("task", deployment(), refs())).not.toHaveProperty("environment_variables"); + expect(mapBailianDeploymentUpdate("task", deployment(), refs())).toHaveProperty("environment_variables", {}); + }); + + test("update sends the exact variable replacement object", async () => { + const calls = mockResponses([ + { id: "depl_1", environment_variables: { OLD: "value" } }, + { id: "depl_1", environment_variables: { NEW: "value" } }, + ]); + await new BailianAdapter("test", undefined, BASE).updateDeployment( + "depl_1", + "task", + deployment({ NEW: "value" }), + refs(), + "/tmp/agents.yaml", + ); + expect(calls).toEqual([ + { url: `${BASE}/deployments/depl_1`, method: "GET" }, + { + url: `${BASE}/deployments/depl_1`, + method: "POST", + body: { + name: "task", + agent: { id: "agent_1" }, + environment_id: "env_1", + initial_events: [{ role: "user", type: "message", content: [{ type: "text", text: "run" }] }], + environment_variables: { NEW: "value" }, + description: "", + vault_ids: [], + resources: [], + schedule: null, + }, + }, + ]); + }); + + test.each([{ variables: { MODE: "on" } }, { variables: {} }])("get and list preserve variable maps %j", async ({ + variables, + }) => { + const response = { id: "depl_1", status: "active", environment_variables: variables }; + mockResponses([response, { data: [response] }]); + const adapter = new BailianAdapter("test", undefined, BASE); + const detail = await adapter.getDeploymentById("depl_1"); + const listed = await adapter.listDeployments(); + expect(detail.environment_variables).toEqual(variables); + expect(detail.attributes).toEqual(response); + expect(listed.deployments[0]?.environment_variables).toEqual(variables); + expect(listed.deployments[0]?.attributes).toEqual(response); + }); + + test.each([ + { variables: undefined }, + { variables: null }, + { variables: "MODE=on" }, + { variables: { MODE: 1 } }, + ])("retains raw unexpected response shapes without lifting %j", async ({ variables }) => { + const response = { id: "depl_1", status: "active", environment_variables: variables }; + mockResponses([response]); + const detail = await new BailianAdapter("test", undefined, BASE).getDeploymentById("depl_1"); + expect(detail).not.toHaveProperty("environment_variables"); + expect(detail.attributes).toEqual(JSON.parse(JSON.stringify(response))); + }); +}); + +test("Bailian Deployment variable changes and removal apply and converge to no-op", async () => { + const directory = mkdtempSync(join(tmpdir(), "deployment-environment-")); + try { + const project = config("bailian", { MODE: "one", REGION: "cn" }); + const state = StateManager.initialize(join(directory, "state.json")); + const dependencyAddresses: ResourceAddress[] = [ + { type: "environment", name: "sandbox", provider: "bailian" }, + { type: "agent", name: "assistant", provider: "bailian" }, + ]; + for (const address of dependencyAddresses) { + state.setResource({ address, remote_id: address.type === "agent" ? "agent_1" : "env_1", content_hash: "" }); + } + for (const resource of state.listResources()) { + state.setResource({ + ...resource, + content_hash: await computeResourceHash(resource.address, project, undefined, state), + }); + } + const address: ResourceAddress = { type: "deployment", name: "task", provider: "bailian" }; + let remote: Record = {}; + const variableWrites: unknown[] = []; + let requestCount = 0; + globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => { + requestCount += 1; + if (init?.method === "POST") { + const body = JSON.parse(String(init.body)) as Record; + variableWrites.push(body.environment_variables); + remote = { ...remote, ...body, id: "depl_1", type: "deployment", status: "active" }; + } + return Response.json(remote); + }) as unknown as typeof fetch; + const adapter = new BailianAdapter("test", undefined, BASE); + const context = { + config: project, + configPath: join(directory, "agents.yaml"), + state, + providers: new Map([["bailian", adapter]]), + }; + const firstHash = await computeResourceHash(address, project, undefined, state); + project.deployments!.task!.environment_variables = { REGION: "cn", MODE: "one" }; + expect(await computeResourceHash(address, project, undefined, state)).toBe(firstHash); + for (const stage of ["create", "change", "remove"] as const) { + if (stage === "change") project.deployments!.task!.environment_variables = { MODE: "two" }; + if (stage === "remove") delete project.deployments!.task!.environment_variables; + const plan = await buildPlan(project, state.getStateFile()); + expect(plan.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + expect(plan.actions.find((action) => action.address.type === "deployment")?.action).toBe( + stage === "create" ? "create" : "update", + ); + const result = await executePlan(plan, context); + expect(result.partial).toBe(false); + expect(result.results.every((actionResult) => actionResult.status === "success")).toBe(true); + const converged = await buildPlan(project, state.getStateFile()); + expect(converged.actions.every((action) => action.action === "no-op")).toBe(true); + expect((await adapter.getDeploymentById("depl_1")).environment_variables).toEqual( + stage === "remove" ? {} : project.deployments!.task!.environment_variables, + ); + } + expect(variableWrites).toEqual([{ REGION: "cn", MODE: "one" }, { MODE: "two" }, {}]); + expect(adapter.getDriftSupport("deployment")).toBe("unsupported"); + const beforeRefresh = requestCount; + await refreshState(state, context.providers, { + config: project, + quiet: true, + resourceKeys: new Set([addressKey(address)]), + }); + expect(requestCount).toBe(beforeRefresh); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index 4a4642f..a32a896 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,7 +9,7 @@ import { DeploymentCreateConflictError } from "../../src/internal/providers/depl 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 { MemoryStoreDecl, ProjectConfig } from "../../src/internal/types/config.ts"; import type { ExecutionPlan } from "../../src/internal/types/plan.ts"; function tmpPath(): string { @@ -58,8 +58,332 @@ function makeCtx( const existingResource: RemoteResource = { id: "env_existing", type: "environment", version: 3 }; +const bailianMemoryConfig: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + memory_stores: { notes: { description: "Updated notes" } }, +}; + +const memoryStoreAddress = { type: "memory_store" as const, name: "notes", provider: "bailian" }; + +function memoryStorePlan(action: "create" | "update"): ExecutionPlan { + return { + actions: [ + { + action, + address: memoryStoreAddress, + reason: action === "create" ? "Resource does not exist in state" : "Local config changed", + after: { content_hash: "new-memory-hash" }, + dependencies: [], + }, + ], + diagnostics: [], + }; +} + +function makeBailianMemoryProvider() { + const methods = { + name: "bailian", + supportsResourceNameLookup: (resourceType: string) => resourceType !== "memory_store", + findResource: mock(async () => ({ id: "memstore_same_name", type: "memory_store" })), + createMemoryStore: mock(async (_storeName: string, _declaration: MemoryStoreDecl) => ({ + id: "memstore_new", + type: "memory_store", + })), + deleteMemoryStore: mock(async () => {}), + updateMemoryStore: mock(async (storeId: string) => ({ + id: storeId, + type: "memory_store", + name: "notes", + description: "Updated notes", + metadata: {}, + created_at: "", + updated_at: "", + })), + listMemories: mock(async () => ({ data: [], has_more: false })), + createMemory: mock(async () => ({ id: "mem_seed", type: "memory" })), + updateMemory: mock(async () => ({ id: "mem_seed", type: "memory" })), + deleteMemory: mock(async () => {}), + }; + return { ...methods, provider: methods as unknown as ProviderAdapter }; +} + describe("executor conflict-adopt", () => { - test("memory store conflict adopts and reconciles without deleting learned memories", async () => { + test("Bailian memory store without state creates and records a new id despite a same-name store", async () => { + const memoryProvider = makeBailianMemoryProvider(); + const state = StateManager.initialize(tmpPath()); + const result = await executePlan( + memoryStorePlan("create"), + makeCtx(memoryProvider.provider, state, bailianMemoryConfig), + ); + + expect(result.partial).toBe(false); + expect(result.results[0]?.status).toBe("success"); + expect(memoryProvider.createMemoryStore).toHaveBeenCalledTimes(1); + expect(memoryProvider.findResource).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.listMemories).not.toHaveBeenCalled(); + expect(memoryProvider.createMemory).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemory).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)?.remote_id).toBe("memstore_new"); + }); + + test("new Bailian memory store checkpoints its id and initializes entries separately", async () => { + const memoryProvider = makeBailianMemoryProvider(); + const declaration: MemoryStoreDecl = { + description: "Initial notes", + entries: [{ key: "seed.md", content: "Initial content" }], + }; + const memoryConfig: ProjectConfig = { ...bailianMemoryConfig, memory_stores: { notes: declaration } }; + const state = StateManager.initialize(tmpPath()); + const result = await executePlan(memoryStorePlan("create"), makeCtx(memoryProvider.provider, state, memoryConfig)); + + expect(result.partial).toBe(false); + expect(memoryProvider.createMemoryStore).toHaveBeenCalledWith("notes", declaration, undefined); + expect(memoryProvider.updateMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.listMemories).toHaveBeenCalledTimes(1); + expect(memoryProvider.createMemory).toHaveBeenCalledWith( + "memstore_new", + { path: "/seed.md", content: "Initial content" }, + undefined, + ); + expect(memoryProvider.updateMemory).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)?.remote_id).toBe("memstore_new"); + expect(state.getResource(memoryStoreAddress)?.memory_initialization).toBeUndefined(); + }); + + test("Bailian memory store with a recorded id reconciles that id without name lookup or creation", async () => { + const memoryProvider = makeBailianMemoryProvider(); + const state = StateManager.initialize(tmpPath()); + state.setResource({ address: memoryStoreAddress, remote_id: "memstore_tracked", content_hash: "old-memory-hash" }); + const result = await executePlan( + memoryStorePlan("update"), + makeCtx(memoryProvider.provider, state, bailianMemoryConfig), + ); + + expect(result.partial).toBe(false); + expect(result.results[0]?.status).toBe("success"); + expect(memoryProvider.updateMemoryStore).toHaveBeenCalledTimes(1); + expect(memoryProvider.updateMemoryStore).toHaveBeenCalledWith( + "memstore_tracked", + { name: "notes", description: "Updated notes", metadata: {} }, + undefined, + ); + expect(memoryProvider.listMemories).not.toHaveBeenCalled(); + expect(memoryProvider.createMemory).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemory).not.toHaveBeenCalled(); + expect(memoryProvider.findResource).not.toHaveBeenCalled(); + expect(memoryProvider.createMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)?.remote_id).toBe("memstore_tracked"); + }); + + const initializationOnlyChanges: Array<{ change: string; declaration: MemoryStoreDecl }> = [ + { + change: "description and metadata change", + declaration: { + description: "Updated description", + metadata: { topic: "updated" }, + entries: [{ key: "seed.md", content: "Original seed" }], + }, + }, + { + change: "entry addition", + declaration: { + description: "notes", + entries: [ + { key: "seed.md", content: "Original seed" }, + { key: "new.md", content: "Additional seed" }, + ], + }, + }, + { + change: "entry content change", + declaration: { description: "notes", entries: [{ key: "seed.md", content: "Changed seed" }] }, + }, + { + change: "entry removal", + declaration: { description: "notes", entries: [] }, + }, + { + change: "explicit initialize mode", + declaration: { + description: "notes", + entries_mode: "initialize", + entries: [{ key: "seed.md", content: "Changed seed" }], + }, + }, + ]; + + test.each(initializationOnlyChanges)("existing memory store preserves its memories after $change", async ({ + declaration, + }) => { + const memoryProvider = makeBailianMemoryProvider(); + const memoryConfig: ProjectConfig = { ...bailianMemoryConfig, memory_stores: { notes: declaration } }; + const state = StateManager.initialize(tmpPath()); + state.setResource({ address: memoryStoreAddress, remote_id: "memstore_tracked", content_hash: "old-memory-hash" }); + const result = await executePlan(memoryStorePlan("update"), makeCtx(memoryProvider.provider, state, memoryConfig)); + + expect(result.partial).toBe(false); + expect(memoryProvider.updateMemoryStore).toHaveBeenCalledWith( + "memstore_tracked", + { name: "notes", description: declaration.description, metadata: declaration.metadata ?? {} }, + undefined, + ); + expect(memoryProvider.listMemories).not.toHaveBeenCalled(); + expect(memoryProvider.createMemory).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemory).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemory).not.toHaveBeenCalled(); + expect(memoryProvider.createMemoryStore).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)?.remote_id).toBe("memstore_tracked"); + }); + + test.each([ + null, + "", + ])("Bailian memory store update without a remote id (%p) creates and records a new store", async (remoteId) => { + const memoryProvider = makeBailianMemoryProvider(); + const state = StateManager.initialize(tmpPath()); + state.setResource({ address: memoryStoreAddress, remote_id: remoteId, content_hash: "old-memory-hash" }); + const result = await executePlan( + memoryStorePlan("update"), + makeCtx(memoryProvider.provider, state, bailianMemoryConfig), + ); + + expect(result.partial).toBe(false); + expect(result.results[0]?.status).toBe("success"); + expect(memoryProvider.createMemoryStore).toHaveBeenCalledTimes(1); + expect(memoryProvider.findResource).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)?.remote_id).toBe("memstore_new"); + }); + + test("Bailian memory store conflict preserves the original error without adopting a same-name store", async () => { + const memoryProvider = makeBailianMemoryProvider(); + const originalError = new ConflictError(409, "Memory store creation was rejected", "Bailian API"); + memoryProvider.createMemoryStore.mockRejectedValue(originalError); + const state = StateManager.initialize(tmpPath()); + const result = await executePlan( + memoryStorePlan("create"), + makeCtx(memoryProvider.provider, state, bailianMemoryConfig), + ); + + expect(result.partial).toBe(true); + expect(result.results[0]?.status).toBe("failed"); + expect(result.results[0]?.error).toBe(originalError); + expect(memoryProvider.findResource).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + expect(memoryProvider.listMemories).not.toHaveBeenCalled(); + expect(state.getResource(memoryStoreAddress)).toBeUndefined(); + }); + + test("default memory store conflict adoption updates attributes without reading or overwriting memories", async () => { + const memoryProvider = makeBailianMemoryProvider(); + memoryProvider.createMemoryStore.mockRejectedValue(new ConflictError(409, "exists", "Qoder API")); + const provider = { + ...memoryProvider.provider, + name: "qoder", + supportsResourceNameLookup: () => true, + } as unknown as ProviderAdapter; + const memoryConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + memory_stores: { notes: { description: "Updated notes", entries: [{ key: "seed.md", content: "YAML seed" }] } }, + }; + const address = { ...memoryStoreAddress, provider: "qoder" }; + const plan: ExecutionPlan = { + actions: [{ ...memoryStorePlan("create").actions[0]!, address }], + diagnostics: [], + }; + const state = StateManager.initialize(tmpPath()); + const result = await executePlan(plan, { + config: memoryConfig, + configPath: "/tmp/agents.yaml", + providers: new Map([["qoder", provider]]), + state, + }); + + expect(result.partial).toBe(false); + expect(memoryProvider.findResource).toHaveBeenCalledTimes(1); + expect(memoryProvider.findResource).toHaveBeenCalledWith("memory_store", "notes", undefined, "managed"); + expect(memoryProvider.updateMemoryStore).toHaveBeenCalledWith( + "memstore_same_name", + { name: "notes", description: "Updated notes", metadata: {} }, + "managed", + ); + expect(memoryProvider.listMemories).not.toHaveBeenCalled(); + expect(memoryProvider.createMemory).not.toHaveBeenCalled(); + expect(memoryProvider.updateMemory).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemory).not.toHaveBeenCalled(); + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + expect(state.getResource(address)?.remote_id).toBe("memstore_same_name"); + }); + + test.each([ + true, + false, + ])("memory store conflict only searches the actual creation name (matching store exists: %p)", async (matchingStoreExists) => { + const memoryProvider = makeBailianMemoryProvider(); + memoryProvider.createMemoryStore.mockRejectedValue(new ConflictError(409, "exists", "Qoder API")); + const findResource = mock(async (_resourceType: string, resourceName: string): Promise => { + if (resourceName === "Shared notes" && matchingStoreExists) { + return { id: "memstore_shared", type: "memory_store" }; + } + if (resourceName === "notes") { + return { id: "memstore_unrelated", type: "memory_store" }; + } + return null; + }); + const provider = { + ...memoryProvider.provider, + name: "qoder", + supportsResourceNameLookup: () => true, + findResource, + } as unknown as ProviderAdapter; + const declaration: MemoryStoreDecl = { name: "Shared notes", description: "Updated notes" }; + const memoryConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + memory_stores: { notes: declaration }, + }; + const address = { ...memoryStoreAddress, provider: "qoder" }; + const plan: ExecutionPlan = { + actions: [{ ...memoryStorePlan("create").actions[0]!, address }], + diagnostics: [], + }; + const state = StateManager.initialize(tmpPath()); + const result = await executePlan(plan, { + config: memoryConfig, + configPath: "/tmp/agents.yaml", + providers: new Map([["qoder", provider]]), + state, + }); + + expect(memoryProvider.createMemoryStore).toHaveBeenCalledWith("Shared notes", declaration, "managed"); + expect(findResource).toHaveBeenCalledTimes(1); + expect(findResource).toHaveBeenCalledWith("memory_store", "Shared notes", undefined, "managed"); + expect(result.partial).toBe(!matchingStoreExists); + if (matchingStoreExists) { + expect(memoryProvider.updateMemoryStore).toHaveBeenCalledWith( + "memstore_shared", + { name: "Shared notes", description: "Updated notes", metadata: {} }, + "managed", + ); + expect(state.getResource(address)?.remote_id).toBe("memstore_shared"); + } else { + expect(memoryProvider.updateMemoryStore).not.toHaveBeenCalled(); + expect(state.getResource(address)).toBeUndefined(); + } + expect(memoryProvider.deleteMemoryStore).not.toHaveBeenCalled(); + }); + + test("explicit sync memory store conflict reconciles seed entries without deleting learned memories", async () => { const calls: string[] = []; let storeMetadata: Record | undefined; const provider = { @@ -84,7 +408,7 @@ describe("executor conflict-adopt", () => { updated_at: "", }; }, - listMemories: async () => ({ + listMemories: mock(async () => ({ data: [ { id: "mem_seed", @@ -110,7 +434,7 @@ describe("executor conflict-adopt", () => { }, ], has_more: false, - }), + })), updateMemory: async (_storeId: string, id: string) => { calls.push(`update-memory:${id}`); return { id, type: "memory" }; @@ -121,7 +445,9 @@ describe("executor conflict-adopt", () => { version: "1", providers: { qoder: {} }, defaults: { provider: "qoder" }, - memory_stores: { notes: { description: "notes", entries: [{ key: "seed.md", content: "new" }] } }, + memory_stores: { + notes: { description: "notes", entries_mode: "sync", entries: [{ key: "seed.md", content: "new" }] }, + }, }; const plan: ExecutionPlan = { actions: [ @@ -145,6 +471,11 @@ describe("executor conflict-adopt", () => { expect(result.partial).toBe(false); expect(calls).toEqual(["update-store:memstore_existing", "update-memory:mem_seed"]); expect(storeMetadata).toEqual({}); + expect(provider.listMemories).toHaveBeenCalledWith( + "memstore_existing", + expect.objectContaining({ limit: 100 }), + "managed", + ); }); test("ConflictError → findResource finds existing → onExisting rebuilds via updateEnvironment", async () => { diff --git a/packages/sdk/tests/unit/map-session.test.ts b/packages/sdk/tests/unit/map-session.test.ts index f255d4d..9fd501b 100644 --- a/packages/sdk/tests/unit/map-session.test.ts +++ b/packages/sdk/tests/unit/map-session.test.ts @@ -293,6 +293,13 @@ describe("Claude toSessionInfo", () => { }); describe("Qoder toSessionInfo", () => { + test("keeps provider string environment variables only in raw attributes", () => { + const response = { id: "sess_q_env", environment_variables: "FEATURE_FLAG=on;LOG_LEVEL=debug" }; + const info = qoderToSessionInfo(response); + expect(info).not.toHaveProperty("environment_variables"); + expect(info.attributes).toBe(response); + }); + test("full response with flat fields", () => { const info = qoderToSessionInfo({ id: "sess_q1", @@ -350,6 +357,29 @@ describe("Qoder toSessionInfo", () => { }); describe("Bailian toSessionInfo", () => { + test.each([{ FEATURE_FLAG: "on", EMPTY_VALUE: "" }, {}] satisfies Record< + string, + string + >[])("preserves environment variables and raw attributes %j", (environmentVariables) => { + const response = { id: "sess_b_env", environment_variables: environmentVariables }; + const info = bailianToSessionInfo(response); + expect(info.environment_variables).toEqual(environmentVariables); + expect(info.attributes).toBe(response); + }); + + test.each([ + { environmentVariables: undefined }, + { environmentVariables: null }, + { environmentVariables: "FEATURE_FLAG=on" }, + { environmentVariables: ["on"] }, + { environmentVariables: { FEATURE_FLAG: 1 } }, + ])("does not lift absent or non-string-map environment variables %j", ({ environmentVariables }) => { + const response = { id: "sess_b_env", environment_variables: environmentVariables }; + const info = bailianToSessionInfo(response); + expect(info).not.toHaveProperty("environment_variables"); + expect(info.attributes).toBe(response); + }); + test("full response with nested agent", () => { const info = bailianToSessionInfo({ id: "sess_b1", diff --git a/packages/sdk/tests/unit/mcp-tool-defaults.test.ts b/packages/sdk/tests/unit/mcp-tool-defaults.test.ts new file mode 100644 index 0000000..90f9e5d --- /dev/null +++ b/packages/sdk/tests/unit/mcp-tool-defaults.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { buildAgentDecl } from "../../src/internal/core/agent-builder.ts"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; +import { mapAgent } from "../../src/internal/providers/bailian/mapper.ts"; +import type { AgentDecl } from "../../src/internal/types/config.ts"; + +const serverName = "market-cmgjmcp00074952"; + +function configWithToolkit(toolkit: Record) { + return { + version: "1", + providers: { bailian: {} }, + agents: { + assistant: { + model: "qwen3.7-max", + instructions: "Use the financial MCP tools.", + tools: { builtin: ["read"], mcp: [toolkit] }, + mcp_servers: [{ type: "official", name: serverName }], + }, + }, + }; +} + +describe("MCP tool defaults", () => { + for (const toolkit of [ + { mcp_server_name: serverName, default_config: { enabled: true } }, + { mcpServerName: serverName, defaultConfig: { enabled: true } }, + { mcp_server_name: serverName, default_config: { enabled: true }, configs: [] }, + ]) { + test(`enables all tools without placeholders: ${JSON.stringify(toolkit)}`, () => { + const parsed = projectConfigSchema.parse(configWithToolkit(toolkit)); + const agent = parsed.agents!.assistant!; + const expectedToolkit = { + type: "mcp_toolkit", + mcp_server_name: serverName, + default_config: { enabled: true }, + configs: [], + }; + expect(agent.tools?.mcp).toEqual([expectedToolkit]); + const body = mapAgent("assistant", agent, { skill_ids: [] }); + expect(body.tools).toContainEqual(expectedToolkit); + }); + } + + test("keeps tools disabled when both defaults and overrides are omitted", () => { + const parsed = projectConfigSchema.parse(configWithToolkit({ mcp_server_name: serverName })); + expect(parsed.agents?.assistant?.tools?.mcp).toEqual([ + { + type: "mcp_toolkit", + mcp_server_name: serverName, + default_config: { enabled: false }, + configs: [], + }, + ]); + }); + + test("preserves explicit tool overrides and defaults an omitted tool enabled flag to true", () => { + const parsed = projectConfigSchema.parse( + configWithToolkit({ + mcp_server_name: serverName, + default_config: { enabled: true }, + configs: [{ name: "query", enabled: false }, { name: "report" }], + }), + ); + const body = mapAgent("assistant", parsed.agents!.assistant!, { skill_ids: [] }); + expect(body.tools).toContainEqual({ + type: "mcp_toolkit", + mcp_server_name: serverName, + default_config: { enabled: true }, + configs: [ + { name: "query", enabled: false }, + { name: "report", enabled: true }, + ], + }); + }); + + for (const invalidToolkit of [ + { default_config: { enabled: true } }, + { mcp_server_name: serverName, configs: null }, + { mcp_server_name: serverName, configs: "all" }, + { mcp_server_name: serverName, configs: [{ enabled: true }] }, + { mcp_server_name: serverName, configs: [{ name: "query", enabled: "true" }] }, + ]) { + test(`rejects invalid toolkit configuration: ${JSON.stringify(invalidToolkit)}`, () => { + expect(projectConfigSchema.safeParse(configWithToolkit(invalidToolkit)).success).toBe(false); + }); + } + + for (const defaultConfig of [undefined, { enabled: false }, { enabled: true }]) { + test(`normalizes omitted overrides for direct SDK declarations: ${JSON.stringify(defaultConfig)}`, () => { + const agent: AgentDecl = { + model: "qwen3.7-max", + instructions: "Use the financial MCP tools.", + tools: { + builtin: ["read"], + mcp: [{ type: "mcp_toolkit", mcp_server_name: serverName, default_config: defaultConfig }], + }, + mcp_servers: [{ type: "official", name: serverName }], + }; + const body = mapAgent("assistant", agent, { skill_ids: [] }); + expect(body.tools).toContainEqual({ + type: "mcp_toolkit", + mcp_server_name: serverName, + default_config: defaultConfig ?? { enabled: false }, + configs: [], + }); + }); + } + + test("builds an agent with server defaults and no per-tool list", () => { + const { agent } = buildAgentDecl(undefined, { + model: "qwen3.7-max", + mcp: [{ serverName, serverType: "official", defaultEnabled: true }], + }); + expect(agent.tools?.mcp).toEqual([ + { + type: "mcp_toolkit", + mcp_server_name: serverName, + default_config: { enabled: true }, + configs: [], + }, + ]); + expect( + projectConfigSchema.safeParse({ version: "1", providers: { bailian: {} }, agents: { assistant: agent } }).success, + ).toBe(true); + }); +}); diff --git a/packages/sdk/tests/unit/memory-entry-mode.test.ts b/packages/sdk/tests/unit/memory-entry-mode.test.ts new file mode 100644 index 0000000..f90c1fa --- /dev/null +++ b/packages/sdk/tests/unit/memory-entry-mode.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, test } from "bun:test"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; +import { ArkAdapter } from "../../src/internal/providers/ark/adapter.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const entries = [ + { key: "notes/project.md", content: "Initial project notes" }, + { key: "notes/preferences.md", content: "Initial preferences" }, +]; + +function memoryProject(entriesMode?: unknown) { + return { + version: "1", + providers: { ark: {} }, + memory_stores: { + notes: { + description: "Project notes", + metadata: { owner: "test" }, + entries, + ...(entriesMode === undefined ? {} : { entries_mode: entriesMode }), + }, + }, + }; +} + +const supportedModes = [ + { label: "omitted", entriesMode: undefined }, + { label: "initialize", entriesMode: "initialize" }, + { label: "sync", entriesMode: "sync" }, +]; + +test.each(supportedModes)("parses $label memory entries mode without injecting a default", ({ entriesMode }) => { + const config = projectConfigSchema.parse(memoryProject(entriesMode)); + const declaration = config.memory_stores?.notes; + + expect(declaration).toEqual(memoryProject(entriesMode).memory_stores.notes); + expect(declaration?.entries).toEqual(entries); + expect(Object.hasOwn(declaration!, "entries_mode")).toBe(entriesMode !== undefined); +}); + +test.each(["replace", "", null, true, 1])("rejects invalid memory entries mode %p", (entriesMode) => { + const result = projectConfigSchema.safeParse(memoryProject(entriesMode)); + + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues.some((issue) => issue.path.join(".") === "memory_stores.notes.entries_mode")).toBe(true); +}); + +test.each(supportedModes)("$label mode initializes every seed when a new store is created", async ({ entriesMode }) => { + const requests: { url: string; method: string; body: Record }[] = []; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const body = (await request.json()) as Record; + requests.push({ url: request.url, method: request.method, body }); + if (requests.length === 1) + return Response.json({ + id: "memstore_created", + type: "memory_store", + name: "notes", + description: "Project notes", + metadata: { owner: "test" }, + }); + return Response.json({ + id: `memory_seed_${requests.length - 1}`, + type: "memory", + memory_store_id: "memstore_created", + path: body.path, + content: body.content, + content_size_bytes: Buffer.byteLength(String(body.content)), + content_sha256: "seed-content-hash", + metadata: {}, + created_at: "2026-10-13T00:00:00Z", + updated_at: "2026-10-13T00:00:00Z", + }); + }; + const config = projectConfigSchema.parse(memoryProject(entriesMode)); + const declaration = config.memory_stores!.notes!; + const created = await new ArkAdapter("test-key").createMemoryStore("notes", declaration); + + expect(created.id).toBe("memstore_created"); + expect(requests).toHaveLength(3); + expect(requests.every((request) => request.method === "POST")).toBe(true); + expect(requests[0]?.url).toEndWith("/memory_stores"); + expect(requests[0]?.body).toEqual({ + name: "notes", + description: "Project notes", + metadata: { owner: "test" }, + }); + expect(requests.slice(1).map((request) => request.url)).toEqual([ + `${requests[0]?.url}/memstore_created/memories`, + `${requests[0]?.url}/memstore_created/memories`, + ]); + expect(requests.slice(1).map((request) => request.body)).toEqual( + entries.map((entry) => ({ path: `/${entry.key}`, content: entry.content })), + ); +}); diff --git a/packages/sdk/tests/unit/planner.test.ts b/packages/sdk/tests/unit/planner.test.ts index d52a101..3ff85fb 100644 --- a/packages/sdk/tests/unit/planner.test.ts +++ b/packages/sdk/tests/unit/planner.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { resolve } from "node:path"; import { loadConfig } from "../../src/internal/parser/index.ts"; import { buildPlan } from "../../src/internal/planner/planner.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; import type { StateFile } from "../../src/internal/types/state.ts"; import "../../src/internal/providers/claude/index.ts"; import "../../src/internal/providers/qoder/index.ts"; @@ -13,6 +14,92 @@ const emptyState: StateFile = { resources: [], }; +const memoryConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + memory_stores: { notes: { description: "Project notes" } }, +}; + +async function matchingMemoryState(remoteId: string | null, projectConfig = memoryConfig): Promise { + const initialPlan = await buildPlan(projectConfig, emptyState); + const initialAction = initialPlan.actions.find((action) => action.address.type === "memory_store")!; + const { content_hash } = initialAction.after as { content_hash: string }; + return { + resources: [{ address: initialAction.address, remote_id: remoteId, content_hash }], + }; +} + +test.each([ + null, + "", +])("plans memory store creation when remote ID is %p even if the config hash matches", async (remoteId) => { + const plan = await buildPlan(memoryConfig, await matchingMemoryState(remoteId)); + expect(plan.actions).toHaveLength(1); + expect(plan.actions[0]?.action).toBe("create"); + expect(plan.actions[0]?.address).toEqual({ provider: "qoder", type: "memory_store", name: "notes" }); +}); + +test("keeps a memory store with a recorded remote ID unchanged when the config hash matches", async () => { + const plan = await buildPlan(memoryConfig, await matchingMemoryState("memstore_tracked")); + expect(plan.actions).toHaveLength(1); + expect(plan.actions[0]?.action).toBe("no-op"); +}); + +test.each([ + { entries: [{ key: "seed.md", content: "Edited initial content" }] }, + { entries: [{ key: "another.md", content: "Added initial content" }] }, + { entries: [] }, + { entries: undefined }, +])("does not update an existing store when initialization entries alone change to %p", async (entryChange) => { + const originalConfig: ProjectConfig = { + ...memoryConfig, + memory_stores: { + notes: { description: "Project notes", entries: [{ key: "seed.md", content: "Initial content" }] }, + }, + }; + const state = await matchingMemoryState("memstore_tracked", originalConfig); + const nextConfig: ProjectConfig = { + ...originalConfig, + memory_stores: { notes: { ...originalConfig.memory_stores!.notes!, ...entryChange } }, + }; + const plan = await buildPlan(nextConfig, state); + expect(plan.actions).toHaveLength(1); + expect(plan.actions[0]?.action).toBe("no-op"); +}); + +test("explicit initialize mode is equivalent to the omitted default", async () => { + const state = await matchingMemoryState("memstore_tracked"); + const config: ProjectConfig = { + ...memoryConfig, + memory_stores: { notes: { ...memoryConfig.memory_stores!.notes!, entries_mode: "initialize" } }, + }; + const plan = await buildPlan(config, state); + expect(plan.actions[0]?.action).toBe("no-op"); +}); + +test("plans an update when explicitly synchronized entry content changes", async () => { + const originalConfig: ProjectConfig = { + ...memoryConfig, + memory_stores: { + notes: { + description: "Project notes", + entries_mode: "sync", + entries: [{ key: "seed.md", content: "Old content" }], + }, + }, + }; + const state = await matchingMemoryState("memstore_tracked", originalConfig); + const nextConfig: ProjectConfig = { + ...originalConfig, + memory_stores: { + notes: { ...originalConfig.memory_stores!.notes!, entries: [{ key: "seed.md", content: "New content" }] }, + }, + }; + const plan = await buildPlan(nextConfig, state); + expect(plan.actions[0]?.action).toBe("update"); +}); + test("creates actions for all resources from empty state", async () => { const { config } = await loadConfig(resolve(FIXTURES, "minimal.yaml")); const plan = await buildPlan(config, emptyState); @@ -97,9 +184,7 @@ test("diagnoses Bailian MCP servers without matching tool config", async () => { }); }); -test("emits no action for a kind whose provider capability tier is unsupported", async () => { - // bailian marks memory_store unsupported in its capability matrix; the graph must - // filter it so no create action reaches the executor's throw-guarded switch. +test("plans native Bailian Memory Store creation without unsupported diagnostics", async () => { const plan = await buildPlan( { version: "1", @@ -112,11 +197,33 @@ test("emits no action for a kind whose provider capability tier is unsupported", emptyState, ); - expect(plan.actions.some((a) => a.address.type === "memory_store")).toBe(false); + expect(plan.actions).toContainEqual( + expect.objectContaining({ + action: "create", + address: { type: "memory_store", name: "notes", provider: "bailian" }, + }), + ); + expect(plan.diagnostics.some((diagnostic) => diagnostic.code === "bailian.memory_store.unsupported")).toBe(false); +}); + +test("emits no action for a kind whose provider capability tier is unsupported", async () => { + const plan = await buildPlan( + { + version: "1", + providers: { bailian: {} }, + defaults: { provider: "bailian" }, + identities: { + user: { external_id: "user_456", name: "Test user" }, + }, + }, + emptyState, + ); + + expect(plan.actions.some((action) => action.address.type === "identity")).toBe(false); expect(plan.diagnostics).toContainEqual( expect.objectContaining({ - code: "bailian.memory_store.unsupported", - resource: { type: "memory_store", name: "notes", provider: "bailian" }, + code: "bailian.identity.unsupported", + resource: { type: "identity", name: "user", provider: "bailian" }, }), ); }); diff --git a/packages/sdk/tests/unit/project-source-environment-redaction.test.ts b/packages/sdk/tests/unit/project-source-environment-redaction.test.ts new file mode 100644 index 0000000..cd7e528 --- /dev/null +++ b/packages/sdk/tests/unit/project-source-environment-redaction.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { parse, stringify } from "yaml"; +import { inspectProjectSource } from "../../src/internal/core/project-source-security.ts"; + +function environmentReference(name: string): string { + return ["$", "{", name, "}"].join(""); +} + +function projectSource(environmentVariables: Record | string, provider = "bailian"): string { + return stringify({ + version: "1", + providers: { [provider]: { api_key: "" } }, + environments: { sandbox: { environment_id: "env_synthetic", config: { type: "cloud" } } }, + agents: { + assistant: { + model: "synthetic-model", + instructions: "Keep a and info in ordinary text.", + environment: "sandbox", + }, + }, + deployments: { + daily: { + agent: "assistant", + environment: "sandbox", + initial_events: [{ type: "user.message", content: "Keep a and info in the initial event." }], + metadata: { label: "a", log_level: "info" }, + environment_variables: environmentVariables, + }, + }, + }); +} + +function previewDeployment(source: string): Record { + return parse(source).deployments.daily; +} + +describe("display-only environment variable redaction", () => { + test("keeps aliases valid when an Agent environment value defines an anchor", async () => { + const source = `version: "1" +providers: + qoder: {} +agents: + assistant: + model: synthetic-model + instructions: Keep this configuration valid. + environment_variables: + MODE: &setting normal + metadata: + label: *setting +`; + const inspected = await inspectProjectSource(source, "/tmp/display-only-environment/agents.yaml"); + const preview = parse(inspected.redacted_source).agents.assistant; + expect(inspected.diagnostics).toEqual([]); + expect(preview.environment_variables.MODE).toBe("[redacted]"); + expect(preview.metadata.label).toBe("[redacted]"); + }); + + test("keeps aliases valid when a Qoder Deployment variable string defines an anchor", async () => { + const source = `version: "1" +providers: + qoder: {} +agents: + assistant: + model: synthetic-model + instructions: Keep this configuration valid. +deployments: + daily: + agent: assistant + initial_events: + - type: user.message + content: Run the daily task. + environment_variables: &settings MODE=normal + metadata: + label: *settings +`; + const inspected = await inspectProjectSource(source, "/tmp/display-only-environment/agents.yaml"); + const preview = previewDeployment(inspected.redacted_source); + expect(inspected.diagnostics).toEqual([]); + expect(preview.environment_variables).toBe("[redacted]"); + expect(preview.metadata).toEqual({ label: "[redacted]" }); + }); + + test("masks all map literals without rewriting names, references or unrelated short values", async () => { + const variables = { + LOG_LEVEL: "info", + EMPTY: "", + KEY_WITH_A: "a", + CUSTOM_VALUE: "fake=preview=sentinel", + FROM_ENV: environmentReference("CUSTOM_VALUE"), + API_KEY: environmentReference("DEPLOYMENT_API_KEY"), + }; + const source = projectSource(variables); + const inspected = await inspectProjectSource(source, "/tmp/display-only-environment/agents.yaml"); + const preview = previewDeployment(inspected.redacted_source); + expect(preview.environment_variables).toEqual({ + LOG_LEVEL: "[redacted]", + EMPTY: "[redacted]", + KEY_WITH_A: "[redacted]", + CUSTOM_VALUE: "[redacted]", + FROM_ENV: environmentReference("CUSTOM_VALUE"), + API_KEY: environmentReference("DEPLOYMENT_API_KEY"), + }); + expect(preview.metadata).toEqual({ label: "a", log_level: "info" }); + expect(inspected.redacted_source).toContain("Keep a and info in ordinary text."); + expect(inspected.redacted_source).toContain("Keep a and info in the initial event."); + expect(inspected.redacted_source).not.toContain("fake=preview=sentinel"); + expect(inspected.sensitive_literals).toEqual([]); + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + false, + ); + expect(previewDeployment(source).environment_variables).toEqual(variables); + }); + + test("keeps credential-literal blocking independent from node-level display masking", async () => { + const credential = "fake-credential-preview-secret"; + const project = parse(projectSource({ API_KEY: credential, LOG_LEVEL: "info" })); + project.agents.assistant.instructions = `Copied credential ${credential}`; + const source = `${stringify(project)}# Copied credential ${credential}\n`; + const inspected = await inspectProjectSource(source, "/tmp/display-only-environment/agents.yaml"); + expect(previewDeployment(inspected.redacted_source).environment_variables).toEqual({ + API_KEY: "[redacted]", + LOG_LEVEL: "[redacted]", + }); + expect(previewDeployment(inspected.redacted_source).metadata).toEqual({ label: "a", log_level: "info" }); + expect(inspected.diagnostics.some((diagnostic) => diagnostic.code === "project.version.sensitive_literal")).toBe( + true, + ); + expect(inspected.sensitive_literals).toEqual(["[redacted]"]); + expect(inspected.redacted_source).not.toContain(credential); + expect(inspected.redacted_source).toContain("Copied credential [redacted]"); + expect(previewDeployment(source).environment_variables).toEqual({ API_KEY: credential, LOG_LEVEL: "info" }); + }); + + test("masks Qoder string literals while retaining complete environment references", async () => { + const literal = await inspectProjectSource( + projectSource("LOG_LEVEL=info;VALUE=fake-preview-sentinel", "qoder"), + "/tmp/display-only-environment/agents.yaml", + ); + expect(previewDeployment(literal.redacted_source).environment_variables).toBe("[redacted]"); + const reference = projectSource(environmentReference("DEPLOYMENT_VARIABLES"), "qoder"); + const inspected = await inspectProjectSource(reference, "/tmp/display-only-environment/agents.yaml"); + expect(inspected.redacted_source).toBe(reference); + expect(inspected.sensitive_literals).toEqual([]); + }); +}); diff --git a/packages/sdk/tests/unit/project-versions.test.ts b/packages/sdk/tests/unit/project-versions.test.ts index d266763..d78db4d 100644 --- a/packages/sdk/tests/unit/project-versions.test.ts +++ b/packages/sdk/tests/unit/project-versions.test.ts @@ -4,6 +4,7 @@ import { access, chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, w import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { parse } from "yaml"; import { commitPreparedProjectVersion, createProjectVersionService, @@ -23,6 +24,47 @@ afterEach(async () => { }); describe("shared project versions", () => { + test("versions anchored environment values while keeping source blobs and restored YAML intact", async () => { + const root = await temporaryDirectory(); + const configPath = join(root, "agents.yaml"); + const source = `${projectYaml("Anchored environment")} + environment_variables: + MODE: &setting normal + metadata: + label: *setting +`; + await writeFile(configPath, source); + const enabled = await enableProjectVersioning(configPath); + const version = enabled.version!; + const blobPath = join(enabled.versioning.store_root, "blobs", `${version.source_hash}.yaml`); + expect(await readFile(blobPath, "utf8")).toBe(source); + + const changedSource = source.replace("&setting normal", "&setting debug"); + await writeFile(configPath, changedSource); + const prepared = await prepareProjectVersion(configPath, changedSource); + try { + expect(prepared?.source).toBe(changedSource); + expect(prepared?.needsVersion).toBe(true); + } finally { + await releasePreparedProjectVersion(prepared); + } + + const preview = await previewProjectVersion(configPath, version.version_id); + expect(preview.can_restore).toBe(true); + for (const yaml of [preview.before_yaml, preview.after_yaml]) { + const agent = parse(yaml).agents.assistant; + expect(agent.environment_variables.MODE).toBe("[redacted]"); + expect(agent.metadata.label).toBe("[redacted]"); + } + expect(await readFile(configPath, "utf8")).toBe(changedSource); + + await restoreProjectVersion(configPath, version.version_id, { + headVersion: preview.base_head_version, + sourceRevision: preview.base_source_revision, + }); + expect(await readFile(configPath, "utf8")).toBe(source); + }); + test("does not create storage until the shared switch is explicitly enabled", async () => { const root = await temporaryDirectory(); const configPath = join(root, "agents.yaml"); diff --git a/packages/sdk/tests/unit/project-workspace.test.ts b/packages/sdk/tests/unit/project-workspace.test.ts index 8aa18eb..bb18451 100644 --- a/packages/sdk/tests/unit/project-workspace.test.ts +++ b/packages/sdk/tests/unit/project-workspace.test.ts @@ -167,7 +167,7 @@ describe("directory project build", () => { }); }); - test("infers Memory Store list references without bypassing Bailian capability validation", async () => { + test("builds native Bailian Memory Stores with inferred references and a read-only preview", async () => { const root = await temporaryProject(); await initializeDirectoryProject({ projectRoot: root }); for (const id of ["facts", "notes"]) { @@ -182,14 +182,24 @@ describe("directory project build", () => { await writeFile(agentPath, `${JSON.stringify({ ...agent, memory_stores: ["notes"] })}\n`); const before = await readFile(agentPath, "utf8"); const preview = await previewProjectBuild(root); - expect(parse(preview.canonical_yaml).agents.assistant.memory_stores).toEqual(["notes", "facts"]); + const previewConfig = parse(preview.canonical_yaml); + expect(previewConfig.agents.assistant.memory_stores).toEqual(["notes", "facts"]); + expect(previewConfig.memory_stores).toEqual({ + facts: { description: "facts" }, + notes: { description: "notes" }, + shared: { description: "Shared" }, + }); expect(preview.warnings.map((diagnostic) => diagnostic.code)).toContain("project.memory_store.agent_link.inferred"); - expect(preview.can_build).toBe(false); - expect(preview.diagnostics.map((diagnostic) => diagnostic.code)).toContain("bailian.memory_store.unsupported"); - await expect(commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision })).rejects.toThrow( - "Project contains errors", - ); + expect(preview.can_build).toBe(true); + expect(preview.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain("bailian.memory_store.unsupported"); expect(await readFile(agentPath, "utf8")).toBe(before); + await expect(stat(resolve(root, ".openagentpack/build/agents.yaml"))).rejects.toMatchObject({ code: "ENOENT" }); + + await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision }); + expect(JSON.parse(await readFile(agentPath, "utf8")).memory_stores).toEqual(["notes", "facts"]); + const builtConfig = parse(await readFile(resolve(root, ".openagentpack/build/agents.yaml"), "utf8")); + expect(builtConfig.agents.assistant.memory_stores).toEqual(["notes", "facts"]); + expect(builtConfig.memory_stores).toEqual(previewConfig.memory_stores); }); for (const type of ["environment", "vault"] as const) { diff --git a/packages/sdk/tests/unit/session-environment-dto.test.ts b/packages/sdk/tests/unit/session-environment-dto.test.ts new file mode 100644 index 0000000..4e2ad82 --- /dev/null +++ b/packages/sdk/tests/unit/session-environment-dto.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateSessionRequestSchema, + CreateSessionResponseSchema, + SessionSchema, +} from "../../src/internal/types/dto.ts"; + +for (const { name, schema, requiredFields } of [ + { name: "SessionSchema", schema: SessionSchema, requiredFields: { session_id: "sess_1" } }, + { name: "CreateSessionRequestSchema", schema: CreateSessionRequestSchema, requiredFields: { agent_id: "agent_1" } }, + { + name: "CreateSessionResponseSchema", + schema: CreateSessionResponseSchema, + requiredFields: { session_id: "sess_1" }, + }, +]) { + describe(`${name} environment variables`, () => { + test.each([{ FEATURE_FLAG: "on", COMPLEX_VALUE: "alpha=beta;中文", EMPTY_VALUE: "" }, {}] satisfies Record< + string, + string + >[])("preserves string maps %j", (environmentVariables) => { + const parsed = schema.parse({ ...requiredFields, environment_variables: environmentVariables }); + expect(parsed.environment_variables).toEqual(environmentVariables); + }); + + test("accepts payloads without environment variables", () => { + expect(schema.parse(requiredFields)).not.toHaveProperty("environment_variables"); + }); + + test.each([ + { environmentVariables: null }, + { environmentVariables: "FEATURE_FLAG=on" }, + { environmentVariables: ["on"] }, + { environmentVariables: { FEATURE_FLAG: 1 } }, + ])("rejects non-string maps %j", ({ environmentVariables }) => { + expect(schema.safeParse({ ...requiredFields, environment_variables: environmentVariables }).success).toBe(false); + }); + }); +} diff --git a/packages/sdk/tests/unit/session-manager.test.ts b/packages/sdk/tests/unit/session-manager.test.ts index 08bb18a..ccbcc51 100644 --- a/packages/sdk/tests/unit/session-manager.test.ts +++ b/packages/sdk/tests/unit/session-manager.test.ts @@ -76,6 +76,55 @@ function makeState(): StateManager { } describe("buildSessionBindings", () => { + test.each([{ FEATURE_FLAG: "on", COMPLEX_VALUE: "alpha=beta;中文" }, {}] satisfies Record< + string, + string + >[])("accepts explicit Bailian Session environment variables %j", (environmentVariables) => { + const state = makeState(); + state.setResource({ + address: { type: "agent", name: "minimal", provider: "bailian" }, + remote_id: "agent_bailian", + content_hash: "h", + }); + const config = makeConfig({ providers: { bailian: {} } }); + config.agents!.minimal!.environment_variables = { AGENT_DEFAULT: "not-injected" }; + const bindings = buildSessionBindings("minimal", config, "bailian", state, { + environmentId: "env_bailian", + environmentVariables, + }); + expect(bindings.environment_variables).toEqual(environmentVariables); + }); + + for (const provider of ["bailian", "claude", "ark"]) { + test.each([{ AGENT_DEFAULT: "not-injected" }, {}] satisfies Record< + string, + string + >[])(`rejects Agent environment variable defaults on ${provider} %j`, (environmentVariables) => { + const config = makeConfig(); + config.agents!.minimal!.environment_variables = environmentVariables; + expect(() => + buildSessionBindings("minimal", config, provider, makeState(), { environmentId: "env_remote" }), + ).toThrow("Agent environment_variables defaults are supported only by Qoder"); + }); + } + + test.each(["claude", "ark"])("rejects explicit Session environment variables on %s", (provider) => { + expect(() => + buildSessionBindings("minimal", makeConfig(), provider, makeState(), { + environmentVariables: { FEATURE_FLAG: "on" }, + }), + ).toThrow("Session environment variables are supported only by Bailian and Qoder"); + }); + + test("explicit Qoder Session environment variables override declared defaults", () => { + const config = makeConfig(); + config.agents!.minimal!.environment_variables = { DEFAULT: "value" }; + const bindings = buildSessionBindings("minimal", config, "qoder", makeState(), { + environmentVariables: { SESSION: "value" }, + }); + expect(bindings.environment_variables).toEqual({ SESSION: "value" }); + }); + test("inherits environment, vault, memory_stores from agent declaration", () => { const config = makeConfig(); config.agents!.researcher!.environment_variables = { FEATURE_FLAG: "on" }; diff --git a/packages/sdk/tests/unit/validate-config.test.ts b/packages/sdk/tests/unit/validate-config.test.ts index 2bf189a..53d19ea 100644 --- a/packages/sdk/tests/unit/validate-config.test.ts +++ b/packages/sdk/tests/unit/validate-config.test.ts @@ -98,6 +98,15 @@ test("validates tunnel references and limits tunnels to Qoder", () => { expect(diagnostics.some((d) => d.code === "claude.agent.tunnel.unsupported")).toBe(true); }); +test("continues rejecting Bailian Agent environment variable defaults", () => { + const config = fixture(); + config.agents!.assistant!.environment_variables = { FEATURE_FLAG: "on" }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((diagnostic) => diagnostic.code === "bailian.agent.environment_variables.unsupported")).toBe( + true, + ); +}); + test("limits Agent environment variables to Qoder", () => { const config: ProjectConfig = { version: "1", diff --git a/scripts/braces-security.test.ts b/scripts/braces-security.test.ts new file mode 100644 index 0000000..ebcad1c --- /dev/null +++ b/scripts/braces-security.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const braces = require("braces"); + +type BraceNode = { + type: string; + value?: string; + nodes?: BraceNode[]; + parent?: BraceNode; + queue?: unknown[]; +}; + +function nestedAst(depth: number): BraceNode { + let node: BraceNode = { type: "text", value: "x" }; + for (let level = 0; level < depth; level++) node = { type: "root", nodes: [node] }; + return node; +} + +describe("vendored braces security backport", () => { + test("the Changesets dependency chain loads the guarded braces package", () => { + let consumerRequire = createRequire(require.resolve("@changesets/cli/package.json")); + for (const dependency of ["@changesets/apply-release-plan", "@changesets/git", "micromatch"]) { + consumerRequire = createRequire(consumerRequire.resolve(dependency)); + } + const consumedBraces = consumerRequire("braces"); + expect(consumerRequire("braces/package.json").name).toBe("@openagentpack/braces"); + expect(consumedBraces.expand("{a,b}")).toEqual(["a", "b"]); + expect(() => consumedBraces(`${"{".repeat(4000)}x${"}".repeat(4000)}`)).toThrow(SyntaxError); + }); + + test("preserves the expected public API and normal expansions", () => { + expect(braces("a/{b,c}/d")).toEqual(["a/(b|c)/d"]); + expect(braces.compile("a/{b,c}/d")).toBe("a/(b|c)/d"); + expect(braces.expand("a/{1..3}/{x,y}")).toEqual(["a/1/x", "a/1/y", "a/2/x", "a/2/y", "a/3/x", "a/3/y"]); + expect(braces.expand("{a,{b,c}}", { nodupes: true })).toEqual(["a", "b", "c"]); + expect(braces.expand("{a,,a,b}", { noempty: true, nodupes: true })).toEqual(["a", "b"]); + expect(braces.expand("{03..01}")).toEqual(["03", "02", "01"]); + expect(braces.expand("{1..5..2}")).toEqual(["1", "3", "5"]); + expect(braces.expand("{a..c}")).toEqual(["a", "b", "c"]); + expect(braces.expand("{}")).toEqual(["{}"]); + expect(braces.expand("\\{a,b\\}")).toEqual(["{a,b}"]); + expect(braces("")).toEqual([""]); + expect(braces([])).toEqual([]); + expect(braces.stringify(braces.parse("x/{a,b}/(c)"))).toBe("x/{a,b}/(c)"); + }); + + for (const operation of ["parse", "compile", "expand", "stringify", "create"] as const) { + test(`${operation} rejects deeply nested strings under the existing character limit`, () => { + for (const pattern of [ + `${"{".repeat(4000)}a,b${"}".repeat(4000)}`, + `${"(".repeat(4000)}x${")".repeat(4000)}`, + `${"{(".repeat(2000)}x${")}".repeat(2000)}`, + `${"{".repeat(4000)}x`, + ]) + expect(() => braces[operation](pattern)).toThrow(SyntaxError); + }); + } + + test("the callable API also rejects deep patterns", () => { + const pattern = `${"{".repeat(4000)}x${"}".repeat(4000)}`; + expect(() => braces(pattern)).toThrow(SyntaxError); + expect(() => braces(pattern, { expand: true })).toThrow(SyntaxError); + expect(() => braces(["normal", pattern])).toThrow(SyntaxError); + }); + + test("counts mixed braces and parentheses towards the fixed parser limit", () => { + const accepted = `${"{(".repeat(64)}x${")}".repeat(64)}`; + expect(() => braces.parse(accepted)).not.toThrow(); + expect(() => braces.compile(accepted)).not.toThrow(); + expect(() => braces.parse(`(${accepted})`)).toThrow(SyntaxError); + expect(() => braces.parse("(".repeat(129), { maxDepth: Infinity })).toThrow(SyntaxError); + }); + + for (const operation of ["compile", "expand", "stringify"] as const) { + test(`${operation} rejects deep and cyclic hand-built ASTs`, () => { + expect(() => braces[operation](nestedAst(12000))).toThrow(SyntaxError); + const direct: BraceNode = { type: "root", nodes: [] }; + direct.nodes!.push(direct); + expect(() => braces[operation](direct)).toThrow(SyntaxError); + const indirect: BraceNode = { type: "root", nodes: [] }; + indirect.nodes!.push({ type: "root", nodes: [indirect] }); + expect(() => braces[operation](indirect)).toThrow(SyntaxError); + }); + + test(`${operation} enforces the child-edge depth boundary`, () => { + expect(() => braces[operation](nestedAst(129))).not.toThrow(); + expect(() => braces[operation](nestedAst(130))).toThrow(SyntaxError); + }); + + test(`${operation} bounds repeated visits through a shared DAG`, () => { + let node: BraceNode = { type: "text", value: "x" }; + for (let level = 0; level < 24; level++) node = { type: "root", nodes: [node, node] }; + expect(() => braces[operation](node)).toThrow(SyntaxError); + }); + + test(`${operation} permits ordinary shared acyclic leaves`, () => { + const leaf: BraceNode = { type: "text", value: "a" }; + const result = braces[operation]({ type: "root", nodes: [leaf, leaf] }); + expect(result).toEqual(operation === "expand" ? ["aa"] : "aa"); + }); + + test(`${operation} rejects malformed child lists and recursive value arrays`, () => { + expect(() => braces[operation]({ type: "root", nodes: "bad" })).toThrow(SyntaxError); + const recursive: unknown[] = []; + recursive.push(recursive); + expect(() => braces[operation]({ type: "root", nodes: [{ type: "text", value: recursive }] })).toThrow( + SyntaxError, + ); + }); + } + + test("expansion ignores cyclic parent metadata and external queues", () => { + const foreignQueue: unknown[] = []; + foreignQueue.push(foreignQueue); + const child: BraceNode = { type: "paren", nodes: [{ type: "text", value: "x" }] }; + child.parent = child; + child.queue = foreignQueue; + expect(braces.expand({ type: "root", nodes: [child] })).toEqual(["x"]); + expect(foreignQueue[0]).toBe(foreignQueue); + const untrustedParent: BraceNode = { type: "paren", nodes: [{ type: "text", value: "y" }] }; + Object.defineProperty(untrustedParent, "parent", { + get: () => { + throw new Error("parent must not be read"); + }, + }); + expect(braces.expand({ type: "root", nodes: [untrustedParent] })).toEqual(["y"]); + }); + + test("expands a standalone parenthesis subtree without stale parent queues", () => { + const parsed = braces.parse("(a)"); + expect(braces.expand(parsed.nodes[1])).toEqual(["(a)"]); + }); +}); diff --git a/tools/architecture/ast-grep-rules/no-host-dto-redefinition-tsx.yml b/tools/architecture/ast-grep-rules/no-host-dto-redefinition-tsx.yml index 0adfc22..81ace5b 100644 --- a/tools/architecture/ast-grep-rules/no-host-dto-redefinition-tsx.yml +++ b/tools/architecture/ast-grep-rules/no-host-dto-redefinition-tsx.yml @@ -13,5 +13,5 @@ rule: - pattern: const $NAME = z.union($$$ARGS) constraints: NAME: - regex: ^(ResourceAddress|Diagnostic|ActionType|DriftKind|PlannedAction|AgentReadinessStatus|AgentDriftSeverity|AgentSkillRef|AgentModel|AgentDefinition|AgentReadiness|AgentWithReadiness|AgentRecoveryAction|SessionEventType|SafeSessionEvent|SafeSessionEventList|AgentSyncStatus|AgentSyncResult|AgentSyncRun|SessionContentBlock|SessionEvent|Session|CreateSessionRequest|CreateSessionResponse|GetSessionRequest|ListSessionsRequest|ListSessionsResponse|ListSessionEventsRequest|ListSessionEventsResponse|SendEventRequest|SendEventResponse)(Schema)?$ + regex: ^(ResourceAddress|Diagnostic|ActionType|DriftKind|PlannedAction|AgentReadinessStatus|AgentDriftSeverity|AgentSkillRef|AgentModel|AgentDefinition|AgentReadiness|AgentWithReadiness|AgentRecoveryAction|SessionEventType|SafeSessionEvent|SafeSessionEventList|AgentSyncStatus|AgentSyncResult|AgentSyncRun|SessionContentBlock|SessionEvent|Session|CreateSessionRequest|CreateSessionResponse|GetSessionRequest|ListSessionsRequest|ListSessionsResponse|ListSessionEventsRequest|ListSessionEventsResponse|SendEventRequest|SendEventResponse|CreateWebhookEndpointInput|UpdateWebhookEndpointInput|WebhookEndpoint|WebhookEndpointWithSecret|WebhookEndpointList|WebhookDeleteResult|WebhookSecretResetResult|WebhookEventData|WebhookEvent|WebhookTestResult|WebhookDelivery|WebhookEventWithDelivery|WebhookEventPage)(Schema)?$ note: This mirrors no-host-dto-redefinition for TSX files (including the session-runtime owned names; `SessionAgent` is omitted for the same reason). diff --git a/tools/architecture/ast-grep-rules/no-host-dto-redefinition.yml b/tools/architecture/ast-grep-rules/no-host-dto-redefinition.yml index 62f51bf..38c1cea 100644 --- a/tools/architecture/ast-grep-rules/no-host-dto-redefinition.yml +++ b/tools/architecture/ast-grep-rules/no-host-dto-redefinition.yml @@ -14,7 +14,7 @@ rule: - pattern: const $NAME = z.union($$$ARGS) constraints: NAME: - regex: ^(ResourceAddress|Diagnostic|ActionType|DriftKind|PlannedAction|AgentReadinessStatus|AgentDriftSeverity|AgentSkillRef|AgentModel|AgentDefinition|AgentReadiness|AgentWithReadiness|AgentRecoveryAction|SessionEventType|SafeSessionEvent|SafeSessionEventList|AgentSyncStatus|AgentSyncResult|AgentSyncRun|SessionContentBlock|SessionEvent|Session|CreateSessionRequest|CreateSessionResponse|GetSessionRequest|ListSessionsRequest|ListSessionsResponse|ListSessionEventsRequest|ListSessionEventsResponse|SendEventRequest|SendEventResponse)(Schema)?$ + regex: ^(ResourceAddress|Diagnostic|ActionType|DriftKind|PlannedAction|AgentReadinessStatus|AgentDriftSeverity|AgentSkillRef|AgentModel|AgentDefinition|AgentReadiness|AgentWithReadiness|AgentRecoveryAction|SessionEventType|SafeSessionEvent|SafeSessionEventList|AgentSyncStatus|AgentSyncResult|AgentSyncRun|SessionContentBlock|SessionEvent|Session|CreateSessionRequest|CreateSessionResponse|GetSessionRequest|ListSessionsRequest|ListSessionsResponse|ListSessionEventsRequest|ListSessionEventsResponse|SendEventRequest|SendEventResponse|CreateWebhookEndpointInput|UpdateWebhookEndpointInput|WebhookEndpoint|WebhookEndpointWithSecret|WebhookEndpointList|WebhookDeleteResult|WebhookSecretResetResult|WebhookEventData|WebhookEvent|WebhookTestResult|WebhookDelivery|WebhookEventWithDelivery|WebhookEventPage)(Schema)?$ note: >- This rule is deliberately name-based and scoped outside packages/sdk. New shared DTOs should be added to @openagentpack/sdk and, when necessary, to this rule's owned-name diff --git a/vendor/braces/LICENSE b/vendor/braces/LICENSE new file mode 100644 index 0000000..9af4a67 --- /dev/null +++ b/vendor/braces/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-present, Jon Schlinkert. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/braces/README.md b/vendor/braces/README.md new file mode 100644 index 0000000..3e9586c --- /dev/null +++ b/vendor/braces/README.md @@ -0,0 +1,586 @@ +# braces [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=W8YFZ425KND68) [![NPM version](https://img.shields.io/npm/v/braces.svg?style=flat)](https://www.npmjs.com/package/braces) [![NPM monthly downloads](https://img.shields.io/npm/dm/braces.svg?style=flat)](https://npmjs.org/package/braces) [![NPM total downloads](https://img.shields.io/npm/dt/braces.svg?style=flat)](https://npmjs.org/package/braces) [![Linux Build Status](https://img.shields.io/travis/micromatch/braces.svg?style=flat&label=Travis)](https://travis-ci.org/micromatch/braces) + +> Bash-like brace expansion, implemented in JavaScript. Safer than other brace expansion libs, with complete support for the Bash 4.3 braces specification, without sacrificing speed. + +Please consider following this project's author, [Jon Schlinkert](https://github.com/jonschlinkert), and consider starring the project to show your :heart: and support. + +## Install + +Install with [npm](https://www.npmjs.com/): + +```sh +$ npm install --save braces +``` + +## v3.0.0 Released!! + +See the [changelog](https://github.com/micromatch/braces/blob/3.0.3/CHANGELOG.md) for details. + +## Why use braces? + +Brace patterns make globs more powerful by adding the ability to match specific ranges and sequences of characters. + +- **Accurate** - complete support for the [Bash 4.3 Brace Expansion](https://www.gnu.org/software/bash/) specification (passes all of the Bash braces tests) +- **[fast and performant](#benchmarks)** - Starts fast, runs fast and [scales well](#performance) as patterns increase in complexity. +- **Organized code base** - The parser and compiler are easy to maintain and update when edge cases crop up. +- **Well-tested** - Thousands of test assertions, and passes all of the Bash, minimatch, and [brace-expansion](https://github.com/juliangruber/brace-expansion) unit tests (as of the date this was written). +- **Safer** - You shouldn't have to worry about users defining aggressive or malicious brace patterns that can break your application. Braces takes measures to prevent malicious regex that can be used for DDoS attacks (see [catastrophic backtracking](https://www.regular-expressions.info/catastrophic.html)). +- [Supports lists](#lists) - (aka "sets") `a/{b,c}/d` => `['a/b/d', 'a/c/d']` +- [Supports sequences](#sequences) - (aka "ranges") `{01..03}` => `['01', '02', '03']` +- [Supports steps](#steps) - (aka "increments") `{2..10..2}` => `['2', '4', '6', '8', '10']` +- [Supports escaping](#escaping) - To prevent evaluation of special characters. + +## Usage + +The main export is a function that takes one or more brace `patterns` and `options`. + +```js +const braces = require('braces'); +// braces(patterns[, options]); + +console.log(braces(['{01..05}', '{a..e}'])); +//=> ['(0[1-5])', '([a-e])'] + +console.log(braces(['{01..05}', '{a..e}'], { expand: true })); +//=> ['01', '02', '03', '04', '05', 'a', 'b', 'c', 'd', 'e'] +``` + +### Brace Expansion vs. Compilation + +By default, brace patterns are compiled into strings that are optimized for creating regular expressions and matching. + +**Compiled** + +```js +console.log(braces('a/{x,y,z}/b')); +//=> ['a/(x|y|z)/b'] +console.log(braces(['a/{01..20}/b', 'a/{1..5}/b'])); +//=> [ 'a/(0[1-9]|1[0-9]|20)/b', 'a/([1-5])/b' ] +``` + +**Expanded** + +Enable brace expansion by setting the `expand` option to true, or by using [braces.expand()](#expand) (returns an array similar to what you'd expect from Bash, or `echo {1..5}`, or [minimatch](https://github.com/isaacs/minimatch)): + +```js +console.log(braces('a/{x,y,z}/b', { expand: true })); +//=> ['a/x/b', 'a/y/b', 'a/z/b'] + +console.log(braces.expand('{01..10}')); +//=> ['01','02','03','04','05','06','07','08','09','10'] +``` + +### Lists + +Expand lists (like Bash "sets"): + +```js +console.log(braces('a/{foo,bar,baz}/*.js')); +//=> ['a/(foo|bar|baz)/*.js'] + +console.log(braces.expand('a/{foo,bar,baz}/*.js')); +//=> ['a/foo/*.js', 'a/bar/*.js', 'a/baz/*.js'] +``` + +### Sequences + +Expand ranges of characters (like Bash "sequences"): + +```js +console.log(braces.expand('{1..3}')); // ['1', '2', '3'] +console.log(braces.expand('a/{1..3}/b')); // ['a/1/b', 'a/2/b', 'a/3/b'] +console.log(braces('{a..c}', { expand: true })); // ['a', 'b', 'c'] +console.log(braces('foo/{a..c}', { expand: true })); // ['foo/a', 'foo/b', 'foo/c'] + +// supports zero-padded ranges +console.log(braces('a/{01..03}/b')); //=> ['a/(0[1-3])/b'] +console.log(braces('a/{001..300}/b')); //=> ['a/(0{2}[1-9]|0[1-9][0-9]|[12][0-9]{2}|300)/b'] +``` + +See [fill-range](https://github.com/jonschlinkert/fill-range) for all available range-expansion options. + +### Steppped ranges + +Steps, or increments, may be used with ranges: + +```js +console.log(braces.expand('{2..10..2}')); +//=> ['2', '4', '6', '8', '10'] + +console.log(braces('{2..10..2}')); +//=> ['(2|4|6|8|10)'] +``` + +When the [.optimize](#optimize) method is used, or [options.optimize](#optionsoptimize) is set to true, sequences are passed to [to-regex-range](https://github.com/jonschlinkert/to-regex-range) for expansion. + +### Nesting + +Brace patterns may be nested. The results of each expanded string are not sorted, and left to right order is preserved. + +**"Expanded" braces** + +```js +console.log(braces.expand('a{b,c,/{x,y}}/e')); +//=> ['ab/e', 'ac/e', 'a/x/e', 'a/y/e'] + +console.log(braces.expand('a/{x,{1..5},y}/c')); +//=> ['a/x/c', 'a/1/c', 'a/2/c', 'a/3/c', 'a/4/c', 'a/5/c', 'a/y/c'] +``` + +**"Optimized" braces** + +```js +console.log(braces('a{b,c,/{x,y}}/e')); +//=> ['a(b|c|/(x|y))/e'] + +console.log(braces('a/{x,{1..5},y}/c')); +//=> ['a/(x|([1-5])|y)/c'] +``` + +### Escaping + +**Escaping braces** + +A brace pattern will not be expanded or evaluted if _either the opening or closing brace is escaped_: + +```js +console.log(braces.expand('a\\{d,c,b}e')); +//=> ['a{d,c,b}e'] + +console.log(braces.expand('a{d,c,b\\}e')); +//=> ['a{d,c,b}e'] +``` + +**Escaping commas** + +Commas inside braces may also be escaped: + +```js +console.log(braces.expand('a{b\\,c}d')); +//=> ['a{b,c}d'] + +console.log(braces.expand('a{d\\,c,b}e')); +//=> ['ad,ce', 'abe'] +``` + +**Single items** + +Following bash conventions, a brace pattern is also not expanded when it contains a single character: + +```js +console.log(braces.expand('a{b}c')); +//=> ['a{b}c'] +``` + +## Options + +### options.maxLength + +**Type**: `Number` + +**Default**: `10,000` + +**Description**: Limit the length of the input string. Useful when the input string is generated or your application allows users to pass a string, et cetera. + +```js +console.log(braces('a/{b,c}/d', { maxLength: 3 })); //=> throws an error +``` + +### options.expand + +**Type**: `Boolean` + +**Default**: `undefined` + +**Description**: Generate an "expanded" brace pattern (alternatively you can use the `braces.expand()` method, which does the same thing). + +```js +console.log(braces('a/{b,c}/d', { expand: true })); +//=> [ 'a/b/d', 'a/c/d' ] +``` + +### options.nodupes + +**Type**: `Boolean` + +**Default**: `undefined` + +**Description**: Remove duplicates from the returned array. + +### options.rangeLimit + +**Type**: `Number` + +**Default**: `1000` + +**Description**: To prevent malicious patterns from being passed by users, an error is thrown when `braces.expand()` is used or `options.expand` is true and the generated range will exceed the `rangeLimit`. + +You can customize `options.rangeLimit` or set it to `Inifinity` to disable this altogether. + +**Examples** + +```js +// pattern exceeds the "rangeLimit", so it's optimized automatically +console.log(braces.expand('{1..1000}')); +//=> ['([1-9]|[1-9][0-9]{1,2}|1000)'] + +// pattern does not exceed "rangeLimit", so it's NOT optimized +console.log(braces.expand('{1..100}')); +//=> ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '100'] +``` + +### options.transform + +**Type**: `Function` + +**Default**: `undefined` + +**Description**: Customize range expansion. + +**Example: Transforming non-numeric values** + +```js +const alpha = braces.expand('x/{a..e}/y', { + transform(value, index) { + // When non-numeric values are passed, "value" is a character code. + return 'foo/' + String.fromCharCode(value) + '-' + index; + }, +}); +console.log(alpha); +//=> [ 'x/foo/a-0/y', 'x/foo/b-1/y', 'x/foo/c-2/y', 'x/foo/d-3/y', 'x/foo/e-4/y' ] +``` + +**Example: Transforming numeric values** + +```js +const numeric = braces.expand('{1..5}', { + transform(value) { + // when numeric values are passed, "value" is a number + return 'foo/' + value * 2; + }, +}); +console.log(numeric); +//=> [ 'foo/2', 'foo/4', 'foo/6', 'foo/8', 'foo/10' ] +``` + +### options.quantifiers + +**Type**: `Boolean` + +**Default**: `undefined` + +**Description**: In regular expressions, quanitifiers can be used to specify how many times a token can be repeated. For example, `a{1,3}` will match the letter `a` one to three times. + +Unfortunately, regex quantifiers happen to share the same syntax as [Bash lists](#lists) + +The `quantifiers` option tells braces to detect when [regex quantifiers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#quantifiers) are defined in the given pattern, and not to try to expand them as lists. + +**Examples** + +```js +const braces = require('braces'); +console.log(braces('a/b{1,3}/{x,y,z}')); +//=> [ 'a/b(1|3)/(x|y|z)' ] +console.log(braces('a/b{1,3}/{x,y,z}', { quantifiers: true })); +//=> [ 'a/b{1,3}/(x|y|z)' ] +console.log(braces('a/b{1,3}/{x,y,z}', { quantifiers: true, expand: true })); +//=> [ 'a/b{1,3}/x', 'a/b{1,3}/y', 'a/b{1,3}/z' ] +``` + +### options.keepEscaping + +**Type**: `Boolean` + +**Default**: `undefined` + +**Description**: Do not strip backslashes that were used for escaping from the result. + +## What is "brace expansion"? + +Brace expansion is a type of parameter expansion that was made popular by unix shells for generating lists of strings, as well as regex-like matching when used alongside wildcards (globs). + +In addition to "expansion", braces are also used for matching. In other words: + +- [brace expansion](#brace-expansion) is for generating new lists +- [brace matching](#brace-matching) is for filtering existing lists + +
+More about brace expansion (click to expand) + +There are two main types of brace expansion: + +1. **lists**: which are defined using comma-separated values inside curly braces: `{a,b,c}` +2. **sequences**: which are defined using a starting value and an ending value, separated by two dots: `a{1..3}b`. Optionally, a third argument may be passed to define a "step" or increment to use: `a{1..100..10}b`. These are also sometimes referred to as "ranges". + +Here are some example brace patterns to illustrate how they work: + +**Sets** + +``` +{a,b,c} => a b c +{a,b,c}{1,2} => a1 a2 b1 b2 c1 c2 +``` + +**Sequences** + +``` +{1..9} => 1 2 3 4 5 6 7 8 9 +{4..-4} => 4 3 2 1 0 -1 -2 -3 -4 +{1..20..3} => 1 4 7 10 13 16 19 +{a..j} => a b c d e f g h i j +{j..a} => j i h g f e d c b a +{a..z..3} => a d g j m p s v y +``` + +**Combination** + +Sets and sequences can be mixed together or used along with any other strings. + +``` +{a,b,c}{1..3} => a1 a2 a3 b1 b2 b3 c1 c2 c3 +foo/{a,b,c}/bar => foo/a/bar foo/b/bar foo/c/bar +``` + +The fact that braces can be "expanded" from relatively simple patterns makes them ideal for quickly generating test fixtures, file paths, and similar use cases. + +## Brace matching + +In addition to _expansion_, brace patterns are also useful for performing regular-expression-like matching. + +For example, the pattern `foo/{1..3}/bar` would match any of following strings: + +``` +foo/1/bar +foo/2/bar +foo/3/bar +``` + +But not: + +``` +baz/1/qux +baz/2/qux +baz/3/qux +``` + +Braces can also be combined with [glob patterns](https://github.com/jonschlinkert/micromatch) to perform more advanced wildcard matching. For example, the pattern `*/{1..3}/*` would match any of following strings: + +``` +foo/1/bar +foo/2/bar +foo/3/bar +baz/1/qux +baz/2/qux +baz/3/qux +``` + +## Brace matching pitfalls + +Although brace patterns offer a user-friendly way of matching ranges or sets of strings, there are also some major disadvantages and potential risks you should be aware of. + +### tldr + +**"brace bombs"** + +- brace expansion can eat up a huge amount of processing resources +- as brace patterns increase _linearly in size_, the system resources required to expand the pattern increase exponentially +- users can accidentally (or intentially) exhaust your system's resources resulting in the equivalent of a DoS attack (bonus: no programming knowledge is required!) + +For a more detailed explanation with examples, see the [geometric complexity](#geometric-complexity) section. + +### The solution + +Jump to the [performance section](#performance) to see how Braces solves this problem in comparison to other libraries. + +### Geometric complexity + +At minimum, brace patterns with sets limited to two elements have quadradic or `O(n^2)` complexity. But the complexity of the algorithm increases exponentially as the number of sets, _and elements per set_, increases, which is `O(n^c)`. + +For example, the following sets demonstrate quadratic (`O(n^2)`) complexity: + +``` +{1,2}{3,4} => (2X2) => 13 14 23 24 +{1,2}{3,4}{5,6} => (2X2X2) => 135 136 145 146 235 236 245 246 +``` + +But add an element to a set, and we get a n-fold Cartesian product with `O(n^c)` complexity: + +``` +{1,2,3}{4,5,6}{7,8,9} => (3X3X3) => 147 148 149 157 158 159 167 168 169 247 248 + 249 257 258 259 267 268 269 347 348 349 357 + 358 359 367 368 369 +``` + +Now, imagine how this complexity grows given that each element is a n-tuple: + +``` +{1..100}{1..100} => (100X100) => 10,000 elements (38.4 kB) +{1..100}{1..100}{1..100} => (100X100X100) => 1,000,000 elements (5.76 MB) +``` + +Although these examples are clearly contrived, they demonstrate how brace patterns can quickly grow out of control. + +**More information** + +Interested in learning more about brace expansion? + +- [linuxjournal/bash-brace-expansion](http://www.linuxjournal.com/content/bash-brace-expansion) +- [rosettacode/Brace_expansion](https://rosettacode.org/wiki/Brace_expansion) +- [cartesian product](https://en.wikipedia.org/wiki/Cartesian_product) + +
+ +## Performance + +Braces is not only screaming fast, it's also more accurate the other brace expansion libraries. + +### Better algorithms + +Fortunately there is a solution to the ["brace bomb" problem](#brace-matching-pitfalls): _don't expand brace patterns into an array when they're used for matching_. + +Instead, convert the pattern into an optimized regular expression. This is easier said than done, and braces is the only library that does this currently. + +**The proof is in the numbers** + +Minimatch gets exponentially slower as patterns increase in complexity, braces does not. The following results were generated using `braces()` and `minimatch.braceExpand()`, respectively. + +| **Pattern** | **braces** | **[minimatch][]** | +| --------------------------- | ------------------- | ---------------------------- | +| `{1..9007199254740991}`[^1] | `298 B` (5ms 459μs) | N/A (freezes) | +| `{1..1000000000000000}` | `41 B` (1ms 15μs) | N/A (freezes) | +| `{1..100000000000000}` | `40 B` (890μs) | N/A (freezes) | +| `{1..10000000000000}` | `39 B` (2ms 49μs) | N/A (freezes) | +| `{1..1000000000000}` | `38 B` (608μs) | N/A (freezes) | +| `{1..100000000000}` | `37 B` (397μs) | N/A (freezes) | +| `{1..10000000000}` | `35 B` (983μs) | N/A (freezes) | +| `{1..1000000000}` | `34 B` (798μs) | N/A (freezes) | +| `{1..100000000}` | `33 B` (733μs) | N/A (freezes) | +| `{1..10000000}` | `32 B` (5ms 632μs) | `78.89 MB` (16s 388ms 569μs) | +| `{1..1000000}` | `31 B` (1ms 381μs) | `6.89 MB` (1s 496ms 887μs) | +| `{1..100000}` | `30 B` (950μs) | `588.89 kB` (146ms 921μs) | +| `{1..10000}` | `29 B` (1ms 114μs) | `48.89 kB` (14ms 187μs) | +| `{1..1000}` | `28 B` (760μs) | `3.89 kB` (1ms 453μs) | +| `{1..100}` | `22 B` (345μs) | `291 B` (196μs) | +| `{1..10}` | `10 B` (533μs) | `20 B` (37μs) | +| `{1..3}` | `7 B` (190μs) | `5 B` (27μs) | + +### Faster algorithms + +When you need expansion, braces is still much faster. + +_(the following results were generated using `braces.expand()` and `minimatch.braceExpand()`, respectively)_ + +| **Pattern** | **braces** | **[minimatch][]** | +| --------------- | --------------------------- | ---------------------------- | +| `{1..10000000}` | `78.89 MB` (2s 698ms 642μs) | `78.89 MB` (18s 601ms 974μs) | +| `{1..1000000}` | `6.89 MB` (458ms 576μs) | `6.89 MB` (1s 491ms 621μs) | +| `{1..100000}` | `588.89 kB` (20ms 728μs) | `588.89 kB` (156ms 919μs) | +| `{1..10000}` | `48.89 kB` (2ms 202μs) | `48.89 kB` (13ms 641μs) | +| `{1..1000}` | `3.89 kB` (1ms 796μs) | `3.89 kB` (1ms 958μs) | +| `{1..100}` | `291 B` (424μs) | `291 B` (211μs) | +| `{1..10}` | `20 B` (487μs) | `20 B` (72μs) | +| `{1..3}` | `5 B` (166μs) | `5 B` (27μs) | + +If you'd like to run these comparisons yourself, see [test/support/generate.js](https://github.com/micromatch/braces/blob/3.0.3/test/support/generate.js). + +## Benchmarks + +### Running benchmarks + +Install dev dependencies: + +```bash +npm i -d && npm benchmark +``` + +### Latest results + +Braces is more accurate, without sacrificing performance. + +```bash +● expand - range (expanded) + braces x 53,167 ops/sec ±0.12% (102 runs sampled) + minimatch x 11,378 ops/sec ±0.10% (102 runs sampled) +● expand - range (optimized for regex) + braces x 373,442 ops/sec ±0.04% (100 runs sampled) + minimatch x 3,262 ops/sec ±0.18% (100 runs sampled) +● expand - nested ranges (expanded) + braces x 33,921 ops/sec ±0.09% (99 runs sampled) + minimatch x 10,855 ops/sec ±0.28% (100 runs sampled) +● expand - nested ranges (optimized for regex) + braces x 287,479 ops/sec ±0.52% (98 runs sampled) + minimatch x 3,219 ops/sec ±0.28% (101 runs sampled) +● expand - set (expanded) + braces x 238,243 ops/sec ±0.19% (97 runs sampled) + minimatch x 538,268 ops/sec ±0.31% (96 runs sampled) +● expand - set (optimized for regex) + braces x 321,844 ops/sec ±0.10% (97 runs sampled) + minimatch x 140,600 ops/sec ±0.15% (100 runs sampled) +● expand - nested sets (expanded) + braces x 165,371 ops/sec ±0.42% (96 runs sampled) + minimatch x 337,720 ops/sec ±0.28% (100 runs sampled) +● expand - nested sets (optimized for regex) + braces x 242,948 ops/sec ±0.12% (99 runs sampled) + minimatch x 87,403 ops/sec ±0.79% (96 runs sampled) +``` + +## About + +
+Contributing + +Pull requests and stars are always welcome. For bugs and feature requests, [please create an issue](https://github.com/micromatch/braces/issues/new). + +
+ +
+Running Tests + +Running and reviewing unit tests is a great way to get familiarized with a library and its API. You can install dependencies and run tests with the following command: + +```sh +$ npm install && npm test +``` + +
+ +
+Building docs + +_(This project's readme.md is generated by [verb](https://github.com/verbose/verb-generate-readme), please don't edit the readme directly. Any changes to the readme must be made in the [.verb.md](https://github.com/micromatch/braces/blob/3.0.3/.verb.md) readme template.)_ + +To generate the readme, run the following command: + +```sh +$ npm install -g verbose/verb#dev verb-generate-readme && verb +``` + +
+ +### Contributors + +| **Commits** | **Contributor** | +| ----------- | ------------------------------------------------------------- | +| 197 | [jonschlinkert](https://github.com/jonschlinkert) | +| 4 | [doowb](https://github.com/doowb) | +| 1 | [es128](https://github.com/es128) | +| 1 | [eush77](https://github.com/eush77) | +| 1 | [hemanth](https://github.com/hemanth) | +| 1 | [wtgtybhertgeghgtwtg](https://github.com/wtgtybhertgeghgtwtg) | + +### Author + +**Jon Schlinkert** + +- [GitHub Profile](https://github.com/jonschlinkert) +- [Twitter Profile](https://twitter.com/jonschlinkert) +- [LinkedIn Profile](https://linkedin.com/in/jonschlinkert) + +### License + +Copyright © 2019, [Jon Schlinkert](https://github.com/jonschlinkert). +Released under the [MIT License](LICENSE). + +--- + +_This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on April 08, 2019._ diff --git a/vendor/braces/SECURITY-PATCH.md b/vendor/braces/SECURITY-PATCH.md new file mode 100644 index 0000000..d653226 --- /dev/null +++ b/vendor/braces/SECURITY-PATCH.md @@ -0,0 +1,83 @@ +# Private braces security backport + +This directory vendors the official `braces@3.0.3` npm release under the private +identity `@openagentpack/braces@3.0.3-patch.1`. It is not an official upstream +release and must not be published. The original MIT license is preserved in +`LICENSE`; unchanged upstream files retain their original contents and style. + +## Origin + +- Official tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz +- npm integrity, verified against the downloaded bytes: + `sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==` +- Upstream source and compatibility tests: + https://github.com/micromatch/braces/tree/3.0.3 +- Advisory: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm + (`CVE-2026-93687`). On 2026-10-15 npm still listed `3.0.3` as latest, and + the advisory listed no patched version. +- Depth/cycle preflight design reviewed from upstream PR #88, + https://github.com/micromatch/braces/pull/88/commits/a795ee83ecd011131b2d618fe754ac5a8e124dae +- Expansion queue isolation reviewed from upstream PR #82, + https://github.com/micromatch/braces/pull/82/commits/ce0745dcc6bc534243efadf5cef567f62ba76851 + +Both PRs are unmerged proposals, not upstream releases. The local backport is +reviewed and maintained by OpenAgentPack. Its additional visit budget and input +shape checks are described below rather than being attributed to an upstream +release. + +The vendored README uses absolute upstream links for files not included in this +source copy; the original documentation text is otherwise preserved. + +## Local changes + +- Parsing permits at most 128 nested brace/parenthesis containers, counting both + kinds together, and throws `SyntaxError` before accepting the next container. + This also covers unmatched opening delimiters and does not count escaped or + quoted delimiters as containers. Callers cannot disable the limit. +- `compile`, `expand`, and `stringify` validate child edges iteratively before + their recursive walkers run. The child-edge limit is 129: root depth zero, + plus up to 128 containers and their final leaf. Child cycles are rejected. + Normal parser `parent` and `prev` backlinks are deliberately not child edges. +- Shared acyclic children are permitted, but traversal is limited to 65,536 + node visits so repeatedly shared subtrees cannot make the preflight or walkers + take exponential time. AST nodes must be objects, child lists must be arrays, + and values, when supplied, must be strings, preventing nested arrays from + entering the recursive expansion queues through a hand-built AST value. +- Expansion derives queue ownership from the actual validated child traversal. + It does not trust caller-supplied `parent` backlinks or consume external + queues. This prevents cycles or deeply nested external queues from bypassing + the child-edge check. Generated work queues remain internal to the traversal. + +The CommonJS API and `fill-range@^7.1.1` dependency are retained. Inputs beyond +the documented safety bounds now fail with a bounded `SyntaxError`; ordinary +patterns retain upstream behavior. Some malformed/unclosed patterns that previously +failed with a parent-queue TypeError now return a literal expansion. This backport addresses the specified +nesting/AST vulnerability, not every possible resource cost of brace expansion. + +## Integration and verification + +Use a root Bun override and an explicit development dependency: + +```json +{ + "devDependencies": {"braces": "file:./vendor/braces"}, + "overrides": {"braces": "file:./vendor/braces"} +} +``` + +Both entries are needed with the repository's Bun 1.3.5: a clean install with +only the relative file override can write a lockfile entry but omit the package +and its dependencies. The direct dependency gives Bun a concrete local package +to install, while the override routes transitive users to the same patched copy. +Verify actual resolution through a transitive consumer as well as the lockfile; +an audit result alone does not prove that the runtime package was installed. + +The package identity records the actual patched source; it is not a fabricated +official `braces` version or an audit-ignore rule. Keep the repository's normal +audit enabled. `scripts/braces-security.test.ts` covers all public recursive +entry points, hand-built deep/cyclic ASTs, shared nodes, parent metadata, and +ordinary ranges/empty/nested patterns. + +Remove this vendor override once upstream publishes a reviewed fix that covers +these entry points; rerun the security tests, upstream compatibility suite, +dependency audit, and release verification against that replacement. diff --git a/vendor/braces/index.js b/vendor/braces/index.js new file mode 100644 index 0000000..d222c13 --- /dev/null +++ b/vendor/braces/index.js @@ -0,0 +1,170 @@ +'use strict'; + +const stringify = require('./lib/stringify'); +const compile = require('./lib/compile'); +const expand = require('./lib/expand'); +const parse = require('./lib/parse'); + +/** + * Expand the given pattern or create a regex-compatible string. + * + * ```js + * const braces = require('braces'); + * console.log(braces('{a,b,c}', { compile: true })); //=> ['(a|b|c)'] + * console.log(braces('{a,b,c}')); //=> ['a', 'b', 'c'] + * ``` + * @param {String} `str` + * @param {Object} `options` + * @return {String} + * @api public + */ + +const braces = (input, options = {}) => { + let output = []; + + if (Array.isArray(input)) { + for (const pattern of input) { + const result = braces.create(pattern, options); + if (Array.isArray(result)) { + output.push(...result); + } else { + output.push(result); + } + } + } else { + output = [].concat(braces.create(input, options)); + } + + if (options && options.expand === true && options.nodupes === true) { + output = [...new Set(output)]; + } + return output; +}; + +/** + * Parse the given `str` with the given `options`. + * + * ```js + * // braces.parse(pattern, [, options]); + * const ast = braces.parse('a/{b,c}/d'); + * console.log(ast); + * ``` + * @param {String} pattern Brace pattern to parse + * @param {Object} options + * @return {Object} Returns an AST + * @api public + */ + +braces.parse = (input, options = {}) => parse(input, options); + +/** + * Creates a braces string from an AST, or an AST node. + * + * ```js + * const braces = require('braces'); + * let ast = braces.parse('foo/{a,b}/bar'); + * console.log(stringify(ast.nodes[2])); //=> '{a,b}' + * ``` + * @param {String} `input` Brace pattern or AST. + * @param {Object} `options` + * @return {Array} Returns an array of expanded values. + * @api public + */ + +braces.stringify = (input, options = {}) => { + if (typeof input === 'string') { + return stringify(braces.parse(input, options), options); + } + return stringify(input, options); +}; + +/** + * Compiles a brace pattern into a regex-compatible, optimized string. + * This method is called by the main [braces](#braces) function by default. + * + * ```js + * const braces = require('braces'); + * console.log(braces.compile('a/{b,c}/d')); + * //=> ['a/(b|c)/d'] + * ``` + * @param {String} `input` Brace pattern or AST. + * @param {Object} `options` + * @return {Array} Returns an array of expanded values. + * @api public + */ + +braces.compile = (input, options = {}) => { + if (typeof input === 'string') { + input = braces.parse(input, options); + } + return compile(input, options); +}; + +/** + * Expands a brace pattern into an array. This method is called by the + * main [braces](#braces) function when `options.expand` is true. Before + * using this method it's recommended that you read the [performance notes](#performance)) + * and advantages of using [.compile](#compile) instead. + * + * ```js + * const braces = require('braces'); + * console.log(braces.expand('a/{b,c}/d')); + * //=> ['a/b/d', 'a/c/d']; + * ``` + * @param {String} `pattern` Brace pattern + * @param {Object} `options` + * @return {Array} Returns an array of expanded values. + * @api public + */ + +braces.expand = (input, options = {}) => { + if (typeof input === 'string') { + input = braces.parse(input, options); + } + + let result = expand(input, options); + + // filter out empty strings if specified + if (options.noempty === true) { + result = result.filter(Boolean); + } + + // filter out duplicates if specified + if (options.nodupes === true) { + result = [...new Set(result)]; + } + + return result; +}; + +/** + * Processes a brace pattern and returns either an expanded array + * (if `options.expand` is true), a highly optimized regex-compatible string. + * This method is called by the main [braces](#braces) function. + * + * ```js + * const braces = require('braces'); + * console.log(braces.create('user-{200..300}/project-{a,b,c}-{1..10}')) + * //=> 'user-(20[0-9]|2[1-9][0-9]|300)/project-(a|b|c)-([1-9]|10)' + * ``` + * @param {String} `pattern` Brace pattern + * @param {Object} `options` + * @return {Array} Returns an array of expanded values. + * @api public + */ + +braces.create = (input, options = {}) => { + if (input === '' || input.length < 3) { + return [input]; + } + + return options.expand !== true + ? braces.compile(input, options) + : braces.expand(input, options); +}; + +/** + * Expose "braces" + */ + +module.exports = braces; diff --git a/vendor/braces/lib/assert-depth.js b/vendor/braces/lib/assert-depth.js new file mode 100644 index 0000000..3870998 --- /dev/null +++ b/vendor/braces/lib/assert-depth.js @@ -0,0 +1,54 @@ +'use strict'; + +// A parsed leaf adds one child edge below the innermost brace/parenthesis. +const MAX_NESTING = 128; +const MAX_AST_DEPTH = MAX_NESTING + 1; +const MAX_AST_VISITS = 65536; + +exports.assertNesting = depth => { + if (depth > MAX_NESTING) { + throw new SyntaxError(`Brace nesting exceeds safe depth of ${MAX_NESTING}`); + } +}; + +// Only child edges are traversed. Parser parent/prev backlinks are intentional. +// Active-path tracking allows shared acyclic nodes; the visit budget also bounds +// DAGs whose repeated subtrees would otherwise take exponential time to walk. +exports.assertTree = ast => { + const pending = [{ node: ast, depth: 0, childIndex: 0, entered: false }]; + const ancestors = new WeakSet(); + let visits = 0; + + while (pending.length > 0) { + const frame = pending[pending.length - 1]; + const node = frame.node; + + if (!frame.entered) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new SyntaxError('Brace AST nodes must be objects'); + } + if (frame.depth > MAX_AST_DEPTH || ++visits > MAX_AST_VISITS) { + throw new SyntaxError('Brace AST exceeds safe traversal limits'); + } + if (ancestors.has(node)) { + throw new SyntaxError('Brace AST must not contain child cycles'); + } + if (node.nodes !== undefined && !Array.isArray(node.nodes)) { + throw new SyntaxError('Brace AST children must be an array'); + } + if (node.value !== undefined && typeof node.value !== 'string') { + throw new SyntaxError('Brace AST values must be strings'); + } + ancestors.add(node); + frame.entered = true; + } + + if (node.nodes && frame.childIndex < node.nodes.length) { + const child = node.nodes[frame.childIndex++]; + pending.push({ node: child, depth: frame.depth + 1, childIndex: 0, entered: false }); + } else { + ancestors.delete(node); + pending.pop(); + } + } +}; diff --git a/vendor/braces/lib/compile.js b/vendor/braces/lib/compile.js new file mode 100644 index 0000000..86fa1e3 --- /dev/null +++ b/vendor/braces/lib/compile.js @@ -0,0 +1,62 @@ +'use strict'; + +const fill = require('fill-range'); +const utils = require('./utils'); +const { assertTree } = require('./assert-depth'); + +const compile = (ast, options = {}) => { + assertTree(ast); + const walk = (node, parent = {}) => { + const invalidBlock = utils.isInvalidBrace(parent); + const invalidNode = node.invalid === true && options.escapeInvalid === true; + const invalid = invalidBlock === true || invalidNode === true; + const prefix = options.escapeInvalid === true ? '\\' : ''; + let output = ''; + + if (node.isOpen === true) { + return prefix + node.value; + } + + if (node.isClose === true) { + console.log('node.isClose', prefix, node.value); + return prefix + node.value; + } + + if (node.type === 'open') { + return invalid ? prefix + node.value : '('; + } + + if (node.type === 'close') { + return invalid ? prefix + node.value : ')'; + } + + if (node.type === 'comma') { + return node.prev.type === 'comma' ? '' : invalid ? node.value : '|'; + } + + if (node.value) { + return node.value; + } + + if (node.nodes && node.ranges > 0) { + const args = utils.reduce(node.nodes); + const range = fill(...args, { ...options, wrap: false, toRegex: true, strictZeros: true }); + + if (range.length !== 0) { + return args.length > 1 && range.length > 1 ? `(${range})` : range; + } + } + + if (node.nodes) { + for (const child of node.nodes) { + output += walk(child, node); + } + } + + return output; + }; + + return walk(ast); +}; + +module.exports = compile; diff --git a/vendor/braces/lib/constants.js b/vendor/braces/lib/constants.js new file mode 100644 index 0000000..2bb3b88 --- /dev/null +++ b/vendor/braces/lib/constants.js @@ -0,0 +1,57 @@ +'use strict'; + +module.exports = { + MAX_LENGTH: 10000, + + // Digits + CHAR_0: '0', /* 0 */ + CHAR_9: '9', /* 9 */ + + // Alphabet chars. + CHAR_UPPERCASE_A: 'A', /* A */ + CHAR_LOWERCASE_A: 'a', /* a */ + CHAR_UPPERCASE_Z: 'Z', /* Z */ + CHAR_LOWERCASE_Z: 'z', /* z */ + + CHAR_LEFT_PARENTHESES: '(', /* ( */ + CHAR_RIGHT_PARENTHESES: ')', /* ) */ + + CHAR_ASTERISK: '*', /* * */ + + // Non-alphabetic chars. + CHAR_AMPERSAND: '&', /* & */ + CHAR_AT: '@', /* @ */ + CHAR_BACKSLASH: '\\', /* \ */ + CHAR_BACKTICK: '`', /* ` */ + CHAR_CARRIAGE_RETURN: '\r', /* \r */ + CHAR_CIRCUMFLEX_ACCENT: '^', /* ^ */ + CHAR_COLON: ':', /* : */ + CHAR_COMMA: ',', /* , */ + CHAR_DOLLAR: '$', /* . */ + CHAR_DOT: '.', /* . */ + CHAR_DOUBLE_QUOTE: '"', /* " */ + CHAR_EQUAL: '=', /* = */ + CHAR_EXCLAMATION_MARK: '!', /* ! */ + CHAR_FORM_FEED: '\f', /* \f */ + CHAR_FORWARD_SLASH: '/', /* / */ + CHAR_HASH: '#', /* # */ + CHAR_HYPHEN_MINUS: '-', /* - */ + CHAR_LEFT_ANGLE_BRACKET: '<', /* < */ + CHAR_LEFT_CURLY_BRACE: '{', /* { */ + CHAR_LEFT_SQUARE_BRACKET: '[', /* [ */ + CHAR_LINE_FEED: '\n', /* \n */ + CHAR_NO_BREAK_SPACE: '\u00A0', /* \u00A0 */ + CHAR_PERCENT: '%', /* % */ + CHAR_PLUS: '+', /* + */ + CHAR_QUESTION_MARK: '?', /* ? */ + CHAR_RIGHT_ANGLE_BRACKET: '>', /* > */ + CHAR_RIGHT_CURLY_BRACE: '}', /* } */ + CHAR_RIGHT_SQUARE_BRACKET: ']', /* ] */ + CHAR_SEMICOLON: ';', /* ; */ + CHAR_SINGLE_QUOTE: '\'', /* ' */ + CHAR_SPACE: ' ', /* */ + CHAR_TAB: '\t', /* \t */ + CHAR_UNDERSCORE: '_', /* _ */ + CHAR_VERTICAL_LINE: '|', /* | */ + CHAR_ZERO_WIDTH_NOBREAK_SPACE: '\uFEFF' /* \uFEFF */ +}; diff --git a/vendor/braces/lib/expand.js b/vendor/braces/lib/expand.js new file mode 100644 index 0000000..f76a632 --- /dev/null +++ b/vendor/braces/lib/expand.js @@ -0,0 +1,107 @@ +'use strict'; + +const fill = require('fill-range'); +const stringify = require('./stringify'); +const utils = require('./utils'); +const { assertTree } = require('./assert-depth'); + +const append = (queue = '', stash = '', enclose = false) => { + const result = []; + + queue = [].concat(queue); + stash = [].concat(stash); + + if (!stash.length) return queue; + if (!queue.length) { + return enclose ? utils.flatten(stash).map(ele => `{${ele}}`) : stash; + } + + for (const item of queue) { + if (Array.isArray(item)) { + for (const value of item) { + result.push(append(value, stash, enclose)); + } + } else { + for (let ele of stash) { + if (enclose === true && typeof ele === 'string') ele = `{${ele}}`; + result.push(Array.isArray(ele) ? append(item, ele, enclose) : item + ele); + } + } + } + return utils.flatten(result); +}; + +const expand = (ast, options = {}) => { + assertTree(ast); + const rangeLimit = options.rangeLimit === undefined ? 1000 : options.rangeLimit; + + const walk = (node, parentQueue) => { + node.queue = []; + + const q = parentQueue; + + if (node.invalid || node.dollar) { + q.push(append(q.pop(), stringify(node, options))); + return; + } + + if (node.type === 'brace' && node.invalid !== true && node.nodes.length === 2) { + q.push(append(q.pop(), ['{}'])); + return; + } + + if (node.nodes && node.ranges > 0) { + const args = utils.reduce(node.nodes); + + if (utils.exceedsLimit(...args, options.step, rangeLimit)) { + throw new RangeError('expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.'); + } + + let range = fill(...args, options); + if (range.length === 0) { + range = stringify(node, options); + } + + q.push(append(q.pop(), range)); + node.nodes = []; + return; + } + + const enclose = utils.encloseBrace(node); + // Work queues belong to the actual validated traversal, not caller-supplied + // parent metadata or external queues, which may be cyclic or arbitrarily deep. + const queue = node.type === 'brace' || node.type === 'root' || parentQueue === undefined + ? node.queue + : parentQueue; + + for (let i = 0; i < node.nodes.length; i++) { + const child = node.nodes[i]; + + if (child.type === 'comma' && node.type === 'brace') { + if (i === 1) queue.push(''); + queue.push(''); + continue; + } + + if (child.type === 'close') { + q.push(append(q.pop(), queue, enclose)); + continue; + } + + if (child.value && child.type !== 'open') { + queue.push(append(queue.pop(), child.value)); + continue; + } + + if (child.nodes) { + walk(child, queue); + } + } + + return queue; + }; + + return utils.flatten(walk(ast)); +}; + +module.exports = expand; diff --git a/vendor/braces/lib/parse.js b/vendor/braces/lib/parse.js new file mode 100644 index 0000000..127a62f --- /dev/null +++ b/vendor/braces/lib/parse.js @@ -0,0 +1,334 @@ +'use strict'; + +const stringify = require('./stringify'); +const { assertNesting } = require('./assert-depth'); + +/** + * Constants + */ + +const { + MAX_LENGTH, + CHAR_BACKSLASH, /* \ */ + CHAR_BACKTICK, /* ` */ + CHAR_COMMA, /* , */ + CHAR_DOT, /* . */ + CHAR_LEFT_PARENTHESES, /* ( */ + CHAR_RIGHT_PARENTHESES, /* ) */ + CHAR_LEFT_CURLY_BRACE, /* { */ + CHAR_RIGHT_CURLY_BRACE, /* } */ + CHAR_LEFT_SQUARE_BRACKET, /* [ */ + CHAR_RIGHT_SQUARE_BRACKET, /* ] */ + CHAR_DOUBLE_QUOTE, /* " */ + CHAR_SINGLE_QUOTE, /* ' */ + CHAR_NO_BREAK_SPACE, + CHAR_ZERO_WIDTH_NOBREAK_SPACE +} = require('./constants'); + +/** + * parse + */ + +const parse = (input, options = {}) => { + if (typeof input !== 'string') { + throw new TypeError('Expected a string'); + } + + const opts = options || {}; + const max = typeof opts.maxLength === 'number' ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + if (input.length > max) { + throw new SyntaxError(`Input length (${input.length}), exceeds max characters (${max})`); + } + + const ast = { type: 'root', input, nodes: [] }; + const stack = [ast]; + let block = ast; + let prev = ast; + let brackets = 0; + const length = input.length; + let index = 0; + let depth = 0; + let value; + + /** + * Helpers + */ + + const advance = () => input[index++]; + const push = node => { + if (node.type === 'text' && prev.type === 'dot') { + prev.type = 'text'; + } + + if (prev && prev.type === 'text' && node.type === 'text') { + prev.value += node.value; + return; + } + + block.nodes.push(node); + node.parent = block; + node.prev = prev; + prev = node; + return node; + }; + + push({ type: 'bos' }); + + while (index < length) { + block = stack[stack.length - 1]; + value = advance(); + + /** + * Invalid chars + */ + + if (value === CHAR_ZERO_WIDTH_NOBREAK_SPACE || value === CHAR_NO_BREAK_SPACE) { + continue; + } + + /** + * Escaped chars + */ + + if (value === CHAR_BACKSLASH) { + push({ type: 'text', value: (options.keepEscaping ? value : '') + advance() }); + continue; + } + + /** + * Right square bracket (literal): ']' + */ + + if (value === CHAR_RIGHT_SQUARE_BRACKET) { + push({ type: 'text', value: '\\' + value }); + continue; + } + + /** + * Left square bracket: '[' + */ + + if (value === CHAR_LEFT_SQUARE_BRACKET) { + brackets++; + + let next; + + while (index < length && (next = advance())) { + value += next; + + if (next === CHAR_LEFT_SQUARE_BRACKET) { + brackets++; + continue; + } + + if (next === CHAR_BACKSLASH) { + value += advance(); + continue; + } + + if (next === CHAR_RIGHT_SQUARE_BRACKET) { + brackets--; + + if (brackets === 0) { + break; + } + } + } + + push({ type: 'text', value }); + continue; + } + + /** + * Parentheses + */ + + if (value === CHAR_LEFT_PARENTHESES) { + assertNesting(stack.length); + block = push({ type: 'paren', nodes: [] }); + stack.push(block); + push({ type: 'text', value }); + continue; + } + + if (value === CHAR_RIGHT_PARENTHESES) { + if (block.type !== 'paren') { + push({ type: 'text', value }); + continue; + } + block = stack.pop(); + push({ type: 'text', value }); + block = stack[stack.length - 1]; + continue; + } + + /** + * Quotes: '|"|` + */ + + if (value === CHAR_DOUBLE_QUOTE || value === CHAR_SINGLE_QUOTE || value === CHAR_BACKTICK) { + const open = value; + let next; + + if (options.keepQuotes !== true) { + value = ''; + } + + while (index < length && (next = advance())) { + if (next === CHAR_BACKSLASH) { + value += next + advance(); + continue; + } + + if (next === open) { + if (options.keepQuotes === true) value += next; + break; + } + + value += next; + } + + push({ type: 'text', value }); + continue; + } + + /** + * Left curly brace: '{' + */ + + if (value === CHAR_LEFT_CURLY_BRACE) { + assertNesting(stack.length); + depth++; + + const dollar = prev.value && prev.value.slice(-1) === '$' || block.dollar === true; + const brace = { + type: 'brace', + open: true, + close: false, + dollar, + depth, + commas: 0, + ranges: 0, + nodes: [] + }; + + block = push(brace); + stack.push(block); + push({ type: 'open', value }); + continue; + } + + /** + * Right curly brace: '}' + */ + + if (value === CHAR_RIGHT_CURLY_BRACE) { + if (block.type !== 'brace') { + push({ type: 'text', value }); + continue; + } + + const type = 'close'; + block = stack.pop(); + block.close = true; + + push({ type, value }); + depth--; + + block = stack[stack.length - 1]; + continue; + } + + /** + * Comma: ',' + */ + + if (value === CHAR_COMMA && depth > 0) { + if (block.ranges > 0) { + block.ranges = 0; + const open = block.nodes.shift(); + block.nodes = [open, { type: 'text', value: stringify(block) }]; + } + + push({ type: 'comma', value }); + block.commas++; + continue; + } + + /** + * Dot: '.' + */ + + if (value === CHAR_DOT && depth > 0 && block.commas === 0) { + const siblings = block.nodes; + + if (depth === 0 || siblings.length === 0) { + push({ type: 'text', value }); + continue; + } + + if (prev.type === 'dot') { + block.range = []; + prev.value += value; + prev.type = 'range'; + + if (block.nodes.length !== 3 && block.nodes.length !== 5) { + block.invalid = true; + block.ranges = 0; + prev.type = 'text'; + continue; + } + + block.ranges++; + block.args = []; + continue; + } + + if (prev.type === 'range') { + siblings.pop(); + + const before = siblings[siblings.length - 1]; + before.value += prev.value + value; + prev = before; + block.ranges--; + continue; + } + + push({ type: 'dot', value }); + continue; + } + + /** + * Text + */ + + push({ type: 'text', value }); + } + + // Mark imbalanced braces and brackets as invalid + do { + block = stack.pop(); + + if (block.type !== 'root') { + block.nodes.forEach(node => { + if (!node.nodes) { + if (node.type === 'open') node.isOpen = true; + if (node.type === 'close') node.isClose = true; + if (!node.nodes) node.type = 'text'; + node.invalid = true; + } + }); + + // get the location of the block on parent.nodes (block's siblings) + const parent = stack[stack.length - 1]; + const index = parent.nodes.indexOf(block); + // replace the (invalid) block with it's nodes + parent.nodes.splice(index, 1, ...block.nodes); + } + } while (stack.length > 0); + + push({ type: 'eos' }); + return ast; +}; + +module.exports = parse; diff --git a/vendor/braces/lib/stringify.js b/vendor/braces/lib/stringify.js new file mode 100644 index 0000000..89a4370 --- /dev/null +++ b/vendor/braces/lib/stringify.js @@ -0,0 +1,33 @@ +'use strict'; + +const utils = require('./utils'); +const { assertTree } = require('./assert-depth'); + +module.exports = (ast, options = {}) => { + assertTree(ast); + const stringify = (node, parent = {}) => { + const invalidBlock = options.escapeInvalid && utils.isInvalidBrace(parent); + const invalidNode = node.invalid === true && options.escapeInvalid === true; + let output = ''; + + if (node.value) { + if ((invalidBlock || invalidNode) && utils.isOpenOrClose(node)) { + return '\\' + node.value; + } + return node.value; + } + + if (node.value) { + return node.value; + } + + if (node.nodes) { + for (const child of node.nodes) { + output += stringify(child); + } + } + return output; + }; + + return stringify(ast); +}; diff --git a/vendor/braces/lib/utils.js b/vendor/braces/lib/utils.js new file mode 100644 index 0000000..d19311f --- /dev/null +++ b/vendor/braces/lib/utils.js @@ -0,0 +1,122 @@ +'use strict'; + +exports.isInteger = num => { + if (typeof num === 'number') { + return Number.isInteger(num); + } + if (typeof num === 'string' && num.trim() !== '') { + return Number.isInteger(Number(num)); + } + return false; +}; + +/** + * Find a node of the given type + */ + +exports.find = (node, type) => node.nodes.find(node => node.type === type); + +/** + * Find a node of the given type + */ + +exports.exceedsLimit = (min, max, step = 1, limit) => { + if (limit === false) return false; + if (!exports.isInteger(min) || !exports.isInteger(max)) return false; + return ((Number(max) - Number(min)) / Number(step)) >= limit; +}; + +/** + * Escape the given node with '\\' before node.value + */ + +exports.escapeNode = (block, n = 0, type) => { + const node = block.nodes[n]; + if (!node) return; + + if ((type && node.type === type) || node.type === 'open' || node.type === 'close') { + if (node.escaped !== true) { + node.value = '\\' + node.value; + node.escaped = true; + } + } +}; + +/** + * Returns true if the given brace node should be enclosed in literal braces + */ + +exports.encloseBrace = node => { + if (node.type !== 'brace') return false; + if ((node.commas >> 0 + node.ranges >> 0) === 0) { + node.invalid = true; + return true; + } + return false; +}; + +/** + * Returns true if a brace node is invalid. + */ + +exports.isInvalidBrace = block => { + if (block.type !== 'brace') return false; + if (block.invalid === true || block.dollar) return true; + if ((block.commas >> 0 + block.ranges >> 0) === 0) { + block.invalid = true; + return true; + } + if (block.open !== true || block.close !== true) { + block.invalid = true; + return true; + } + return false; +}; + +/** + * Returns true if a node is an open or close node + */ + +exports.isOpenOrClose = node => { + if (node.type === 'open' || node.type === 'close') { + return true; + } + return node.open === true || node.close === true; +}; + +/** + * Reduce an array of text nodes. + */ + +exports.reduce = nodes => nodes.reduce((acc, node) => { + if (node.type === 'text') acc.push(node.value); + if (node.type === 'range') node.type = 'text'; + return acc; +}, []); + +/** + * Flatten an array + */ + +exports.flatten = (...args) => { + const result = []; + + const flat = arr => { + for (let i = 0; i < arr.length; i++) { + const ele = arr[i]; + + if (Array.isArray(ele)) { + flat(ele); + continue; + } + + if (ele !== undefined) { + result.push(ele); + } + } + return result; + }; + + flat(args); + return result; +}; diff --git a/vendor/braces/package.json b/vendor/braces/package.json new file mode 100644 index 0000000..4e9a53e --- /dev/null +++ b/vendor/braces/package.json @@ -0,0 +1,57 @@ +{ + "name": "@openagentpack/braces", + "description": "Private braces 3.0.3 backport with bounded parsing and AST traversal.", + "version": "3.0.3-patch.1", + "homepage": "https://github.com/micromatch/braces", + "author": "Jon Schlinkert (https://github.com/jonschlinkert)", + "contributors": [ + "Brian Woodward (https://twitter.com/doowb)", + "Elan Shanker (https://github.com/es128)", + "Eugene Sharygin (https://github.com/eush77)", + "hemanth.hm (http://h3manth.com)", + "Jon Schlinkert (http://twitter.com/jonschlinkert)" + ], + "repository": "micromatch/braces", + "bugs": { + "url": "https://github.com/micromatch/braces/issues" + }, + "license": "MIT", + "files": [ + "index.js", + "lib", + "LICENSE", + "SECURITY-PATCH.md" + ], + "main": "index.js", + "engines": { + "node": ">=8" + }, + "dependencies": { + "fill-range": "^7.1.1" + }, + "keywords": [ + "alpha", + "alphabetical", + "bash", + "brace", + "braces", + "expand", + "expansion", + "filepath", + "fill", + "fs", + "glob", + "globbing", + "letter", + "match", + "matches", + "matching", + "number", + "numerical", + "path", + "range", + "ranges", + "sh" + ], + "private": true +}