From 41ce46af24c0bda9441216191a4b119fef846fab Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 26 Oct 2026 01:19:27 +0800 Subject: [PATCH] feat(managed-agent): add pinned extension package management (#15490) * feat(managed-agent): support pinned managed extension packages * fix(managed-agent): align extension package CI contracts * test(managed-agent): pin package audit ambiguities * fix(managed-agent): report unavailable settings and satisfy broker style * fix(managed-agent): retain queue inventory in extension integration * test(managed-agent): preserve pinned context across Todo refresh --- .../2026-10-21-managed-capability-parity.md | 12 +- ...6-10-21-managed-capability-parity.zh-CN.md | 2 +- .../2026-10-24-managed-extension-packages.md | 211 ++ ...-10-24-managed-extension-packages.zh-CN.md | 159 ++ .../cli/src/serve/capability-parity.test.ts | 1 + packages/cli/src/serve/capability-parity.ts | 31 +- .../hosted-approval-continuation.test.ts | 106 +- .../src/serve/hosted-approval-continuation.ts | 55 +- .../cli/src/serve/hosted-extension-agent.ts | 84 + .../serve/hosted-extension-command.test.ts | 175 ++ .../cli/src/serve/hosted-extension-command.ts | 370 ++++ .../serve/hosted-extension-session.test.ts | 334 +++ .../cli/src/serve/hosted-extension-session.ts | 800 +++++++ .../hosted-extension-tool-context.test.ts | 136 ++ .../serve/hosted-extension-tool-context.ts | 139 ++ .../src/serve/hosted-harness-contract.test.ts | 1 + .../cli/src/serve/hosted-harness-contract.ts | 1 + .../src/serve/hosted-harness-model.test.ts | 77 + .../cli/src/serve/hosted-harness-model.ts | 40 +- .../src/serve/hosted-harness-session.test.ts | 242 ++- .../cli/src/serve/hosted-harness-session.ts | 674 +++++- packages/cli/src/serve/hosted-hook-session.ts | 22 +- .../cli/src/serve/hosted-mcp-session.test.ts | 57 + packages/cli/src/serve/hosted-mcp-session.ts | 108 +- .../cli/src/serve/hosted-runtime-recovery.ts | 46 + .../cli/src/serve/hosted-skill-catalog.ts | 108 +- .../cli/src/serve/hosted-tool-approval.ts | 10 +- .../cli/src/serve/hosted-workspace-broker.ts | 66 +- ...ed-workspace-tool-turn.child-agent.test.ts | 173 +- .../serve/hosted-workspace-tool-turn.test.ts | 116 + .../src/serve/hosted-workspace-tool-turn.ts | 594 +++++- .../serve/managed-context-envelope.test.ts | 93 + .../cli/src/serve/managed-context-envelope.ts | 78 +- .../src/serve/managed-context-worker.test.ts | 336 ++- .../cli/src/serve/managed-context-worker.ts | 524 ++++- .../src/serve/managed-extension-cache.test.ts | 136 ++ .../cli/src/serve/managed-extension-cache.ts | 224 ++ .../managed-extension-command-runtime.test.ts | 196 ++ .../managed-extension-command-runtime.ts | 358 ++++ .../managed-extension-environment.test.ts | 32 + .../serve/managed-extension-environment.ts | 32 + .../cli/src/serve/managed-extension-error.ts | 7 + .../cli/src/serve/managed-extension-hook.ts | 121 ++ .../serve/managed-extension-manifests.test.ts | 431 ++++ .../src/serve/managed-extension-manifests.ts | 250 +++ .../serve/managed-extension-preflight.test.ts | 69 + .../src/serve/managed-extension-preflight.ts | 194 ++ .../serve/managed-extension-process.test.ts | 194 ++ .../src/serve/managed-extension-process.ts | 479 +++++ .../src/serve/managed-extension-profile.ts | 68 + .../serve/managed-extension-runtime.test.ts | 396 ++++ .../src/serve/managed-extension-runtime.ts | 998 +++++++++ .../managed-extension-shell-executor.test.ts | 66 + .../src/serve/managed-extension-shell.test.ts | 92 + .../cli/src/serve/managed-extension-shell.ts | 150 ++ .../cli/src/serve/managed-extension-stdio.ts | 90 + .../src/serve/managed-hook-runtime.test.ts | 36 + .../cli/src/serve/managed-hook-runtime.ts | 181 +- .../cli/src/serve/managed-mcp-runtime.test.ts | 142 ++ packages/cli/src/serve/managed-mcp-runtime.ts | 264 ++- .../managed-runtime-attestation-contract.ts | 10 +- .../managed-runtime-attestation-worker.ts | 28 +- .../src/serve/managed-runtime-bootstrap.ts | 15 +- .../cli/src/serve/managed-runtime-ledger.ts | 35 + .../serve/managed-runtime-tool-executor.ts | 98 +- .../src/serve/managed-workspace-activation.ts | 14 +- .../cli/src/serve/process-env-guard.test.ts | 10 +- .../managed-package-preparation.test.ts | 123 ++ .../routes/managed-package-preparation.ts | 176 ++ .../src/serve/routes/workspace-extensions.ts | 2 + .../src/services/markdown-command-parser.ts | 72 +- packages/core/src/extension/github.ts | 37 +- .../src/extension/managed-package.test.ts | 260 +++ .../core/src/extension/managed-package.ts | 792 +++++++ packages/core/src/extension/npm.ts | 8 +- packages/core/src/hooks/hookRunner.ts | 250 ++- packages/core/src/hooks/types.ts | 5 + .../managed-config-install-v1.fixtures.json | 526 +++++ ...aged-extension-projection-v1.fixtures.json | 1 + .../managed-tool-stage-v1.fixtures.json | 632 ++++++ .../http-managed-session-store.ts | 8 +- .../managed-child-operations.test.ts | 3 + .../managed-child-operations.ts | 71 +- .../managed-config-install.test.ts | 316 +++ .../managed-runtime/managed-config-install.ts | 333 +++ .../managed-extension-contributions.test.ts | 79 + .../managed-extension-contributions.ts | 120 ++ .../managed-extension-package.test.ts | 72 + .../managed-extension-package.ts | 424 ++++ .../managed-extension-projection.ts | 14 + .../managed-session-authority.ts | 86 +- .../managed-session-records.ts | 1 + .../managed-tool-stage-record.test.ts | 40 + .../managed-tool-stage-record.ts | 125 ++ packages/core/src/utils/markdown-command.ts | 71 + packages/managed-agent-server/README.md | 49 + .../config/application.example.yml | 20 + .../distribution/systemd/README.md | 17 + .../distribution/systemd/README.zh-CN.md | 14 + .../ManagedExtensionManagementController.java | 76 + .../ManagedExtensionPackageController.java | 155 ++ .../api/PublicAgentController.java | 5 +- .../managedagent/api/SignatureAuthFilter.java | 90 +- .../api/WebShellAgentController.java | 5 +- .../config/HarnessConfiguration.java | 11 +- .../config/ManagedAgentProperties.java | 36 + .../config/RuntimeBrokerConfiguration.java | 9 +- .../ManagedExtensionArchiveStore.java | 121 ++ .../ManagedExtensionManagementService.java | 221 ++ .../ManagedExtensionPackageService.java | 425 ++++ .../extensions/ManagedExtensionPackages.java | 73 + .../harness/HarnessConnector.java | 10 + .../harness/HarnessPoolConnector.java | 15 + .../harness/QwenHostedHarnessConnector.java | 117 +- .../code/managedagent/model/ApiModels.java | 16 +- .../persistence/ManagedActionStore.java | 16 +- .../persistence/ManagedAgentStore.java | 182 +- .../persistence/ManagedConfigInstall.java | 214 ++ .../ManagedExtensionAgentLaunch.java | 194 ++ .../ManagedExtensionCommandVerifier.java | 131 ++ .../ManagedExtensionProjection.java | 5 + .../ManagedExtensionRecordStore.java | 52 +- .../ManagedHostedRecoveryRecords.java | 22 +- .../persistence/ManagedMcpSettings.java | 4 + .../persistence/ManagedSessionSkills.java | 89 +- .../persistence/ManagedSessionStore.java | 28 +- .../persistence/ManagedToolStageRecords.java | 81 + .../persistence/WakeRecoveryStore.java | 2 +- .../WorkspaceCsiApplicationInventory.java | 32 +- .../managedagent/session/AgentStateStore.java | 25 + .../session/ManagedActionService.java | 37 +- .../session/ManagedAgentService.java | 22 +- .../runtime/EmbeddedRuntimeBroker.java | 92 + .../runtime/WorkspaceRuntimeProvisioner.java | 2 +- .../runtime/WorkspaceRuntimeResolver.java | 24 +- .../runtime/WorkspaceRuntimeTransport.java | 46 +- .../code/runtimebroker/HarnessOwnership.java | 2 +- .../ManagedExtensionExecutionProfile.java | 42 + .../runtimebroker/RuntimeBrokerService.java | 131 ++ .../runtimebroker/RuntimeDrainReceipt.java | 49 +- .../RuntimeExtensionCommandVerifier.java | 9 + .../code/runtimebroker/RuntimeTransport.java | 6 + .../WorkspaceExecutionProfile.java | 6 +- .../managedschema/ManagedSchema.java | 2 +- .../managedschema/ManagedSchemaCatalog.java | 3 +- .../managedschema/ManagedSchemaContract.java | 3 + .../LocalProcessRuntimeProvisioner.java | 38 +- .../provisioning/ManagedContextProtocol.java | 25 +- .../provisioning/SshRuntimeProvisioner.java | 43 +- .../transport/HttpRuntimeTransport.java | 140 +- .../ManagedExtensionCommandProtocol.java | 68 + .../transport/ManagedExtensionProtocol.java | 232 ++ .../transport/RuntimeBrokerHttpServer.java | 23 + .../src/main/resources/application.yml | 9 + .../db/managed-schema/V1__managed_schema.sql | 58 + .../managed-agent-public-api.openapi.json | 1862 ++++++++++++++++- .../managedagent/BackfillWitnessesTest.java | 2 +- .../managedagent/ExtensionRecordJournal.java | 2 +- .../ManagedAgentApiContractTest.java | 58 + .../ManagedConfigInstallContractTest.java | 101 + .../ManagedExtensionCommandVerifierTest.java | 205 ++ ...edExtensionConfigurationAdmissionTest.java | 187 ++ .../ManagedExtensionConfigurationMySqlIT.java | 13 + .../ManagedExtensionCreationMySqlIT.java | 13 + .../ManagedSessionCreationSkillsTest.java | 200 ++ .../ManagedToolStageContractTest.java | 60 + .../RuntimeBrokerFlywaySchemaTest.java | 4 +- .../SurfaceAdmissionAcceptanceTest.java | 70 +- .../api/SignatureAuthFilterTest.java | 35 + .../managedagent/api/SurfaceRegistry.java | 63 +- .../ManagedExtensionPackageServiceTest.java | 206 ++ .../harness/HarnessPoolConnectorTest.java | 35 + .../QwenHostedHarnessConnectorTest.java | 56 + .../ManagedExtensionAgentLaunchTest.java | 85 + .../persistence/SessionRowLockOrderTest.java | 11 +- .../WorkspaceCsiApplicationInventoryTest.java | 35 +- .../WorkspaceCsiNativeActivationGateTest.java | 2 +- .../WorkspaceCsiSessionMainTest.java | 2 +- .../WorkspaceCsiSqlWorkerMySqlIT.java | 2 +- .../ManagedExtensionExecutionProfileTest.java | 19 + .../RuntimeExtensionMetadataControlTest.java | 150 ++ .../managedschema/ManagedSchemaTest.java | 8 +- .../SshRuntimeProvisionerTest.java | 20 +- .../ManagedExtensionProtocolTest.java | 108 + .../ManagedExtensionTransportTest.java | 164 ++ .../capability-parity/managed-surface.txt | 25 +- .../code/daemon/CreateHarnessSession.java | 22 + .../qwen/code/daemon/HostedHarnessClient.java | 42 + .../qwen/code/daemon/LoadHarnessSession.java | 26 + .../code/daemon/ManagedExtensionsClient.java | 221 ++ .../HostedAgentDefinitionRequestTest.java | 14 + .../daemon/ManagedExtensionsClientTest.java | 108 + .../sdk-typescript/src/daemon/DaemonClient.ts | 65 + packages/sdk-typescript/src/daemon/index.ts | 5 + .../daemon/managed-extension-package-types.ts | 30 + packages/web-shell/client/App.tsx | 1 + .../extensions/ExtensionsManagerPage.tsx | 17 +- .../ManagedExtensionsManagerPage.test.tsx | 353 ++++ .../managed/ManagedExtensionsManagerPage.tsx | 749 +++++++ .../managed/WorkspaceBindingCreator.test.tsx | 98 + .../managed/WorkspaceBindingCreator.tsx | 123 ++ .../managed/generated/managed-agent-api.ts | 637 +++++- .../managed/java-managed-agent-client.ts | 136 ++ .../managed/java-managed-agent-provider.ts | 73 + .../managed/managed-agent-provider.ts | 63 + .../managed/managed-extension-messages.ts | 82 + .../managed/managed-extension-types.ts | 87 + .../web-shell/client/e2e/managed-mcp.spec.ts | 2 + .../client/e2e/managed-recovery.spec.ts | 2 + ...managed-ssh-workspace-registration.spec.ts | 2 + .../client/e2e/managed-usability.spec.ts | 4 + packages/web-shell/client/i18n.tsx | 6 + 212 files changed, 25916 insertions(+), 573 deletions(-) create mode 100644 docs/design/2026-10-24-managed-extension-packages.md create mode 100644 docs/design/2026-10-24-managed-extension-packages.zh-CN.md create mode 100644 packages/cli/src/serve/hosted-extension-agent.ts create mode 100644 packages/cli/src/serve/hosted-extension-command.test.ts create mode 100644 packages/cli/src/serve/hosted-extension-command.ts create mode 100644 packages/cli/src/serve/hosted-extension-session.test.ts create mode 100644 packages/cli/src/serve/hosted-extension-session.ts create mode 100644 packages/cli/src/serve/hosted-extension-tool-context.test.ts create mode 100644 packages/cli/src/serve/hosted-extension-tool-context.ts create mode 100644 packages/cli/src/serve/managed-extension-cache.test.ts create mode 100644 packages/cli/src/serve/managed-extension-cache.ts create mode 100644 packages/cli/src/serve/managed-extension-command-runtime.test.ts create mode 100644 packages/cli/src/serve/managed-extension-command-runtime.ts create mode 100644 packages/cli/src/serve/managed-extension-environment.test.ts create mode 100644 packages/cli/src/serve/managed-extension-environment.ts create mode 100644 packages/cli/src/serve/managed-extension-error.ts create mode 100644 packages/cli/src/serve/managed-extension-hook.ts create mode 100644 packages/cli/src/serve/managed-extension-manifests.test.ts create mode 100644 packages/cli/src/serve/managed-extension-manifests.ts create mode 100644 packages/cli/src/serve/managed-extension-preflight.test.ts create mode 100644 packages/cli/src/serve/managed-extension-preflight.ts create mode 100644 packages/cli/src/serve/managed-extension-process.test.ts create mode 100644 packages/cli/src/serve/managed-extension-process.ts create mode 100644 packages/cli/src/serve/managed-extension-profile.ts create mode 100644 packages/cli/src/serve/managed-extension-runtime.test.ts create mode 100644 packages/cli/src/serve/managed-extension-runtime.ts create mode 100644 packages/cli/src/serve/managed-extension-shell-executor.test.ts create mode 100644 packages/cli/src/serve/managed-extension-shell.test.ts create mode 100644 packages/cli/src/serve/managed-extension-shell.ts create mode 100644 packages/cli/src/serve/managed-extension-stdio.ts create mode 100644 packages/cli/src/serve/routes/managed-package-preparation.test.ts create mode 100644 packages/cli/src/serve/routes/managed-package-preparation.ts create mode 100644 packages/core/src/extension/managed-package.test.ts create mode 100644 packages/core/src/extension/managed-package.ts create mode 100644 packages/core/src/managed-runtime/contracts/managed-config-install-v1.fixtures.json create mode 100644 packages/core/src/managed-runtime/contracts/managed-tool-stage-v1.fixtures.json create mode 100644 packages/core/src/managed-runtime/managed-config-install.test.ts create mode 100644 packages/core/src/managed-runtime/managed-config-install.ts create mode 100644 packages/core/src/managed-runtime/managed-extension-contributions.test.ts create mode 100644 packages/core/src/managed-runtime/managed-extension-contributions.ts create mode 100644 packages/core/src/managed-runtime/managed-extension-package.test.ts create mode 100644 packages/core/src/managed-runtime/managed-extension-package.ts create mode 100644 packages/core/src/managed-runtime/managed-tool-stage-record.test.ts create mode 100644 packages/core/src/managed-runtime/managed-tool-stage-record.ts create mode 100644 packages/core/src/utils/markdown-command.ts create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionManagementController.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionPackageController.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionArchiveStore.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionManagementService.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageService.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackages.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedConfigInstall.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunch.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionCommandVerifier.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedToolStageRecords.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfile.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionCommandVerifier.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionCommandProtocol.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocol.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedConfigInstallContractTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCommandVerifierTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationMySqlIT.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCreationMySqlIT.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolStageContractTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageServiceTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunchTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfileTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionMetadataControlTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocolTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionTransportTest.java create mode 100644 packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClient.java create mode 100644 packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClientTest.java create mode 100644 packages/sdk-typescript/src/daemon/managed-extension-package-types.ts create mode 100644 packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.test.tsx create mode 100644 packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.tsx create mode 100644 packages/web-shell/client/components/managed/managed-extension-messages.ts create mode 100644 packages/web-shell/client/components/managed/managed-extension-types.ts diff --git a/docs/design/2026-10-21-managed-capability-parity.md b/docs/design/2026-10-21-managed-capability-parity.md index 188876aa343..44c80f36a24 100644 --- a/docs/design/2026-10-21-managed-capability-parity.md +++ b/docs/design/2026-10-21-managed-capability-parity.md @@ -87,11 +87,13 @@ matches its own route lines. is mounted with no legacy tag and no exclusion appears in the column, which is the signal #15075 asked for. The remaining labels are pinned in `MANAGED_ONLY_REPORTED`, so an addition fails the suite until someone - records it. The column currently reports three labels, each a public and a - WebShell route pair: `SESSION_CONTEXT_COMPRESSION` compresses a Session's - context, `SESSION_RECOVERY_QUERY` reads its recovery state, and - `SESSION_RECOVERY_RECHECK` re-runs its recovery checks. The daemon - advertises no tag for any of the three. + records it. The column currently reports twelve labels: nine `EXTENSION_*` + labels describe package publication, archive access, settings, operation + queries and Session configuration. Their Session-pinned contract differs + from the daemon's extension APIs; recording them does not establish legacy + API compatibility or platform acceptance. `SESSION_CONTEXT_COMPRESSION` + compresses a Session's context, `SESSION_RECOVERY_QUERY` reads its recovery + state, and `SESSION_RECOVERY_RECHECK` re-runs its recovery checks. ## Acceptance diff --git a/docs/design/2026-10-21-managed-capability-parity.zh-CN.md b/docs/design/2026-10-21-managed-capability-parity.zh-CN.md index d2c74d07c72..d7b71f1d8e4 100644 --- a/docs/design/2026-10-21-managed-capability-parity.zh-CN.md +++ b/docs/design/2026-10-21-managed-capability-parity.zh-CN.md @@ -54,7 +54,7 @@ mvn -Dtest=ManagedSurfaceCaptureTest -Dcapability.parity.capture=true test - 矩阵不判断某条路径是否应复制另一条路径。 - internal writer 路由不在 OpenAPI 文档中。只有注册表列出它们。 -- `managed only` 列由推导得出:列出副本中没有任何 `managed` 行引用的路由标签。排除集合记录某个标签为何不算缺口,且其中每一项都必须指向副本真实记录的标签。新挂载且没有旧标签、也没有排除理由的路由会出现在该列,这正是 #15075 要求的信号。剩余标签由 `MANAGED_ONLY_REPORTED` 固定,因此新增项会使测试失败,直到有人记录其记账方式。该列目前报告三个标签,每个都对应 public 与 WebShell 一对路由:`SESSION_CONTEXT_COMPRESSION` 压缩会话上下文,`SESSION_RECOVERY_QUERY` 读取会话恢复状态,`SESSION_RECOVERY_RECHECK` 重跑会话恢复检查。守护进程对这三者都没有标签。 +- `managed only` 列由推导得出:列出副本中没有任何 `managed` 行引用的路由标签。排除集合记录某个标签为何不算缺口,且其中每一项都必须指向副本真实记录的标签。新挂载且没有旧标签、也没有排除理由的路由会出现在该列,这正是 #15075 要求的信号。剩余标签由 `MANAGED_ONLY_REPORTED` 固定,因此新增项会使测试失败,直到有人记录其记账方式。该列目前报告十二个标签:九个 `EXTENSION_*` 标签描述包发布、archive 访问、设置、操作查询及 Session 配置。它们按 Session 固定版本的合同与守护进程的扩展 API 不同;登记这些标签不代表旧 API 兼容或平台验收。`SESSION_CONTEXT_COMPRESSION` 压缩会话上下文,`SESSION_RECOVERY_QUERY` 读取会话恢复状态,`SESSION_RECOVERY_RECHECK` 重跑会话恢复检查。 ## 验收 diff --git a/docs/design/2026-10-24-managed-extension-packages.md b/docs/design/2026-10-24-managed-extension-packages.md new file mode 100644 index 00000000000..c779ea2ca01 --- /dev/null +++ b/docs/design/2026-10-24-managed-extension-packages.md @@ -0,0 +1,211 @@ +# Managed extension packages + +[English](2026-10-24-managed-extension-packages.md) | [简体中文](2026-10-24-managed-extension-packages.zh-CN.md) + +## Status and baseline + +Implementation based on `101b3d416de4b7a7dca094afd475549013d8b7d5`. +The code is under verification. This document describes the accepted design, +not completed runtime qualification. + +## Problem and scope + +Managed Sessions can load published static Skills, MCP pins and Hook catalogs, +but cannot install, distribute and explicitly apply a complete extension package. +An ordinary installed directory is not portable: loaders expand host paths and +environment variables, converters can rewrite plugin roots, and release selection +uses the installer's platform. Copying it would move the wrong bytes or secrets. + +The first profile supports static Skills and assets, commands, declarative agents, +context, MCP, Hooks and versioned settings. It rejects dynamic Skill permissions, +Hooks, model overrides and path activation, agent-private MCP/Hooks, executors, +LSP, channels and workflows before lossy conversion. It does not build dependencies. +The execution targets are local Linux, SSH Linux and opt-in native macOS developer +process-group supervision. This is not full ordinary-CLI semantic compatibility. + +## Package and settings ownership + +Prepare original Git/npm/directory/archive inputs with existing acquisition, +conversion and parsing primitives, explicit target platform and explicit roots. +Never initialize Config or mutate process environment/cwd during preparation. +Keep placeholders until compilation on the target Runtime. Imported installed +extensions must be prepared again from their original source. + +Canonical archives are tar.gz with a root manifest. The package digest is SHA-256 +of archive bytes; metadata records the source revision, target and compiler version. +Enforce the existing 100 MiB compressed, 1 GiB expanded and 100,000-entry ceilings +for every source. Reject escaping resources and unsupported archive links. +External interpreters remain target dependencies, not package resources. + +The control plane owns immutable archives in a configured durable artifact +directory and SQL metadata, operations and references scoped to tenant/Workspace. +Use bounded binary streams, not base64 archives in JSON or Session resources. +Production replicas need shared durable storage. Runtime private-state directories +are verified caches, never the only recovery source. Retain installed versions, +Session/child pins, pending recovery and history references before collecting data. + +Settings are immutable encrypted revisions. Reuse SecretProtector/key rotation; +bind encryption context to tenant, Workspace, extension and settings revision. +Do not copy source Keychain selectors, .env or credential sidecars. Deliver exact +settings only to the selected Runtime and extension subprocess. Public responses, +journals and browser persistence contain references/configured keys, never values. + +Publishing and Runtime execution are separate disabled-by-default settings. The +control plane requires a configured durable artifact directory and permitted actor; +the Workspace OPERATOR grant remains mandatory. Runtime execution additionally +selects the platform supervision profile and private cache directory. Published +versions and settings are retained; this increment does not run GC. +See the [deployment configuration](../../packages/managed-agent-server/distribution/config/application.example.yml). + +## Session configuration and recovery + +Keep AgentDefinition identity and the base tool profile immutable. An effective +configuration combines the base definition, independent MCP selections, mandatory +base Hooks and selected package contributions. Create/load/resume/recovery use the +same binding; package pins cannot masquerade as definition pins or upgrade a profile. + +Session creation accepts optional `extensionPins`. Omission or null preserves the +legacy path; an explicit empty array selects an empty package configuration. +Include the exact pins in the creation idempotency digest and authorize package +membership and deployment policy before creating the Session. +Initial pins, including an explicit empty selection, require the supported Shell/1 +or MCP/1 profile with negotiated lifecycle v2 before a Session or creation receipt is persisted. Version 2 of the +existing creation receipt keeps immutable `initialSelection` beside the optional +static Skills snapshot; version 1 retains its meaning. No installation ledger or +schema column is added. Private create/load carries that same expected-revision-0 +command. Bootstrap through normal config_install before the first model submission, +and reject a different command or any bootstrap after the initial Turn was submitted. +An incomplete bootstrap keeps inference blocked and recovery targets its original owner. +Before that first submission, the exact initial command may also recover its previous +content as revision 2 under the normal successor checks. Child Sessions accept only +continuation or explicit recovery of their original bootstrap operation; an operator +cannot replace their package selection. Cold management loads stay passive so a failed +bootstrap can be repaired without admitting inference. + +SSH child execution requires a newly persisted public-child contract and the enabled +extension execution profile. Deployment changes do not promote an older Session. +Parent and child retain their own Runtime owners and credentials. A package launch +checks the parent's original READY binding, generation and active slot under lock; +remote worktree and snapshot children remain unavailable. + +Evolve the existing config_install domain with a closed tagged body, retaining +managed-config_install/1 and old MCP records. Sessions without packages keep the +old path. The first apply imports independent MCP/base Hooks; subsequent MCP writes +use the unified adapter, even after all packages are removed. Journal phase, +effective configuration and MCP component revisions are distinct counters. + +Apply is deliberately stop-and-reconfigure: prepare, stage immutable inputs and +verify both stage ACKs; atomically commit config_install/config.bound and an admission +barrier; drain changed Session-owned components; enable replacements; verify both +enable ACKs; publish active. Staging does not connect MCP or execute Hooks. A stage +failure leaves the old configuration usable. After commit, reconcile the original +operation/owner; unknown results retain the barrier and cannot be replayed elsewhere. + +Explicit recovery to previous content first proves replacement owners stopped, +then compiles that content into new effective catalog/configuration revisions. +Runtime reconciliation accepts the exact original prepared, pending or active operation, +including after a restart, and refuses to stop a newer configuration. +Never decrement Hook/MCP revisions, erase history, reset base once consumption or +re-run SessionStart. A Session update must not close/seal/retire the whole Runtime. + +## Component and platform wiring + +Keep source provenance and native naming/conflict rules for Skills. Legacy static +Skills with ambiguous origin conflict explicitly instead of being guessed/replaced. +Expose pinned package assets through a read-only source namespace, not widened +Workspace paths or file history. Shell extension context supplies target package +roots while retaining Session cwd. Cache drift refuses use and permits reconstruction. +Product read-only access does not establish a new same-UID Shell sandbox. + +The model-facing tool schema enumerates selected public package pins. Read and +Glob accept a package-relative path or `extension:///` with the +matching explicit source; instruction roots use that logical namespace. Shell +receives `QWEN_EXTENSION_ROOT` through its explicit extension context. No private +cache path or settings value is added to instruction text. Upgrading a selected +package preserves its exact settings revision; users may explicitly reuse the +latest saved settings revision without reading its values. + +Commands reuse parsers and injection/escaping rules. File/Shell expansion uses +stable Runtime invocations with Hooks, approval and durable receipts. Save expansion +before inference; recovery must not execute it twice. User order is +UserPromptSubmit, expansion, UserPromptExpansion, inference. Model command calls +dispatch through skill in their own batch; static Skills retain instruction-only +behavior. Child launch snapshots pin the parent binding, agent descriptor, tool +restrictions and configured model route; Java validates membership before creation. +Keep depth/quota/cancellation/relay contracts and never widen parent permissions. + +Target dependency preflight checks directly resolvable commands and interpreters +without running Hooks or connecting MCP. Authored complex Shell expressions remain +subject to the real approved invocation. Argument placeholders in nested Shell +substitutions, backticks and heredocs are explicitly refused rather than inserted +with an unproven escaping context. + +Linux owner shutdown uses process-tree evidence; SSH needs actual startup, scoped +configuration, drain, retirement and recovery implementations, not a boolean flip. +Native macOS developer mode is off by default and only proves registered process +groups stopped. Persist the supervision mode in registration and receipts. Old +proofs cannot certify new owners, and production verifiers reject developer proofs. +Only an original Runtime negotiating the new contract may apply packages. + +## Interfaces, rollout and verification + +Add Workspace package publication/catalog/settings/operation APIs and Session +selection/query/apply/recovery APIs to public/WebShell/SDK surfaces. Publication +requires Workspace OPERATOR and deployment source/actor/execution policy. Commands +use stable idempotency keys and expected configuration revisions. Status is read-only: +it never creates Sessions, cold-loads Harnesses or resubmits work. Reuse the manager +UI with a distinct Managed provider and abort stale target requests. +Catalogs contain at most 256 packages; a Session may select at most 32. +Only the explicit pre-admission `extension_config_revision_conflict` response +clears a pending apply and refreshes its configuration revision. Generic conflicts +and unknown outcomes retain the original selection and idempotency key. + +Implement protocol/storage, local execution, SSH/macOS profiles, then fault gates. +Test cross-platform source preparation, secrets/root isolation, cache reconstruction, +all seven components, apply/disable/remove/recovery, independent Sessions, preserved +base MCP/Hook state, commit/drain/enable/ACK loss, permissions and old logs/profiles. +Use real MySQL/process/SSH evidence and distinguish Linux cleanup from macOS scope. +Follow affected-package checks and the Java module's shared ten-minute local budget; +CI owns complete platform/fault matrices. Evidence lives in +`.qwen/e2e-tests/managed-extension-packages.md`; incomplete gates stay explicit. + +## Implementation and qualification + +The implementation includes the raw compiler, durable archive/settings APIs, +unified configuration ledger and barriers, Runtime component/asset wiring, +public child snapshots, SDKs and Managed WebShell provider. Both publication and +execution remain opt-in. A missing or drifted private cache is refused by ordinary +tools; explicit apply can verify the authoritative archive and reconstruct the +same private path without widening Workspace access. + +Focused TypeScript tests include two extensions whose real MCP/Hook subprocesses +consume identically named settings without seeing the other extension's values. +The native macOS worker HTTP slice exercises attestation, upload, activation, +read-only assets, file-history isolation and Shell. These tests do not establish +Linux process-tree or SSH cleanup. + +A real MySQL/control-plane/Hosted Harness/native macOS run reached initial active +revision 1, passed context and static Skill delivery, a real MCP effect, Hook +invocation and public approval. Its allotted Java window ended during command +expansion; command completion, child invocation, update, compensation and restart +non-replay remain unqualified by that run. It also exposed missing Hook setting +injection, subsequently fixed and verified by the isolated real-process test above. +Selected MySQL configuration/creation cases passed; a new child-admission case +reached an invalid test policy, which was corrected but not rerun locally. + +The module's shared local Java verification used approximately 596 seconds of its +600-second budget. Complete Java/SpotBugs/MySQL, seven-component execution, +Linux/SSH/macOS cleanup, permission revocation and commit/drain/start/ACK/response +fault matrices remain required CI gates. A Harness/control-plane restart may +reconnect a surviving original Runtime; this increment does not claim transparent +replacement of a lost Runtime process or recovery after host reboot. Lost original +owners keep their existing fail-closed recovery rules and must never replay work. + +The existing CI collects the new unit/contract/MySQL test classes, but does not yet +automate the complete package platform/fault matrix. The existing static +`HostedExtensionsIT` cannot substitute for that remaining qualification. + +## Open decisions + +None for the accepted first profile. Unsupported dynamic semantics require a +separate activation contract rather than silently expanding this profile. diff --git a/docs/design/2026-10-24-managed-extension-packages.zh-CN.md b/docs/design/2026-10-24-managed-extension-packages.zh-CN.md new file mode 100644 index 00000000000..228c3524570 --- /dev/null +++ b/docs/design/2026-10-24-managed-extension-packages.zh-CN.md @@ -0,0 +1,159 @@ +# Managed 扩展包 + +[English](2026-10-24-managed-extension-packages.md) | [简体中文](2026-10-24-managed-extension-packages.zh-CN.md) + +## 状态与基线 + +基于 `101b3d416de4b7a7dca094afd475549013d8b7d5` 实现。 +代码正在验证中。本文记录已采纳设计,不代表运行验收完成。 + +## 问题与范围 + +Managed Session 已能加载发布的静态 Skills、MCP pins 和 Hook catalog, +但不能安装、分发和显式应用完整扩展包。普通已安装目录不可直接移植: +加载器展开本机路径与环境变量,转换器会改写 plugin root,Release 选择依据安装机平台。 +直接复制会带入错误字节或凭据。 + +首期支持静态 Skills 与资产、commands、声明式 agents、context、MCP、Hooks 和版本化 settings。 +在有损转换前拒绝动态 Skill 权限、Hooks、模型覆盖、路径激活,agent 私有 MCP/Hook, +executor、LSP、channels 和 workflows。不构建依赖。 +执行目标为本地 Linux、SSH Linux,以及显式开启的原生 macOS 开发者进程组监管。 +这不表示兼容普通 CLI 的所有语义。 + +## 包与设置归属 + +对原始 Git/npm/目录/archive 输入复用现有获取、转换和解析原语,显式传入目标平台和根。 +准备时不初始化 Config,不修改共享进程环境或 cwd。 +保留占位符,直到目标 Runtime 编译。导入已安装扩展须重新获取原始来源。 + +规范 archive 为根目录包含 manifest 的 tar.gz;包摘要为 archive 字节的 SHA-256, +元数据记录来源修订、目标平台和编译器版本。所有来源统一执行既有压缩 100 MiB、 +展开 1 GiB、100,000 个条目的上限。拒绝逃逸资源和不支持的 archive 链接。 +外部解释器是目标依赖,不冒充包内资源。 + +控制面在配置的耐久 artifact 目录保存不可变 archive,SQL 按租户与 Workspace +保存元数据、操作和引用。使用有界二进制流,不把 base64 大包放进 JSON 或 Session 资源。 +生产多副本要求共享持久存储。Runtime 私有 state 目录仅作校验缓存,不是唯一恢复来源。 +安装版本、Session/child pin、未决恢复和历史引用未释放前,不回收字节。 + +Settings 使用不可变加密修订,复用 SecretProtector 与密钥轮换;加密上下文绑定租户、 +Workspace、extension 和 settings revision。不复制源 Keychain selector、.env 或凭据 sidecar。 +只向选定 Runtime 及所属扩展子进程交付精确设置。公开响应、日志和浏览器持久化仅含引用、 +已配置键,不含值。 + +发布和 Runtime 执行分别默认关闭。控制面要求配置耐久 artifact 目录及允许的 actor, +Workspace OPERATOR 授权仍为必要条件。Runtime 执行还需选择平台监管 profile 与私有缓存目录。 +此增量保留已发布版本和 settings,不执行 GC。 +参见[部署配置](../../packages/managed-agent-server/distribution/config/application.example.yml)。 + +## 会话配置与恢复 + +保持 AgentDefinition 身份与基础工具 profile 不变。effective configuration 组合基础定义、 +独立 MCP 选择、不可删除的基础 Hooks,以及所选包贡献项。 +创建、加载、resume、恢复读取同一 binding;包 pin 不冒充 definition pin,也不提升 profile。 + +创建 Session 接受可选的 `extensionPins`:省略或 null 保持旧流程,显式空数组选择空的新格式包配置。 +创建幂等摘要包含精确 pins,并在创建 Session 前核验包所属 Workspace 及部署权限。 +初始 pins(包括显式空选择)要求受支持且已协商 lifecycle v2 的 Shell/1 或 MCP/1 profile,在持久化 Session 或创建 receipt 前拒绝不兼容 profile。 +现有创建 receipt 的 v2 在可选静态 Skills 快照旁保存不可变的 `initialSelection`,v1 保留原语义, +不新增安装账本或 schema 列。私有 create/load 传递同一 expectedRevision 为 0 的命令, +首次模型提交前通过正常 config_install 完成 bootstrap;其他命令或首次 Turn 已提交后的 bootstrap 均拒绝。 +初始化未完成时阻止推理,恢复仍只针对原操作及原执行者。 +在首次提交前,精确初始命令也可通过正常 successor 校验,以第 2 版恢复之前内容。 +子 Session 只接受原 bootstrap 操作的继续或显式恢复,operator 不能替换其包选择。 +冷启动管理加载保持被动,让失败初始化可修复,同时继续阻止推理。 + +SSH 子执行要求新创建时持久化的 public-child 合同及已开启的 extension 执行 profile, +修改部署不会提升旧 Session。父子保留各自 Runtime owner 和凭据;包 agent 启动在锁内核验 +父配置的原 READY binding、generation 和 active slot。远程 worktree 与 snapshot 子任务仍不可用。 + +在既有 config_install domain 增加闭合 tagged 正文,保留 managed-config_install/1 和旧 MCP 记录。 +不使用包的会话保持旧路径。首次 apply 导入独立 MCP/基础 Hooks;进入新格式后, +即使移除全部包,MCP 写入也继续统一适配。日志 phase、实际配置和 MCP component revision 分开。 + +应用采用有停顿的排空重配:prepare,stage 不可变输入并核验双方 ACK;同事务提交 +config_install/config.bound 与准入屏障;排空变更的 Session 组件;启用替代项; +核验双方 enable ACK;发布 active。Stage 不连接 MCP、不执行 Hook。 +Stage 失败时旧配置继续可用;commit 后只对账原 operation/owner。 +结果未知时保持屏障,不在另一 Runtime 重放。 + +显式恢复上一版先证明替代 owner 已停止,再将旧内容编译为新的 effective catalog/configuration +修订。不降低 Hook/MCP 版本、不擦历史、不重置基础 once、不补跑 SessionStart。 +Runtime 对账接受精确的原 prepared、pending 或 active 操作,包括重启后的状态;不能停止较新的配置。 +更新一个 Session 不能关闭、封闭或退役整个 Runtime。 + +## 组件与平台接线 + +Skills 保留真实来源及原生命名、冲突规则;旧静态 Skill 来源不明时明确冲突,不猜测替换。 +固定包资产使用只读 source 命名空间,不放宽 Workspace 路径、不进入 file history。 +Shell 的扩展执行上下文提供目标包根,cwd 仍是 Session 目录。 +缓存漂移时拒绝使用并可重建。产品只读访问不意味着新增同 UID Shell 沙箱。 + +模型工具 schema 列出已选的公开 package pins。Read/Glob 在匹配的显式 source 下接受 +包内相对路径或 `extension:///`,指令根使用该逻辑命名空间。 +Shell 通过显式扩展上下文取得 `QWEN_EXTENSION_ROOT`。指令正文不添加私有缓存路径或设置值。 +升级所选包保留精确 settings revision;用户可以显式复用最新保存修订,无须读取值。 + +Commands 复用 parser、injection 与转义规则;文件/Shell 展开使用稳定 Runtime invocation, +遵守 Hook、审批和持久回执。先保存展开结果再推理,恢复不重复执行。 +用户顺序为 UserPromptSubmit、展开、UserPromptExpansion、推理。 +模型命令通过 skill 分派并使用独立批次;静态 Skills 保持仅加载指令。 +Child launch snapshot 固定父 binding、agent descriptor、工具限制和已配置模型路由, +Java 核对来源成员关系后创建。保留层数、限额、取消、中继,不扩大父权限。 + +目标依赖预检只检查可直接解析的命令和解释器,不运行 Hook、不连接 MCP。 +作者提供的复杂 Shell 表达式仍由真实审批调用检查。嵌套 Shell substitution、 +backtick 和 heredoc 中的参数占位符明确拒绝,不在未经证明的转义上下文中插入参数。 + +Linux owner 停止使用进程树证据。SSH 必须实现启动、按范围配置、排空、退役与恢复, +不能仅翻转布尔开关。原生 macOS 开发者模式默认关闭,只证明登记的进程组已停止。 +注册和回执持久保存监管模式。旧证明不能认证新 owner,生产验证器拒绝开发者证明。 +只有原 Runtime 协商新合同时才允许 apply。 + +## 接口、发布与验证 + +公开、WebShell、SDK 增加 Workspace 包发布/目录/settings/operation 接口, +以及 Session 选择、查询、apply、恢复接口。发布要求 Workspace OPERATOR 加部署来源、 +actor 和执行策略。命令使用稳定幂等键与预期配置版本。 +状态查询只读,不创建 Session、不冷加载 Harness、不重提操作。 +管理 UI 复用界面,使用独立 Managed provider,并取消旧目标请求。 +目录最多包含 256 个包,每个 Session 最多选择 32 个。 +只有明确在受理前返回的 `extension_config_revision_conflict` 才清除 pending apply 并刷新配置版本; +通用冲突和未知结果保留原选择及幂等键。 + +按协议/存储、本地执行、SSH/macOS profile、故障门禁顺序实施。 +覆盖跨平台准备、凭据与根隔离、缓存重建、七类组件、应用/停用/移除/恢复、 +同 Workspace 会话隔离、基础 MCP/Hook 保留、commit/drain/enable/ACK 丢失、权限和旧记录/profile。 +使用真实 MySQL、进程和 SSH 证据,区分 Linux 清理与 macOS 范围。 +遵守受影响包检查和 Java 模块共享十分钟本地验证预算;CI 承担完整平台/故障矩阵。 +证据记录在 `.qwen/e2e-tests/managed-extension-packages.md`,缺失门禁保持明确。 + +## 实现与验收 + +实现包括原始包编译器、耐久 archive/settings API、统一配置账本与屏障、Runtime 组件及 +资产接线、公开 child snapshot、SDK 和 Managed WebShell provider。发布与执行仍需显式开启。 +普通工具拒绝缺失或漂移的私有缓存;显式 apply 可以校验权威 archive 并重建原私有路径, +不放宽 Workspace 访问。 + +定向 TypeScript 测试包含两个扩展的真实 MCP/Hook 子进程:读取同名设置,同时看不到 +另一扩展的值。原生 macOS worker HTTP 切片覆盖身份核验、上传、激活、只读资产、 +file-history 隔离和 Shell。这些测试不证明 Linux 进程树或 SSH 清理。 + +真实 MySQL/控制面/Hosted Harness/原生 macOS 运行达到初始 active revision 1, +通过 context、静态 Skill 交付、真实 MCP 副作用、Hook 调用和公开审批。 +分配的 Java 窗口在 command 展开期间结束;此运行尚未验收 command 完成、child 调用、 +更新、补偿及重启不重放。它也暴露了 Hook settings 注入缺失,随后已修复,并通过上述 +独立真实进程测试。所选 MySQL 配置及创建用例通过;新增 child 准入用例触发了无效测试策略, +fixture 已修正,但未在本地重跑。 + +Java 模块共享本地验证预算已用约 596/600 秒。完整 Java/SpotBugs/MySQL、七类组件执行、 +Linux/SSH/macOS 清理、权限撤销以及 commit/drain/start/ACK/response 故障矩阵, +仍是必要 CI 门禁。Harness/控制面重启可以重连仍存活的原 Runtime;此增量不宣称透明替换 +丢失的 Runtime 进程或主机重启恢复。原 owner 丢失沿用既有 fail-closed 恢复规则,不能重放工作。 + +现有 CI 会收集新增单元、合同及 MySQL 测试类,但尚未自动化完整包的平台/故障矩阵。 +既有静态 `HostedExtensionsIT` 不能替代这部分剩余验收。 + +## 未决决策 + +首期已采纳范围没有未决产品选择。不支持的动态语义另行实现激活合同,不能静默扩大本 profile。 diff --git a/packages/cli/src/serve/capability-parity.test.ts b/packages/cli/src/serve/capability-parity.test.ts index 7d33e078389..5186855304a 100644 --- a/packages/cli/src/serve/capability-parity.test.ts +++ b/packages/cli/src/serve/capability-parity.test.ts @@ -39,6 +39,7 @@ const MANAGED_SURFACE_FILE = resolve( const ROUTE_METHODS: ReadonlySet = new Set([ 'GET', 'POST', + 'PUT', 'PATCH', 'DELETE', ]); diff --git a/packages/cli/src/serve/capability-parity.ts b/packages/cli/src/serve/capability-parity.ts index 0f769f2458b..9c82f111afc 100644 --- a/packages/cli/src/serve/capability-parity.ts +++ b/packages/cli/src/serve/capability-parity.ts @@ -330,10 +330,10 @@ export const CAPABILITY_PARITY: Record = { 'no Managed session refresh route', ), extension_batch_activation_v2: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), extension_activation_explicit_refresh: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), workspace_skill_manage: NO_MANAGED_EQUIVALENT( 'no Managed skill management route', @@ -410,7 +410,7 @@ export const CAPABILITY_PARITY: Record = { workspace_hooks: { standing: 'managed', evidence: 'surface:HOOK_CATALOG' }, session_hooks: { standing: 'managed', evidence: 'surface:HOOK_CATALOG' }, workspace_extensions: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), session_branch: NO_MANAGED_EQUIVALENT('no Managed branch route'), session_branch_worktree: NO_MANAGED_EQUIVALENT('no Managed branch route'), @@ -487,19 +487,19 @@ export const CAPABILITY_PARITY: Record = { 'issue #15075: no Managed memory CRUD', ), extension_management_v2: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), extension_list_details: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), extension_state: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), extension_git_credentials: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), extension_local_path_install: NO_MANAGED_EQUIVALENT( - 'no Managed extension management route', + 'Managed packages use a separate Session-pinned lifecycle contract', ), workspace_persisted_transcript: { standing: 'managed', @@ -620,13 +620,22 @@ export const MANAGED_ONLY_NOT_A_GAP = new Set([ * column checked — a route added or removed without a decision fails the * suite — while the signal stays visible to whoever reads the matrix. * - * Each label is a route pair on the public and the WebShell surface: + * The extension labels describe the Managed package lifecycle, whose + * Session-pinned contract differs from the daemon's extension APIs. * `SESSION_CONTEXT_COMPRESSION` compresses a Session's context, * `SESSION_RECOVERY_QUERY` reads a Session's recovery state and - * `SESSION_RECOVERY_RECHECK` re-runs its recovery checks. The daemon - * advertises no tag for any of the three. + * `SESSION_RECOVERY_RECHECK` re-runs its recovery checks. */ export const MANAGED_ONLY_REPORTED: readonly string[] = [ + 'EXTENSION_ARCHIVE', + 'EXTENSION_CONFIG_APPLY', + 'EXTENSION_CONFIG_GET', + 'EXTENSION_GET', + 'EXTENSION_LIST', + 'EXTENSION_OPERATION', + 'EXTENSION_PUBLISH', + 'EXTENSION_SETTINGS_GET', + 'EXTENSION_SETTINGS_SET', 'SESSION_CONTEXT_COMPRESSION', 'SESSION_RECOVERY_QUERY', 'SESSION_RECOVERY_RECHECK', diff --git a/packages/cli/src/serve/hosted-approval-continuation.test.ts b/packages/cli/src/serve/hosted-approval-continuation.test.ts index 81d46beee7b..621e63112f9 100644 --- a/packages/cli/src/serve/hosted-approval-continuation.test.ts +++ b/packages/cli/src/serve/hosted-approval-continuation.test.ts @@ -30,6 +30,7 @@ import { import { findHostedApprovalContinuation } from './hosted-approval-continuation.js'; import { HostedChildAgentSession } from './hosted-child-agent-session.js'; import { hostedPublicPlanActive } from './hosted-plan-mode.js'; +import { HostedSkillCatalog } from './hosted-skill-catalog.js'; import { slowHostBudgetMs } from '../test-utils/slow-test-host.js'; import type { HttpToolPublicationOwner } from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js'; @@ -80,6 +81,16 @@ it.each( ), ), ) + .concat( + ['allow', 'deny'].map((decision) => ({ + decision, + promptId: randomUUID(), + round: 1, + planMode: false, + profile: 'hosted-workspace-shell/1', + publicTool: 'skill', + })), + ) .concat( ['allow', 'deny', 'cancelled', 'expired'].flatMap((decision) => ['write_file', 'web_fetch', 'agent', 'run_shell_command'].map( @@ -211,6 +222,34 @@ it.each( const prepare = vi.spyOn(HostedWorkspaceBroker.prototype, 'prepare'); const execute = vi.spyOn(HostedWorkspaceBroker.prototype, 'execute'); const publicShell = profile === 'hosted-workspace-shell/1' && !planMode; + const commandSkills = + publicTool === 'skill' + ? new HostedSkillCatalog( + [], + [ + { + name: 'sample:run', + description: 'Run once', + prompt: '!{printf %s {{args}}}', + extensionId: 'sample', + modelInvocable: true, + }, + ], + ) + : undefined; + const extensionConfig = { + revision: 1, + packages: [ + { + pin: { + extensionId: 'sample', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }, + assetRoot: 'extension://sample/', + }, + ], + }; const publication = publicShell ? { owner: { @@ -251,6 +290,7 @@ it.each( }, { profile, + ...(commandSkills ? { skills: commandSkills, extensionConfig } : {}), publicPlanMode: planMode, ...(profile === 'hosted-workspace-shell/1' && !planMode ? { @@ -276,17 +316,22 @@ it.each( callId: 'original-call', args: planMode ? { plan: '# Plan\nRead the files, then update original.txt.' } - : publicTool === 'agent' - ? { - description: 'audit', - prompt: 'review the file', - run_in_background: true, - } - : publicTool === 'run_shell_command' - ? { command: 'printf once' } - : publicTool === 'web_fetch' - ? { url: 'https://example.com/docs', prompt: 'Summarize' } - : { file_path: 'original.txt', content: 'one original batch' }, + : publicTool === 'skill' + ? { skill: 'sample:run', args: 'argument' } + : publicTool === 'agent' + ? { + description: 'audit', + prompt: 'review the file', + run_in_background: true, + } + : publicTool === 'run_shell_command' + ? { command: 'printf once' } + : publicTool === 'web_fetch' + ? { url: 'https://example.com/docs', prompt: 'Summarize' } + : { + file_path: 'original.txt', + content: 'one original batch', + }, isClientInitiated: false, prompt_id: promptId, }; @@ -411,6 +456,45 @@ it.each( continuation: { phase: 'model_output_committed' }, }, }); + if (publicTool === 'skill') { + expect(saved!.plan.commandStep).toMatchObject({ + toolName: 'shell', + configRevision: 1, + }); + if (decision === 'allow') { + const commandExecute = vi + .spyOn(HostedWorkspaceBroker.prototype, 'extensionCommand') + .mockResolvedValue({ + state: 'settled', + result: { + executionStatus: 'success', + responseParts: [{ text: 'ran once' }], + }, + }); + const resumed = new HostedWorkspaceToolTurn( + { baseUrl: 'http://127.0.0.1:1', token: 'fixture-no-traffic' }, + replacement, + harness, + promptId, + vi.fn().mockResolvedValue('unused'), + () => true, + publication, + undefined, + { + settings: { mode: 'default', timeoutMs: 60_000 }, + waiters: new HostedApprovalWaiters(), + }, + { profile, skills: commandSkills, extensionConfig }, + ); + const result = await resumed.resumeSavedNativeBatch( + saved!, + 1, + new AbortController().signal, + ); + expect(JSON.stringify(result)).toContain('ran once'); + expect(commandExecute).toHaveBeenCalledTimes(1); + } + } if (publicShell && publicTool === 'run_shell_command') { expect(saved!.plan.calls[0].publicationId).toMatch( /^[a-f0-9-]{36}$/u, diff --git a/packages/cli/src/serve/hosted-approval-continuation.ts b/packages/cli/src/serve/hosted-approval-continuation.ts index e6b895d704d..29cb8cdde20 100644 --- a/packages/cli/src/serve/hosted-approval-continuation.ts +++ b/packages/cli/src/serve/hosted-approval-continuation.ts @@ -21,6 +21,7 @@ import { assertManagedSessionStableId, type ManagedSessionDurableRef, type ManagedSessionKey, + type ManagedSessionJsonValue, } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; import { hostedActionAllowed, @@ -87,6 +88,12 @@ export interface HostedApprovalContinuation { readonly stage: 'approval' | 'final'; readonly approvalOrdinal: number; readonly actionId: string | null; + readonly commandStep?: { + readonly toolStageId: string; + readonly toolName: 'read_file' | 'shell'; + readonly inputRef: ManagedSessionDurableRef; + readonly configRevision: number; + }; readonly calls: readonly HostedNativePlannedCall[]; } @@ -114,7 +121,8 @@ export async function readHostedApprovalContinuation( const plan = raw as unknown as HostedApprovalContinuation; const header = session.authority.sessionHeader; if ( - Object.keys(raw).length !== 15 + (plan.publicChildModel ? 1 : 0) || + Object.keys(raw).length !== + 15 + (plan.publicChildModel ? 1 : 0) + (plan.commandStep ? 1 : 0) || plan.promptId !== promptId || !['approval', 'final'].includes(plan.stage) || !isDeepStrictEqual(plan.definitionRef, header.definitionRef) || @@ -128,7 +136,8 @@ export async function readHostedApprovalContinuation( plan.approvalOrdinal >= plan.calls.length || !plan.runtime || Object.keys(plan.runtime).length !== 4 || - plan.runtime.runtimeSessionId !== hostedRuntimeSessionId(promptId) || + (!plan.commandStep && + plan.runtime.runtimeSessionId !== hostedRuntimeSessionId(promptId)) || !/^[1-9][0-9]{0,18}$/u.test(plan.runtime.generation) || !/^[1-9][0-9]{0,18}$/u.test(plan.runtime.workspaceGeneration) || BigInt(plan.runtime.generation) > 2n ** 63n - 1n || @@ -141,6 +150,27 @@ export async function readHostedApprovalContinuation( throw new Error( 'Hosted approval continuation conflicts with the original Turn.', ); + if (plan.commandStep) { + if ( + Object.keys(plan.commandStep).sort().join(',') !== + 'configRevision,inputRef,toolName,toolStageId' || + !Number.isSafeInteger(plan.commandStep.configRevision) || + plan.commandStep.configRevision < 1 || + !['read_file', 'shell'].includes(plan.commandStep.toolName) || + plan.stage !== 'approval' || + plan.calls.some((item) => item.call.name !== 'skill') + ) + throw new Error('Invalid command step continuation.'); + assertManagedSessionStableId(plan.commandStep.toolStageId, 'command step'); + assertManagedSessionStableId( + plan.runtime.runtimeSessionId, + 'command owner', + ); + assertManagedSessionDurableRef( + plan.commandStep.inputRef as unknown as ManagedSessionJsonValue, + 'command approval input', + ); + } for (const id of [ plan.batchId, plan.runtime.bindingId, @@ -217,6 +247,7 @@ export async function readHostedApprovalContinuation( !item.call || !( NATIVE_TOOLS.has(item.call.name) || + (plan.commandStep && item.call.name === 'skill') || publicPlanExit || (publicShell && ['agent', 'run_shell_command'].includes(item.call.name)) ) || @@ -233,8 +264,8 @@ export async function readHostedApprovalContinuation( parts[item.partIndex]?.functionCall?.id !== item.call.callId || parts[item.partIndex]?.functionCall?.name !== item.call.name || item.prepareKey !== - `${hostedRuntimeSessionId(promptId)}:${item.runtimeCallId}` || - item.prepareReference?.sessionId !== hostedRuntimeSessionId(promptId) || + `${plan.runtime.runtimeSessionId}:${item.runtimeCallId}` || + item.prepareReference?.sessionId !== plan.runtime.runtimeSessionId || item.prepareReference.promptId !== promptId || item.prepareReference.callId !== item.runtimeCallId || item.prepareReference.argsDigest !== item.requestDigest || @@ -266,7 +297,7 @@ export async function readHostedApprovalContinuation( }; if ( saved.harnessSessionId !== header.sessionKey.sessionId || - saved.runtimeSessionId !== hostedRuntimeSessionId(promptId) || + saved.runtimeSessionId !== plan.runtime.runtimeSessionId || typeof saved.payloadJson !== 'string' || item.requestDigest !== `sha256:${createHash('sha256').update(saved.payloadJson).digest('hex')}` @@ -306,9 +337,19 @@ export async function readHostedApprovalContinuation( options.v !== 3 || options.functionCallId !== item.call.callId || options.turnId !== promptId || - options.toolName !== item.call.name || + options.toolName !== + (plan.commandStep && ordinal === plan.approvalOrdinal + ? plan.commandStep.toolName === 'shell' + ? 'run_shell_command' + : 'read_file' + : item.call.name) || (options.inputRef && - !isDeepStrictEqual(options.inputRef, item.inputRef)) || + !isDeepStrictEqual( + options.inputRef, + plan.commandStep && ordinal === plan.approvalOrdinal + ? plan.commandStep.inputRef + : item.inputRef, + )) || (action.state === 'decided' && !hostedActionAllowed(action, options.policyRevision) && !hostedActionDenied(action, options.policyRevision)) || diff --git a/packages/cli/src/serve/hosted-extension-agent.ts b/packages/cli/src/serve/hosted-extension-agent.ts new file mode 100644 index 00000000000..85f55d18634 --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-agent.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { + ManagedExtensionApplyCommand, + ManagedExtensionPin, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import { + parsePublicChildExtensionAgent, + type PublicChildExtensionAgent, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; +import { + assertManagedSessionDurableRef, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; + +export interface HostedChildExtensionAgent { + readonly extensionAgent: PublicChildExtensionAgent; + readonly effectiveConfigRef: ManagedSessionDurableRef; +} + +export function parseHostedChildExtensionAgent( + value: unknown, +): HostedChildExtensionAgent | undefined { + if (value === undefined) return undefined; + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join(',') !== 'effectiveConfigRef,extensionAgent' + ) + throw new Error('Invalid child extension binding.'); + const fields = value as Record; + const effectiveConfigRef = assertManagedSessionDurableRef( + fields['effectiveConfigRef'], + 'child extension source', + ); + const extensionAgent = parsePublicChildExtensionAgent( + fields['extensionAgent'], + ); + if ( + effectiveConfigRef.kind !== 'managed-extension-config-bundle' || + effectiveConfigRef.schemaVersion !== 1 || + effectiveConfigRef.digest !== extensionAgent.bundleRef.digest + ) + throw new Error( + 'Child extension source does not match its parent binding.', + ); + return { extensionAgent, effectiveConfigRef }; +} + +export function hostedChildExtensionCommandId( + sessionId: string, + sourceDigest: string, +): string { + const bytes = createHash('sha256') + .update(`managed-extension-child:${sessionId}:${sourceDigest}`) + .digest() + .subarray(0, 16); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function hostedChildExtensionApplyCommand( + sessionId: string, + sourceDigest: string, + extensions: readonly ManagedExtensionPin[], +): ManagedExtensionApplyCommand { + return { + operationId: hostedChildExtensionCommandId(sessionId, sourceDigest), + actorKey: createHash('sha256') + .update(`managed-extension-child:${sessionId}`) + .digest('hex'), + expectedRevision: 0, + extensions, + }; +} diff --git a/packages/cli/src/serve/hosted-extension-command.test.ts b/packages/cli/src/serve/hosted-extension-command.test.ts new file mode 100644 index 00000000000..5ed26315a35 --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-command.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; +import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import type { ManagedToolStage } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-stage-record.js'; +import type { HostedWorkspaceBroker } from './hosted-workspace-broker.js'; +import { expandHostedExtensionCommand } from './hosted-extension-command.js'; + +function fixture(prompt: string) { + const bytes = new Map(); + const records = new Map< + string, + { + record: ManagedToolStage; + revision: number; + recordRef: ManagedSessionDurableRef; + operationId: string; + } + >(); + const publish = async (kind: string, content: Buffer) => { + const digest = createHash('sha256').update(content).digest('hex'); + const resourceId = `resource-${bytes.size}`; + bytes.set(resourceId, content); + return { + kind, + resourceId, + schemaVersion: 1, + byteLength: content.length, + digest, + }; + }; + const session = { + resources: { + publish, + read: async (ref: ManagedSessionDurableRef) => bytes.get(ref.resourceId)!, + }, + authority: { + sessionHeader: { + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: 'session', + }, + }, + extensionRecord: (_domain: string, id: string) => records.get(id), + commitExtensionRecord: async ( + _command: unknown, + request: { record: ManagedToolStage }, + ) => { + const previous = records.get(request.record.toolStageId); + const recordRef = await publish( + 'managed-tool_stage', + Buffer.from(JSON.stringify(request.record)), + ); + records.set(request.record.toolStageId, { + record: request.record, + recordRef, + revision: (previous?.revision ?? 0) + 1, + operationId: request.record.toolStageId, + }); + }, + issueOperationGrant: vi.fn((request: Record) => request), + }, + } as unknown as ManagedSession; + const control = vi.fn(async () => ({ + state: 'settled', + result: { executionStatus: 'success', responseParts: [{ text: 'RESULT' }] }, + })); + const broker = { + runtimeSessionId: 'owner', + runtime: { + bindingId: 'binding', + generation: '1', + workspaceGeneration: '1', + }, + extensionCommand: control, + } as unknown as HostedWorkspaceBroker; + const decide = vi.fn( + async ( + _id: string, + toolName: 'read_file' | 'shell', + input: Record, + ) => ({ + allowed: true, + toolName, + input, + approvalMode: 'default' as const, + preHookOccurrence: null, + permissionHookOccurrence: null, + actionRequestId: null, + }), + ); + const completed = vi.fn(async () => undefined); + const options = { + session, + broker, + promptId: 'prompt', + callId: 'call', + revision: 1, + command: { + name: 'sample:run', + description: 'Run sample', + prompt, + extensionId: 'sample', + modelInvocable: true, + }, + args: 'hello; $(touch /tmp/never)', + signal: new AbortController().signal, + definitionDigest: () => 'a'.repeat(64), + decide, + completed, + }; + return { options, records, control, decide, completed }; +} + +describe('Hosted durable command expansion', () => { + it('expands pure prompt arguments without starting Runtime work', async () => { + const context = fixture('Review {{args}}'); + const prompt = await expandHostedExtensionCommand(context.options); + expect(prompt).toContain('Review hello; $(touch /tmp/never)'); + expect(context.decide).not.toHaveBeenCalled(); + expect(context.control).not.toHaveBeenCalled(); + }); + + it('escapes arguments inside Shell and reuses the committed result', async () => { + const context = fixture('Before !{printf %s {{args}}} after'); + expect(await expandHostedExtensionCommand(context.options)).toContain( + 'Before RESULT after', + ); + expect(context.decide.mock.calls[0]?.[2]).toMatchObject({ + command: "printf %s 'hello; $(touch /tmp/never)'", + }); + const stage = [...context.records.values()][0]!.record; + expect(stage.run.state).toBe('settled'); + expect(stage.permissionRef).not.toBeNull(); + expect(await expandHostedExtensionCommand(context.options)).toContain( + 'Before RESULT after', + ); + expect(context.control).toHaveBeenCalledTimes(1); + expect(context.decide).toHaveBeenCalledTimes(1); + }); + + it('queries an unknown original step without dispatching it again', async () => { + const context = fixture('!{pwd}'); + context.control.mockResolvedValueOnce({ + state: 'outcome_unknown', + } as never); + await expect(expandHostedExtensionCommand(context.options)).rejects.toThrow( + 'outcome is unknown', + ); + expect(await expandHostedExtensionCommand(context.options)).toContain( + 'RESULT', + ); + expect( + context.control.mock.calls.map( + (call) => (call as unknown as [{ action: string }])[0].action, + ), + ).toEqual(['execute', 'status']); + expect(context.decide).toHaveBeenCalledTimes(1); + }); + + it('keeps quoted Shell placeholders literal', async () => { + const context = fixture('!{printf "%s" "{{args}}"}'); + await expandHostedExtensionCommand(context.options); + expect(context.decide.mock.calls[0]?.[2]).toMatchObject({ + command: 'printf "%s" "hello; \\$(touch /tmp/never)"', + }); + }); +}); diff --git a/packages/cli/src/serve/hosted-extension-command.ts b/packages/cli/src/serve/hosted-extension-command.ts new file mode 100644 index 00000000000..44c809f5ce7 --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-command.ts @@ -0,0 +1,370 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; +import { + parseManagedToolStage, + type ManagedToolStage, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-stage-record.js'; +import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import type { HostedCommand } from './hosted-skill-catalog.js'; +import type { HostedWorkspaceBroker } from './hosted-workspace-broker.js'; +import { extractInjections } from '../services/prompt-processors/injectionParser.js'; +export class HostedCommandExpansionRefusedError extends Error {} + +export interface HostedCommandPermission { + readonly allowed: boolean; + readonly toolName: 'read_file' | 'shell'; + readonly input: Record; + readonly inputRef?: ManagedSessionDurableRef; + readonly approvalMode: 'default' | 'auto-edit' | 'auto' | 'yolo'; + readonly preHookOccurrence: string | null; + readonly permissionHookOccurrence: string | null; + readonly actionRequestId: string | null; +} + +export async function expandHostedExtensionCommand(options: { + session: ManagedSession; + broker: HostedWorkspaceBroker; + promptId: string; + callId: string; + revision: number; + command: HostedCommand; + args: string; + signal: AbortSignal; + definitionDigest(toolName: 'read_file' | 'shell'): string; + decide( + toolStageId: string, + toolName: 'read_file' | 'shell', + input: Record, + ): Promise; + completed( + toolStageId: string, + toolName: 'read_file' | 'shell', + input: Record, + result: Record, + ): Promise; +}): Promise { + const { session, broker, command, signal } = options; + const expansionId = `command-${hash([session.authority.sessionHeader.sessionKey, options.promptId, options.callId, options.revision, command.name])}`; + const original = command.prompt; + const injections = [ + ...injectionsFor('!{').map((entry) => ({ + ...entry, + toolName: 'shell' as const, + })), + ...injectionsFor('@{').map((entry) => ({ + ...entry, + toolName: 'read_file' as const, + })), + ].sort((a, b) => a.startIndex - b.startIndex); + if ( + injections.some( + (entry, index) => + index > 0 && entry.startIndex < injections[index - 1]!.endIndex, + ) + ) + throw new HostedCommandExpansionRefusedError( + 'Nested file and shell command preprocessing is unsupported.', + ); + let prompt = ''; + let offset = 0; + for (const [stepIndex, injection] of injections.entries()) { + signal.throwIfAborted(); + const toolStageId = `command-step-${hash([expansionId, stepIndex])}`; + prompt += raw(original.slice(offset, injection.startIndex)); + offset = injection.endIndex; + if (!injection.content) continue; + const input: Record = + injection.toolName === 'shell' + ? { + command: renderShellArguments( + injection.content, + options.args, + ).replaceAll('${extensionPath}', '${QWEN_EXTENSION_ROOT}'), + timeout: 120000, + } + : { file_path: raw(injection.content) }; + const saved = session.authority.extensionRecord('tool_stage', toolStageId); + let stage: ManagedToolStage; + if (saved) { + stage = parseManagedToolStage(saved.record); + if ( + stage.expansionId !== expansionId || + stage.stepIndex !== stepIndex || + stage.toolName !== injection.toolName + ) + throw new Error('Command preprocessing identity conflicts.'); + } else { + const decision = await options.decide( + toolStageId, + injection.toolName, + input, + ); + const inputRef = + decision.inputRef ?? + (await publish('managed-extension-command-input', decision.input)); + const { input: _input, inputRef: _inputRef, ...permission } = decision; + const permissionRef = await publish( + 'managed-extension-command-permission', + { ...permission, inputRef }, + ); + stage = { + kind: 'tool_stage', + toolStageId, + expansionId, + stepIndex, + toolName: injection.toolName, + inputRef, + permissionRef, + resultRef: null, + run: { + state: 'admitted', + reason: null, + definition: { + definitionId: injection.toolName, + definitionRevision: 1, + definitionDigest: options.definitionDigest(injection.toolName), + }, + executionCallId: toolStageId, + effectId: null, + dispatchId: null, + deliveryId: null, + execution: 'intent', + runtime: null, + delivery: null, + }, + }; + await commit(stage); + if (!decision.allowed) { + const result = { + executionStatus: 'not_started', + responseParts: [ + { text: 'The command preprocessing step was denied.' }, + ], + }; + stage = { + ...stage, + resultRef: await publish('managed-extension-command-result', result), + run: { + ...stage.run, + state: 'cancelled', + execution: 'not_started_proven', + }, + }; + await commit(stage); + } + } + const actualInput = await read>(stage.inputRef); + let result: Record; + if (stage.resultRef) result = await read(stage.resultRef); + else { + if (!broker.runtime) + throw new Error('Command Runtime owner is unavailable.'); + const runtime = { + runtimeBindingId: broker.runtime.bindingId, + generation: broker.runtime.generation, + }; + if (stage.run.runtime && !isDeepStrictEqual(stage.run.runtime, runtime)) + throw new Error( + 'Command preprocessing belongs to its original Runtime.', + ); + const resume = stage.run.execution !== 'intent'; + if (!resume) { + stage = { + ...stage, + run: { + ...stage.run, + state: 'running', + execution: 'dispatch_started', + runtime, + }, + }; + await commit(stage); + } + const operation = { + kind: 'extension-command' as const, + operationId: toolStageId, + toolName: injection.toolName, + input: actualInput, + revision: options.revision, + extensionId: command.extensionId, + }; + const send = (action: 'execute' | 'status' | 'cancel') => + broker.extensionCommand({ + ...operation, + action, + grant: session.authority.issueOperationGrant({ + domain: 'tool_stage', + recordId: toolStageId, + ownerId: broker.runtimeSessionId, + workspaceGeneration: broker.runtime!.workspaceGeneration, + phases: ['execute', 'status', 'cancel'], + leaseDurationMs: 60_000, + }), + }); + let response = await send(resume ? 'status' : 'execute'); + while (response['state'] === 'running') { + if (signal.aborted) { + response = await send('cancel'); + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + response = await send('status'); + } + if ( + response['state'] !== 'settled' || + !response['result'] || + typeof response['result'] !== 'object' + ) + throw new Error( + 'Command preprocessing outcome is unknown; query its original operation.', + ); + result = response['result'] as Record; + stage = { + ...stage, + resultRef: await publish('managed-extension-command-result', result), + run: { + ...stage.run, + state: result['executionStatus'] === 'success' ? 'settled' : 'failed', + execution: 'settled', + }, + }; + await commit(stage); + } + await options.completed( + toolStageId, + injection.toolName, + actualInput, + result, + ); + if (result['executionStatus'] !== 'success') + throw new HostedCommandExpansionRefusedError( + 'Command preprocessing did not complete successfully.', + ); + if (!Array.isArray(result['responseParts'])) + throw new Error('Command preprocessing returned invalid text.'); + const output = result['responseParts'] + .map((part: unknown) => + part && + typeof part === 'object' && + 'text' in part && + typeof part.text === 'string' + ? part.text + : '', + ) + .join('\n'); + if (Buffer.byteLength(output) > 48 * 1024) + throw new HostedCommandExpansionRefusedError( + 'Command preprocessing output exceeds the inline limit.', + ); + prompt += output; + } + prompt += raw(original.slice(offset)); + if ( + !original.includes('{{args}}') && + !original.includes('$ARGUMENTS') && + options.args.trim() + ) + prompt += `\n\n/${command.name} ${options.args}`; + if (Buffer.byteLength(prompt) > 48 * 1024) + throw new HostedCommandExpansionRefusedError( + 'Expanded command exceeds the inline limit.', + ); + return `\n${prompt}\n`; + + function raw(text: string): string { + return text + .replaceAll('{{args}}', options.args) + .replaceAll('$ARGUMENTS', options.args) + .replaceAll('${extensionPath}', `extension://${command.extensionId}`); + } + function injectionsFor(trigger: string) { + try { + return extractInjections(original, trigger, command.name); + } catch (cause) { + throw new HostedCommandExpansionRefusedError( + cause instanceof Error + ? cause.message + : 'Invalid command preprocessing.', + ); + } + } + function publish(kind: string, value: unknown) { + return session.resources.publish(kind, Buffer.from(JSON.stringify(value))); + } + function read(ref: ManagedSessionDurableRef): Promise { + return session.resources + .read(ref) + .then((bytes) => JSON.parse(bytes.toString('utf8')) as T); + } + async function commit(stage: ManagedToolStage): Promise { + const previous = session.authority.extensionRecord( + 'tool_stage', + stage.toolStageId, + ); + await session.authority.commitExtensionRecord( + { + operation: 'commitCommandToolStage', + commandId: previous + ? `${stage.toolStageId}:${previous.revision + 1}` + : stage.toolStageId, + sessionKey: session.authority.sessionHeader.sessionKey, + contentDigest: hash(stage), + }, + { domain: 'tool_stage', record: stage }, + { class: 'trusted_entry' }, + ); + } +} + +function hash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} +function quote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function renderShellArguments(template: string, args: string): string { + if ( + (template.includes('{{args}}') || template.includes('$ARGUMENTS')) && + /`|\$\(|< Promise> = []; +afterEach(async () => { + vi.restoreAllMocks(); + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), 'managed-extension-apply-')); + const sessionKey = { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: randomUUID(), + }; + const resources = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: root, + sessionKey, + }); + const session = await openManagedSession({ + runtimeBaseDir: root, + cwd: root, + transcriptPath: path.join(root, 'transcript.jsonl'), + sessionId: sessionKey.sessionId, + sessionKey, + version: 'test', + workerId: 'test', + activationLeaseDurationMs: 60_000, + create: { + definitionRef: await resources.publish( + 'managed-definition', + Buffer.from('{}'), + ), + rootSnapshotRef: await resources.publish( + 'managed-root', + Buffer.from('{}'), + ), + createdBy: 'test', + }, + }); + cleanups.push(async () => { + await session.close(); + await rm(root, { recursive: true, force: true }); + }); + const owners = { + bindingId: 'original-binding', + generation: '1', + workspaceGeneration: '1', + }; + const warm = vi + .spyOn(HostedWorkspaceBroker.prototype, 'warm') + .mockImplementation(async function (this: HostedWorkspaceBroker) { + this.runtime = owners; + }); + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'capabilityDigest', + 'get', + ).mockReturnValue(MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST); + const states = new Map(); + let rejectPrepare = false; + let rejectActivation = false; + const control = vi + .spyOn(HostedWorkspaceBroker.prototype, 'extensionControl') + .mockImplementation(async function ( + this: HostedWorkspaceBroker, + operation: ManagedExtensionRuntimeControl, + ) { + this.runtime = owners; + const common = { + operationId: operation.operationId, + executionMode: 'developer-process-group', + }; + if (operation.kind === 'extension-prepare') { + if (rejectPrepare) throw new Error('invalid package'); + states.set(operation.operationId, { + state: 'prepared', + revision: operation.expectedRevision + 1, + }); + return { + ...common, + state: 'prepared', + expectedRevision: operation.expectedRevision, + revision: operation.expectedRevision + 1, + packages: operation.extensions.map((pin) => ({ + pin, + assetRoot: `extension://${pin.extensionId}/`, + contributions: { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + }, + })), + mcpServers: operation.baseMcpServers, + hookCatalog: null, + }; + } + if (operation.kind === 'extension-activate') { + if (rejectActivation) { + states.set(operation.operationId, { + state: 'outcome_unknown', + revision: operation.revision, + }); + throw new Error('lost activation response'); + } + states.set(operation.operationId, { + state: 'active', + revision: operation.revision, + }); + } + if (operation.kind === 'extension-reconcile') { + states.set(operation.operationId, { + state: 'failed', + revision: operation.revision, + }); + return { + ...common, + state: 'stopped', + revision: operation.revision, + receipts: [{ ownerId: 'old-owner', state: 'stopped' }], + }; + } + return { + ...common, + ...(states.get(operation.operationId) ?? { + state: 'not_found', + revision: 0, + }), + }; + }); + const callbacks = { + validate: vi.fn(), + drain: vi.fn(async () => undefined), + enable: vi.fn(async () => undefined), + }; + const manager = new HostedExtensionSession( + session, + { baseUrl: 'http://127.0.0.1:1', token: 'fixture-no-traffic' }, + { mcpRevision: 0, mcpServers: [], hookCatalog: null }, + callbacks, + ); + await manager.open(); + const command = (expectedRevision = 0, extensionId = 'sample') => ({ + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision, + extensions: [ + { extensionId, packageRevision: 1, packageDigest: 'b'.repeat(64) }, + ], + }); + return { + session, + manager, + warm, + control, + callbacks, + command, + failPrepare: (fail = true) => { + rejectPrepare = fail; + }, + failActivation: (fail = true) => { + rejectActivation = fail; + }, + }; +} + +describe('Hosted extension Session configuration', () => { + it('blocks a failed initial selection on reload until an explicit repair is active', async () => { + const context = await fixture(); + const initial = context.command(); + context.failPrepare(); + await expect(context.manager.bootstrap(initial)).rejects.toThrow( + 'invalid package', + ); + await expect(context.manager.bootstrap(initial)).rejects.toThrow( + 'requires recovery', + ); + expect(context.manager.activeBundleRef).toBeUndefined(); + context.failPrepare(false); + await context.manager.submit(context.command(0, 'repaired')); + await expect(context.manager.bootstrap(initial)).resolves.toBeUndefined(); + expect(await context.manager.active()).toMatchObject({ + configRevision: 1, + packages: [{ pin: { extensionId: 'repaired' } }], + }); + }); + + it('activates an explicit empty initial selection', async () => { + const context = await fixture(); + await context.manager.bootstrap({ ...context.command(), extensions: [] }); + expect(context.manager.activeBundleRef).toBeDefined(); + expect(await context.manager.active()).toMatchObject({ + configRevision: 1, + packages: [], + }); + }); + + it('keeps the previous view usable when preparation fails', async () => { + const context = await fixture(); + context.failPrepare(); + await expect(context.manager.submit(context.command())).rejects.toThrow( + 'invalid package', + ); + expect(context.manager.pending).toBe(false); + expect(await context.manager.snapshot()).toMatchObject({ + revision: 0, + extensions: [], + operation: { state: 'failed' }, + }); + expect(context.callbacks.drain).not.toHaveBeenCalled(); + expect( + context.session.authority + .eventsInSequenceRange(1, context.session.authority.committedSequence) + .filter((event) => event.kind === 'config.bound'), + ).toHaveLength(0); + }); + + it('commits the binding before drain and only queries an unknown original operation', async () => { + const context = await fixture(); + context.callbacks.drain.mockImplementation(async () => { + expect( + context.session.authority + .eventsInSequenceRange(1, context.session.authority.committedSequence) + .filter((event) => event.kind === 'config.bound'), + ).toHaveLength(1); + }); + context.failActivation(); + const command = context.command(); + await expect(context.manager.submit(command)).rejects.toThrow( + 'lost activation response', + ); + expect(context.manager.pending).toBe(true); + const activations = () => + context.control.mock.calls.filter( + ([operation]) => operation.kind === 'extension-activate', + ); + expect(activations()).toHaveLength(1); + await expect(context.manager.continue(command.operationId)).rejects.toThrow( + 'requires recovery', + ); + expect(activations()).toHaveLength(1); + expect(context.warm).toHaveBeenCalledTimes(1); + }); + + it('restores saved pins as a newer revision after proving the failed owner stopped', async () => { + const context = await fixture(); + await context.manager.submit(context.command()); + context.failActivation(); + const failed = context.command(1, 'replacement'); + await expect(context.manager.submit(failed)).rejects.toThrow(); + context.failActivation(false); + await context.manager.recoverPrevious(failed.operationId); + expect(await context.manager.snapshot()).toMatchObject({ + revision: 3, + extensions: [{ extensionId: 'sample' }], + operation: { mode: 'recoverPrevious', state: 'active' }, + }); + const prepares = context.control.mock.calls + .map(([operation]) => operation) + .filter((operation) => operation.kind === 'extension-prepare'); + expect(prepares.at(-1)).toMatchObject({ + expectedRevision: 2, + extensions: [{ extensionId: 'sample' }], + }); + expect(prepares.at(-1)!.operationId).not.toBe(failed.operationId); + }); + + it('retries a compensation preparation on its original operation after a transient failure', async () => { + const context = await fixture(); + await context.manager.submit(context.command()); + context.failActivation(); + const failed = context.command(1, 'replacement'); + await expect(context.manager.submit(failed)).rejects.toThrow(); + context.failActivation(false); + context.failPrepare(); + await expect( + context.manager.recoverPrevious(failed.operationId), + ).rejects.toThrow('invalid package'); + expect(context.manager.pending).toBe(true); + expect(await context.manager.snapshot()).toMatchObject({ + operation: { mode: 'recoverPrevious', state: 'preparing' }, + }); + const prepareOperation = context.control.mock.calls + .map(([operation]) => operation) + .filter((operation) => operation.kind === 'extension-prepare') + .at(-1)!.operationId; + context.failPrepare(false); + await context.manager.continue(failed.operationId); + expect(context.manager.pending).toBe(false); + expect(await context.manager.snapshot()).toMatchObject({ + revision: 3, + extensions: [{ extensionId: 'sample' }], + operation: { mode: 'recoverPrevious', state: 'active' }, + }); + const compensationPrepares = context.control.mock.calls + .map(([operation]) => operation) + .filter( + (operation) => + operation.kind === 'extension-prepare' && + operation.expectedRevision === 2, + ); + expect(compensationPrepares).toHaveLength(3); + expect( + compensationPrepares.every( + (operation) => operation.operationId === prepareOperation, + ), + ).toBe(true); + expect( + context.control.mock.calls.filter( + ([operation]) => operation.kind === 'extension-reconcile', + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/serve/hosted-extension-session.ts b/packages/cli/src/serve/hosted-extension-session.ts new file mode 100644 index 00000000000..b74c3256cda --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-session.ts @@ -0,0 +1,800 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; +import { + publishManagedExtensionContributions, + readManagedExtensionContributions, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-contributions.js'; +import { + isManagedExtensionConfig, + managedConfigBinding, + managedExtensionCompensationOperationId, + parseManagedConfigInstallRecord, + type ManagedConfigInstallContent, + type ManagedConfigInstallRecord, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-config-install.js'; +import { + parseManagedExtensionApplyCommand, + parseManagedExtensionEffectiveBundle, + type ManagedExtensionEffectiveBundle, + type ManagedExtensionMcpSelection, + type ManagedExtensionPrepareResult, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import type { ManagedHookCatalogPin } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js'; +import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import { + HostedWorkspaceBroker, + type HostedWorkspaceBrokerOptions, +} from './hosted-workspace-broker.js'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; +import { + selectedMcpServers, + type HostedMcpCommand, +} from './hosted-mcp-settings.js'; +import { HostedMcpConflictError } from './hosted-mcp-session.js'; + +export interface HostedExtensionPackage { + readonly extensionId: string; + readonly contributions: ManagedPackageContributions; +} + +export interface HostedExtensionCallbacks { + validate(packages: readonly HostedExtensionPackage[]): void; + drain( + bundle: ManagedExtensionEffectiveBundle, + forceMcpServerId?: string, + ): Promise; + enable( + bundle: ManagedExtensionEffectiveBundle, + packages: readonly HostedExtensionPackage[], + operationId: string, + ): Promise; +} + +interface RuntimeOwner { + readonly rootSnapshotRef: ManagedSessionDurableRef; + readonly runtimeSessionId: string; + readonly executionMode: ManagedExtensionEffectiveBundle['executionMode']; + readonly runtime: { + readonly bindingId: string; + readonly generation: string; + readonly workspaceGeneration: string; + }; +} + +export class HostedExtensionRevisionConflict extends HostedMcpConflictError {} + +export class HostedExtensionRecoveryRequired extends Error { + constructor() { + super('The original extension configuration requires recovery.'); + } +} + +/** All durable facts use the Session's existing config_install chain. */ +export class HostedExtensionSession { + private history: ManagedConfigInstallRecord[] = []; + + constructor( + private readonly session: ManagedSession, + private readonly brokerOptions: HostedWorkspaceBrokerOptions, + private readonly baseline: { + readonly mcpRevision: number; + readonly mcpServers: readonly ManagedExtensionMcpSelection[]; + readonly hookCatalog: ManagedHookCatalogPin | null; + }, + private readonly callbacks: HostedExtensionCallbacks, + ) {} + + async open(): Promise { + await this.session.authority.settlePendingAppend(); + let current = + this.session.authority.domainRecord('config_install')?.recordRef; + const history: ManagedConfigInstallRecord[] = []; + const seen = new Set(); + while (current) { + if (seen.has(current.resourceId)) + throw new HostedExtensionRecoveryRequired(); + seen.add(current.resourceId); + const value = await this.read(current); + if (!isManagedExtensionConfig(value)) break; + const record = parseManagedConfigInstallRecord(value); + history.push(record); + current = record.previousRecordRef ?? undefined; + } + this.history = history.reverse(); + } + + get enabled(): boolean { + return this.history.length > 0; + } + get activeBundleRef(): ManagedSessionDurableRef | undefined { + return ( + this.history.findLast((entry) => entry.phase === 'active')?.bundleRef ?? + undefined + ); + } + + get pending(): boolean { + const latest = this.history.at(-1); + return ( + !!latest && + latest.phase !== 'active' && + !(latest.phase === 'failed' && latest.mode === 'apply') + ); + } + + async active(): Promise { + const active = this.history.findLast((entry) => entry.phase === 'active'); + const previous = this.history[0]?.previousBundleRef; + const ref = active?.bundleRef ?? previous; + return ref ? this.bundle(ref) : undefined; + } + + async packages( + bundle: ManagedExtensionEffectiveBundle, + ): Promise { + return Promise.all( + bundle.packages.map(async (entry) => ({ + extensionId: entry.pin.extensionId, + contributions: await readManagedExtensionContributions( + this.session.resources, + entry.contributionsRef, + ), + })), + ); + } + + async snapshot(operationId?: string) { + const latest = this.history.at(-1); + const selected = operationId + ? this.history.findLast( + (entry) => entry.command.operationId === operationId, + ) + : latest; + if (operationId && !selected) + throw new Error('Extension operation was not found.'); + const active = await this.active(); + const requested = latest?.bundleRef + ? await this.bundle(latest.bundleRef) + : latest?.restoresBundleRef + ? await this.bundle(latest.restoresBundleRef) + : undefined; + return { + sessionId: this.session.authority.sessionHeader.sessionKey.sessionId, + supported: true, + revision: + latest && + ['committed', 'active', 'recovery_required'].includes(latest.phase) + ? latest.configRevision + : (latest?.previousConfigRevision ?? 0), + extensions: active?.packages.map((entry) => entry.pin) ?? [], + requestedExtensions: + requested?.packages.map((entry) => entry.pin) ?? + latest?.command.extensions ?? + [], + ...(selected + ? { + operation: { + operationId: selected.command.operationId, + state: selected.phase, + mode: selected.mode, + revision: selected.configRevision, + ...(selected.error ? { error: selected.error } : {}), + }, + } + : {}), + }; + } + + async submit(value: unknown): Promise { + const command = parseManagedExtensionApplyCommand(value); + const prior = this.history.findLast( + (entry) => entry.command.operationId === command.operationId, + ); + if (prior) { + if (JSON.stringify(prior.command) !== JSON.stringify(command)) + throw new HostedMcpConflictError( + 'Extension operation identity conflicts.', + ); + if (['active', 'failed'].includes(prior.phase)) return; + if (this.history.at(-1) !== prior) + throw new HostedExtensionRecoveryRequired(); + await this.continue(command.operationId); + return; + } + if (this.pending) throw new HostedExtensionRecoveryRequired(); + const active = await this.active(); + if (command.expectedRevision !== (active?.configRevision ?? 0)) + throw new HostedExtensionRevisionConflict( + 'Extension configuration revision conflicts.', + ); + const broker = await this.runtime(command.operationId); + const executionMode = this.executionMode(broker); + const baseline = + active ?? + parseManagedExtensionEffectiveBundle({ + kind: 'managed-extension-config-bundle', + formatVersion: 1, + configRevision: 0, + executionMode, + mcpRevision: this.baseline.mcpRevision, + independentMcpSelections: this.baseline.mcpServers, + baseMcpServers: this.baseline.mcpServers + .filter((pin) => pin.state === 'enabled') + .map(({ state: _state, ...pin }) => pin), + baseHookCatalog: this.baseline.hookCatalog, + packages: [], + mcpServers: this.baseline.mcpServers + .filter((pin) => pin.state === 'enabled') + .map(({ state: _state, ...pin }) => pin), + hookCatalog: this.baseline.hookCatalog, + }); + if (baseline.executionMode !== executionMode) + throw new HostedExtensionRecoveryRequired(); + const previousBundleRef = await this.publish( + 'managed-extension-config-bundle', + baseline, + ); + const owner: RuntimeOwner = { + rootSnapshotRef: this.session.authority.sessionHeader.rootSnapshotRef, + runtimeSessionId: broker.runtimeSessionId, + executionMode, + runtime: { + bindingId: broker.runtime!.bindingId, + generation: broker.runtime!.generation, + workspaceGeneration: broker.runtime!.workspaceGeneration, + }, + }; + const rootSnapshotRef = await this.publish('managed-extension-root', owner); + await this.commit({ + kind: 'hosted-extension-config', + formatVersion: 1, + command, + mode: 'apply', + phaseOperationId: command.operationId, + configRevision: command.expectedRevision + 1, + previousConfigRevision: command.expectedRevision, + phase: 'preparing', + previousBundleRef, + restoresBundleRef: null, + bundleRef: null, + rootSnapshotRef, + stageAckRefs: [], + enableAckRefs: [], + error: null, + }); + await this.continue(command.operationId); + } + + async bootstrap(value: unknown): Promise { + const command = parseManagedExtensionApplyCommand(value); + if (command.expectedRevision !== 0 || command.mcpChange) + throw new Error('Invalid initial extension selection.'); + await this.submit(command); + const active = await this.active(); + if ( + !this.activeBundleRef || + !active || + active.configRevision < 1 || + this.pending + ) + throw new HostedExtensionRecoveryRequired(); + } + + async continue(operationId: string): Promise { + let latest = this.history.at(-1); + if (!latest || latest.command.operationId !== operationId) + throw new HostedExtensionRecoveryRequired(); + if ( + latest.phase === 'active' || + (latest.phase === 'failed' && latest.mode === 'apply') + ) + return; + if (latest.phase === 'failed') throw new HostedExtensionRecoveryRequired(); + const broker = await this.runtime(operationId, latest.rootSnapshotRef); + if (latest.phase === 'preparing') { + try { + latest = await this.prepare(latest, broker); + } catch (cause) { + if (latest.mode === 'apply') + await this.commit({ + ...this.content(latest), + phase: 'failed', + error: { code: 'extension_prepare_failed' }, + }); + throw cause; + } + } + if (latest.phase === 'prepared') + latest = await this.commit({ + ...this.content(latest), + phase: 'committed', + }); + if (latest.phase !== 'committed' && latest.phase !== 'recovery_required') + throw new HostedExtensionRecoveryRequired(); + const current = latest; + try { + const phaseOperationId = current.phaseOperationId; + const status = await broker.extensionControl({ + kind: 'extension-status', + operationId: phaseOperationId, + }); + if (status['state'] === 'prepared') { + await this.prepareRuntime(current, broker); + await this.callbacks.drain( + await this.bundle(current.bundleRef!), + current.mode === 'apply' && + ['reload', 'restart'].includes( + current.command.mcpChange?.action ?? '', + ) + ? current.command.mcpChange?.serverId + : undefined, + ); + await broker.extensionControl({ + kind: 'extension-activate', + operationId: phaseOperationId, + expectedRevision: current.previousConfigRevision, + revision: current.configRevision, + }); + } else if ( + status['state'] !== 'active' || + status['revision'] !== current.configRevision + ) { + throw new HostedExtensionRecoveryRequired(); + } + const enabled = await broker.extensionControl({ + kind: 'extension-status', + operationId: phaseOperationId, + }); + if ( + enabled['state'] !== 'active' || + enabled['revision'] !== current.configRevision || + enabled['executionMode'] !== this.executionMode(broker) + ) + throw new HostedExtensionRecoveryRequired(); + const bundle = await this.bundle(current.bundleRef!); + await this.callbacks.enable( + bundle, + await this.packages(bundle), + current.phaseOperationId, + ); + const runtimeAck = await this.publish('managed-extension-enable-ack', { + operationId, + revision: current.configRevision, + executionMode: bundle.executionMode, + side: 'runtime', + }); + const harnessAck = await this.publish('managed-extension-enable-ack', { + operationId, + revision: current.configRevision, + bundleRef: current.bundleRef, + side: 'harness', + }); + await this.commit({ + ...this.content(current), + phase: 'active', + enableAckRefs: [runtimeAck, harnessAck], + error: null, + }); + } catch (cause) { + if (current.phase !== 'recovery_required') + await this.commit({ + ...this.content(current), + phase: 'recovery_required', + error: { code: 'extension_recovery_required' }, + }); + throw cause; + } + } + + async recoverPrevious(operationId: string): Promise { + const latest = this.history.at(-1); + if (!latest || latest.command.operationId !== operationId) + throw new HostedExtensionRecoveryRequired(); + if (latest.mode === 'recoverPrevious') { + await this.continue(operationId); + return; + } + if ( + latest.phase !== 'recovery_required' || + !latest.bundleRef || + !latest.previousBundleRef + ) + throw new HostedExtensionRecoveryRequired(); + const broker = await this.runtime(operationId, latest.rootSnapshotRef); + const proof = await broker.extensionControl({ + kind: 'extension-reconcile', + operationId, + expectedRevision: latest.previousConfigRevision, + revision: latest.configRevision, + }); + if ( + proof['state'] !== 'stopped' || + proof['revision'] !== latest.configRevision || + proof['executionMode'] !== this.executionMode(broker) || + !Array.isArray(proof['receipts']) || + !proof['receipts'].length + ) + throw new HostedExtensionRecoveryRequired(); + await this.callbacks.drain(await this.bundle(latest.previousBundleRef)); + await this.commit({ + ...this.content(latest), + mode: 'recoverPrevious', + phaseOperationId: managedExtensionCompensationOperationId(operationId), + configRevision: latest.configRevision + 1, + previousConfigRevision: latest.configRevision, + previousBundleRef: latest.bundleRef, + restoresBundleRef: latest.previousBundleRef, + bundleRef: null, + phase: 'preparing', + stageAckRefs: [], + enableAckRefs: [], + error: null, + }); + await this.continue(operationId); + } + + async runtimeOwner(): Promise { + const latest = this.history.findLast((entry) => entry.phase === 'active'); + return latest ? this.read(latest.rootSnapshotRef) : undefined; + } + + async submitMcp(command: HostedMcpCommand): Promise { + const replay = this.history.findLast( + (entry) => entry.command.operationId === command.operationId, + ); + if (replay) { + const change = replay.command.mcpChange; + const server = change?.servers.find( + (entry) => entry.serverId === command.serverId, + ); + if ( + !change || + replay.command.actorKey !== command.actorKey || + change.action !== command.action || + change.serverId !== command.serverId || + change.expectedRevision !== command.expectedRevision || + (command.server && + (!server || + command.server.serverRevision !== server.serverRevision || + command.server.definitionDigest !== server.definitionDigest)) + ) + throw new HostedMcpConflictError('MCP command identity conflicts.'); + await this.submit(replay.command); + return; + } + const active = await this.active(); + if (!active || this.pending) throw new HostedExtensionRecoveryRequired(); + if (command.expectedRevision !== active.mcpRevision) + throw new HostedMcpConflictError('MCP settings revision conflicts.'); + if ( + active.mcpServers.some((pin) => pin.serverId === command.serverId) && + !active.independentMcpSelections.some( + (pin) => pin.serverId === command.serverId, + ) + ) + throw new HostedMcpConflictError( + 'Manage this MCP server through its extension.', + ); + const servers = selectedMcpServers( + active.independentMcpSelections, + command, + ); + const broker = await this.runtime(command.operationId); + const available = await broker.mcpDefinitions(); + const selected = servers.find((pin) => pin.serverId === command.serverId)!; + if ( + (selected.state === 'enabled' || command.action === 'edit') && + !available.some( + (pin) => + pin.serverId === selected.serverId && + pin.serverRevision === selected.serverRevision && + pin.definitionDigest === selected.definitionDigest, + ) + ) + throw new HostedMcpConflictError( + 'MCP definition is not published for this Workspace.', + ); + await this.submit({ + operationId: command.operationId, + actorKey: command.actorKey, + expectedRevision: active.configRevision, + extensions: active.packages.map((entry) => entry.pin), + mcpChange: { + action: command.action, + serverId: command.serverId, + expectedRevision: command.expectedRevision, + servers, + }, + }); + } + + async mcpSnapshot(operationId?: string, inventory = true) { + const active = await this.active(); + if (!active) throw new HostedExtensionRecoveryRequired(); + const record = operationId + ? this.history.findLast( + (entry) => entry.command.operationId === operationId, + ) + : this.history.findLast((entry) => entry.command.mcpChange !== undefined); + if (operationId && !record?.command.mcpChange) + throw new Error('MCP operation was not found.'); + const broker = new HostedWorkspaceBroker( + this.brokerOptions, + this.session.authority.sessionHeader.sessionKey, + 'mcp-management-inventory', + ); + const change = record?.command.mcpChange; + return { + sessionId: this.session.authority.sessionHeader.sessionKey.sessionId, + revision: active.mcpRevision, + supported: true, + availableServers: inventory ? await broker.mcpDefinitions() : [], + servers: active.independentMcpSelections, + ...(record && change + ? { + operation: { + operationId: record.command.operationId, + action: change.action, + serverId: change.serverId, + revision: change.expectedRevision + 1, + state: + record.phase === 'active' + ? 'settled' + : record.phase === 'failed' + ? 'failed' + : record.phase === 'recovery_required' + ? 'recovery_required' + : 'pending', + ...(record.error ? { error: record.error } : {}), + }, + } + : {}), + }; + } + + private async prepareRuntime( + record: ManagedConfigInstallRecord, + broker: HostedWorkspaceBroker, + ) { + const baseline = await this.bundle( + record.mode === 'recoverPrevious' + ? record.restoresBundleRef! + : record.previousBundleRef!, + ); + const change = + record.mode === 'apply' ? record.command.mcpChange : undefined; + if (change && change.expectedRevision !== baseline.mcpRevision) + throw new HostedMcpConflictError('MCP component revision conflicts.'); + const selected = change?.servers ?? baseline.independentMcpSelections; + const baseMcpServers = selected + .filter((entry) => entry.state === 'enabled') + .map(({ state: _state, ...pin }) => pin); + const command = + record.mode === 'apply' + ? record.command + : { + operationId: record.phaseOperationId, + actorKey: record.command.actorKey, + expectedRevision: record.previousConfigRevision, + extensions: baseline.packages.map((entry) => entry.pin), + }; + const result = await broker.extensionControl({ + kind: 'extension-prepare', + operationId: record.phaseOperationId, + command, + expectedRevision: record.previousConfigRevision, + extensions: command.extensions, + baseMcpServers, + baseHookCatalog: baseline.baseHookCatalog, + }); + if ( + result['state'] !== 'prepared' || + result['revision'] !== record.configRevision || + result['executionMode'] !== baseline.executionMode || + !Array.isArray(result['packages']) + ) + throw new HostedExtensionRecoveryRequired(); + const prepared = result as unknown as ManagedExtensionPrepareResult; + if ( + JSON.stringify(prepared.packages.map((entry) => entry.pin)) !== + JSON.stringify(command.extensions) + ) + throw new HostedExtensionRecoveryRequired(); + return { prepared, baseline, selected, baseMcpServers }; + } + + private async prepare( + record: ManagedConfigInstallRecord, + broker: HostedWorkspaceBroker, + ) { + const { prepared, baseline, selected, baseMcpServers } = + await this.prepareRuntime(record, broker); + const packages = await Promise.all( + prepared.packages.map(async (entry) => ({ + pin: entry.pin, + assetRoot: `extension://${entry.pin.extensionId}/`, + contributionsRef: await publishManagedExtensionContributions( + this.session.resources, + entry.contributions, + ), + })), + ); + this.callbacks.validate( + prepared.packages.map((entry) => ({ + extensionId: entry.pin.extensionId, + contributions: entry.contributions, + })), + ); + const previous = + record.mode === 'recoverPrevious' + ? await this.bundle(record.previousBundleRef!) + : undefined; + const bundle = parseManagedExtensionEffectiveBundle({ + kind: 'managed-extension-config-bundle', + formatVersion: 1, + configRevision: record.configRevision, + executionMode: prepared.executionMode, + mcpRevision: previous + ? previous.mcpRevision + 1 + : baseline.mcpRevision + (record.command.mcpChange ? 1 : 0), + independentMcpSelections: selected, + ...(record.mode === 'apply' && record.command.mcpChange + ? { mcpChange: record.command.mcpChange } + : {}), + baseMcpServers, + baseHookCatalog: baseline.baseHookCatalog, + packages, + mcpServers: prepared.mcpServers, + hookCatalog: prepared.hookCatalog, + }); + const bundleRef = await this.publish( + 'managed-extension-config-bundle', + bundle, + ); + const runtimeAck = await this.publish('managed-extension-stage-ack', { + operationId: record.command.operationId, + revision: record.configRevision, + bundleRef, + side: 'runtime', + executionMode: prepared.executionMode, + }); + const harnessAck = await this.publish('managed-extension-stage-ack', { + operationId: record.command.operationId, + revision: record.configRevision, + bundleRef, + side: 'harness', + }); + return this.commit({ + ...this.content(record), + phase: 'prepared', + bundleRef, + stageAckRefs: [runtimeAck, harnessAck], + }); + } + + private async runtime( + operationId: string, + rootSnapshotRef?: ManagedSessionDurableRef, + ) { + const owner = rootSnapshotRef + ? await this.read(rootSnapshotRef) + : undefined; + if ( + owner && + JSON.stringify(owner.rootSnapshotRef) !== + JSON.stringify(this.session.authority.sessionHeader.rootSnapshotRef) + ) + throw new HostedExtensionRecoveryRequired(); + const id = + owner?.runtimeSessionId ?? + `extensions-${hash([this.session.authority.sessionHeader.sessionKey, operationId])}`; + const broker = new HostedWorkspaceBroker( + this.brokerOptions, + this.session.authority.sessionHeader.sessionKey, + id, + ); + if (owner) + broker.expectExtensionRuntime({ + runtimeBindingId: owner.runtime.bindingId, + generation: owner.runtime.generation, + }); + else { + await broker.warm(); + if (!broker.runtime) throw new HostedExtensionRecoveryRequired(); + broker.expectExtensionRuntime({ + runtimeBindingId: broker.runtime.bindingId, + generation: broker.runtime.generation, + }); + } + await broker.extensionControl({ kind: 'extension-status', operationId }); + if (owner && owner.executionMode !== this.executionMode(broker)) + throw new HostedExtensionRecoveryRequired(); + return broker; + } + + private executionMode( + broker: HostedWorkspaceBroker, + ): ManagedExtensionEffectiveBundle['executionMode'] { + if (broker.capabilityDigest === MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST) + return 'linux-cgroup'; + if ( + broker.capabilityDigest === MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST + ) + return 'developer-process-group'; + throw new Error( + 'The selected Runtime does not support extension packages.', + ); + } + + private content( + record: ManagedConfigInstallRecord, + ): ManagedConfigInstallContent { + const { + operationId: _operationId, + revision: _revision, + previousRecordRef: _previous, + ...content + } = record; + return content; + } + + private async commit(content: ManagedConfigInstallContent) { + const receipt = await this.session.authority.commitDomainRecord( + { + operation: `applyExtensions.${content.mode}.${content.phase}`, + commandId: content.command.operationId, + sessionKey: this.session.authority.sessionHeader.sessionKey, + contentDigest: hash(content), + }, + { + domain: 'config_install', + content: { ...content }, + ...(content.phase === 'committed' + ? { configBinding: managedConfigBinding(content) } + : {}), + }, + { class: 'trusted_entry' }, + ); + const record = parseManagedConfigInstallRecord( + await this.read(receipt.recordRef), + ); + this.history.push(record); + return record; + } + + private async bundle(ref: ManagedSessionDurableRef) { + if ( + ref.kind !== 'managed-extension-config-bundle' || + ref.schemaVersion !== 1 + ) + throw new HostedExtensionRecoveryRequired(); + return parseManagedExtensionEffectiveBundle(await this.read(ref)); + } + + private read(ref: ManagedSessionDurableRef): Promise { + return this.session.resources + .read(ref) + .then((bytes) => JSON.parse(bytes.toString('utf8')) as T); + } + + private publish(kind: string, value: unknown) { + return this.session.resources.publish( + kind, + Buffer.from(JSON.stringify(value)), + ); + } +} + +function hash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} diff --git a/packages/cli/src/serve/hosted-extension-tool-context.test.ts b/packages/cli/src/serve/hosted-extension-tool-context.test.ts new file mode 100644 index 00000000000..20725532adc --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-tool-context.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + extensionInstructionText, + extensionToolDeclaration, + extensionToolInput, +} from './hosted-extension-tool-context.js'; + +const source = { + extensionId: 'one', + packageRevision: 2, + packageDigest: 'a'.repeat(64), +}; +const pin = { ...source, settingsRevision: 3 }; +const config = { + revision: 7, + packages: [{ pin, assetRoot: 'extension://one/' }], +}; + +describe('Hosted extension native tool context', () => { + it('binds a model-selected exact package to the current Harness revision and private settings pin', () => { + expect( + extensionToolInput('root', config, 'read_file', { + file_path: 'asset.txt', + extension_source: source, + }), + ).toEqual({ + file_path: 'asset.txt', + managedExtension: { harnessSessionId: 'root', configRevision: 7, pin }, + }); + expect( + extensionToolInput('root', config, 'run_shell_command', { + command: 'pwd', + extension_context: source, + }), + ).toMatchObject({ command: 'pwd', managedExtension: { pin } }); + expect( + extensionToolInput('root', config, 'read_file', { + file_path: 'workspace.txt', + }), + ).toEqual({ file_path: 'workspace.txt' }); + }); + + it('rejects fabricated private metadata, stale pins, writes, and background execution', () => { + for (const [tool, input] of [ + ['read_file', { managedExtension: { pin } }], + ['read_file', { extension_source: { ...source, packageRevision: 1 } }], + ['read_file', { extension_source: pin }], + ['write_file', { extension_source: source }], + ['run_shell_command', { extension_source: source }], + ['read_file', { extension_context: source }], + ['run_shell_command', { extension_context: source, is_background: true }], + ['run_shell_command', { extension_context: source, is_monitor: true }], + ] as const) + expect(() => extensionToolInput('root', config, tool, input)).toThrow(); + }); + + it('publishes only public pin selectors without mutating the original native schema', () => { + const declaration = { + name: 'read_file', + description: 'Read', + parametersJsonSchema: { + type: 'object', + properties: { file_path: { type: 'string' } }, + }, + }; + const augmented = extensionToolDeclaration(declaration, config); + expect(augmented.parametersJsonSchema).toMatchObject({ + properties: { + extension_source: { + additionalProperties: false, + required: ['extensionId', 'packageRevision', 'packageDigest'], + oneOf: [ + { + properties: { + extensionId: { const: 'one' }, + packageRevision: { const: 2 }, + packageDigest: { const: 'a'.repeat(64) }, + }, + }, + ], + }, + }, + }); + expect(declaration.parametersJsonSchema.properties).not.toHaveProperty( + 'extension_source', + ); + expect(JSON.stringify(augmented)).not.toContain('settingsRevision'); + expect( + extensionToolDeclaration({ ...declaration, name: 'write_file' }, config) + .parametersJsonSchema, + ).toBe(declaration.parametersJsonSchema); + }); + + it('maps instruction assets to the selected logical package without exposing a Runtime path', () => { + expect( + extensionInstructionText( + 'Read ${extensionPath}/references/guide.md and ${CLAUDE_PLUGIN_ROOT}/data.txt', + 'one', + ), + ).toBe( + 'Read extension://one/references/guide.md and extension://one/data.txt', + ); + expect( + extensionToolInput('root', config, 'read_file', { + extension_source: source, + file_path: 'extension://one/references/guide.md', + }), + ).toMatchObject({ + file_path: 'references/guide.md', + managedExtension: { pin }, + }); + expect( + extensionToolInput('root', config, 'glob', { + extension_source: source, + path: 'extension://one/references', + pattern: '**/*.md', + }), + ).toMatchObject({ path: 'references', pattern: '**/*.md' }); + for (const file_path of [ + 'extension://other/data.txt', + 'extension://one/../private.txt', + 'extension://one//../../private.txt', + ]) + expect(() => + extensionToolInput('root', config, 'read_file', { + extension_source: source, + file_path, + }), + ).toThrow(); + }); +}); diff --git a/packages/cli/src/serve/hosted-extension-tool-context.ts b/packages/cli/src/serve/hosted-extension-tool-context.ts new file mode 100644 index 00000000000..aba1789d27d --- /dev/null +++ b/packages/cli/src/serve/hosted-extension-tool-context.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FunctionDeclaration } from '@google/genai'; +import { + parseManagedExtensionPin, + type ManagedExtensionPin, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import { normalizeWorkspaceRelativePath } from './managed-workspace-binding.js'; + +export interface HostedExtensionToolConfig { + readonly revision: number; + readonly packages: ReadonlyArray<{ + readonly pin: ManagedExtensionPin; + readonly assetRoot: string; + }>; +} + +const sourceSchema = { + type: 'object', + properties: { + extensionId: { type: 'string' }, + packageRevision: { type: 'integer', minimum: 1 }, + packageDigest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + }, + required: ['extensionId', 'packageRevision', 'packageDigest'], + additionalProperties: false, +}; + +export function extensionToolDeclaration( + tool: FunctionDeclaration, + config?: HostedExtensionToolConfig, +): FunctionDeclaration { + if ( + !config?.packages.length || + !['read_file', 'glob', 'run_shell_command'].includes(tool.name ?? '') + ) + return tool; + const schema = structuredClone(tool.parametersJsonSchema) as { + properties: Record; + }; + schema.properties[ + tool.name === 'run_shell_command' ? 'extension_context' : 'extension_source' + ] = { + ...sourceSchema, + oneOf: config.packages.map(({ pin }) => ({ + properties: { + extensionId: { const: pin.extensionId }, + packageRevision: { const: pin.packageRevision }, + packageDigest: { const: pin.packageDigest }, + }, + })), + }; + return { + ...tool, + parametersJsonSchema: schema, + description: `${tool.description ?? ''} Select one of the listed exact extension pins to read its read-only assets using a package-relative path or extension:///. For Shell, select extension_context and use $QWEN_EXTENSION_ROOT for package files; cwd stays the Session working directory.`, + }; +} + +export function extensionToolInput( + harnessSessionId: string, + config: HostedExtensionToolConfig | undefined, + toolName: string, + input: Readonly>, +): Record { + const { extension_source: source, extension_context: context } = input; + const ordinary: Record = { ...input }; + delete ordinary['extension_source']; + delete ordinary['extension_context']; + if (Object.hasOwn(input, 'managedExtension')) + throw new Error('Private extension tool metadata is not a model argument.'); + if (source === undefined && context === undefined) return ordinary; + const shell = toolName === 'run_shell_command'; + if ( + (!shell && !['read_file', 'glob'].includes(toolName)) || + (shell ? source !== undefined : context !== undefined) || + !config + ) + throw new Error('This tool does not admit the selected extension context.'); + const requested = shell ? context : source; + if ( + !requested || + typeof requested !== 'object' || + Array.isArray(requested) || + Object.keys(requested).sort().join(',') !== + 'extensionId,packageDigest,packageRevision' + ) + throw new Error('Select an exact extension package pin.'); + const pin = parseManagedExtensionPin(requested); + const selected = config.packages.find( + (entry) => + entry.pin.extensionId === pin.extensionId && + entry.pin.packageRevision === pin.packageRevision && + entry.pin.packageDigest === pin.packageDigest, + ); + if (!selected) + throw new Error( + 'The extension package is not selected in this Session configuration.', + ); + if (!shell) { + for (const field of ['file_path', 'path', 'pattern']) { + const value = ordinary[field]; + if (typeof value !== 'string' || !value.startsWith('extension://')) + continue; + const prefix = `extension://${pin.extensionId}/`; + if (!value.startsWith(prefix)) + throw new Error('Asset path names another extension.'); + ordinary[field] = normalizeWorkspaceRelativePath( + value.slice(prefix.length), + ); + } + } + if ( + shell && + (ordinary['is_background'] === true || ordinary['is_monitor'] === true) + ) + throw new Error('Extension Shell requires a foreground invocation.'); + return { + ...ordinary, + managedExtension: { + harnessSessionId, + configRevision: config.revision, + pin: selected.pin, + }, + }; +} + +export function extensionInstructionText( + body: string, + extensionId: string, +): string { + return body + .replaceAll('${extensionPath}', `extension://${extensionId}`) + .replaceAll('${CLAUDE_PLUGIN_ROOT}', `extension://${extensionId}`); +} diff --git a/packages/cli/src/serve/hosted-harness-contract.test.ts b/packages/cli/src/serve/hosted-harness-contract.test.ts index ef911e959b5..09efbbd1db6 100644 --- a/packages/cli/src/serve/hosted-harness-contract.test.ts +++ b/packages/cli/src/serve/hosted-harness-contract.test.ts @@ -68,6 +68,7 @@ describe('Hosted Harness private contract', () => { 'session-skills/2', 'workspace-shell-mcp/1', 'session-mcp-management/1', + 'session-extension-packages/1', 'recovery-cleanup/1', 'targeted-cancel/1', 'event-keepalive/1', diff --git a/packages/cli/src/serve/hosted-harness-contract.ts b/packages/cli/src/serve/hosted-harness-contract.ts index eb9bf30b1e1..1753b6c6eab 100644 --- a/packages/cli/src/serve/hosted-harness-contract.ts +++ b/packages/cli/src/serve/hosted-harness-contract.ts @@ -97,6 +97,7 @@ export function createHostedHarnessContract( 'session-skills/2', 'workspace-shell-mcp/1', 'session-mcp-management/1', + 'session-extension-packages/1', 'recovery-cleanup/1', 'targeted-cancel/1', // GET /session/:id/events writes keepalive comments while no event diff --git a/packages/cli/src/serve/hosted-harness-model.test.ts b/packages/cli/src/serve/hosted-harness-model.test.ts index c73825c69b8..e5877a1a902 100644 --- a/packages/cli/src/serve/hosted-harness-model.test.ts +++ b/packages/cli/src/serve/hosted-harness-model.test.ts @@ -2963,6 +2963,83 @@ it('keeps agent instructions ahead of refreshed project context', async () => { ); }); +it('preserves pinned instruction sources when Todo changes after a tool batch', async () => { + const mocks = config([]); + const observed: string[] = []; + const skills = new HostedSkillCatalog([ + { name: 'published', description: 'SKILL_SECTION', body: 'body' }, + ]); + let todo = 'TODO_FIRST'; + let round = 0; + const call = { + callId: 'context-call', + name: 'read_file', + args: {}, + isClientInitiated: false, + prompt_id: input.promptId, + }; + mocks.getHistory.mockImplementation(() => [ + { + role: 'model', + parts: + round === 1 + ? [{ functionCall: { name: call.name } }] + : [{ text: 'done' }], + }, + ]); + mocks.sendMessageStream.mockImplementation(async function* () { + observed.push(mocks.setUserMemory.mock.calls.at(-1)?.[0] ?? ''); + round++; + if (round === 1) yield { type: LlmEventType.ToolCallRequest, value: call }; + else yield { type: LlmEventType.Content, value: 'done' }; + yield { type: LlmEventType.Finished }; + }); + const execute = vi.fn(async () => { + todo = 'TODO_UPDATED'; + return [ + { + functionResponse: { + id: call.callId, + name: call.name, + response: { output: 'contents' }, + }, + }, + ]; + }); + await runHostedHarnessTextTurn({ + ...input, + agentInstructions: 'AGENT_SECTION', + extensionInstructions: 'EXTENSION_SECTION', + skills, + workspaceContext: { read: () => 'WORKSPACE_SECTION' }, + toolTurn: { + declarations: async () => [{ name: call.name }], + consumeResults: async () => undefined, + execute, + planModeInstructions: () => 'PLAN_SECTION', + todoInstructions: async () => todo, + }, + }); + expect(execute).toHaveBeenCalledOnce(); + expect(observed).toHaveLength(2); + for (const [index, prompt] of observed.entries()) { + let position = -1; + for (const marker of [ + 'AGENT_SECTION', + 'EXTENSION_SECTION', + 'SKILL_SECTION', + 'PLAN_SECTION', + index === 0 ? 'TODO_FIRST' : 'TODO_UPDATED', + 'WORKSPACE_SECTION', + ]) { + const next = prompt.indexOf(marker); + expect(next).toBeGreaterThan(position); + position = next; + } + } + expect(observed[1]).not.toContain('TODO_FIRST'); +}); + it('advertises only the published Skill catalog while keeping host Skill discovery disabled', async () => { const mocks = config([{ type: LlmEventType.Finished }]); await runHostedHarnessTextTurn({ diff --git a/packages/cli/src/serve/hosted-harness-model.ts b/packages/cli/src/serve/hosted-harness-model.ts index 3316e2e2b47..537197a74d6 100644 --- a/packages/cli/src/serve/hosted-harness-model.ts +++ b/packages/cli/src/serve/hosted-harness-model.ts @@ -303,6 +303,7 @@ export async function runHostedHarnessTextTurn(input: { | 'setToolOutputBudget' | 'capturesChildModelRoute' | 'setChildModelRoute' + | 'setChildModelResolver' | 'planModeInstructions' | 'todoInstructions' > @@ -312,6 +313,7 @@ export async function runHostedHarnessTextTurn(input: { modelSelection?: HostedModelSelection; publicChildModelRoute?: PublicChildModelRoute; agentInstructions?: string; + extensionInstructions?: string; skills?: HostedSkillCatalog; textDeltas?: HostedHarnessTextDeltas; completeFinalOutput?: ( @@ -381,6 +383,7 @@ async function runPreparedHostedHarnessTextTurn( input.agentInstructions ? `# Agent instructions\n\n${input.agentInstructions}` : undefined, + input.extensionInstructions, input.skills?.instructions(), input.toolTurn?.planModeInstructions?.(), todoContext, @@ -500,7 +503,7 @@ async function runPreparedHostedHarnessTextTurn( await input.hooks?.ensureReady(input.signal); // Seed instructions when memory reload is skipped; restore them after // initialize() when its hierarchical memory refresh replaces user memory. - if (input.agentInstructions || input.skills) + if (input.agentInstructions || input.extensionInstructions || input.skills) config.setUserMemory( contextWithInstructions(input.workspaceContext?.read()), ); @@ -517,7 +520,11 @@ async function runPreparedHostedHarnessTextTurn( const authType = config.getModelsConfig().getCurrentAuthType(); if (!authType) throw new Error('Hosted Harness model authentication is unavailable.'); - if (input.agentInstructions || input.skills) { + if ( + input.agentInstructions || + input.extensionInstructions || + input.skills + ) { config.setUserMemory( contextWithInstructions(input.workspaceContext?.read()), ); @@ -625,6 +632,31 @@ async function runPreparedHostedHarnessTextTurn( } : {}), }); + input.toolTurn.setChildModelResolver?.(async (selector) => { + const resolved = await config + .getBaseLlmClient() + .resolveForModel(selector, { failClosed: true }); + const selected = resolved.contentGeneratorConfig; + if (!selected.authType) throw new HostedModelUnavailableError(); + return { + modelId: resolved.model, + route: { + authType: selected.authType, + endpointDigest: createHash('sha256') + .update(selected.baseUrl ?? '') + .digest('hex'), + providerPin: llmPreparedProviderPin(selected), + origin: { + modelId: config.getModel(), + authType: active.authType!, + endpointDigest: createHash('sha256') + .update(active.baseUrl ?? '') + .digest('hex'), + providerPin: llmPreparedProviderPin(active), + }, + }, + }; + }); }; if ( [...initialMessages, ...(input.resumeModel?.prepared.history ?? [])].some( @@ -636,7 +668,7 @@ async function runPreparedHostedHarnessTextTurn( config.getEffectiveInputModalities().image !== true ) throw new HostedImageInputUnsupportedError(); - if (input.agentInstructions || input.skills) + if (input.agentInstructions || input.extensionInstructions || input.skills) await config.getLlmClient().refreshSystemInstruction(); await input.modelScope?.bindBudget(config.getTurnBudget(), input.prompt); runPromptHook = input.modelScope @@ -871,7 +903,7 @@ async function runPreparedHostedHarnessTextTurn( const calls: ToolCallRequestInfo[] = selectedSkills.map((skill) => ({ callId: randomUUID(), name: 'skill', - args: { skill }, + args: { skill, ...input.skills?.userArguments(skill, effectivePrompt) }, isClientInitiated: true, origin: { kind: 'client' }, prompt_id: input.promptId, diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index ba331209f4e..136cd3a67ba 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -102,6 +102,7 @@ import { HostedWorkspaceBrokerRejection, } from './hosted-workspace-broker.js'; import { WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import { MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST } from './managed-extension-profile.js'; import { HostedShellPublisher } from './hosted-shell-publisher.js'; import type { ShellPublisherDescriptor } from './managed-shell-publisher.js'; import { @@ -545,16 +546,28 @@ afterEach(async () => { listeners.clear(); }); -async function app(withBroker = false, instanceId?: string) { +async function app( + withBroker = false, + instanceId?: string, + extensionPackages = true, +) { const result = express(); result.use(express.json()); - const contract = createHostedHarnessContract( + const advertised = createHostedHarnessContract( `sha256:${'a'.repeat(64)}`, BOOT_ID, ['hosted-workspace-shell/1', 'hosted-workspace-mcp/1'], instanceId, ); + const contract = extensionPackages + ? advertised + : { + ...advertised, + features: advertised.features?.filter( + (feature) => feature !== 'session-extension-packages/1', + ), + }; installHostedHarnessContractMiddleware(result, contract); registerHostedHarnessSessionRoutes( result, @@ -23345,6 +23358,165 @@ describe('Hosted Harness tool approvals', () => { const files = 'hosted-workspace-files/1'; + it('loads a failed initial extension selection for management while keeping the first Prompt blocked', async () => { + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'capabilityDigest', + 'get', + ).mockReturnValue(MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST); + vi.mocked(HostedWorkspaceBroker.prototype.warm).mockImplementation( + async function (this: HostedWorkspaceBroker) { + this.runtime = { + bindingId: 'original-binding', + generation: '1', + workspaceGeneration: '1', + }; + }, + ); + let failPrepare = true; + const runtimeStates = new Map(); + const control = vi + .spyOn(HostedWorkspaceBroker.prototype, 'extensionControl') + .mockImplementation(async (operation) => { + const common = { + operationId: operation.operationId, + executionMode: 'developer-process-group', + }; + if (operation.kind === 'extension-prepare') { + if (failPrepare) throw new Error('invalid initial package'); + runtimeStates.set(operation.operationId, 'prepared'); + return { + ...common, + state: 'prepared', + expectedRevision: operation.expectedRevision, + revision: operation.expectedRevision + 1, + packages: operation.extensions.map((pin) => ({ + pin, + assetRoot: `extension://${pin.extensionId}/`, + contributions: { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + }, + })), + mcpServers: [], + hookCatalog: null, + }; + } + if (operation.kind === 'extension-activate') + runtimeStates.set(operation.operationId, 'active'); + return { + ...common, + state: runtimeStates.get(operation.operationId), + revision: 1, + }; + }); + const initialSelection = { + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision: 0, + extensions: [ + { + extensionId: 'sample', + packageRevision: 1, + packageDigest: 'b'.repeat(64), + }, + ], + }; + const definition = { + managedSessionStore: store(), + toolProfile: 'hosted-workspace-shell/1', + publicChildAgentsVersion: 1, + agentDefinition: { + agentId: `agent_${'c'.repeat(32)}`, + revision: '1', + digest: 'd'.repeat(64), + }, + initialSelection, + }; + const original = await app(true); + await headers(supertest(original).post('/session')) + .send({ ...definition, sessionId: SESSION_ID, sessionScope: 'thread' }) + .expect(503); + await headers(supertest(original).post(`/session/${SESSION_ID}/load`)) + .send(definition) + .expect(503); + expect(control.mock.calls.map(([operation]) => operation.kind)).toEqual([ + 'extension-status', + 'extension-status', + 'extension-prepare', + ]); + const replacement = await app(true); + const attachment = await headers( + supertest(replacement).post(`/session/${SESSION_ID}/load`), + ) + .send({ ...definition, passiveManagedRuntimeRecovery: true }) + .expect(200); + expect(control).toHaveBeenCalledTimes(3); + expect(HostedWorkspaceBroker.prototype.warm).toHaveBeenCalledOnce(); + expect(state.model).not.toHaveBeenCalled(); + const authorize = (request: supertest.Test) => + headers(request).set('X-Qwen-Client-Id', attachment.body.clientId); + const prompt = [{ type: 'text', text: 'start after configuration' }]; + const promptBody = { + prompt, + promptId: PROMPT_ID, + payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`, + }; + await authorize( + supertest(replacement).post(`/session/${SESSION_ID}/prompt`), + ) + .send(promptBody) + .expect(409); + failPrepare = false; + const repair = await authorize( + supertest(replacement).post( + `/session/${SESSION_ID}/extensions/management`, + ), + ) + .send({ ...initialSelection, operationId: randomUUID() }) + .expect(202); + expect(repair.body).toMatchObject({ + revision: 1, + operation: { state: 'active' }, + }); + const callsBeforeConflict = control.mock.calls.length; + const rejected = await authorize( + supertest(replacement).post( + `/session/${SESSION_ID}/extensions/management`, + ), + ) + .send({ ...initialSelection, operationId: randomUUID() }) + .expect(409); + expect(rejected.body).toMatchObject({ + error: 'extension_config_revision_conflict', + }); + expect(control).toHaveBeenCalledTimes(callsBeforeConflict); + state.model.mockResolvedValue({ text: 'ready', model: 'test-model' }); + await authorize( + supertest(replacement).post(`/session/${SESSION_ID}/prompt`), + ) + .send(promptBody) + .expect(202); + await waitFor(async () => { + const status = await authorize( + supertest(replacement).get(`/session/${SESSION_ID}/status`), + ); + expect(status.body).toMatchObject({ + hasActivePrompt: false, + recoveryBlocked: false, + }); + }); + expect(state.model).toHaveBeenCalledOnce(); + await authorize( + supertest(replacement).post(`/session/${SESSION_ID}/detach`), + ).expect(204); + }); + it.each(['write_file', 'exit_plan_mode'] as const)( 'rechecks live approval after a cold restore while preserving %s approval semantics', async (toolName) => { @@ -38512,6 +38684,72 @@ describe('Hosted Harness stored definitions', () => { }); }); + it('creates and cold-loads a custom public Shell definition with base Hooks and no extension selection', async () => { + vi.spyOn(HostedWorkspaceBroker.prototype, 'release').mockResolvedValue(); + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'activeHoldRuntimeSessionIds', + ).mockResolvedValue([]); + const server = await app(true); + const request = { + managedSessionStore: store(), + toolProfile: 'hosted-workspace-shell/1', + publicChildAgentsVersion: 1, + lifecycleProtocolVersion: 2, + agentDefinition: { + agentId: `agent_${'a'.repeat(32)}`, + revision: '1', + digest: 'b'.repeat(64), + }, + hookCatalog: { + catalogId: 'deployment', + catalogRevision: 1, + definitionDigest: 'c'.repeat(64), + }, + }; + const created = await headers(supertest(server).post('/session')).send({ + ...request, + sessionId: SESSION_ID, + sessionScope: 'thread', + }); + expect(created.status).toBe(200); + await headers(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .set('X-Qwen-Client-Id', created.body.clientId) + .expect(204); + const replacement = await app(true); + const loaded = await headers( + supertest(replacement).post(`/session/${SESSION_ID}/load`), + ) + .send({ ...request, passiveManagedRuntimeRecovery: true }) + .expect(200); + expect(state.model).not.toHaveBeenCalled(); + await headers(supertest(replacement).post(`/session/${SESSION_ID}/detach`)) + .set('X-Qwen-Client-Id', loaded.body.clientId) + .expect(204); + const refused = await headers( + supertest(replacement).post(`/session/${SESSION_ID}/load`), + ).send({ ...request, publicChildAgentsVersion: 0 }); + expect(refused.status).toBe(409); + }); + + it('keeps the public custom Shell gate closed on the legacy extension contract', async () => { + const server = await app(true, undefined, false); + await headers(supertest(server).post('/session')) + .send({ + sessionId: SESSION_ID, + sessionScope: 'thread', + managedSessionStore: store(), + toolProfile: 'hosted-workspace-shell/1', + publicChildAgentsVersion: 1, + agentDefinition: { + agentId: `agent_${'a'.repeat(32)}`, + revision: '1', + digest: 'b'.repeat(64), + }, + }) + .expect(400); + }); + it('pins creation-time Skills for the built-in agent across resident and cold loads', async () => { const server = await app(true); const resources = LocalManagedSessionResourceStore.create({ diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 299d3666fa2..e5e7711767e 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -6,6 +6,7 @@ import { parsePublicChildModelRoute, + type PublicChildExtensionAgent, type PublicChildModelRoute, } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; @@ -13,6 +14,7 @@ import { HostedContextCompression, HostedCompressionRefusal, } from './hosted-context-compression.js'; +import { extensionInstructionText } from './hosted-extension-tool-context.js'; import { parseHostedApprovalSelection, @@ -92,6 +94,25 @@ import { readHostedMcpSettings, validateHostedMcpHistory, } from './hosted-mcp-settings.js'; +import { isManagedExtensionConfig } from '@qwen-code/qwen-code-core/managed-runtime/managed-config-install.js'; +import { + parseManagedExtensionApplyCommand, + type ManagedExtensionApplyCommand, + parseManagedExtensionEffectiveBundle, + type ManagedExtensionEffectiveBundle, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import { readManagedExtensionContributions } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-contributions.js'; +import { + parseHostedChildExtensionAgent, + hostedChildExtensionApplyCommand, + type HostedChildExtensionAgent, +} from './hosted-extension-agent.js'; +import { + HostedExtensionSession, + HostedExtensionRecoveryRequired, + HostedExtensionRevisionConflict, + type HostedExtensionPackage, +} from './hosted-extension-session.js'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Part } from '@google/genai'; @@ -377,6 +398,9 @@ const RESTORE_CONTAINER_KINDS = new Set([ 'managed-definition', 'managed-session_metadata', 'managed-config_install', + 'managed-extension-config-bundle', + 'managed-extension-contributions', + 'managed-extension-root', 'managed-file_history', 'managed-tool-outcome', 'managed-tool-result-manifest', @@ -490,6 +514,102 @@ function describeManagedError(cause: unknown): string { * Lifts a Session's recovery block. The named reason belongs to the block, * so it is cleared with it here rather than by hand at each lift site. */ +function selectExtensionCatalog( + session: HostedSession, + bundle: ManagedExtensionEffectiveBundle, + packages: readonly HostedExtensionPackage[], +): void { + session.baseSkills ??= session.skills ?? new HostedSkillCatalog([]); + session.skills = extensionSkillCatalog(session.baseSkills, packages); + session.extensionBundle = bundle; + session.extensionPackages = packages; + session.extensionContext = packages + .flatMap((entry) => + entry.contributions.context.map( + (context) => + `\n${extensionInstructionText(context.body, entry.extensionId)}\n`, + ), + ) + .join('\n\n'); +} + +function extensionSkillCatalog( + base: HostedSkillCatalog | undefined, + packages: readonly HostedExtensionPackage[], +): HostedSkillCatalog { + const agents = packages.flatMap((entry) => + entry.contributions.agents.map((agent) => agent.name), + ); + if (new Set(agents).size !== agents.length) + throw new Error('Extension agent names conflict.'); + return new HostedSkillCatalog( + [ + ...(base?.entries() ?? []), + ...packages.flatMap((entry) => + entry.contributions.skills.map((skill) => ({ + name: skill.name, + description: skill.description, + body: extensionInstructionText(skill.body, entry.extensionId), + model_invocable: skill.model_invocable, + user_invocable: skill.user_invocable, + asset_directory: `extension://${entry.extensionId}/${skill.assetDirectory.replace(/\/$/u, '')}/`, + })), + ), + ], + packages.flatMap((entry) => + entry.contributions.commands.map((command) => ({ + ...command, + extensionId: entry.extensionId, + })), + ), + ); +} + +function extensionAgentsOf(session: HostedSession) { + const bundleRef = session.extensions?.activeBundleRef; + if (!bundleRef || !session.extensionBundle) return []; + return (session.extensionPackages ?? []).flatMap((entry) => { + const member = session.extensionBundle!.packages.find( + (member) => member.pin.extensionId === entry.extensionId, + )!; + return entry.contributions.agents.map((agent) => ({ + name: agent.name, + ...(agent.model ? { model: agent.model } : {}), + ...(agent.tools ? { tools: agent.tools } : {}), + ...(agent.disallowedTools + ? { disallowedTools: agent.disallowedTools } + : {}), + descriptor: { + configRevision: session.extensionBundle!.configRevision, + bundleRef, + pin: member.pin, + contributionsRef: member.contributionsRef, + agentName: agent.name, + toolNames: [], + }, + })); + }); +} + +async function extensionBootstrapIncomplete( + session: HostedSession, + manager: HostedExtensionSession, +): Promise { + if (!session.initialSelection && !session.publicChildExtensionAgent) + return false; + const active = await manager.active(); + return ( + !manager.activeBundleRef || + !active || + active.configRevision < 1 || + (!!session.publicChildExtensionAgent && + !isDeepStrictEqual( + active.packages.map((entry) => entry.pin), + session.extensionAgentPins, + )) + ); +} + function liftRecoveryBlock(session: HostedSession): void { session.blocked = false; session.recoveryReason = undefined; @@ -533,6 +653,17 @@ interface HostedSession { agentDefinition?: HostedAgentDefinition; sessionSkillsRef?: ManagedSessionDurableRef; skills?: HostedSkillCatalog; + baseSkills?: HostedSkillCatalog; + extensions?: HostedExtensionSession; + extensionBundle?: ManagedExtensionEffectiveBundle; + extensionPackages?: readonly HostedExtensionPackage[]; + extensionContext?: string; + publicChildExtensionAgent?: HostedChildExtensionAgent; + initialSelection?: ManagedExtensionApplyCommand; + extensionBootstrapPending?: boolean; + extensionAgentPins?: ManagedExtensionApplyCommand['extensions']; + extensionAgentTools?: readonly string[]; + extensionAgentPrompt?: string; clientId: string; cwd: string; streams: Set<() => void>; @@ -1127,6 +1258,7 @@ function childAgentsOf(session: HostedSession) { promptId: string, model: string, route: PublicChildModelRoute, + extensionAgent?: PublicChildExtensionAgent, ) => { const selection = parseHostedApprovalSelection( await session.stores!.approvalSelection(), @@ -1150,7 +1282,8 @@ function childAgentsOf(session: HostedSession) { promptId, ); return { - version: 1 as const, + version: extensionAgent ? (2 as const) : (1 as const), + ...(extensionAgent ? { extensionAgent } : {}), sourcePromptId: promptId, approvalMode: selection.mode === 'auto' ? ('default' as const) : selection.mode, @@ -3688,6 +3821,14 @@ async function executeHostedTurn( mcp: session.mcp, hooks: session.hooks, skills: session.skills, + extensionConfig: session.extensionBundle + ? { + revision: session.extensionBundle.configRevision, + packages: session.extensionBundle.packages, + } + : undefined, + extensionAgents: extensionAgentsOf(session), + allowedToolNames: session.extensionAgentTools, profile: session.toolProfile, foregroundOnly: session.foregroundOnly, context: workspaceContext, @@ -3817,6 +3958,7 @@ async function executeHostedTurn( session.managed, promptId, ), + extensionInstructions: session.extensionContext, agentInstructions: await readHostedAgentInstructions( session.agentDefinition, session.managed.resources, @@ -4453,7 +4595,24 @@ export function registerHostedHarnessSessionRoutes( let captureBytes = body?.['captureBytes']; const publicChildAgentsVersion = body?.['publicChildAgentsVersion'] ?? 0; let publicChildModelRoute: PublicChildModelRoute | undefined; + let publicChildExtensionAgent: HostedChildExtensionAgent | undefined; + let initialSelection: ManagedExtensionApplyCommand | undefined; try { + if (body?.['initialSelection'] !== undefined) { + initialSelection = parseManagedExtensionApplyCommand( + body['initialSelection'], + ); + if ( + initialSelection.expectedRevision !== 0 || + initialSelection.mcpChange + ) + throw new Error('Invalid initial extension selection.'); + } + publicChildExtensionAgent = parseHostedChildExtensionAgent( + body?.['publicChildExtensionAgent'], + ); + if (publicChildExtensionAgent && initialSelection) + throw new Error('Child and creation extension bindings conflict.'); publicChildModelRoute = body?.['publicChildModelRoute'] === undefined ? undefined @@ -4463,6 +4622,11 @@ export function registerHostedHarnessSessionRoutes( (publicChildAgentsVersion !== 1 || (!body?.['lineage'] && create)) ) throw new Error('A model route belongs only to a public child.'); + if ( + publicChildExtensionAgent && + (!publicChildModelRoute || (create && !body?.['lineage'])) + ) + throw new Error('An extension agent belongs only to a public child.'); } catch { return error(res, 400, 'invalid_public_child_model_route'); } @@ -4470,13 +4634,17 @@ export function registerHostedHarnessSessionRoutes( error(res, 400, 'invalid_public_child_agents_version'); return; } + const extensionChildProfile = + contract.features?.includes('session-extension-packages/1') === true && + contract.lifecycleV2Profiles?.includes('hosted-workspace-shell/1'); if ( publicChildAgentsVersion === 1 && (toolProfile !== 'hosted-workspace-shell/1' || body?.['foregroundOnly'] === true || - body?.['mcpServers'] !== undefined || - body?.['hookCatalog'] !== undefined || - agentDefinition !== undefined || + (!extensionChildProfile && + (body?.['mcpServers'] !== undefined || + body?.['hookCatalog'] !== undefined || + agentDefinition !== undefined)) || body?.['childWorkspaces'] === true || body?.['childSnapshots'] === true) ) { @@ -5013,6 +5181,19 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_tool_profile_conflict'); return; } + if ( + !isDeepStrictEqual( + resident.publicChildExtensionAgent, + publicChildExtensionAgent, + ) + ) { + error(res, 409, 'hosted_tool_profile_conflict'); + return; + } + if (!isDeepStrictEqual(resident.initialSelection, initialSelection)) { + error(res, 409, 'hosted_tool_profile_conflict'); + return; + } if (resident.childAgents) { await resident.childAgents.serialized(async () => { resident.publicChildLaunchEnabled = @@ -5565,6 +5746,10 @@ export function registerHostedHarnessSessionRoutes( ? { publicChildAgentsVersion } : {}), ...(publicChildModelRoute ? { publicChildModelRoute } : {}), + ...(publicChildExtensionAgent + ? { publicChildExtensionAgent } + : {}), + ...(initialSelection ? { initialSelection } : {}), ...(sessionSkillsRef ? { sessionSkillsRef } : {}), ...(toolProfile ? { toolProfile } : {}), ...(foregroundOnly ? { foregroundOnly: true } : {}), @@ -5684,6 +5869,17 @@ export function registerHostedHarnessSessionRoutes( ) ) throw new Error('Public child model route changed.'); + if ( + !isDeepStrictEqual( + definition?.['publicChildExtensionAgent'], + publicChildExtensionAgent, + ) + ) + throw new Error('Public child extension binding changed.'); + if ( + !isDeepStrictEqual(definition?.['initialSelection'], initialSelection) + ) + throw new Error('Initial extension selection changed.'); if ( (definition?.['publicChildAgentsVersion'] ?? 0) !== publicChildAgentsVersion @@ -5776,6 +5972,8 @@ export function registerHostedHarnessSessionRoutes( ? { publicChildAgentsVersion: 1 as const } : {}), ...(publicChildModelRoute ? { publicChildModelRoute } : {}), + ...(publicChildExtensionAgent ? { publicChildExtensionAgent } : {}), + ...(initialSelection ? { initialSelection } : {}), publicChildLaunchEnabled: body?.['suppressChildAgents'] !== true, ...(savedSessionSkillsRef ? { sessionSkillsRef: savedSessionSkillsRef } @@ -5851,7 +6049,20 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_tool_profile_conflict'); return; } - const mcpSettings = await readHostedMcpSettings(managed); + const configurationHead = + managed.authority.domainRecord('config_install'); + const hasExtensionConfiguration = + configurationHead && + isManagedExtensionConfig( + JSON.parse( + ( + await managed.resources.read(configurationHead.recordRef) + ).toString('utf8'), + ), + ); + const mcpSettings = hasExtensionConfiguration + ? [] + : await readHostedMcpSettings(managed); validateHostedMcpHistory(mcpServers ?? [], mcpSettings); if ( mcpSettings.length && @@ -5900,21 +6111,109 @@ export function registerHostedHarnessSessionRoutes( sendAttachment(res, sessionId, session); return; } - if ((mcpServers || mcpSettings.length) && brokerOptions) + const extensionManagement = + hasExtensionConfiguration || + publicChildExtensionAgent || + initialSelection + ? await extensionsManager(session, !!publicChildExtensionAgent) + : undefined; + if (initialSelection && extensionManagement && !passiveRecovery) + await extensionManagement.bootstrap(initialSelection); + if (publicChildExtensionAgent && extensionManagement) { + const source = parseManagedExtensionEffectiveBundle( + JSON.parse( + ( + await managed.resources.read( + publicChildExtensionAgent.effectiveConfigRef, + ) + ).toString('utf8'), + ), + ); + const descriptor = publicChildExtensionAgent.extensionAgent; + const member = source.packages.find( + (entry) => + isDeepStrictEqual(entry.pin, descriptor.pin) && + isDeepStrictEqual( + entry.contributionsRef, + descriptor.contributionsRef, + ), + ); + if (!member || source.configRevision !== descriptor.configRevision) + throw new Error('Child extension source membership changed.'); + const contributions = await readManagedExtensionContributions( + managed.resources, + member.contributionsRef, + ); + const agent = contributions.agents.find( + (entry) => entry.name === descriptor.agentName, + ); + if ( + !agent || + descriptor.toolNames.some( + (name) => + (agent.tools && !agent.tools.includes(name)) || + agent.disallowedTools?.includes(name), + ) + ) + throw new Error('Child extension descriptor changed.'); + session.extensionAgentTools = descriptor.toolNames; + session.extensionAgentPrompt = extensionInstructionText( + agent.systemPrompt, + descriptor.pin.extensionId, + ); + session.extensionAgentPins = source.packages.map((entry) => entry.pin); + if (!passiveRecovery) + await extensionManagement.bootstrap( + hostedChildExtensionApplyCommand( + sessionId, + publicChildExtensionAgent.effectiveConfigRef.digest, + session.extensionAgentPins, + ), + ); + } + const extensionBundle = await extensionManagement?.active(); + if ( + extensionManagement && + (await extensionBootstrapIncomplete(session, extensionManagement)) + ) { + if (!passiveRecovery) throw new HostedExtensionRecoveryRequired(); + session.extensionBootstrapPending = true; + session.blocked = true; + } + const effectiveMcpServers = extensionBundle?.mcpServers ?? mcpServers; + const effectiveHookCatalog = extensionBundle + ? extensionBundle.hookCatalog + : hookCatalog; + if ( + (effectiveMcpServers?.length || mcpSettings.length) && + brokerOptions && + !session.mcp + ) session.mcp = new HostedMcpSession( { ...brokerOptions, lifecycleAuthority: () => session.lifecycle }, managed, - mcpServers ?? [], + effectiveMcpServers ?? [], mcpSettings.at(-1), + undefined, + extensionBundle !== undefined, ); - if (hookCatalog && brokerOptions) + if (effectiveHookCatalog && brokerOptions && !session.hooks) session.hooks = new HostedHookSession( { ...brokerOptions, lifecycleAuthority: () => session.lifecycle }, managed, - hookCatalog, + effectiveHookCatalog, session.mcp?.broker, () => session.lifecycleProtocolVersion === 2, ); + if (extensionBundle && extensionManagement) + selectExtensionCatalog( + session, + extensionBundle, + await extensionManagement.packages(extensionBundle), + ); + if (session.extensionAgentPrompt) + session.extensionContext = `${session.extensionContext ?? ''}\n\n${session.extensionAgentPrompt}`; + if (extensionManagement?.pending) session.blocked = true; if (!foregroundOnly && session.toolProfile && brokerOptions) { session.childRuns = new HostedChildRunSession( { @@ -6539,6 +6838,11 @@ export function registerHostedHarnessSessionRoutes( session.monitorWake?.kick(); } if (pinned) session.approval = pinned; + if (session.extensionBootstrapPending) { + sessions.set(sessionId, session); + sendAttachment(res, sessionId, session); + return; + } // A takeover recovers exactly the parked Turn, including the file // history it left pending; only refuse a stranger's pending state. // A bare load of a parked Session keeps refusing with 409 so it never @@ -6773,8 +7077,7 @@ export function registerHostedHarnessSessionRoutes( takeover && unsettled && session.toolProfile && - !session.hooks && - !session.mcp && + ((!session.hooks && !session.mcp) || session.extensions?.enabled) && brokerOptions ) { const verdict = await managed.authority.harnessRunAuthorization(); @@ -7466,8 +7769,26 @@ export function registerHostedHarnessSessionRoutes( if (!identity(req, sessions, legacyClose)) return error(res, 404, 'hosted_session_not_found'); try { + const extensionBody = object(req.body); + const extensionCommand = + object(extensionBody?.['command']) ?? extensionBody; + const extensionOperation = + req.method === 'POST' && + (req.path === '/extensions/management' || + (req.path === '/mcp/management' && session.extensions?.enabled)) + ? extensionCommand?.['operationId'] + : req.method === 'POST' + ? /^\/extensions\/management\/operations\/([^/]+)\/(?:continue|recover-previous)$/u.exec( + req.path, + )?.[1] + : undefined; await session.stores!.authorizeOrdinary( - legacyClose ? 'legacy-close' : undefined, + legacyClose + ? 'legacy-close' + : typeof extensionOperation === 'string' && + HOSTED_UUID.test(extensionOperation) + ? `configuration:${extensionOperation}` + : undefined, ); if (session.lifecycle) return error(res, 409, 'hosted_lifecycle_operation_active'); @@ -8516,6 +8837,8 @@ export function registerHostedHarnessSessionRoutes( const session = identity(req, sessions); if (!session) return error(res, 404, 'hosted_session_not_found'); if (!session.hooks) return error(res, 409, 'hosted_hooks_unavailable'); + if (session.extensions?.enabled) + return error(res, 409, 'hosted_extension_configuration_required'); if ( session.active || session.hooksBusy || @@ -8574,12 +8897,295 @@ export function registerHostedHarnessSessionRoutes( ); }; + async function extensionsManager( + session: HostedSession, + childBootstrap = false, + ): Promise { + if ( + !brokerOptions || + session.foregroundOnly || + ((session.childDepth ?? 0) !== 0 && + !(childBootstrap && session.publicChildExtensionAgent)) || + !['hosted-workspace-shell/1', HOSTED_MCP_PROFILE].includes( + session.toolProfile ?? '', + ) || + !contract.lifecycleV2Profiles?.includes(session.toolProfile!) + ) + throw new Error('Hosted extension management is unavailable.'); + if (session.extensions) return session.extensions; + const head = session.managed.authority.domainRecord('config_install'); + const unified = + head && + isManagedExtensionConfig( + JSON.parse( + (await session.managed.resources.read(head.recordRef)).toString( + 'utf8', + ), + ), + ); + const legacy = unified ? [] : await readHostedMcpSettings(session.managed); + const baseMcp = session.definition?.['mcpServers']; + const options = { + ...brokerOptions, + lifecycleAuthority: () => session.lifecycle, + }; + const manager = new HostedExtensionSession( + session.managed, + options, + { + mcpRevision: legacy.at(-1)?.revision ?? 0, + mcpServers: + legacy.at(-1)?.servers ?? + (baseMcp === undefined + ? [] + : parseHostedMcpServers(baseMcp).map((pin) => ({ + ...pin, + state: 'enabled' as const, + }))), + hookCatalog: + session.hooks?.selectedPin ?? + (session.definition?.['hookCatalog'] === undefined + ? null + : parseHostedHookPin(session.definition['hookCatalog'])), + }, + { + validate: (packages) => { + extensionSkillCatalog(session.baseSkills ?? session.skills, packages); + if ( + (session.childDepth ?? 0) === 0 && + session.publicChildAgentsVersion !== 1 && + packages.some((entry) => entry.contributions.agents.length > 0) + ) + throw new Error( + 'Extension agents require the public child Session profile.', + ); + }, + drain: async (bundle, forceMcpServerId) => { + if ( + session.hooks && + !isDeepStrictEqual(session.hooks.selectedPin, bundle.hookCatalog) + ) { + await session.hooks.close(); + session.hooks = undefined; + } + if (bundle.mcpServers.length || session.hooks) + await session.mcp?.drainForExtensionConfiguration( + bundle.mcpServers, + forceMcpServerId, + ); + else if (session.mcp) { + await session.mcp.close(); + session.mcp = undefined; + } + }, + enable: async (bundle, packages, operationId) => { + const ownerId = `extensions-components-${createHash('sha256') + .update( + JSON.stringify([ + session.managed.authority.sessionHeader.sessionKey, + bundle.configRevision, + ]), + ) + .digest('hex')}`; + if (bundle.mcpServers.length && !session.mcp) { + const broker = + session.hooks?.broker ?? + new HostedWorkspaceBroker( + options, + session.managed.authority.sessionHeader.sessionKey, + ownerId, + ); + session.mcp = new HostedMcpSession( + options, + session.managed, + bundle.mcpServers, + undefined, + broker, + true, + ); + if (session.hooks?.broker === broker) + session.hooks.delegateBrokerToMcp(); + } + if (bundle.hookCatalog && !session.hooks) { + session.hooks = new HostedHookSession( + options, + session.managed, + bundle.hookCatalog, + session.mcp?.broker, + () => session.lifecycleProtocolVersion === 2, + ownerId, + ); + } + if ( + bundle.hookCatalog && + session.hooks && + (!isDeepStrictEqual( + session.hooks.selectedPin, + bundle.hookCatalog, + ) || + session.managed.authority.extensionRecordsInDomain( + 'hook_registration', + ).length === 0) + ) + await session.hooks.configure( + `extension-catalog-${operationId}-${bundle.configRevision}`, + bundle.hookCatalog, + session.managed.authority.extensionRecordsInDomain( + 'hook_registration', + ).length, + ); + await session.mcp?.enableExtensionConfiguration( + bundle.mcpServers, + operationId, + ); + await session.hooks?.ensureReady(); + selectExtensionCatalog(session, bundle, packages); + }, + }, + ); + await manager.open(); + session.extensions = manager; + return manager; + } + + app.get('/session/:id/extensions/management', (req, res) => { + const session = identity(req, sessions); + if (!session) return error(res, 404, 'hosted_session_not_found'); + void extensionsManager(session, !!session.publicChildExtensionAgent) + .then(async (manager) => res.json(await manager.snapshot())) + .catch(() => error(res, 409, 'hosted_extension_management_unavailable')); + }); + + app.get( + '/session/:id/extensions/management/operations/:operationId', + (req, res) => { + const session = identity(req, sessions); + if (!session) return error(res, 404, 'hosted_session_not_found'); + void extensionsManager(session, !!session.publicChildExtensionAgent) + .then(async (manager) => + res.json(await manager.snapshot(req.params['operationId'])), + ) + .catch(() => error(res, 404, 'hosted_extension_operation_not_found')); + }, + ); + + for (const action of ['apply', 'continue', 'recover-previous'] as const) { + const path = + action === 'apply' + ? '/session/:id/extensions/management' + : `/session/:id/extensions/management/operations/:operationId/${action}`; + app.post(path, (req, res) => { + const session = identity(req, sessions); + if (!session) return error(res, 404, 'hosted_session_not_found'); + if ( + session.active || + session.hooksBusy || + session.mcpBusy || + session.mcpRecovering || + session.mcpClosing || + session.lifecycle || + session.recoveryCleanupOnly || + (action === 'apply' && + session.blocked && + !session.extensionBootstrapPending) + ) + return error(res, 409, 'hosted_turn_active'); + let operationId: string; + let command: + | ReturnType + | undefined; + try { + if (action === 'apply') { + command = parseManagedExtensionApplyCommand( + object(req.body)?.['command'] ?? req.body, + ); + operationId = command.operationId; + } else { + operationId = req.params['operationId']; + if (!HOSTED_UUID.test(operationId)) + throw new Error('Invalid operation.'); + } + } catch { + return error(res, 400, 'invalid_extension_command'); + } + session.mcpBusy = true; + void (async () => { + const manager = await extensionsManager( + session, + !!session.publicChildExtensionAgent, + ); + try { + if (command && session.publicChildExtensionAgent) { + const binding = session.publicChildExtensionAgent; + const source = parseManagedExtensionEffectiveBundle( + JSON.parse( + ( + await session.managed.resources.read( + binding.effectiveConfigRef, + ) + ).toString('utf8'), + ), + ); + if ( + !isDeepStrictEqual( + command, + hostedChildExtensionApplyCommand( + session.managed.authority.sessionHeader.sessionKey.sessionId, + binding.effectiveConfigRef.digest, + source.packages.map((entry) => entry.pin), + ), + ) + ) + throw new HostedMcpConflictError( + 'Child extension configuration is immutable.', + ); + } + if (command) await manager.submit(command); + else if (action === 'continue') await manager.continue(operationId); + else await manager.recoverPrevious(operationId); + session.extensionBootstrapPending = + await extensionBootstrapIncomplete(session, manager); + session.blocked = + manager.pending || session.extensionBootstrapPending; + res.status(202).json(await manager.snapshot(operationId)); + } catch (cause) { + session.extensionBootstrapPending = + await extensionBootstrapIncomplete(session, manager); + session.blocked = + manager.pending || session.extensionBootstrapPending; + if (cause instanceof HostedExtensionRevisionConflict) + error(res, 409, 'extension_config_revision_conflict'); + else if ( + cause instanceof HostedMcpConflictError || + cause instanceof ManagedSessionConflictError || + cause instanceof ManagedSessionCommitRejectedError + ) + error(res, 409, 'hosted_extension_configuration_conflict'); + else if (manager.enabled) + res.status(202).json(await manager.snapshot(operationId)); + else error(res, 503, 'hosted_extension_recovery_required'); + } + })() + .catch(() => { + if (!res.headersSent) + error(res, 503, 'hosted_extension_recovery_required'); + }) + .finally(() => { + session.mcpBusy = false; + }); + }); + } + app.get('/session/:id/mcp/management', (req, res) => { const session = identity(req, sessions); if (!session) return error(res, 404, 'hosted_session_not_found'); if (session.recoveryCleanupOnly) return error(res, 409, 'hosted_mcp_session_load_required'); void (async () => { + if (session.extensions?.enabled) { + res.json(await session.extensions.mcpSnapshot()); + return; + } const manager = mcpManager(session); await manager.open(); res.json(await manager.snapshot()); @@ -8592,6 +9198,15 @@ export function registerHostedHarnessSessionRoutes( if (session.recoveryCleanupOnly) return error(res, 409, 'hosted_mcp_session_load_required'); void (async () => { + if (session.extensions?.enabled) { + res.json( + await session.extensions.mcpSnapshot( + req.params['operationId'], + false, + ), + ); + return; + } const manager = mcpManager(session); await manager.open(); if (!manager.status(req.params['operationId'])) @@ -8617,16 +9232,26 @@ export function registerHostedHarnessSessionRoutes( ) return error(res, 409, 'hosted_turn_active'); let command; - let manager: HostedMcpManager; + let manager: HostedMcpManager | undefined; try { command = parseHostedMcpCommand(req.body); - manager = mcpManager(session); + if (!session.extensions?.enabled) manager = mcpManager(session); } catch { return error(res, 400, 'invalid_mcp_command'); } session.mcpBusy = true; void (async () => { try { + if (session.extensions?.enabled) { + await session.extensions.submitMcp(command); + res + .status(202) + .json( + await session.extensions.mcpSnapshot(command.operationId, false), + ); + return; + } + if (!manager) throw new Error('MCP manager is unavailable.'); await manager.open(); await manager.submit(command); res @@ -8639,13 +9264,21 @@ export function registerHostedHarnessSessionRoutes( cause instanceof ManagedSessionCommitRejectedError ) error(res, 409, 'hosted_mcp_configuration_conflict'); - else if (manager.status(command.operationId)) + else if (session.extensions?.enabled) + res + .status(202) + .json( + await session.extensions.mcpSnapshot(command.operationId, false), + ); + else if (manager?.status(command.operationId)) res .status(202) .json(await manager.snapshot(false, command.operationId)); else error(res, 503, 'hosted_mcp_recovery_required'); } finally { - session.mcp = manager.mcp; + if (manager) session.mcp = manager.mcp; + if (session.extensions?.enabled) + session.blocked = session.extensions.pending; if (session.mcp && session.hooks?.broker === session.mcp.broker) session.hooks.delegateBrokerToMcp(); session.mcpBusy = false; @@ -10245,6 +10878,14 @@ export function registerHostedHarnessSessionRoutes( { mcp: session.mcp, skills: session.skills, + extensionConfig: session.extensionBundle + ? { + revision: session.extensionBundle.configRevision, + packages: session.extensionBundle.packages, + } + : undefined, + extensionAgents: extensionAgentsOf(session), + allowedToolNames: session.extensionAgentTools, profile: session.toolProfile, foregroundOnly: session.foregroundOnly, context: workspaceContext, @@ -10525,6 +11166,7 @@ export function registerHostedHarnessSessionRoutes( session.managed, promptId, ), + extensionInstructions: session.extensionContext, agentInstructions: await readHostedAgentInstructions( session.agentDefinition, session.managed.resources, diff --git a/packages/cli/src/serve/hosted-hook-session.ts b/packages/cli/src/serve/hosted-hook-session.ts index 70705265016..2d492a81fa7 100644 --- a/packages/cli/src/serve/hosted-hook-session.ts +++ b/packages/cli/src/serve/hosted-hook-session.ts @@ -395,6 +395,7 @@ export class HostedHookSession { private readonly initialPin: ManagedHookCatalogPin, broker?: HostedWorkspaceBroker, private readonly lifecycleV2: () => boolean = () => false, + runtimeSessionId?: string, ) { this.ownsBroker = broker === undefined; const previous = lifecycleV2() @@ -405,9 +406,10 @@ export class HostedHookSession { new HostedWorkspaceBroker( options, this.key, - previous - ? parseHookExecution(previous.record).runtimeSessionId - : `hooks-activation-${digest([session.activation.activationId, session.activation.epoch])}`, + runtimeSessionId ?? + (previous + ? parseHookExecution(previous.record).runtimeSessionId + : `hooks-activation-${digest([session.activation.activationId, session.activation.epoch])}`), ); } @@ -442,6 +444,20 @@ export class HostedHookSession { ); } + get selectedPin(): ManagedHookCatalogPin { + const latest = this.session.authority + .extensionRecordsInDomain('hook_registration') + .filter((entry) => entry.run.state === 'settled') + .at(-1); + if (!latest) return this.initialPin; + const registration = parseHookRegistration(latest.record); + return { + catalogId: registration.catalogId, + catalogRevision: registration.catalogRevision, + definitionDigest: registration.run.definition!.definitionDigest, + }; + } + async acquire(): Promise { if (this.acquired) { // A release absorbed on an earlier turn is retried here, so a fenced diff --git a/packages/cli/src/serve/hosted-mcp-session.test.ts b/packages/cli/src/serve/hosted-mcp-session.test.ts index a3db84bf4bc..0c99bad4754 100644 --- a/packages/cli/src/serve/hosted-mcp-session.test.ts +++ b/packages/cli/src/serve/hosted-mcp-session.test.ts @@ -340,6 +340,63 @@ it('queries a lost configure reply by its original identity instead of reconnect }); }); +it('keeps an unchanged independent MCP connection through package configuration changes', async () => { + await mcp.ensureReady(); + const before = mcp.tools(); + const configuration = + session.authority.extensionRecordsInDomain('mcp_configuration')[0]!; + const token = parseMcpConfiguration(configuration.record); + const requestCount = requests.length; + await mcp.drainForExtensionConfiguration([pin]); + await mcp.enableExtensionConfiguration([pin], randomUUID()); + expect(requests).toHaveLength(requestCount); + expect(mcp.tools()).toEqual(before); + expect( + session.authority.extensionRecordsInDomain('mcp_configuration'), + ).toHaveLength(1); + expect( + parseMcpConfiguration( + session.authority.extensionRecord( + 'mcp_configuration', + token.configurationId, + )!.record, + ), + ).toMatchObject({ + configurationId: token.configurationId, + configRevision: token.configRevision, + connectionGeneration: token.connectionGeneration, + releaseState: 'active', + }); +}); + +it('drains only the MCP server changed by an extension configuration', async () => { + const other = { ...pin, serverId: 'other' }; + mcp = new HostedMcpSession( + { baseUrl: 'http://127.0.0.1:1', token: 'test' }, + session, + [pin, other], + ); + await mcp.ensureReady(); + const preserved = session.authority + .extensionRecordsInDomain('mcp_configuration') + .map((entry) => parseMcpConfiguration(entry.record)) + .find((entry) => entry.serverId === 'other')!; + const next = { ...pin, serverRevision: 2, definitionDigest: 'b'.repeat(64) }; + await mcp.drainForExtensionConfiguration([next, other]); + await mcp.enableExtensionConfiguration([next, other], randomUUID()); + expect( + requests + .filter((request) => request.kind === 'mcp-release') + .map((request) => ('serverId' in request ? request.serverId : undefined)), + ).toEqual(['demo']); + expect( + session.authority + .extensionRecordsInDomain('mcp_configuration') + .map((entry) => parseMcpConfiguration(entry.record)) + .filter((entry) => entry.serverId === 'other'), + ).toEqual([preserved]); +}); + async function interruptBeforeDispatch(operationId: string) { await mcp.ensureReady(); const original = session.authority.commitExtensionRecord.bind( diff --git a/packages/cli/src/serve/hosted-mcp-session.ts b/packages/cli/src/serve/hosted-mcp-session.ts index d6558c88136..06a835a2339 100644 --- a/packages/cli/src/serve/hosted-mcp-session.ts +++ b/packages/cli/src/serve/hosted-mcp-session.ts @@ -121,6 +121,7 @@ export class HostedMcpSession { string, { configuration: McpConfiguration; catalog: ManagedMcpCatalog } >(); + private extensionServers?: readonly HostedMcpServerPin[]; constructor( private readonly options: HostedWorkspaceBrokerOptions, @@ -128,8 +129,10 @@ export class HostedMcpSession { private readonly admissionServers: readonly HostedMcpServerPin[], settings?: HostedMcpSettings, sharedBroker?: HostedWorkspaceBroker, + extensionView = false, ) { this.settings = settings; + if (extensionView) this.extensionServers = admissionServers; const configurations = this.configurations(); const owners = new Set( configurations @@ -163,6 +166,7 @@ export class HostedMcpSession { } get servers(): readonly HostedMcpServerPin[] { + if (this.extensionServers) return this.extensionServers; return [ ...new Map( [...this.admissionServers, ...(this.settings?.servers ?? [])].map( @@ -173,6 +177,11 @@ export class HostedMcpSession { } get selectedServers(): readonly HostedMcpSelection[] { + if (this.extensionServers) + return this.extensionServers.map((pin) => ({ + ...pin, + state: 'enabled' as const, + })); return ( this.settings?.servers ?? this.admissionServers.map((pin) => ({ @@ -182,6 +191,10 @@ export class HostedMcpSession { ); } + get managementRevision(): number { + return this.settings?.revision ?? 0; + } + private enabled(serverId: string): boolean { return this.selectedServers.some( (pin) => pin.serverId === serverId && pin.state === 'enabled', @@ -260,13 +273,104 @@ export class HostedMcpSession { signal?.throwIfAborted(); if (this.configuring.size) throw new HostedMcpConflictError('MCP configuration is in progress.'); - this.ready ??= this.initialize(signal).catch((cause: unknown) => { + this.ready ??= ( + this.extensionServers + ? this.installExtensionServers( + this.extensionServers, + `extension-load-${digest(this.extensionServers).slice(0, 24)}`, + signal, + ) + : this.initialize(signal) + ).catch((cause: unknown) => { this.ready = undefined; throw cause; }); await waitForTurn(this.ready, signal); } + async drainForExtensionConfiguration( + servers: readonly HostedMcpServerPin[], + forceServerId?: string, + ): Promise { + await this.reconcileEffects(); + const changed = this.configurations().filter( + (entry) => + entry.releaseState !== 'released' && + (entry.serverId === forceServerId || + !servers.some( + (pin) => + pin.serverId === entry.serverId && + pin.serverRevision === entry.serverRevision && + pin.definitionDigest === entry.run.definition?.definitionDigest, + )), + ); + await this.drainConfigurations(changed); + for (const entry of changed) { + const latest = this.configurations().find( + (value) => value.configurationId === entry.configurationId, + )!; + if (latest.releaseState !== 'released') + await this.commitConfiguration({ ...latest, releaseState: 'released' }); + this.catalogs.delete(entry.serverId); + for (const [key, binding] of this.bindings) + if (binding.configuration.serverId === entry.serverId) + this.bindings.delete(key); + } + this.extensionServers = servers; + this.settings = undefined; + } + + async enableExtensionConfiguration( + servers: readonly HostedMcpServerPin[], + operationId: string, + ): Promise { + this.extensionServers = servers; + this.settings = undefined; + await this.installExtensionServers(servers, operationId); + this.ready = Promise.resolve(); + } + + private async installExtensionServers( + servers: readonly HostedMcpServerPin[], + operationId: string, + signal?: AbortSignal, + ): Promise { + for (const pin of servers) { + const matching = this.configurations() + .filter( + (entry) => + entry.serverId === pin.serverId && + entry.serverRevision === pin.serverRevision && + entry.run.definition?.definitionDigest === pin.definitionDigest && + entry.releaseState === 'active' && + entry.run.state === 'settled', + ) + .at(-1); + if (matching) { + if ( + this.catalogs.get(pin.serverId)?.configuration.configurationId !== + matching.configurationId + ) + await this.install(pin, matching, undefined, undefined, signal); + continue; + } + const id = `extension-mcp-${digest([operationId, pin.serverId]).slice(0, 48)}`; + const previous = this.session.authority.extensionRecord( + 'mcp_configuration', + id, + ); + const revision = previous + ? parseMcpConfiguration(previous.record).configRevision - 1 + : Math.max( + 0, + ...this.configurations() + .filter((entry) => entry.serverId === pin.serverId) + .map((entry) => entry.configRevision), + ); + await this.configure(id, pin, revision, signal); + } + } + async refresh(signal?: AbortSignal): Promise { this.refreshing++; try { @@ -664,7 +768,7 @@ export class HostedMcpSession { for (const { catalog, configuration } of this.bindings.values()) { if ( !this.enabled(catalog.serverId) || - (this.settings && + ((this.settings || this.extensionServers !== undefined) && parseMcpConfiguration( this.session.authority.extensionRecord( 'mcp_configuration', diff --git a/packages/cli/src/serve/hosted-runtime-recovery.ts b/packages/cli/src/serve/hosted-runtime-recovery.ts index 15fa4f7b50f..a4ece831ef0 100644 --- a/packages/cli/src/serve/hosted-runtime-recovery.ts +++ b/packages/cli/src/serve/hosted-runtime-recovery.ts @@ -5,6 +5,8 @@ */ import { randomUUID } from 'node:crypto'; +import { parseManagedConfigInstallRecord } from '@qwen-code/qwen-code-core/managed-runtime/managed-config-install.js'; +import { expandHostedExtensionCommand } from './hosted-extension-command.js'; import { isDeepStrictEqual } from 'node:util'; import type { Part } from '@google/genai'; import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; @@ -1196,6 +1198,50 @@ async function hostedSkillResponse( args = updated; } const problem = catalog.validationError(args, origin); + const command = catalog.command(args); + if (!problem && command) { + const head = session.authority.domainRecord('config_install'); + if (!head) throw new HostedHookRecoveryRequiredError(); + const record = parseManagedConfigInstallRecord( + JSON.parse( + (await session.resources.read(head.recordRef)).toString('utf8'), + ), + ); + if (record.phase !== 'active') throw new HostedHookRecoveryRequiredError(); + try { + const text = await expandHostedExtensionCommand({ + session, + broker: {} as HostedWorkspaceBroker, + promptId, + callId, + revision: record.configRevision, + command, + args: String((args as Record)['args'] ?? ''), + signal: new AbortController().signal, + definitionDigest: () => { + throw new HostedHookRecoveryRequiredError(); + }, + decide: async () => { + throw new HostedHookRecoveryRequiredError(); + }, + completed: async (id, _name, _input, result) => { + if ( + hooks && + !hooks.hasCompletedOccurrence( + result['executionStatus'] === 'success' + ? HookEventName.PostToolUse + : HookEventName.PostToolUseFailure, + id, + ) + ) + throw new HostedHookRecoveryRequiredError(); + }, + }); + return convertToFunctionResponse('skill', callId, [{ text }]); + } catch { + throw new HostedHookRecoveryRequiredError(); + } + } return problem ? convertToFunctionErrorResponse('skill', callId, [], problem) : convertToFunctionResponse('skill', callId, [ diff --git a/packages/cli/src/serve/hosted-skill-catalog.ts b/packages/cli/src/serve/hosted-skill-catalog.ts index 86975196d44..bbb41883e3c 100644 --- a/packages/cli/src/serve/hosted-skill-catalog.ts +++ b/packages/cli/src/serve/hosted-skill-catalog.ts @@ -28,6 +28,16 @@ export interface HostedSkill { readonly base_directory?: string; readonly model_invocable?: boolean; readonly user_invocable?: boolean; + readonly asset_directory?: string; +} + +export interface HostedCommand { + readonly name: string; + readonly description: string; + readonly prompt: string; + readonly extensionId: string; + readonly modelInvocable: boolean; + readonly argumentHint?: string; } export function parseSessionSkillsRef( @@ -96,6 +106,7 @@ export function parseHostedSkills(value: unknown): readonly HostedSkill[] { 'base_directory', 'model_invocable', 'user_invocable', + 'asset_directory', ].includes(key), ) || !boundedText(skill['name'], 128) || @@ -111,6 +122,14 @@ export function parseHostedSkills(value: unknown): readonly HostedSkill[] { if (names.has(skill['name'])) invalid(); names.add(skill['name']); const base = skill['base_directory']; + const asset = skill['asset_directory']; + if ( + asset !== undefined && + (typeof asset !== 'string' || + !/^extension:\/\/[A-Za-z0-9._:-]+\/(?:[^\\\s]*\/?)$/u.test(asset) || + asset.split('/').some((part) => part === '..' || part === '.')) + ) + invalid(); if (base !== undefined) { if ( !boundedText(base, 4096) || @@ -132,6 +151,7 @@ export function parseHostedSkills(value: unknown): readonly HostedSkill[] { name: skill['name'], description: skill['description'], body: skill['body'], + ...(asset === undefined ? {} : { asset_directory: asset as string }), ...(base === undefined ? {} : { base_directory: base as string }), ...(skill['model_invocable'] === undefined ? {} @@ -169,14 +189,56 @@ export async function readHostedSkills( export class HostedSkillCatalog { private readonly skills: readonly HostedSkill[]; - constructor(skills: readonly HostedSkill[]) { + constructor( + skills: readonly HostedSkill[], + private readonly commands: readonly HostedCommand[] = [], + ) { this.skills = parseHostedSkills(skills); + const names = new Set(this.skills.map((skill) => skill.name)); + for (const command of commands) { + validateSkillName(command.name); + if ( + names.has(command.name) || + !boundedText(command.description, 4096) || + !boundedText(command.prompt, 512 * 1024) + ) + invalid(); + names.add(command.name); + } + } + + entries(): readonly HostedSkill[] { + return this.skills; + } + + command(args: unknown): HostedCommand | undefined { + if (!args || typeof args !== 'object') return undefined; + return this.commands.find( + (command) => command.name === (args as { skill?: unknown }).skill, + ); + } + + userArguments(name: string, prompt: string): { args?: string } { + return this.commands.some((command) => command.name === name) + ? { + args: prompt + .trimStart() + .slice(name.length + 1) + .trimStart(), + } + : {}; } instructions(): string | undefined { - const available = this.skills.filter( - (skill) => skill.model_invocable !== false, - ); + const available = [ + ...this.skills.filter((skill) => skill.model_invocable !== false), + ...this.commands + .filter((command) => command.modelInvocable) + .map((command) => ({ + name: command.name, + description: command.description, + })), + ]; if (!available.length) return undefined; return `The following published skills are available. Use the skill tool to load their instructions when relevant.\n${renderAvailableSkillsBlock(available)}`; } @@ -188,21 +250,25 @@ export class HostedSkillCatalog { const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(remaining); if ( !match || - !this.skills.some( + (!this.skills.some( (skill) => skill.name === match[1] && skill.user_invocable !== false, - ) + ) && + !this.commands.some((command) => command.name === match[1])) ) return [...names]; names.add(match[1]); + if (this.commands.some((command) => command.name === match[1])) + return [...names]; remaining = remaining.slice(match[0].length); } } declaration(): FunctionDeclaration | undefined { - const available = this.skills.filter( - (skill) => skill.model_invocable !== false, - ); - if (!available.length) return undefined; + const available = [ + ...this.skills.filter((skill) => skill.model_invocable !== false), + ...this.commands.filter((command) => command.modelInvocable), + ]; + if (!available.length && !this.commands.length) return undefined; return { name: 'skill', description: @@ -214,6 +280,7 @@ export class HostedSkillCatalog { type: 'string', enum: available.map((skill) => skill.name), }, + ...(this.commands.length ? { args: { type: 'string' } } : {}), }, required: ['skill'], additionalProperties: false, @@ -228,8 +295,19 @@ export class HostedSkillCatalog { if (!args || typeof args !== 'object' || Array.isArray(args)) return 'Hosted Skill requires only a skill name.'; const values = args as Record; - if (Object.keys(values).length !== 1 || typeof values['skill'] !== 'string') + const command = this.command(args); + if ( + (command + ? Object.keys(values).some((key) => !['skill', 'args'].includes(key)) || + (values['args'] !== undefined && typeof values['args'] !== 'string') + : Object.keys(values).length !== 1) || + typeof values['skill'] !== 'string' + ) return 'Hosted Skill requires only a skill name; arguments and activation options are unsupported.'; + if (command) + return origin === 'model' && !command.modelInvocable + ? 'This command may only be invoked by the user.' + : undefined; if (!this.skills.some((skill) => skill.name === values['skill'])) return `Skill ${JSON.stringify(values['skill'])} is not published for this Session.`; const skill = this.skills.find((entry) => entry.name === values['skill'])!; @@ -244,7 +322,13 @@ export class HostedSkillCatalog { const error = this.validationError(args); if (error) throw new Error(error); const name = (args as { skill: string }).skill; + if (this.command(args)) + throw new Error( + 'Extension commands require the durable command expansion pipeline.', + ); const skill = this.skills.find((entry) => entry.name === name)!; - return `Base directory for this skill relative to the Session working directory: ${skill.base_directory ?? '.'}\nResolve asset paths relative to this directory with the Workspace tools.\n\n${skill.body}\n`; + return skill.asset_directory + ? `Base directory for this skill: ${skill.asset_directory}\nResolve asset paths relative to this directory with the Workspace tools.\n\n${skill.body}\n` + : `Base directory for this skill relative to the Session working directory: ${skill.base_directory ?? '.'}\nResolve asset paths relative to this directory with the Workspace tools.\n\n${skill.body}\n`; } } diff --git a/packages/cli/src/serve/hosted-tool-approval.ts b/packages/cli/src/serve/hosted-tool-approval.ts index 6cf5bea2f2a..44f416c2bc4 100644 --- a/packages/cli/src/serve/hosted-tool-approval.ts +++ b/packages/cli/src/serve/hosted-tool-approval.ts @@ -251,7 +251,15 @@ export function parseHostedActionOptions(bytes: Buffer): HostedActionOptions { ]) { if (!Object.hasOwn(options, field)) continue; const ref = assertManagedSessionDurableRef(options[field], field); - if (ref.kind !== kind || ref.schemaVersion !== 1) + if ( + (ref.kind !== kind && + !( + field === 'inputRef' && + options['v'] === 3 && + ref.kind === 'managed-extension-command-input' + )) || + ref.schemaVersion !== 1 + ) throw new Error('Invalid Hosted Action reference.'); } return options as unknown as HostedActionOptions; diff --git a/packages/cli/src/serve/hosted-workspace-broker.ts b/packages/cli/src/serve/hosted-workspace-broker.ts index df271b7b45d..19c62bbcf01 100644 --- a/packages/cli/src/serve/hosted-workspace-broker.ts +++ b/packages/cli/src/serve/hosted-workspace-broker.ts @@ -48,6 +48,11 @@ import type { ManagedMonitorControl, ManagedMonitorOperationView, } from '@qwen-code/qwen-code-core/managed-runtime/managed-monitor-protocol.js'; +import type { ManagedExtensionRuntimeControl } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; import type { ManagedSessionLifecycleAuthority } from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js'; export interface HostedWorkspaceBrokerOptions { @@ -126,6 +131,7 @@ export class HostedWorkspaceBrokerExecutionUnresolved extends Error { } export class HostedWorkspaceBroker { + private extensionBinding?: { runtimeBindingId: string; generation: string }; private readonly baseUrl: URL; private leaseAcquired = false; private capability?: string; @@ -295,6 +301,10 @@ export class HostedWorkspaceBroker { scope['tenantId'] !== this.key.tenantId || scope['workspaceId'] !== this.key.workspaceId || (scope['capabilityDigest'] !== WORKSPACE_CAPABILITY_DIGEST && + scope['capabilityDigest'] !== + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST && + scope['capabilityDigest'] !== + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST && !( scope['capabilityDigest'] === RESIDENT_WORKSPACE_CAPABILITY_DIGEST && scope['isolationClass'] === 'workspace' @@ -746,6 +756,59 @@ export class HostedWorkspaceBroker { return result as unknown as ManagedHookOperationView; } + async extensionControl( + operation: ManagedExtensionRuntimeControl, + ): Promise> { + const envelope = await this.request( + `/harness-sessions/${encodeURIComponent(this.key.sessionId)}/extensions/control`, + { + operation, + ...(this.extensionBinding + ? { + expectedRuntimeBindingId: this.extensionBinding.runtimeBindingId, + expectedRuntimeGeneration: this.extensionBinding.generation, + } + : {}), + }, + true, + 8 * 1024 * 1024, + ); + this.readRuntime(envelope); + this.extensionBinding = { + runtimeBindingId: this.runtime!.bindingId, + generation: this.runtime!.generation, + }; + const result = object(envelope['result']); + if (result['operationId'] !== operation.operationId) + throw new Error('Runtime extension response identity is invalid.'); + return result; + } + + async extensionCommand( + operation: import('@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js').ManagedExtensionCommandControl, + ): Promise> { + const envelope = await this.request( + `/tool-sessions/${encodeURIComponent(this.identity.runtimeSessionId)}/control`, + { operation }, + ); + const result = object(envelope['result']); + if ( + result['operationId'] !== operation.operationId || + !['running', 'settled', 'outcome_unknown'].includes( + String(result['state']), + ) + ) + throw new Error('Runtime extension command response is invalid.'); + return result; + } + + expectExtensionRuntime(expected: { + runtimeBindingId: string; + generation: string; + }): void { + this.extensionBinding = expected; + } + /** * #13896: one watch's maintenance through the client route. A watch * answers running, exited or unknown — never the Hook/MCP settlement @@ -1050,6 +1113,7 @@ export class HostedWorkspaceBroker { path: string, body?: Record, harnessScoped = false, + maximumResponseBytes = 2 * 1024 * 1024, ): Promise> { const url = new URL(`/internal/runtime-broker/v1${path}`, this.baseUrl); const fields = { @@ -1091,7 +1155,7 @@ export class HostedWorkspaceBroker { const chunk = await reader.read(); if (chunk.done) break; length += chunk.value.length; - if (length > 2 * 1024 * 1024) { + if (length > maximumResponseBytes) { await reader.cancel(); throw new Error('Runtime Broker response exceeds its limit.'); } diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.child-agent.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.child-agent.test.ts index 6022fee94ea..1bac66e0f44 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.child-agent.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.child-agent.test.ts @@ -45,12 +45,17 @@ import { hostedShellLaneAttachesChildAgents, type HostedWorkspaceToolProfile, } from './hosted-workspace-profiles.js'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; // H4b: the kind gate admits `child_agent` for real, so this suite runs // without an enablement mock. The Broker is mocked as in the sibling // suite: the agent path never dispatches through it, but the turn's // constructor warms it. const broker = vi.hoisted(() => ({ + capabilityDigest: '', fileHistory: vi.fn(), warm: vi.fn().mockResolvedValue(undefined), acquire: vi.fn().mockResolvedValue(undefined), @@ -71,6 +76,9 @@ vi.mock('./hosted-workspace-broker.js', async (importOriginal) => ({ HostedWorkspaceBroker: class { resolveCapabilities = async () => {}; readonly runtimeSessionId = 'prompt'; + get capabilityDigest() { + return broker.capabilityDigest; + } acquired = false; runtime = { bindingId: 'binding', @@ -135,6 +143,10 @@ function createTurn( childSnapshots = false, publicChildren = false, launchEnabled: () => boolean = () => true, + extensionExtras: Pick< + NonNullable[9]>, + 'extensionAgents' | 'extensionConfig' | 'mcp' | 'hooks' + > = {}, ): HostedWorkspaceToolTurn { return new HostedWorkspaceToolTurn( { baseUrl: 'http://127.0.0.1:1', token: 'test' }, @@ -175,8 +187,13 @@ function createTurn( funnel: children, publicChildren, launchEnabled, - snapshot: async (sourcePromptId, modelId, modelRoute) => ({ - version: 1, + snapshot: async ( + sourcePromptId, + modelId, + modelRoute, + extensionAgent, + ) => ({ + version: extensionAgent ? 2 : 1, sourcePromptId, modelId, modelRoute, @@ -184,6 +201,7 @@ function createTurn( approvalRevision: 2, modelRevision: 1, skillsRef: null, + ...(extensionAgent ? { extensionAgent } : {}), }), depth, childWorkspaces, @@ -217,6 +235,7 @@ function createTurn( } : {}), ...(hooksSession ? { hooks: hooksSession } : {}), + ...extensionExtras, }, ); } @@ -254,6 +273,7 @@ function cancelWhenChildWaits(abort: AbortController): void { beforeEach(async () => { vi.resetAllMocks(); + broker.capabilityDigest = ''; for (const method of [ broker.warm, broker.acquire, @@ -1581,6 +1601,155 @@ it('refuses a background agent while the Session owner holds the mount', async ( ); }); +it.each([ + { owner: 'mcp', capability: MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST }, + { owner: 'hooks', capability: MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST }, +] as const)( + 'keeps the parent $owner owner while a pinned v2 extension child returns its result', + async ({ owner, capability }) => { + broker.capabilityDigest = capability; + const pin = { + extensionId: 'review', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }; + const descriptor = { + configRevision: 1, + bundleRef: await session.resources.publish( + 'managed-extension-config-bundle', + Buffer.from('{}'), + ), + pin, + contributionsRef: await session.resources.publish( + 'managed-extension-contributions', + Buffer.from('{}'), + ), + agentName: 'review:auditor', + toolNames: [], + }; + const heldOwner = { + broker: new HostedWorkspaceBroker( + { baseUrl: 'http://127.0.0.1:1', token: 'test' }, + sessionKey, + 'parent-mcp', + ), + mountHeld: true, + ensureReady: vi.fn(async () => undefined), + acquire: vi.fn(async () => undefined), + refresh: vi.fn(async () => undefined), + tools: () => [], + toolInput: () => undefined, + fire: vi.fn(async () => []), + close: vi.fn(async () => undefined), + }; + const extensionExtras = { + extensionAgents: [{ name: descriptor.agentName, descriptor }], + extensionConfig: { + revision: 1, + packages: [{ pin, assetRoot: 'extension://review/' }], + }, + ...(owner === 'mcp' + ? { + mcp: heldOwner as unknown as import('./hosted-mcp-session.js').HostedMcpSession, + } + : { + hooks: heldOwner as unknown as HostedHookSession, + }), + }; + const turn = createTurn( + 0, + undefined, + 'prompt', + true, + false, + true, + undefined, + 'hosted-workspace-shell/1', + false, + true, + () => true, + extensionExtras, + ); + turn.setChildModelRoute('model', { + authType: 'openai', + endpointDigest: 'a'.repeat(64), + providerPin: 'b'.repeat(64), + }); + const ordinary = await executeAgent( + turn, + call({ description: 'ordinary', prompt: 'review' }, 'legacy'), + ); + expect(JSON.stringify(ordinary)).toContain(mountRefusal); + broker.capabilityDigest = 'legacy-runtime'; + const legacyRuntime = await executeAgent( + turn, + call( + { + description: 'extension audit', + prompt: 'review', + subagent_type: descriptor.agentName, + }, + 'legacy-runtime', + ), + ); + expect(JSON.stringify(legacyRuntime)).toContain(mountRefusal); + broker.capabilityDigest = capability; + extensionExtras.extensionConfig.revision = 2; + const stale = await executeAgent( + turn, + call( + { + description: 'extension audit', + prompt: 'review', + subagent_type: descriptor.agentName, + }, + 'stale-config', + ), + ); + expect(JSON.stringify(stale)).toContain(mountRefusal); + extensionExtras.extensionConfig.revision = 1; + const completed = executeAgent( + turn, + call({ + description: 'extension audit', + prompt: 'review the package', + subagent_type: descriptor.agentName, + run_in_background: false, + }), + ); + await vi.waitFor( + () => expect(children.record('prompt:call-1')).toBeDefined(), + { timeout: 10_000 }, + ); + expect(await children.publicLaunchSnapshot('prompt:call-1')).toMatchObject({ + version: 2, + extensionAgent: { ...descriptor, toolNames: expect.any(Array) }, + }); + await children.dispatchStarted('prompt:call-1', { + dispatchId: 'child-dispatch', + runtime: { runtimeBindingId: 'child-binding', generation: '1' }, + }); + await children.attach('prompt:call-1', randomUUID()); + await children.settleCompleted('prompt:call-1', { + result: Buffer.from('extension child result'), + receipt: Buffer.from('{"outcome":"settled"}'), + }); + await children.accept('prompt:call-1'); + expect(JSON.stringify(await completed)).toContain('extension child result'); + expect(heldOwner.mountHeld).toBe(true); + expect(heldOwner.close).not.toHaveBeenCalled(); + expect(broker.release).not.toHaveBeenCalled(); + expect(consumption).toEqual(['prompt:call-1']); + const launch = await children.publicLaunchSnapshot('prompt:call-1'); + expect(launch?.version).toBe(2); + if (launch?.version === 2 && launch.extensionAgent) { + expect(launch.extensionAgent.toolNames).toContain('read_file'); + expect(launch.extensionAgent.toolNames).not.toContain('agent'); + expect(launch.extensionAgent.toolNames).not.toContain('send_message'); + } + }, +); + // The wake-turn id already embeds its commissioning run id // (`:accept:notify`); using it verbatim as the launch base // grows one suffix per chained hop into the 128-char lineage bound. The diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts index e26fe8b9bf6..644ab59e6f9 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts @@ -36,6 +36,7 @@ import { type HostedFileHistorySettleBlocker, } from './hosted-file-history.js'; import { HostedShellPublisher } from './hosted-shell-publisher.js'; +import type { HostedExtensionToolConfig } from './hosted-extension-tool-context.js'; import { HOSTED_TOOL_INPUT_ORDER, HostedHookSession, @@ -9809,6 +9810,7 @@ function backgroundTurnRig( fitsInline?: (type: string) => boolean; publicationMissing?: boolean; foregroundOnly?: boolean; + extensionConfig?: HostedExtensionToolConfig; closeResults?: ReadonlyArray<'NOT_STARTED' | 'MISSING'>; executionCallIds?: readonly string[]; hooks?: HostedHookSession; @@ -9966,6 +9968,7 @@ function backgroundTurnRig( childRuns: orchestrator as never, backgroundLane: options.lane, foregroundOnly: options.foregroundOnly, + extensionConfig: options.extensionConfig, }, ); return { @@ -10726,6 +10729,59 @@ it.each([true, false])( }, ); +it.each([true, false])( + 'preserves extension Shell pins with background admission enabled (%s)', + async (enabled) => { + const pin = { + extensionId: 'ext_11111111-1111-4111-8111-111111111111', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }; + const rig = backgroundTurnRig( + { executionStatus: 'not_started', responseParts: [], capture: null }, + { + extensionConfig: { + revision: 1, + packages: [{ pin, assetRoot: path.join(root, 'extension-assets') }], + }, + }, + ); + turn = rig.turn; + enablement.childRun = enabled; + const declarations = await turn.declarations(new AbortController().signal); + const shell = declarations.find( + (tool) => tool.name === 'run_shell_command', + ); + expect(shell?.parametersJsonSchema).toHaveProperty( + 'properties.extension_context.oneOf', + [ + { + properties: Object.fromEntries( + Object.entries(pin).map(([key, value]) => [key, { const: value }]), + ), + }, + ], + ); + expect(shell?.description).toContain('$QWEN_EXTENSION_ROOT'); + expect(shell?.description).toContain( + 'cwd stays the Session working directory', + ); + if (enabled) + expect(shell?.parametersJsonSchema).toHaveProperty( + 'properties.is_background', + ); + else { + expect(shell?.parametersJsonSchema).not.toHaveProperty( + 'properties.is_background', + ); + expect(shell?.description).toContain( + 'Shell file mutations are not backed up', + ); + } + expect(declarations.map((tool) => tool.name)).not.toContain('monitor'); + }, +); + it.each([true, false])( 'advertises Monitor only while its domain is enabled (%s)', async (enabled) => { @@ -12557,3 +12613,63 @@ it('refuses a malformed permission rewrite on an admitted background Shell', asy expect(broker.executeV3).not.toHaveBeenCalled(); expect(broker.acquire).toHaveBeenCalledOnce(); }); + +it.each(['auto', 'yolo'] as const)( + 'enforces Session deny rules on extension command steps in %s mode', + async (mode) => { + turn = createTurn(true, { + mode, + permissionRules: { allow: [], ask: [], deny: ['Bash'] }, + }); + const signal = new AbortController().signal; + await turn.declarations(signal); + const permission = await ( + turn as unknown as { + commandPermission( + id: string, + toolName: 'shell', + input: Record, + signal: AbortSignal, + outerCallId: string, + ): Promise<{ allowed: boolean; approvalMode: string }>; + } + ).commandPermission( + 'command-step', + 'shell', + { command: 'printf denied' }, + signal, + 'outer-command', + ); + expect(permission).toMatchObject({ allowed: false, approvalMode: mode }); + expect(actionIds()).toEqual([]); + expect(broker.execute).not.toHaveBeenCalled(); + }, +); + +it('honors an explicit Session allow rule for an extension command step', async () => { + turn = createTurn(true, { + mode: 'default', + permissionRules: { allow: ['Bash'], ask: [], deny: [] }, + }); + const signal = new AbortController().signal; + await turn.declarations(signal); + const permission = await ( + turn as unknown as { + commandPermission( + id: string, + toolName: 'shell', + input: Record, + signal: AbortSignal, + outerCallId: string, + ): Promise<{ allowed: boolean }>; + } + ).commandPermission( + 'command-step', + 'shell', + { command: 'printf allowed' }, + signal, + 'outer-command', + ); + expect(permission.allowed).toBe(true); + expect(actionIds()).toEqual([]); +}); diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.ts index 498296bb899..2070cc66f40 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.ts @@ -1,3 +1,8 @@ +import { + extensionToolDeclaration, + extensionToolInput, + type HostedExtensionToolConfig, +} from './hosted-extension-tool-context.js'; /** * @license * Copyright 2026 Qwen Team @@ -11,6 +16,10 @@ import { } from './hosted-turn-cleanup.js'; import { probeNeverStarted } from './hosted-never-started-reconcile.js'; import { RESIDENT_WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; import { readHostedFileHistory, @@ -136,6 +145,7 @@ import type { HostedChildRunSession } from './hosted-child-run-session.js'; import type { PublicChildLaunchSnapshot, PublicChildModelRoute, + PublicChildExtensionAgent, } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; import type { HostedChildAgentSession } from './hosted-child-agent-session.js'; import { @@ -230,6 +240,11 @@ import { HOSTED_SKILL_BATCH_REFUSAL, type HostedSkillCatalog, } from './hosted-skill-catalog.js'; +import { + expandHostedExtensionCommand, + HostedCommandExpansionRefusedError, + type HostedCommandPermission, +} from './hosted-extension-command.js'; import { endHostedAction, HOSTED_APPROVAL_OPTIONS, @@ -237,6 +252,7 @@ import { hostedActionAllowed, hostedApprovalAsks, hostedLiveApprovalPoll, + readHostedActionOptions, type HostedActionOptions, type HostedApprovalSettings, type HostedApprovalWaiters, @@ -1852,7 +1868,19 @@ export class HostedWorkspaceToolTurn { promptId: string, model: string, route: PublicChildModelRoute, + extensionAgent?: PublicChildExtensionAgent, ) => Promise; + private childModelResolver?: ( + model: string, + ) => Promise<{ modelId: string; route: PublicChildModelRoute }>; + private readonly extensionAgents: ReadonlyArray<{ + name: string; + model?: string; + tools?: readonly string[]; + disallowedTools?: readonly string[]; + descriptor: PublicChildExtensionAgent; + }>; + private readonly allowedToolNames?: readonly string[]; private readonly childDepth: number; private readonly childWorkspaces: boolean; private readonly childSnapshots: boolean; @@ -1862,6 +1890,7 @@ export class HostedWorkspaceToolTurn { /** H4e-b3: the member's command funnel to its parent, beside its messages. */ private readonly commands?: HostedSessionCommandSession; private readonly skills?: HostedSkillCatalog; + private readonly extensionConfig?: HostedExtensionToolConfig; private toolOutputBudget = DEFAULT_TOOL_OUTPUT_BATCH_BUDGET; /** b3b: whether this Session's run carries a member preamble (once read). */ private memberPreamble?: boolean; @@ -1922,6 +1951,7 @@ export class HostedWorkspaceToolTurn { promptId: string, model: string, route: PublicChildModelRoute, + extensionAgent?: PublicChildExtensionAgent, ) => Promise; readonly depth: number; /** #13753 I2: the host serves child Workspaces. */ @@ -1934,6 +1964,15 @@ export class HostedWorkspaceToolTurn { teams?: HostedTeamSession; commands?: HostedSessionCommandSession; skills?: HostedSkillCatalog; + extensionConfig?: HostedExtensionToolConfig; + extensionAgents?: ReadonlyArray<{ + name: string; + model?: string; + tools?: readonly string[]; + disallowedTools?: readonly string[]; + descriptor: PublicChildExtensionAgent; + }>; + allowedToolNames?: readonly string[]; /** * H4e-b3 decisions 5/9: this run's first input carried an unescaped * `plan-mode="required"` preamble; its calls admit reads only. @@ -1942,6 +1981,7 @@ export class HostedWorkspaceToolTurn { publicPlanMode?: boolean; }, ) { + this.extensionConfig = extras?.extensionConfig; this.foregroundOnly = extras?.foregroundOnly === true; this.mcp = this.foregroundOnly ? undefined : extras?.mcp; this.hooks = this.foregroundOnly ? undefined : extras?.hooks; @@ -1972,6 +2012,8 @@ export class HostedWorkspaceToolTurn { this.teams = this.foregroundOnly ? undefined : extras?.teams; this.commands = this.foregroundOnly ? undefined : extras?.commands; this.skills = extras?.skills; + this.extensionAgents = extras?.extensionAgents ?? []; + this.allowedToolNames = extras?.allowedToolNames; this.memberPlanMode = extras?.memberPlanMode === true; this.publicPlanMode = extras?.publicPlanMode === true; this.planning = this.publicPlanMode; @@ -2037,6 +2079,13 @@ export class HostedWorkspaceToolTurn { setChildModelRoute(modelId: string, route: PublicChildModelRoute): void { this.childModelRoute = { modelId, route }; } + setChildModelResolver( + resolve: ( + model: string, + ) => Promise<{ modelId: string; route: PublicChildModelRoute }>, + ): void { + this.childModelResolver = resolve; + } private agentsAdmitted(): boolean { return ( @@ -2138,7 +2187,8 @@ export class HostedWorkspaceToolTurn { ...(todoTool ? [HOSTED_TODO_WRITE_TOOL] : []), ] .filter((tool) => tool.name !== 'monitor' || this.monitorsAdmitted()) - .map((tool) => { + .map((declaration) => { + let tool = declaration; if ( tool.name === 'run_shell_command' && !this.backgroundShellAdmitted() @@ -2147,17 +2197,17 @@ export class HostedWorkspaceToolTurn { properties?: Record; }; delete schema.properties?.['is_background']; - return { + tool = { ...tool, description: 'Run one foreground shell command in the saved Session working directory. Timeout is 1..600000 ms. Shell file mutations are not backed up. Changing tracked file content or permissions can cause subsequent Write/Edit and undo conflicts.', parametersJsonSchema: schema, }; } - return hostedWorkspaceDeclarations( - [tool], - !this.fileBackupsEnabled, - )[0]; + return extensionToolDeclaration( + hostedWorkspaceDeclarations([tool], !this.fileBackupsEnabled)[0], + this.extensionConfig, + ); }), ...(this.mcp?.tools() ?? []), ...(skill ? [skill] : []), @@ -2250,6 +2300,31 @@ export class HostedWorkspaceToolTurn { ); } if (this.publicPlanMode) this.advertised.push(HOSTED_PUBLIC_PLAN_TOOL); + if (this.publicChildren && this.extensionAgents.length) + this.advertised = this.advertised.map((tool) => + tool.name !== 'agent' + ? tool + : { + ...tool, + parametersJsonSchema: { + ...(tool.parametersJsonSchema as Record), + properties: { + ...((tool.parametersJsonSchema as Record)[ + 'properties' + ] as Record), + subagent_type: { + type: 'string', + enum: this.extensionAgents.map((agent) => agent.name), + description: 'Run a pinned extension agent.', + }, + }, + }, + }, + ); + if (this.allowedToolNames) + this.advertised = this.advertised.filter((tool) => + this.allowedToolNames!.includes(tool.name ?? ''), + ); return this.planning ? this.advertised.filter( (tool) => @@ -2415,6 +2490,25 @@ export class HostedWorkspaceToolTurn { ); } + private extensionChildCanShareWorkspace(call: ToolCallRequestInfo): boolean { + const selected = this.extensionAgents.find( + (agent) => agent.name === call.args['subagent_type'], + ); + return ( + this.publicChildren && + selected !== undefined && + this.extensionConfig !== undefined && + selected.descriptor.configRevision === this.extensionConfig.revision && + this.extensionConfig.packages.some((entry) => + isDeepStrictEqual(entry.pin, selected.descriptor.pin), + ) && + [ + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + ].includes(this.broker.capabilityDigest ?? '') + ); + } + async resumeCommittedResults(signal?: AbortSignal): Promise { if (this.acquired) return; if (this.skills && !this.hooks && !this.mcp) { @@ -3403,8 +3497,9 @@ export class HostedWorkspaceToolTurn { this.session.authority.action(item.actionId)?.state === 'expired', ); if ( - this.hooks || - this.mcp || + (!plan.commandStep && (this.hooks || this.mcp)) || + (plan.commandStep && + plan.commandStep.configRevision !== this.extensionConfig?.revision) || this.promptId !== plan.promptId || this.broker.runtimeSessionId !== plan.runtime.runtimeSessionId ) @@ -3423,8 +3518,10 @@ export class HostedWorkspaceToolTurn { (item) => item.call.name !== 'agent', ); if (needsRuntime) { - await oweHostedTurnCleanup(this.session, this.promptId, this.broker); - this.cleanupOwed = true; + if (!this.hooks && !this.mcp) { + await oweHostedTurnCleanup(this.session, this.promptId, this.broker); + this.cleanupOwed = true; + } await this.broker.acquire({ runtimeBindingId: plan.runtime.bindingId, generation: plan.runtime.generation, @@ -3515,6 +3612,7 @@ export class HostedWorkspaceToolTurn { stage: 'approval' | 'final', actionId: string | null, approvalOrdinal: number, + commandStep?: HostedApprovalContinuation['commandStep'], ): Promise { const batch = this.nativeBatch; const runtime = this.broker.runtime; @@ -3527,15 +3625,16 @@ export class HostedWorkspaceToolTurn { batch.uncaptured || batch.requests.some((request) => request.todo) || !runtime || - (isHostedWorkspaceShellProfile(this.profile) && + (!commandStep && + isHostedWorkspaceShellProfile(this.profile) && !singlePlanExit && !this.publicChildren) || - this.hooks || - this.mcp || + (!commandStep && (this.hooks || this.mcp)) || batch.requests.some( (request) => !( singlePlanExit || + (commandStep && request.call.name === 'skill') || (this.publicChildren && ['agent', 'run_shell_command'].includes(request.call.name)) || [ @@ -3632,6 +3731,7 @@ export class HostedWorkspaceToolTurn { stage, approvalOrdinal, actionId, + ...(commandStep ? { commandStep } : {}), calls, }; const planBytes = Buffer.from(JSON.stringify(plan)); @@ -3693,7 +3793,13 @@ export class HostedWorkspaceToolTurn { }, ): Promise { signal.throwIfAborted(); - if (this.mcp) await waitForTurn(this.warmed, signal); + if ( + this.mcp || + (this.publicChildren && + this.extensionAgents.length > 0 && + calls.some((call) => call.name === 'agent')) + ) + await waitForTurn(this.warmed, signal); const declarations = this.advertised ?? (await this.declarations(signal)); this.hookAllowed.clear(); this.agentDispatched.clear(); @@ -3717,6 +3823,8 @@ export class HostedWorkspaceToolTurn { call.name === 'skill' && this.skills !== undefined; if ( + (this.allowedToolNames !== undefined && + !this.allowedToolNames.includes(call.name)) || (!declarations.some((tool) => tool.name === call.name) && !(skill && call.isClientInitiated)) || ids.has(call.callId) || @@ -3807,9 +3915,13 @@ export class HostedWorkspaceToolTurn { const args = call.args; const unsupportedKey = Object.keys(args).find( (key) => - !['command', 'timeout', 'description', 'is_background'].includes( - key, - ), + ![ + 'command', + 'timeout', + 'description', + 'is_background', + 'extension_context', + ].includes(key), ); // H3: a background request is admitted exactly when this Session // owns its child_run orchestrator and the domain is enabled — the @@ -3938,9 +4050,11 @@ export class HostedWorkspaceToolTurn { validationError = globError; } else { try { - input['path'] = normalizeWorkspaceRelativePath( - directory.trim(), - ); + input['path'] = + directory.trim().startsWith('extension://') && + call.args['extension_source'] !== undefined + ? directory.trim() + : normalizeWorkspaceRelativePath(directory.trim()); } catch (cause) { if (!(cause instanceof InvalidWorkspaceRelativePathError)) throw cause; @@ -4000,6 +4114,8 @@ export class HostedWorkspaceToolTurn { input = { ...args, is_monitor: true }; } else if (call.name === 'agent') { const args = call.args; + const sharedExtensionWorkspace = + this.extensionChildCanShareWorkspace(call); // The deliberate refusals below keep their texts whenever the // Agent call is not admitted. agentAdmitted = this.agentsAdmitted(); @@ -4016,6 +4132,9 @@ export class HostedWorkspaceToolTurn { ? ['plan_mode_required'] : []), 'isolation', + ...(this.publicChildren && this.extensionAgents.length + ? ['subagent_type'] + : []), ].includes(key), ); const backgroundValue = args['run_in_background']; @@ -4040,6 +4159,14 @@ export class HostedWorkspaceToolTurn { 'Public child agents share this Workspace; isolation is unavailable.'; } else if (unsupportedKey !== undefined) { validationError = `Hosted child agent received unsupported argument ${JSON.stringify(unsupportedKey)}. This profile runs only the Session's own definition, without nesting: fork_*, working_dir, ${teamsOn ? '' : 'name, '}model and subagent_type belong to the legacy Agent tool.`; + } else if ( + args['subagent_type'] !== undefined && + !this.extensionAgents.some( + (agent) => agent.name === args['subagent_type'], + ) + ) { + validationError = + 'Hosted extension agent is not in the active pinned configuration.'; } else if ( args['isolation'] !== undefined && args['isolation'] !== 'worktree' && @@ -4068,9 +4195,16 @@ export class HostedWorkspaceToolTurn { } else if (named && !agentBackground) { validationError = 'Hosted team member always runs in the background; omit run_in_background or set it to true.'; - } else if (this.sessionOwnerMayHoldMount()) { + } else if ( + !sharedExtensionWorkspace && + this.sessionOwnerMayHoldMount() + ) { validationError = `Hosted child agent is unavailable while ${HOSTED_CHILD_MOUNT_REFUSALS.sessionOwner}; a Session-owned hold releases only when the Session closes, so read the work through ordinary tools instead.`; - } else if (!agentBackground && this.acquired) { + } else if ( + !sharedExtensionWorkspace && + !agentBackground && + this.acquired + ) { // A foreground child cannot borrow its waiting parent's mount. // Ordered after the Session-owner arm: a Session-scoped mount // outlives this Turn, so the background and fresh-turn advice @@ -4078,6 +4212,7 @@ export class HostedWorkspaceToolTurn { // Session-owned hold advice that cannot act on it. validationError = `Hosted child agent run_in_background=false is unavailable while ${HOSTED_CHILD_MOUNT_REFUSALS.turn}; run it in the background or let the current tool work finish first in a fresh turn.`; } else if ( + !sharedExtensionWorkspace && !agentBackground && calls.some( (other) => other.name !== 'agent' && other.name !== 'workflow', @@ -4352,7 +4487,11 @@ export class HostedWorkspaceToolTurn { validationError = filePathError; } else { try { - input['file_path'] = normalizeWorkspaceRelativePath(file.trim()); + input['file_path'] = + file.trim().startsWith('extension://') && + call.args['extension_source'] !== undefined + ? file.trim() + : normalizeWorkspaceRelativePath(file.trim()); } catch (cause) { if (!(cause instanceof InvalidWorkspaceRelativePathError)) throw cause; @@ -4360,6 +4499,19 @@ export class HostedWorkspaceToolTurn { } } } + try { + input = extensionToolInput( + this.session.authority.sessionHeader.sessionKey.sessionId, + this.extensionConfig, + call.name, + input, + ); + } catch (cause) { + validationError ??= + cause instanceof Error + ? cause.message + : 'Extension tool context was refused.'; + } // The inline Session Store limit keeps rejecting exactly as main // does for the ordinary tools. A call that already carries its own // refusal never reads its encoding. Workflow launches and messages @@ -5205,21 +5357,18 @@ export class HostedWorkspaceToolTurn { ] : [], ); - const skillBatch = requests.every((request) => request.skill) - ? enforceFunctionResponseBudget( - requests.map((request, index) => ({ - callId: request.call.callId, - toolName: request.call.name, - responseParts: - refusal(index) ?? - convertToFunctionResponse( - request.call.name, - request.call.callId, - [{ text: this.skills!.response(request.call.args) }], - ), - })), - this.toolOutputBudget, - ) + const skillResponses = []; + if (requests.every((request) => request.skill)) + for (const [index, request] of requests.entries()) + skillResponses.push({ + callId: request.call.callId, + toolName: request.call.name, + responseParts: + refusal(index) ?? + (await this.skillResponse(request.call, signal)), + }); + const skillBatch = skillResponses.length + ? enforceFunctionResponseBudget(skillResponses, this.toolOutputBudget) : undefined; executing = true; for (const [index, request] of requests.entries()) { @@ -6545,14 +6694,49 @@ export class HostedWorkspaceToolTurn { // can ever answer. Translate the throw into the same refusal: an // over-size prompt is a model-correctable argument error, never a // recovery-blocked Turn. + const extensionAgent = + request.call.args['subagent_type'] === undefined + ? undefined + : this.extensionAgents.find( + (agent) => agent.name === request.call.args['subagent_type'], + ); + const childModel = extensionAgent?.model + ? await this.childModelResolver?.(extensionAgent.model) + : this.childModelRoute; + if (extensionAgent?.model && !childModel) + throw new Error('Extension agent model is unavailable.'); + const allowed = (this.advertised ?? []) + .map((tool) => tool.name!) + .filter( + (name) => + (HOSTED_WORKSPACE_SHELL_TOOLS.some((tool) => tool.name === name) || + name === 'glob' || + name === 'skill' || + name === 'todo_write' || + this.mcp?.tools().some((tool) => tool.name === name)) && + (!extensionAgent?.tools || extensionAgent.tools.includes(name)) && + !extensionAgent?.disallowedTools?.includes(name), + ); + const descriptor = extensionAgent + ? { ...extensionAgent.descriptor, toolNames: allowed } + : undefined; const publicLaunch = this.publicChildren ? ((await children.publicLaunchSnapshot(childRunId)) ?? (await this.childSnapshot!( this.promptId, - this.childModelRoute?.modelId ?? model, - this.childModelRoute!.route, + childModel?.modelId ?? model, + childModel!.route, + descriptor, ))) : undefined; + if ( + extensionAgent && + (publicLaunch?.version !== 2 || + !isDeepStrictEqual(publicLaunch.extensionAgent, descriptor)) + ) + throw new Error( + 'Extension child requires its pinned v2 launch snapshot.', + ); let envelopeBytes: number; try { envelopeBytes = encodeChildLaunchEnvelope({ @@ -8316,6 +8500,336 @@ export class HostedWorkspaceToolTurn { : refusals; } + private async skillResponse( + call: ToolCallRequestInfo, + signal: AbortSignal, + ): Promise { + const command = this.skills!.command(call.args); + if (!command) + return convertToFunctionResponse(call.name, call.callId, [ + { text: this.skills!.response(call.args) }, + ]); + if (!this.extensionConfig) + throw new Error('The command has no pinned extension configuration.'); + try { + const expanded = await expandHostedExtensionCommand({ + session: this.session, + broker: this.broker, + promptId: this.promptId, + callId: call.callId, + revision: this.extensionConfig.revision, + command, + args: String(call.args['args'] ?? ''), + signal, + definitionDigest: (toolName) => + createHash('sha256') + .update( + JSON.stringify( + (this.advertised ?? []).find( + (tool) => + tool.name === + (toolName === 'shell' ? 'run_shell_command' : toolName), + ) ?? + HOSTED_WORKSPACE_SHELL_TOOLS.find( + (tool) => + tool.name === + (toolName === 'shell' ? 'run_shell_command' : toolName), + ), + ), + ) + .digest('hex'), + decide: async (id, name, input) => { + await waitForTurn(this.warmed, signal); + if (!this.acquired) await this.acquire(false, signal); + return this.commandPermission(id, name, input, signal, call.callId); + }, + completed: async (id, name, input, result) => { + const failed = result['executionStatus'] !== 'success'; + const event = failed + ? HookEventName.PostToolUseFailure + : HookEventName.PostToolUse; + const fields = { + tool_name: name === 'shell' ? 'run_shell_command' : name, + tool_input: input, + tool_use_id: id, + prompt_id: this.promptId, + permission_mode: this.approval?.settings.mode ?? 'yolo', + ...(failed + ? { + error: String( + result['error'] ?? 'Command preprocessing failed', + ), + is_interrupt: result['executionStatus'] === 'cancelled', + } + : { tool_response: result }), + }; + const output = await this.hooks?.fire( + event, + id, + await this.toolHookFields(event, id, fields), + signal, + this.promptHookRunner, + ); + if ( + output && + createHookOutput( + failed + ? HookEventName.PostToolUseFailure + : HookEventName.PostToolUse, + output, + ).shouldStopExecution() + ) { + this.hookStopReason ??= + 'Command preprocessing was stopped by a Hook.'; + throw new HostedCommandExpansionRefusedError( + 'Command preprocessing was stopped by a Hook.', + ); + } + }, + }); + return convertToFunctionResponse(call.name, call.callId, [ + { text: expanded }, + ]); + } catch (cause) { + if (cause instanceof HostedCommandExpansionRefusedError) + return convertToFunctionErrorResponse( + call.name, + call.callId, + [], + cause.message, + ); + throw new HostedToolRecoveryRequiredError(cause); + } + } + + private async commandPermission( + id: string, + toolName: 'read_file' | 'shell', + initial: Record, + signal: AbortSignal, + outerCallId: string, + ): Promise { + const name = toolName === 'shell' ? 'run_shell_command' : toolName; + const settings = await this.readApprovalSettings(); + const result: HostedCommandPermission = { + allowed: false, + toolName, + input: initial, + approvalMode: settings.mode, + preHookOccurrence: this.hooks ? id : null, + permissionHookOccurrence: null, + actionRequestId: null, + }; + if ( + !this.advertised?.some((tool) => tool.name === name) || + ((this.memberPlanMode || this.planning) && toolName === 'shell') + ) + return result; + let call: ToolCallRequestInfo = { + name, + callId: id, + args: initial, + prompt_id: this.promptId, + isClientInitiated: false, + }; + const pre = await this.hooks?.fire( + HookEventName.PreToolUse, + id, + await this.toolHookFields( + HookEventName.PreToolUse, + id, + this.preToolUseFields(call), + ), + signal, + this.promptHookRunner, + undefined, + HOSTED_TOOL_INPUT_ORDER, + ); + if (preToolUseRefusal(pre, false)) return result; + call = { ...call, args: preToolUseReplacement(pre, false) ?? call.args }; + if (hostedShellInputError(name, call.args)) + return { ...result, input: call.args }; + const mustAsk = pre !== undefined && new PreToolUseHookOutput(pre).isAsk(); + let policy = await evaluateHostedPermissionRules( + settings.permissionRules, + call, + ); + if (policy === 'deny') return { ...result, input: call.args }; + let hookAllowed = + pre !== undefined && + new PreToolUseHookOutput(pre).getPermissionDecision() === 'allow'; + let permissionHookOccurrence: string | null = null; + if ( + this.hooks && + policy === 'default' && + hostedApprovalAsks(settings, name, this.globProfile) + ) { + permissionHookOccurrence = id; + const permission = await this.hooks.fire( + HookEventName.PermissionRequest, + id, + this.permissionRequestFields(call), + signal, + this.promptHookRunner, + undefined, + HOSTED_TOOL_INPUT_ORDER, + ); + const decision = permissionRequestDecision(permission); + if (decision.refusal) + return { ...result, input: call.args, permissionHookOccurrence }; + hookAllowed ||= decision.allowed === true; + call = { ...call, args: decision.input ?? call.args }; + } + if (hostedShellInputError(name, call.args)) + return { ...result, input: call.args, permissionHookOccurrence }; + policy = await evaluateHostedPermissionRules( + settings.permissionRules, + call, + ); + if (policy === 'deny') + return { ...result, input: call.args, permissionHookOccurrence }; + let actionRequestId: string | null = null; + let approvedInputRef: ManagedSessionDurableRef | undefined; + let allowed = true; + if ( + mustAsk || + policy === 'ask' || + (policy !== 'allow' && + !hookAllowed && + hostedApprovalAsks(settings, name, this.globProfile)) + ) { + if (!this.approval) allowed = false; + else { + actionRequestId = `command_approval_${createHash('sha256').update(id).digest('hex').slice(0, 48)}`; + const authority = this.session.authority; + const existing = authority.action(actionRequestId); + let options: HostedActionOptions; + if (existing) { + options = await readHostedActionOptions(this.session, existing); + if ( + options.toolName !== name || + !('inputRef' in options) || + !options.inputRef || + !isDeepStrictEqual( + JSON.parse( + (await this.session.resources.read(options.inputRef)).toString( + 'utf8', + ), + ), + call.args, + ) + ) + throw new HostedToolRecoveryRequiredError( + 'Command step approval input changed.', + ); + } else { + const inputRef = await this.session.resources.publish( + 'managed-extension-command-input', + Buffer.from(JSON.stringify(call.args)), + ); + const createdAt = Date.now(); + const ordinal = + this.nativeBatch?.requests.findIndex( + (request) => request.call.callId === outerCallId, + ) ?? -1; + if (ordinal < 0) + throw new HostedToolRecoveryRequiredError( + 'Command outer batch is unavailable.', + ); + this.nativeBatch!.actionIds.set(ordinal, actionRequestId); + const continuationRef = await this.captureNativeBatch( + 'approval', + actionRequestId, + ordinal, + { + toolStageId: id, + toolName, + inputRef, + configRevision: this.extensionConfig!.revision, + }, + ); + if (!continuationRef) + throw new HostedToolRecoveryRequiredError( + 'Command approval continuation is unavailable.', + ); + options = { + v: 3, + continuationRef, + inputRef, + requestId: actionRequestId, + turnId: this.promptId, + functionCallId: outerCallId, + toolName: name, + policyRevision: HOSTED_TOOL_APPROVAL_POLICY, + inputRevision: 1, + createdAt, + expiresAt: createdAt + settings.timeoutMs, + options: HOSTED_APPROVAL_OPTIONS, + ...(mustAsk || policy === 'ask' + ? { requiresHumanDecision: true as const } + : {}), + }; + const optionsRef = await this.session.resources.publish( + 'managed-action-options', + Buffer.from(JSON.stringify(options)), + ); + await this.harness.commitDurableWait( + { + requestId: actionRequestId, + kind: 'permission', + source: 'tool_call', + optionsRef, + inputRevision: '1', + invocationRef: inputRef, + attemptId: id, + routeRef: inputRef, + }, + { turnId: this.promptId, promptId: this.promptId }, + ); + } + await this.approval.waiters.wait( + actionRequestId, + options.expiresAt, + signal, + () => + authority.action(actionRequestId!)?.state !== 'requested' || + authority.writesStopped, + this.approval.readSettings + ? hostedLiveApprovalPoll( + this.session, + this.approval.waiters, + options, + () => this.readApprovalSettings(), + signal, + this.globProfile, + this.approval.isBlocked, + ) + : undefined, + ); + if (authority.action(actionRequestId)?.state === 'requested') + await endHostedAction( + this.session, + actionRequestId, + signal.aborted ? 'cancelled' : 'expired', + ); + await this.harness.resolveDurableWait(); + approvedInputRef = 'inputRef' in options ? options.inputRef : undefined; + allowed = hostedActionAllowed( + authority.action(actionRequestId)!, + options.policyRevision, + ); + } + } + return { + ...result, + allowed: allowed && !signal.aborted, + input: call.args, + permissionHookOccurrence, + actionRequestId, + ...(approvedInputRef ? { inputRef: approvedInputRef } : {}), + }; + } + private async ask( approval: HostedApprovalTurnOptions, call: ToolCallRequestInfo, diff --git a/packages/cli/src/serve/managed-context-envelope.test.ts b/packages/cli/src/serve/managed-context-envelope.test.ts index 6166ac0d886..d6359c32ca2 100644 --- a/packages/cli/src/serve/managed-context-envelope.test.ts +++ b/packages/cli/src/serve/managed-context-envelope.test.ts @@ -29,6 +29,10 @@ import { type ManagedContextReceipt, } from './managed-context-envelope.js'; import { createHash } from 'node:crypto'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; import { CONTEXT_BINDING_DOMAIN_TAG, computeManagedContextDigest, @@ -424,6 +428,95 @@ describe('Managed context envelope contract', () => { expect(Object.isFrozen((outcome as { body: object }).body)).toBe(true); }); + it.each([ + { + capabilityDigest: MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + managedExtensions: { + enabled: true, + mode: 'linux-cgroup', + cacheDirectory: '/private/extension-cache', + cgroupRoot: '/sys/fs/cgroup/qwen', + }, + }, + { + capabilityDigest: MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + managedExtensions: { + enabled: true, + mode: 'developer-process-group', + cacheDirectory: '/private/extension-cache', + }, + }, + ])( + 'attests the exact extension boot configuration $managedExtensions.mode', + (profile) => { + const configured = parseManagedContextBoot({ ...boot, ...profile }); + // Java ManagedContextProtocol derives both bodies by removing these boot fields. + const { + type: _type, + version: _version, + token: _token, + ...responseFields + } = configured; + const { + runtimeInstanceId: _runtimeInstanceId, + runtimeIncarnation: _runtimeIncarnation, + leaseId: _leaseId, + epoch: _epoch, + ...requestFields + } = responseFields; + const request = { ...requestFields, protocolVersion: 3 }; + expect(checkManagedContextAttestation(request, configured)).toStrictEqual( + { + status: 200, + body: { ...responseFields, protocolVersion: 3 }, + }, + ); + expect(createManagedContextAttestationResponse(configured)).toStrictEqual( + { + ...responseFields, + protocolVersion: 3, + }, + ); + const { managedExtensions: _extensions, ...missing } = request; + expect(checkManagedContextAttestation(missing, configured)).toStrictEqual( + INVALID, + ); + expect( + checkManagedContextAttestation( + { + ...request, + managedExtensions: { ...profile.managedExtensions, unknown: true }, + }, + configured, + ), + ).toStrictEqual(INVALID); + expect( + checkManagedContextAttestation( + { + ...request, + managedExtensions: { + ...profile.managedExtensions, + cacheDirectory: '/another/cache', + }, + }, + configured, + ), + ).toStrictEqual({ + status: 409, + code: 'managed_runtime_identity_conflict', + }); + expect( + checkManagedContextAttestation( + { + ...(fixtures.attestationCases[0].body as object), + managedExtensions: profile.managedExtensions, + }, + boot, + ), + ).toStrictEqual(INVALID); + }, + ); + it.each(fixtures.attestationCases)( 'answers the $id attestation case', (fixture) => { diff --git a/packages/cli/src/serve/managed-context-envelope.ts b/packages/cli/src/serve/managed-context-envelope.ts index b1528ec150a..7152390d225 100644 --- a/packages/cli/src/serve/managed-context-envelope.ts +++ b/packages/cli/src/serve/managed-context-envelope.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + parseManagedExtensionRuntimeConfiguration, +} from './managed-extension-profile.js'; import { computeManagedContextDigest, isCanonicalDecimalText, @@ -139,6 +144,7 @@ export type ManagedContextIsolationClass = 'session' | 'workspace'; export interface ManagedContextBoot { readonly type: 'boot'; + readonly managedExtensions?: import('./managed-extension-profile.js').ManagedExtensionRuntimeConfiguration; readonly version: typeof MANAGED_CONTEXT_BOOT_VERSION; readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; readonly runtimeInstanceId: string; @@ -170,6 +176,7 @@ export interface ManagedContextReady { } export interface ManagedContextAttestationResponse { + readonly managedExtensions?: import('./managed-extension-profile.js').ManagedExtensionRuntimeConfiguration; readonly protocolVersion: typeof PROTOCOL_VERSION; readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; readonly runtimeInstanceId: string; @@ -237,22 +244,42 @@ const CONTEXT_UNAVAILABLE: ManagedContextRefusal = Object.freeze({ * without echoing the input, which carries the bearer token. */ export function parseManagedContextBoot(value: unknown): ManagedContextBoot { - const boot = readClosed(value, BOOT_KEYS); + const hasExtensions = + !!value && + typeof value === 'object' && + Object.hasOwn(value, 'managedExtensions'); + const boot = readClosed( + value, + hasExtensions ? [...BOOT_KEYS, 'managedExtensions'].sort() : BOOT_KEYS, + ); if ( !boot || - boot.type !== 'boot' || - boot.version !== MANAGED_CONTEXT_BOOT_VERSION || - boot.managedContext !== MANAGED_CONTEXT_PROTOCOL || - !isBearerToken(boot.token) || - !isManagedIdentifier(boot.runtimeInstanceId) || - !isManagedIdentifier(boot.runtimeIncarnation) || - !isManagedIdentifier(boot.leaseId) || - !isEpoch(boot.epoch) || + boot['type'] !== 'boot' || + boot['version'] !== MANAGED_CONTEXT_BOOT_VERSION || + boot['managedContext'] !== MANAGED_CONTEXT_PROTOCOL || + !isBearerToken(boot['token']) || + !isManagedIdentifier(boot['runtimeInstanceId']) || + !isManagedIdentifier(boot['runtimeIncarnation']) || + !isManagedIdentifier(boot['leaseId']) || + !isEpoch(boot['epoch']) || !hasValidAttestedFields(boot) ) { throw new Error(INVALID_BOOT_MESSAGE); } - return Object.freeze(boot) as ManagedContextBoot; + if ( + [ + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + ].includes(boot['capabilityDigest'] as string) !== hasExtensions + ) + throw new Error(INVALID_BOOT_MESSAGE); + if (hasExtensions) { + boot['managedExtensions'] = parseManagedExtensionRuntimeConfiguration( + boot['managedExtensions'], + boot['capabilityDigest'] as string, + ); + } + return Object.freeze(boot) as unknown as ManagedContextBoot; } /** The ready v2 record for a worker listening on the loopback `port`. */ @@ -316,16 +343,36 @@ export function checkManagedContextAttestation( boot: ManagedContextBoot, ): ManagedContextOutcome { const identity = parseManagedContextBoot(boot); - const request = readClosed(body, ATTESTATION_KEYS); + const request = readClosed( + body, + identity.managedExtensions + ? [...ATTESTATION_KEYS, 'managedExtensions'].sort() + : ATTESTATION_KEYS, + ); if ( !request || - request.protocolVersion !== PROTOCOL_VERSION || - request.managedContext !== MANAGED_CONTEXT_PROTOCOL || + request['protocolVersion'] !== PROTOCOL_VERSION || + request['managedContext'] !== MANAGED_CONTEXT_PROTOCOL || !hasValidAttestedFields(request) ) { return INVALID; } - if (ATTESTED_KEYS.some((key) => request[key] !== identity[key])) { + let managedExtensions; + if (identity.managedExtensions) { + try { + managedExtensions = parseManagedExtensionRuntimeConfiguration( + request['managedExtensions'], + request['capabilityDigest'] as string, + ); + } catch { + return INVALID; + } + } + if ( + ATTESTED_KEYS.some((key) => request[key] !== identity[key]) || + JSON.stringify(managedExtensions) !== + JSON.stringify(identity.managedExtensions) + ) { return IDENTITY_CONFLICT; } return { status: 200, body: attestationResponse(identity) }; @@ -509,6 +556,9 @@ function attestationResponse( mountRoot: identity.mountRoot, capabilityDigest: identity.capabilityDigest, isolationClass: identity.isolationClass, + ...(identity.managedExtensions + ? { managedExtensions: identity.managedExtensions } + : {}), }); } diff --git a/packages/cli/src/serve/managed-context-worker.test.ts b/packages/cli/src/serve/managed-context-worker.test.ts index b071277a7f2..e18b7a626d5 100644 --- a/packages/cli/src/serve/managed-context-worker.test.ts +++ b/packages/cli/src/serve/managed-context-worker.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import * as tar from 'tar'; +import { compileManagedExtensionPackage } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; +import { extensionToolInput } from './hosted-extension-tool-context.js'; import express from 'express'; import fs from 'node:fs'; import os from 'node:os'; @@ -374,6 +381,268 @@ describe('Managed context worker boot', () => { ).rejects.toThrow('Managed Runtime worker boot payload is invalid.'); }); + it.skipIf(process.platform !== 'darwin')( + 'routes exact extension assets read-only and native Shell with private settings through the selected worker', + async () => { + const root = workspace(['services/api', 'services/web']); + const cache = fs.mkdtempSync( + path.join(os.tmpdir(), 'extension-worker-cache-'), + ); + const source = fs.mkdtempSync( + path.join(os.tmpdir(), 'extension-worker-source-'), + ); + onTestFinished(() => { + fs.rmSync(cache, { recursive: true, force: true }); + fs.rmSync(source, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + fs.writeFileSync( + path.join(source, 'qwen-extension.json'), + JSON.stringify({ + name: 'native-test', + version: '1.0.0', + settings: [ + { name: 'Token', envVar: 'SELECTED_SECRET', sensitive: true }, + ], + }), + ); + fs.writeFileSync( + path.join(source, 'resource.txt'), + 'private package resource', + ); + const target = { + platform: 'darwin' as const, + arch: process.arch as 'arm64' | 'x64', + }; + const compiled = await compileManagedExtensionPackage(source, { + target, + resolveRecipes: false, + }); + const archive = path.join(cache, 'upload.tgz'); + await tar.c({ cwd: source, file: archive, gzip: true }, [ + 'qwen-extension.json', + 'resource.txt', + ]); + const bytes = fs.readFileSync(archive); + const pin = { + extensionId: 'one', + packageRevision: 1, + packageDigest: createHash('sha256').update(bytes).digest('hex'), + settingsRevision: 1, + }; + vi.stubEnv('OTHER_EXTENSION_SECRET', 'must-not-inherit'); + const origin = await startWorker({ + ...BOOT, + mountRoot: root, + capabilityDigest: MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + managedExtensions: { + enabled: true, + mode: 'developer-process-group', + cacheDirectory: cache, + }, + }); + const attested = await post(origin, ATTEST, { + ...(CANONICAL_ATTESTATION as Record), + mountRoot: root, + capabilityDigest: MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + managedExtensions: { + enabled: true, + mode: 'developer-process-group', + cacheDirectory: cache, + }, + }); + expect(attested.status).toBe(200); + expect((await attested.json()).managedExtensions).toStrictEqual({ + enabled: true, + mode: 'developer-process-group', + cacheDirectory: cache, + }); + const uploaded = await fetch( + `${origin}/internal/managed-runtime/v3/extensions/packages/${pin.packageDigest}`, + { + method: 'PUT', + headers: { ...HEADERS, 'content-type': 'application/gzip' }, + body: bytes, + }, + ); + expect(uploaded.status).toBe(200); + const installed = workspaceInstallation( + '550e8400-e29b-41d4-a716-446655440101', + 'services/api', + ); + const operationId = randomUUID(); + const command = { + operationId, + actorKey: 'a'.repeat(64), + expectedRevision: 0, + extensions: [pin], + }; + const control = (operation: Record) => + post(origin, '/internal/managed-runtime/v3/extensions', { + protocolVersion: 1, + sessionKey: { + tenantId: BOOT.tenantId, + workspaceId: BOOT.workspaceId, + sessionId: '550e8400-e29b-41d4-a716-446655440201', + }, + contextBinding: installed.binding, + operation, + }); + const prepared = await control({ + kind: 'extension-prepare', + operationId, + command, + expectedRevision: 0, + extensions: [pin], + baseMcpServers: [], + baseHookCatalog: null, + settings: { one: { SELECTED_SECRET: 'owned-secret' } }, + toolProfile: 'hosted-workspace-shell/1', + packageManifests: { one: compiled.manifest }, + }); + expect(prepared.status).toBe(200); + expect(JSON.stringify(await prepared.json())).not.toContain( + 'owned-secret', + ); + expect( + ( + await control({ + kind: 'extension-activate', + operationId, + expectedRevision: 0, + revision: 1, + }) + ).status, + ).toBe(200); + expect((await post(origin, CONTEXT, installed)).status).toBe(200); + expect( + (await post(origin, ACTIVATION, workspaceActivation(installed))).status, + ).toBe(200); + const globalHistory = vi + .spyOn(Storage, 'getGlobalQwenDir') + .mockReturnValue(path.join(cache, 'global-history')); + onTestFinished(() => globalHistory.mockRestore()); + const historyBound = await post( + origin, + MANAGED_RUNTIME_PROVIDER_ROUTE.path, + { + protocolVersion: 1, + providerProtocol: MANAGED_RUNTIME_PROVIDER_PROTOCOL, + session: { + harnessSessionId: '550e8400-e29b-41d4-a716-446655440201', + runtimeSessionId: '550e8400-e29b-41d4-a716-446655440101', + turnKind: 'continuation', + }, + operation: { kind: 'raw-file-history', action: 'bind', state: null }, + }, + ); + expect(historyBound.status).toBe(200); + const config = { + revision: 1, + packages: [{ pin, assetRoot: 'extension://one/' }], + }; + const call = async ( + id: string, + toolName: string, + input: Record, + ) => + post(origin, EXECUTE, { + ...shell('550e8400-e29b-41d4-a716-446655440101', id, ''), + toolName, + input: extensionToolInput( + '550e8400-e29b-41d4-a716-446655440201', + config, + toolName, + input, + ), + }); + const selector = { + extensionId: pin.extensionId, + packageRevision: pin.packageRevision, + packageDigest: pin.packageDigest, + }; + const read = await call('extension-read', 'read_file', { + file_path: 'resource.txt', + extension_source: selector, + }); + expect(read.status).toBe(200); + expect(JSON.stringify(await read.json())).toContain( + 'private package resource', + ); + expect(fs.existsSync(path.join(root, 'services/api/resource.txt'))).toBe( + false, + ); + fs.writeFileSync( + path.join(root, 'services/api/workspace-only.txt'), + 'workspace', + ); + const globbed = await call('extension-glob', 'glob', { + pattern: '*.txt', + extension_source: selector, + }); + expect(globbed.status).toBe(200); + const globBody = JSON.stringify(await globbed.json()); + expect(globBody).toContain('resource.txt'); + expect(globBody).not.toContain('workspace-only.txt'); + const writeAttempt = await post(origin, EXECUTE, { + ...shell('550e8400-e29b-41d4-a716-446655440101', 'extension-write', ''), + toolName: 'write_file', + input: { + file_path: 'resource.txt', + content: 'must not write', + managedExtension: { + harnessSessionId: '550e8400-e29b-41d4-a716-446655440201', + configRevision: 1, + pin, + }, + }, + }); + expect(writeAttempt.status).toBe(500); + const readAgain = await call('extension-read-again', 'read_file', { + file_path: 'resource.txt', + extension_source: selector, + }); + expect(JSON.stringify(await readAgain.json())).toContain( + 'private package resource', + ); + + const escaped = await call('extension-escape', 'read_file', { + file_path: '../../../etc/passwd', + extension_source: selector, + }); + expect(JSON.stringify(await escaped.json())).not.toContain('root:'); + const shellResponse = await call('extension-shell', 'run_shell_command', { + command: + 'printf "%s|%s|%s" "$SELECTED_SECRET" "${OTHER_EXTENSION_SECRET-unset}" "$PWD"', + extension_context: selector, + }); + expect(shellResponse.status).toBe(200); + const shellBody = JSON.stringify(await shellResponse.json()); + expect(shellBody).toContain('owned-secret|unset|'); + expect(shellBody).toContain(realDirectory(root, 'services/api')); + const sibling = workspaceInstallation( + '550e8400-e29b-41d4-a716-446655440102', + 'services/web', + ); + await post(origin, CONTEXT, sibling); + await post(origin, ACTIVATION, workspaceActivation(sibling)); + const crossScope = await post(origin, EXECUTE, { + ...shell('550e8400-e29b-41d4-a716-446655440102', 'cross-extension', ''), + toolName: 'read_file', + input: extensionToolInput( + '550e8400-e29b-41d4-a716-446655440201', + config, + 'read_file', + { + file_path: 'resource.txt', + extension_source: selector, + }, + ), + }); + expect(crossScope.status).toBe(500); + }, + ); + it('answers ready v2 and serves exactly the boot v2 routes', async () => { const worker = await startManagedRuntimeAttestationWorker(BOOT); openWorkers.add(worker); @@ -3251,3 +3520,68 @@ describe('selectShellCapturePublisher', () => { expect(prepared.publisher).toBe(remotePublishers); }); }); + +it.skipIf(!['darwin', 'linux'].includes(process.platform))( + 'admits extension command control through the negotiated strict worker gate', + async () => { + const mountRoot = workspace(); + const cacheDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'review-extension-route-'), + ); + onTestFinished(() => + fs.rmSync(cacheDirectory, { recursive: true, force: true }), + ); + const origin = await startWorker({ + ...BOOT, + mountRoot, + capabilityDigest: + process.platform === 'linux' + ? MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST + : MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + managedExtensions: { + enabled: true, + mode: + process.platform === 'linux' + ? 'linux-cgroup' + : 'developer-process-group', + cacheDirectory, + ...(process.platform === 'linux' + ? { cgroupRoot: path.join(cacheDirectory, 'cgroup') } + : {}), + }, + }); + const observations = []; + for (const action of ['execute', 'status', 'cancel']) { + const response = await post( + origin, + '/internal/managed-runtime/v3/extensions/commands', + { + protocolVersion: 1, + runtimeSessionId: 'test-runtime-session', + operation: { kind: 'extension-command', action }, + }, + ); + observations.push({ + action, + status: response.status, + body: await response.text(), + }); + } + for (const entry of observations) { + expect(entry.status).toBe(409); + expect(JSON.parse(entry.body)).toEqual({ + code: 'managed_extension_command_refused', + }); + } + const legacy = await startWorker({ ...BOOT, mountRoot }); + expect( + ( + await post( + legacy, + '/internal/managed-runtime/v3/extensions/commands', + {}, + ) + ).status, + ).toBe(404); + }, +); diff --git a/packages/cli/src/serve/managed-context-worker.ts b/packages/cli/src/serve/managed-context-worker.ts index c8c665ea8e2..c1f17f60e35 100644 --- a/packages/cli/src/serve/managed-context-worker.ts +++ b/packages/cli/src/serve/managed-context-worker.ts @@ -5,10 +5,50 @@ */ import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { preflightManagedExtensionPackage } from './managed-extension-preflight.js'; +import { managedExtensionEnvironment } from './managed-extension-environment.js'; import { CSI_FILES_RETIREMENT_CAPABILITY_DIGEST } from './managed-csi-file-profile.js'; -import { constants, promises as fs, type BigIntStats } from 'node:fs'; +import { + compileManagedExtensionPackage, + type ManagedPackageContributions, +} from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { + MANAGED_EXTENSION_RETIRE_ROUTE, + ManagedExtensionRuntime, + registerManagedExtensionRuntimeRoutes, +} from './managed-extension-runtime.js'; +import { runManagedExtensionShell } from './managed-extension-shell.js'; +import { + ManagedExtensionCommandRuntime, + registerManagedExtensionCommandRoute, +} from './managed-extension-command-runtime.js'; +import { parseManagedExtensionPin } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import { + type ManagedToolSet, + type ManagedToolSetResolver, + createManagedToolSet, + ManagedToolExecutor, + realpathDeepestExisting, + type ManagedShellCapturePublisher, +} from './managed-runtime-tool-executor.js'; +import { ManagedExtensionProcessOwners } from './managed-extension-process.js'; +import { stageManagedExtensionManifests } from './managed-extension-manifests.js'; + +import { + constants, + promises as fs, + mkdirSync, + type BigIntStats, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { sessionIdContext } from '@qwen-code/qwen-code-core/utils/sessionIdContext.js'; +import { + computeManagedContextDigest, + normalizeWorkspaceRelativePath, + type ManagedContextBinding, +} from './managed-workspace-binding.js'; import type express from 'express'; import type { Application, Response } from 'express'; import { @@ -26,16 +66,10 @@ import { type ManagedContextBoot, type ManagedContextOutcome, } from './managed-context-envelope.js'; -import { - createManagedToolSet, - ManagedToolExecutor, - realpathDeepestExisting, - type ManagedShellCapturePublisher, -} from './managed-runtime-tool-executor.js'; import { ManagedChildRunSupervisor } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-run-supervisor.js'; import { RemoteShellResultPublisher } from './remote-shell-result-publication.js'; import type { ManagedShellPublisherRegistry } from './managed-shell-publisher.js'; -import type { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; +import { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; import { registerManagedRuntimeToolRoutes } from './managed-runtime-tool-routes.js'; import { registerManagedRuntimeToolV3Routes } from './managed-runtime-tool-v3-routes.js'; import { @@ -296,13 +330,64 @@ export function registerManagedContextRoutes( if (boot.capabilityDigest === CSI_FILES_RETIREMENT_CAPABILITY_DIGEST) throw new Error('Private CSI file profile requires its dedicated worker.'); const installations = new ManagedContextInstallations(boot); + let extensionRuntime: ManagedExtensionRuntime | undefined; + let extensionToolSet: + | (( + reference: Parameters[0], + invocation: NonNullable[1]>, + tools: ManagedToolSet, + ) => Promise) + | undefined; + const extensionConfiguration = boot.managedExtensions; + const processDirectory = extensionConfiguration + ? path.join( + extensionConfiguration.cacheDirectory, + 'owners', + createHash('sha256') + .update( + `${boot.tenantId}\0${boot.workspaceId}\0${boot.runtimeInstanceId}\0${boot.epoch}`, + ) + .digest('hex'), + ) + : undefined; + if (extensionConfiguration && !ledger && processDirectory) { + mkdirSync(processDirectory, { recursive: true, mode: 0o700 }); + const pgid = Number( + execFileSync('/bin/ps', ['-o', 'pgid=', '-p', String(process.pid)], { + encoding: 'utf8', + }).trim(), + ); + if (!Number.isSafeInteger(pgid) || pgid < 2) + throw new Error('Worker process group is unavailable.'); + ledger = ManagedRuntimeLedger.create({ + workFile: path.join(processDirectory, 'ledger.json'), + worker: { + pid: process.pid, + pgid, + groupOwned: false, + incarnation: boot.runtimeIncarnation, + startedAt: Date.now(), + }, + }); + } + + const extensionProcesses = extensionConfiguration + ? new ManagedExtensionProcessOwners(extensionConfiguration.mode, { + ledger, + cgroupRoot: extensionConfiguration.cgroupRoot, + directory: processDirectory, + }) + : undefined; + const foregroundOnly = sshForegroundOnly && !extensionConfiguration; const activations = new WorkspaceActivations(); const resident = boot.capabilityDigest === RESIDENT_WORKSPACE_CAPABILITY_DIGEST; if (resident && boot.isolationClass !== 'workspace') throw new Error('Resident Workspace requires Workspace isolation.'); const requiresActivation = - boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || resident; + boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + resident || + extensionConfiguration !== undefined; const admissionOpen = (): boolean => executor.isAdmissionOpen; const remotePublisher = !capturePublisher && requiresActivation && !resident @@ -345,7 +430,7 @@ export function registerManagedContextRoutes( handleManagedRuntimeJsonError, ); - const mcp = sshForegroundOnly + const mcp = foregroundOnly ? undefined : new ManagedMcpRuntime( boot, @@ -355,7 +440,8 @@ export function registerManagedContextRoutes( !mount.isAvailable || resident || !requiresActivation || - !activations.isActive(runtimeSessionId) + !activations.isActive(runtimeSessionId) || + extensionRuntime?.isChanging(runtimeSessionId) === true ) return undefined; const binding = installations.installed(runtimeSessionId); @@ -370,9 +456,10 @@ export function registerManagedContextRoutes( loadManagedMcpManifest( resident ? undefined : process.env['QWEN_MANAGED_MCP_CONFIG'], ), + extensionProcesses, ); if (mcp) registerManagedMcpRoutes(app, boot, mcp); - const hooks = sshForegroundOnly + const hooks = foregroundOnly ? undefined : new ManagedHookRuntime( boot, @@ -382,7 +469,8 @@ export function registerManagedContextRoutes( !mount.isAvailable || resident || !requiresActivation || - !activations.isActive(runtimeSessionId) + !activations.isActive(runtimeSessionId) || + extensionRuntime?.isChanging(runtimeSessionId) === true ) return undefined; const binding = installations.installed(runtimeSessionId); @@ -397,6 +485,7 @@ export function registerManagedContextRoutes( loadManagedHookManifest( resident ? undefined : process.env['QWEN_MANAGED_HOOK_CONFIG'], ), + extensionProcesses, ); if (hooks) registerManagedHookRoutes(app, boot, hooks); // H3 background Shells share the delegation the Hook commands already use; @@ -406,7 +495,7 @@ export function registerManagedContextRoutes( ? undefined : process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT']; const backgroundSupervisor = - !sshForegroundOnly && cgroupRoot + !foregroundOnly && cgroupRoot ? ManagedChildRunSupervisor.create({ cgroupRoot }) : undefined; const backgroundRegistry = new ManagedBackgroundShellRegistry(); @@ -414,8 +503,9 @@ export function registerManagedContextRoutes( // private second instance could only ever answer unknown. const monitorRegistry = new ManagedMonitorRegistry(); const executor: ManagedToolExecutor = new ManagedToolExecutor( - async (reference) => { + async (reference, invocation) => { const isActive = () => + extensionRuntime?.isChanging(reference.sessionId) !== true && admissionOpen() && mount.isAvailable && (!requiresActivation || activations.isActive(reference.sessionId)); @@ -434,13 +524,21 @@ export function registerManagedContextRoutes( boot.runtimeInstanceId, reference.sessionId, ); - const toolSet = sessionIdContext.run(sessionId, () => + let toolSet = sessionIdContext.run(sessionId, () => createManagedToolSet( directory, sessionId, requiresActivation ? boot.mountRoot : directory, ), ); + if (invocation) { + if (!extensionToolSet) + throw new Error('Extension tool context is unavailable.'); + toolSet = await extensionToolSet(reference, invocation, { + ...toolSet, + isActive, + }); + } return { ...toolSet, tools: resident @@ -450,7 +548,7 @@ export function registerManagedContextRoutes( ), ) : toolSet.tools, - isActive, + isActive: toolSet.isActive ?? isActive, }; }, publisher, @@ -562,6 +660,7 @@ export function registerManagedContextRoutes( ); } const isActive = () => + extensionRuntime?.isChanging(sessionId) !== true && !executor.isAdmissionSealed && mount.isAvailable && activations.isActive(sessionId); @@ -579,6 +678,397 @@ export function registerManagedContextRoutes( }, sshForegroundOnly, ); + + if (extensionConfiguration && mcp && hooks && extensionProcesses) { + const configurationDirectory = path.join( + extensionConfiguration.cacheDirectory, + createHash('sha256') + .update( + `${boot.tenantId}\0${boot.workspaceId}\0${boot.workspaceGeneration}`, + ) + .digest('hex'), + ); + const relativeCache = path.relative( + path.resolve(boot.mountRoot), + configurationDirectory, + ); + if (!relativeCache.startsWith('..') && !path.isAbsolute(relativeCache)) + throw new Error('Extension cache must stay outside the Workspace.'); + const contexts = new Map(); + const hydrated = new Map< + string, + { + contributions: ManagedPackageContributions; + environment: Readonly>; + } + >(); + const staged = new Map< + string, + ReturnType + >(); + extensionRuntime = new ManagedExtensionRuntime({ + directory: configurationDirectory, + executionMode: extensionConfiguration.mode, + ownerJournalId: extensionProcesses.journalId, + bindContext: async (sessionId, value) => { + const binding = value as ManagedContextBinding; + computeManagedContextDigest(binding); + if ( + binding.tenantId !== boot.tenantId || + binding.workspaceId !== boot.workspaceId || + binding.workspaceGeneration !== boot.workspaceGeneration || + binding.storageId !== boot.storageId || + !(await mount.resolve(binding.cwdRelative)) + ) + throw new Error('Extension context scope conflicts.'); + const existing = contexts.get(sessionId); + if ( + existing && + computeManagedContextDigest(existing) !== + computeManagedContextDigest(binding) + ) + throw new Error('Extension context changed.'); + contexts.set(sessionId, { ...binding }); + }, + admitted: async (sessionId) => { + const binding = contexts.get(sessionId); + return ( + admissionOpen() && + binding !== undefined && + (await mount.resolve(binding.cwdRelative)) !== undefined + ); + }, + idle: (sessionId) => + !mcp.hasExtensionPendingOperations(sessionId) && + !hooks.hasExtensionHolds(sessionId) && + executor.isSessionConfigurationIdle( + extensionRuntime?.runtimeSessionId(sessionId) ?? sessionId, + ), + compile: async ( + directory, + _pin, + environment, + sessionId, + publishedManifest, + ) => { + const binding = contexts.get(sessionId); + const workspaceDirectory = + binding && (await mount.resolve(binding.cwdRelative)); + if ( + !workspaceDirectory || + !['linux', 'darwin'].includes(process.platform) || + !['x64', 'arm64'].includes(process.arch) + ) + throw new Error('Extension target is unavailable.'); + const target = { + platform: process.platform as 'linux' | 'darwin', + arch: process.arch as 'x64' | 'arm64', + }; + const raw = await compileManagedExtensionPackage(directory, { + target, + workspaceDirectory, + resolveRecipes: false, + }); + if ( + Buffer.byteLength(JSON.stringify(raw.contributions)) > + 2 * 1024 * 1024 + ) + throw new Error('Extension contributions exceed their size limit.'); + if ( + publishedManifest === undefined || + !isDeepStrictEqual(raw.manifest, publishedManifest) + ) + throw new Error('Extension published manifest conflicts.'); + const resolved = await compileManagedExtensionPackage(directory, { + target, + workspaceDirectory, + environment, + }); + await preflightManagedExtensionPackage( + resolved.contributions, + directory, + workspaceDirectory, + managedExtensionEnvironment( + process.env, + workspaceDirectory, + environment, + ), + ); + hydrated.set(directory, { + contributions: resolved.contributions, + environment, + }); + return raw.contributions; + }, + stage: async (sessionId, command, packages, base) => { + await extensionProcesses.coverSession( + sessionId, + command.expectedRevision + 1, + ); + const manifests = stageManagedExtensionManifests({ + tenantId: boot.tenantId, + workspaceId: boot.workspaceId, + runtimeSessionId: sessionId, + command, + packages, + ...base, + mcp, + hooks, + hydrated: (directory) => { + const value = hydrated.get(directory); + if (!value) throw new Error('Extension recipes are unavailable.'); + return value; + }, + }); + staged.set(`${sessionId}\0${command.operationId}`, manifests); + return manifests; + }, + drain: async (sessionId, command) => { + const next = staged.get(`${sessionId}\0${command.operationId}`); + if (!next) throw new Error('Extension configuration is not staged.'); + const retained = mcp.retainedExtensionOwnerIds( + sessionId, + next.mcpManifest, + ); + await mcp.drainExtensionSession(sessionId, next.mcpManifest); + if (hooks.hasExtensionHolds(sessionId)) + throw new Error('Original extension Hook outcome is unknown.'); + const receipts = await extensionProcesses.stopSession( + sessionId, + retained, + ); + if (receipts.some((receipt) => receipt.state !== 'stopped')) + throw new Error('Original extension process stop is unknown.'); + }, + reconcile: async (sessionId) => { + await mcp.drainExtensionSession(sessionId); + if (hooks.hasExtensionHolds(sessionId)) + throw new Error('Original Hook execution outcome is unknown.'); + return extensionProcesses.stopSession(sessionId); + }, + install: async (sessionId, _packages, command) => { + const manifests = staged.get(`${sessionId}\0${command.operationId}`); + if (!manifests) + throw new Error('Extension configuration is not prepared.'); + const retained = mcp.retainedExtensionOwnerIds( + sessionId, + manifests.mcpManifest, + ); + if (await extensionProcesses.hasLiveSessionOwners(sessionId, retained)) + throw new Error('Original extension owners require reconciliation.'); + mcp.installSessionDefinitions(sessionId, manifests.mcpManifest); + hooks.installSessionManifest(sessionId, manifests.hookManifest); + }, + }); + executor.attachExtensionLifecycle({ + mode: extensionConfiguration.mode, + drain: async () => { + if ( + mcp.hasUnsettledExtensionOperations() || + hooks.hasUnsettledExtensionOperations() + ) + throw new Error('Original extension operations are unsettled.'); + const receipts = await extensionProcesses.stopAll(); + if (receipts.some((entry) => entry.state !== 'stopped')) + throw new Error('Original extension owners are not stopped.'); + }, + }); + app.post( + MANAGED_EXTENSION_RETIRE_ROUTE.path, + managedRuntimeNoStore, + authorizeManagedRuntime(boot), + managedRuntimeJsonBody(4096), + async (req, res) => { + try { + const body = req.body as Record; + if ( + !body || + Object.keys(body).sort().join(',') !== + 'protocolVersion,retirementId' || + body['protocolVersion'] !== 1 || + typeof body['retirementId'] !== 'string' + ) + throw new Error('Invalid extension retirement.'); + await executor.retireSsh(body['retirementId']); + res.json({ + protocolVersion: 1, + retirementId: body['retirementId'], + executionMode: extensionConfiguration.mode, + scope: + extensionConfiguration.mode === 'linux-cgroup' + ? 'process_tree' + : 'process_group', + state: 'stopped', + runtimeInstanceId: boot.runtimeInstanceId, + runtimeIncarnation: boot.runtimeIncarnation, + leaseId: boot.leaseId, + epoch: boot.epoch, + }); + } catch { + res.status(409).json({ code: 'managed_extension_stop_unverified' }); + } + }, + ); + extensionToolSet = async (reference, invocation, original) => { + const value = invocation.input['managedExtension']; + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid extension tool context.'); + const context = value as Record; + if ( + Object.keys(context).sort().join(',') !== + 'configRevision,harnessSessionId,pin' || + typeof context['harnessSessionId'] !== 'string' || + !Number.isSafeInteger(context['configRevision']) || + Number(context['configRevision']) < 1 + ) + throw new Error('Invalid extension tool context.'); + const logicalId = context['harnessSessionId']; + const installed = installations.installed(reference.sessionId); + const logical = contexts.get(logicalId); + if ( + !installed || + !logical || + computeManagedContextDigest(installed) !== + computeManagedContextDigest(logical) + ) + throw new Error('Extension tool Session binding conflicts.'); + const runtime = extensionRuntime!; + const selected = await runtime.getToolContext( + logicalId, + Number(context['configRevision']), + parseManagedExtensionPin(context['pin']), + ); + const active = () => + original.isActive?.() !== false && !runtime.isChanging(logicalId); + if ( + invocation.toolName === 'read_file' || + invocation.toolName === 'glob' + ) { + const assetTools = createManagedToolSet( + selected.assetRoot, + runtimeSessionKey( + boot.runtimeInstanceId, + logicalId + + '/' + + parseManagedExtensionPin(context['pin']).extensionId, + ), + selected.assetRoot, + ); + return { + ...assetTools, + extensionAssets: true, + tools: new Map( + [...assetTools.tools].filter( + ([name]) => name === invocation.toolName, + ), + ), + isActive: active, + }; + } + if (invocation.toolName !== 'run_shell_command') + throw new Error('Extension assets are read-only.'); + return { + ...original, + isActive: active, + runShell: (input, signal, capture) => + runManagedExtensionShell(extensionProcesses, { + sessionId: logicalId, + configRevision: Number(context['configRevision']), + command: String(input['command']), + cwd: original.directory!, + environment: managedExtensionEnvironment( + process.env, + original.directory!, + selected.environment, + ), + signal, + capture, + timeoutMs: + typeof input['timeout'] === 'number' + ? input['timeout'] + : undefined, + }), + }; + }; + registerManagedExtensionRuntimeRoutes(app, boot, extensionRuntime); + registerManagedExtensionCommandRoute( + app, + boot, + new ManagedExtensionCommandRuntime({ + directory: path.join(configurationDirectory, 'command-receipts'), + tenantId: boot.tenantId, + workspaceId: boot.workspaceId, + workspaceGeneration: boot.workspaceGeneration, + prepareInput: async (runtimeSessionId, operation) => { + const logicalId = operation.grant.sessionKey.sessionId; + const installed = installations.installed(runtimeSessionId); + const logical = contexts.get(logicalId); + if ( + !admissionOpen() || + !installed || + !logical || + (requiresActivation && !activations.isActive(runtimeSessionId)) || + computeManagedContextDigest(installed) !== + computeManagedContextDigest(logical) + ) + throw new Error('Command Session context conflicts.'); + const runtime = extensionRuntime!; + const active = await runtime.status(logicalId); + const pin = active.extensions.find( + (entry) => entry.extensionId === operation.extensionId, + ); + if ( + active.state !== 'active' || + active.pendingOperationId || + active.revision !== operation.revision || + !pin + ) + throw new Error('Command configuration is unavailable.'); + await runtime.getToolContext(logicalId, operation.revision, pin); + const input = structuredClone(operation.input); + const context = { + harnessSessionId: logicalId, + configRevision: operation.revision, + pin, + }; + if (operation.toolName === 'shell') { + if ( + Object.keys(input).some( + (key) => !['command', 'timeout'].includes(key), + ) || + typeof input['command'] !== 'string' + ) + throw new Error('Invalid command Shell input.'); + return { ...input, managedExtension: context }; + } + if ( + Object.keys(input).some( + (key) => !['file_path', 'offset', 'limit', 'pages'].includes(key), + ) || + typeof input['file_path'] !== 'string' + ) + throw new Error('Invalid command file input.'); + const prefix = `extension://${operation.extensionId}/`; + if (input['file_path'].startsWith(prefix)) { + input['file_path'] = normalizeWorkspaceRelativePath( + input['file_path'].slice(prefix.length), + ); + return { ...input, managedExtension: context }; + } + if (input['file_path'].startsWith('extension://')) + throw new Error('Command asset belongs to another extension.'); + input['file_path'] = normalizeWorkspaceRelativePath( + input['file_path'], + ); + return input; + }, + execute: (reference, toolName, input) => + executor.execute(reference, toolName, input), + cancel: (reference) => executor.cancel(reference), + }), + ); + } + activations.register(app, boot, installations, executor); registerManagedRuntimeToolRoutes(app, boot, executor); if (publisher) { diff --git a/packages/cli/src/serve/managed-extension-cache.test.ts b/packages/cli/src/serve/managed-extension-cache.test.ts new file mode 100644 index 00000000000..dd6aac8a00e --- /dev/null +++ b/packages/cli/src/serve/managed-extension-cache.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import * as tar from 'tar'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ManagedExtensionCache } from './managed-extension-cache.js'; + +let temporary: string; +beforeEach(async () => { + temporary = await fs.mkdtemp( + path.join(os.tmpdir(), 'managed-package-cache-'), + ); +}); +afterEach(async () => { + await fs.rm(temporary, { force: true, recursive: true }); +}); + +async function archive(link = false) { + const source = path.join(temporary, 'source'); + await fs.mkdir(source); + await fs.writeFile( + path.join(source, 'qwen-extension.json'), + '{"name":"test"}', + ); + await fs.mkdir(path.join(source, 'skills')); + await fs.writeFile(path.join(source, 'skills', 'script.sh'), 'echo test'); + if (link) await fs.symlink('/etc/passwd', path.join(source, 'outside')); + const file = path.join(temporary, 'package.tar.gz'); + await tar.c({ cwd: source, file, gzip: true }, ['.']); + const bytes = await fs.readFile(file); + return { bytes, digest: createHash('sha256').update(bytes).digest('hex') }; +} + +describe('ManagedExtensionCache', () => { + it('retains full package resources and never replaces another materialization', async () => { + const { bytes, digest } = await archive(); + const cache = new ManagedExtensionCache(path.join(temporary, 'cache')); + await cache.upload(digest, Readable.from(bytes)); + const first = await cache.materialize(digest); + expect( + await fs.readFile(path.join(first, 'skills/script.sh'), 'utf8'), + ).toBe('echo test'); + const [concurrentA, concurrentB] = await Promise.all([ + cache.materialize(digest), + cache.materialize(digest), + ]); + expect(concurrentA).not.toBe(concurrentB); + const second = await cache.materialize(digest); + expect(second).not.toBe(first); + expect( + await fs.readFile(path.join(first, 'skills/script.sh'), 'utf8'), + ).toBe('echo test'); + }); + + it('repairs missing or drifted assets at the identical path only from a verified archive', async () => { + const { bytes, digest } = await archive(); + const cache = new ManagedExtensionCache(path.join(temporary, 'cache')); + await cache.upload(digest, Readable.from(bytes)); + const pinned = await cache.materialize(digest); + await fs.rm(pinned, { recursive: true }); + await cache.restoreMaterialized(digest, pinned); + await expect( + cache.verifyMaterialized(digest, pinned), + ).resolves.toBeUndefined(); + await fs.writeFile(path.join(pinned, 'skills/script.sh'), 'changed'); + await expect(cache.verifyMaterialized(digest, pinned)).rejects.toThrow( + 'drifted', + ); + await cache.restoreMaterialized(digest, pinned); + expect( + await fs.readFile(path.join(pinned, 'skills/script.sh'), 'utf8'), + ).toBe('echo test'); + const sibling = await cache.materialize(digest); + await fs.writeFile(path.join(pinned, 'skills/script.sh'), 'changed again'); + await fs.writeFile( + path.join(cache.directory, `${digest}.tar.gz`), + 'corrupt', + ); + await expect(cache.restoreMaterialized(digest, pinned)).rejects.toThrow( + 'digest', + ); + expect( + await fs.readFile(path.join(pinned, 'skills/script.sh'), 'utf8'), + ).toBe('changed again'); + await expect( + cache.verifyMaterialized(digest, sibling), + ).resolves.toBeUndefined(); + const outside = path.join(temporary, `${digest}-${'a'.repeat(36)}`); + await fs.mkdir(outside); + await expect(cache.restoreMaterialized(digest, outside)).rejects.toThrow( + 'scope', + ); + expect(await fs.stat(outside)).toBeDefined(); + }); + + it('rejects corrupt uploads without leaving a cache artifact', async () => { + const { bytes } = await archive(); + const cachePath = path.join(temporary, 'cache'); + const cache = new ManagedExtensionCache(cachePath); + await expect( + cache.upload('0'.repeat(64), Readable.from(bytes)), + ).rejects.toThrow('digest'); + expect(await fs.readdir(cachePath)).toEqual([]); + }); + + it('rejects links before materializing a directory', async () => { + const { bytes, digest } = await archive(true); + const cachePath = path.join(temporary, 'cache'); + const cache = new ManagedExtensionCache(cachePath); + await expect(cache.upload(digest, Readable.from(bytes))).rejects.toThrow(); + expect(await fs.readdir(cachePath)).toEqual([]); + }); + + it('cleans up an interrupted stream', async () => { + const cachePath = path.join(temporary, 'cache'); + const cache = new ManagedExtensionCache(cachePath); + const stream = Readable.from( + (async function* () { + yield Buffer.from('partial'); + throw new Error('disconnected'); + })(), + ); + await expect(cache.upload('0'.repeat(64), stream)).rejects.toThrow( + 'disconnected', + ); + expect(await fs.readdir(cachePath)).toEqual([]); + }); +}); diff --git a/packages/cli/src/serve/managed-extension-cache.ts b/packages/cli/src/serve/managed-extension-cache.ts new file mode 100644 index 00000000000..b322c3083e4 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-cache.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream, promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import * as tar from 'tar'; +import { + MAX_ARCHIVE_ENTRIES, + MAX_ARCHIVE_EXPANDED_BYTES, + MAX_ARCHIVE_PATH_BYTES, +} from '@qwen-code/qwen-code-core/extension/archive-safety.js'; + +export const MANAGED_EXTENSION_ARCHIVE_MAX_BYTES = 100 * 1024 * 1024; +const DIGEST = /^[0-9a-f]{64}$/u; + +export class ManagedExtensionCache { + constructor(readonly directory: string) { + if (!path.isAbsolute(directory)) throw new Error('Invalid package cache.'); + } + + async upload(digest: string, source: Readable): Promise { + this.assertDigest(digest); + await fs.mkdir(this.directory, { recursive: true, mode: 0o700 }); + const temporary = path.join(this.directory, `.upload-${randomUUID()}`); + const hash = createHash('sha256'); + let bytes = 0; + const limit = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.byteLength; + if (bytes > MANAGED_EXTENSION_ARCHIVE_MAX_BYTES) { + callback(new Error('Package archive exceeds its size limit.')); + return; + } + hash.update(chunk); + callback(null, chunk); + }, + }); + try { + await pipeline( + source, + limit, + createWriteStream(temporary, { flags: 'wx', mode: 0o600 }), + ); + if (hash.digest('hex') !== digest) + throw new Error('Package archive digest mismatch.'); + await this.validateArchive(temporary); + await fs.rename(temporary, this.archivePath(digest)); + } finally { + await fs.rm(temporary, { force: true }); + } + } + + materialize(digest: string): Promise { + this.assertDigest(digest); + return this.extract(digest); + } + + private async extract(digest: string): Promise { + const archive = this.archivePath(digest); + const hash = createHash('sha256'); + for await (const chunk of createReadStream(archive)) hash.update(chunk); + if (hash.digest('hex') !== digest) + throw new Error('Cached package archive digest mismatch.'); + await this.validateArchive(archive); + // Each materialization has its own directory: a caller never replaces a + // directory another Session has pinned. + const destination = path.join(this.directory, `${digest}-${randomUUID()}`); + await fs.mkdir(destination, { mode: 0o700 }); + try { + await tar.x({ + file: archive, + cwd: destination, + strict: true, + noChmod: true, + }); + await fs.writeFile( + `${destination}.integrity`, + await this.treeDigest(destination), + { mode: 0o600, flag: 'wx' }, + ); + return destination; + } catch (error) { + await fs.rm(destination, { recursive: true, force: true }); + throw error; + } + } + + async restoreMaterialized(digest: string, directory: string): Promise { + this.assertDigest(digest); + if ( + path.dirname(directory) !== this.directory || + !new RegExp(`^${digest}-[0-9a-f-]{36}$`, 'u').test( + path.basename(directory), + ) || + !(await fs.lstat(this.directory)).isDirectory() + ) + throw new Error('Extension cache scope conflicts.'); + try { + await this.verifyMaterialized(digest, directory); + return; + } catch { + // Only explicit preparation repairs assets; ordinary tool reads only verify. + } + const fresh = await this.materialize(digest); + const quarantine = path.join(this.directory, `.quarantine-${randomUUID()}`); + const moveIfPresent = async (source: string, target: string) => { + try { + await fs.rename(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + }; + try { + await moveIfPresent(directory, quarantine); + await moveIfPresent(`${directory}.integrity`, `${quarantine}.integrity`); + await fs.rename(fresh, directory); + await fs.rename(`${fresh}.integrity`, `${directory}.integrity`); + await this.verifyMaterialized(digest, directory); + await fs.rm(quarantine, { recursive: true, force: true }); + await fs.rm(`${quarantine}.integrity`, { force: true }); + } finally { + await fs.rm(fresh, { recursive: true, force: true }); + await fs.rm(`${fresh}.integrity`, { force: true }); + } + } + + async verifyMaterialized(digest: string, directory: string): Promise { + this.assertDigest(digest); + if ( + path.dirname(directory) !== this.directory || + !path.basename(directory).startsWith(`${digest}-`) + ) + throw new Error('Extension cache scope conflicts.'); + if (!(await fs.lstat(directory)).isDirectory()) + throw new Error('Extension cache root is unsafe.'); + const expected = await fs.readFile(`${directory}.integrity`, 'utf8'); + if ((await this.treeDigest(directory)) !== expected) + throw new Error('Extension package assets drifted.'); + } + + private async treeDigest(directory: string): Promise { + const hash = createHash('sha256'); + const visit = async (relative: string): Promise => { + const entries = await fs.readdir(path.join(directory, relative), { + withFileTypes: true, + }); + for (const entry of entries.sort((a, b) => + a.name.localeCompare(b.name, 'en'), + )) { + const child = path.posix.join(relative, entry.name); + if (!entry.isDirectory() && !entry.isFile()) + throw new Error('Extension assets contain an unsafe entry.'); + hash.update( + JSON.stringify([child, entry.isDirectory() ? 'directory' : 'file']), + ); + if (entry.isDirectory()) await visit(child); + else { + const content = createHash('sha256'); + for await (const chunk of createReadStream( + path.join(directory, child), + )) + content.update(chunk); + hash.update(content.digest()); + } + } + }; + await visit(''); + return hash.digest('hex'); + } + + private async validateArchive(file: string): Promise { + let count = 0; + let bytes = 0; + let pathBytes = 0; + let invalid = false; + const seen = new Set(); + const source = createReadStream(file); + const parser = tar.t({ + strict: true, + onReadEntry(entry) { + const normalized = path.posix.normalize(entry.path).replace(/\/$/u, ''); + count++; + bytes += entry.size; + pathBytes += Buffer.byteLength(entry.path); + if ( + !['File', 'Directory', 'OldFile', 'ContiguousFile'].includes( + entry.type, + ) || + entry.path.includes('\\') || + entry.path.includes('\0') || + path.posix.isAbsolute(entry.path) || + /^[a-zA-Z]:/u.test(entry.path) || + normalized === '..' || + normalized.startsWith('../') || + seen.has(normalized) || + count > MAX_ARCHIVE_ENTRIES || + bytes > MAX_ARCHIVE_EXPANDED_BYTES || + pathBytes > MAX_ARCHIVE_PATH_BYTES + ) { + invalid = true; + source.destroy(new Error('Unsafe package archive.')); + } + seen.add(normalized); + }, + }); + await pipeline(source, parser); + if (invalid) throw new Error('Unsafe package archive.'); + } + + private archivePath(digest: string): string { + return path.join(this.directory, `${digest}.tar.gz`); + } + + private assertDigest(digest: string): void { + if (!DIGEST.test(digest)) throw new Error('Invalid package digest.'); + } +} diff --git a/packages/cli/src/serve/managed-extension-command-runtime.test.ts b/packages/cli/src/serve/managed-extension-command-runtime.test.ts new file mode 100644 index 00000000000..88bba625dba --- /dev/null +++ b/packages/cli/src/serve/managed-extension-command-runtime.test.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ManagedToolResultPayload } from './managed-runtime-tool-executor.js'; +import { + ManagedExtensionCommandRuntime, + type ManagedExtensionCommand, + type ManagedExtensionCommandDependencies, +} from './managed-extension-command-runtime.js'; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-command-')); +}); +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +function operation(): ManagedExtensionCommand { + const operationId = `command-step-${'a'.repeat(64)}`; + return { + kind: 'extension-command', + operationId, + action: 'execute', + toolName: 'shell', + input: { command: 'echo hello' }, + revision: 1, + extensionId: 'ext_00000000-0000-4000-8000-000000000001', + grant: { + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: 'harness', + }, + operationId, + domain: 'tool_stage', + operationRevision: 2, + ownerId: 'runtime-session', + workspaceGeneration: '1', + resourceScope: { + recordRef: { + resourceId: 'record', + kind: 'managed-tool_stage', + schemaVersion: 1, + byteLength: 2, + digest: 'b'.repeat(64), + }, + phases: ['execute', 'status', 'cancel'], + }, + leaseDurationMs: 60_000, + expiresAt: Date.now() + 60_000, + }, + }; +} + +function dependencies(): ManagedExtensionCommandDependencies { + return { + directory, + tenantId: 'tenant', + workspaceId: 'workspace', + workspaceGeneration: '1', + prepareInput: vi.fn(async (_id, command) => command.input), + execute: vi.fn( + async (): Promise => ({ + executionStatus: 'success', + responseParts: [{ text: 'hello' }], + }), + ), + cancel: vi.fn(), + }; +} + +describe('Managed extension command durable execution', () => { + it('persists a result before exposing settlement and never repeats a lost response', async () => { + const deps = dependencies(); + const runtime = new ManagedExtensionCommandRuntime(deps); + const command = operation(); + expect(await runtime.control('runtime-session', command)).toMatchObject({ + state: 'running', + }); + await vi.waitFor(async () => { + expect( + await runtime.control('runtime-session', { + ...command, + action: 'status', + }), + ).toMatchObject({ + state: 'settled', + result: { executionStatus: 'success' }, + }); + }); + const restarted = new ManagedExtensionCommandRuntime(deps); + expect(await restarted.control('runtime-session', command)).toMatchObject({ + state: 'settled', + }); + expect(deps.execute).toHaveBeenCalledTimes(1); + await expect( + restarted.control('runtime-session', { + ...command, + input: { command: 'touch other' }, + }), + ).rejects.toThrow('identity'); + }); + + it('holds a surviving intent on restart and refuses to redispatch it', async () => { + const deps = dependencies(); + let finish!: (value: ManagedToolResultPayload) => void; + vi.mocked(deps.execute).mockImplementation( + async () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const original = new ManagedExtensionCommandRuntime(deps); + const command = operation(); + await original.control('runtime-session', command); + await vi.waitFor(() => expect(deps.execute).toHaveBeenCalledTimes(1)); + const replacement = new ManagedExtensionCommandRuntime(deps); + expect(await replacement.control('runtime-session', command)).toMatchObject( + { state: 'outcome_unknown' }, + ); + expect( + await replacement.control('runtime-session', { + ...command, + action: 'cancel', + }), + ).toMatchObject({ state: 'outcome_unknown' }); + expect(deps.execute).toHaveBeenCalledTimes(1); + finish({ executionStatus: 'success', responseParts: [] }); + await vi.waitFor(async () => + expect( + await original.control('runtime-session', { + ...command, + action: 'status', + }), + ).toMatchObject({ state: 'settled' }), + ); + }); + + it('never creates an execution during status or under another owner or expired grant', async () => { + const deps = dependencies(); + const runtime = new ManagedExtensionCommandRuntime(deps); + const command = operation(); + expect( + await runtime.control('runtime-session', { + ...command, + action: 'status', + }), + ).toMatchObject({ state: 'outcome_unknown' }); + await expect(runtime.control('other', command)).rejects.toThrow('owner'); + await expect( + runtime.control('runtime-session', { + ...command, + grant: { ...command.grant, expiresAt: Date.now() - 1 }, + }), + ).rejects.toThrow(); + expect(deps.execute).not.toHaveBeenCalled(); + expect(await fs.readdir(directory)).toEqual([]); + }); + + it('cancels preparation before it can start a tool', async () => { + const deps = dependencies(); + let finish!: (input: Record) => void; + vi.mocked(deps.prepareInput).mockImplementation( + async () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const runtime = new ManagedExtensionCommandRuntime(deps); + const command = operation(); + await runtime.control('runtime-session', command); + await vi.waitFor(() => expect(deps.prepareInput).toHaveBeenCalledTimes(1)); + await runtime.control('runtime-session', { ...command, action: 'cancel' }); + finish(command.input); + await vi.waitFor(async () => + expect( + await runtime.control('runtime-session', { + ...command, + action: 'status', + }), + ).toMatchObject({ + state: 'settled', + result: { executionStatus: 'not_started' }, + }), + ); + expect(deps.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/managed-extension-command-runtime.ts b/packages/cli/src/serve/managed-extension-command-runtime.ts new file mode 100644 index 00000000000..bb3b93f524b --- /dev/null +++ b/packages/cli/src/serve/managed-extension-command-runtime.ts @@ -0,0 +1,358 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Application } from 'express'; +import { atomicWriteJSON } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js'; +import { + parseOperationGrant, + type OperationGrant, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js'; +import { ManagedOperationGrantGate } from '@qwen-code/qwen-code-core/managed-runtime/managed-operation-grant-gate.js'; +import { + authorizeManagedRuntime, + managedRuntimeJsonBody, + managedRuntimeNoStore, + nameManagedRuntimeIncarnation, + type ManagedRuntimeRequestIdentity, +} from './managed-runtime-attestation-contract.js'; +import { + ManagedMcpToolUnknownError, + type ManagedToolReference, + type ManagedToolResultPayload, +} from './managed-runtime-tool-executor.js'; + +export const MANAGED_EXTENSION_COMMAND_ROUTE = Object.freeze({ + key: 'extension-command', + method: 'POST', + path: '/internal/managed-runtime/v3/extensions/commands', +}); + +export interface ManagedExtensionCommand { + readonly kind: 'extension-command'; + readonly operationId: string; + readonly action: 'execute' | 'status' | 'cancel'; + readonly grant: OperationGrant; + readonly toolName: 'read_file' | 'shell'; + readonly input: Record; + readonly revision: number; + readonly extensionId: string; +} + +interface View { + readonly operationId: string; + readonly state: 'running' | 'settled' | 'outcome_unknown'; + readonly result?: ManagedToolResultPayload; +} + +interface Receipt { + readonly operationId: string; + readonly fingerprint: string; + readonly state: 'running' | 'settled'; + readonly result?: ManagedToolResultPayload; +} + +export interface ManagedExtensionCommandDependencies { + readonly directory: string; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceGeneration: string; + readonly prepareInput: ( + runtimeSessionId: string, + operation: ManagedExtensionCommand, + ) => Promise>; + readonly execute: ( + reference: ManagedToolReference, + toolName: string, + input: Record, + ) => Promise; + readonly cancel: (reference: ManagedToolReference) => unknown; +} + +/** An intent surviving its process is unknown. It can never dispatch twice. */ +export class ManagedExtensionCommandRuntime { + private readonly gates = new ManagedOperationGrantGate(); + private readonly locks = new Map>(); + private readonly running = new Map< + string, + { + reference: ManagedToolReference; + controller: AbortController; + done: Promise; + } + >(); + + constructor( + private readonly dependencies: ManagedExtensionCommandDependencies, + ) { + if (!path.isAbsolute(dependencies.directory)) + throw new Error('Invalid command receipt directory.'); + } + + async control(runtimeSessionId: string, value: unknown): Promise { + const operation = this.parse(runtimeSessionId, value); + const key = digest([operation.grant.sessionKey, operation.operationId]); + const previous = this.locks.get(key) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(() => this.controlLocked(key, runtimeSessionId, operation)); + this.locks.set(key, pending); + try { + return await pending; + } finally { + if (this.locks.get(key) === pending) this.locks.delete(key); + } + } + + private async controlLocked( + key: string, + runtimeSessionId: string, + operation: ManagedExtensionCommand, + ): Promise { + const filename = path.join(this.dependencies.directory, `${key}.json`); + const fingerprint = digest({ + runtimeSessionId, + harnessSessionId: operation.grant.sessionKey.sessionId, + operationId: operation.operationId, + toolName: operation.toolName, + input: operation.input, + revision: operation.revision, + extensionId: operation.extensionId, + }); + const stored = await this.read(filename); + if ( + stored && + (stored.operationId !== operation.operationId || + stored.fingerprint !== fingerprint) + ) + throw new Error('Original extension command identity conflicts.'); + if (stored?.state === 'settled') + return { + operationId: operation.operationId, + state: 'settled', + result: stored.result!, + }; + const active = this.running.get(key); + if (operation.action === 'cancel') { + if (active) { + active.controller.abort(); + if (active.reference.argsDigest) + await this.dependencies.cancel(active.reference); + } + return { + operationId: operation.operationId, + state: active ? 'running' : 'outcome_unknown', + }; + } + if (stored || operation.action === 'status') + return { + operationId: operation.operationId, + state: active ? 'running' : 'outcome_unknown', + }; + await fs.mkdir(this.dependencies.directory, { + recursive: true, + mode: 0o700, + }); + await atomicWriteJSON( + filename, + { operationId: operation.operationId, fingerprint, state: 'running' }, + { mode: 0o600, forceMode: true, noFollow: true }, + ); + const reference = { + sessionId: runtimeSessionId, + promptId: `extension-command:${operation.operationId}`, + callId: operation.operationId, + argsDigest: '', + }; + const controller = new AbortController(); + const work = Promise.resolve() + .then(async () => { + let started = false; + try { + const input = await this.dependencies.prepareInput( + runtimeSessionId, + operation, + ); + controller.signal.throwIfAborted(); + reference.argsDigest = createHash('sha256') + .update(JSON.stringify(input)) + .digest('hex'); + started = true; + const result = await this.dependencies.execute( + reference, + operation.toolName === 'shell' ? 'run_shell_command' : 'read_file', + input, + ); + // Command expansion has the same bounded inline result contract as its persisted resource. + const bounded: ManagedToolResultPayload = + Buffer.byteLength(JSON.stringify(result)) <= 48 * 1024 + ? result + : { + executionStatus: 'error', + responseParts: [ + { + text: 'Command preprocessing output exceeds the inline limit.', + }, + ], + }; + await atomicWriteJSON( + filename, + { + operationId: operation.operationId, + fingerprint, + state: 'settled', + result: bounded, + }, + { mode: 0o600, forceMode: true, noFollow: true }, + ); + } catch (error) { + if (started || error instanceof ManagedMcpToolUnknownError) return; + await atomicWriteJSON( + filename, + { + operationId: operation.operationId, + fingerprint, + state: 'settled', + result: { + executionStatus: 'not_started', + responseParts: [ + { text: 'Extension command admission failed.' }, + ], + }, + }, + { mode: 0o600, forceMode: true, noFollow: true }, + ); + } + }) + .finally(() => this.running.delete(key)); + this.running.set(key, { reference, controller, done: work }); + void work.catch(() => undefined); + return { operationId: operation.operationId, state: 'running' }; + } + + private parse( + runtimeSessionId: string, + value: unknown, + ): ManagedExtensionCommand { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid command control.'); + const op = value as Record; + if ( + Object.keys(op).sort().join(',') !== + 'action,extensionId,grant,input,kind,operationId,revision,toolName' || + op['kind'] !== 'extension-command' || + !['execute', 'status', 'cancel'].includes(String(op['action'])) || + typeof op['operationId'] !== 'string' || + !/^command-step-[a-f0-9]{64}$/u.test(op['operationId']) || + !['read_file', 'shell'].includes(String(op['toolName'])) || + !op['input'] || + typeof op['input'] !== 'object' || + Array.isArray(op['input']) || + !Number.isSafeInteger(op['revision']) || + Number(op['revision']) < 1 || + typeof op['extensionId'] !== 'string' || + !/^ext_[a-f0-9-]{36}$/u.test(op['extensionId']) + ) + throw new Error('Invalid command control.'); + const grant = parseOperationGrant(op['grant']); + if ( + grant.domain !== 'tool_stage' || + grant.operationId !== op['operationId'] || + grant.ownerId !== runtimeSessionId || + grant.sessionKey.tenantId !== this.dependencies.tenantId || + grant.sessionKey.workspaceId !== this.dependencies.workspaceId || + grant.workspaceGeneration !== this.dependencies.workspaceGeneration + ) + throw new Error('Command owner conflicts.'); + this.gates.install(grant); + if ( + !this.gates.admits( + grant.sessionKey, + grant.operationId, + String(op['action']), + Date.now(), + ) + ) + throw new Error('Command grant does not admit this action.'); + return { ...op, grant } as unknown as ManagedExtensionCommand; + } + + private async read(filename: string): Promise { + try { + const stat = await fs.lstat(filename); + if (!stat.isFile() || stat.size > 64 * 1024) + throw new Error('Invalid command receipt.'); + const value = JSON.parse(await fs.readFile(filename, 'utf8')) as Receipt; + if ( + !['running', 'settled'].includes(value.state) || + typeof value.fingerprint !== 'string' || + (value.state === 'settled' && + (!value.result || !Array.isArray(value.result.responseParts))) + ) + throw new Error('Invalid command receipt.'); + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + } +} + +export function registerManagedExtensionCommandRoute( + app: Application, + identity: ManagedRuntimeRequestIdentity & { runtimeIncarnation: string }, + runtime: ManagedExtensionCommandRuntime, +): void { + app.post( + MANAGED_EXTENSION_COMMAND_ROUTE.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + nameManagedRuntimeIncarnation(identity), + managedRuntimeJsonBody(64 * 1024), + async (req, res) => { + try { + const body = req.body as Record; + if ( + !body || + Object.keys(body).sort().join(',') !== + 'operation,protocolVersion,runtimeSessionId' || + body['protocolVersion'] !== 1 || + typeof body['runtimeSessionId'] !== 'string' + ) + throw new Error('Invalid command envelope.'); + const result = await runtime.control( + body['runtimeSessionId'], + body['operation'], + ); + res.json({ + protocolVersion: 1, + runtimeSessionId: body['runtimeSessionId'], + result, + }); + } catch { + res.status(409).json({ code: 'managed_extension_command_refused' }); + } + }, + ); +} + +function digest(value: unknown): string { + const canonical = (item: unknown): unknown => + Array.isArray(item) + ? item.map(canonical) + : item && typeof item === 'object' + ? Object.fromEntries( + Object.entries(item) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, canonical(child)]), + ) + : item; + return createHash('sha256') + .update(JSON.stringify(canonical(value))) + .digest('hex'); +} diff --git a/packages/cli/src/serve/managed-extension-environment.test.ts b/packages/cli/src/serve/managed-extension-environment.test.ts new file mode 100644 index 00000000000..7ea17aa57fe --- /dev/null +++ b/packages/cli/src/serve/managed-extension-environment.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { expect, it } from 'vitest'; +import { managedExtensionEnvironment } from './managed-extension-environment.js'; + +it('inherits only execution essentials and the selected extension settings', () => { + const inherited = { + PATH: '/bin', + HOME: '/private/operator', + LANG: 'en_US.UTF-8', + QWEN_MANAGED_TOKEN: 'runtime-token', + OTHER_EXTENSION_SECRET: 'other-secret', + NODE_OPTIONS: '--require /private/preload.js', + }; + expect( + managedExtensionEnvironment(inherited, '/workspace/session', { + SELECTED_SECRET: 'selected', + QWEN_EXTENSION_ROOT: '/private/package', + }), + ).toEqual({ + PATH: '/bin', + LANG: 'en_US.UTF-8', + HOME: '/workspace/session', + USERPROFILE: '/workspace/session', + SELECTED_SECRET: 'selected', + QWEN_EXTENSION_ROOT: '/private/package', + }); + expect(inherited.HOME).toBe('/private/operator'); +}); diff --git a/packages/cli/src/serve/managed-extension-environment.ts b/packages/cli/src/serve/managed-extension-environment.ts new file mode 100644 index 00000000000..211054a7714 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-environment.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function managedExtensionEnvironment( + inherited: NodeJS.ProcessEnv, + directory: string, + extension: Readonly> = {}, +): Record { + const environment: Record = {}; + for (const key of [ + 'PATH', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TMPDIR', + 'TMP', + 'TEMP', + ]) { + const value = inherited[key]; + if (value !== undefined) environment[key] = value; + } + return { + ...environment, + HOME: directory, + USERPROFILE: directory, + ...extension, + }; +} diff --git a/packages/cli/src/serve/managed-extension-error.ts b/packages/cli/src/serve/managed-extension-error.ts new file mode 100644 index 00000000000..2df4b988b35 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-error.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export class ManagedExtensionExecutionUnknownError extends Error {} diff --git a/packages/cli/src/serve/managed-extension-hook.ts b/packages/cli/src/serve/managed-extension-hook.ts new file mode 100644 index 00000000000..bfd2a6daba1 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-hook.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HookRunner } from '@qwen-code/qwen-code-core/hooks/hookRunner.js'; +import { resolveCommandHookTimeoutMs } from '@qwen-code/qwen-code-core/hooks/hook-timeout.js'; +import type { + CommandHookConfig, + HookExecutionResult, + HookInput, + HookEventName, +} from '@qwen-code/qwen-code-core/hooks/types.js'; +import type { ManagedExtensionProcessOwners } from './managed-extension-process.js'; + +export async function executeManagedExtensionCommandHook( + runner: HookRunner, + owners: ManagedExtensionProcessOwners, + sessionId: string, + configRevision: number, + config: CommandHookConfig, + input: HookInput, + signal: AbortSignal, + environment: NodeJS.ProcessEnv, +): Promise { + const startedAt = Date.now(); + signal.throwIfAborted(); + const { shellConfig, command, env } = runner.commandInvocation( + config, + input, + environment, + ); + const { child, ownerId } = await owners.start({ + sessionId, + configRevision, + executable: shellConfig.executable, + args: [...shellConfig.argsPrefix, command], + env, + cwd: input.cwd, + }); + let stdout = ''; + let stderr = ''; + let overflow = false; + const capture = (chunk: Buffer, stream: 'stdout' | 'stderr') => { + if (stream === 'stdout') stdout += chunk.toString(); + else stderr += chunk.toString(); + if ( + Buffer.byteLength(stdout) > 1024 * 1024 || + Buffer.byteLength(stderr) > 1024 * 1024 + ) { + overflow = true; + stdout = stdout.slice(0, 1024 * 1024); + stderr = stderr.slice(0, 1024 * 1024); + void owners.stop(ownerId); + } + }; + child.stdout?.on('data', (chunk: Buffer) => capture(chunk, 'stdout')); + child.stderr?.on('data', (chunk: Buffer) => capture(chunk, 'stderr')); + child.stdin?.on('error', () => undefined); + child.stdin?.end(JSON.stringify(input)); + let timer: ReturnType | undefined; + let cancel: (() => void) | undefined; + try { + const exit = new Promise<'exited'>((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) + resolve('exited'); + else child.once('exit', () => resolve('exited')); + }); + const cancelled = new Promise<'cancelled'>((resolve) => { + cancel = () => resolve('cancelled'); + signal.addEventListener('abort', cancel, { once: true }); + if (signal.aborted) cancel(); + }); + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout( + () => resolve('timeout'), + resolveCommandHookTimeoutMs( + config.timeout, + config.name ?? config.command, + ), + ); + }); + const outcome = await Promise.race([exit, cancelled, timeout]); + const receipt = await owners.stop(ownerId); + const result: HookExecutionResult = + outcome === 'exited' + ? runner.commandResult( + config, + input.hook_event_name as HookEventName, + stdout, + stderr, + child.exitCode, + Date.now() - startedAt, + ) + : ({ + hookConfig: config, + eventName: input.hook_event_name as HookEventName, + success: false, + outcome, + duration: Date.now() - startedAt, + stdout, + stderr, + } satisfies HookExecutionResult); + if (overflow) { + result.success = false; + result.outcome = 'non_blocking_error'; + result.error = new Error('Hook output exceeds its size limit.'); + } + return { + ...result, + processReceipt: receipt, + ...(receipt.scope === 'process_tree' + ? { processTreeDrained: receipt.state === 'stopped' } + : {}), + }; + } finally { + if (timer) clearTimeout(timer); + if (cancel) signal.removeEventListener('abort', cancel); + } +} diff --git a/packages/cli/src/serve/managed-extension-manifests.test.ts b/packages/cli/src/serve/managed-extension-manifests.test.ts new file mode 100644 index 00000000000..7f81b1db167 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-manifests.test.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, it, vi } from 'vitest'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import type { OperationGrant } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js'; +import { HookEventName } from '@qwen-code/qwen-code-core/hooks/types.js'; +import { ManagedMcpRuntime } from './managed-mcp-runtime.js'; +import { ManagedHookRuntime } from './managed-hook-runtime.js'; +import { stageManagedExtensionManifests } from './managed-extension-manifests.js'; +import { ManagedExtensionProcessOwners } from './managed-extension-process.js'; +import { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; + +it('keeps private settings out of public MCP and Hook digest inputs', () => { + const workspace = { + tenantId: 'tenant', + workspaceId: 'workspace', + workspaceGeneration: '1', + }; + const contributions = { + skills: [], + commands: [], + agents: [], + context: [], + settings: [{ name: 'Token', envVar: 'TOKEN', sensitive: true }], + mcpServers: { + fixture: { command: process.execPath }, + }, + hooks: { + PreToolUse: [ + { hooks: [{ type: 'command', command: 'printf "%s" "$TOKEN"' }] }, + ], + }, + }; + const pin = { + extensionId: 'one', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + settingsRevision: 1, + }; + const build = (secret: string, settingsRevision = 1) => + stageManagedExtensionManifests({ + ...workspace, + runtimeSessionId: 'root', + command: { + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision: 0, + extensions: [], + }, + packages: [ + { + pin: { ...pin, settingsRevision }, + assetRoot: '/private/package', + contributions, + }, + ], + baseMcpServers: [], + baseHookCatalog: null, + mcp: new ManagedMcpRuntime(workspace, async () => undefined, { + version: 1, + servers: [], + }), + hooks: new ManagedHookRuntime(workspace, async () => undefined, { + version: 1, + catalogs: [], + }), + hydrated: () => ({ + environment: { TOKEN: secret, UNDECLARED: 'must-not-leak' }, + contributions, + }), + }); + const first = build('fixture-secret-a'); + const changedPrivateInput = build('fixture-secret-b'); + expect(first.mcpServers).toEqual(changedPrivateInput.mcpServers); + expect(first.hookCatalog).toEqual(changedPrivateInput.hookCatalog); + expect(first.mcpManifest.servers[0]?.env).toEqual({ + TOKEN: 'fixture-secret-a', + }); + expect(first.hookManifest.catalogs[0]?.hooks[0]?.config).toMatchObject({ + env: { TOKEN: 'fixture-secret-a' }, + }); + expect(JSON.stringify(contributions)).not.toContain('fixture-secret'); + expect(JSON.stringify(first)).not.toContain('must-not-leak'); + const newRevision = build('fixture-secret-b', 2); + expect(newRevision.mcpServers[0]?.definitionDigest).not.toEqual( + first.mcpServers[0]?.definitionDigest, + ); + expect(newRevision.hookCatalog?.definitionDigest).not.toEqual( + first.hookCatalog?.definitionDigest, + ); +}); + +it('keeps unchanged package MCP revisions and restores their exact pins after worker reconstruction', () => { + const workspace = { + tenantId: 'tenant', + workspaceId: 'workspace', + workspaceGeneration: '1', + }; + const mcp = new ManagedMcpRuntime(workspace, async () => undefined, { + version: 1, + servers: [], + }); + const hooks = new ManagedHookRuntime(workspace, async () => undefined, { + version: 1, + catalogs: [], + }); + const contributions = { + skills: [], + commands: [], + agents: [], + context: [], + settings: [], + hooks: {}, + mcpServers: { fixture: { command: process.execPath } }, + }; + const input = { + ...workspace, + runtimeSessionId: 'root', + command: { + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision: 6, + extensions: [], + }, + packages: [ + { + pin: { + extensionId: 'one', + packageRevision: 1, + packageDigest: 'b'.repeat(64), + }, + assetRoot: '/private/package', + contributions, + }, + ], + baseMcpServers: [], + baseHookCatalog: null, + mcp, + hooks, + hydrated: () => ({ contributions, environment: {} }), + }; + const initial = stageManagedExtensionManifests(input); + mcp.installSessionDefinitions('root', initial.mcpManifest); + const updated = stageManagedExtensionManifests({ + ...input, + command: { ...input.command, expectedRevision: 7 }, + }); + expect(updated.mcpServers).toEqual(initial.mcpServers); + const unrelated = stageManagedExtensionManifests({ + ...input, + runtimeSessionId: 'other-root', + command: { ...input.command, expectedRevision: 7 }, + }); + expect(unrelated.mcpServers[0]?.serverRevision).toBe(8); + const reconstructed = new ManagedMcpRuntime( + workspace, + async () => undefined, + { version: 1, servers: [] }, + ); + const restored = stageManagedExtensionManifests({ + ...input, + command: { ...input.command, expectedRevision: 7 }, + mcp: reconstructed, + preparedMcpServers: updated.mcpServers, + }); + expect(restored.mcpServers).toEqual(initial.mcpServers); + const conflicting = { + ...initial.mcpManifest.servers[0]!, + serverRevision: 1, + definitionDigest: 'c'.repeat(64), + }; + const conflictingRuntime = new ManagedMcpRuntime( + workspace, + async () => undefined, + { version: 1, servers: [conflicting] }, + ); + expect(() => + stageManagedExtensionManifests({ + ...input, + mcp: conflictingRuntime, + baseMcpServers: [ + { + serverId: conflicting.serverId, + serverRevision: 1, + definitionDigest: conflicting.definitionDigest, + }, + ], + }), + ).toThrow('duplicate'); +}); + +it.skipIf(process.platform !== 'darwin')( + 'delivers only each package declared settings to real MCP and Hook subprocesses without recipe env mappings', + async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-env-'), + ); + const key = { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: 'root', + }; + const workspace = { ...key, workspaceGeneration: '1' }; + const ledger = ManagedRuntimeLedger.create({ + workFile: path.join(directory, 'ledger.json'), + worker: { + pid: process.pid, + pgid: process.pid, + incarnation: 'settings-test', + startedAt: Date.now(), + groupOwned: false, + }, + }); + const owners = new ManagedExtensionProcessOwners( + 'developer-process-group', + { + ledger, + directory: path.join(directory, 'owners'), + }, + ); + const mcp = new ManagedMcpRuntime( + workspace, + async () => directory, + { + version: 1, + servers: [], + }, + owners, + ); + const hooks = new ManagedHookRuntime( + workspace, + async () => directory, + { + version: 1, + catalogs: [], + }, + owners, + ); + const probe = + '{TOKEN:process.env.TOKEN,ONLY_ONE:process.env.ONLY_ONE??null,ONLY_TWO:process.env.ONLY_TWO??null,UNDECLARED:process.env.UNDECLARED??null}'; + const server = ` +const {createInterface}=require("node:readline"); +createInterface({input:process.stdin}).on("line",line=>{ + const req=JSON.parse(line); + if(req.id===undefined)return; + const result=req.method==="initialize" + ?{protocolVersion:"2024-11-05",capabilities:{tools:{}},serverInfo:{name:"settings",version:"1"}} + :{tools:[{name:"probe",description:JSON.stringify(${probe}),inputSchema:{type:"object"}}]}; + process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:req.id,result})+"\\n"); +});`; + const packages = ['one', 'two'].map((name, index) => { + const contributions: ManagedPackageContributions = { + skills: [], + commands: [], + agents: [], + context: [], + settings: [ + { name: 'Token', envVar: 'TOKEN', sensitive: true }, + { name: 'Own value', envVar: `ONLY_${name.toUpperCase()}` }, + ], + mcpServers: { + probe: { command: process.execPath, args: ['-e', server] }, + }, + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: `${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({systemMessage:JSON.stringify(${probe})}))'`, + }, + ], + }, + ], + }, + }; + return { + pin: { + extensionId: name, + packageRevision: 1, + packageDigest: String(index + 1).repeat(64), + settingsRevision: 1, + }, + assetRoot: path.join(directory, name), + contributions, + }; + }); + const staged = stageManagedExtensionManifests({ + ...workspace, + runtimeSessionId: key.sessionId, + command: { + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision: 0, + extensions: packages.map((entry) => entry.pin), + }, + packages, + baseMcpServers: [], + baseHookCatalog: null, + mcp, + hooks, + hydrated: (root) => ({ + contributions: packages.find((entry) => entry.assetRoot === root)! + .contributions, + environment: { + TOKEN: `${path.basename(root)}-secret`, + ONLY_ONE: 'one-only', + ONLY_TWO: 'two-only', + UNDECLARED: 'must-not-leak', + }, + }), + }); + const grant = ( + operationId: string, + domain: OperationGrant['domain'], + phases: string[], + ): OperationGrant => ({ + sessionKey: key, + operationId, + domain, + operationRevision: 1, + ownerId: 'owner', + workspaceGeneration: '1', + resourceScope: { + recordRef: { + resourceId: `record:${operationId}`, + kind: `managed-${domain}`, + schemaVersion: 1, + byteLength: 1, + digest: 'b'.repeat(64), + }, + phases, + }, + leaseDurationMs: 60_000, + expiresAt: Date.now() + 60_000, + }); + try { + expect( + JSON.stringify({ + packages, + mcpServers: staged.mcpServers, + hookCatalog: staged.hookCatalog, + }), + ).not.toContain('-secret'); + mcp.installSessionDefinitions(key.sessionId, staged.mcpManifest); + hooks.installSessionManifest(key.sessionId, staged.hookManifest); + for (const [index, pkg] of packages.entries()) { + const expected = { + TOKEN: `${pkg.pin.extensionId}-secret`, + ONLY_ONE: index === 0 ? 'one-only' : null, + ONLY_TWO: index === 1 ? 'two-only' : null, + UNDECLARED: null, + }; + const serverPin = staged.mcpServers[index]!; + const configureId = `configure-${index}`; + await mcp.control('owner', { + kind: 'mcp-configure', + sessionKey: key, + operationId: configureId, + ...serverPin, + configRevision: 1, + grant: grant(configureId, 'mcp_configuration', [ + 'configure', + 'discover', + 'release', + ]), + }); + await vi.waitFor( + async () => { + const result = await mcp.control('owner', { + kind: 'mcp-status', + sessionKey: key, + operationId: `query-${index}`, + targetOperationId: configureId, + }); + expect(result.state).toBe('settled'); + expect(result.error).toBeUndefined(); + expect(JSON.parse(result.catalog!.tools[0]!.description!)).toEqual( + expected, + ); + }, + { timeout: 10_000 }, + ); + const executeId = `hook-${index}`; + await hooks.control('owner', { + kind: 'hook-execute', + sessionKey: key, + operationId: executeId, + pin: staged.hookCatalog!, + hookId: staged.hookManifest.catalogs[0]!.hooks[index]!.hookId, + input: { + session_id: key.sessionId, + cwd: directory, + transcript_path: '', + hook_event_name: HookEventName.PreToolUse, + timestamp: new Date().toISOString(), + }, + grant: grant(executeId, 'hook_execution', ['execute']), + }); + await vi.waitFor( + async () => { + const result = await hooks.control('owner', { + kind: 'hook-status', + sessionKey: key, + operationId: `hook-query-${index}`, + targetOperationId: executeId, + }); + expect(result.state).toBe('settled'); + expect(result.error).toBeUndefined(); + expect(result.result?.success).toBe(true); + expect(JSON.parse(result.result!.output!.systemMessage!)).toEqual( + expected, + ); + }, + { timeout: 10_000 }, + ); + } + } finally { + await Promise.all([mcp.close(), hooks.close()]); + await owners.stopAll(); + await fs.rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/packages/cli/src/serve/managed-extension-manifests.ts b/packages/cli/src/serve/managed-extension-manifests.ts new file mode 100644 index 00000000000..138eb91613d --- /dev/null +++ b/packages/cli/src/serve/managed-extension-manifests.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { + HookEventName, + HooksConfigSource, +} from '@qwen-code/qwen-code-core/hooks/types.js'; +import type { + ManagedExtensionApplyCommand, + ManagedExtensionMcpPin, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import type { ManagedHookCatalogPin } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js'; +import { + ManagedHookRuntime, + type ManagedHookDefinition, + type ManagedHookManifest, +} from './managed-hook-runtime.js'; +import { + ManagedMcpRuntime, + type ManagedMcpDefinition, + type ManagedMcpManifest, +} from './managed-mcp-runtime.js'; +import type { ManagedRuntimeExtensionPackage } from './managed-extension-runtime.js'; + +function digest(value: unknown) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid extension recipe.'); + return value as Record; +} + +export function stageManagedExtensionManifests(input: { + tenantId: string; + workspaceId: string; + runtimeSessionId: string; + command: ManagedExtensionApplyCommand; + packages: readonly ManagedRuntimeExtensionPackage[]; + baseMcpServers: readonly ManagedExtensionMcpPin[]; + preparedMcpServers?: readonly ManagedExtensionMcpPin[]; + baseHookCatalog: ManagedHookCatalogPin | null; + mcp: ManagedMcpRuntime; + hooks: ManagedHookRuntime; + hydrated: (assetRoot: string) => { + contributions: ManagedPackageContributions; + environment: Readonly>; + }; +}) { + const servers = [...input.mcp.resolvePinnedDefinitions(input.baseMcpServers)]; + const hooks = input.baseHookCatalog + ? [...input.hooks.resolvePinnedCatalog(input.baseHookCatalog).hooks] + : []; + const revision = input.command.expectedRevision + 1; + for (const pkg of input.packages) { + const { contributions, environment } = input.hydrated(pkg.assetRoot); + const settings = Object.fromEntries( + pkg.contributions.settings + .filter((setting) => Object.hasOwn(environment, setting.envVar)) + .map((setting) => [setting.envVar, environment[setting.envVar]!]), + ); + for (const [name, raw] of Object.entries(contributions.mcpServers)) { + const recipe = object(raw); + if ( + Object.keys(recipe).some( + (key) => + ![ + 'command', + 'args', + 'env', + 'timeout', + 'url', + 'httpUrl', + 'headers', + 'type', + ].includes(key), + ) + ) + throw new Error('Unsupported extension MCP recipe field.'); + const transport = + recipe['command'] !== undefined + ? 'stdio' + : recipe['type'] === 'sse' + ? 'sse' + : 'streamable-http'; + const definition: ManagedMcpDefinition = { + tenantId: input.tenantId, + workspaceId: input.workspaceId, + serverId: `${pkg.pin.extensionId}:${name}`, + serverRevision: revision, + definitionDigest: digest({ + pin: pkg.pin, + recipe: pkg.contributions.mcpServers[name], + }), + transport, + ...(transport === 'stdio' + ? { + command: recipe['command'] as string, + args: recipe['args'] as string[] | undefined, + env: { + ...settings, + ...(recipe['env'] === undefined + ? {} + : (object(recipe['env']) as Record)), + }, + } + : { + url: (recipe['httpUrl'] ?? recipe['url']) as string, + headers: recipe['headers'] as Record | undefined, + }), + ...(recipe['timeout'] === undefined + ? {} + : { timeoutMs: Number(recipe['timeout']) }), + }; + const unchanged = input.mcp.matchingSessionDefinition( + input.runtimeSessionId, + definition.serverId, + definition.definitionDigest, + ); + const restored = input.preparedMcpServers?.find( + (pin) => + pin.serverId === definition.serverId && + pin.definitionDigest === definition.definitionDigest, + ); + servers.push( + restored + ? { ...definition, serverRevision: restored.serverRevision } + : unchanged + ? { ...definition, serverRevision: unchanged.serverRevision } + : definition, + ); + } + for (const [event, rawGroups] of Object.entries(contributions.hooks)) { + if ( + !Object.values(HookEventName).includes(event as HookEventName) || + !Array.isArray(rawGroups) + ) + throw new Error('Unsupported extension hook event.'); + for (const [groupIndex, rawGroup] of rawGroups.entries()) { + const group = object(rawGroup); + if ( + Object.keys(group).some( + (key) => !['hooks', 'matcher', 'sequential'].includes(key), + ) + ) + throw new Error('Unsupported extension Hook group field.'); + if (!Array.isArray(group['hooks'])) + throw new Error('Invalid extension Hook group.'); + for (const [index, rawHook] of group['hooks'].entries()) { + const config = object(rawHook); + if (!['command', 'http', 'prompt'].includes(String(config['type']))) + throw new Error('Unsupported extension Hook type.'); + hooks.push({ + hookId: `${pkg.pin.extensionId}:${event}:${groupIndex}:${index}`, + eventName: event as HookEventName, + ...(typeof group['matcher'] === 'string' + ? { matcher: group['matcher'] } + : {}), + sequential: group['sequential'] === true, + onceKey: + config['once'] === true + ? `${pkg.pin.extensionId}:${event}:${groupIndex}:${index}` + : null, + failClosed: true, + async: config['async'] === true, + source: HooksConfigSource.Extensions, + sourceTrusted: true, + enabled: true, + config: { + ...Object.fromEntries( + Object.entries(config).filter( + ([key]) => !['once', 'async'].includes(key), + ), + ), + ...(config['type'] === 'command' + ? { + env: { + ...settings, + ...(config['env'] === undefined + ? {} + : object(config['env'])), + }, + } + : {}), + } as unknown as ManagedHookDefinition['config'], + }); + } + } + } + } + if ( + servers.length > 32 || + new Set(servers.map((server) => server.serverId)).size !== servers.length + ) + throw new Error( + 'Extension MCP selection has duplicate identities or exceeds its limit.', + ); + const mcpManifest: ManagedMcpManifest = { version: 1, servers }; + const hookCatalog = hooks.length + ? { + catalogId: `extensions:${digest(input.runtimeSessionId).slice(0, 32)}`, + catalogRevision: revision, + definitionDigest: digest({ + base: input.baseHookCatalog, + packages: input.packages + .filter((pkg) => Object.keys(pkg.contributions.hooks).length > 0) + .map((pkg) => ({ pin: pkg.pin, hooks: pkg.contributions.hooks })), + }), + } + : null; + const hookManifest: ManagedHookManifest = { + version: 1, + catalogs: hookCatalog + ? [ + { + ...hookCatalog, + tenantId: input.tenantId, + workspaceId: input.workspaceId, + hooks, + }, + ] + : [], + }; + // Constructors validate every recipe during staging, without opening a + // transport or dispatching a Hook. + const workspace = { + tenantId: input.tenantId, + workspaceId: input.workspaceId, + workspaceGeneration: '1', + }; + new ManagedMcpRuntime(workspace, async () => undefined, mcpManifest); + new ManagedHookRuntime(workspace, async () => undefined, hookManifest); + return { + mcpManifest, + hookManifest, + mcpServers: servers.map( + ({ serverId, serverRevision, definitionDigest }) => ({ + serverId, + serverRevision, + definitionDigest, + }), + ), + hookCatalog, + }; +} diff --git a/packages/cli/src/serve/managed-extension-preflight.test.ts b/packages/cli/src/serve/managed-extension-preflight.test.ts new file mode 100644 index 00000000000..17d93e3a3ec --- /dev/null +++ b/packages/cli/src/serve/managed-extension-preflight.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, it } from 'vitest'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { preflightManagedExtensionPackage } from './managed-extension-preflight.js'; + +const empty: ManagedPackageContributions = { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], +}; +it('checks target executables and package scripts without executing code, while reporting dynamic dependency boundaries', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'extension-preflight-')); + try { + const script = path.join(root, 'probe.js'); + await fs.writeFile(script, 'throw new Error("must not execute")'); + const contribution = { + ...empty, + mcpServers: { test: { command: process.execPath, args: [script] } }, + }; + await expect( + preflightManagedExtensionPackage(contribution, root, root, { + PATH: '/usr/bin:/bin', + }), + ).resolves.toEqual({ dynamicCommands: 0 }); + await fs.rm(script); + await expect( + preflightManagedExtensionPackage(contribution, root, root, {}), + ).rejects.toThrow(); + await expect( + preflightManagedExtensionPackage( + { + ...empty, + mcpServers: { bad: { command: 'missing-extension-interpreter-123' } }, + }, + root, + root, + { PATH: root }, + ), + ).rejects.toThrow('unavailable'); + await expect( + preflightManagedExtensionPackage( + { + ...empty, + hooks: { + PreToolUse: [ + { hooks: [{ type: 'command', command: 'echo dynamic | cat' }] }, + ], + }, + }, + root, + root, + { PATH: '/usr/bin:/bin' }, + ), + ).resolves.toEqual({ dynamicCommands: 1 }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/serve/managed-extension-preflight.ts b/packages/cli/src/serve/managed-extension-preflight.ts new file mode 100644 index 00000000000..f163b4a89b9 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-preflight.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { constants, promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; + +async function executable( + command: string, + cwd: string, + environment: Readonly>, +): Promise { + const candidates = command.includes('/') + ? [path.resolve(cwd, command)] + : (environment['PATH'] ?? '/usr/bin:/bin') + .split(path.delimiter) + .map((directory) => path.resolve(cwd, directory, command)); + for (const candidate of candidates) { + try { + if (!(await fs.stat(candidate)).isFile()) continue; + await fs.access(candidate, constants.X_OK); + return candidate; + } catch { + /* Try the next PATH entry. */ + } + } + throw new Error(`Extension executable is unavailable: ${command}`); +} + +export async function preflightManagedExtensionPackage( + contributions: ManagedPackageContributions, + root: string, + cwd: string, + environment: Readonly>, +): Promise<{ dynamicCommands: number }> { + const env: Record = { + ...environment, + QWEN_EXTENSION_ROOT: root, + CLAUDE_PLUGIN_ROOT: root, + extensionPath: root, + }; + const expand = (value: string, executionEnvironment = env) => + value.replace( + /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/gu, + (original, braced: string, plain: string) => + executionEnvironment[braced ?? plain] ?? original, + ); + let dynamicCommands = 0; + const check = async ( + command: string, + args: readonly string[], + executionEnvironment = env, + ) => { + const resolved = await executable( + expand(command, executionEnvironment), + cwd, + executionEnvironment, + ); + const handle = await fs.open(resolved, 'r'); + let head: string; + try { + const buffer = Buffer.alloc(512); + const read = await handle.read(buffer, 0, 512, 0); + head = buffer.subarray(0, read.bytesRead).toString(); + } finally { + await handle.close(); + } + if (head.startsWith('#!')) { + const shebang = head.split('\n')[0]!.slice(2).trim().split(/\s+/u); + await executable(shebang[0]!, cwd, executionEnvironment); + if ( + path.basename(shebang[0]!) === 'env' && + shebang[1] && + !shebang[1].startsWith('-') + ) + await executable(shebang[1], cwd, executionEnvironment); + } + if (path.basename(resolved) === 'env' && args.length) { + if (args[0]!.startsWith('-') || args[0]!.includes('=')) { + dynamicCommands++; + return; + } + await check(args[0]!, args.slice(1), executionEnvironment); + return; + } + if (args.includes('-e') || args.includes('--eval') || args.includes('-c')) { + dynamicCommands++; + return; + } + for (const argument of args.filter( + (value) => + !value.startsWith('-') && /\.(?:[cm]?js|py|rb|sh)$/u.test(value), + )) { + const script = path.resolve(cwd, expand(argument, executionEnvironment)); + if (!(await fs.stat(script)).isFile()) + throw new Error('Extension script is unavailable.'); + await fs.access(script, constants.R_OK); + } + }; + const shell = async (command: string, executionEnvironment = env) => { + // Operators, substitutions and conditional commands need runtime resolution. + if (/[|;&><`\n]|\$\(/u.test(command)) { + dynamicCommands++; + return; + } + const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|([^\s]+)/gu)].map( + (match) => match[1] ?? match[2] ?? match[3]!, + ); + if (!tokens.length) return; + if ( + tokens.some( + (token) => + token.includes('$') && expand(token, executionEnvironment) === token, + ) + ) { + dynamicCommands++; + return; + } + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(tokens[0]!)) { + dynamicCommands++; + return; + } + if ( + [ + ':', + '[', + 'alias', + 'bg', + 'break', + 'cd', + 'continue', + 'exit', + 'export', + 'false', + 'fg', + 'getopts', + 'hash', + 'jobs', + 'pwd', + 'read', + 'readonly', + 'return', + 'set', + 'shift', + 'test', + 'times', + 'trap', + 'true', + 'type', + 'ulimit', + 'umask', + 'unalias', + 'unset', + 'wait', + ].includes(tokens[0]!) + ) + return; + if (tokens[0] === 'eval') { + dynamicCommands++; + return; + } + if (['exec', 'command'].includes(tokens[0]!)) tokens.shift(); + if (tokens.length) + await check(tokens[0]!, tokens.slice(1), executionEnvironment); + }; + for (const value of Object.values(contributions.mcpServers)) { + const recipe = value as { + command?: string; + args?: string[]; + env?: Record; + }; + if (recipe.command) + await check(recipe.command, recipe.args ?? [], { ...env, ...recipe.env }); + } + for (const groups of Object.values(contributions.hooks)) { + for (const group of groups as Array<{ + hooks?: Array<{ + type?: string; + command?: string; + env?: Record; + }>; + }>) { + for (const hook of group.hooks ?? []) + if (hook.type === 'command' && hook.command) + await shell(hook.command, { ...env, ...hook.env }); + } + } + for (const command of contributions.commands) + for (const match of command.prompt.matchAll(/!\{([^}]*)\}/gu)) + await shell(match[1]!); + return { dynamicCommands }; +} diff --git a/packages/cli/src/serve/managed-extension-process.test.ts b/packages/cli/src/serve/managed-extension-process.test.ts new file mode 100644 index 00000000000..dcc8e3b0ed5 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-process.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; +import { ManagedExtensionProcessOwners } from './managed-extension-process.js'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +describe('ManagedExtensionProcessOwners', () => { + it('does not silently substitute developer mode on unsupported hosts', () => { + if (process.platform !== 'linux') + expect( + () => new ManagedExtensionProcessOwners('linux-cgroup', {}), + ).toThrow('unavailable'); + expect( + () => new ManagedExtensionProcessOwners('developer-process-group', {}), + ).toThrow('unavailable'); + }); + + it.skipIf(process.platform !== 'darwin')( + 'stops changed Session owners while preserving retained MCP owners and sibling Sessions', + async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-retained-'), + ); + directories.push(directory); + const ledger = ManagedRuntimeLedger.create({ + workFile: path.join(directory, 'ledger.json'), + worker: { + pid: process.pid, + pgid: process.pid, + incarnation: 'test', + startedAt: Date.now(), + groupOwned: false, + }, + }); + const owners = new ManagedExtensionProcessOwners( + 'developer-process-group', + { ledger, directory: path.join(directory, 'owners') }, + ); + const start = (sessionId: string) => + owners.start({ + sessionId, + configRevision: 1, + executable: process.execPath, + args: ['-e', 'setInterval(()=>{},1000)'], + env: {}, + cwd: directory, + }); + try { + const retained = await start('a'); + const changed = await start('a'); + const sibling = await start('b'); + const receipts = await owners.stopSession( + 'a', + new Set([retained.ownerId]), + ); + expect(receipts).toEqual([ + expect.objectContaining({ + ownerId: changed.ownerId, + state: 'stopped', + }), + ]); + expect(receipts.some((receipt) => receipt.coverage !== undefined)).toBe( + false, + ); + expect(await owners.hasLiveSessionOwners('a')).toBe(true); + expect( + await owners.hasLiveSessionOwners('a', new Set([retained.ownerId])), + ).toBe(false); + expect(await owners.hasLiveSessionOwners('b')).toBe(true); + expect(retained.child.exitCode).toBeNull(); + expect(sibling.child.exitCode).toBeNull(); + } finally { + await owners.stopAll(); + } + }, + ); + + it.skipIf(process.platform !== 'darwin')( + 'persists zero-owner coverage and rejects a missing indexed owner after restart', + async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-journal-'), + ); + directories.push(directory); + const ledger = ManagedRuntimeLedger.create({ + workFile: path.join(directory, 'ledger.json'), + worker: { + pid: process.pid, + pgid: process.pid, + incarnation: 'test', + startedAt: Date.now(), + groupOwned: false, + }, + }); + const options = { ledger, directory: path.join(directory, 'owners') }; + const original = new ManagedExtensionProcessOwners( + 'developer-process-group', + options, + ); + await original.coverSession('root', 1); + const resumed = new ManagedExtensionProcessOwners( + 'developer-process-group', + options, + ); + expect(await resumed.stopSession('root')).toEqual([ + expect.objectContaining({ + state: 'stopped', + coverage: 'registered-owners', + configRevision: 1, + journalId: original.journalId, + }), + ]); + await fs.writeFile( + path.join(options.directory, '.owners.json'), + JSON.stringify(['extension-aaaa']), + ); + const damaged = new ManagedExtensionProcessOwners( + 'developer-process-group', + options, + ); + await expect(damaged.stopSession('root')).rejects.toThrow('missing'); + }, + ); + + it.skipIf(process.platform !== 'darwin')( + 'records the process group before target code and reports only group scope', + async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-process-'), + ); + directories.push(directory); + const ledger = ManagedRuntimeLedger.create({ + workFile: path.join(directory, 'ledger.json'), + worker: { + pid: process.pid, + pgid: process.pid, + incarnation: 'test', + startedAt: Date.now(), + }, + }); + const owners = new ManagedExtensionProcessOwners( + 'developer-process-group', + { ledger }, + ); + const owner = await owners.start({ + sessionId: 'session-a', + configRevision: 1, + executable: process.execPath, + args: [ + '-e', + 'process.stdout.write("ready\\n");setInterval(()=>{},1000)', + ], + env: {}, + cwd: directory, + }); + try { + expect(ledger.outstandingGroups()).toEqual([ + expect.objectContaining({ + callId: owner.ownerId, + pgid: owner.child.pid, + }), + ]); + const receipt = await owners.stop(owner.ownerId); + expect(receipt).toMatchObject({ + state: 'stopped', + scope: 'process_group', + mode: 'developer-process-group', + sessionId: 'session-a', + }); + expect(await owners.stopSession('session-b')).toEqual([]); + expect(await owners.stop(owner.ownerId)).toEqual(receipt); + } finally { + owner.child.kill('SIGKILL'); + await owners.stop(owner.ownerId); + } + }, + ); +}); diff --git a/packages/cli/src/serve/managed-extension-process.ts b/packages/cli/src/serve/managed-extension-process.ts new file mode 100644 index 00000000000..a3a2c012942 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-process.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ManagedExtensionExecutionUnknownError } from './managed-extension-error.js'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID, createHash } from 'node:crypto'; +import { + promises as fs, + readFileSync, + mkdirSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { atomicWriteJSON } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js'; +import type { Writable } from 'node:stream'; +import { HookCommandCgroup } from '@qwen-code/qwen-code-core/hooks/hook-command-cgroup.js'; +import type { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; + +export type ManagedExtensionProcessMode = + | 'linux-cgroup' + | 'developer-process-group'; + +export interface ManagedExtensionProcessReceipt { + readonly ownerId: string; + readonly sessionId: string; + readonly configRevision: number; + readonly mode: ManagedExtensionProcessMode; + readonly scope: 'process_tree' | 'process_group'; + readonly state: 'stopped' | 'outcome_unknown'; + readonly coverage?: 'registered-owners'; + readonly journalId?: string; +} + +interface Owner { + readonly ownerId: string; + readonly sessionId: string; + readonly configRevision: number; + readonly child?: ChildProcess; + readonly pgid?: number; + readonly startedAt: number; + readonly uptimeMs: number; + readonly unit?: HookCommandCgroup; +} + +// The target command is not even read until its group is durably registered. +const DEVELOPER_LAUNCHER = String.raw` +const fs = require('node:fs'); +const { spawn } = require('node:child_process'); +let message = ''; +const byte = Buffer.alloc(1); +while (fs.readSync(3, byte, 0, 1, null)) { + if (byte[0] === 10) break; + message += byte.toString(); +} +if (!message) process.exit(1); +const input = JSON.parse(message); +const child = spawn(input.executable, input.args, { env: input.env, stdio: 'inherit' }); +child.on('error', () => process.exit(1)); +child.on('exit', code => process.exit(code ?? 1)); +`; + +export class ManagedExtensionProcessOwners { + private readonly owners = new Map(); + private readonly receipts = new Map(); + readonly journalId: string; + private readonly recovering: Promise; + private journalUpdates: Promise = Promise.resolve(); + private readonly journalOwners = new Set(); + private readonly bootIdentity = + process.platform === 'darwin' + ? execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { + encoding: 'utf8', + }).trim() + : process.platform === 'linux' + ? readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + : ''; + private readonly stopping = new Map< + string, + Promise + >(); + + constructor( + readonly mode: ManagedExtensionProcessMode, + private readonly options: { + readonly cgroupRoot?: string; + readonly ledger?: ManagedRuntimeLedger; + readonly directory?: string; + }, + ) { + if ( + (mode === 'linux-cgroup' && process.platform !== 'linux') || + (mode === 'developer-process-group' && + (process.platform !== 'darwin' || !options.ledger)) + ) + throw new Error('Extension process mode is unavailable on this Runtime.'); + if (options.directory) { + mkdirSync(options.directory, { recursive: true, mode: 0o700 }); + const marker = path.join(options.directory, '.journal-id'); + try { + this.journalId = readFileSync(marker, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + this.journalId = randomUUID(); + writeFileSync(marker, this.journalId, { flag: 'wx', mode: 0o600 }); + writeFileSync(path.join(options.directory, '.owners.json'), '[]', { + flag: 'wx', + mode: 0o600, + }); + } + } else this.journalId = randomUUID(); + this.recovering = this.loadOwners(); + void this.recovering.catch(() => undefined); + } + + async start(input: { + readonly sessionId: string; + readonly configRevision: number; + readonly executable: string; + readonly args: readonly string[]; + readonly env: NodeJS.ProcessEnv; + readonly cwd: string; + }): Promise<{ ownerId: string; child: ChildProcess }> { + await this.recovering; + if ( + !input.sessionId || + !Number.isSafeInteger(input.configRevision) || + input.configRevision < 1 + ) + throw new Error('Invalid extension process owner.'); + const ownerId = `extension-${randomUUID()}`; + const startedAt = Date.now(); + const uptimeMs = os.uptime() * 1000; + await this.saveOwner({ + ownerId, + sessionId: input.sessionId, + configRevision: input.configRevision, + startedAt, + uptimeMs, + }); + const unit = + this.mode === 'linux-cgroup' + ? HookCommandCgroup.create(this.options.cgroupRoot, ownerId) + : undefined; + const launch = unit?.launch(input.executable, [...input.args], input.env); + const child = spawn( + launch?.executable ?? process.execPath, + launch?.args ?? ['--input-type=commonjs', '--eval', DEVELOPER_LAUNCHER], + { + cwd: input.cwd, + env: launch?.env ?? { PATH: '/usr/bin:/bin' }, + detached: !unit, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + }, + ); + child.on('error', () => undefined); + child.stdio[3]?.on('error', () => undefined); + const owner = { + ownerId, + sessionId: input.sessionId, + configRevision: input.configRevision, + child, + unit, + startedAt, + uptimeMs, + pgid: child.pid, + }; + this.owners.set(ownerId, owner); + try { + await this.saveOwner(owner); + if (unit) { + let joined = ''; + child.stdio[3]?.on('data', (chunk: Buffer) => { + joined += chunk.toString(); + }); + for ( + let attempt = 0; + attempt < 40 && !joined.includes('joined\n'); + attempt++ + ) { + if ( + joined.includes('unavailable\n') || + child.exitCode !== null || + child.signalCode !== null + ) + throw new Error('Extension process membership is unproven.'); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (!joined.includes('joined\n')) + throw new Error('Extension process membership is unproven.'); + } else { + if (!child.pid) throw new Error('Extension process failed to start.'); + this.options.ledger!.addGroup({ + pgid: child.pid, + callId: ownerId, + startedAt, + }); + (child.stdio[3] as Writable).end( + `${JSON.stringify({ executable: input.executable, args: input.args, env: input.env })}\n`, + ); + } + return { ownerId, child }; + } catch (error) { + // Unknown cleanup keeps the owner: nobody can reinterpret a failed + // startup as permission to discard its process identity. + child.stdio[3]?.destroy(); + child.kill('SIGKILL'); + const receipt = await this.stop(ownerId).catch(() => undefined); + if (receipt?.state !== 'stopped') + throw new ManagedExtensionExecutionUnknownError( + 'Extension process startup cleanup is unknown.', + ); + throw error; + } + } + + async hasLiveSessionOwners( + sessionId: string, + except: ReadonlySet = new Set(), + ): Promise { + await this.recovering; + return [...this.owners.values()].some( + (owner) => + owner.sessionId === sessionId && + !except.has(owner.ownerId) && + this.receipts.get(owner.ownerId)?.state !== 'stopped', + ); + } + + async coverSession(sessionId: string, configRevision: number): Promise { + await this.recovering; + if (!this.options.directory) + throw new Error('Extension owner scope requires a durable journal.'); + const file = path.join( + this.options.directory, + `.scope-${createHash('sha256').update(sessionId).digest('hex')}.json`, + ); + await atomicWriteJSON( + file, + { + sessionId, + configRevision, + journalId: this.journalId, + ownerId: `coverage-${randomUUID()}`, + }, + { mode: 0o600 }, + ); + } + + async stopSession( + sessionId: string, + except: ReadonlySet = new Set(), + ): Promise { + await this.recovering; + const stopped = await Promise.all( + [...this.owners.values()] + .filter( + (owner) => + owner.sessionId === sessionId && !except.has(owner.ownerId), + ) + .map((owner) => this.stop(owner.ownerId)), + ); + if ( + except.size > 0 || + !this.options.directory || + stopped.some((receipt) => receipt.state !== 'stopped') + ) + return stopped; + const coverage = JSON.parse( + await fs.readFile( + path.join( + this.options.directory, + `.scope-${createHash('sha256').update(sessionId).digest('hex')}.json`, + ), + 'utf8', + ), + ) as Record; + if ( + coverage['sessionId'] !== sessionId || + coverage['journalId'] !== this.journalId || + typeof coverage['ownerId'] !== 'string' || + !Number.isSafeInteger(coverage['configRevision']) + ) + throw new Error('Extension owner coverage is unavailable.'); + return [ + ...stopped, + { + ownerId: coverage['ownerId'], + sessionId, + configRevision: Number(coverage['configRevision']), + mode: this.mode, + scope: this.mode === 'linux-cgroup' ? 'process_tree' : 'process_group', + state: 'stopped', + coverage: 'registered-owners', + journalId: this.journalId, + }, + ]; + } + + stop(ownerId: string): Promise { + let pending = this.stopping.get(ownerId); + if (!pending) { + pending = this.stopOwner(ownerId).finally(() => + this.stopping.delete(ownerId), + ); + this.stopping.set(ownerId, pending); + } + return pending; + } + + async stopAll(): Promise { + await this.recovering; + return Promise.all([...this.owners.keys()].map((id) => this.stop(id))); + } + + private async stopOwner( + ownerId: string, + ): Promise { + await this.recovering; + const existing = this.receipts.get(ownerId); + if (existing) return existing; + const owner = this.owners.get(ownerId); + if (!owner) throw new Error('Extension process owner is unknown.'); + let stopped = false; + try { + if (owner.unit) { + await owner.unit.terminate(1_000); + stopped = await owner.unit.waitForEmpty(5_000); + if (stopped) owner.unit.remove(); + } else if (owner.pgid && !this.options.ledger!.hasIntegrityFailure) { + stopped = + (await this.options.ledger!.stopWitnessedGroup(owner.pgid, 5000)) === + 'gone'; + stopped &&= !this.options.ledger!.hasIntegrityFailure; + } + } catch { + stopped = false; + } + const receipt: ManagedExtensionProcessReceipt = { + ownerId, + sessionId: owner.sessionId, + configRevision: owner.configRevision, + mode: this.mode, + scope: this.mode === 'linux-cgroup' ? 'process_tree' : 'process_group', + state: stopped ? 'stopped' : 'outcome_unknown', + }; + if (stopped) { + if (this.options.directory) + await atomicWriteJSON( + path.join(this.options.directory, `${ownerId}.receipt.json`), + receipt, + { mode: 0o600 }, + ); + this.receipts.set(ownerId, receipt); + } + return receipt; + } + private async saveOwner(owner: Omit): Promise { + if (!this.options.directory) return; + await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); + this.journalOwners.add(owner.ownerId); + await atomicWriteJSON( + path.join(this.options.directory, `${owner.ownerId}.json`), + { + version: 1, + bootIdentity: this.bootIdentity, + mode: this.mode, + ownerId: owner.ownerId, + sessionId: owner.sessionId, + configRevision: owner.configRevision, + startedAt: owner.startedAt, + uptimeMs: owner.uptimeMs, + pgid: owner.pgid ?? null, + }, + { mode: 0o600 }, + ); + this.journalUpdates = this.journalUpdates.then(() => + atomicWriteJSON( + path.join(this.options.directory!, '.owners.json'), + [...this.journalOwners].sort(), + { mode: 0o600 }, + ), + ); + await this.journalUpdates; + } + + private async loadOwners(): Promise { + if (!this.options.directory) return; + await fs.mkdir(this.options.directory, { recursive: true, mode: 0o700 }); + const index = JSON.parse( + await fs.readFile( + path.join(this.options.directory, '.owners.json'), + 'utf8', + ), + ) as unknown; + if ( + !Array.isArray(index) || + !index.every( + (id) => typeof id === 'string' && /^extension-[0-9a-f-]+$/u.test(id), + ) + ) + throw new Error('Extension owner index is invalid.'); + const files = await fs.readdir(this.options.directory); + for (const id of index) { + if (!files.includes(`${id}.json`)) + throw new Error('Original extension owner record is missing.'); + this.journalOwners.add(id); + } + for (const file of files) { + if (!/^extension-[0-9a-f-]+\.json$/u.test(file)) continue; + const value = JSON.parse( + await fs.readFile(path.join(this.options.directory, file), 'utf8'), + ) as Record; + if ( + value['version'] !== 1 || + value['bootIdentity'] !== this.bootIdentity || + value['mode'] !== this.mode || + typeof value['ownerId'] !== 'string' || + `${value['ownerId']}.json` !== file || + typeof value['sessionId'] !== 'string' || + !Number.isSafeInteger(value['configRevision']) || + !Number.isFinite(value['startedAt']) || + !Number.isFinite(value['uptimeMs']) + ) + throw new Error('Extension owner journal is invalid.'); + // Never infer ownership after a reboot or a clock-domain inconsistency. + if (Number(value['uptimeMs']) > os.uptime() * 1000) + throw new Error('Original extension owner boot is unavailable.'); + const owner: Owner = { + ownerId: value['ownerId'], + sessionId: value['sessionId'], + configRevision: Number(value['configRevision']), + startedAt: Number(value['startedAt']), + uptimeMs: Number(value['uptimeMs']), + pgid: typeof value['pgid'] === 'number' ? value['pgid'] : undefined, + ...(this.mode === 'linux-cgroup' + ? { + unit: HookCommandCgroup.attach( + this.options.cgroupRoot, + value['ownerId'], + ), + } + : {}), + }; + this.owners.set(owner.ownerId, owner); + if (this.mode === 'developer-process-group' && owner.pgid) + this.options.ledger!.addGroup({ + pgid: owner.pgid, + callId: owner.ownerId, + startedAt: owner.startedAt, + uptimeMs: owner.uptimeMs, + }); + try { + const receipt = JSON.parse( + await fs.readFile( + path.join(this.options.directory, `${owner.ownerId}.receipt.json`), + 'utf8', + ), + ) as ManagedExtensionProcessReceipt; + if ( + receipt.ownerId !== owner.ownerId || + receipt.sessionId !== owner.sessionId || + receipt.mode !== this.mode || + receipt.configRevision !== owner.configRevision || + receipt.state !== 'stopped' || + receipt.scope !== + (this.mode === 'linux-cgroup' ? 'process_tree' : 'process_group') + ) + throw new Error('Extension stop receipt conflicts.'); + this.receipts.set(owner.ownerId, receipt); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + } +} diff --git a/packages/cli/src/serve/managed-extension-profile.ts b/packages/cli/src/serve/managed-extension-profile.ts new file mode 100644 index 00000000000..1b01af0f424 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-profile.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import type { ManagedExtensionProcessMode } from './managed-extension-process.js'; + +export interface ManagedExtensionRuntimeConfiguration { + readonly enabled: true; + readonly mode: ManagedExtensionProcessMode; + readonly cacheDirectory: string; + readonly cgroupRoot?: string; +} + +export const MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST = `sha256:${createHash('sha256').update('managed-extension-execution/1;linux-process-tree/1').digest('hex')}`; +export const MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST = `sha256:${createHash('sha256').update('managed-extension-execution/1;darwin-developer-process-group/1').digest('hex')}`; + +export function parseManagedExtensionRuntimeConfiguration( + value: unknown, + capabilityDigest: string, +): ManagedExtensionRuntimeConfiguration { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid Managed extension profile.'); + const config = value as Record; + if ( + ![ + 'cacheDirectory,enabled,mode', + 'cacheDirectory,cgroupRoot,enabled,mode', + ].includes(Object.keys(config).sort().join(',')) || + config['enabled'] !== true || + typeof config['cacheDirectory'] !== 'string' || + !path.isAbsolute(config['cacheDirectory']) || + config['cacheDirectory'].includes('\0') || + !( + (config['mode'] === 'linux-cgroup' && + capabilityDigest === MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST) || + (config['mode'] === 'developer-process-group' && + capabilityDigest === MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST) + ) + ) + throw new Error( + 'Managed extension profile conflicts with its capability digest.', + ); + if ( + config['mode'] === 'linux-cgroup' && + (typeof config['cgroupRoot'] !== 'string' || + !path.isAbsolute(config['cgroupRoot'])) + ) + throw new Error( + 'Managed Linux extensions require a delegated cgroup root.', + ); + if ( + config['mode'] === 'developer-process-group' && + config['cgroupRoot'] !== undefined + ) + throw new Error('Developer process groups do not use cgroups.'); + return Object.freeze({ + enabled: true, + mode: config['mode'], + cacheDirectory: config['cacheDirectory'], + ...(typeof config['cgroupRoot'] === 'string' + ? { cgroupRoot: config['cgroupRoot'] } + : {}), + }); +} diff --git a/packages/cli/src/serve/managed-extension-runtime.test.ts b/packages/cli/src/serve/managed-extension-runtime.test.ts new file mode 100644 index 00000000000..6778ca6ffe4 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-runtime.test.ts @@ -0,0 +1,396 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import * as tar from 'tar'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ManagedExtensionRuntime, + type ManagedExtensionRuntimeDependencies, +} from './managed-extension-runtime.js'; + +let temporary: string; +beforeEach(async () => { + temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'extension-runtime-')); +}); +afterEach(async () => { + await fs.rm(temporary, { recursive: true, force: true }); +}); + +function dependencies(): ManagedExtensionRuntimeDependencies { + return { + executionMode: 'developer-process-group', + directory: path.join(temporary, 'runtime'), + admitted: vi.fn(async () => true), + idle: vi.fn(() => true), + compile: vi.fn(async () => ({ + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + })), + drain: vi.fn(async () => undefined), + install: vi.fn(async () => undefined), + }; +} +function command(expectedRevision = 0) { + return { + operationId: randomUUID(), + actorKey: 'a'.repeat(64), + expectedRevision, + extensions: [], + }; +} + +describe('ManagedExtensionRuntime', () => { + it('uses Session revision CAS and drains only the selected Session', async () => { + const deps = dependencies(); + const runtime = new ManagedExtensionRuntime(deps); + const first = command(); + await runtime.prepare('a', first); + expect(await runtime.status('a')).toMatchObject({ revision: 0 }); + await runtime.activate('a', first.operationId); + expect(await runtime.status('a')).toMatchObject({ revision: 1 }); + expect(await runtime.status('b')).toMatchObject({ revision: 0 }); + expect(deps.drain).toHaveBeenCalledExactlyOnceWith('a', first); + await expect(runtime.prepare('a', command())).rejects.toThrow('revision'); + await runtime.activate('a', first.operationId); + expect(deps.install).toHaveBeenCalledTimes(1); + }); + + it('retains an unknown original operation across reconstruction without replaying install', async () => { + const deps = dependencies(); + vi.mocked(deps.install).mockRejectedValue(new Error('reply lost')); + const runtime = new ManagedExtensionRuntime(deps); + const input = command(); + await runtime.prepare('a', input); + await expect(runtime.activate('a', input.operationId)).rejects.toThrow( + 'reply lost', + ); + const replacement = new ManagedExtensionRuntime(deps); + expect(await replacement.status('a')).toMatchObject({ + revision: 0, + pendingOperationId: input.operationId, + }); + await expect(replacement.activate('a', input.operationId)).rejects.toThrow( + 'unknown', + ); + await expect(replacement.prepare('a', command())).rejects.toThrow( + 'unknown', + ); + expect(deps.install).toHaveBeenCalledTimes(1); + }); + + it('blocks concurrent activation while another Session stays available', async () => { + const deps = dependencies(); + let release!: () => void; + vi.mocked(deps.drain).mockImplementation( + async () => + new Promise((resolve) => { + release = resolve; + }), + ); + const runtime = new ManagedExtensionRuntime(deps); + const input = command(); + await runtime.prepare('a', input); + const activation = runtime.activate('a', input.operationId); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await expect(runtime.prepare('a', command())).rejects.toThrow('changing'); + expect(await runtime.status('b')).toMatchObject({ revision: 0 }); + release(); + await activation; + }); + + it('rehydrates active metadata after restart only through exact original prepare', async () => { + const deps = dependencies(); + const original = new ManagedExtensionRuntime(deps); + const input = command(); + await original.prepare('root', input); + await original.activate('root', input.operationId); + const restarted = new ManagedExtensionRuntime(deps); + await expect(restarted.activate('root', input.operationId)).rejects.toThrow( + 'reprepare', + ); + await restarted.prepare('root', input); + await restarted.activate('root', input.operationId); + expect(deps.drain).toHaveBeenCalledTimes(1); + expect(deps.install).toHaveBeenCalledTimes(2); + }); + + it('rebuilds a missing active root through a new explicit apply without changing its definition paths', async () => { + const source = path.join(temporary, 'source'); + await fs.mkdir(source); + await fs.writeFile(path.join(source, 'asset.txt'), 'authoritative'); + const archive = path.join(temporary, 'archive.tgz'); + await tar.c({ file: archive, cwd: source, gzip: true }, ['asset.txt']); + const bytes = await fs.readFile(archive); + const digest = createHash('sha256').update(bytes).digest('hex'); + const deps = dependencies(); + const runtime = new ManagedExtensionRuntime(deps); + await runtime.cache.upload(digest, Readable.from(bytes)); + const pin = { + extensionId: 'one', + packageRevision: 1, + packageDigest: digest, + }; + const first = { ...command(), extensions: [pin] }; + const initial = await runtime.prepare('a', first); + await runtime.activate('a', first.operationId); + const root = initial.packages[0]!.assetRoot; + await fs.rm(root, { recursive: true }); + await expect(runtime.getToolContext('a', 1, pin)).rejects.toThrow(); + const next = { ...command(1), extensions: [pin] }; + const rebuilt = await runtime.prepare('a', next); + expect(rebuilt.packages[0]!.assetRoot).toBe(root); + expect(await fs.readFile(path.join(root, 'asset.txt'), 'utf8')).toBe( + 'authoritative', + ); + await runtime.activate('a', next.operationId); + expect(await runtime.readAsset('a', 2, 'one', 'asset.txt')).toEqual({ + path: 'asset.txt', + content: 'authoritative', + }); + expect((await runtime.getToolContext('a', 2, pin)).assetRoot).toBe(root); + expect(deps.compile).toHaveBeenNthCalledWith( + 2, + root, + pin, + {}, + 'a', + undefined, + ); + await fs.writeFile(path.join(root, 'asset.txt'), 'drift'); + await expect(runtime.getToolContext('a', 2, pin)).rejects.toThrow( + 'drifted', + ); + const repair = { ...command(2), extensions: [pin] }; + expect((await runtime.prepare('a', repair)).packages[0]!.assetRoot).toBe( + root, + ); + await runtime.activate('a', repair.operationId); + expect(await runtime.readAsset('a', 3, 'one', 'asset.txt')).toEqual({ + path: 'asset.txt', + content: 'authoritative', + }); + }); + + it('contains private assets to the active package and exact Session revision', async () => { + const source = path.join(temporary, 'source'); + await fs.mkdir(source); + await fs.writeFile(path.join(source, 'asset.txt'), 'inside'); + const archive = path.join(temporary, 'archive.tgz'); + await tar.c({ file: archive, cwd: source, gzip: true }, ['asset.txt']); + const bytes = await fs.readFile(archive); + const digest = createHash('sha256').update(bytes).digest('hex'); + const runtime = new ManagedExtensionRuntime(dependencies()); + await runtime.cache.upload(digest, Readable.from(bytes)); + const input = { + ...command(), + extensions: [ + { extensionId: 'one', packageRevision: 1, packageDigest: digest }, + ], + }; + const prepared = await runtime.prepare('a', input, { + baseMcpServers: [], + baseHookCatalog: null, + settings: { one: { TOKEN: 'private-secret' } }, + }); + expect(JSON.stringify(prepared)).not.toContain('private-secret'); + await runtime.activate('a', input.operationId); + expect( + (await runtime.getToolContext('a', 1, input.extensions[0])).environment, + ).toMatchObject({ TOKEN: 'private-secret' }); + await expect( + runtime.getToolContext('b', 1, input.extensions[0]), + ).rejects.toThrow('unavailable'); + expect(await runtime.readAsset('a', 1, 'one', 'asset.txt')).toEqual({ + path: 'asset.txt', + content: 'inside', + }); + expect(await runtime.globAssets('a', 1, 'one', '**/*.txt')).toEqual([ + 'asset.txt', + ]); + await expect(runtime.readAsset('a', 0, 'one', 'asset.txt')).rejects.toThrow( + 'unavailable', + ); + await expect(runtime.readAsset('b', 1, 'one', 'asset.txt')).rejects.toThrow( + 'unavailable', + ); + await expect( + runtime.readAsset('a', 1, 'one', '../../archive.tgz'), + ).rejects.toThrow(); + const unchanged = await runtime.prepare( + 'a', + { ...command(1), extensions: input.extensions }, + { + baseMcpServers: [], + baseHookCatalog: null, + settings: { one: { TOKEN: 'private-secret' } }, + }, + ); + expect(unchanged.packages[0]!.assetRoot).toBe( + prepared.packages[0]!.assetRoot, + ); + await fs.writeFile( + path.join(prepared.packages[0]!.assetRoot, 'asset.txt'), + 'drift', + ); + await expect( + runtime.getToolContext('a', 1, input.extensions[0]), + ).rejects.toThrow('drifted'); + await expect(runtime.readAsset('a', 1, 'one', 'asset.txt')).rejects.toThrow( + 'drifted', + ); + }); +}); + +it('reconciles the exact active operation after restart and does not replay its stop', async () => { + const deps: ManagedExtensionRuntimeDependencies = { + ...dependencies(), + reconcile: vi.fn(async () => [ + { + ownerId: 'original-owner', + sessionId: 'a', + configRevision: 1, + mode: 'developer-process-group' as const, + scope: 'process_group' as const, + state: 'stopped' as const, + }, + ]), + }; + const runtime = new ManagedExtensionRuntime(deps); + const input = command(); + await runtime.prepare('a', input); + await runtime.activate('a', input.operationId); + expect(await runtime.operationStatus('a', input.operationId)).toMatchObject({ + state: 'active', + revision: 1, + }); + const restarted = new ManagedExtensionRuntime(deps); + await expect( + restarted.reconcile('a', input.operationId, 0, 1), + ).resolves.toMatchObject({ + state: 'stopped', + operationId: input.operationId, + }); + await expect( + restarted.reconcile('a', input.operationId, 0, 1), + ).resolves.toMatchObject({ + state: 'stopped', + operationId: input.operationId, + }); + expect(deps.reconcile).toHaveBeenCalledExactlyOnceWith('a'); + expect(deps.install).toHaveBeenCalledOnce(); +}); + +it('refuses compensation when a newer active operation owns the Session', async () => { + const deps = { ...dependencies(), reconcile: vi.fn() }; + const runtime = new ManagedExtensionRuntime(deps); + const first = command(); + await runtime.prepare('a', first); + await runtime.activate('a', first.operationId); + const next = command(1); + await runtime.prepare('a', next); + await runtime.activate('a', next.operationId); + await expect(runtime.reconcile('a', first.operationId, 0, 1)).rejects.toThrow( + 'conflicts', + ); + expect(deps.reconcile).not.toHaveBeenCalled(); + expect(await runtime.status('a')).toMatchObject({ + operationId: next.operationId, + revision: 2, + }); +}); + +it.each([false, true])( + 'reconciles a committed prepared operation before activation with prior configuration: %s', + async (previous) => { + const deps: ManagedExtensionRuntimeDependencies = { + ...dependencies(), + reconcile: vi.fn(async () => [ + { + ownerId: 'original-owner', + sessionId: 'a', + configRevision: previous ? 2 : 1, + mode: 'developer-process-group' as const, + scope: 'process_group' as const, + state: 'stopped' as const, + }, + ]), + }; + const runtime = new ManagedExtensionRuntime(deps); + if (previous) { + const initial = command(); + await runtime.prepare('a', initial); + await runtime.activate('a', initial.operationId); + } + const input = command(previous ? 1 : 0); + await runtime.prepare('a', input); + const restarted = new ManagedExtensionRuntime(deps); + await expect( + restarted.reconcile( + 'a', + input.operationId, + input.expectedRevision, + input.expectedRevision + 1, + ), + ).resolves.toMatchObject({ + state: 'stopped', + operationId: input.operationId, + }); + expect(deps.install).toHaveBeenCalledTimes(previous ? 1 : 0); + const recovery = command(input.expectedRevision + 1); + await restarted.prepare('a', recovery); + await restarted.activate('a', recovery.operationId); + expect(await restarted.status('a')).toMatchObject({ + revision: input.expectedRevision + 2, + operationId: recovery.operationId, + }); + }, +); + +it('retains an unknown stop for the active original owner until evidence arrives', async () => { + const reconcile = vi.fn(async () => [ + { + ownerId: 'original-owner', + sessionId: 'a', + configRevision: 1, + mode: 'developer-process-group' as const, + scope: 'process_group' as const, + state: 'outcome_unknown' as 'outcome_unknown' | 'stopped', + }, + ]); + const runtime = new ManagedExtensionRuntime({ ...dependencies(), reconcile }); + const input = command(); + await runtime.prepare('a', input); + await runtime.activate('a', input.operationId); + await expect( + runtime.reconcile('a', input.operationId, 0, 1), + ).resolves.toMatchObject({ state: 'outcome_unknown' }); + expect(await runtime.operationStatus('a', input.operationId)).toMatchObject({ + state: 'active', + }); + reconcile.mockResolvedValueOnce([ + { + ownerId: 'original-owner', + sessionId: 'a', + configRevision: 1, + mode: 'developer-process-group', + scope: 'process_group', + state: 'stopped', + }, + ]); + await expect( + runtime.reconcile('a', input.operationId, 0, 1), + ).resolves.toMatchObject({ state: 'stopped' }); +}); diff --git a/packages/cli/src/serve/managed-extension-runtime.ts b/packages/cli/src/serve/managed-extension-runtime.ts new file mode 100644 index 00000000000..383c3958dbc --- /dev/null +++ b/packages/cli/src/serve/managed-extension-runtime.ts @@ -0,0 +1,998 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Application } from 'express'; +import { minimatch } from 'minimatch'; +import { + parseManagedExtensionApplyCommand, + parseManagedExtensionPins, + parseManagedExtensionPin, + type ManagedExtensionApplyCommand, + type ManagedExtensionPin, + type ManagedExtensionMcpPin, + type ManagedExtensionPrepareResult, + type ManagedExtensionRuntimeControl, + type ManagedExtensionPreparedPackage, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-package.js'; +import type { ManagedHookCatalogPin } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js'; +import type { ManagedPackageContributions } from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { atomicWriteJSON } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js'; +import { + authorizeManagedRuntime, + managedRuntimeJsonBody, + managedRuntimeNoStore, + type ManagedRuntimeRequestIdentity, +} from './managed-runtime-attestation-contract.js'; +import { ManagedExtensionCache } from './managed-extension-cache.js'; + +export const MANAGED_EXTENSION_RUNTIME_ROUTE = Object.freeze({ + key: 'extensions', + method: 'POST', + path: '/internal/managed-runtime/v3/extensions', +}); +export const MANAGED_EXTENSION_RETIRE_ROUTE = Object.freeze({ + key: 'extension-retire', + method: 'POST', + path: '/internal/managed-runtime/v3/extensions/retire', +}); +export const MANAGED_EXTENSION_UPLOAD_ROUTE = Object.freeze({ + key: 'extension-package-upload', + method: 'PUT', + path: '/internal/managed-runtime/v3/extensions/packages/:digest', +}); + +export type ManagedRuntimeExtensionPackage = ManagedExtensionPreparedPackage; + +export interface ManagedExtensionRuntimeState { + readonly state: 'active' | 'failed' | 'outcome_unknown'; + readonly executionMode: 'linux-cgroup' | 'developer-process-group'; + readonly revision: number; + readonly extensions: readonly ManagedExtensionPin[]; + readonly operationId: string | null; + readonly pendingOperationId?: string; +} + +interface Prepared { + readonly ownerJournalId: string; + readonly base: { + readonly baseMcpServers: readonly ManagedExtensionMcpPin[]; + readonly baseHookCatalog: ManagedHookCatalogPin | null; + }; + readonly mcpServers: readonly ManagedExtensionMcpPin[]; + readonly hookCatalog: ManagedHookCatalogPin | null; + readonly command: ManagedExtensionApplyCommand; + readonly packages: readonly ManagedRuntimeExtensionPackage[]; +} + +export interface ManagedExtensionRuntimeDependencies { + readonly ownerJournalId?: string; + readonly executionMode: 'linux-cgroup' | 'developer-process-group'; + readonly directory: string; + readonly bindContext?: ( + sessionId: string, + contextBinding: unknown, + ) => Promise; + readonly admitted: (sessionId: string) => Promise; + readonly idle: (sessionId: string) => boolean; + readonly compile: ( + directory: string, + pin: ManagedExtensionPin, + environment: Readonly>, + sessionId: string, + publishedManifest?: unknown, + ) => Promise; + readonly stage?: ( + sessionId: string, + command: ManagedExtensionApplyCommand, + packages: readonly ManagedRuntimeExtensionPackage[], + base: { + baseMcpServers: readonly ManagedExtensionMcpPin[]; + baseHookCatalog: ManagedHookCatalogPin | null; + preparedMcpServers?: readonly ManagedExtensionMcpPin[]; + }, + ) => Promise<{ + mcpServers: readonly ManagedExtensionMcpPin[]; + hookCatalog: ManagedHookCatalogPin | null; + }>; + // This is Session scoped: closing the Runtime would also stop its siblings. + readonly drain: ( + sessionId: string, + command: ManagedExtensionApplyCommand, + ) => Promise; + readonly reconcile?: ( + sessionId: string, + ) => Promise< + ReadonlyArray< + import('./managed-extension-process.js').ManagedExtensionProcessReceipt + > + >; + readonly install: ( + sessionId: string, + packages: readonly ManagedRuntimeExtensionPackage[], + command: ManagedExtensionApplyCommand, + ) => Promise; +} + +export class ManagedExtensionRuntime { + readonly cache: ManagedExtensionCache; + private readonly prepared = new Map(); + private readonly active = new Map(); + private readonly busy = new Set(); + private readonly environments = new Map< + string, + Readonly>>> + >(); + private readonly sessionOwners = new Map(); + private readonly ownerSessions = new Map(); + + constructor( + private readonly dependencies: ManagedExtensionRuntimeDependencies, + ) { + if (!path.isAbsolute(dependencies.directory)) + throw new Error('Invalid extension Runtime directory.'); + this.cache = new ManagedExtensionCache( + path.join(dependencies.directory, 'packages'), + ); + } + + async status(sessionId: string): Promise { + await this.requireSession(sessionId); + let pendingOperationId: string | undefined; + try { + pendingOperationId = parseManagedExtensionApplyCommand( + JSON.parse( + await fs.readFile(this.pendingPath(sessionId), 'utf8'), + ) as unknown, + ).operationId; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const pending = pendingOperationId ? { pendingOperationId } : {}; + const installed = this.active.get(sessionId); + if (installed) return { ...this.state(installed.command), ...pending }; + try { + const stored = JSON.parse( + await fs.readFile(this.statePath(sessionId), 'utf8'), + ) as unknown; + const command = parseManagedExtensionApplyCommand(stored); + const stopped = await fs + .stat(`${this.operationPath(sessionId, command.operationId)}.stopped`) + .then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false; + throw error; + }, + ); + return { + ...this.state(command), + ...(stopped ? { state: 'failed' as const } : {}), + ...pending, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { + state: pendingOperationId ? 'outcome_unknown' : 'active', + executionMode: this.dependencies.executionMode, + revision: 0, + extensions: [], + operationId: null, + ...pending, + }; + throw error; + } + } + + async operationStatus(sessionId: string, operationId: string) { + const current = await this.status(sessionId); + try { + const proof = JSON.parse( + await fs.readFile( + `${this.operationPath(sessionId, operationId)}.stopped`, + 'utf8', + ), + ) as Record; + if ( + proof['operationId'] !== operationId || + proof['executionMode'] !== this.dependencies.executionMode || + proof['state'] !== 'stopped' + ) + throw new Error('Original stop proof conflicts.'); + return proof; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (current.operationId === operationId && !current.pendingOperationId) + return current; + const prepared = + this.prepared.get(this.preparedKey(sessionId, operationId)) ?? + (await this.loadPrepared(sessionId, operationId)); + if (prepared && !current.pendingOperationId) + return this.preparedView(prepared); + return { ...current, state: 'outcome_unknown' as const, operationId }; + } + + async prepare( + sessionId: string, + value: unknown, + base: { + baseMcpServers: readonly ManagedExtensionMcpPin[]; + baseHookCatalog: ManagedHookCatalogPin | null; + settings?: Readonly>>>; + toolProfile?: string; + packageManifests?: Readonly>; + } = { baseMcpServers: [], baseHookCatalog: null }, + ): Promise { + const command = parseManagedExtensionApplyCommand(value); + return this.lock(sessionId, async () => { + const status = await this.status(sessionId); + const key = this.preparedKey(sessionId, command.operationId); + const existing = + this.prepared.get(key) ?? + (await this.loadPrepared(sessionId, command.operationId)); + const publicBase = { + baseMcpServers: base.baseMcpServers, + baseHookCatalog: base.baseHookCatalog, + }; + if (existing) { + if ( + JSON.stringify(existing.command) !== JSON.stringify(command) || + JSON.stringify(existing.base) !== JSON.stringify(publicBase) + ) + throw new Error('Extension operation identity conflicts.'); + for (const pkg of existing.packages) { + await this.cache.restoreMaterialized( + pkg.pin.packageDigest, + pkg.assetRoot, + ); + const contributions = await this.dependencies.compile( + pkg.assetRoot, + pkg.pin, + base.settings?.[pkg.pin.extensionId] ?? {}, + sessionId, + base.packageManifests?.[pkg.pin.extensionId], + ); + validateExtensionToolProfile(contributions, base.toolProfile); + if ( + JSON.stringify(contributions) !== JSON.stringify(pkg.contributions) + ) + throw new Error('Prepared extension package drifted.'); + } + const catalogs = this.dependencies.stage + ? await this.dependencies.stage( + sessionId, + command, + existing.packages, + { ...publicBase, preparedMcpServers: existing.mcpServers }, + ) + : { mcpServers: [], hookCatalog: null }; + if ( + JSON.stringify(catalogs.mcpServers) !== + JSON.stringify(existing.mcpServers) || + JSON.stringify(catalogs.hookCatalog) !== + JSON.stringify(existing.hookCatalog) + ) + throw new Error('Prepared extension catalogs drifted.'); + this.environments.set(key, base.settings ?? {}); + this.prepared.set(key, existing); + return this.preparedView(existing); + } + if (status.pendingOperationId) + throw new Error('Original extension activation outcome is unknown.'); + if (status.revision !== command.expectedRevision) + throw new Error('Extension configuration revision conflicts.'); + if (!this.dependencies.idle(sessionId)) + throw new Error('Extension configuration requires an idle Session.'); + const packages: ManagedRuntimeExtensionPackage[] = []; + try { + for (const pin of command.extensions) { + const unchanged = this.active + .get(sessionId) + ?.packages.find( + (entry) => JSON.stringify(entry.pin) === JSON.stringify(pin), + ); + const assetRoot = + unchanged?.assetRoot ?? + (await this.cache.materialize(pin.packageDigest)); + await this.cache.restoreMaterialized(pin.packageDigest, assetRoot); + packages.push({ + pin, + assetRoot, + contributions: { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + }, + }); + const contributions = await this.dependencies.compile( + assetRoot, + pin, + base.settings?.[pin.extensionId] ?? {}, + sessionId, + base.packageManifests?.[pin.extensionId], + ); + validateExtensionToolProfile(contributions, base.toolProfile); + packages[packages.length - 1] = { pin, assetRoot, contributions }; + } + } catch (error) { + await Promise.all( + packages.map((entry) => + this.active + .get(sessionId) + ?.packages.some((active) => active.assetRoot === entry.assetRoot) + ? Promise.resolve() + : fs.rm(entry.assetRoot, { recursive: true, force: true }), + ), + ); + throw error; + } + const catalogs = this.dependencies.stage + ? await this.dependencies.stage(sessionId, command, packages, base) + : { mcpServers: [], hookCatalog: null }; + if ( + !this.dependencies.stage && + packages.some( + (entry) => + Object.keys(entry.contributions.mcpServers).length || + Object.keys(entry.contributions.hooks).length, + ) + ) + throw new Error('Extension process components are unsupported.'); + const prepared: Prepared = { + ownerJournalId: this.dependencies.ownerJournalId ?? 'in-memory', + command, + packages, + base: publicBase, + mcpServers: catalogs.mcpServers, + hookCatalog: catalogs.hookCatalog, + }; + const view = this.preparedView(prepared); + await fs.mkdir( + path.dirname(this.operationPath(sessionId, command.operationId)), + { recursive: true, mode: 0o700 }, + ); + await atomicWriteJSON( + this.operationPath(sessionId, command.operationId), + prepared, + { mode: 0o600 }, + ); + this.environments.set(key, base.settings ?? {}); + this.prepared.set(key, prepared); + return view; + }); + } + + async reconcile( + sessionId: string, + operationId: string, + expectedRevision: number, + revision: number, + ) { + return this.lock(sessionId, async () => { + const prepared = await this.loadPrepared(sessionId, operationId); + if (!prepared) + throw new Error('Original extension owner journal is unavailable.'); + try { + const proof = JSON.parse( + await fs.readFile( + `${this.operationPath(sessionId, operationId)}.stopped`, + 'utf8', + ), + ) as Record; + if ( + proof['operationId'] !== operationId || + proof['revision'] !== revision || + expectedRevision + 1 !== revision || + proof['executionMode'] !== this.dependencies.executionMode || + proof['state'] !== 'stopped' + ) + throw new Error('Original stop proof conflicts.'); + return proof; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const command = prepared.command; + const current = await this.status(sessionId); + if ( + command.operationId !== operationId || + command.expectedRevision !== expectedRevision || + revision !== expectedRevision + 1 || + (current.pendingOperationId !== undefined && + current.pendingOperationId !== operationId) || + (current.revision !== expectedRevision && + (current.operationId !== operationId || + current.revision !== revision || + JSON.stringify(current.extensions) !== + JSON.stringify(command.extensions))) + ) + throw new Error('Original extension operation conflicts.'); + const receipts = this.dependencies.reconcile + ? await this.dependencies.reconcile(sessionId) + : undefined; + if ( + !receipts || + receipts.length === 0 || + receipts.some((entry) => entry.state !== 'stopped') + ) + return { + operationId, + revision, + state: 'outcome_unknown', + executionMode: this.dependencies.executionMode, + }; + const proof = { + operationId, + revision, + state: 'stopped' as const, + executionMode: this.dependencies.executionMode, + receipts, + }; + await atomicWriteJSON( + `${this.operationPath(sessionId, operationId)}.stopped`, + proof, + { mode: 0o600 }, + ); + await atomicWriteJSON(this.statePath(sessionId), command, { + mode: 0o600, + }); + this.active.delete(sessionId); + await fs.rm(this.pendingPath(sessionId), { force: true }); + return proof; + }); + } + + async activate( + sessionId: string, + operationId: string, + ): Promise { + return this.lock(sessionId, async () => { + const status = await this.status(sessionId); + if (status.pendingOperationId) + throw new Error('Original extension activation outcome is unknown.'); + if (status.operationId === operationId) { + if (!this.active.has(sessionId)) { + const restored = this.prepared.get( + this.preparedKey(sessionId, operationId), + ); + if (!restored) + throw new Error( + 'Active extension configuration requires exact reprepare.', + ); + await this.dependencies.install( + sessionId, + restored.packages, + restored.command, + ); + this.active.set(sessionId, restored); + } + return status; + } + const prepared = this.prepared.get( + this.preparedKey(sessionId, operationId), + ); + if (!prepared || status.revision !== prepared.command.expectedRevision) + throw new Error('Extension configuration was not prepared.'); + if (!this.dependencies.idle(sessionId)) + throw new Error('Extension configuration requires an idle Session.'); + await fs.mkdir(path.dirname(this.statePath(sessionId)), { + recursive: true, + mode: 0o700, + }); + await atomicWriteJSON(this.pendingPath(sessionId), prepared.command, { + mode: 0o600, + }); + await this.dependencies.drain(sessionId, prepared.command); + await this.requireSession(sessionId); + if (!this.dependencies.idle(sessionId)) + throw new Error('Extension Session acquired work while draining.'); + await this.dependencies.install( + sessionId, + prepared.packages, + prepared.command, + ); + await fs.mkdir(path.dirname(this.statePath(sessionId)), { + recursive: true, + mode: 0o700, + }); + await atomicWriteJSON(this.statePath(sessionId), prepared.command, { + mode: 0o600, + }); + this.active.set(sessionId, prepared); + await fs.rm(this.pendingPath(sessionId)); + return this.state(prepared.command); + }); + } + + async getToolContext( + sessionId: string, + configRevision: number, + value: unknown, + ): Promise<{ + assetRoot: string; + environment: Readonly>; + }> { + await this.requireSession(sessionId); + if (this.busy.has(sessionId)) + throw new Error('Extension configuration is changing.'); + const status = await this.status(sessionId); + const active = this.active.get(sessionId); + const pin = parseManagedExtensionPin(value); + const pkg = active?.packages.find( + (entry) => JSON.stringify(entry.pin) === JSON.stringify(pin), + ); + if ( + !active || + !pkg || + status.pendingOperationId || + status.revision !== configRevision + ) + throw new Error('Extension tool configuration is unavailable.'); + const environment = this.environments.get( + this.preparedKey(sessionId, active.command.operationId), + ); + if (!environment) + throw new Error('Extension settings require exact reprepare.'); + await this.cache.verifyMaterialized(pin.packageDigest, pkg.assetRoot); + return { + assetRoot: pkg.assetRoot, + environment: { + ...environment[pin.extensionId], + QWEN_EXTENSION_ROOT: pkg.assetRoot, + }, + }; + } + + async bindSession( + sessionId: string, + runtimeSessionId: string | undefined, + contextBinding: unknown, + ): Promise { + if (this.dependencies.bindContext) + await this.dependencies.bindContext(sessionId, contextBinding); + if (runtimeSessionId === undefined) return; + const existing = this.ownerSessions.get(runtimeSessionId); + if (existing && existing !== sessionId) + throw new Error('Extension Runtime owner conflicts.'); + this.ownerSessions.set(runtimeSessionId, sessionId); + this.sessionOwners.set(sessionId, runtimeSessionId); + } + + runtimeSessionId(sessionId: string): string { + return this.sessionOwners.get(sessionId) ?? sessionId; + } + + isChanging(sessionId: string): boolean { + return this.busy.has(this.ownerSessions.get(sessionId) ?? sessionId); + } + + async readAsset( + sessionId: string, + revision: number, + extensionId: string, + relativePath: string, + ) { + const root = await this.assetRoot(sessionId, revision, extensionId); + const file = await this.contained(root, relativePath); + const handle = await fs.open(file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > 256 * 1024) + throw new Error('Extension asset exceeds the read limit.'); + return { path: relativePath, content: await handle.readFile('utf8') }; + } finally { + await handle.close(); + } + } + + async globAssets( + sessionId: string, + revision: number, + extensionId: string, + pattern: string, + ) { + const root = await this.assetRoot(sessionId, revision, extensionId); + if (pattern.length > 1024 || pattern.includes('\0')) + throw new Error('Invalid extension glob.'); + const paths: string[] = []; + const visit = async (directory: string) => { + for (const entry of await fs.readdir(directory, { + withFileTypes: true, + })) { + const filename = path.join(directory, entry.name); + const relative = path + .relative(root, filename) + .split(path.sep) + .join('/'); + if (entry.isSymbolicLink()) + throw new Error('Extension assets contain a link.'); + if (entry.isDirectory()) await visit(filename); + else if ( + entry.isFile() && + minimatch(relative, pattern, { dot: true }) + ) { + if (paths.length === 2048) + throw new Error('Extension glob exceeds its result limit.'); + paths.push(relative); + } + } + }; + await visit(root); + return paths.sort(); + } + + private async assetRoot( + sessionId: string, + revision: number, + extensionId: string, + ): Promise { + const status = await this.status(sessionId); + const active = this.active.get(sessionId); + if (this.isChanging(sessionId) || !active || status.revision !== revision) + throw new Error('Extension configuration is unavailable.'); + const entry = active.packages.find( + (item) => item.pin.extensionId === extensionId, + ); + if (!entry) + throw new Error('Extension package is not active in this Session.'); + if (status.pendingOperationId) + throw new Error('Extension activation outcome is unknown.'); + await this.cache.verifyMaterialized( + entry.pin.packageDigest, + entry.assetRoot, + ); + return fs.realpath(entry.assetRoot); + } + + private async contained(root: string, relative: string): Promise { + if ( + !relative || + relative.includes('\\') || + relative.includes('\0') || + path.isAbsolute(relative) + ) + throw new Error('Invalid extension asset path.'); + const filename = await fs.realpath(path.resolve(root, relative)); + const resolved = path.relative(root, filename); + if ( + resolved === '..' || + resolved.startsWith(`..${path.sep}`) || + path.isAbsolute(resolved) + ) + throw new Error('Extension asset path escapes its package.'); + return filename; + } + + private preparedView(value: Prepared): ManagedExtensionPrepareResult { + const result: ManagedExtensionPrepareResult = { + operationId: value.command.operationId, + state: 'prepared', + expectedRevision: value.command.expectedRevision, + revision: value.command.expectedRevision + 1, + executionMode: this.dependencies.executionMode, + packages: value.packages, + mcpServers: value.mcpServers, + hookCatalog: value.hookCatalog, + }; + if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) + throw new Error('Extension prepare response exceeds its size limit.'); + return result; + } + + private state( + command: ManagedExtensionApplyCommand, + ): ManagedExtensionRuntimeState { + return { + state: 'active', + executionMode: this.dependencies.executionMode, + revision: command.expectedRevision + 1, + extensions: command.extensions, + operationId: command.operationId, + }; + } + + private async loadPrepared( + sessionId: string, + operationId: string, + ): Promise { + try { + const value = JSON.parse( + await fs.readFile(this.operationPath(sessionId, operationId), 'utf8'), + ) as Prepared; + if ( + value.ownerJournalId !== + (this.dependencies.ownerJournalId ?? 'in-memory') + ) + throw new Error('Original extension owner journal is unavailable.'); + const command = parseManagedExtensionApplyCommand(value.command); + if ( + command.operationId !== operationId || + !Array.isArray(value.packages) || + value.packages.length !== command.extensions.length + ) + throw new Error('Invalid prepared extension receipt.'); + const pins = parseManagedExtensionPins( + value.packages.map((pkg) => pkg.pin), + ); + if (JSON.stringify(pins) !== JSON.stringify(command.extensions)) + throw new Error('Prepared extension pins conflict.'); + for (const pkg of value.packages) { + if ( + typeof pkg.assetRoot !== 'string' || + path.dirname(pkg.assetRoot) !== this.cache.directory || + !path.basename(pkg.assetRoot).startsWith(`${pkg.pin.packageDigest}-`) + ) + throw new Error('Invalid prepared extension asset root.'); + } + this.preparedView(value); + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + } + + private operationPath(sessionId: string, operationId: string): string { + return `${this.statePath(sessionId)}.${createHash('sha256').update(operationId).digest('hex')}.prepared`; + } + + private statePath(sessionId: string): string { + return path.join( + this.dependencies.directory, + 'sessions', + `${createHash('sha256').update(sessionId).digest('hex')}.json`, + ); + } + + private pendingPath(sessionId: string): string { + return `${this.statePath(sessionId)}.pending`; + } + + private preparedKey(sessionId: string, operationId: string): string { + return `${sessionId}\0${operationId}`; + } + + private async requireSession(sessionId: string): Promise { + if (!sessionId || !(await this.dependencies.admitted(sessionId))) + throw new Error('Extension Session is unavailable.'); + } + + private async lock(sessionId: string, work: () => Promise): Promise { + if (this.busy.has(sessionId)) + throw new Error('Extension Session configuration is changing.'); + this.busy.add(sessionId); + try { + await this.requireSession(sessionId); + return await work(); + } finally { + this.busy.delete(sessionId); + } + } +} + +export function registerManagedExtensionRuntimeRoutes( + app: Application, + identity: ManagedRuntimeRequestIdentity & { + readonly tenantId: string; + readonly workspaceId: string; + }, + runtime: ManagedExtensionRuntime, +): void { + app.put( + MANAGED_EXTENSION_UPLOAD_ROUTE.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + async (req, res) => { + try { + if (req.headers['content-type'] !== 'application/gzip') + throw new Error('Invalid package content type.'); + await runtime.cache.upload(String(req.params['digest']), req); + res.json({ protocolVersion: 1, packageDigest: req.params['digest'] }); + } catch { + res.status(400).json({ code: 'managed_extension_package_invalid' }); + } + }, + ); + app.post( + MANAGED_EXTENSION_RUNTIME_ROUTE.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + managedRuntimeJsonBody(512 * 1024), + async (req, res) => { + try { + const body = req.body as Record; + if ( + !body || + ![ + 'contextBinding,operation,protocolVersion,sessionKey', + 'contextBinding,operation,protocolVersion,runtimeSessionId,sessionKey', + ].includes(Object.keys(body).sort().join(',')) || + body['protocolVersion'] !== 1 || + (body['runtimeSessionId'] !== undefined && + typeof body['runtimeSessionId'] !== 'string') + ) + throw new Error('Invalid extension request.'); + const runtimeSessionId = body['runtimeSessionId'] as string | undefined; + const sessionKey = body['sessionKey'] as Record; + if ( + !sessionKey || + Object.keys(sessionKey).sort().join(',') !== + 'sessionId,tenantId,workspaceId' || + sessionKey['tenantId'] !== identity.tenantId || + sessionKey['workspaceId'] !== identity.workspaceId || + typeof sessionKey['sessionId'] !== 'string' || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(sessionKey['sessionId']) + ) + throw new Error('Extension Session scope conflicts.'); + const sessionId = sessionKey['sessionId']; + await runtime.bindSession( + sessionId, + runtimeSessionId, + body['contextBinding'], + ); + const operation = body['operation'] as Record; + let result: unknown; + if ( + operation['kind'] === 'prepare' || + operation['kind'] === 'extension-prepare' + ) { + const control = operation as unknown as Extract< + ManagedExtensionRuntimeControl, + { kind: 'extension-prepare' } + >; + const parsed = parseManagedExtensionApplyCommand(control.command); + if ( + control.kind === 'extension-prepare' && + (control.operationId !== parsed.operationId || + control.expectedRevision !== parsed.expectedRevision || + JSON.stringify(control.extensions) !== + JSON.stringify(parsed.extensions)) + ) + throw new Error('Extension prepare identity conflicts.'); + result = await runtime.prepare( + sessionId, + parsed, + control.kind === 'extension-prepare' + ? { + toolProfile: + typeof operation['toolProfile'] === 'string' + ? operation['toolProfile'] + : undefined, + packageManifests: operation['packageManifests'] as + | Readonly> + | undefined, + baseMcpServers: control.baseMcpServers, + baseHookCatalog: control.baseHookCatalog, + settings: operation['settings'] as + | Readonly>>> + | undefined, + } + : undefined, + ); + } else if ( + (operation['kind'] === 'activate' || + operation['kind'] === 'extension-activate') && + typeof operation['operationId'] === 'string' + ) + result = await runtime.activate(sessionId, operation['operationId']); + else if ( + operation['kind'] === 'extension-reconcile' && + typeof operation['operationId'] === 'string' && + Number.isSafeInteger(operation['expectedRevision']) && + Number.isSafeInteger(operation['revision']) + ) + result = await runtime.reconcile( + sessionId, + operation['operationId'], + Number(operation['expectedRevision']), + Number(operation['revision']), + ); + else if ( + operation['kind'] === 'status' || + operation['kind'] === 'extension-status' + ) + result = + typeof operation['operationId'] === 'string' + ? await runtime.operationStatus( + sessionId, + operation['operationId'], + ) + : await runtime.status(sessionId); + else if ( + (operation['kind'] === 'asset-read' || + operation['kind'] === 'extension-asset-read') && + Number.isSafeInteger(operation['revision']) && + typeof operation['extensionId'] === 'string' && + typeof operation['path'] === 'string' + ) + result = await runtime.readAsset( + sessionId, + Number(operation['revision']), + operation['extensionId'], + operation['path'], + ); + else if ( + (operation['kind'] === 'asset-glob' || + operation['kind'] === 'extension-asset-glob') && + Number.isSafeInteger(operation['revision']) && + typeof operation['extensionId'] === 'string' && + typeof operation['pattern'] === 'string' + ) + result = await runtime.globAssets( + sessionId, + Number(operation['revision']), + operation['extensionId'], + operation['pattern'], + ); + else throw new Error('Invalid extension operation.'); + if ( + typeof operation['operationId'] === 'string' && + String(operation['kind']).includes('asset-') + ) + result = Array.isArray(result) + ? { operationId: operation['operationId'], paths: result } + : { ...(result as object), operationId: operation['operationId'] }; + res.json({ + protocolVersion: 1, + sessionKey, + ...(runtimeSessionId ? { runtimeSessionId } : {}), + result, + }); + } catch { + res + .status(409) + .json({ code: 'managed_extension_configuration_unavailable' }); + } + }, + ); +} + +function validateExtensionToolProfile( + value: ManagedPackageContributions, + profile: string | undefined, +): void { + if (profile === undefined) return; + const shell = + profile === 'hosted-workspace-shell/1' || + profile === 'hosted-workspace-team-lead/1'; + const mcp = shell || profile === 'hosted-workspace-mcp/1'; + if (!mcp && Object.keys(value.mcpServers).length) + throw new Error('Session profile does not admit extension MCP.'); + if ( + !shell && + (JSON.stringify(value.hooks).includes('"type":"command"') || + Object.values(value.mcpServers).some( + (recipe) => recipe && typeof recipe === 'object' && 'command' in recipe, + ) || + value.commands.some((command) => /!\{|!`/u.test(command.prompt))) + ) + throw new Error( + 'Session profile does not admit extension Shell execution.', + ); + const base = ['read_file', 'glob']; + if (profile !== 'hosted-workspace-read/1') + base.push( + 'write_file', + 'edit', + 'list_directory', + 'grep_search', + 'todo_write', + ); + if (shell) base.push('run_shell_command'); + for (const agent of value.agents) + if ( + agent.tools?.some( + (tool) => !base.includes(tool) && !(mcp && tool.startsWith('mcp__')), + ) + ) + throw new Error('Extension agent tools exceed the Session profile.'); +} diff --git a/packages/cli/src/serve/managed-extension-shell-executor.test.ts b/packages/cli/src/serve/managed-extension-shell-executor.test.ts new file mode 100644 index 00000000000..c3f2d901f6b --- /dev/null +++ b/packages/cli/src/serve/managed-extension-shell-executor.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { managedToolDigest } from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; +import { ManagedExtensionExecutionUnknownError } from './managed-extension-error.js'; +import { + createManagedToolSet, + ManagedToolExecutor, + ManagedMcpToolUnknownError, +} from './managed-runtime-tool-executor.js'; + +it('retains an unproven extension Shell as unknown without settling or replaying the native invocation', async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-executor-'), + ); + const runShell = vi.fn(async () => { + throw new ManagedExtensionExecutionUnknownError( + 'Original owner stop is unknown.', + ); + }); + const native = createManagedToolSet(directory, 'owner'); + const resolver = vi.fn(async () => ({ ...native, runShell })); + const executor = new ManagedToolExecutor(resolver); + const input = { + command: 'printf test', + managedExtension: { + harnessSessionId: 'root', + configRevision: 1, + pin: { + extensionId: 'one', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }, + }, + }; + const reference = { + sessionId: 'owner', + promptId: 'prompt', + callId: 'original-step', + argsDigest: managedToolDigest(input), + }; + try { + await expect( + executor.execute(reference, 'run_shell_command', input), + ).rejects.toBeInstanceOf(ManagedMcpToolUnknownError); + expect(executor.status(reference)).toMatchObject({ state: 'unknown' }); + expect(executor.isSessionConfigurationIdle('owner')).toBe(false); + expect(resolver).toHaveBeenCalledWith(reference, { + toolName: 'run_shell_command', + input, + }); + await expect( + executor.execute(reference, 'run_shell_command', input), + ).rejects.toBeInstanceOf(ManagedMcpToolUnknownError); + expect(runShell).toHaveBeenCalledTimes(1); + } finally { + await executor.close(); + await fs.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/serve/managed-extension-shell.test.ts b/packages/cli/src/serve/managed-extension-shell.test.ts new file mode 100644 index 00000000000..f284a43bc52 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-shell.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; +import { ManagedExtensionProcessOwners } from './managed-extension-process.js'; +import { runManagedExtensionShell } from './managed-extension-shell.js'; +import { ManagedExtensionExecutionUnknownError } from './managed-extension-error.js'; + +it.skipIf(process.platform !== 'darwin')( + 'runs with exact private environment and seals capture after registered group stop', + async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'extension-shell-'), + ); + const ledger = ManagedRuntimeLedger.create({ + workFile: path.join(directory, 'ledger.json'), + worker: { + pid: process.pid, + pgid: process.pid, + incarnation: 'test', + startedAt: Date.now(), + groupOwned: false, + }, + }); + const owners = new ManagedExtensionProcessOwners( + 'developer-process-group', + { ledger, directory: path.join(directory, 'owners') }, + ); + const write = vi.fn( + async (_stream: 'stdout' | 'stderr', _chunk: Buffer) => {}, + ); + const capture = { + write, + finish: vi.fn(async () => {}), + setStarted: vi.fn(), + setProcessResult: vi.fn(), + }; + try { + const result = await runManagedExtensionShell(owners, { + sessionId: 'root', + configRevision: 1, + command: 'printf "%s" "$EXT_TEST_TOKEN"', + cwd: directory, + environment: { EXT_TEST_TOKEN: 'private-value', PATH: '/usr/bin:/bin' }, + signal: new AbortController().signal, + capture, + }); + expect(result.llmContent).toBe('private-value'); + expect( + Buffer.concat( + write.mock.calls.map((call) => call[1] as Buffer), + ).toString(), + ).toBe('private-value'); + expect(capture.finish).toHaveBeenCalledWith('stdout', true); + expect(await owners.hasLiveSessionOwners('root')).toBe(false); + capture.finish.mockClear(); + const stop = vi.spyOn(owners, 'stop').mockResolvedValueOnce({ + ownerId: 'unproven', + sessionId: 'root', + configRevision: 1, + mode: 'developer-process-group', + scope: 'process_group', + state: 'outcome_unknown', + }); + try { + await expect( + runManagedExtensionShell(owners, { + sessionId: 'root', + configRevision: 1, + command: 'printf unproven', + cwd: directory, + environment: { PATH: '/usr/bin:/bin' }, + signal: new AbortController().signal, + capture, + }), + ).rejects.toBeInstanceOf(ManagedExtensionExecutionUnknownError); + expect(capture.finish).not.toHaveBeenCalled(); + } finally { + stop.mockRestore(); + } + } finally { + await owners.stopAll(); + await fs.rm(directory, { force: true, recursive: true }); + } + }, +); diff --git a/packages/cli/src/serve/managed-extension-shell.ts b/packages/cli/src/serve/managed-extension-shell.ts new file mode 100644 index 00000000000..b114b136581 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-shell.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ManagedExtensionExecutionUnknownError } from './managed-extension-error.js'; +import { ToolErrorType } from '@qwen-code/qwen-code-core/tools/tool-error.js'; +import { getShellConfiguration } from '@qwen-code/qwen-code-core/utils/shell-utils.js'; +import type { + ShellRawCaptureSink, + ShellExecutionResult, +} from '@qwen-code/qwen-code-core/services/shellExecutionService.js'; +import type { ToolResult } from '@qwen-code/qwen-code-core/tools/tools.js'; +import type { ManagedExtensionProcessOwners } from './managed-extension-process.js'; + +export async function runManagedExtensionShell( + owners: ManagedExtensionProcessOwners, + input: { + sessionId: string; + configRevision: number; + command: string; + cwd: string; + environment: NodeJS.ProcessEnv; + signal: AbortSignal; + capture?: ShellRawCaptureSink; + timeoutMs?: number; + }, +): Promise { + input.signal.throwIfAborted(); + const shell = getShellConfiguration(); + const { child, ownerId } = await owners.start({ + sessionId: input.sessionId, + configRevision: input.configRevision, + executable: shell.executable, + args: [...shell.argsPrefix, input.command], + cwd: input.cwd, + env: input.environment, + }); + const streamsClosed = new Promise((resolve) => + child.once('close', () => resolve()), + ); + if (child.pid) input.capture?.setStarted(child.pid); + child.stdin?.end(); + const chunks: Buffer[] = []; + let retained = 0; + let exceeded = false; + const captures = { stdout: Promise.resolve(), stderr: Promise.resolve() }; + for (const id of ['stdout', 'stderr'] as const) { + child[id]?.on('data', (chunk: Buffer) => { + if (input.capture) { + child[id]?.pause(); + captures[id] = captures[id] + .then(() => input.capture!.write(id, chunk)) + .finally(() => child[id]?.resume()); + } + const remaining = Math.max(0, 1024 * 1024 - retained); + if (chunk.length > remaining) exceeded = true; + if (remaining) { + const prefix = chunk.subarray(0, remaining); + chunks.push(prefix); + retained += prefix.length; + } + }); + } + let timedOut = false; + let timer: ReturnType | undefined; + let wake: (() => void) | undefined; + const stop = () => { + wake?.(); + }; + try { + const exited = new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + const interrupted = new Promise((resolve) => { + wake = resolve; + input.signal.addEventListener('abort', stop, { once: true }); + if (input.signal.aborted) resolve(); + timer = setTimeout(() => { + timedOut = true; + resolve(); + }, input.timeoutMs ?? 120000); + }); + await Promise.race([exited, interrupted]); + const receipt = await owners.stop(ownerId).catch(() => { + throw new ManagedExtensionExecutionUnknownError( + 'Extension Shell process stop is unknown.', + ); + }); + if (receipt.state !== 'stopped') + throw new ManagedExtensionExecutionUnknownError( + 'Extension Shell process stop is unknown.', + ); + let drainTimer: ReturnType | undefined; + try { + await Promise.race([ + streamsClosed, + new Promise((_resolve, reject) => { + drainTimer = setTimeout( + () => + reject( + new ManagedExtensionExecutionUnknownError( + 'Extension Shell output closure is unknown.', + ), + ), + 5000, + ); + }), + ]); + } finally { + if (drainTimer) clearTimeout(drainTimer); + } + await Promise.all(Object.values(captures)); + const rawOutput = Buffer.concat(chunks); + const output = rawOutput.toString('utf8'); + const result: ShellExecutionResult = { + rawOutput, + output, + exitCode: child.exitCode, + signal: null, + error: null, + aborted: input.signal.aborted || timedOut, + pid: child.pid, + executionMethod: 'child_process', + outputCaptureLimitExceeded: exceeded, + }; + input.capture?.setProcessResult(result); + await input.capture?.finish('stdout', true); + await input.capture?.finish('stderr', true); + return { + llmContent: `${output}${exceeded ? '\n[Output truncated]' : ''}${timedOut ? '\n[Command timed out]' : ''}`, + returnDisplay: output, + ...(child.exitCode !== 0 || result.aborted + ? { + error: { + message: result.aborted + ? 'Shell command stopped.' + : `Shell exited with code ${child.exitCode}.`, + type: ToolErrorType.EXECUTION_FAILED, + }, + } + : {}), + }; + } finally { + if (timer) clearTimeout(timer); + input.signal.removeEventListener('abort', stop); + } +} diff --git a/packages/cli/src/serve/managed-extension-stdio.ts b/packages/cli/src/serve/managed-extension-stdio.ts new file mode 100644 index 00000000000..df62adadb12 --- /dev/null +++ b/packages/cli/src/serve/managed-extension-stdio.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'node:child_process'; +import { + ReadBuffer, + serializeMessage, +} from '@modelcontextprotocol/sdk/shared/stdio.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { ManagedExtensionProcessOwners } from './managed-extension-process.js'; + +export class ManagedExtensionStdioTransport implements Transport { + onclose?: Transport['onclose']; + onerror?: Transport['onerror']; + onmessage?: Transport['onmessage']; + private child?: ChildProcess; + private ownerId?: string; + private closed = false; + private readonly buffer = new ReadBuffer(); + + constructor( + private readonly owners: ManagedExtensionProcessOwners, + private readonly spec: Parameters< + ManagedExtensionProcessOwners['start'] + >[0], + ) {} + + get processOwnerId(): string | undefined { + return this.ownerId; + } + + async start(): Promise { + if (this.child) + throw new Error('Extension stdio transport already started.'); + const owner = await this.owners.start(this.spec); + this.ownerId = owner.ownerId; + this.child = owner.child; + this.child.stderr?.on('data', () => undefined); + this.child.on('error', (error) => this.onerror?.(error)); + this.child.stdin?.on('error', (error) => this.onerror?.(error)); + this.child.stdout?.on('data', (chunk: Buffer) => { + this.buffer.append(chunk); + try { + let message: JSONRPCMessage | null; + while ((message = this.buffer.readMessage()) !== null) + this.onmessage?.(message); + } catch (error) { + this.onerror?.( + error instanceof Error ? error : new Error('Invalid MCP message.'), + ); + } + }); + this.child.on('exit', () => { + void this.close().catch((error: unknown) => + this.onerror?.( + error instanceof Error + ? error + : new Error('Extension process stop is unknown.'), + ), + ); + }); + } + + async send(message: JSONRPCMessage): Promise { + const input = this.child?.stdin; + if (!input || this.closed) + throw new Error('Extension stdio transport is closed.'); + await new Promise((resolve, reject) => { + input.write(serializeMessage(message), (error) => + error ? reject(error) : resolve(), + ); + }); + } + + async close(): Promise { + if (this.closed) return; + if (!this.ownerId) + throw new Error('Extension process owner is unavailable.'); + this.child?.stdin?.end(); + const receipt = await this.owners.stop(this.ownerId); + if (receipt.state !== 'stopped') + throw new Error('Extension process stop outcome is unknown.'); + this.closed = true; + this.onclose?.(); + } +} diff --git a/packages/cli/src/serve/managed-hook-runtime.test.ts b/packages/cli/src/serve/managed-hook-runtime.test.ts index 87bb995072a..ec6bf21c99c 100644 --- a/packages/cli/src/serve/managed-hook-runtime.test.ts +++ b/packages/cli/src/serve/managed-hook-runtime.test.ts @@ -149,6 +149,42 @@ async function settled(instance: ManagedHookRuntime, id = 'execution') { return view!; } describe('ManagedHookRuntime', () => { + it('keeps a Session package catalog out of a sibling Session', async () => { + const instance = new ManagedHookRuntime( + { ...key, workspaceGeneration: '1' }, + async () => directory, + { version: 1, catalogs: [] }, + ); + runtimes.push(instance); + instance.installSessionManifest(key.sessionId, { + version: 1, + catalogs: [ + { + ...pin, + tenantId: key.tenantId, + workspaceId: key.workspaceId, + hooks: [definition()], + }, + ], + }); + const query = { + kind: 'hook-catalog', + sessionKey: key, + operationId: 'catalog-lookup', + pin, + }; + expect( + (await instance.control('runtime-a', query)).catalog?.hooks, + ).toHaveLength(1); + await expect( + instance.control('runtime-b', { + ...query, + sessionKey: { ...key, sessionId: 'other-session' }, + operationId: 'other-lookup', + }), + ).rejects.toThrow('catalog_unavailable'); + }); + it.each([1, 2])( 'fences native hook dispatch when CSI seals during directory lookup %s', async (gateLookup) => { diff --git a/packages/cli/src/serve/managed-hook-runtime.ts b/packages/cli/src/serve/managed-hook-runtime.ts index 060a2c783ae..03034bec5b2 100644 --- a/packages/cli/src/serve/managed-hook-runtime.ts +++ b/packages/cli/src/serve/managed-hook-runtime.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { managedExtensionEnvironment } from './managed-extension-environment.js'; import { createHash } from 'node:crypto'; import { createAbortController } from '@qwen-code/qwen-code-core/utils/abortController.js'; import { readFileSync } from 'node:fs'; @@ -40,6 +41,8 @@ import { type RegisteredHandlerRef, } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js'; import type { ManagedSessionKey } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import type { ManagedExtensionProcessOwners } from './managed-extension-process.js'; +import { executeManagedExtensionCommandHook } from './managed-extension-hook.js'; export interface ManagedHookDefinition extends Omit { @@ -513,6 +516,7 @@ interface Operation { export class ManagedHookRuntime { private readonly manifest: ManagedHookManifest; + private readonly sessionManifests = new Map(); private readonly operations = new Map(); private readonly gates = new ManagedOperationGrantGate(); private readonly shutdown = createAbortController(); @@ -531,10 +535,48 @@ export class ManagedHookRuntime { runtimeSessionId: string, ) => Promise, manifest: ManagedHookManifest, + private readonly extensionProcesses?: ManagedExtensionProcessOwners, ) { this.manifest = parseManifest(manifest); } + resolvePinnedCatalog( + pin: ManagedHookCatalogPin, + ): ManagedHookManifest['catalogs'][number] { + const available = [ + ...this.manifest.catalogs, + ...[...this.sessionManifests.values()].flatMap( + (manifest) => manifest.catalogs, + ), + ]; + const found = available.find( + (entry) => + entry.catalogId === pin.catalogId && + entry.catalogRevision === pin.catalogRevision && + entry.definitionDigest === pin.definitionDigest, + ); + if (!found) throw new ManagedHookError('managed_hook_catalog_unavailable'); + return structuredClone(found); + } + + installSessionManifest( + runtimeSessionId: string, + manifest: ManagedHookManifest, + ): void { + const parsed = parseManifest(manifest); + if (this.hasExtensionHolds(runtimeSessionId)) + throw new ManagedHookError('managed_hook_session_busy'); + if ( + parsed.catalogs.some( + (entry) => + entry.tenantId !== this.workspace.tenantId || + entry.workspaceId !== this.workspace.workspaceId, + ) + ) + throw new ManagedHookError('managed_hook_scope_conflict'); + this.sessionManifests.set(runtimeSessionId, parsed); + } + sealAdmission(): void { this.admissionSealed = true; } @@ -593,7 +635,9 @@ export class ManagedHookRuntime { return structuredClone(existing.view); } const { pin: expected } = control; - const catalog = this.manifest.catalogs.find( + const catalog = ( + this.sessionManifests.get(control.sessionKey.sessionId) ?? this.manifest + ).catalogs.find( (entry) => entry.tenantId === control.sessionKey.tenantId && entry.workspaceId === control.sessionKey.workspaceId && @@ -842,6 +886,26 @@ export class ManagedHookRuntime { ]; } + hasUnsettledExtensionOperations(): boolean { + return ( + this.pendingStarts > 0 || + [...this.operations.values()].some( + (entry) => + entry.view.state !== 'settled' || + entry.moduleEvaluationPending === true, + ) + ); + } + + hasExtensionHolds(sessionId: string): boolean { + return [...this.operations.values()].some( + (entry) => + entry.sessionKey.sessionId === sessionId && + (entry.view.state !== 'settled' || + entry.moduleEvaluationPending === true), + ); + } + hasHolds(runtimeSessionId: string): boolean { return [...this.operations.values()].some( (entry) => @@ -981,53 +1045,71 @@ export class ManagedHookRuntime { : undefined, ); dispatched = true; - const result = await runner.executeHook( - config, - definition.eventName, - { - ...control.input, - cwd: directory, - session_id: control.sessionKey.sessionId, - }, - { - signal: entry.controller.signal, - ...(Array.isArray( - (control.input as unknown as Record)['messages'], - ) - ? { - messages: ( - control.input as unknown as { - messages: Array>; - } - ).messages, - } - : {}), - ...('tool_use_id' in control.input && - typeof control.input.tool_use_id === 'string' - ? { toolUseID: control.input.tool_use_id } - : {}), - }, - config.type === HookType.Command - ? { - waitForProcessTree: true, - cgroupRoot: process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT'], - environment: { - PATH: process.env['PATH'] ?? '', - HOME: directory, - USERPROFILE: directory, - ...(process.env['SystemRoot'] - ? { SYSTEMROOT: process.env['SystemRoot'] } + const result = + config.type === HookType.Command && this.extensionProcesses + ? await executeManagedExtensionCommandHook( + runner, + this.extensionProcesses, + control.sessionKey.sessionId, + control.pin.catalogRevision, + config, + { + ...control.input, + cwd: directory, + session_id: control.sessionKey.sessionId, + }, + entry.controller.signal, + managedExtensionEnvironment(process.env, directory), + ) + : await runner.executeHook( + config, + definition.eventName, + { + ...control.input, + cwd: directory, + session_id: control.sessionKey.sessionId, + }, + { + signal: entry.controller.signal, + ...(Array.isArray( + (control.input as unknown as Record)[ + 'messages' + ], + ) + ? { + messages: ( + control.input as unknown as { + messages: Array>; + } + ).messages, + } + : {}), + ...('tool_use_id' in control.input && + typeof control.input.tool_use_id === 'string' + ? { toolUseID: control.input.tool_use_id } : {}), }, - } - : config.type === HookType.Http - ? { - trackHttpRequest: true, - // Preserve the response as completion proof after user cancel. - httpRequestSignal: this.shutdown.signal, - } - : undefined, - ); + config.type === HookType.Command + ? { + waitForProcessTree: true, + cgroupRoot: process.env['QWEN_MANAGED_HOOK_CGROUP_ROOT'], + environment: { + PATH: process.env['PATH'] ?? '', + HOME: directory, + USERPROFILE: directory, + ...(process.env['SystemRoot'] + ? { SYSTEMROOT: process.env['SystemRoot'] } + : {}), + }, + } + : config.type === HookType.Http + ? { + trackHttpRequest: true, + // Preserve the response as completion proof after user cancel. + httpRequestSignal: this.shutdown.signal, + } + : undefined, + ); if (result.error instanceof HookCommandIsolationUnavailableError) { entry.view = { operationId: control.operationId, @@ -1056,7 +1138,12 @@ export class ManagedHookRuntime { } const uncertain = (config.type === HookType.Command && - result.processTreeDrained !== true) || + result.processTreeDrained !== true && + !( + this.extensionProcesses?.mode === 'developer-process-group' && + result.processReceipt?.scope === 'process_group' && + result.processReceipt.state === 'stopped' + )) || (config.type === HookType.Function && !functionSettled) || (config.type === HookType.Http && (!result.httpRequestState || diff --git a/packages/cli/src/serve/managed-mcp-runtime.test.ts b/packages/cli/src/serve/managed-mcp-runtime.test.ts index d5839110bc5..2e3904dbf28 100644 --- a/packages/cli/src/serve/managed-mcp-runtime.test.ts +++ b/packages/cli/src/serve/managed-mcp-runtime.test.ts @@ -212,6 +212,142 @@ function invoke( } describe('Managed MCP Runtime', () => { + it('installs package definitions only for the selected Session and drains that Session', async () => { + const instance = runtime([]); + instance.installSessionDefinitions(sessionKey.sessionId, { + version: 1, + servers: [stdioDefinition()], + }); + expect( + instance.publishedDefinitions( + sessionKey.tenantId, + sessionKey.workspaceId, + ), + ).toEqual([]); + const configured = await settled(instance, configure()); + expect(configured.state).toBe('settled'); + expect(configured.error).toBeUndefined(); + expect(instance.hasHolds(runtimeSessionId)).toBe(true); + await instance.drainSession('unrelated-session'); + expect(instance.hasHolds(runtimeSessionId)).toBe(true); + expect(() => + instance.installSessionDefinitions(sessionKey.sessionId, { + version: 1, + servers: [], + }), + ).toThrow('busy'); + await instance.drainSession(runtimeSessionId); + expect(instance.hasHolds(runtimeSessionId)).toBe(false); + instance.installSessionDefinitions(sessionKey.sessionId, { + version: 1, + servers: [], + }); + }); + + it('preserves independent MCP connection identity across another component change and leaves sibling Sessions untouched', async () => { + const independent = stdioDefinition(); + const extension = { ...stdioDefinition(), serverId: 'one:fixture' }; + const otherKey = { ...sessionKey, sessionId: 'session-b' }; + const otherOwner = 'mcp:session-b'; + const instance = new ManagedMcpRuntime( + { ...sessionKey, workspaceGeneration: '1' }, + async () => directory, + { version: 1, servers: [independent] }, + ); + runtimes.push(instance); + instance.installSessionDefinitions(sessionKey.sessionId, { + version: 1, + servers: [independent, extension], + }); + instance.installSessionDefinitions(otherKey.sessionId, { + version: 1, + servers: [independent], + }); + const finish = async (owner: string, control: ManagedMcpControl) => { + let value = + control.kind === 'mcp-invoke' && control.request.kind === 'tool_call' + ? await instance.invokeTool(owner, control) + : await instance.control(owner, control); + await vi.waitFor( + async () => { + value = await instance.control(owner, { + kind: 'mcp-status', + sessionKey: control.sessionKey, + operationId: 'lookup', + targetOperationId: control.operationId, + }); + expect(value.state).not.toBe('running'); + }, + { timeout: 15000 }, + ); + return value; + }; + const original = (await finish(runtimeSessionId, configure())).catalog!; + await finish(runtimeSessionId, { + ...configure(), + operationId: 'extension-configuration', + serverId: extension.serverId, + grant: grant('extension-configuration'), + }); + const otherControl = { + ...configure(), + operationId: 'configuration-2', + configRevision: 2, + sessionKey: otherKey, + grant: { ...grant('configuration-2'), sessionKey: otherKey }, + }; + const sibling = (await finish(otherOwner, otherControl)).catalog!; + const before = ( + await readFile(path.join(directory, 'calls-1'), 'utf8') + ).split('initialize').length; + const next = { version: 1 as const, servers: [independent] }; + await instance.drainExtensionSession(sessionKey.sessionId, next); + instance.installSessionDefinitions(sessionKey.sessionId, next); + expect(instance.hasHolds(runtimeSessionId)).toBe(true); + expect(instance.hasHolds(otherOwner)).toBe(true); + const invokeA = invoke(original, 'same-a', { + kind: 'tool_call', + name: 'echo', + arguments: { preserved: 'a' }, + }); + const resultA = await finish(runtimeSessionId, invokeA); + expect(resultA.state).toBe('settled'); + expect(resultA.error).toBeUndefined(); + const invokeB = invoke(sibling, 'same-b', { + kind: 'tool_call', + name: 'echo', + arguments: { preserved: 'b' }, + }); + const resultB = await finish(otherOwner, { + ...invokeB, + sessionKey: otherKey, + grant: { ...invokeB.grant, sessionKey: otherKey }, + }); + expect(resultB.state).toBe('settled'); + expect(resultB.error).toBeUndefined(); + expect( + (await readFile(path.join(directory, 'calls-1'), 'utf8')).split( + 'initialize', + ).length, + ).toBe(before); + expect( + ( + await finish(runtimeSessionId, { + kind: 'mcp-discover', + sessionKey, + operationId: 'unchanged-discovery', + serverId: original.serverId, + serverRevision: original.serverRevision, + connectionGeneration: original.connectionGeneration, + grant: grant('configuration-1'), + }) + ).catalog, + ).toMatchObject({ + configRevision: original.configRevision, + connectionGeneration: original.connectionGeneration, + }); + }); + it('isolates pending Session lookups while preserving the Workspace drain total', async () => { vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue( path.join(directory, 'global'), @@ -263,6 +399,12 @@ describe('Managed MCP Runtime', () => { expect(instance.getDrainInspection().pendingStarts).toBe(1); expect(instance.hasPendingWork(otherRuntime)).toBe(true); expect(instance.hasPendingWork(runtimeSessionId)).toBe(false); + expect(instance.hasExtensionPendingOperations(sessionKey.sessionId)).toBe( + false, + ); + expect(instance.hasExtensionPendingOperations(otherKey.sessionId)).toBe( + true, + ); await expect( executor.controlFileHistory(sessionKey.sessionId, runtimeSessionId, { kind: 'raw-file-history', diff --git a/packages/cli/src/serve/managed-mcp-runtime.ts b/packages/cli/src/serve/managed-mcp-runtime.ts index 203a9103097..983e962523e 100644 --- a/packages/cli/src/serve/managed-mcp-runtime.ts +++ b/packages/cli/src/serve/managed-mcp-runtime.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { managedExtensionEnvironment } from './managed-extension-environment.js'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -34,6 +35,8 @@ import { } from '@qwen-code/qwen-code-core/managed-runtime/managed-mcp-protocol.js'; import type { ManagedSessionKey } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; import { launchManagedStdio } from './managed-stdio-supervisor.js'; +import type { ManagedExtensionProcessOwners } from './managed-extension-process.js'; +import { ManagedExtensionStdioTransport } from './managed-extension-stdio.js'; export interface ManagedMcpDefinition { readonly tenantId: string; @@ -333,6 +336,7 @@ function parseManifest(value: unknown): ManagedMcpManifest { export class ManagedMcpRuntime { private readonly definitions: ManagedMcpManifest; + private readonly sessionDefinitions = new Map(); private readonly connections = new Map(); private readonly operations = new Map(); private readonly gates = new ManagedOperationGrantGate(); @@ -342,6 +346,7 @@ export class ManagedMcpRuntime { private admissionSealed = false; private grantInstalled = false; private readonly pendingStarts = new Map(); + private readonly extensionPendingStarts = new Map(); constructor( private readonly workspace: { @@ -353,10 +358,170 @@ export class ManagedMcpRuntime { runtimeSessionId: string, ) => Promise, manifest: ManagedMcpManifest, + private readonly extensionProcesses?: ManagedExtensionProcessOwners, ) { this.definitions = parseManifest(manifest); } + resolvePinnedDefinitions( + pins: ReadonlyArray<{ + serverId: string; + serverRevision: number; + definitionDigest: string; + }>, + ): readonly ManagedMcpDefinition[] { + const available = [ + ...this.definitions.servers, + ...[...this.sessionDefinitions.values()].flatMap( + (manifest) => manifest.servers, + ), + ]; + return pins.map((pin) => { + const found = available.find( + (entry) => + entry.serverId === pin.serverId && + entry.serverRevision === pin.serverRevision && + entry.definitionDigest === pin.definitionDigest, + ); + if (!found) + throw new ManagedMcpError('managed_mcp_definition_unavailable'); + return structuredClone(found); + }); + } + + installSessionDefinitions( + runtimeSessionId: string, + manifest: ManagedMcpManifest, + ): void { + const parsed = parseManifest(manifest); + if ( + [...this.connections.values()].some( + (entry) => + entry.sessionKey.sessionId === runtimeSessionId && + (entry.pending.size > 0 || + (!entry.closed && !this.matchesDefinition(entry, parsed))), + ) || + this.hasExtensionPendingOperations(runtimeSessionId) + ) + throw new ManagedMcpError('managed_mcp_session_busy'); + if ( + parsed.servers.some( + (entry) => + entry.tenantId !== this.workspace.tenantId || + entry.workspaceId !== this.workspace.workspaceId, + ) + ) + throw new ManagedMcpError('managed_mcp_scope_conflict'); + this.sessionDefinitions.set(runtimeSessionId, parsed); + } + + hasExtensionPendingOperations(sessionId: string): boolean { + return ( + this.extensionPendingStarts.has(sessionId) || + [...this.operations.values()].some( + (entry) => + entry.control.sessionKey.sessionId === sessionId && + entry.view.state !== 'settled', + ) + ); + } + + hasUnsettledExtensionOperations(): boolean { + return ( + this.pendingStarts.size > 0 || + [...this.operations.values()].some( + (entry) => entry.view.state !== 'settled', + ) + ); + } + + matchingSessionDefinition( + sessionId: string, + serverId: string, + digest: string, + ): ManagedMcpDefinition | undefined { + return this.sessionDefinitions + .get(sessionId) + ?.servers.find( + (entry) => + entry.serverId === serverId && entry.definitionDigest === digest, + ); + } + + private matchesDefinition( + connection: Connection, + manifest: ManagedMcpManifest, + ): boolean { + return ( + !connection.retiring && + manifest.servers.some( + (entry) => + entry.serverId === connection.definition.serverId && + entry.serverRevision === connection.definition.serverRevision && + entry.definitionDigest === connection.definition.definitionDigest, + ) + ); + } + + retainedExtensionOwnerIds( + sessionId: string, + manifest: ManagedMcpManifest, + ): ReadonlySet { + return new Set( + [...this.connections.values()] + .filter( + (entry) => + entry.sessionKey.sessionId === sessionId && + !entry.closed && + this.matchesDefinition(entry, manifest), + ) + .flatMap((entry) => + entry.transport instanceof ManagedExtensionStdioTransport && + entry.transport.processOwnerId + ? [entry.transport.processOwnerId] + : [], + ), + ); + } + + async drainExtensionSession( + sessionId: string, + next?: ManagedMcpManifest, + ): Promise { + if (this.hasExtensionPendingOperations(sessionId)) + throw new ManagedMcpError('managed_mcp_drain_unknown'); + await Promise.all( + [...this.connections.values()] + .filter( + (entry) => + entry.sessionKey.sessionId === sessionId && + (!next || !this.matchesDefinition(entry, next)), + ) + .map((entry) => this.closeConnection(entry)), + ); + if ( + [...this.connections.values()].some( + (entry) => + entry.sessionKey.sessionId === sessionId && + (!next || !this.matchesDefinition(entry, next)) && + (!entry.closed || entry.pending.size), + ) + ) + throw new ManagedMcpError('managed_mcp_drain_unknown'); + } + + async drainSession(runtimeSessionId: string): Promise { + if (this.hasPendingWork(runtimeSessionId)) + throw new ManagedMcpError('managed_mcp_drain_unknown'); + await Promise.all( + [...this.connections.values()] + .filter((entry) => entry.runtimeSessionId === runtimeSessionId) + .map((entry) => this.closeConnection(entry)), + ); + if (this.hasHolds(runtimeSessionId)) + throw new ManagedMcpError('managed_mcp_drain_unknown'); + } + sealAdmission(): void { this.admissionSealed = true; } @@ -493,6 +658,11 @@ export class ManagedMcpRuntime { throw new ManagedMcpError('managed_mcp_grant_invalid'); let directory: string | undefined; if (control.kind !== 'mcp-release') { + this.extensionPendingStarts.set( + control.sessionKey.sessionId, + (this.extensionPendingStarts.get(control.sessionKey.sessionId) ?? 0) + + 1, + ); this.pendingStarts.set( runtimeSessionId, (this.pendingStarts.get(runtimeSessionId) ?? 0) + 1, @@ -500,6 +670,14 @@ export class ManagedMcpRuntime { try { directory = await this.sessionDirectory(runtimeSessionId); } finally { + const scopeRemaining = + this.extensionPendingStarts.get(control.sessionKey.sessionId)! - 1; + if (scopeRemaining) + this.extensionPendingStarts.set( + control.sessionKey.sessionId, + scopeRemaining, + ); + else this.extensionPendingStarts.delete(control.sessionKey.sessionId); const remaining = this.pendingStarts.get(runtimeSessionId)! - 1; if (remaining) this.pendingStarts.set(runtimeSessionId, remaining); else this.pendingStarts.delete(runtimeSessionId); @@ -674,7 +852,10 @@ export class ManagedMcpRuntime { ): Promise { const control = operation.control; if (control.kind === 'mcp-configure') { - const definition = this.definitions.servers.find( + const definition = ( + this.sessionDefinitions.get(control.sessionKey.sessionId) ?? + this.definitions + ).servers.find( (item) => item.tenantId === control.sessionKey.tenantId && item.workspaceId === control.sessionKey.workspaceId && @@ -706,43 +887,56 @@ export class ManagedMcpRuntime { throw new ManagedMcpError('managed_mcp_connection_quota'); const transport = definition.transport === 'stdio' - ? (() => { - const env = { - ...Object.fromEntries( - DEFAULT_INHERITED_ENV_VARS.map((key) => [key, '']), + ? this.extensionProcesses + ? new ManagedExtensionStdioTransport(this.extensionProcesses, { + sessionId: control.sessionKey.sessionId, + configRevision: control.configRevision, + executable: definition.command!, + args: definition.args ?? [], + env: managedExtensionEnvironment( + process.env, + directory!, + definition.env, ), - PATH: process.env['PATH'] ?? '', - HOME: directory!, - USERPROFILE: directory!, - ...(process.env['SystemRoot'] - ? { SYSTEMROOT: process.env['SystemRoot'] } - : {}), - ...definition.env, - }; - // Windows keeps the SDK transport: its cross-spawn launch still - // resolves .cmd/.bat command shims, which the native launcher - // does not. Containment on Windows is unchanged. - if (process.platform === 'win32') + cwd: directory!, + }) + : (() => { + const env = { + ...Object.fromEntries( + DEFAULT_INHERITED_ENV_VARS.map((key) => [key, '']), + ), + PATH: process.env['PATH'] ?? '', + HOME: directory!, + USERPROFILE: directory!, + ...(process.env['SystemRoot'] + ? { SYSTEMROOT: process.env['SystemRoot'] } + : {}), + ...definition.env, + }; + // Windows keeps the SDK transport: its cross-spawn launch still + // resolves .cmd/.bat command shims, which the native launcher + // does not. Containment on Windows is unchanged. + if (process.platform === 'win32') + return new StdioClientTransport({ + command: definition.command!, + args: definition.args, + cwd: directory!, + stderr: 'pipe', + env, + }); + const launch = launchManagedStdio( + definition.command!, + definition.args ?? [], + env, + ); return new StdioClientTransport({ - command: definition.command!, - args: definition.args, + command: launch.executable, + args: launch.args, cwd: directory!, stderr: 'pipe', - env, + env: launch.env, }); - const launch = launchManagedStdio( - definition.command!, - definition.args ?? [], - env, - ); - return new StdioClientTransport({ - command: launch.executable, - args: launch.args, - cwd: directory!, - stderr: 'pipe', - env: launch.env, - }); - })() + })() : definition.transport === 'streamable-http' ? new StreamableHTTPClientTransport(new URL(definition.url!), { requestInit: { headers: definition.headers, redirect: 'error' }, @@ -807,7 +1001,9 @@ export class ManagedMcpRuntime { try { await client.connect(transport, { timeout: TIMEOUT_MS }); const onmessage = transport.onmessage!; - transport.onmessage = (message) => { + transport.onmessage = ( + message: import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage, + ) => { if ('method' in message) { const changed = { 'notifications/tools/list_changed': 'tools', diff --git a/packages/cli/src/serve/managed-runtime-attestation-contract.ts b/packages/cli/src/serve/managed-runtime-attestation-contract.ts index 23ae17ea874..8eaf7997514 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-contract.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-contract.ts @@ -363,7 +363,15 @@ export function isOwnedManagedRuntimeRoute( url: string | undefined, routes: readonly DeclaredManagedRuntimeRoute[], ): boolean { - return routes.some((route) => method === route.method && url === route.path); + return routes.some((route) => { + if (method !== route.method || !url) return false; + if (url === route.path) return true; + if (!route.path.endsWith('/:digest')) return false; + const prefix = route.path.slice(0, -':digest'.length); + return ( + url.startsWith(prefix) && /^[0-9a-f]{64}$/u.test(url.slice(prefix.length)) + ); + }); } /** Admits exactly the declared routes, by default those of boot v1. */ diff --git a/packages/cli/src/serve/managed-runtime-attestation-worker.ts b/packages/cli/src/serve/managed-runtime-attestation-worker.ts index 80d72c5fe6d..85a197cea27 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-worker.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-worker.ts @@ -34,6 +34,12 @@ import { } from './managed-runtime-tool-executor.js'; import { managedRuntimeLedgerFromEnvironment } from './managed-runtime-ledger.js'; import type { ManagedSshRuntime } from './managed-runtime-ssh-worker.js'; +import { + MANAGED_EXTENSION_RETIRE_ROUTE, + MANAGED_EXTENSION_RUNTIME_ROUTE, + MANAGED_EXTENSION_UPLOAD_ROUTE, +} from './managed-extension-runtime.js'; +import { MANAGED_EXTENSION_COMMAND_ROUTE } from './managed-extension-command-runtime.js'; import { PUBLICATION_INSTALL_ROUTE } from './remote-shell-result-publication.js'; import { WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; import { @@ -316,9 +322,18 @@ export async function startManagedRuntimeAttestationWorker( boot.version === 2 ? capturePublisher || remotePublishers || - boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST + boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + boot.managedExtensions !== undefined ? [ ...MANAGED_CONTEXT_WORKER_ROUTES, + ...(boot.managedExtensions + ? [ + MANAGED_EXTENSION_RUNTIME_ROUTE, + MANAGED_EXTENSION_UPLOAD_ROUTE, + MANAGED_EXTENSION_RETIRE_ROUTE, + MANAGED_EXTENSION_COMMAND_ROUTE, + ] + : []), ...(csiBoot ? [ MANAGED_CSI_ATTEST_ROUTE, @@ -330,12 +345,21 @@ export async function startManagedRuntimeAttestationWorker( ...(sshRuntime ? [sshRuntime.route] : []), ...(remotePublishers ? [MANAGED_SHELL_PUBLISHER_ROUTE] : []), ...(!capturePublisher && - boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST + (boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + boot.managedExtensions !== undefined) ? [PUBLICATION_INSTALL_ROUTE] : []), ] : [ ...MANAGED_CONTEXT_WORKER_ROUTES, + ...(boot.managedExtensions + ? [ + MANAGED_EXTENSION_RUNTIME_ROUTE, + MANAGED_EXTENSION_UPLOAD_ROUTE, + MANAGED_EXTENSION_RETIRE_ROUTE, + MANAGED_EXTENSION_COMMAND_ROUTE, + ] + : []), ...(csiBoot ? [ MANAGED_CSI_ATTEST_ROUTE, diff --git a/packages/cli/src/serve/managed-runtime-bootstrap.ts b/packages/cli/src/serve/managed-runtime-bootstrap.ts index 430e02d7ddb..00bfa33b496 100644 --- a/packages/cli/src/serve/managed-runtime-bootstrap.ts +++ b/packages/cli/src/serve/managed-runtime-bootstrap.ts @@ -59,7 +59,9 @@ export interface SshRuntimeRecord { } export interface SshRuntimeCleanProof { - readonly version: 1; + readonly version: 1 | 2; + readonly executionMode?: 'linux-cgroup' | 'developer-process-group'; + readonly scope?: 'process_tree' | 'process_group'; readonly resourceId: string; readonly bindingId: string; readonly bindingGeneration: number; @@ -364,7 +366,16 @@ export function sshCleanProof( retiredAt: string, ): SshRuntimeCleanProof { return { - version: 1, + version: record.boot.managedExtensions ? 2 : 1, + ...(record.boot.managedExtensions + ? { + executionMode: record.boot.managedExtensions.mode, + scope: + record.boot.managedExtensions.mode === 'linux-cgroup' + ? ('process_tree' as const) + : ('process_group' as const), + } + : {}), resourceId: record.resourceId, bindingId: record.bindingId, bindingGeneration: record.bindingGeneration, diff --git a/packages/cli/src/serve/managed-runtime-ledger.ts b/packages/cli/src/serve/managed-runtime-ledger.ts index 19c9ed7d0a6..f890d004508 100644 --- a/packages/cli/src/serve/managed-runtime-ledger.ts +++ b/packages/cli/src/serve/managed-runtime-ledger.ts @@ -59,6 +59,7 @@ const POSIX_PS = '/bin/ps'; const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; export interface ManagedRuntimeLedgerWorkerRecord { + readonly groupOwned?: boolean; readonly pid: number; /** The worker's own process group; its pid on Windows, which has none. */ readonly pgid: number; @@ -697,6 +698,33 @@ export class ManagedRuntimeLedger { return state; } + async stopWitnessedGroup( + pgid: number, + budgetMs: number, + ): Promise { + const record = this.groups.get(pgid); + if (record === undefined) return 'gone'; + const identity = judgeGroupIdentity( + record, + queryTableQuietly(), + Date.now(), + ); + if (identity === 'gone' || identity === 'recycled') + return this.waitForGroupExit(pgid, 0); + if (identity !== 'ours') return 'denied'; + signalProcessGroup(pgid, 'SIGTERM'); + if ((await this.waitForGroupExit(pgid, 1000)) === 'gone') return 'gone'; + const remainingIdentity = judgeGroupIdentity( + record, + queryTableQuietly(), + Date.now(), + ); + if (remainingIdentity === 'gone' || remainingIdentity === 'recycled') + return this.waitForGroupExit(pgid, 0); + if (remainingIdentity !== 'ours') return 'denied'; + return this.stopGroup(pgid, budgetMs); + } + async stopGroup(pgid: number, budgetMs: number): Promise { const record = this.groups.get(pgid); if (record === undefined) return 'gone'; @@ -910,6 +938,13 @@ export async function sweepWorkerLedger( ); } const document = parseLedgerDocument(raw); + if (document?.worker.groupOwned === false) { + throw new LedgerSweepUnprovenError( + workFile, + [document.worker.pgid], + 'Worker process group belongs to its host; only explicit extension-owner reconciliation may stop its registered groups.', + ); + } if (!document) { // A ledger nobody can read is an operator's evidence, not a retryable // stop: a reaper would re-read the same immutable bytes forever and diff --git a/packages/cli/src/serve/managed-runtime-tool-executor.ts b/packages/cli/src/serve/managed-runtime-tool-executor.ts index 6a53e89eb2c..247dc71f550 100644 --- a/packages/cli/src/serve/managed-runtime-tool-executor.ts +++ b/packages/cli/src/serve/managed-runtime-tool-executor.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ManagedExtensionExecutionUnknownError } from './managed-extension-error.js'; import path from 'node:path'; import type { RetainedFileHistoryStorage } from '@qwen-code/qwen-code-core/services/fileHistoryService.js'; import { promises as fs } from 'node:fs'; +import type { ShellRawCaptureSink } from '@qwen-code/qwen-code-core/services/shellExecutionService.js'; import { lstat, readlink, realpath } from 'node:fs/promises'; import { isDeepStrictEqual } from 'node:util'; import { @@ -209,6 +211,12 @@ export class ManagedToolUnavailableError extends Error { /** The admitted tools, keyed by name, over one configuration. */ export interface ManagedToolSet { + readonly extensionAssets?: true; + readonly runShell?: ( + input: Readonly>, + signal: AbortSignal, + capture?: ShellRawCaptureSink, + ) => Promise; readonly retainedFileHistory?: RetainedFileHistoryStorage; readonly fileHistory?: ManagedRuntimeFileHistory; /** @@ -238,6 +246,10 @@ export interface ManagedToolSet { */ export type ManagedToolSetResolver = ( reference: ManagedToolReference, + invocation?: { + readonly toolName: string; + readonly input: Readonly>; + }, ) => Promise; const ADMITTED_TOOL_NAMES: ReadonlySet = new Set([ @@ -373,6 +385,18 @@ export class ManagedToolExecutor { private csiClosing: Promise | undefined; private retirementId?: string; private providerWasUsed = false; + private extensionLifecycle?: { + readonly mode: 'linux-cgroup' | 'developer-process-group'; + readonly drain: () => Promise; + }; + + attachExtensionLifecycle( + value: NonNullable, + ): void { + if (this.extensionLifecycle) + throw new Error('Extension lifecycle is already attached.'); + this.extensionLifecycle = value; + } private capturePreparationStarted = false; private historyControlOutcomeUnknown = false; private ledgerIntegrityFailed = false; @@ -1017,7 +1041,10 @@ export class ManagedToolExecutor { if (existing?.refusal) return existing.refusal; if (!existing) { this.assertAdmissionOpen(); - const tools = await this.toolsFor(reference); + const tools = + input['managedExtension'] === undefined + ? await this.toolsFor(reference) + : await this.toolsFor(reference, { toolName, input }); this.assertLegacySession(reference.sessionId); if (!this.isAdmissionOpen || !tools || tools.isActive?.() === false) throw new ManagedToolUnavailableError( @@ -1079,7 +1106,10 @@ export class ManagedToolExecutor { return join(existing, reference, toolName, inputJson); } this.assertAdmissionOpen(); - const tools = await this.toolsFor(reference); + const tools = + input['managedExtension'] === undefined + ? await this.toolsFor(reference) + : await this.toolsFor(reference, { toolName, input }); this.assertLegacySession(reference.sessionId); // A concurrent execute of the same call may have journaled it meanwhile. const joined = this.entries.get(reference.callId); @@ -1212,7 +1242,10 @@ export class ManagedToolExecutor { 'Tool v3 capture is not available.', ); } - const tools = await this.toolsFor(reference); + const tools = + input['managedExtension'] === undefined + ? await this.toolsFor(reference) + : await this.toolsFor(reference, { toolName, input }); this.assertLegacySession(reference.sessionId); const joined = this.entries.get(reference.callId); if (joined) return this.executeV3(request); @@ -1227,6 +1260,15 @@ export class ManagedToolExecutor { return this.executeV3Monitor(request, tools, structuredClone(input)); } const normalized = structuredClone(input); + delete normalized['managedExtension']; + if ( + input['managedExtension'] !== undefined && + (normalized['is_background'] === true || + normalized['is_monitor'] === true) + ) + throw new ManagedToolConflictError( + 'Extension Shell requires a foreground invocation.', + ); // The background and monitor families reject long waits' shell-side // policies such as the foreground sleep guard — they exist exactly so // that a wait is durable rather than blocked. Validate with their own @@ -1909,6 +1951,25 @@ export class ManagedToolExecutor { } /** Read-only lookup; never creates or advances an invocation. */ + isSessionConfigurationIdle(sessionId: string): boolean { + return ( + !this.closing && + !this.retirementId && + this.pendingStarts.size === 0 && + !this.historyControls.has(sessionId) && + this.hooks?.hasHolds(sessionId) !== true && + this.mcp?.hasPendingWork(sessionId) !== true && + this.provider?.hasActiveSession(sessionId) !== true && + !this.backgroundRegistry.hasHolds(sessionId) && + !this.monitorRegistry.hasHolds(sessionId) && + ![...this.entries.values()].some( + (entry) => + entry.reference.sessionId === sessionId && + !['settled', 'acknowledged'].includes(entry.state), + ) + ); + } + hasActiveSession(sessionId: string): boolean { return ( this.hooks?.hasHolds(sessionId) === true || @@ -2085,9 +2146,13 @@ export class ManagedToolExecutor { this.pendingStarts.size > 0 || this.historyControls.size > 0 || this.providerWasUsed || - this.mcpCalls.size > 0 || - this.mcp?.getDrainInspection().hasActivity || - this.hooks?.getDrainInspection().hasActivity || + (!this.extensionLifecycle && + (this.mcpCalls.size > 0 || + this.mcp?.getDrainInspection().hasActivity || + this.hooks?.getDrainInspection().hasActivity)) || + (this.extensionLifecycle && + (this.mcp?.hasUnsettledExtensionOperations() || + this.hooks?.hasUnsettledExtensionOperations())) || [...this.entries.values()].some((entry) => entry.version === 3 ? entry.state !== 'settled' || @@ -2106,6 +2171,7 @@ export class ManagedToolExecutor { this.backgroundRegistry.stopAll(5_000), this.monitorRegistry.stopAll(5_000), ]); + await this.extensionLifecycle?.drain(); const remaining = await ledger.killOutstanding(); if ( ledger.hasIntegrityFailure || @@ -2180,6 +2246,7 @@ export class ManagedToolExecutor { // Runs start only from a fresh journal entry, which still carries its // input; only an acknowledged entry loses it, and nothing runs that. const params = structuredClone(entry.input!); + delete params['managedExtension']; let fileInvocation: AnyToolInvocation | undefined; if ( directory && @@ -2350,6 +2417,12 @@ export class ManagedToolExecutor { return sessionIdContext.run(sessionId, () => { const invocation = fileInvocation ?? tool.build(params); invocationStarted = true; + if (entry.toolName === ShellTool.Name && tools.runShell) + return tools.runShell( + params, + entry.controller.signal, + entry.captureSink, + ); const ledger = this.options.ledger; const trackGroup = (pid: number) => { if (!ledger) return; @@ -2402,9 +2475,11 @@ export class ManagedToolExecutor { return invocation.execute(entry.controller.signal); }); }; - const registeredHistory = this.fileHistories.get( - entry.reference.sessionId, - ); + const registeredHistory = + tools.extensionAssets === true && + [ReadFileTool.Name, GlobTool.Name].includes(entry.toolName) + ? undefined + : this.fileHistories.get(entry.reference.sessionId); const history = tools.fileHistory ?? registeredHistory; const mutating = [WriteFileTool.Name, EditTool.Name].includes( entry.toolName, @@ -2502,6 +2577,11 @@ export class ManagedToolExecutor { } payload = toPayload(result, ManagedToolExecutor.isCancelRequested(entry)); } catch (error) { + if (error instanceof ManagedExtensionExecutionUnknownError) { + entry.state = 'unknown'; + entry.lastSequence++; + return; + } const message = error instanceof Error ? error.message : String(error); payload = { executionStatus: diff --git a/packages/cli/src/serve/managed-workspace-activation.ts b/packages/cli/src/serve/managed-workspace-activation.ts index 0e98666d49b..9deeb99bef4 100644 --- a/packages/cli/src/serve/managed-workspace-activation.ts +++ b/packages/cli/src/serve/managed-workspace-activation.ts @@ -15,6 +15,10 @@ import type { ManagedContextBoot, ManagedContextInstallations, } from './managed-context-envelope.js'; +import { + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, +} from './managed-extension-profile.js'; import { computeManagedContextDigest } from './managed-workspace-binding.js'; import type { ManagedToolExecutor } from './managed-runtime-tool-executor.js'; @@ -86,7 +90,15 @@ export class WorkspaceActivations { (boot.capabilityDigest === RESIDENT_WORKSPACE_CAPABILITY_DIGEST ? boot.isolationClass !== 'workspace' || request['profile'] !== RESIDENT_WORKSPACE_PROFILE - : boot.capabilityDigest !== WORKSPACE_CAPABILITY_DIGEST || + : ![ + WORKSPACE_CAPABILITY_DIGEST, + ...(boot.managedExtensions + ? [ + MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, + MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, + ] + : []), + ].includes(boot.capabilityDigest) || request['profile'] !== WORKSPACE_EXECUTION_PROFILE) || binding === undefined || binding.contextConfigRef !== WORKSPACE_CONTEXT_CONFIG_REF || diff --git a/packages/cli/src/serve/process-env-guard.test.ts b/packages/cli/src/serve/process-env-guard.test.ts index 16a8b8ff48f..7aebb31a3a6 100644 --- a/packages/cli/src/serve/process-env-guard.test.ts +++ b/packages/cli/src/serve/process-env-guard.test.ts @@ -221,8 +221,9 @@ const allowedProcessEnvAccesses = normalizeAllowances([ 'packages/cli/src/serve/managed-context-worker.ts', { reason: - 'Managed Runtime startup selects its deployment-owned MCP and Hook manifests and the delegated cgroup root for process isolation from the process environment; definitions are then scoped by tenant and workspace.', + 'Managed Runtime startup selects its deployment-owned MCP and Hook manifests and the delegated cgroup root for process isolation from the process environment; definitions are then scoped by tenant and workspace. Extension preflight and Shell children copy only the target Runtime environment allowlist through managedExtensionEnvironment, with settings from the selected extension.', accesses: { + whole: 2, 'key:QWEN_MANAGED_HOOK_CGROUP_ROOT': 1, 'key:QWEN_MANAGED_HOOK_CONFIG': 1, 'key:QWEN_MANAGED_MCP_CONFIG': 1, @@ -257,8 +258,9 @@ const allowedProcessEnvAccesses = normalizeAllowances([ 'packages/cli/src/serve/managed-hook-runtime.ts', { reason: - 'Hook commands use the Runtime host PATH and Windows SystemRoot for executable lookup and OS startup, and the deployment-owned cgroup root for process-tree isolation; HOME and USERPROFILE come from the verified Session directory.', + 'Hook commands use the Runtime host PATH and Windows SystemRoot for executable lookup and OS startup, and the deployment-owned cgroup root for process-tree isolation; HOME and USERPROFILE come from the verified Session directory. Extension Hooks pass the target Runtime environment through managedExtensionEnvironment to copy only its allowlist.', accesses: { + whole: 1, 'key:PATH': 1, 'key:QWEN_MANAGED_HOOK_CGROUP_ROOT': 1, 'key:SystemRoot': 2, @@ -289,8 +291,8 @@ const allowedProcessEnvAccesses = normalizeAllowances([ 'packages/cli/src/serve/managed-mcp-runtime.ts', { reason: - 'MCP stdio children use the Runtime host PATH and Windows SystemRoot for executable lookup and OS startup; their remaining environment comes from the verified workspace directory and deployment-owned definition.', - accesses: { 'key:PATH': 1, 'key:SystemRoot': 2 }, + 'MCP stdio children use the Runtime host PATH and Windows SystemRoot for executable lookup and OS startup; their remaining environment comes from the verified workspace directory and deployment-owned definition. Extension MCP children pass the target Runtime environment through managedExtensionEnvironment to copy only its allowlist and the selected extension settings.', + accesses: { 'key:PATH': 1, 'key:SystemRoot': 2, whole: 1 }, }, ], [ diff --git a/packages/cli/src/serve/routes/managed-package-preparation.test.ts b/packages/cli/src/serve/routes/managed-package-preparation.test.ts new file mode 100644 index 00000000000..b69a25ed6f4 --- /dev/null +++ b/packages/cli/src/serve/routes/managed-package-preparation.test.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import { promises as fs } from 'node:fs'; +import type { Server } from 'node:http'; +import path from 'node:path'; +import os from 'node:os'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { registerManagedPackagePreparation } from './managed-package-preparation.js'; +import type { ExtensionsController } from './workspace-extensions-controller.js'; + +const prepare = vi.hoisted(() => vi.fn()); +vi.mock('@qwen-code/qwen-code-core/extension/managed-package.js', () => ({ + prepareManagedExtensionPackage: prepare, + ManagedPackageCompatibilityError: class extends Error {}, +})); + +let server: Server; +let base: string; +let selected: ExtensionsController; +let originalSource: string; +const directories: string[] = []; +const owner = () => + ({ + validateExtensionMutationClient: () => true, + }) as unknown as ExtensionsController; + +beforeEach(async () => { + selected = owner(); + originalSource = await fs.mkdtemp( + path.join(os.tmpdir(), 'managed-prepare-test-'), + ); + directories.push(originalSource); + await fs.writeFile( + path.join(originalSource, 'qwen-extension.json'), + '{"name":"fixture","version":"1"}', + ); + prepare.mockReset().mockImplementation(async (_source, options) => { + directories.push(options.destination); + await fs.writeFile( + path.join(options.destination, 'package.tar.gz'), + 'archive', + ); + return { + packageDigest: 'a'.repeat(64), + manifest: {}, + sourceRevision: 'original', + }; + }); + const app = express(); + app.use(express.json()); + registerManagedPackagePreparation( + app, + '/workspace/extensions', + () => selected, + () => (_req, _res, next) => next(), + ); + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing test port'); + base = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await Promise.all( + directories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +const publish = () => + fetch(`${base}/workspace/extensions/managed-packages/prepare`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: originalSource, + consent: true, + target: { platform: 'linux', arch: 'x64' }, + }), + }); + +it('streams the prepared archive only to its original Workspace controller', async () => { + const original = selected; + const response = await publish(); + expect(response.status).toBe(201); + const result = (await response.json()) as { archiveUrl: string }; + expect(await (await fetch(base + result.archiveUrl)).text()).toBe('archive'); + selected = owner(); + expect((await fetch(base + result.archiveUrl)).status).toBe(404); + selected = original; + expect((await fetch(base + result.archiveUrl)).status).toBe(200); +}); + +it('discards a preparation whose Workspace controller changed before completion', async () => { + prepare.mockImplementationOnce(async (_source, options) => { + directories.push(options.destination); + selected = owner(); + return { + packageDigest: 'a'.repeat(64), + manifest: {}, + sourceRevision: 'original', + }; + }); + const response = await publish(); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: 'managed_package_target_changed', + }); + await vi.waitFor(async () => { + await expect(fs.stat(directories.at(-1)!)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); +}); diff --git a/packages/cli/src/serve/routes/managed-package-preparation.ts b/packages/cli/src/serve/routes/managed-package-preparation.ts new file mode 100644 index 00000000000..cb22c87caca --- /dev/null +++ b/packages/cli/src/serve/routes/managed-package-preparation.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Application, Request, Response, RequestHandler } from 'express'; +import { + prepareManagedExtensionPackage, + ManagedPackageCompatibilityError, +} from '@qwen-code/qwen-code-core/extension/managed-package.js'; +import { parseInstallSource } from '@qwen-code/qwen-code-core/extension/marketplace.js'; +import type { ExtensionsController } from './workspace-extensions-controller.js'; + +export function registerManagedPackagePreparation( + app: Application, + base: string, + resolve: ( + req: Request, + res: Response, + trust: boolean, + ) => ExtensionsController | null, + mutate: (options: { strict: boolean }) => RequestHandler, +): void { + let preparing = 0; + const prepared = new Map< + string, + { directory: string; expiresAt: number; owner: ExtensionsController } + >(); + const sweep = setInterval(() => { + for (const [id, entry] of prepared) { + if (entry.expiresAt <= Date.now()) { + prepared.delete(id); + void fs.rm(entry.directory, { recursive: true, force: true }); + } + } + }, 60_000); + sweep.unref(); + + app.post( + `${base}/managed-packages/prepare`, + mutate({ strict: true }), + async (req, res) => { + const controller = resolve(req, res, true); + if ( + !controller || + !controller.validateExtensionMutationClient(req, res, { + requireClientId: false, + }) + ) + return; + const body: unknown = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) + return res.status(400).json({ code: 'invalid_managed_package_source' }); + const input = body as Record; + const target = input['target'] as + | { platform?: string; arch?: string } + | undefined; + if ( + typeof input['source'] !== 'string' || + input['source'].length > 4096 || + input['consent'] !== true || + !target || + !['linux', 'darwin'].includes(target.platform ?? '') || + !['arm64', 'x64', 'any'].includes(target.arch ?? '') || + (input['ref'] !== undefined && + (typeof input['ref'] !== 'string' || + input['ref'].startsWith('-') || + input['ref'].length > 256)) + ) { + return res.status(400).json({ code: 'invalid_managed_package_source' }); + } + if (prepared.size + preparing >= 16) + return res + .status(429) + .json({ code: 'managed_package_preparation_capacity' }); + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 10 * 60_000); + timer.unref(); + res.once('close', () => { + if (!res.writableEnded) abort.abort(); + }); + let directory: string | undefined; + preparing++; + try { + const source = await parseInstallSource(input['source']); + if ( + (source.type === 'local' && !path.isAbsolute(source.source)) || + source.pluginName || + source.marketplaceConfig + ) { + return res + .status(400) + .json({ code: 'managed_package_original_source_required' }); + } + source.ref = input['ref'] as string | undefined; + directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-managed-publish-'), + ); + const result = await prepareManagedExtensionPackage(source, { + target: target as { + platform: 'linux' | 'darwin'; + arch: 'arm64' | 'x64' | 'any'; + }, + destination: directory, + signal: abort.signal, + }); + if (resolve(req, res, true) !== controller) { + if (!res.headersSent) + res.status(409).json({ code: 'managed_package_target_changed' }); + return; + } + const id = randomUUID(); + prepared.set(id, { + directory, + expiresAt: Date.now() + 15 * 60_000, + owner: controller, + }); + directory = undefined; + return res.status(201).json({ + packageDigest: result.packageDigest, + manifest: result.manifest, + sourceRevision: result.sourceRevision, + archiveUrl: `${req.originalUrl.split('?')[0].replace(/\/managed-packages\/prepare$/, '')}/managed-packages/${id}/archive`, + }); + } catch (error) { + if (!res.headersSent) + res.status(400).json({ + code: 'managed_package_preparation_failed', + error: + error instanceof ManagedPackageCompatibilityError + ? error.message + : 'The original package could not be prepared for the selected target.', + }); + } finally { + clearTimeout(timer); + preparing--; + if (directory) await fs.rm(directory, { recursive: true, force: true }); + } + return; + }, + ); + + app.get( + `${base}/managed-packages/:preparationId/archive`, + async (req, res) => { + const owner = resolve(req, res, true); + if (!owner) return; + const entry = prepared.get(String(req.params['preparationId'])); + if (!entry || entry.expiresAt <= Date.now() || entry.owner !== owner) + return res + .status(404) + .json({ code: 'managed_package_preparation_not_found' }); + const archive = path.join(entry.directory, 'package.tar.gz'); + const stat = await fs.stat(archive).catch(() => undefined); + if (!stat) + return res + .status(404) + .json({ code: 'managed_package_preparation_not_found' }); + res.setHeader('Cache-Control', 'no-store'); + res + .type('application/octet-stream') + .setHeader('Content-Length', stat.size); + const stream = createReadStream(archive, { flags: 'r' }); + stream.on('error', () => res.destroy()); + res.once('close', () => stream.destroy()); + stream.pipe(res); + return; + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 5791a473969..2be67b15c05 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { registerManagedPackagePreparation } from './managed-package-preparation.js'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; @@ -861,6 +862,7 @@ export function registerWorkspaceExtensionRoutes( } const registerFor = (base: string, resolve: ResolveController): void => { + registerManagedPackagePreparation(app, base, resolve, mutate); // GET {base} — read-only installed extension status. app.get(base, async (req, res) => { const assertGenerationOpen = deps.captureGenerationAssertion?.(); diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index 8e0e0139c92..57d0d84f8dd 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -4,70 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; -import { - parse as parseYaml, - normalizeContent, -} from '@qwen-code/qwen-code-core'; - -/** - * Defines the Zod schema for a Markdown command definition file. - * The frontmatter contains optional metadata, and the body is the prompt. - */ -export const MarkdownCommandDefSchema = z.object({ - frontmatter: z - .object({ - description: z.string().optional(), - 'argument-hint': z.string().optional(), - when_to_use: z.string().optional(), - 'disable-model-invocation': z.boolean().optional(), - }) - .passthrough() - .optional(), - prompt: z.string({ - required_error: 'The prompt content is required.', - invalid_type_error: 'The prompt content must be a string.', - }), -}); - -export type MarkdownCommandDef = z.infer; - -/** - * Parses a Markdown command file with optional YAML frontmatter. - * @param content The file content - * @returns Parsed command definition with frontmatter and prompt - */ -export function parseMarkdownCommand(content: string): MarkdownCommandDef { - const normalizedContent = normalizeContent(content); - - // Match YAML frontmatter pattern: ---\n...\n---\n - // Allow empty frontmatter: ---\n---\n - const frontmatterRegex = /^---\n(?:([\s\S]*?)\n)?---(?:\n|$)([\s\S]*)$/; - const match = normalizedContent.match(frontmatterRegex); - - if (!match) { - // No frontmatter, entire content is the prompt - return { - prompt: normalizedContent.trim(), - }; - } - - const [, frontmatterYaml = '', body] = match; - - // Parse YAML frontmatter if not empty - let frontmatter: Record | undefined; - if (frontmatterYaml.trim()) { - try { - frontmatter = parseYaml(frontmatterYaml) as Record; - } catch (error) { - throw new Error( - `Failed to parse YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return { - frontmatter, - prompt: body.trim(), - }; -} +export { + MarkdownCommandDefSchema, + parseMarkdownCommand, + type MarkdownCommandDef, +} from '@qwen-code/qwen-code-core/utils/markdown-command.js'; diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index a12b3a0d113..0a924f2d4bd 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -844,6 +844,8 @@ export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, + target?: { platform: string; arch: string }, + archiveSafety: TarArchiveSafetyOptions = {}, ): Promise { const { source, ref } = installMetadata; const { owner, repo } = parseGitHubRepoForReleases(source); @@ -859,7 +861,7 @@ export async function downloadFromGitHubRelease( throw new Error(`No release data found for ${owner}/${repo} at tag ${ref}`); } - const asset = findReleaseAsset(releaseData.assets); + const asset = findReleaseAsset(releaseData.assets, target); let archiveUrl: string | undefined; let isTar = false; let isZip = false; @@ -907,7 +909,12 @@ export async function downloadFromGitHubRelease( } signal?.throwIfAborted(); - await extractArchiveFile(downloadedAssetPath, destination, signal); + await extractArchiveFile( + downloadedAssetPath, + destination, + signal, + archiveSafety, + ); signal?.throwIfAborted(); await fs.promises.unlink(downloadedAssetPath); @@ -921,6 +928,7 @@ export async function downloadFromArchiveUrl( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, + archiveSafety: TarArchiveSafetyOptions = {}, ): Promise { const archiveExtension = getSupportedArchiveExtension(installMetadata.source); if (!archiveExtension) { @@ -953,7 +961,12 @@ export async function downloadFromArchiveUrl( } signal?.throwIfAborted(); - await extractArchiveFile(downloadedAssetPath, destination, signal); + await extractArchiveFile( + downloadedAssetPath, + destination, + signal, + archiveSafety, + ); signal?.throwIfAborted(); await fs.promises.unlink(downloadedAssetPath); } @@ -995,9 +1008,12 @@ export async function extractArchiveFile( assertExtractedArchiveContainsExtensionSource(destination); } -export function findReleaseAsset(assets: Asset[]): Asset | undefined { - const platform = os.platform(); - const arch = os.arch(); +export function findReleaseAsset( + assets: Asset[], + target?: { platform: string; arch: string }, +): Asset | undefined { + const platform = target?.platform ?? os.platform(); + const arch = target?.arch ?? os.arch(); const platformArchPrefix = `${platform}.${arch}.`; const platformPrefix = `${platform}.`; @@ -1011,8 +1027,13 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined { } // Check for platform specific asset - const platformAsset = assets.find((asset) => - asset.name.toLowerCase().startsWith(platformPrefix), + const platformAsset = assets.find( + (asset) => + asset.name.toLowerCase().startsWith(platformPrefix) && + (!target || + !/^(?:x64|arm64|arm|ia32|ppc64|s390x|riscv64)\./.test( + asset.name.toLowerCase().slice(platformPrefix.length), + )), ); if (platformAsset) { return platformAsset; diff --git a/packages/core/src/extension/managed-package.test.ts b/packages/core/src/extension/managed-package.test.ts new file mode 100644 index 00000000000..5532e948c79 --- /dev/null +++ b/packages/core/src/extension/managed-package.test.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as tar from 'tar'; +import { + compileManagedExtensionPackage, + prepareManagedExtensionPackage, + managedPackageRelativePath, +} from './managed-package.js'; +import { findReleaseAsset } from './github.js'; + +const target = { platform: 'linux', arch: 'x64' } as const; +let temporary: string; +let root: string; +async function file(name: string, content: string): Promise { + const destination = path.join(root, name); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, content); +} + +beforeEach(async () => { + temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-package-test-')); + root = path.join(temporary, 'source'); + await fs.mkdir(root); + await file( + 'qwen-extension.json', + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); +}); +afterEach(async () => { + await fs.rm(temporary, { recursive: true, force: true }); +}); + +describe('Managed raw package compiler', () => { + it('preserves an architecture-independent publication when compiled on either target architecture', async () => { + const prepared = await prepareManagedExtensionPackage( + { type: 'local', source: root }, + { + target: { platform: 'linux', arch: 'any' }, + destination: path.join(temporary, 'prepared'), + }, + ); + const extracted = path.join(temporary, 'target'); + await fs.mkdir(extracted); + await tar.x({ file: prepared.archivePath, cwd: extracted }); + for (const arch of ['x64', 'arm64'] as const) { + const compiled = await compileManagedExtensionPackage(extracted, { + target: { platform: 'linux', arch }, + resolveRecipes: false, + }); + expect(compiled.manifest).toEqual(prepared.manifest); + } + await expect( + compileManagedExtensionPackage(extracted, { + target: { platform: 'darwin', arch: 'arm64' }, + }), + ).rejects.toThrow('package target does not match this Runtime'); + }); + + it('compiles all seven categories using only target roots and supplied settings', async () => { + await file( + 'qwen-extension.json', + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + mcpServers: { + echo: { + command: 'node', + args: ['${extensionPath}/server.js'], + env: { TOKEN: '${TOKEN}' }, + }, + }, + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'node ${CLAUDE_PLUGIN_ROOT}/hook.js', + }, + ], + }, + ], + }, + settings: [{ name: 'Token', envVar: 'TOKEN', sensitive: true }], + }), + ); + await file( + 'skills/inspect/SKILL.md', + '---\nname: inspect\ndescription: inspect assets\ndisable-model-invocation: true\n---\nRead assets/data.txt.', + ); + await file('skills/inspect/assets/data.txt', 'durable asset'); + await file( + 'commands/review.md', + '---\ndescription: Review\nargument-hint: \n---\nReview @{README.md} and !{git status}.', + ); + await file( + 'agents/reviewer.md', + '---\nname: reviewer\ndescription: Reviews changes\ntools: Read, Glob\nmodel: inherit\n---\nReview precisely.', + ); + await file('QWEN.md', 'Use the fixed context.'); + const before = { cwd: process.cwd(), token: process.env['TOKEN'] }; + const compiled = await compileManagedExtensionPackage(root, { + target, + workspaceDirectory: '/target/workspace', + environment: { TOKEN: 'explicit-value' }, + }); + expect(compiled.manifest.components).toEqual([ + 'skills', + 'commands', + 'agents', + 'context', + 'mcp', + 'hooks', + 'settings', + ]); + expect(compiled.contributions.skills[0]).toMatchObject({ + name: 'fixture:inspect', + assetDirectory: 'skills/inspect', + model_invocable: false, + user_invocable: true, + }); + expect(compiled.contributions.commands[0].prompt).toContain( + '!{git status}', + ); + expect(compiled.contributions.mcpServers).toMatchObject({ + echo: { + args: [path.join(root, 'server.js')], + env: { TOKEN: 'explicit-value' }, + }, + }); + expect({ cwd: process.cwd(), token: process.env['TOKEN'] }).toEqual(before); + }); + + it.each([ + 'allowed-tools: Read', + 'hooks: {PreToolUse: []}', + 'model: large', + 'paths: [src/**]', + ])( + 'rejects dynamic Skill declarations before static conversion: %s', + async (declaration) => { + await file( + 'skills/unsafe/SKILL.md', + `---\nname: unsafe\ndescription: fixture\n${declaration}\n---\nBody`, + ); + await expect( + compileManagedExtensionPackage(root, { target }), + ).rejects.toThrow('unsupported'); + }, + ); + + it('checks unsupported fields in source format before a converter can discard them', async () => { + await fs.rm(path.join(root, 'qwen-extension.json')); + await file( + 'gemini-extension.json', + JSON.stringify({ name: 'source', version: '1', lspServers: { x: {} } }), + ); + await expect( + compileManagedExtensionPackage(root, { target }), + ).rejects.toThrow('lspServers'); + }); + + it('never silently reads a publisher environment variable', async () => { + await file( + 'qwen-extension.json', + JSON.stringify({ + name: 'fixture', + mcpServers: { x: { command: '${HOME}/bin/server' } }, + }), + ); + await expect( + compileManagedExtensionPackage(root, { target }), + ).rejects.toThrow('required'); + const raw = await compileManagedExtensionPackage(root, { + target, + resolveRecipes: false, + }); + expect(raw.contributions.mcpServers).toMatchObject({ + x: { command: '${HOME}/bin/server' }, + }); + }); + + it('prepares raw recipes, strips private sidecars and preserves assets and executable modes', async () => { + await file( + 'qwen-extension.json', + JSON.stringify({ + name: 'fixture', + mcpServers: { + x: { command: 'node', args: ['${extensionPath}/run.js'] }, + }, + }), + ); + await file('run.js', 'console.log("ok")'); + await fs.chmod(path.join(root, 'run.js'), 0o755); + await file('.env', 'DO_NOT_DISTRIBUTE=value'); + await file('.qwen-extension-settings.json', 'DO_NOT_DISTRIBUTE'); + const result = await prepareManagedExtensionPackage( + { source: root, type: 'local' }, + { target, destination: path.join(temporary, 'output') }, + ); + const restored = path.join(temporary, 'restored'); + await fs.mkdir(restored); + await tar.x({ file: result.archivePath, cwd: restored }); + expect( + await fs.readFile(path.join(restored, 'qwen-extension.json'), 'utf8'), + ).toContain('${extensionPath}'); + expect(await fs.readdir(restored)).not.toContain('.env'); + expect(await fs.readdir(restored)).not.toContain( + '.qwen-extension-settings.json', + ); + expect((await fs.stat(path.join(restored, 'run.js'))).mode & 0o111).toBe( + 0o111, + ); + expect(result.packageDigest).toMatch(/^[a-f0-9]{64}$/); + }); + + it('refuses an installed copy without a separately supplied original source', async () => { + await file('.qwen-extension-install.json', '{}'); + await expect( + prepareManagedExtensionPackage( + { source: root, type: 'local' }, + { target, destination: path.join(temporary, 'output') }, + ), + ).rejects.toThrow('original source'); + }); + + it('rejects symbolic links and package root escapes', async () => { + await fs.symlink('/etc/passwd', path.join(root, 'outside')); + await expect( + compileManagedExtensionPackage(root, { target }), + ).rejects.toThrow('unsupported resource'); + for (const unsafe of ['../x', '/x', 'C:/x', 'a/../x', 'a\\x', 'a//x']) + expect(() => managedPackageRelativePath(unsafe)).toThrow(); + }); + + it('chooses the target release asset when publication happens on another OS', () => { + const asset = (name: string) => ({ + name, + browser_download_url: `https://example.test/${name}`, + }); + const assets = [ + asset('darwin.arm64.package.tar.gz'), + asset('linux.x64.package.tar.gz'), + ]; + expect(findReleaseAsset(assets, target)?.name).toBe( + 'linux.x64.package.tar.gz', + ); + expect( + findReleaseAsset(assets, { platform: 'linux', arch: 'arm64' }), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extension/managed-package.ts b/packages/core/src/extension/managed-package.ts new file mode 100644 index 00000000000..5af6241bd27 --- /dev/null +++ b/packages/core/src/extension/managed-package.ts @@ -0,0 +1,792 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { parse as parseToml } from '@iarna/toml'; +import * as tar from 'tar'; +import { + parseMarkdownCommand, + MarkdownCommandDefSchema, +} from '../utils/markdown-command.js'; +import { parseSkillContent } from '../skills/skill-load.js'; +import { qualifySkillName } from '../skills/types.js'; +import { resolveBuiltinToolName } from '../tools/tool-names.js'; +import { + convertClaudeToQwenConfig, + type ClaudePluginConfig, +} from './claude-converter.js'; +import { convertGeminiToQwenConfig } from './gemini-converter.js'; +import { + cloneFromGit, + downloadFromArchiveUrl, + downloadFromGitHubRelease, + extractArchiveFile, +} from './github.js'; +import { downloadFromNpmRegistry } from './npm.js'; +import { + MAX_ARCHIVE_ENTRIES, + MAX_ARCHIVE_EXPANDED_BYTES, +} from './archive-safety.js'; +import type { ExtensionInstallMetadata } from '../config/config.js'; + +export const MANAGED_PACKAGE_COMPILER_VERSION = 1; +export const MANAGED_PACKAGE_MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; +export type ManagedPackageComponent = + | 'skills' + | 'commands' + | 'agents' + | 'context' + | 'mcp' + | 'hooks' + | 'settings'; +export interface ManagedPackageTarget { + platform: 'linux' | 'darwin'; + arch: 'arm64' | 'x64' | 'any'; +} +export interface ManagedPackageManifest { + schemaVersion: 1; + compilerVersion: 1; + name: string; + version: string; + target: ManagedPackageTarget; + components: ManagedPackageComponent[]; + sourceType?: string; + sourceRevision?: string; +} +export interface ManagedPackageSkill { + name: string; + description: string; + body: string; + assetDirectory: string; + model_invocable: boolean; + user_invocable: boolean; +} +export interface ManagedPackageCommand { + name: string; + description: string; + prompt: string; + argumentHint?: string; + modelInvocable: boolean; +} +export interface ManagedPackageAgent { + name: string; + description: string; + systemPrompt: string; + tools?: string[]; + disallowedTools?: string[]; + model?: string; +} +export interface ManagedPackageContributions { + skills: ManagedPackageSkill[]; + commands: ManagedPackageCommand[]; + agents: ManagedPackageAgent[]; + context: Array<{ path: string; body: string }>; + mcpServers: Record; + hooks: Record; + settings: Array<{ name: string; envVar: string; sensitive?: boolean }>; +} +export interface CompiledManagedPackage { + manifest: ManagedPackageManifest; + contributions: ManagedPackageContributions; +} +export class ManagedPackageCompatibilityError extends Error { + constructor(readonly reasons: readonly string[]) { + super(`Managed extension package is incompatible: ${reasons.join(', ')}`); + this.name = 'ManagedPackageCompatibilityError'; + } +} + +function object(value: unknown, field: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ManagedPackageCompatibilityError([`${field}: invalid object`]); + return value as Record; +} + +function text(value: unknown, field: string, maxBytes = 512 * 1024): string { + if ( + typeof value !== 'string' || + !value.trim() || + Buffer.byteLength(value) > maxBytes + ) + throw new ManagedPackageCompatibilityError([`${field}: invalid text`]); + return value; +} + +export function managedPackageRelativePath(value: string): string { + const normalized = value.replace(/^\.\//, ''); + if ( + !normalized || + normalized.includes('\\') || + normalized.includes(':') || + normalized.includes('\0') || + normalized.startsWith('/') || + normalized.split('/').some((part) => !part || part === '.' || part === '..') + ) + throw new ManagedPackageCompatibilityError([ + 'package resource path escapes its root', + ]); + return normalized; +} + +function frontmatter(content: string): { + metadata: Record; + body: string; +} { + const parsed = parseMarkdownCommand(content); + return { metadata: parsed.frontmatter ?? {}, body: parsed.prompt }; +} + +function hasValue(value: unknown): boolean { + return ( + value !== undefined && + value !== null && + value !== false && + value !== '' && + (!Array.isArray(value) || value.length > 0) && + (typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length > 0) + ); +} + +function rejectFields( + value: Record, + fields: readonly string[], + source: string, +): void { + const reasons = fields + .filter((key) => hasValue(value[key])) + .map((key) => `${source}.${key}: unsupported`); + if (reasons.length) throw new ManagedPackageCompatibilityError(reasons); +} + +const PRIVATE_FILES = new Set([ + '.git', + '.env', + '.qwen-extension-settings.json', + '.qwen-extension-git-credentials.json', + '.qwen-extension-install.json', +]); + +async function filesUnder( + root: string, + excludePrivate = false, +): Promise { + const files: string[] = []; + let total = 0; + const visit = async (relative: string): Promise => { + const entries = await fs.readdir(path.join(root, relative), { + withFileTypes: true, + }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if ( + excludePrivate && + (PRIVATE_FILES.has(entry.name) || entry.name.startsWith('.env.')) + ) + continue; + const name = relative ? `${relative}/${entry.name}` : entry.name; + managedPackageRelativePath(name); + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) + throw new ManagedPackageCompatibilityError([ + `${name}: unsupported resource type`, + ]); + if (entry.isDirectory()) await visit(name); + else { + total += (await fs.lstat(path.join(root, name))).size; + files.push(name); + if ( + files.length > MAX_ARCHIVE_ENTRIES || + total > MAX_ARCHIVE_EXPANDED_BYTES + ) + throw new ManagedPackageCompatibilityError([ + 'package resource limit exceeded', + ]); + } + } + }; + await visit(''); + return files; +} + +async function readText(root: string, relative: string): Promise { + const file = managedPackageRelativePath(relative); + const info = await fs.lstat(path.join(root, file)); + if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) + throw new ManagedPackageCompatibilityError([ + `${file}: invalid text resource`, + ]); + return new TextDecoder('utf-8', { fatal: true }).decode( + await fs.readFile(path.join(root, file)), + ); +} + +async function readObject( + root: string, + relative: string, +): Promise> { + return object(JSON.parse(await readText(root, relative)), relative); +} + +async function rawConfig( + root: string, + files: readonly string[], +): Promise> { + if (files.includes('qwen-extension.json')) + return readObject(root, 'qwen-extension.json'); + if (files.includes('gemini-extension.json')) { + const raw = await readObject(root, 'gemini-extension.json'); + rejectFields( + raw, + ['lspServers', 'channels', 'workflows', 'executor', 'outputStyles'], + 'gemini-extension.json', + ); + return { ...raw, ...convertGeminiToQwenConfig(root) }; + } + const pluginFile = files.includes('.claude-plugin/plugin.json') + ? '.claude-plugin/plugin.json' + : files.includes('.qoder-plugin/plugin.json') + ? '.qoder-plugin/plugin.json' + : undefined; + if (pluginFile) { + const raw = await readObject(root, pluginFile); + rejectFields( + raw, + ['lspServers', 'channels', 'workflows', 'outputStyles', 'executor'], + pluginFile, + ); + const config: Record = { + ...convertClaudeToQwenConfig(raw as unknown as ClaudePluginConfig), + name: raw['name'], + version: raw['version'] ?? '1.0.0', + }; + for (const key of [ + 'commands', + 'skills', + 'agents', + 'contextFileName', + 'settings', + 'mcpServers', + 'hooks', + ]) + if (raw[key] !== undefined) config[key] = raw[key]; + return config; + } + if (files.includes('plugin.json')) { + const raw = await readObject(root, 'plugin.json'); + if ( + raw['$schema'] !== + 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' + ) + throw new ManagedPackageCompatibilityError([ + 'plugin.json: unsupported schema', + ]); + rejectFields( + raw, + ['lspServers', 'channels', 'workflows', 'executor', 'outputStyles'], + 'plugin.json', + ); + return { ...raw, name: raw['name'], version: raw['version'] ?? '1.0.0' }; + } + throw new ManagedPackageCompatibilityError(['extension manifest missing']); +} + +function resourceRoots( + config: Record, + key: string, + fallback: string, +): string[] { + const value = config[key] ?? fallback; + const roots = typeof value === 'string' ? [value] : value; + if (!Array.isArray(roots) || roots.some((item) => typeof item !== 'string')) + throw new ManagedPackageCompatibilityError([ + `${key}: invalid resource roots`, + ]); + return (roots as string[]).map(managedPackageRelativePath); +} + +function selectedFiles( + files: readonly string[], + roots: readonly string[], +): string[] { + return files.filter((file) => + roots.some((root) => file === root || file.startsWith(`${root}/`)), + ); +} + +function strings(value: unknown, field: string): string[] | undefined { + if (value === undefined) return undefined; + const entries = + typeof value === 'string' + ? value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : value; + if ( + !Array.isArray(entries) || + entries.some((entry) => typeof entry !== 'string' || !entry) + ) + throw new ManagedPackageCompatibilityError([`${field}: invalid list`]); + return (entries as string[]).map( + (entry) => resolveBuiltinToolName(entry) ?? entry, + ); +} + +export async function compileManagedExtensionPackage( + root: string, + options: { + target: ManagedPackageTarget; + workspaceDirectory?: string; + environment?: Readonly>; + resolveRecipes?: boolean; + }, +): Promise { + const files = await filesUnder(root); + if ( + files.some((file) => + file + .split('/') + .some((part) => PRIVATE_FILES.has(part) || part.startsWith('.env.')), + ) + ) + throw new ManagedPackageCompatibilityError([ + 'package contains private settings or credentials', + ]); + const config = await rawConfig(root, files); + let packageTarget = options.target; + if (config['managedTarget'] !== undefined) { + const target = object(config['managedTarget'], 'managedTarget'); + if ( + target['platform'] !== options.target.platform || + (target['arch'] !== 'any' && target['arch'] !== options.target.arch) + ) { + throw new ManagedPackageCompatibilityError([ + 'package target does not match this Runtime', + ]); + } + packageTarget = { + platform: options.target.platform, + arch: target['arch'] as ManagedPackageTarget['arch'], + }; + } + rejectFields( + config, + ['lspServers', 'channels', 'workflows', 'executor', 'outputStyles'], + 'manifest', + ); + if (files.some((file) => /^(?:workflows|channels|lsp)\//.test(file))) + throw new ManagedPackageCompatibilityError([ + 'package contains unsupported executable components', + ]); + const name = text(config['name'], 'name', 128); + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) + throw new ManagedPackageCompatibilityError(['name: invalid identity']); + const contributions: ManagedPackageContributions = { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + }; + for (const file of selectedFiles( + files, + resourceRoots(config, 'skills', 'skills'), + ).filter((file) => file.endsWith('/SKILL.md'))) { + const content = await readText(root, file); + const { metadata } = frontmatter(content); + rejectFields( + metadata, + [ + 'allowedTools', + 'allowed-tools', + 'hooks', + 'paths', + 'arguments', + 'args-file', + ], + file, + ); + if (metadata['model'] !== undefined && metadata['model'] !== 'inherit') + throw new ManagedPackageCompatibilityError([ + `${file}.model: unsupported`, + ]); + const skill = parseSkillContent(content, file); + contributions.skills.push({ + name: qualifySkillName(name, skill.name), + description: skill.description, + body: skill.body, + assetDirectory: path.posix.dirname(file), + model_invocable: skill.disableModelInvocation !== true, + user_invocable: skill.userInvocable !== false, + }); + } + const commandRoots = resourceRoots(config, 'commands', 'commands'); + for (const file of selectedFiles(files, commandRoots).filter((file) => + /\.(?:md|toml)$/.test(file), + )) { + const content = await readText(root, file); + const parsed = file.endsWith('.toml') + ? { metadata: {}, body: '', definition: object(parseToml(content), file) } + : { ...frontmatter(content), definition: undefined }; + if (!parsed.definition) + MarkdownCommandDefSchema.parse({ + frontmatter: parsed.metadata, + prompt: parsed.body, + }); + const metadata = parsed.definition ?? parsed.metadata; + rejectFields( + metadata, + ['allowed-tools', 'allowedTools', 'hooks', 'model', 'context', 'agent'], + file, + ); + const relative = commandRoots.find((entry) => file.startsWith(`${entry}/`)); + const authored = ( + relative ? file.slice(relative.length + 1) : path.posix.basename(file) + ) + .replace(/\.(?:md|toml)$/, '') + .split('/') + .map((part) => part.replaceAll(':', '_')) + .join(':'); + contributions.commands.push({ + name: `${name}:${authored}`, + description: + typeof metadata['description'] === 'string' + ? metadata['description'] + : authored, + prompt: text(parsed.definition?.['prompt'] ?? parsed.body, file), + ...(typeof metadata['argument-hint'] === 'string' + ? { argumentHint: metadata['argument-hint'] } + : {}), + modelInvocable: + metadata['disable-model-invocation'] !== true && + metadata['disable-model-invocation'] !== 'true', + }); + } + for (const file of selectedFiles( + files, + resourceRoots(config, 'agents', 'agents'), + ).filter((file) => file.endsWith('.md'))) { + const { metadata, body } = frontmatter(await readText(root, file)); + rejectFields( + metadata, + [ + 'mcpServers', + 'mcp_servers', + 'hooks', + 'executor', + 'container', + 'isolation', + 'skills', + 'permissionMode', + 'approvalMode', + 'background', + 'memory', + ], + file, + ); + const agent: ManagedPackageAgent = { + name: `${name}:${text(metadata['name'] ?? path.posix.basename(file, '.md'), file, 128)}`, + description: text(metadata['description'], `${file}.description`, 4096), + systemPrompt: text(body, file), + ...(metadata['tools'] !== undefined + ? { tools: strings(metadata['tools'], file) } + : {}), + ...(metadata['disallowedTools'] !== undefined + ? { disallowedTools: strings(metadata['disallowedTools'], file) } + : {}), + ...(metadata['model'] !== undefined + ? { model: text(metadata['model'], `${file}.model`, 512) } + : {}), + }; + contributions.agents.push(agent); + } + const contextRoots = + config['contextFileName'] === undefined + ? files.includes('QWEN.md') + ? ['QWEN.md'] + : files.includes('system-prompt.md') + ? ['system-prompt.md'] + : [] + : resourceRoots(config, 'contextFileName', 'QWEN.md'); + for (const file of contextRoots) + contributions.context.push({ + path: file, + body: await readText(root, file), + }); + let mcp = config['mcpServers']; + if (typeof mcp === 'string') + mcp = await readObject(root, managedPackageRelativePath(mcp)); + if (mcp === undefined && files.includes('.mcp.json')) { + const loaded = await readObject(root, '.mcp.json'); + mcp = loaded['mcpServers'] ?? loaded; + } + if (mcp === undefined && files.includes('mcp.json')) { + const loaded = await readObject(root, 'mcp.json'); + mcp = loaded['mcpServers'] ?? loaded['servers'] ?? loaded; + } + if (mcp !== undefined) contributions.mcpServers = object(mcp, 'mcpServers'); + let hooks = config['hooks']; + if (typeof hooks === 'string') + hooks = await readObject(root, managedPackageRelativePath(hooks)); + if (hooks === undefined && files.includes('hooks/hooks.json')) + hooks = await readObject(root, 'hooks/hooks.json'); + if (hooks !== undefined) { + const loaded = object(hooks, 'hooks'); + contributions.hooks = object(loaded['hooks'] ?? loaded, 'hooks'); + } + if (config['settings'] !== undefined) { + if (!Array.isArray(config['settings'])) + throw new ManagedPackageCompatibilityError(['settings: invalid list']); + contributions.settings = config['settings'].map((entry: unknown) => { + const setting = object(entry, 'setting'); + const envVar = text(setting['envVar'], 'setting.envVar', 128); + if ( + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envVar) || + envVar.startsWith('QWEN_MANAGED_') || + [ + 'QWEN_EXTENSION_ROOT', + 'CLAUDE_PLUGIN_ROOT', + 'extensionPath', + 'workspacePath', + ].includes(envVar) + ) + throw new ManagedPackageCompatibilityError([ + 'setting.envVar: reserved or invalid', + ]); + return { + name: text(setting['name'], 'setting.name', 512), + envVar, + sensitive: setting['sensitive'] === true, + }; + }); + } + if (options.resolveRecipes !== false) { + const environment: Readonly> = { + ...options.environment, + extensionPath: root, + CLAUDE_PLUGIN_ROOT: root, + QWEN_EXTENSION_ROOT: root, + workspacePath: options.workspaceDirectory ?? '', + workspaceFolder: options.workspaceDirectory ?? '', + pathSeparator: '/', + '/': '/', + }; + const hydrate = (value: unknown): unknown => { + if (typeof value === 'string') + return value.replace(/\$\{([^}]+)\}/g, (_, variable: string) => { + if (!Object.hasOwn(environment, variable)) + throw new ManagedPackageCompatibilityError([ + `setting ${variable}: required`, + ]); + return environment[variable]; + }); + if (Array.isArray(value)) return value.map(hydrate); + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, hydrate(item)]), + ); + return value; + }; + contributions.mcpServers = hydrate(contributions.mcpServers) as Record< + string, + unknown + >; + contributions.hooks = hydrate(contributions.hooks) as Record< + string, + unknown + >; + } + for (const entries of [ + contributions.skills, + contributions.commands, + contributions.agents, + ]) { + if (new Set(entries.map((entry) => entry.name)).size !== entries.length) + throw new ManagedPackageCompatibilityError([ + 'duplicate component identity', + ]); + } + if ( + contributions.skills.length > 256 || + Object.keys(contributions.mcpServers).length > 16 + ) + throw new ManagedPackageCompatibilityError([ + 'component catalog limit exceeded', + ]); + const components: ManagedPackageComponent[] = []; + if (contributions.skills.length) components.push('skills'); + if (contributions.commands.length) components.push('commands'); + if (contributions.agents.length) components.push('agents'); + if (contributions.context.length) components.push('context'); + if (Object.keys(contributions.mcpServers).length) components.push('mcp'); + if (Object.keys(contributions.hooks).length) components.push('hooks'); + if (contributions.settings.length) components.push('settings'); + return { + manifest: { + schemaVersion: 1, + compilerVersion: MANAGED_PACKAGE_COMPILER_VERSION, + name, + version: text(config['version'] ?? '1.0.0', 'version', 128), + target: packageTarget, + components, + ...(config['managedSource'] + ? { + sourceType: text( + object(config['managedSource'], 'managedSource')['type'], + 'sourceType', + 64, + ), + sourceRevision: text( + object(config['managedSource'], 'managedSource')['revision'], + 'sourceRevision', + 256, + ), + } + : {}), + }, + contributions, + }; +} + +export async function prepareManagedExtensionPackage( + source: ExtensionInstallMetadata, + options: { + target: ManagedPackageTarget; + destination: string; + signal?: AbortSignal; + }, +): Promise<{ + archivePath: string; + packageDigest: string; + manifest: ManagedPackageManifest; + sourceRevision: string; +}> { + if (['local', 'link', 'snapshot'].includes(source.type)) { + const isDirectory = (await fs.stat(source.source)).isDirectory(); + const installed = + isDirectory && + (await fs + .lstat(path.join(source.source, '.qwen-extension-install.json')) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + return undefined; + })); + if (installed) + throw new ManagedPackageCompatibilityError([ + 'installed package requires its original source', + ]); + } + const temporary = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-managed-package-'), + ); + const root = path.join(temporary, 'package'); + await fs.mkdir(root); + let sourceRevision = source.ref ?? ''; + try { + if (source.type === 'git') + sourceRevision = await cloneFromGit(source, root, options.signal); + else if (source.type === 'npm') { + const result = await downloadFromNpmRegistry( + { ...source }, + root, + options.signal, + { enforceResourceLimits: true }, + ); + sourceRevision = result.version; + } else if (source.type === 'github-release') { + const result = await downloadFromGitHubRelease( + source, + root, + options.signal, + options.target, + { enforceResourceLimits: true }, + ); + sourceRevision = result.tagName; + } else if (source.type === 'archive-url') + await downloadFromArchiveUrl(source, root, options.signal, { + enforceResourceLimits: true, + }); + else if (/\.(?:zip|tar\.gz)$/i.test(source.source)) + await extractArchiveFile(source.source, root, options.signal, { + enforceResourceLimits: true, + }); + else { + const files = await filesUnder(source.source, true); + for (const file of files) { + const destination = path.join(root, file); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(path.join(source.source, file), destination); + } + } + options.signal?.throwIfAborted(); + const scrub = async (directory: string): Promise => { + for (const entry of await fs.readdir(directory, { + withFileTypes: true, + })) { + const file = path.join(directory, entry.name); + if (PRIVATE_FILES.has(entry.name) || entry.name.startsWith('.env.')) { + await fs.rm(file, { recursive: true, force: true }); + } else if (entry.isDirectory()) await scrub(file); + } + }; + await scrub(root); + let files = await filesUnder(root); + const config = await rawConfig(root, files); + config['managedTarget'] = options.target; + config['managedSource'] = { + type: + source.type === 'local' + ? /\.(?:zip|tar\.gz)$/i.test(source.source) + ? 'archive' + : 'directory' + : source.type, + revision: + sourceRevision || text(config['version'] ?? 'snapshot', 'version', 128), + }; + await fs.writeFile( + path.join(root, 'qwen-extension.json'), + JSON.stringify(config), + ); + files = await filesUnder(root); + const compiled = await compileManagedExtensionPackage(root, { + target: options.target, + resolveRecipes: false, + }); + await fs.mkdir(options.destination, { recursive: true }); + const archivePath = path.join(options.destination, 'package.tar.gz'); + await tar.create( + { + cwd: root, + file: archivePath, + gzip: true, + portable: true, + noMtime: true, + }, + files.sort(), + ); + if ((await fs.stat(archivePath)).size > MANAGED_PACKAGE_MAX_ARCHIVE_BYTES) + throw new ManagedPackageCompatibilityError([ + 'compressed package limit exceeded', + ]); + const hash = createHash('sha256'); + for await (const chunk of createReadStream(archivePath)) hash.update(chunk); + const packageDigest = hash.digest('hex'); + return { + archivePath, + packageDigest, + manifest: compiled.manifest, + sourceRevision: sourceRevision || packageDigest, + }; + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +} diff --git a/packages/core/src/extension/npm.ts b/packages/core/src/extension/npm.ts index 0a19bec2450..79cd0340939 100644 --- a/packages/core/src/extension/npm.ts +++ b/packages/core/src/extension/npm.ts @@ -12,7 +12,10 @@ import { ExtensionUpdateState } from './extensionManager.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; import { clientForUrl } from './http-client.js'; -import { assertTarArchiveLinksAreSafe } from './archive-safety.js'; +import { + assertTarArchiveLinksAreSafe, + type TarArchiveSafetyOptions, +} from './archive-safety.js'; import { resolveNetworkTarget } from './network-policy.js'; const debugLogger = createDebugLogger('EXT_NPM'); @@ -524,6 +527,7 @@ export async function downloadFromNpmRegistry( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, + archiveSafety: TarArchiveSafetyOptions = {}, ): Promise { const { name, version: requestedVersion } = parseNpmPackageSource( installMetadata.source, @@ -602,7 +606,7 @@ export async function downloadFromNpmRegistry( signal?.throwIfAborted(); // Extract tarball - await assertTarArchiveLinksAreSafe(tarballPath); + await assertTarArchiveLinksAreSafe(tarballPath, signal, archiveSafety); signal?.throwIfAborted(); await tar.x({ file: tarballPath, diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 5c9d7572118..921c2bdd29a 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1204,25 +1204,12 @@ export class HookRunner { } }; - // Use hook-specific shell configuration if specified - const shellConfig = this.getShellConfigForHook(hookConfig); - const command = this.expandCommand( - hookConfig.command, + const { shellConfig, command, env } = this.commandInvocation( + hookConfig, input, - shellConfig.shell, + options?.environment, ); - const env: NodeJS.ProcessEnv = { - // Hook commands are child processes launched on the agent's behalf, - // so they must not inherit Qwen-internal daemon secrets. - ...sanitizeChildEnv(options?.environment ?? process.env), - GEMINI_PROJECT_DIR: input.cwd, - CLAUDE_PROJECT_DIR: input.cwd, // For compatibility - QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility - ...getShellContextEnvVars(), - ...hookConfig.env, - }; - let processUnit: HookCommandCgroup | undefined; if (options?.waitForProcessTree) { try { @@ -1591,101 +1578,16 @@ export class HookRunner { return; } - // Parse output - // Exit code 2 is a blocking error - ignore stdout, use stderr only - let output: HookOutput | undefined; - const isBlockingError = exitCode === 2; - - // For exit code 2, only use stderr (ignore stdout) - const stdoutText = stdout.trim(); - const textToParse = isBlockingError - ? stderr.trim() - : stdoutText || stderr.trim(); - // Only stdout is promoted as plain-text context; the stderr fallback - // stays a system message. JSON on stderr is still parsed as structured - // output when stdout is empty, as it was before. - const parsedFromStdout = !isBlockingError && stdoutText !== ''; - - if (textToParse) { - // Structured output is a JSON object, possibly double-encoded as a - // JSON string (applies to both exit 0 and exit 2). Anything else, - // including bare JSON values such as `42` or `[1, 2]`, is plain text. - let parsed: unknown; - let parseFailed = false; - try { - parsed = JSON.parse(textToParse); - if (typeof parsed === 'string') { - parsed = JSON.parse(parsed); - } - } catch { - parseFailed = true; - } - if ( - !parseFailed && - parsed !== null && - typeof parsed === 'object' && - !Array.isArray(parsed) - ) { - output = parsed as HookOutput; - } else if ( - parseFailed && - !isBlockingError && - textToParse.startsWith('{') - ) { - // Output that starts like a JSON object but does not parse is a - // broken structured payload, not context or a message: as in - // Claude Code, it is a non-blocking error and nothing of it reaches - // the model. Exit code 2 still blocks on its stderr text below. - debugLogger.warn( - `Hook "${hookConfig.name || hookConfig.command}" printed output that starts like a JSON object but is not valid JSON; it is ignored`, - ); - finish({ - hookConfig, - eventName, - success: false, - outcome: 'non_blocking_error', - error: new Error('Hook output is not valid JSON'), - stdout, - stderr, - exitCode: exitCode ?? -1, - duration, - }); - return; - } else { - output = this.convertPlainTextToHookOutput( - textToParse, - isBlockingError - ? exitCode - : exitCode === EXIT_CODE_SUCCESS - ? EXIT_CODE_SUCCESS - : EXIT_CODE_NON_BLOCKING_ERROR, - parsedFromStdout ? eventName : undefined, - ); - } - } - - const killedBySignal = exitCode === null; - finish({ - hookConfig, - eventName, - success: exitCode === EXIT_CODE_SUCCESS, - // A signal this runner did not send (it returned above for its own - // abort and timeout) leaves exitCode null: a failure, not a cancel. - outcome: - exitCode === EXIT_CODE_SUCCESS - ? 'success' - : exitCode === 2 - ? 'blocking' - : 'non_blocking_error', - output, - stdout, - stderr, - exitCode: exitCode ?? -1, - duration, - ...(killedBySignal && { - error: new Error('Hook killed by signal'), - }), - }); + finish( + this.commandResult( + hookConfig, + eventName, + stdout, + stderr, + exitCode, + duration, + ), + ); }); // Handle process errors @@ -1711,6 +1613,132 @@ export class HookRunner { }); } + commandInvocation( + hookConfig: CommandHookConfig, + input: HookInput, + environment?: NodeJS.ProcessEnv, + ) { + const shellConfig = this.getShellConfigForHook(hookConfig); + return { + shellConfig, + command: this.expandCommand(hookConfig.command, input, shellConfig.shell), + env: { + // Hook commands are child processes launched on the agent's behalf, + // so they must not inherit Qwen-internal daemon secrets. + ...sanitizeChildEnv(environment ?? process.env), + GEMINI_PROJECT_DIR: input.cwd, + CLAUDE_PROJECT_DIR: input.cwd, + QWEN_PROJECT_DIR: input.cwd, + ...getShellContextEnvVars(), + ...hookConfig.env, + } as NodeJS.ProcessEnv, + }; + } + + commandResult( + hookConfig: CommandHookConfig, + eventName: HookEventName, + stdout: string, + stderr: string, + exitCode: number | null, + duration: number, + ): HookExecutionResult { + // Parse output + // Exit code 2 is a blocking error - ignore stdout, use stderr only + let output: HookOutput | undefined; + const isBlockingError = exitCode === 2; + + // For exit code 2, only use stderr (ignore stdout) + const stdoutText = stdout.trim(); + const textToParse = isBlockingError + ? stderr.trim() + : stdoutText || stderr.trim(); + // Only stdout is promoted as plain-text context; the stderr fallback + // stays a system message. JSON on stderr is still parsed as structured + // output when stdout is empty, as it was before. + const parsedFromStdout = !isBlockingError && stdoutText !== ''; + + if (textToParse) { + // Structured output is a JSON object, possibly double-encoded as a + // JSON string (applies to both exit 0 and exit 2). Anything else, + // including bare JSON values such as `42` or `[1, 2]`, is plain text. + let parsed: unknown; + let parseFailed = false; + try { + parsed = JSON.parse(textToParse); + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + } catch { + parseFailed = true; + } + if ( + !parseFailed && + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + output = parsed as HookOutput; + } else if ( + parseFailed && + !isBlockingError && + textToParse.startsWith('{') + ) { + // Output that starts like a JSON object but does not parse is a + // broken structured payload, not context or a message: as in + // Claude Code, it is a non-blocking error and nothing of it reaches + // the model. Exit code 2 still blocks on its stderr text below. + debugLogger.warn( + `Hook "${hookConfig.name || hookConfig.command}" printed output that starts like a JSON object but is not valid JSON; it is ignored`, + ); + return { + hookConfig, + eventName, + success: false, + outcome: 'non_blocking_error', + error: new Error('Hook output is not valid JSON'), + stdout, + stderr, + exitCode: exitCode ?? -1, + duration, + }; + } else { + output = this.convertPlainTextToHookOutput( + textToParse, + isBlockingError + ? exitCode + : exitCode === EXIT_CODE_SUCCESS + ? EXIT_CODE_SUCCESS + : EXIT_CODE_NON_BLOCKING_ERROR, + parsedFromStdout ? eventName : undefined, + ); + } + } + + const killedBySignal = exitCode === null; + return { + hookConfig, + eventName, + success: exitCode === EXIT_CODE_SUCCESS, + // A signal this runner did not send (it returned above for its own + // abort and timeout) leaves exitCode null: a failure, not a cancel. + outcome: + exitCode === EXIT_CODE_SUCCESS + ? 'success' + : exitCode === 2 + ? 'blocking' + : 'non_blocking_error', + output, + stdout, + stderr, + exitCode: exitCode ?? -1, + duration, + ...(killedBySignal && { + error: new Error('Hook killed by signal'), + }), + }; + } + /** * Resolve project directory variables in a command string before launch. * diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 5da91a168f5..e7bc890af2b 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -1395,6 +1395,11 @@ export function detectTodoChanges( * Hook execution result */ export interface HookExecutionResult { + processReceipt?: { + readonly ownerId: string; + readonly scope: 'process_tree' | 'process_group'; + readonly state: 'stopped' | 'outcome_unknown'; + }; /** True only if a managed command never starts or its owned cgroup is empty. */ processTreeDrained?: boolean; httpRequestState?: 'not_started' | 'response_received' | 'outcome_unknown'; diff --git a/packages/core/src/managed-runtime/contracts/managed-config-install-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-config-install-v1.fixtures.json new file mode 100644 index 00000000000..9aa2543712f --- /dev/null +++ b/packages/core/src/managed-runtime/contracts/managed-config-install-v1.fixtures.json @@ -0,0 +1,526 @@ +{ + "schemaVersion": 1, + "valid": [ + { + "name": "nullable_preparing", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "preparing", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": null, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "prepared", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "prepared", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": { + "resourceId": "bundle", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [ + { + "resourceId": "hstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "rstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "active", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "active", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": { + "resourceId": "bundle", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [ + { + "resourceId": "hstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "rstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "enableAckRefs": [ + { + "resourceId": "henable", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "renable", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "error": null + } + }, + { + "name": "separate_mcp_token", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [], + "mcpChange": { + "action": "disable", + "serverId": "base", + "expectedRevision": 7, + "servers": [ + { + "serverId": "base", + "serverRevision": 2, + "definitionDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "state": "disabled" + } + ] + } + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "preparing", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": null, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "committed", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "committed", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": { + "resourceId": "bundle", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [ + { + "resourceId": "hstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "rstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "enableAckRefs": [], + "error": null + } + } + ], + "invalid": [ + { + "name": "committed_missing_stage", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "committed", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": { + "resourceId": "bundle", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "prepared_null_bundle", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "prepared", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": null, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [ + { + "resourceId": "hstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "rstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "active_missing_enable", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "active", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": { + "resourceId": "bundle", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [ + { + "resourceId": "hstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "resourceId": "rstage", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "enableAckRefs": [], + "error": null + } + }, + { + "name": "secret_field", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "preparing", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": null, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [], + "enableAckRefs": [], + "error": null, + "settings": { + "token": "must_not_persist" + } + } + }, + { + "name": "pin_missing_digest", + "content": { + "kind": "hosted-extension-config", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "expectedRevision": 0, + "extensions": [ + { + "extensionId": "sample", + "packageRevision": 1 + } + ] + }, + "mode": "apply", + "phaseOperationId": "11111111-1111-4111-8111-111111111111", + "configRevision": 1, + "previousConfigRevision": 0, + "phase": "preparing", + "previousBundleRef": null, + "restoresBundleRef": null, + "bundleRef": null, + "rootSnapshotRef": { + "resourceId": "root", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "stageAckRefs": [], + "enableAckRefs": [], + "error": null + } + } + ], + "legacy": [ + { + "name": "mcp_v1_unchanged", + "content": { + "kind": "hosted-mcp-settings", + "formatVersion": 1, + "command": { + "operationId": "11111111-1111-4111-8111-111111111111", + "actorKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "action": "disable", + "serverId": "base", + "expectedRevision": 7 + }, + "servers": [ + { + "serverId": "base", + "serverRevision": 2, + "definitionDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "state": "disabled" + } + ] + } + } + ], + "successors": [ + { + "name": "initial_prepare", + "previous": null, + "next": "nullable_preparing", + "valid": true + }, + { + "name": "stage", + "previous": "nullable_preparing", + "next": "prepared", + "valid": true + }, + { + "name": "commit", + "previous": "prepared", + "next": "committed", + "valid": true + }, + { + "name": "enable", + "previous": "committed", + "next": "active", + "valid": true + }, + { + "name": "cannot_skip_commit", + "previous": "prepared", + "next": "active", + "valid": false + }, + { + "name": "cannot_repeat_active", + "previous": "active", + "next": "active", + "valid": false + } + ], + "phaseOperationIds": [ + { + "operationId": "11111111-1111-4111-8111-111111111111", + "expected": "a6204ca0-0013-58fe-8c73-2e92f96c3a7a" + } + ] +} diff --git a/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json index c7159375bed..9d67cf957d7 100644 --- a/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json +++ b/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json @@ -16,6 +16,7 @@ "channel_delivery": "channel_delivery" }, "additionalRecordBodies": { + "tool_stage": null, "history_maintenance": null, "schedule": null, "automation_run": "automation_run", diff --git a/packages/core/src/managed-runtime/contracts/managed-tool-stage-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-tool-stage-v1.fixtures.json new file mode 100644 index 00000000000..84a7b27cdb5 --- /dev/null +++ b/packages/core/src/managed-runtime/contracts/managed-tool-stage-v1.fixtures.json @@ -0,0 +1,632 @@ +{ + "schemaVersion": 1, + "valid": [ + { + "name": "admitted", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + } + } + }, + { + "name": "dispatched", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": null, + "run": { + "state": "running", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "dispatch_started", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + } + }, + { + "name": "settled", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": { + "resourceId": "result", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "run": { + "state": "settled", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "settled", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + } + } + ], + "invalid": [ + { + "name": "wrong_definition", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "read_file", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + } + } + }, + { + "name": "wrong_execution_identity", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "different", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + } + } + }, + { + "name": "unknown_field", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + }, + "secret": true + } + }, + { + "name": "negative_step", + "record": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": -1, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + } + } + } + ], + "successors": [ + { + "name": "dispatch", + "previous": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": null, + "resultRef": null, + "run": { + "state": "admitted", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "intent", + "runtime": null, + "delivery": null + } + }, + "next": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": null, + "run": { + "state": "running", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "dispatch_started", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "valid": true + }, + { + "name": "result", + "previous": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": null, + "run": { + "state": "running", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "dispatch_started", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "next": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": { + "resourceId": "result", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "run": { + "state": "settled", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "settled", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "valid": true + }, + { + "name": "substitute_input", + "previous": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": null, + "run": { + "state": "running", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "dispatch_started", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "next": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "another", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": { + "resourceId": "result", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "run": { + "state": "settled", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "settled", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "valid": false + }, + { + "name": "replace_permission", + "previous": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "permission", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": null, + "run": { + "state": "running", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "dispatch_started", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "next": { + "kind": "tool_stage", + "toolStageId": "stage-1", + "expansionId": "expansion-1", + "stepIndex": 0, + "toolName": "shell", + "inputRef": { + "resourceId": "input", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "permissionRef": { + "resourceId": "another", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "resultRef": { + "resourceId": "result", + "kind": "managed-test", + "schemaVersion": 1, + "byteLength": 2, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "run": { + "state": "settled", + "reason": null, + "definition": { + "definitionId": "shell", + "definitionRevision": 1, + "definitionDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "executionCallId": "stage-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": "settled", + "runtime": { + "runtimeBindingId": "binding-1", + "generation": "1" + }, + "delivery": null + } + }, + "valid": false + } + ] +} diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index 38385f95402..67a56654917 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -176,7 +176,9 @@ export interface HttpManagedSessionStores { close(): Promise; setLifecycleAuthority(authority?: ManagedSessionLifecycleAuthority): void; authorizeLifecycle(kind?: 'close' | 'delete'): Promise; - authorizeOrdinary(kind?: 'legacy-close'): Promise; + authorizeOrdinary( + kind?: 'legacy-close' | `configuration:${string}`, + ): Promise; approvalSelection(): Promise; claimUserMessages( promptId: string, @@ -808,7 +810,9 @@ class ManagedSessionStoreHttpClient { this.lifecycleAuthority = authority; } - async authorizeOrdinary(kind?: 'legacy-close'): Promise { + async authorizeOrdinary( + kind?: 'legacy-close' | `configuration:${string}`, + ): Promise { await this.assertWritable(); await this.json('/execution:authorize', 'POST', { ...(kind ? { kind } : {}), diff --git a/packages/core/src/managed-runtime/managed-child-operations.test.ts b/packages/core/src/managed-runtime/managed-child-operations.test.ts index d5258384b59..1cd784a622f 100644 --- a/packages/core/src/managed-runtime/managed-child-operations.test.ts +++ b/packages/core/src/managed-runtime/managed-child-operations.test.ts @@ -164,6 +164,9 @@ describe('managed child operations (H4b)', () => { } for (const patch of [ { extra: true }, + { version: '1' }, + { version: true }, + { version: ['1'] }, { approvalMode: ['yolo'] }, { approvalRevision: -1 }, { modelRevision: Number.MAX_SAFE_INTEGER + 1 }, diff --git a/packages/core/src/managed-runtime/managed-child-operations.ts b/packages/core/src/managed-runtime/managed-child-operations.ts index fd5e19befd2..28971dfaefd 100644 --- a/packages/core/src/managed-runtime/managed-child-operations.ts +++ b/packages/core/src/managed-runtime/managed-child-operations.ts @@ -17,6 +17,10 @@ import { parseDefinitionPin, type DefinitionPin, } from './managed-extension-record.js'; +import { + parseManagedExtensionPin, + type ManagedExtensionPin, +} from './managed-extension-package.js'; import { assertManagedSessionDurableRef, ManagedSessionRecordError, @@ -127,7 +131,7 @@ export function parsePublicChildModelRoute( } export interface PublicChildLaunchSnapshot { - readonly version: 1; + readonly version: 1 | 2; readonly sourcePromptId: string; readonly approvalMode: 'default' | 'auto-edit' | 'yolo'; readonly approvalRevision: number; @@ -135,6 +139,63 @@ export interface PublicChildLaunchSnapshot { readonly modelRevision: number; readonly modelRoute: PublicChildModelRoute; readonly skillsRef: ManagedSessionDurableRef | null; + readonly extensionAgent?: PublicChildExtensionAgent; +} + +export interface PublicChildExtensionAgent { + readonly configRevision: number; + readonly bundleRef: ManagedSessionDurableRef; + readonly pin: ManagedExtensionPin; + readonly contributionsRef: ManagedSessionDurableRef; + readonly agentName: string; + readonly toolNames: readonly string[]; +} + +export function parsePublicChildExtensionAgent( + value: unknown, +): PublicChildExtensionAgent { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ManagedSessionRecordError('Invalid extension agent descriptor.'); + const fields = value as Record; + if ( + Object.keys(fields).sort().join(',') !== + 'agentName,bundleRef,configRevision,contributionsRef,pin,toolNames' || + !Number.isSafeInteger(fields['configRevision']) || + Number(fields['configRevision']) < 1 || + typeof fields['agentName'] !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(fields['agentName']) || + !Array.isArray(fields['toolNames']) || + fields['toolNames'].length > 256 || + fields['toolNames'].some( + (name) => + typeof name !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(name), + ) || + new Set(fields['toolNames']).size !== fields['toolNames'].length + ) + throw new ManagedSessionRecordError('Invalid extension agent descriptor.'); + const bundleRef = assertManagedSessionDurableRef( + fields['bundleRef'], + 'extension agent bundle', + ); + const contributionsRef = assertManagedSessionDurableRef( + fields['contributionsRef'], + 'extension agent contributions', + ); + if ( + bundleRef.kind !== 'managed-extension-config-bundle' || + bundleRef.schemaVersion !== 1 || + contributionsRef.kind !== 'managed-extension-contributions' || + contributionsRef.schemaVersion !== 1 + ) + throw new ManagedSessionRecordError('Invalid extension agent references.'); + return Object.freeze({ + configRevision: fields['configRevision'] as number, + bundleRef, + pin: parseManagedExtensionPin(fields['pin']), + contributionsRef, + agentName: fields['agentName'], + toolNames: Object.freeze([...(fields['toolNames'] as string[])]), + }); } export interface ChildLaunchEnvelope { @@ -153,8 +214,10 @@ function parsePublicChildLaunch(value: unknown): PublicChildLaunchSnapshot { const fields = value as Record; if ( Object.keys(fields).sort().join(',') !== - 'approvalMode,approvalRevision,modelId,modelRevision,modelRoute,skillsRef,sourcePromptId,version' || - fields['version'] !== 1 || + (fields['version'] === 2 + ? 'approvalMode,approvalRevision,extensionAgent,modelId,modelRevision,modelRoute,skillsRef,sourcePromptId,version' + : 'approvalMode,approvalRevision,modelId,modelRevision,modelRoute,skillsRef,sourcePromptId,version') || + (fields['version'] !== 1 && fields['version'] !== 2) || typeof fields['sourcePromptId'] !== 'string' || !fields['sourcePromptId'].trim() || fields['sourcePromptId'].length > 144 || @@ -173,6 +236,8 @@ function parsePublicChildLaunch(value: unknown): PublicChildLaunchSnapshot { ); } parsePublicChildModelRoute(fields['modelRoute']); + if (fields['version'] === 2) + parsePublicChildExtensionAgent(fields['extensionAgent']); if (fields['skillsRef'] !== null) { const ref = assertManagedSessionDurableRef( fields['skillsRef'] as ManagedSessionJsonValue, diff --git a/packages/core/src/managed-runtime/managed-config-install.test.ts b/packages/core/src/managed-runtime/managed-config-install.test.ts new file mode 100644 index 00000000000..219c5f4c0fc --- /dev/null +++ b/packages/core/src/managed-runtime/managed-config-install.test.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + assertManagedConfigInstallSuccessor, + managedConfigBinding, + managedExtensionCompensationOperationId, + parseManagedConfigInstallContent, + parseManagedConfigInstallRecord, + type ManagedConfigInstallContent, + type ManagedConfigInstallRecord, +} from './managed-config-install.js'; +import { LocalManagedSessionAuthority } from './managed-session-authority.js'; +import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; + +const directories: string[] = []; +const sharedFixtures = JSON.parse( + readFileSync( + new URL( + './contracts/managed-config-install-v1.fixtures.json', + import.meta.url, + ), + 'utf8', + ), +) as { + valid: Array<{ name: string; content: unknown }>; + invalid: Array<{ name: string; content: unknown }>; + successors: Array<{ + name: string; + previous: string | null; + next: string; + valid: boolean; + }>; + phaseOperationIds: Array<{ operationId: string; expected: string }>; +}; +describe('shared Managed configuration fixtures', () => { + for (const fixture of sharedFixtures.successors) + it(`${fixture.name}`, () => { + const find = (name: string) => + parseManagedConfigInstallContent( + sharedFixtures.valid.find((value) => value.name === name)!.content, + ); + const check = () => + assertManagedConfigInstallSuccessor( + fixture.previous === null + ? undefined + : record(find(fixture.previous)), + find(fixture.next), + ); + if (fixture.valid) expect(check).not.toThrow(); + else expect(check).toThrow(); + }); + for (const fixture of sharedFixtures.phaseOperationIds) + it(`derives phase identity ${fixture.operationId}`, () => { + expect(managedExtensionCompensationOperationId(fixture.operationId)).toBe( + fixture.expected, + ); + }); + for (const fixture of sharedFixtures.valid) + it(`${fixture.name}`, () => { + expect(() => + parseManagedConfigInstallContent(fixture.content), + ).not.toThrow(); + }); + for (const fixture of sharedFixtures.invalid) + it(`${fixture.name}`, () => { + expect(() => parseManagedConfigInstallContent(fixture.content)).toThrow(); + }); +}); +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function ref(resourceId: string): ManagedSessionDurableRef { + return { + resourceId, + kind: 'managed-test', + schemaVersion: 1, + byteLength: 2, + digest: 'a'.repeat(64), + }; +} + +function content( + phase: ManagedConfigInstallContent['phase'] = 'preparing', +): ManagedConfigInstallContent { + return { + kind: 'hosted-extension-config', + formatVersion: 1, + command: { + operationId: '11111111-1111-4111-8111-111111111111', + actorKey: 'b'.repeat(64), + expectedRevision: 0, + extensions: [], + }, + mode: 'apply', + phaseOperationId: '11111111-1111-4111-8111-111111111111', + configRevision: 1, + previousConfigRevision: 0, + phase, + previousBundleRef: null, + restoresBundleRef: null, + bundleRef: ref('bundle'), + rootSnapshotRef: ref('root'), + stageAckRefs: phase === 'preparing' ? [] : [ref('h-stage'), ref('r-stage')], + enableAckRefs: phase === 'active' ? [ref('h-enable'), ref('r-enable')] : [], + error: + phase === 'recovery_required' || phase === 'failed' + ? { code: 'runtime_unavailable' } + : null, + }; +} + +function record( + value: ManagedConfigInstallContent, +): ManagedConfigInstallRecord { + return { + ...value, + revision: 1, + operationId: value.command.operationId, + previousRecordRef: null, + }; +} + +describe('Managed configuration install records', () => { + it('requires both stage and enable evidence before active', () => { + expect(parseManagedConfigInstallContent(content('active')).phase).toBe( + 'active', + ); + expect(() => + parseManagedConfigInstallContent({ + ...content('active'), + enableAckRefs: [ref('h-enable')], + }), + ).toThrow(); + expect(() => + parseManagedConfigInstallContent({ + ...content('committed'), + stageAckRefs: [], + }), + ).toThrow(); + }); + + it('does not confuse the phase envelope revision with configuration revision', () => { + const value = { + ...record(content('active')), + revision: 8, + previousRecordRef: { + ...ref('previous-phase'), + kind: 'managed-config_install', + }, + }; + expect(parseManagedConfigInstallRecord(value).configRevision).toBe(1); + expect(() => + parseManagedConfigInstallRecord({ ...value, settings: {} }), + ).toThrow(); + }); + + it('refuses a new operation until the current binding settles', () => { + const previous = record(content('committed')); + expect(() => + assertManagedConfigInstallSuccessor(previous, { + ...content(), + command: { + ...content().command, + operationId: '22222222-2222-4222-8222-222222222222', + }, + }), + ).toThrow(); + }); + + it('does not allow a resumed phase to substitute a different package view', () => { + expect(() => + assertManagedConfigInstallSuccessor(record(content()), { + ...content('prepared'), + bundleRef: ref('other-package-content'), + }), + ).toThrow(); + }); + + it('records compensation as a newer binding of saved content', () => { + const old = ref('old-bundle'); + const previous = record({ + ...content('recovery_required'), + previousBundleRef: old, + }); + const restored = { + ...content(), + mode: 'recoverPrevious' as const, + phaseOperationId: managedExtensionCompensationOperationId( + previous.command.operationId, + ), + configRevision: 2, + previousConfigRevision: 1, + bundleRef: ref('old-content-new-hook-revision'), + previousBundleRef: previous.bundleRef, + restoresBundleRef: old, + }; + expect(parseManagedConfigInstallContent(restored).configRevision).toBe(2); + expect(() => + assertManagedConfigInstallSuccessor(previous, restored), + ).not.toThrow(); + expect(() => + assertManagedConfigInstallSuccessor(previous, { + ...restored, + restoresBundleRef: ref('unrelated-bundle'), + }), + ).toThrow(); + }); +}); + +async function open() { + const directory = await mkdtemp(path.join(tmpdir(), 'qwen-config-install-')); + directories.push(directory); + const runtimeBaseDir = path.join(directory, 'runtime'); + await mkdir(runtimeBaseDir); + const sessionKey = { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: 'session', + }; + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir, + sessionId: sessionKey.sessionId, + transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'), + }); + const resources = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const authority = await LocalManagedSessionAuthority.open({ + journal: LocalJsonlManagedSessionJournalStore.fromLease(lease, sessionKey), + sessionKey, + cwd: '/workspace', + version: 'test', + resources, + create: { + definitionRef: ref('definition'), + rootSnapshotRef: ref('root'), + createdBy: 'daemon', + }, + }); + const commit = (body: ManagedConfigInstallContent, binding = false) => + authority.commitDomainRecord( + { + operation: `extensionConfig.${body.mode}.${body.phase}`, + commandId: body.command.operationId, + sessionKey, + contentDigest: 'c'.repeat(64), + }, + { + domain: 'config_install', + content: { ...body }, + ...(binding ? { configBinding: managedConfigBinding(body) } : {}), + }, + { class: 'trusted_entry' }, + ); + return { authority, resources, commit }; +} + +describe('Managed configuration binding commits', () => { + it('commits intent and binding in one transaction and replays it once', async () => { + const { authority, commit } = await open(); + try { + await commit(content()); + await commit(content('prepared')); + const receipt = await commit(content('committed'), true); + const events = authority.eventsInSequenceRange( + receipt.receipt.firstSequence, + receipt.receipt.lastSequence, + ); + expect(events.map((event) => event.kind)).toEqual([ + 'domain.committed', + 'config.bound', + ]); + expect(events[1].payload['revision']).toBe(1); + expect(receipt.revision).toBe(3); + expect((await commit(content('committed'), true)).receipt.replayed).toBe( + true, + ); + expect(authority.domainRecord('config_install')?.revision).toBe(3); + } finally { + await authority.close(); + } + }); + + it('refuses a commit without its atomic binding before publishing it', async () => { + const { authority, resources, commit } = await open(); + try { + await commit(content()); + await commit(content('prepared')); + const publish = vi.spyOn(resources, 'publish'); + await expect(commit(content('committed'))).rejects.toThrow( + /binding conflicts/u, + ); + expect(publish).not.toHaveBeenCalled(); + expect(authority.domainRecord('config_install')?.revision).toBe(2); + } finally { + await authority.close(); + } + }); +}); diff --git a/packages/core/src/managed-runtime/managed-config-install.ts b/packages/core/src/managed-runtime/managed-config-install.ts new file mode 100644 index 00000000000..76394207b4e --- /dev/null +++ b/packages/core/src/managed-runtime/managed-config-install.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { + assertManagedSessionDurableRef, + ManagedSessionRecordError, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from './managed-session-records.js'; +import { + parseManagedExtensionApplyCommand, + type ManagedExtensionApplyCommand, +} from './managed-extension-package.js'; + +export const MANAGED_EXTENSION_CONFIG_KIND = 'hosted-extension-config'; + +export const MANAGED_CONFIG_INSTALL_PHASES = [ + 'preparing', + 'prepared', + 'committed', + 'active', + 'recovery_required', + 'failed', +] as const; + +export type ManagedConfigInstallPhase = + (typeof MANAGED_CONFIG_INSTALL_PHASES)[number]; + +export interface ManagedConfigInstallContent { + readonly kind: typeof MANAGED_EXTENSION_CONFIG_KIND; + readonly formatVersion: 1; + readonly command: ManagedExtensionApplyCommand; + readonly mode: 'apply' | 'recoverPrevious'; + readonly phaseOperationId: string; + readonly configRevision: number; + readonly previousConfigRevision: number; + readonly phase: ManagedConfigInstallPhase; + readonly previousBundleRef: ManagedSessionDurableRef | null; + readonly restoresBundleRef: ManagedSessionDurableRef | null; + readonly bundleRef: ManagedSessionDurableRef | null; + readonly rootSnapshotRef: ManagedSessionDurableRef; + readonly stageAckRefs: readonly ManagedSessionDurableRef[]; + readonly enableAckRefs: readonly ManagedSessionDurableRef[]; + readonly error: { readonly code: string } | null; +} + +export interface ManagedConfigInstallRecord + extends ManagedConfigInstallContent { + readonly operationId: string; + readonly revision: number; + readonly previousRecordRef: ManagedSessionDurableRef | null; +} + +export interface ManagedConfigBinding { + readonly revision: number; + readonly previousRevision: number | null; + readonly bundleRef: ManagedSessionDurableRef; + readonly rootSnapshotRef: ManagedSessionDurableRef; +} + +const CONTENT_KEYS = [ + 'kind', + 'formatVersion', + 'command', + 'mode', + 'phaseOperationId', + 'configRevision', + 'previousConfigRevision', + 'phase', + 'previousBundleRef', + 'restoresBundleRef', + 'bundleRef', + 'rootSnapshotRef', + 'stageAckRefs', + 'enableAckRefs', + 'error', +] as const; + +export function isManagedExtensionConfig(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + 'kind' in value && + value.kind === MANAGED_EXTENSION_CONFIG_KIND + ); +} + +export function parseManagedConfigInstallContent( + value: unknown, +): ManagedConfigInstallContent { + return parse(value, false) as ManagedConfigInstallContent; +} + +export function parseManagedConfigInstallRecord( + value: unknown, +): ManagedConfigInstallRecord { + return parse(value, true) as ManagedConfigInstallRecord; +} + +function parse( + value: unknown, + envelope: boolean, +): ManagedConfigInstallContent | ManagedConfigInstallRecord { + const body = object(value); + const expected = envelope + ? [...CONTENT_KEYS, 'operationId', 'revision', 'previousRecordRef'] + : CONTENT_KEYS; + if ( + Object.keys(body).length !== expected.length || + Object.keys(body).some( + (key) => !(expected as readonly string[]).includes(key), + ) || + body['kind'] !== MANAGED_EXTENSION_CONFIG_KIND || + body['formatVersion'] !== 1 || + !['apply', 'recoverPrevious'].includes(String(body['mode'])) || + !revision(body['configRevision'], 1) || + !revision(body['previousConfigRevision'], 0) || + body['configRevision'] !== Number(body['previousConfigRevision']) + 1 || + !(MANAGED_CONFIG_INSTALL_PHASES as readonly unknown[]).includes( + body['phase'], + ) + ) + invalid(); + const command = parseManagedExtensionApplyCommand(body['command']); + const phase = body['phase'] as ManagedConfigInstallPhase; + const compensating = body['mode'] === 'recoverPrevious'; + if ( + body['phaseOperationId'] !== + (compensating + ? managedExtensionCompensationOperationId(command.operationId) + : command.operationId) + ) + invalid(); + if ( + Number(body['previousConfigRevision']) !== + command.expectedRevision + (compensating ? 1 : 0) + ) + invalid(); + const stageAckRefs = refs(body['stageAckRefs']); + const enableAckRefs = refs(body['enableAckRefs']); + if ( + (['prepared', 'committed', 'active'].includes(phase) && + stageAckRefs.length < 2) || + (phase === 'active' && enableAckRefs.length < 2) || + (['preparing', 'prepared', 'failed'].includes(phase) && + enableAckRefs.length !== 0) + ) + invalid(); + const error = body['error'] === null ? null : object(body['error']); + if ( + error && + (Object.keys(error).length !== 1 || + typeof error['code'] !== 'string' || + !/^[a-z][a-z0-9_]{0,127}$/u.test(error['code'])) + ) + invalid(); + if ( + (phase === 'failed' || phase === 'recovery_required') !== + (error !== null) + ) + invalid(); + const parsed: ManagedConfigInstallContent = { + kind: MANAGED_EXTENSION_CONFIG_KIND, + formatVersion: 1, + command, + mode: body['mode'] as 'apply' | 'recoverPrevious', + phaseOperationId: body['phaseOperationId'] as string, + configRevision: body['configRevision'] as number, + previousConfigRevision: body['previousConfigRevision'] as number, + phase, + previousBundleRef: + body['previousBundleRef'] === null + ? null + : ref(body['previousBundleRef']), + restoresBundleRef: + body['restoresBundleRef'] === null + ? null + : ref(body['restoresBundleRef']), + bundleRef: body['bundleRef'] === null ? null : ref(body['bundleRef']), + rootSnapshotRef: ref(body['rootSnapshotRef']), + stageAckRefs, + enableAckRefs, + error: error ? { code: error['code'] as string } : null, + }; + if ((parsed.restoresBundleRef !== null) !== compensating) invalid(); + if (parsed.bundleRef === null && !['preparing', 'failed'].includes(phase)) + invalid(); + if (!envelope) return parsed; + if ( + body['operationId'] !== command.operationId || + !revision(body['revision'], 1) + ) + invalid(); + const previousRecordRef = + body['previousRecordRef'] === null ? null : ref(body['previousRecordRef']); + if ( + (body['revision'] === 1) !== (previousRecordRef === null) || + (previousRecordRef && + (previousRecordRef.kind !== 'managed-config_install' || + previousRecordRef.schemaVersion !== 1)) + ) + invalid(); + return { + ...parsed, + operationId: command.operationId, + revision: body['revision'] as number, + previousRecordRef, + }; +} + +export function managedConfigBinding( + content: ManagedConfigInstallContent, +): ManagedConfigBinding { + if (!content.bundleRef) invalid(); + return { + revision: content.configRevision, + previousRevision: content.previousConfigRevision || null, + bundleRef: content.bundleRef, + rootSnapshotRef: content.rootSnapshotRef, + }; +} + +export function assertManagedConfigInstallSuccessor( + previous: ManagedConfigInstallRecord | undefined, + next: ManagedConfigInstallContent, +): void { + if (!previous) { + if ( + next.phase !== 'preparing' || + next.mode !== 'apply' || + next.previousConfigRevision !== 0 + ) + invalid(); + return; + } + if (previous.command.operationId !== next.command.operationId) { + if ( + !['active', 'failed'].includes(previous.phase) || + (previous.phase === 'failed' && previous.mode === 'recoverPrevious') || + next.phase !== 'preparing' || + next.mode !== 'apply' || + next.previousConfigRevision !== + (previous.phase === 'failed' + ? previous.previousConfigRevision + : previous.configRevision) + ) + invalid(); + return; + } + const transitions: Record = { + preparing: ['prepared', 'failed'], + prepared: ['committed', 'failed'], + committed: ['active', 'recovery_required'], + active: [], + recovery_required: ['active', 'recovery_required'], + failed: [], + }; + const compensation = + previous.phase === 'recovery_required' && + previous.mode === 'apply' && + next.mode === 'recoverPrevious' && + next.phase === 'preparing'; + if ( + (!compensation && !transitions[previous.phase].includes(next.phase)) || + JSON.stringify(previous.command) !== JSON.stringify(next.command) || + (!compensation && + (previous.mode !== next.mode || + previous.phaseOperationId !== next.phaseOperationId || + previous.configRevision !== next.configRevision || + (!(previous.phase === 'preparing' && previous.bundleRef === null) && + JSON.stringify(previous.bundleRef) !== + JSON.stringify(next.bundleRef)) || + JSON.stringify(previous.rootSnapshotRef) !== + JSON.stringify(next.rootSnapshotRef) || + JSON.stringify(previous.previousBundleRef) !== + JSON.stringify(next.previousBundleRef) || + JSON.stringify(previous.restoresBundleRef) !== + JSON.stringify(next.restoresBundleRef))) || + (compensation && + (next.configRevision !== previous.configRevision + 1 || + previous.previousBundleRef === null || + JSON.stringify(next.previousBundleRef) !== + JSON.stringify(previous.bundleRef) || + JSON.stringify(next.restoresBundleRef) !== + JSON.stringify(previous.previousBundleRef))) + ) + invalid(); +} + +export function managedExtensionCompensationOperationId( + operationId: string, +): string { + const bytes = createHash('sha256') + .update(`managed-extension-recoverPrevious:${operationId.toLowerCase()}`) + .digest() + .subarray(0, 16); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + return value as Record; +} + +function ref(value: unknown): ManagedSessionDurableRef { + return assertManagedSessionDurableRef( + value as ManagedSessionJsonValue, + 'extension config reference', + ); +} + +function refs(value: unknown): readonly ManagedSessionDurableRef[] { + if (!Array.isArray(value) || value.length > 8) invalid(); + const parsed = value.map(ref); + if (new Set(parsed.map((entry) => entry.resourceId)).size !== parsed.length) + invalid(); + return parsed; +} + +function revision(value: unknown, minimum: number): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +function invalid(): never { + throw new ManagedSessionRecordError('Invalid Managed configuration install.'); +} diff --git a/packages/core/src/managed-runtime/managed-extension-contributions.test.ts b/packages/core/src/managed-runtime/managed-extension-contributions.test.ts new file mode 100644 index 00000000000..3b008c0fedc --- /dev/null +++ b/packages/core/src/managed-runtime/managed-extension-contributions.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; +import { + publishManagedExtensionContributions, + readManagedExtensionContributions, +} from './managed-extension-contributions.js'; + +function store() { + const data = new Map(); + return { + data, + async publish( + kind: string, + bytes: Buffer, + ): Promise { + const resourceId = `resource-${data.size}`; + data.set(resourceId, bytes); + return { + resourceId, + kind, + schemaVersion: 1, + byteLength: bytes.length, + digest: createHash('sha256').update(bytes).digest('hex'), + }; + }, + async read(ref: ManagedSessionDurableRef) { + return data.get(ref.resourceId)!; + }, + }; +} + +describe('Managed extension contribution resource closure', () => { + it('preserves UTF-8 bodies across 64 KiB parts', async () => { + const resources = store(); + const contributions = { + skills: [], + commands: [], + agents: [], + context: [{ path: 'QWEN.md', body: '文'.repeat(60_000) }], + mcpServers: {}, + hooks: {}, + settings: [], + }; + const ref = await publishManagedExtensionContributions( + resources, + contributions, + ); + expect( + [...resources.data.values()].every((bytes) => bytes.length <= 64 * 1024), + ).toBe(true); + expect(await readManagedExtensionContributions(resources, ref)).toEqual( + contributions, + ); + }); + + it('rejects a part whose stored bytes changed', async () => { + const resources = store(); + const ref = await publishManagedExtensionContributions(resources, { + skills: [], + commands: [], + agents: [], + context: [], + mcpServers: {}, + hooks: {}, + settings: [], + }); + resources.data.set('resource-0', Buffer.from('changed')); + await expect( + readManagedExtensionContributions(resources, ref), + ).rejects.toThrow('part does not match'); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-extension-contributions.ts b/packages/core/src/managed-runtime/managed-extension-contributions.ts new file mode 100644 index 00000000000..76ce4f06924 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-extension-contributions.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { ManagedPackageContributions } from '../extension/managed-package.js'; +import type { ManagedSessionResourceStore } from './managed-session-storage.js'; +import { + assertManagedSessionDurableRef, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from './managed-session-records.js'; + +const PART_BYTES = 64 * 1024; +const MAX_BYTES = 2 * 1024 * 1024; + +export async function publishManagedExtensionContributions( + resources: Pick, + contributions: ManagedPackageContributions, +): Promise { + const bytes = Buffer.from(JSON.stringify(contributions)); + if (bytes.length > MAX_BYTES) + throw new Error('Extension contributions exceed 2 MiB.'); + const parts: ManagedSessionDurableRef[] = []; + for (let offset = 0; offset < bytes.length; offset += PART_BYTES) + parts.push( + await resources.publish( + 'managed-extension-contributions-part', + bytes.subarray(offset, offset + PART_BYTES), + ), + ); + return resources.publish( + 'managed-extension-contributions', + Buffer.from( + JSON.stringify({ + kind: 'managed-extension-contributions', + formatVersion: 1, + byteLength: bytes.length, + digest: digest(bytes), + parts, + }), + ), + ); +} + +export async function readManagedExtensionContributions( + resources: Pick, + ref: ManagedSessionDurableRef, +): Promise { + if ( + ref.kind !== 'managed-extension-contributions' || + ref.schemaVersion !== 1 || + ref.byteLength > PART_BYTES + ) + throw new Error('Invalid extension contribution reference.'); + const manifestBytes = await resources.read(ref); + if ( + manifestBytes.length !== ref.byteLength || + digest(manifestBytes) !== ref.digest + ) + throw new Error( + 'Extension contribution manifest does not match its reference.', + ); + const value: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes), + ); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid extension contribution manifest.'); + const manifest = value as Record; + if ( + Object.keys(manifest).sort().join(',') !== + 'byteLength,digest,formatVersion,kind,parts' || + manifest['kind'] !== 'managed-extension-contributions' || + manifest['formatVersion'] !== 1 || + !Number.isSafeInteger(manifest['byteLength']) || + Number(manifest['byteLength']) < 1 || + Number(manifest['byteLength']) > MAX_BYTES || + typeof manifest['digest'] !== 'string' || + !/^[a-f0-9]{64}$/u.test(manifest['digest']) || + !Array.isArray(manifest['parts']) || + manifest['parts'].length < 1 || + manifest['parts'].length > 32 + ) + throw new Error('Invalid extension contribution manifest.'); + const parts: Buffer[] = []; + for (const value of manifest['parts']) { + const part = assertManagedSessionDurableRef( + value as ManagedSessionJsonValue, + 'extension contribution part', + ); + if ( + part.kind !== 'managed-extension-contributions-part' || + part.schemaVersion !== 1 || + part.byteLength < 1 || + part.byteLength > PART_BYTES + ) + throw new Error('Invalid extension contribution part.'); + const bytes = await resources.read(part); + if (bytes.length !== part.byteLength || digest(bytes) !== part.digest) + throw new Error( + 'Extension contribution part does not match its reference.', + ); + parts.push(bytes); + } + const bytes = Buffer.concat(parts); + if ( + bytes.length !== manifest['byteLength'] || + digest(bytes) !== manifest['digest'] + ) + throw new Error('Extension contributions do not match their manifest.'); + return JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ) as ManagedPackageContributions; +} + +function digest(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/packages/core/src/managed-runtime/managed-extension-package.test.ts b/packages/core/src/managed-runtime/managed-extension-package.test.ts new file mode 100644 index 00000000000..43ef2595468 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-extension-package.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + parseManagedExtensionApplyCommand, + parseManagedExtensionPin, + parseManagedExtensionPins, +} from './managed-extension-package.js'; + +const pin = { + extensionId: 'reviewer', + packageRevision: 2, + packageDigest: 'a'.repeat(64), +}; + +describe('Managed extension pins', () => { + it('copies and freezes the complete package and settings identity', () => { + const input = { ...pin, settingsRevision: 3 }; + const parsed = parseManagedExtensionPin(input); + input.packageRevision = 4; + expect(parsed).toEqual({ ...pin, settingsRevision: 3 }); + expect(Object.isFrozen(parsed)).toBe(true); + }); + + it.each([ + { ...pin, packageRevision: 0 }, + { ...pin, packageRevision: Number.MAX_SAFE_INTEGER + 1 }, + { ...pin, packageDigest: 'A'.repeat(64) }, + { ...pin, extensionId: '../other-workspace' }, + { ...pin, settingsRevision: 0 }, + { ...pin, settings: { secret: 'must-not-be-in-a-pin' } }, + ])('rejects an invalid or non-public pin %j', (input) => { + expect(() => parseManagedExtensionPin(input)).toThrow(); + }); + + it('rejects duplicate identities instead of selecting the last version', () => { + expect(() => + parseManagedExtensionPins([pin, { ...pin, packageRevision: 3 }]), + ).toThrow(/duplicate/u); + expect(parseManagedExtensionPins([])).toEqual([]); + }); +}); + +describe('Managed extension apply commands', () => { + const command = { + operationId: '11111111-1111-4111-8111-111111111111', + actorKey: 'b'.repeat(64), + expectedRevision: 0, + extensions: [pin], + }; + + it('retains a complete replacement selection, including an empty one', () => { + expect(parseManagedExtensionApplyCommand(command)).toEqual(command); + expect( + parseManagedExtensionApplyCommand({ ...command, extensions: [] }) + .extensions, + ).toEqual([]); + }); + + it.each([ + { ...command, expectedRevision: -1 }, + { ...command, operationId: 'retry-with-new-operation' }, + { ...command, actorKey: '' }, + { ...command, force: true }, + ])('refuses invalid operation identity or revision %j', (input) => { + expect(() => parseManagedExtensionApplyCommand(input)).toThrow(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-extension-package.ts b/packages/core/src/managed-runtime/managed-extension-package.ts new file mode 100644 index 00000000000..4bd2f114f6a --- /dev/null +++ b/packages/core/src/managed-runtime/managed-extension-package.ts @@ -0,0 +1,424 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + assertManagedSessionDurableRef, + ManagedSessionRecordError, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from './managed-session-records.js'; +import type { ManagedHookCatalogPin } from './managed-hook-protocol.js'; +import type { ManagedPackageContributions } from '../extension/managed-package.js'; +import type { OperationGrant } from './managed-extension-record.js'; + +export interface ManagedExtensionCommandControl { + readonly kind: 'extension-command'; + readonly operationId: string; + readonly action: 'execute' | 'status' | 'cancel'; + readonly grant: OperationGrant; + readonly toolName: 'read_file' | 'shell'; + readonly input: Readonly>; + readonly revision: number; + readonly extensionId: string; +} + +export interface ManagedExtensionPin { + readonly extensionId: string; + readonly packageRevision: number; + readonly packageDigest: string; + readonly settingsRevision?: number; +} + +export interface ManagedExtensionApplyCommand { + readonly operationId: string; + readonly actorKey: string; + readonly expectedRevision: number; + readonly extensions: readonly ManagedExtensionPin[]; + readonly mcpChange?: ManagedExtensionMcpChange; +} + +export interface ManagedExtensionMcpPin { + readonly serverId: string; + readonly serverRevision: number; + readonly definitionDigest: string; +} + +export interface ManagedExtensionMcpSelection extends ManagedExtensionMcpPin { + readonly state: 'enabled' | 'disabled' | 'removed'; +} + +export interface ManagedExtensionMcpChange { + readonly action: + | 'add' + | 'edit' + | 'remove' + | 'enable' + | 'disable' + | 'reload' + | 'restart'; + readonly serverId: string; + readonly expectedRevision: number; + readonly servers: readonly ManagedExtensionMcpSelection[]; +} + +export interface ManagedExtensionPackageView { + readonly pin: ManagedExtensionPin; + readonly assetRoot: string; + readonly contributionsRef: ManagedSessionDurableRef; +} + +export interface ManagedExtensionEffectiveBundle { + readonly kind: 'managed-extension-config-bundle'; + readonly formatVersion: 1; + readonly configRevision: number; + readonly executionMode: 'linux-cgroup' | 'developer-process-group'; + readonly mcpRevision: number; + readonly independentMcpSelections: readonly ManagedExtensionMcpSelection[]; + readonly mcpChange?: ManagedExtensionMcpChange; + readonly baseMcpServers: readonly ManagedExtensionMcpPin[]; + readonly baseHookCatalog: ManagedHookCatalogPin | null; + readonly packages: readonly ManagedExtensionPackageView[]; + readonly mcpServers: readonly ManagedExtensionMcpPin[]; + readonly hookCatalog: ManagedHookCatalogPin | null; +} + +export interface ManagedExtensionPreparedPackage { + readonly pin: ManagedExtensionPin; + readonly assetRoot: string; + readonly contributions: ManagedPackageContributions; +} + +export interface ManagedExtensionPrepareResult { + readonly operationId: string; + readonly state: 'prepared'; + readonly expectedRevision: number; + readonly revision: number; + readonly executionMode: 'linux-cgroup' | 'developer-process-group'; + readonly packages: readonly ManagedExtensionPreparedPackage[]; + readonly mcpServers: readonly ManagedExtensionMcpPin[]; + readonly hookCatalog: ManagedHookCatalogPin | null; +} + +export type ManagedExtensionRuntimeControl = + | { + readonly kind: 'extension-prepare'; + readonly operationId: string; + readonly command: ManagedExtensionApplyCommand; + readonly expectedRevision: number; + readonly extensions: readonly ManagedExtensionPin[]; + readonly baseMcpServers: readonly ManagedExtensionMcpPin[]; + readonly baseHookCatalog: ManagedHookCatalogPin | null; + } + | { + readonly kind: 'extension-activate'; + readonly operationId: string; + readonly expectedRevision: number; + readonly revision: number; + } + | { readonly kind: 'extension-status'; readonly operationId: string } + | { + readonly kind: 'extension-reconcile'; + readonly operationId: string; + readonly expectedRevision: number; + readonly revision: number; + } + | { + readonly kind: 'extension-asset-read'; + readonly operationId: string; + readonly revision: number; + readonly extensionId: string; + readonly path: string; + } + | { + readonly kind: 'extension-asset-glob'; + readonly operationId: string; + readonly revision: number; + readonly extensionId: string; + readonly pattern: string; + }; + +export const MANAGED_EXTENSION_MAX_PACKAGES = 32; + +export function parseManagedExtensionEffectiveBundle( + value: unknown, +): ManagedExtensionEffectiveBundle { + const bundle = object(value, 'effective extension configuration'); + keys( + bundle, + [ + 'kind', + 'formatVersion', + 'configRevision', + 'executionMode', + 'mcpRevision', + 'independentMcpSelections', + 'baseMcpServers', + 'baseHookCatalog', + 'packages', + 'mcpServers', + 'hookCatalog', + ], + ['mcpChange'], + 'effective extension configuration', + ); + if ( + bundle['kind'] !== 'managed-extension-config-bundle' || + bundle['formatVersion'] !== 1 || + !['linux-cgroup', 'developer-process-group'].includes( + String(bundle['executionMode']), + ) || + !revision(bundle['configRevision'], 0) || + !revision(bundle['mcpRevision'], 0) || + !Array.isArray(bundle['packages']) || + bundle['packages'].length > MANAGED_EXTENSION_MAX_PACKAGES + ) + invalid('effective extension configuration'); + const packages = bundle['packages'].map( + (value): ManagedExtensionPackageView => { + const entry = object(value, 'package view'); + keys(entry, ['pin', 'assetRoot', 'contributionsRef'], [], 'package view'); + const pin = parseManagedExtensionPin(entry['pin']); + if (entry['assetRoot'] !== `extension://${pin.extensionId}/`) + invalid('package asset root'); + const contributionsRef = assertManagedSessionDurableRef( + entry['contributionsRef'] as ManagedSessionJsonValue, + 'package contributions', + ); + if ( + contributionsRef.kind !== 'managed-extension-contributions' || + contributionsRef.schemaVersion !== 1 + ) + invalid('package contributions'); + return { pin, assetRoot: entry['assetRoot'] as string, contributionsRef }; + }, + ); + if ( + new Set(packages.map((entry) => entry.pin.extensionId)).size !== + packages.length + ) + invalid('duplicate package view'); + return { + kind: 'managed-extension-config-bundle', + formatVersion: 1, + configRevision: bundle['configRevision'] as number, + executionMode: bundle[ + 'executionMode' + ] as ManagedExtensionEffectiveBundle['executionMode'], + mcpRevision: bundle['mcpRevision'] as number, + independentMcpSelections: mcpSelections(bundle['independentMcpSelections']), + ...(bundle['mcpChange'] === undefined + ? {} + : { mcpChange: mcpChange(bundle['mcpChange']) }), + baseMcpServers: mcpPins(bundle['baseMcpServers']), + baseHookCatalog: hookPin(bundle['baseHookCatalog']), + packages, + mcpServers: mcpPins(bundle['mcpServers']), + hookCatalog: hookPin(bundle['hookCatalog']), + }; +} + +function mcpPins(value: unknown): readonly ManagedExtensionMcpPin[] { + if (!Array.isArray(value) || value.length > 32) invalid('MCP selection'); + const pins = value.map((value): ManagedExtensionMcpPin => { + const pin = object(value, 'MCP pin'); + keys( + pin, + ['serverId', 'serverRevision', 'definitionDigest'], + [], + 'MCP pin', + ); + const identity = parseManagedExtensionPin({ + extensionId: pin['serverId'], + packageRevision: pin['serverRevision'], + packageDigest: pin['definitionDigest'], + }); + return { + serverId: identity.extensionId, + serverRevision: identity.packageRevision, + definitionDigest: identity.packageDigest, + }; + }); + if (new Set(pins.map((pin) => pin.serverId)).size !== pins.length) + invalid('duplicate MCP pin'); + return pins; +} + +function hookPin(value: unknown): ManagedHookCatalogPin | null { + if (value === null) return null; + const pin = object(value, 'Hook catalog pin'); + keys( + pin, + ['catalogId', 'catalogRevision', 'definitionDigest'], + [], + 'Hook catalog pin', + ); + const identity = parseManagedExtensionPin({ + extensionId: pin['catalogId'], + packageRevision: pin['catalogRevision'], + packageDigest: pin['definitionDigest'], + }); + return { + catalogId: identity.extensionId, + catalogRevision: identity.packageRevision, + definitionDigest: identity.packageDigest, + }; +} + +export function parseManagedExtensionPin(value: unknown): ManagedExtensionPin { + const pin = object(value, 'extension pin'); + keys( + pin, + ['extensionId', 'packageRevision', 'packageDigest'], + ['settingsRevision'], + 'extension pin', + ); + if ( + typeof pin['extensionId'] !== 'string' || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(pin['extensionId']) || + !revision(pin['packageRevision'], 1) || + typeof pin['packageDigest'] !== 'string' || + !/^[a-f0-9]{64}$/u.test(pin['packageDigest']) || + (pin['settingsRevision'] !== undefined && + !revision(pin['settingsRevision'], 1)) + ) + invalid('extension pin'); + return Object.freeze({ + extensionId: pin['extensionId'], + packageRevision: pin['packageRevision'] as number, + packageDigest: pin['packageDigest'], + ...(pin['settingsRevision'] === undefined + ? {} + : { settingsRevision: pin['settingsRevision'] as number }), + }); +} + +export function parseManagedExtensionPins( + value: unknown, +): readonly ManagedExtensionPin[] { + if (!Array.isArray(value) || value.length > MANAGED_EXTENSION_MAX_PACKAGES) + invalid('extension selection'); + const pins = value.map(parseManagedExtensionPin); + if (new Set(pins.map((pin) => pin.extensionId)).size !== pins.length) + invalid('duplicate extension selection'); + return Object.freeze(pins); +} + +export function parseManagedExtensionApplyCommand( + value: unknown, +): ManagedExtensionApplyCommand { + const command = object(value, 'extension apply command'); + keys( + command, + ['operationId', 'actorKey', 'expectedRevision', 'extensions'], + ['mcpChange'], + 'extension apply command', + ); + if ( + typeof command['operationId'] !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( + command['operationId'], + ) || + typeof command['actorKey'] !== 'string' || + !/^[a-f0-9]{64}$/u.test(command['actorKey']) || + !revision(command['expectedRevision'], 0) || + command['expectedRevision'] === Number.MAX_SAFE_INTEGER + ) + invalid('extension apply command'); + return Object.freeze({ + operationId: command['operationId'], + actorKey: command['actorKey'], + expectedRevision: command['expectedRevision'] as number, + extensions: parseManagedExtensionPins(command['extensions']), + ...(command['mcpChange'] === undefined + ? {} + : { mcpChange: mcpChange(command['mcpChange']) }), + }); +} + +function mcpSelections( + value: unknown, +): readonly ManagedExtensionMcpSelection[] { + if (!Array.isArray(value) || value.length > 32) invalid('MCP selection'); + const selected = value.map((value): ManagedExtensionMcpSelection => { + const candidate = object(value, 'MCP selection'); + const { state, ...pin } = candidate; + if (!['enabled', 'disabled', 'removed'].includes(String(state))) + invalid('MCP state'); + return { + ...mcpPins([pin])[0], + state: state as ManagedExtensionMcpSelection['state'], + }; + }); + if ( + new Set(selected.map((entry) => entry.serverId)).size !== selected.length || + selected.filter((entry) => entry.state === 'enabled').length > 16 + ) + invalid('MCP selection'); + return selected; +} + +function mcpChange(value: unknown): ManagedExtensionMcpChange { + const change = object(value, 'MCP change'); + keys( + change, + ['action', 'serverId', 'expectedRevision', 'servers'], + [], + 'MCP change', + ); + if ( + ![ + 'add', + 'edit', + 'remove', + 'enable', + 'disable', + 'reload', + 'restart', + ].includes(String(change['action'])) || + typeof change['serverId'] !== 'string' || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(change['serverId']) || + !revision(change['expectedRevision'], 0) + ) + invalid('MCP change'); + const servers = mcpSelections(change['servers']); + if (!servers.some((entry) => entry.serverId === change['serverId'])) + invalid('MCP change'); + return { + action: change['action'] as ManagedExtensionMcpChange['action'], + serverId: change['serverId'], + expectedRevision: change['expectedRevision'] as number, + servers, + }; +} + +function object(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + invalid(label); + return value as Record; +} + +function keys( + value: Record, + required: readonly string[], + optional: readonly string[], + label: string, +): void { + if ( + required.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some( + (key) => !required.includes(key) && !optional.includes(key), + ) + ) + invalid(label); +} + +function revision(value: unknown, minimum: number): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +function invalid(label: string): never { + throw new ManagedSessionRecordError(`Invalid Managed ${label}.`); +} diff --git a/packages/core/src/managed-runtime/managed-extension-projection.ts b/packages/core/src/managed-runtime/managed-extension-projection.ts index a296790565c..bc7eaa4134a 100644 --- a/packages/core/src/managed-runtime/managed-extension-projection.ts +++ b/packages/core/src/managed-runtime/managed-extension-projection.ts @@ -91,6 +91,11 @@ import { isHistoryMaintenanceStart, isHistoryMaintenanceSuccessor, } from './managed-history-maintenance-record.js'; +import { + parseManagedToolStage, + isManagedToolStageStart, + isManagedToolStageSuccessor, +} from './managed-tool-stage-record.js'; // H0c of #12827: how the Session authority keys, chains and projects the // Stage H records of managed-extension-record/1. The shared fixtures in @@ -178,6 +183,15 @@ function childRunTaskKind(record: AnyChildRun): ManagedTaskKind { export const MANAGED_EXTENSION_RECORD_BODIES: Readonly< Partial> > = Object.freeze({ + tool_stage: Object.freeze({ + taskKindOf: () => null, + parse: (value: unknown) => { + const record = parseManagedToolStage(value); + return { record, recordId: record.toolStageId, run: record.run }; + }, + isStart: isManagedToolStageStart, + isSuccessor: isManagedToolStageSuccessor, + }), history_maintenance: Object.freeze({ taskKindOf: () => null, parse: (value: unknown) => { diff --git a/packages/core/src/managed-runtime/managed-session-authority.ts b/packages/core/src/managed-runtime/managed-session-authority.ts index d5491af777c..7e1bc0e9c21 100644 --- a/packages/core/src/managed-runtime/managed-session-authority.ts +++ b/packages/core/src/managed-runtime/managed-session-authority.ts @@ -5,6 +5,15 @@ */ import { randomUUID } from 'node:crypto'; +import { parseManagedToolStage } from './managed-tool-stage-record.js'; +import { + assertManagedConfigInstallSuccessor, + isManagedExtensionConfig, + managedConfigBinding, + parseManagedConfigInstallContent, + parseManagedConfigInstallRecord, + type ManagedConfigBinding, +} from './managed-config-install.js'; import { SessionWriterLease } from '../services/session-writer-lease.js'; import { managedToolDigest } from '../tools/managed-tool-protocol.js'; import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; @@ -1578,6 +1587,7 @@ export class LocalManagedSessionAuthority { request: { domain: ManagedSessionDomain; content: Readonly>; + configBinding?: ManagedConfigBinding; }, actor: ManagedSessionActor, ): Promise { @@ -1614,18 +1624,54 @@ export class LocalManagedSessionAuthority { } const previous = this.domainRecords.get(request.domain); if (request.domain === 'config_install') { - const commandBody = request.content['command']; - if ( - request.content['kind'] !== 'hosted-mcp-settings' || - !commandBody || - typeof commandBody !== 'object' || - !('expectedRevision' in commandBody) || - commandBody.expectedRevision !== (previous?.revision ?? 0) - ) { - throw new ManagedSessionConflictError( - 'MCP settings revision conflicts.', + if (isManagedExtensionConfig(request.content)) { + const content = parseManagedConfigInstallContent(request.content); + const saved = previous + ? JSON.parse( + (await store.read(previous.recordRef)).toString('utf8'), + ) + : undefined; + assertManagedConfigInstallSuccessor( + isManagedExtensionConfig(saved) + ? parseManagedConfigInstallRecord(saved) + : undefined, + content, ); + if ( + content.command.operationId !== command.commandId || + (content.phase === 'committed' + ? JSON.stringify(request.configBinding) !== + JSON.stringify(managedConfigBinding(content)) + : request.configBinding !== undefined) + ) + throw new ManagedSessionConflictError( + 'Extension configuration binding conflicts.', + ); + } else { + const saved = previous + ? JSON.parse( + (await store.read(previous.recordRef)).toString('utf8'), + ) + : undefined; + const commandBody = request.content['command']; + if ( + isManagedExtensionConfig(saved) || + request.content['kind'] !== 'hosted-mcp-settings' || + !commandBody || + typeof commandBody !== 'object' || + !('expectedRevision' in commandBody) || + commandBody.expectedRevision !== (previous?.revision ?? 0) || + request.configBinding !== undefined + ) { + throw new ManagedSessionConflictError( + 'MCP settings revision conflicts.', + ); + } } + } else if (request.configBinding !== undefined) { + throw new ManagedSessionConflictError( + 'Only an extension configuration install may bind configuration.', + ); } let content = request.content; const incomingTitleRevision = content['managedRenameRevision']; @@ -1700,8 +1746,21 @@ export class LocalManagedSessionAuthority { recordRef, }, }, + ...(request.configBinding + ? [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: this.committed + 2, + eventId: `config-bound:${request.configBinding.revision}`, + sessionKey: command.sessionKey, + kind: 'config.bound' as const, + occurredAt: this.now(), + payload: { ...request.configBinding }, + }, + ] + : []), ], - [actor], + request.configBinding ? [actor, actor] : [actor], ); return { receipt, recordRef, revision }; }); @@ -3149,7 +3208,10 @@ export class LocalManagedSessionAuthority { replay = false, ): Promise { let refs: ReadonlyArray = []; - if (domain === 'mcp_configuration') { + if (domain === 'tool_stage') { + const stage = parseManagedToolStage(record); + refs = [stage.inputRef, stage.permissionRef, stage.resultRef]; + } else if (domain === 'mcp_configuration') { refs = [parseMcpConfiguration(record).catalogRef]; } else if (domain === 'mcp_operation') { const operation = parseMcpOperation(record); diff --git a/packages/core/src/managed-runtime/managed-session-records.ts b/packages/core/src/managed-runtime/managed-session-records.ts index 972ea574512..f437f9ca474 100644 --- a/packages/core/src/managed-runtime/managed-session-records.ts +++ b/packages/core/src/managed-runtime/managed-session-records.ts @@ -128,6 +128,7 @@ export type ManagedSessionDomain = (typeof MANAGED_SESSION_DOMAINS)[number]; */ export const MANAGED_SESSION_ENABLED_DOMAINS: readonly ManagedSessionDomain[] = [ + 'tool_stage', 'config_install', 'goal_state', 'todo_state', diff --git a/packages/core/src/managed-runtime/managed-tool-stage-record.test.ts b/packages/core/src/managed-runtime/managed-tool-stage-record.test.ts new file mode 100644 index 00000000000..b1544a065a9 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-tool-stage-record.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + parseManagedToolStage, + isManagedToolStageSuccessor, +} from './managed-tool-stage-record.js'; + +const fixtures = JSON.parse( + readFileSync( + new URL('./contracts/managed-tool-stage-v1.fixtures.json', import.meta.url), + 'utf8', + ), +) as { + valid: Array<{ name: string; record: unknown }>; + invalid: Array<{ name: string; record: unknown }>; + successors: Array<{ + name: string; + previous: unknown; + next: unknown; + valid: boolean; + }>; +}; +describe('shared Managed tool stage fixtures', () => { + it.each(fixtures.valid)('accepts $name', (fixture) => { + expect(() => parseManagedToolStage(fixture.record)).not.toThrow(); + }); + it.each(fixtures.invalid)('rejects $name', (fixture) => { + expect(() => parseManagedToolStage(fixture.record)).toThrow(); + }); + it.each(fixtures.successors)('checks successor $name', (fixture) => { + expect(isManagedToolStageSuccessor(fixture.previous, fixture.next)).toBe( + fixture.valid, + ); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-tool-stage-record.ts b/packages/core/src/managed-runtime/managed-tool-stage-record.ts new file mode 100644 index 00000000000..25fc1857be6 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-tool-stage-record.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isExtensionRunStart, + isExtensionRunSuccessor, + isTerminalRunState, + parseExtensionRun, + type ExtensionRun, +} from './managed-extension-record.js'; +import { isDeepStrictEqual } from 'node:util'; +import { + assertManagedSessionDurableRef, + assertManagedSessionStableId, + ManagedSessionRecordError, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from './managed-session-records.js'; + +export interface ManagedToolStage { + readonly kind: 'tool_stage'; + readonly toolStageId: string; + readonly expansionId: string; + readonly stepIndex: number; + readonly toolName: 'read_file' | 'shell'; + readonly inputRef: ManagedSessionDurableRef; + readonly permissionRef: ManagedSessionDurableRef | null; + readonly resultRef: ManagedSessionDurableRef | null; + readonly run: ExtensionRun; +} + +export function parseManagedToolStage(value: unknown): ManagedToolStage { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + const fields = value as Record; + if ( + Object.keys(fields).sort().join(',') !== + 'expansionId,inputRef,kind,permissionRef,resultRef,run,stepIndex,toolName,toolStageId' || + fields['kind'] !== 'tool_stage' || + !Number.isSafeInteger(fields['stepIndex']) || + Number(fields['stepIndex']) < 0 || + !['read_file', 'shell'].includes(String(fields['toolName'])) + ) + invalid(); + const toolStageId = assertManagedSessionStableId( + fields['toolStageId'], + 'toolStageId', + ); + const run = parseExtensionRun(fields['run']); + if ( + !run.definition || + run.definition.definitionId !== fields['toolName'] || + run.executionCallId !== toolStageId + ) + invalid(); + return { + kind: 'tool_stage', + toolStageId, + expansionId: assertManagedSessionStableId( + fields['expansionId'], + 'expansionId', + ), + stepIndex: fields['stepIndex'] as number, + toolName: fields['toolName'] as ManagedToolStage['toolName'], + inputRef: assertManagedSessionDurableRef(fields['inputRef'], 'inputRef'), + permissionRef: + fields['permissionRef'] === null + ? null + : assertManagedSessionDurableRef( + fields['permissionRef'], + 'permissionRef', + ), + resultRef: + fields['resultRef'] === null + ? null + : assertManagedSessionDurableRef(fields['resultRef'], 'resultRef'), + run, + }; +} + +export function isManagedToolStageStart(value: unknown): boolean { + try { + const record = parseManagedToolStage(value); + return record.resultRef === null && isExtensionRunStart(record.run); + } catch (cause) { + if (cause instanceof ManagedSessionRecordError) return false; + throw cause; + } +} + +export function isManagedToolStageSuccessor( + previous: unknown, + next: unknown, +): boolean { + try { + const before = parseManagedToolStage(previous); + const after = parseManagedToolStage(next); + if (isTerminalRunState(before.run.state)) + return isDeepStrictEqual(before, after); + for (const key of [ + 'kind', + 'toolStageId', + 'expansionId', + 'stepIndex', + 'toolName', + 'inputRef', + ] as const) + if (!isDeepStrictEqual(before[key], after[key])) return false; + for (const key of ['permissionRef', 'resultRef'] as const) + if (before[key] && !isDeepStrictEqual(before[key], after[key])) + return false; + return isExtensionRunSuccessor(before.run, after.run); + } catch (cause) { + if (cause instanceof ManagedSessionRecordError) return false; + throw cause; + } +} + +function invalid(): never { + throw new ManagedSessionRecordError( + 'Invalid Managed command preprocessing stage.', + ); +} diff --git a/packages/core/src/utils/markdown-command.ts b/packages/core/src/utils/markdown-command.ts new file mode 100644 index 00000000000..f20d203ec42 --- /dev/null +++ b/packages/core/src/utils/markdown-command.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { parse as parseYaml } from './yaml-parser.js'; +import { normalizeContent } from './textUtils.js'; + +/** + * Defines the Zod schema for a Markdown command definition file. + * The frontmatter contains optional metadata, and the body is the prompt. + */ +export const MarkdownCommandDefSchema = z.object({ + frontmatter: z + .object({ + description: z.string().optional(), + 'argument-hint': z.string().optional(), + when_to_use: z.string().optional(), + 'disable-model-invocation': z.boolean().optional(), + }) + .passthrough() + .optional(), + prompt: z.string({ + required_error: 'The prompt content is required.', + invalid_type_error: 'The prompt content must be a string.', + }), +}); + +export type MarkdownCommandDef = z.infer; + +/** + * Parses a Markdown command file with optional YAML frontmatter. + * @param content The file content + * @returns Parsed command definition with frontmatter and prompt + */ +export function parseMarkdownCommand(content: string): MarkdownCommandDef { + const normalizedContent = normalizeContent(content); + + // Match YAML frontmatter pattern: ---\n...\n---\n + // Allow empty frontmatter: ---\n---\n + const frontmatterRegex = /^---\n(?:([\s\S]*?)\n)?---(?:\n|$)([\s\S]*)$/; + const match = normalizedContent.match(frontmatterRegex); + + if (!match) { + // No frontmatter, entire content is the prompt + return { + prompt: normalizedContent.trim(), + }; + } + + const [, frontmatterYaml = '', body] = match; + + // Parse YAML frontmatter if not empty + let frontmatter: Record | undefined; + if (frontmatterYaml.trim()) { + try { + frontmatter = parseYaml(frontmatterYaml) as Record; + } catch (error) { + throw new Error( + `Failed to parse YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return { + frontmatter, + prompt: body.trim(), + }; +} diff --git a/packages/managed-agent-server/README.md b/packages/managed-agent-server/README.md index 7abd1be3d0e..e1319ad6945 100644 --- a/packages/managed-agent-server/README.md +++ b/packages/managed-agent-server/README.md @@ -1584,6 +1584,55 @@ nested classes, and context restarts inside one class. See the [isolation design](../../docs/design/2026-10-20-hosted-it-database-isolation.md) ([简体中文](../../docs/design/2026-10-20-hosted-it-database-isolation.zh-CN.md)). +## Managed extension packages + +Complete package distribution and the seven-component Managed profile are opt-in. +Set `QWEN_MANAGED_AGENT_EXTENSION_PACKAGES_ENABLED=true`, +`QWEN_MANAGED_AGENT_EXTENSION_ARTIFACT_DIRECTORY` to a durable private directory, +and `QWEN_MANAGED_AGENT_EXTENSION_ALLOWED_ACTORS` to the permitted operator actor +keys. The configured Workspace OPERATOR grant is also required. See the +[configuration example](distribution/config/application.example.yml) for the +separate Runtime execution profile: Linux LOCAL and SSH require delegated cgroup +v2; native macOS requires the explicit `developer-process-group` mode. Existing +Runtime bindings do not acquire the new execution contract automatically. +Replicas must share the authoritative artifact directory. A Runtime cache is +reconstructible and cannot replace that directory. Published versions and encrypted +settings revisions are retained; this increment does not implement garbage +collection. + +In WebShell, select the Managed Workspace and Session, open Extensions, prepare an +original Git/npm/directory/archive source for its target platform, then publish it. +An update explicitly selects the catalog extension ID. Select package versions and +an exact saved settings revision before applying to an idle Session. Saving settings +creates a complete immutable revision; values never appear in read responses. +Upgrading a package preserves its selected settings revision. An unfinished apply +has separate Continue original operation and Restore previous version controls; +unknown outcomes continue to block inference until the original owner reconciles. + +Public clients use `/v1/agents/workspaces/{workspaceId}/extensions` for bounded +binary publication and catalog queries and `/v1/agents/sessions/{sessionId}/extensions` +for configuration queries and application. The OpenAPI contract documents the +settings and operation subresources. The Java SDK exposes `ManagedExtensionsClient`; +the TypeScript daemon SDK can prepare and stream an original package. Creation's +optional `extensionPins` freezes the initial selection before the first model call. +Omitting it retains the existing Session path. + +Package assets use an explicit read-only extension source. Model tool schemas list +the selected public pins; `read_file` and `glob` accept package-relative paths or +`extension:///`. Shell selects `extension_context` and receives +`QWEN_EXTENSION_ROOT`, while its working directory remains the Session directory. +Ordinary Workspace tools retain their existing containment rules. Executable +preflight checks target interpreters and directly resolvable commands without +running Hooks or connecting MCP; complex authored Shell expressions are checked +when their approved invocation executes. Argument placeholders inside nested Shell +substitution, backticks or heredocs are rejected explicitly. + +See the linked [English design](../../docs/design/2026-10-24-managed-extension-packages.md) +and [Chinese design](../../docs/design/2026-10-24-managed-extension-packages.zh-CN.md). +Local focused tests, macOS developer receipts and the existing Linux static-extension +gate below establish different boundaries. Complete package Linux/SSH cleanup, +fault injection and real-provider qualification remain separate acceptance gates. + ## Linux extension process acceptance `HostedExtensionsIT` is collected by the existing `hosted-harness-mysql` profile. It uses the public AgentDefinition and Session APIs, real MySQL, the packaged Harness and durable native worker, a stdio MCP process, and command Hooks in a delegated cgroup v2 subtree. It verifies static Skill bytes, extension revision pinning across Harness replacement, Hook allow/deny, cancellation of a Hook and its descendant, safe continuation, and physical close. A separate MCP fault case writes and fsyncs an effect before disconnecting, then verifies that a replacement Harness refuses recovery without replaying the effect or reporting successful close. diff --git a/packages/managed-agent-server/distribution/config/application.example.yml b/packages/managed-agent-server/distribution/config/application.example.yml index cdfd5eb50f1..7ccc819b4b4 100644 --- a/packages/managed-agent-server/distribution/config/application.example.yml +++ b/packages/managed-agent-server/distribution/config/application.example.yml @@ -30,3 +30,23 @@ # credential-key: '${QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY:}' # previous-credential-keys: # '[reference-v1]': + +# Extension execution is separately disabled by default. Enable package publishing +# (qwen.managed-agent.extensions.enabled) as well before enabling execution. +# Linux LOCAL/SSH requires a writable delegated cgroup-v2 subtree on the Runtime +# host; cache-directory is an absolute private path outside every Workspace. +# qwen: +# managed-agent: +# extensions: +# enabled: true +# artifact-directory: /var/lib/qwen-managed-agent/extension-packages +# allowed-actors: [your-operator-actor] +# managed-extensions: +# enabled: true +# mode: linux-cgroup +# cache-directory: /var/lib/qwen-managed-agent/extension-cache +# cgroup-root: /sys/fs/cgroup/qwen-managed-agent/extensions +# Native macOS LOCAL is explicitly weaker developer mode: +# mode: developer-process-group +# cache-directory: /Users/developer/.qwen-managed-extension-cache +# cgroup-root: '' diff --git a/packages/managed-agent-server/distribution/systemd/README.md b/packages/managed-agent-server/distribution/systemd/README.md index acb7a881a2f..3438d7d346e 100644 --- a/packages/managed-agent-server/distribution/systemd/README.md +++ b/packages/managed-agent-server/distribution/systemd/README.md @@ -133,3 +133,20 @@ them before restarting with - `/etc/qwen-managed-agent` — credentials and `application.yml`. - `/var/lib/qwen-managed-agent` — Runtime state. - `/srv/qwen-workspace` — Workspace; adjust `ReadWritePaths` when it moves. + +## Extension execution + +Execution is disabled by default. Enable package publishing separately, then set +`QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_ENABLED=true`, +`QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_MODE=linux-cgroup`, an absolute private +`QWEN_MANAGED_AGENT_EXTENSION_CACHE_DIRECTORY` outside Workspace storage, and +`QWEN_MANAGED_AGENT_EXTENSION_CGROUP_ROOT` to a writable delegated cgroup-v2 +subtree on each LOCAL or SSH Runtime host. The service must receive delegation +from its administrator; a writable ordinary directory is insufficient. Missing +cgroup support fails activation; no process-group fallback is used. + +Native macOS LOCAL requires explicit `developer-process-group` mode and an empty +cgroup root. Its receipt proves registered process groups only. It does not prove +that detached descendants were reclaimed and is not a production process-tree +profile. Recovery retains holds when the durable owner journal or stop proof is +missing or uncertain. diff --git a/packages/managed-agent-server/distribution/systemd/README.zh-CN.md b/packages/managed-agent-server/distribution/systemd/README.zh-CN.md index 6820e407431..409f22448f1 100644 --- a/packages/managed-agent-server/distribution/systemd/README.zh-CN.md +++ b/packages/managed-agent-server/distribution/systemd/README.zh-CN.md @@ -79,3 +79,17 @@ curl --fail http://127.0.0.1:8080/actuator/health - `/etc/qwen-managed-agent` — 凭据与 `application.yml`。 - `/var/lib/qwen-managed-agent` — Runtime 状态。 - `/srv/qwen-workspace` — Workspace;迁移位置时同步调整 `ReadWritePaths`。 + +## Extension 执行 + +执行默认关闭。先单独启用包发布,再配置 +`QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_ENABLED=true`、 +`QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_MODE=linux-cgroup`、位于 Workspace 存储之外的绝对私有 +`QWEN_MANAGED_AGENT_EXTENSION_CACHE_DIRECTORY`,并将 +`QWEN_MANAGED_AGENT_EXTENSION_CGROUP_ROOT` 指向每台 LOCAL 或 SSH Runtime 主机上可写、已委派的 +cgroup-v2 子树。管理员必须为服务委派 cgroup;普通可写目录不足以提供此能力。 +缺少 cgroup 支持时激活失败,不回退到进程组。 + +macOS 原生 LOCAL 必须显式选择 `developer-process-group` 且清空 cgroup root。 +该模式的回执仅证明已登记进程组,不证明已脱离进程组的后代被回收,不属于生产进程树监管配置。 +持久 owner journal 或停止证明缺失、不确定时,恢复继续保留 hold。 diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionManagementController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionManagementController.java new file mode 100644 index 00000000000..543090022c2 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionManagementController.java @@ -0,0 +1,76 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionManagementService; +import com.alibaba.qwen.code.managedagent.model.TenantContext; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ManagedExtensionManagementController { + private static final String BASE = "/v1/agents/sessions/{sessionId}/extensions"; + private static final String WEB = "/api/agent/web-shell/v1/session-extensions"; + private final ManagedExtensionManagementService service; + + public ManagedExtensionManagementController(ManagedExtensionManagementService service) { this.service = service; } + + @GetMapping(BASE) + public ResponseEntity query(TenantContext tenant, @PathVariable String sessionId) { + return noStore(service.query(tenant.tenantId(), tenant.requireActorId(), sessionId, null)); + } + + @PostMapping(BASE) + public ResponseEntity submit(TenantContext tenant, @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String key, @RequestBody JsonNode command) { + return noStore(service.submit(tenant.tenantId(), tenant.requireActorId(), sessionId, key, command)); + } + + @GetMapping(BASE + "/operations/{operationId}") + public ResponseEntity operation(TenantContext tenant, @PathVariable String sessionId, @PathVariable String operationId) { + return noStore(service.query(tenant.tenantId(), tenant.requireActorId(), sessionId, operationId)); + } + + @PostMapping(BASE + "/operations/{operationId}/continue") + public ResponseEntity resume(TenantContext tenant, @PathVariable String sessionId, @PathVariable String operationId) { + return noStore(service.resume(tenant.tenantId(), tenant.requireActorId(), sessionId, operationId, false)); + } + + @PostMapping(BASE + "/operations/{operationId}/recover-previous") + public ResponseEntity recover(TenantContext tenant, @PathVariable String sessionId, @PathVariable String operationId) { + return noStore(service.resume(tenant.tenantId(), tenant.requireActorId(), sessionId, operationId, true)); + } + + public record WebQuery(@NotBlank String sessionId, String operationId) { } + public record WebSubmit(@NotBlank String sessionId, @NotBlank String idempotencyKey, @NotNull JsonNode command) { } + + @PostMapping(WEB + "/query") + public ResponseEntity webQuery(TenantContext tenant, @Valid @RequestBody WebQuery request) { + return noStore(service.query(tenant.tenantId(), tenant.requireActorId(), request.sessionId(), request.operationId())); + } + + @PostMapping(WEB + "/submit") + public ResponseEntity webSubmit(TenantContext tenant, @Valid @RequestBody WebSubmit request) { + return noStore(service.submit(tenant.tenantId(), tenant.requireActorId(), request.sessionId(), request.idempotencyKey(), request.command())); + } + + @PostMapping(WEB + "/continue") + public ResponseEntity webResume(TenantContext tenant, @Valid @RequestBody WebQuery request) { + return noStore(service.resume(tenant.tenantId(), tenant.requireActorId(), request.sessionId(), request.operationId(), false)); + } + + @PostMapping(WEB + "/recover-previous") + public ResponseEntity webRecover(TenantContext tenant, @Valid @RequestBody WebQuery request) { + return noStore(service.resume(tenant.tenantId(), tenant.requireActorId(), request.sessionId(), request.operationId(), true)); + } + + private static ResponseEntity noStore(JsonNode value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value); } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionPackageController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionPackageController.java new file mode 100644 index 00000000000..e032a493ddb --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedExtensionPackageController.java @@ -0,0 +1,155 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages; +import com.alibaba.qwen.code.managedagent.model.TenantContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; + +@RestController +public class ManagedExtensionPackageController { + private static final String BASE = "/v1/agents/workspaces/{workspaceId}/extensions"; + private static final String WEB = "/api/agent/web-shell/v1/workspace-extensions"; + private final ManagedExtensionPackageService service; + + public ManagedExtensionPackageController(ManagedExtensionPackageService service) { + this.service = service; + } + + @GetMapping(BASE) + public ResponseEntity list(TenantContext tenant, + @PathVariable String workspaceId) { + return noStore(service.list(tenant.tenantId(), tenant.requireActorId(), workspaceId)); + } + + @PostMapping(value = BASE, consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity create(TenantContext tenant, + @PathVariable String workspaceId, @RequestHeader("Idempotency-Key") String key, + @RequestParam("packageDigest") String digest, + @RequestParam String manifest, HttpServletRequest request) throws IOException { + return noStore(service.publish(tenant.tenantId(), tenant.requireActorId(), workspaceId, null, digest, + key, service.decodeManifest(manifest), request.getInputStream())); + } + + @PutMapping(value = BASE + "/{extensionId}/packages/{packageDigest}", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity publish(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String extensionId, + @PathVariable String packageDigest, @RequestHeader("Idempotency-Key") String key, + @RequestParam String manifest, HttpServletRequest request) throws IOException { + return noStore(service.publish(tenant.tenantId(), tenant.requireActorId(), workspaceId, extensionId, packageDigest, + key, service.decodeManifest(manifest), request.getInputStream())); + } + + @GetMapping(BASE + "/{extensionId}/packages/{packageRevision}") + public ResponseEntity get(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String extensionId, + @PathVariable long packageRevision, @RequestParam String packageDigest) { + return noStore(service.get(tenant.tenantId(), tenant.requireActorId(), workspaceId, extensionId, packageRevision, packageDigest)); + } + + @GetMapping(BASE + "/{extensionId}/packages/{packageRevision}/archive") + public ResponseEntity archive(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String extensionId, + @PathVariable long packageRevision, @RequestParam String packageDigest) throws IOException { + String actor = tenant.requireActorId(); + var saved = service.get(tenant.tenantId(), actor, workspaceId, extensionId, packageRevision, packageDigest); + var archive = service.archive(tenant.tenantId(), actor, workspaceId, extensionId, packageRevision, packageDigest); + StreamingResponseBody body = output -> { + service.get(tenant.tenantId(), actor, workspaceId, extensionId, packageRevision, packageDigest); + try (var input = Files.newInputStream(archive, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + input.transferTo(output); + } + }; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(saved.byteLength()).body(body); + } + + @GetMapping(BASE + "/{extensionId}/settings") + public ResponseEntity settings(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String extensionId) { + return noStore(service.settings(tenant.tenantId(), tenant.requireActorId(), workspaceId, extensionId)); + } + + @PostMapping(BASE + "/{extensionId}/settings") + public ResponseEntity settings(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String extensionId, + @RequestHeader("Idempotency-Key") String key, @RequestBody ManagedExtensionPackages.SettingsCommand command) { + return noStore(service.saveSettings(tenant.tenantId(), tenant.requireActorId(), workspaceId, extensionId, key, command)); + } + + @GetMapping(BASE + "/operations/{operationId}") + public ResponseEntity operation(TenantContext tenant, + @PathVariable String workspaceId, @PathVariable String operationId) { + return noStore(service.operation(tenant.tenantId(), tenant.requireActorId(), workspaceId, operationId)); + } + + @PostMapping(WEB + "/query") + public ResponseEntity query(TenantContext tenant, @Valid @RequestBody WorkspaceQuery request) { + return list(tenant, request.workspaceId()); + } + + @PostMapping(value = WEB + "/publish", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity webPublish(TenantContext tenant, + @RequestParam String workspaceId, @RequestParam(required = false) String extensionId, + @RequestParam String packageDigest, @RequestParam String manifest, + @RequestHeader("Idempotency-Key") String key, HttpServletRequest request) throws IOException { + return noStore(service.publish(tenant.tenantId(), tenant.requireActorId(), workspaceId, extensionId, + packageDigest, key, service.decodeManifest(manifest), request.getInputStream())); + } + + @PostMapping(WEB + "/settings/query") + public ResponseEntity settingsQuery(TenantContext tenant, @Valid @RequestBody ExtensionQuery request) { + return settings(tenant, request.workspaceId(), request.extensionId()); + } + + @PostMapping(WEB + "/settings/submit") + public ResponseEntity settingsSubmit(TenantContext tenant, + @Valid @RequestBody SettingsSubmit request) { + return noStore(service.saveSettings(tenant.tenantId(), tenant.requireActorId(), request.workspaceId(), + request.extensionId(), request.idempotencyKey(), + new ManagedExtensionPackages.SettingsCommand(request.expectedRevision(), request.values()))); + } + + @PostMapping(WEB + "/operations/query") + public ResponseEntity operationQuery(TenantContext tenant, @Valid @RequestBody OperationQuery request) { + return operation(tenant, request.workspaceId(), request.operationId()); + } + + @PostMapping(WEB + "/packages/query") + public ResponseEntity packageQuery(TenantContext tenant, @Valid @RequestBody PackageQuery request) { + return get(tenant, request.workspaceId(), request.pin().extensionId(), request.pin().packageRevision(), request.pin().packageDigest()); + } + + public record WorkspaceQuery(@NotBlank @Size(max = 512) String workspaceId) { } + public record ExtensionQuery(@NotBlank @Size(max = 512) String workspaceId, @NotBlank @Size(max = 128) String extensionId) { } + public record OperationQuery(@NotBlank @Size(max = 512) String workspaceId, @NotBlank @Size(max = 128) String operationId) { } + public record PackageQuery(@NotBlank @Size(max = 512) String workspaceId, @NotNull ManagedExtensionPackages.Pin pin) { } + public record SettingsSubmit(@NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 128) String extensionId, @NotBlank @Size(max = 512) String idempotencyKey, + long expectedRevision, Map values) { + @Override public String toString() { return "ExtensionSettingsSubmit[values=redacted]"; } + } + + private static ResponseEntity noStore(T value) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java index 7201d43e02a..7920f271ab9 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java @@ -96,6 +96,9 @@ public class PublicAgentController { throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Session Skills require a bound Workspace."); } + if (selection == null && request.extensionPins() != null) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Extension packages require a bound Workspace."); + } CommandAdmission admission = selection == null ? service.createSession(tenant.tenantId(), tenant.actorId(), idempotencyKey, request.agentId(), @@ -104,7 +107,7 @@ public class PublicAgentController { : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), idempotencyKey, request.agentId(), request.agentRevision(), null, - request.metadata(), request.input(), selection, null, null, request.skills()); + request.metadata(), request.input(), selection, null, null, request.skills(), null, request.extensionPins()); PublicSession session = service.getPublicSession(tenant.tenantId(), tenant.actorId(), admission.sessionId()); return ResponseEntity.status(HttpStatus.ACCEPTED) diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilter.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilter.java index 85ef7807ccb..46e8cce1c68 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilter.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilter.java @@ -108,6 +108,10 @@ public class SignatureAuthFilter extends OncePerRequestFilter "The signature timestamp is outside the allowed drift."); return; } + if (extensionArchiveUpload(request)) { + authenticateArchive(request, response, chain, tenantId, actorId, signature, timestamp.trim()); + return; + } // The body is signed, so it is buffered here and re-exposed to the // chain; without it a captured signature would authorize any // substitute body on the same method and path inside the window. The @@ -271,9 +275,15 @@ public class SignatureAuthFilter extends OncePerRequestFilter private String sign(String method, String uri, String query, String tenantId, String actorId, String timestamp, byte[] body, String idempotencyKey) { + return signDigest(method, uri, query, tenantId, actorId, timestamp, sha256Hex(body), idempotencyKey); + } + + private String signDigest(String method, String uri, String query, + String tenantId, String actorId, String timestamp, String digest, + String idempotencyKey) { String canonical = "qwen-broker-auth-v1\n" + method + "\n" + uri + "\n" + (query == null ? "" : query) + "\n" + tenantId - + "\n" + actorId + "\n" + timestamp + "\n" + sha256Hex(body) + + "\n" + actorId + "\n" + timestamp + "\n" + digest + "\n" + (idempotencyKey == null ? "" : idempotencyKey); try { return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, @@ -285,6 +295,84 @@ public class SignatureAuthFilter extends OncePerRequestFilter } } + private static boolean extensionArchiveUpload(HttpServletRequest request) { + String path = PublicSurface.pathWithinApplication(request); + String content = request.getContentType(); + return content != null && content.equalsIgnoreCase(MediaType.APPLICATION_OCTET_STREAM_VALUE) + && ("POST".equals(request.getMethod()) + && (path.matches("/v1/agents/workspaces/[^/]+/extensions") + || path.equals("/api/agent/web-shell/v1/workspace-extensions/publish")) + || "PUT".equals(request.getMethod()) + && path.matches("/v1/agents/workspaces/[^/]+/extensions/ext_[a-f0-9-]{36}/packages/[a-f0-9]{64}")); + } + + private void authenticateArchive(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + String tenantId, String actorId, String signature, String timestamp) throws IOException, ServletException { + long maximum = com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.MAX_ARCHIVE_BYTES; + if (request.getContentLengthLong() > maximum) { + reject(request, response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, + "payload_too_large", "Extension archive exceeds 100 MiB."); + return; + } + java.nio.file.Path temporary = java.nio.file.Files.createTempFile("qwen-extension-auth-", ".part"); + try { + var digest = DigestUtils.getSha256Digest(); + long total = 0; + try (var output = java.nio.file.Files.newOutputStream(temporary)) { + byte[] buffer = new byte[64 * 1024]; + int count; + while ((count = request.getInputStream().read(buffer)) != -1) { + total += count; + if (total > maximum) { + reject(request, response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, + "payload_too_large", "Extension archive exceeds 100 MiB."); + return; + } + digest.update(buffer, 0, count); + output.write(buffer, 0, count); + } + } + String expected = PREFIX + signDigest(request.getMethod(), request.getRequestURI(), request.getQueryString(), + tenantId, actorId, timestamp, org.apache.commons.codec.binary.Hex.encodeHexString(digest.digest()), + request.getHeader(IDEMPOTENCY_HEADER)); + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + signature.trim().getBytes(StandardCharsets.US_ASCII))) { + reject(request, response, HttpServletResponse.SC_UNAUTHORIZED, "invalid_signature", "The request signature is invalid."); + return; + } + try (var input = java.nio.file.Files.newInputStream(temporary)) { + var stream = new jakarta.servlet.ServletInputStream() { + private boolean finished; + @Override public boolean isFinished() { return finished; } + @Override public boolean isReady() { return true; } + @Override public void setReadListener(jakarta.servlet.ReadListener listener) { } + @Override public int read() throws IOException { + int value = input.read(); + finished = value == -1; + return value; + } + @Override public int read(byte[] bytes, int offset, int length) throws IOException { + int count = input.read(bytes, offset, length); + finished = count == -1; + return count; + } + }; + chain.doFilter(new HttpServletRequestWrapper(request) { + @Override public jakarta.servlet.ServletInputStream getInputStream() { return stream; } + @Override public Principal getUserPrincipal() { + return new AuthenticatedTenantActor() { + @Override public String tenantId() { return tenantId; } + @Override public String actorId() { return actorId; } + @Override public String getName() { return actorId; } + }; + } + }, response); + } + } finally { + java.nio.file.Files.deleteIfExists(temporary); + } + } + private static String sha256Hex(byte[] body) { try { return DigestUtils.sha256Hex(body); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java index 6fb37ae14b1..48fa5692fe6 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java @@ -228,6 +228,9 @@ public class WebShellAgentController { throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Session Skills require a bound Workspace."); } + if (selection == null && request.extensionPins() != null) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Extension packages require a bound Workspace."); + } // The WebShell handlers forward no metadata at all (null in its // slot): whatever the client sends is accepted and ignored, so no // clientId-only contract is asserted here and immediately @@ -239,7 +242,7 @@ public class WebShellAgentController { : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), request.idempotencyKey(), request.agentId(), null, request.title(), null, - request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId())); + request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins())); return ResponseEntity.accepted().body(admission); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java index 1dba761c2d0..d5d1ab9557f 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java @@ -36,7 +36,8 @@ public class HarnessConfiguration { WorkspaceExecutionStore workspaceExecution, ManagedActionStore actions, ManagedAgentDefinitionStore definitions, ManagedSessionStore resources, SshWorkspaceStore ssh, HarnessPlacementStore placements, - ManagedSessionSkills skills, ObjectMapper json) { + ManagedSessionSkills skills, ObjectMapper json, + com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore extensionRecords) { var pool = properties.getHarness().getPool(); if (pool.isEnabled()) { if ("kubernetes-workspace".equals(properties.getRuntimeBroker().getProvisioner())) { @@ -65,8 +66,12 @@ public class HarnessConfiguration { } try { return new HarnessPoolConnector(properties, placements, discovery, - endpoint -> new QwenHostedHarnessConnector(properties, sessions, workspaceExecution, - actions, definitions, resources, ssh), skills); + endpoint -> { + var connector = new QwenHostedHarnessConnector(properties, sessions, workspaceExecution, + actions, definitions, resources, ssh); + connector.configureExtensionResources(extensionRecords, json); + return connector; + }, skills); } catch (RuntimeException error) { discovery.close(); throw error; diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java index 90a5ca354d8..95ec1ad95d7 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java @@ -59,6 +59,8 @@ public class ManagedAgentProperties implements EnvironmentAware { private final SessionStore sessionStore = new SessionStore(); private final ToolPublication toolPublication = new ToolPublication(); private final Artifacts artifacts = new Artifacts(); + private final Extensions extensions = new Extensions(); + private final ManagedExtensions managedExtensions = new ManagedExtensions(); private final Dispatch dispatch = new Dispatch(); private final Events events = new Events(); private final RuntimeBroker runtimeBroker = new RuntimeBroker(); @@ -126,6 +128,12 @@ public class ManagedAgentProperties implements EnvironmentAware { return artifacts; } + public ManagedExtensions getManagedExtensions() { return managedExtensions; } + + public Extensions getExtensions() { + return extensions; + } + public Dispatch getDispatch() { return dispatch; } @@ -1239,6 +1247,34 @@ public class ManagedAgentProperties implements EnvironmentAware { public void setMaxVerificationTimeout(Duration value) { maxVerificationTimeout = value; } } + public static class ManagedExtensions { + private boolean enabled; + private String mode = "linux-cgroup"; + private String cacheDirectory = ""; + private String cgroupRoot = ""; + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean value) { enabled = value; } + public String getMode() { return mode; } + public void setMode(String value) { mode = value; } + public String getCacheDirectory() { return cacheDirectory; } + public void setCacheDirectory(String value) { cacheDirectory = value; } + public String getCgroupRoot() { return cgroupRoot; } + public void setCgroupRoot(String value) { cgroupRoot = value; } + } + + public static class Extensions { + private boolean enabled; + private String artifactDirectory = ""; + private List allowedActors = List.of(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getArtifactDirectory() { return artifactDirectory; } + public void setArtifactDirectory(String value) { artifactDirectory = value; } + public List getAllowedActors() { return allowedActors; } + public void setAllowedActors(List value) { allowedActors = List.copyOf(value); } + } + public static class Artifacts { private boolean enabled; private boolean publishOriginal; diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java index a4dc4db4937..48c824cbb08 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java @@ -93,11 +93,16 @@ public class RuntimeBrokerConfiguration { org.springframework.beans.factory.ObjectProvider publicationData, org.springframework.jdbc.core.JdbcTemplate jdbc, org.springframework.transaction.PlatformTransactionManager manager, - com.alibaba.qwen.code.managedagent.workspace.SshWorkspaceService ssh) { - return new EmbeddedRuntimeBroker(store, properties, + com.alibaba.qwen.code.managedagent.workspace.SshWorkspaceService ssh, + com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService extensions, + com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionCommandVerifier extensionCommands) { + var broker = new EmbeddedRuntimeBroker(store, properties, bindingRepository, sessionRepository, executionRepository, workspaceExecutionStore, publications.getIfAvailable(), publicationData.getIfAvailable(), jdbc, manager, ssh); + broker.configureExtensions(extensions); + broker.service().configureExtensionCommandVerifier(extensionCommands); + return broker; } // The child result relay and the close cascade probe bindings across diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionArchiveStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionArchiveStore.java new file mode 100644 index 00000000000..ede3051e83b --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionArchiveStore.java @@ -0,0 +1,121 @@ +package com.alibaba.qwen.code.managedagent.extensions; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.UUID; + +@Component +public class ManagedExtensionArchiveStore { + private final ManagedAgentProperties properties; + + public ManagedExtensionArchiveStore(ManagedAgentProperties properties) { + this.properties = properties; + } + + public boolean configured() { + return !StringUtils.isBlank(properties.getExtensions().getArtifactDirectory()); + } + + public long put(String tenant, String workspace, String digest, InputStream source) throws IOException { + Path target = path(tenant, workspace, digest); + privateDirectory(target.getParent()); + Path temporary = target.resolveSibling("." + digest + "." + UUID.randomUUID() + ".part"); + long length = 0; + var hash = DigestUtils.getSha256Digest(); + try { + try (OutputStream output = Files.newOutputStream(temporary, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + byte[] buffer = new byte[64 * 1024]; + int count; + while ((count = source.read(buffer)) != -1) { + length += count; + if (length > ManagedExtensionPackages.MAX_ARCHIVE_BYTES) { + throw new ManagedAgentException(HttpStatus.PAYLOAD_TOO_LARGE, + "extension_package_too_large", "Extension archive exceeds 100 MiB."); + } + hash.update(buffer, 0, count); + output.write(buffer, 0, count); + } + } + if (length == 0 || !digest.equals(org.apache.commons.codec.binary.Hex.encodeHexString(hash.digest()))) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, + "extension_package_digest_mismatch", "Extension archive digest does not match its contents."); + } + try (FileChannel file = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + file.force(true); + } + try { + Files.createLink(target, temporary); + } catch (java.nio.file.FileAlreadyExistsException existing) { + verify(tenant, workspace, digest, length); + } + try (FileChannel directory = FileChannel.open(target.getParent(), StandardOpenOption.READ)) { + directory.force(true); + } + return length; + } finally { + Files.deleteIfExists(temporary); + } + } + + public Path verify(String tenant, String workspace, String digest, long length) throws IOException { + Path target = path(tenant, workspace, digest); + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.size(target) != length) { + throw unavailable(); + } + try (InputStream input = Files.newInputStream(target, LinkOption.NOFOLLOW_LINKS)) { + if (!digest.equals(DigestUtils.sha256Hex(input))) { + throw unavailable(); + } + } + return target; + } + + private Path path(String tenant, String workspace, String digest) throws IOException { + if (!configured()) { + throw unavailable(); + } + if (digest == null || !digest.matches("[a-f0-9]{64}")) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "invalid_request", "Invalid extension digest."); + } + Path root = Path.of(properties.getExtensions().getArtifactDirectory()).toAbsolutePath().normalize(); + if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(root)) { + throw unavailable(); + } + Path scope = root.resolve(ManagedSchemaKeys.key(tenant, workspace)); + if (Files.exists(scope, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(scope)) { + throw unavailable(); + } + return scope.resolve(digest + ".tar.gz"); + } + + private static void privateDirectory(Path directory) throws IOException { + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory)) { + throw unavailable(); + } + if (Files.getFileStore(directory).supportsFileAttributeView("posix")) { + Files.setPosixFilePermissions(directory, + java.nio.file.attribute.PosixFilePermissions.fromString("rwx------")); + } + } + + private static ManagedAgentException unavailable() { + return new ManagedAgentException(HttpStatus.SERVICE_UNAVAILABLE, + "extension_package_unavailable", "The saved extension archive is unavailable."); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionManagementService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionManagementService.java new file mode 100644 index 00000000000..d45daa389ee --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionManagementService.java @@ -0,0 +1,221 @@ +package com.alibaba.qwen.code.managedagent.extensions; + +import com.alibaba.qwen.code.daemon.DaemonException; +import com.alibaba.qwen.code.daemon.DaemonHttpException; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.persistence.ManagedConfigInstall; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecords; +import com.alibaba.qwen.code.managedagent.persistence.ManagedMcpSettings; +import com.alibaba.qwen.code.managedagent.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +@Service +public class ManagedExtensionManagementService { + private final AgentStateStore store; + private final ManagedAgentService sessions; + private final ManagedExtensionRecordStore records; + private final ManagedExtensionPackageService packages; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final HarnessConnector harness; + + public ManagedExtensionManagementService(AgentStateStore store, ManagedAgentService sessions, + ManagedExtensionRecordStore records, ManagedExtensionPackageService packages, JdbcTemplate jdbc, + ObjectMapper json, HarnessConnector harness) { + this.store = store; + this.sessions = sessions; + this.records = records; + this.packages = packages; + this.jdbc = jdbc; + this.json = json; + this.harness = harness; + } + + public JsonNode query(String tenant, String actor, String sessionId, String operationId) { + SessionRecord session = sessions.requireReadableSession(tenant, actor, sessionId); + if (session.workspace() == null) { + return empty(sessionId, false); + } + boolean supported = packages.list(tenant, actor, session.workspace().getWorkspaceId()).supported() + && harness.supportsSessionExtensions(); + JsonNode latest = ManagedMcpSettings.find(jdbc, tenant, sessionId); + if (!ManagedConfigInstall.isPackageConfig(latest)) { + if (operationId != null) { + throw missing(); + } + return empty(sessionId, supported); + } + JsonNode selected = latest; + if (operationId != null) { + if (!ManagedConfigInstall.uuid(operationId)) { + throw invalid(); + } + for (int step = 0; !operationId.equals(selected.path("operationId").asText()); step++) { + if (step >= 4096 || selected.path("previousRecordRef").isNull()) { + throw missing(); + } + selected = read(tenant, sessionId, selected.get("previousRecordRef")); + if (!ManagedConfigInstall.isPackageConfig(selected)) { + throw missing(); + } + } + } + return snapshot(tenant, sessionId, selected, supported); + } + + public JsonNode submit(String tenant, String actor, String sessionId, String key, JsonNode command) { + ManagedAgentService.validateIdempotencyKey(key); + if (command == null || !command.isObject() || command.size() != 2 || !command.has("extensions") + || !command.path("expectedConfigRevision").isIntegralNumber() + || !command.path("expectedConfigRevision").canConvertToLong() + || command.path("expectedConfigRevision").longValue() < 0 + || command.path("expectedConfigRevision").longValue() > 9_007_199_254_740_990L) { + throw invalid(); + } + try { + ManagedConfigInstall.pins(command.get("extensions")); + } catch (ManagedExtensionRecords.InvalidRecordException error) { + throw invalid(); + } + String operationId = UUID.nameUUIDFromBytes(("managed-extension-apply\0" + tenant + "\0" + actor + "\0" + sessionId + "\0" + key) + .getBytes(StandardCharsets.UTF_8)).toString(); + SessionRecord session = store.requireExtensionManagement(tenant, actor, sessionId, operationId, true); + packages.requireExecutionAccess(tenant, actor, session.workspace().getWorkspaceId()); + if (!harness.supportsSessionExtensions()) { + throw unsupported(); + } + for (JsonNode pin : command.get("extensions")) { + packages.get(tenant, actor, session.workspace().getWorkspaceId(), pin.get("extensionId").asText(), + pin.get("packageRevision").asLong(), pin.get("packageDigest").asText()); + } + Map request = new LinkedHashMap<>(); + request.put("operationId", operationId); + request.put("actorKey", DigestUtils.sha256Hex(actor)); + request.put("expectedRevision", command.get("expectedConfigRevision").asLong()); + request.put("extensions", json.convertValue(command.get("extensions"), List.class)); + return invoke(() -> harness.applyExtensions(tenant, sessionId, request), sessionId, operationId); + } + + public JsonNode resume(String tenant, String actor, String sessionId, String operationId, boolean recoverPrevious) { + if (!ManagedConfigInstall.uuid(operationId)) { + throw invalid(); + } + SessionRecord session = store.requireExtensionManagement(tenant, actor, sessionId, operationId, true); + JsonNode current = ManagedMcpSettings.find(jdbc, tenant, sessionId); + if (!ManagedConfigInstall.isPackageConfig(current) || !operationId.equals(current.path("operationId").asText())) { + throw missing(); + } + if (!harness.supportsSessionExtensions() || session.workspace() == null) { + throw unsupported(); + } + packages.requireExecutionAccess(tenant, actor, session.workspace().getWorkspaceId()); + return invoke(() -> harness.continueExtensionOperation(tenant, sessionId, operationId, recoverPrevious), sessionId, operationId); + } + + private ObjectNode snapshot(String tenant, String sessionId, JsonNode body, boolean supported) { + String phase = body.path("phase").asText(); + boolean committed = Set.of("committed", "active", "recovery_required").contains(phase); + ObjectNode result = empty(sessionId, supported); + result.put("revision", body.path(committed ? "configRevision" : "previousConfigRevision").asLong()); + JsonNode requestedRef = "recoverPrevious".equals(body.path("mode").asText()) && "preparing".equals(phase) + ? body.path("restoresBundleRef") : body.path("bundleRef"); + result.set("requestedExtensions", requestedRef.isNull() ? body.path("command").get("extensions") : bundlePins(tenant, sessionId, requestedRef)); + JsonNode active = body; + for (int step = 0; step < 4096; step++) { + if ("active".equals(active.path("phase").asText())) { + result.set("extensions", bundlePins(tenant, sessionId, active.get("bundleRef"))); + break; + } + if (active.path("previousRecordRef").isNull()) { + break; + } + active = read(tenant, sessionId, active.get("previousRecordRef")); + if (!ManagedConfigInstall.isPackageConfig(active)) { + break; + } + } + ObjectNode operation = result.putObject("operation").put("operationId", body.path("operationId").asText()) + .put("state", phase).put("mode", body.path("mode").asText()).put("revision", body.path("configRevision").asLong()); + if (!body.path("error").isNull()) { + operation.set("error", body.get("error")); + } + return result; + } + + private ArrayNode bundlePins(String tenant, String sessionId, JsonNode ref) { + JsonNode bundle = read(tenant, sessionId, ref); + if (!"managed-extension-config-bundle".equals(bundle.path("kind").asText()) || !bundle.path("packages").isArray()) { + throw corrupt(); + } + ArrayNode pins = json.createArrayNode(); + for (JsonNode entry : bundle.get("packages")) { + pins.add(entry.get("pin")); + } + try { + ManagedConfigInstall.pins(pins); + } catch (ManagedExtensionRecords.InvalidRecordException error) { + throw corrupt(); + } + return pins; + } + + private JsonNode read(String tenant, String sessionId, JsonNode ref) { + var resource = records.readCommittedResource(tenant, sessionId, ref); + JsonNode value = ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); + if (value == null) { + throw corrupt(); + } + return value; + } + + private ObjectNode empty(String sessionId, boolean supported) { + ObjectNode result = json.createObjectNode().put("sessionId", sessionId).put("supported", supported).put("revision", 0); + result.putArray("extensions"); + result.putArray("requestedExtensions"); + return result; + } + + private JsonNode invoke(Supplier> request, String sessionId, String operationId) { + try { + JsonNode result = json.valueToTree(request.get()); + if (!sessionId.equals(result.path("sessionId").asText()) || !operationId.equals(result.path("operation").path("operationId").asText())) { + throw corrupt(); + } + return result; + } catch (DaemonHttpException error) { + HttpStatus status = HttpStatus.resolve(error.getStatusCode()); + if (status != null && status.is4xxClientError() && error.getErrorCode() != null + && error.getErrorCode().matches("[a-z][a-z0-9_]{0,127}")) { + throw new ManagedAgentException(status, error.getErrorCode(), "Extension configuration was refused."); + } + throw unavailable(); + } catch (DaemonException error) { + throw unavailable(); + } + } + + private static ManagedAgentException invalid() { return new ManagedAgentException(HttpStatus.BAD_REQUEST, "invalid_request", "Invalid extension configuration."); } + private static ManagedAgentException missing() { return new ManagedAgentException(HttpStatus.NOT_FOUND, "extension_operation_not_found", "The extension operation was not found."); } + private static ManagedAgentException corrupt() { return new ManagedAgentException(HttpStatus.CONFLICT, "session_config_corrupt", "The saved configuration cannot be verified."); } + private static ManagedAgentException unsupported() { return new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "Extension configuration is unavailable."); } + private static ManagedAgentException unavailable() { return new ManagedAgentException(HttpStatus.SERVICE_UNAVAILABLE, "session_config_outcome_unknown", "Query or continue the original extension operation."); } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageService.java new file mode 100644 index 00000000000..373c1c8edd3 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageService.java @@ -0,0 +1,425 @@ +package com.alibaba.qwen.code.managedagent.extensions; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Catalog; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Manifest; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Operation; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Package; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Pin; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Settings; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.SettingsCommand; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; +import com.alibaba.qwen.code.runtimebroker.security.ProtectedSecret; +import com.alibaba.qwen.code.runtimebroker.security.SecretProtector; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; + +@Service +public class ManagedExtensionPackageService { + private static final TypeReference> SETTINGS = new TypeReference<>() { }; + private static final TypeReference> KEYS = new TypeReference<>() { }; + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + private final ManagedWorkspaceRegistry workspaces; + private final ManagedAgentProperties properties; + private final ManagedExtensionArchiveStore archives; + private final ObjectMapper json; + private final SecretProtector secrets; + + public ManagedExtensionPackageService(JdbcTemplate jdbc, PlatformTransactionManager manager, + ManagedWorkspaceRegistry workspaces, ManagedAgentProperties properties, + ManagedExtensionArchiveStore archives, ObjectMapper json, ObjectProvider secrets) { + this.jdbc = jdbc; + this.transaction = new TransactionTemplate(manager); + this.workspaces = workspaces; + this.properties = properties; + this.archives = archives; + this.json = json; + this.secrets = secrets.getIfAvailable(); + } + + public Catalog list(String tenant, String actor, String workspace) { + requireAccess(tenant, actor, workspace, false); + var packages = jdbc.query("SELECT p.extension_id, p.package_revision, p.package_digest, p.manifest_json, p.byte_length" + + " FROM ma_extension_package p WHERE p.scope_key = ? AND p.state = 'PUBLISHED'" + + " AND p.package_revision = (SELECT MAX(v.package_revision) FROM ma_extension_package v" + + " WHERE v.scope_key = p.scope_key AND v.extension_id = p.extension_id AND v.state = 'PUBLISHED')" + + " ORDER BY extension_id, package_revision DESC LIMIT 257", + (row, index) -> new Package(new Pin(row.getString(1), row.getLong(2), row.getString(3), null), + decode(row.getString(4), Manifest.class), row.getLong(5)), scope(tenant, workspace)); + if (packages.size() > 256) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_catalog_too_large", + "The Workspace extension catalog exceeds 256 extensions."); + } + return new Catalog(properties.getExtensions().isEnabled() && archives.configured(), packages); + } + + public void requireExecutionAccess(String tenant, String actor, String workspace) { + requireAccess(tenant, actor, workspace, true); + } + + public void requireSelection(String tenant, String actor, String workspace, List pins) { + requireExecutionAccess(tenant, actor, workspace); + if (pins == null || pins.size() > 32 || pins.stream().anyMatch(java.util.Objects::isNull) + || pins.stream().map(Pin::extensionId).distinct().count() != pins.size()) { + throw invalid(); + } + for (Pin pin : pins) { + get(tenant, workspace, pin); + if (pin.settingsRevision() != null && jdbc.queryForObject("SELECT COUNT(*) FROM ma_extension_settings WHERE settings_key = ?", + Long.class, settingsKey(tenant, workspace, pin.extensionId(), pin.settingsRevision())) != 1) { + throw missing(); + } + } + } + + public Manifest decodeManifest(String encoded) { + try { + if (encoded == null || encoded.length() > ((ManagedExtensionPackages.MAX_MANIFEST_BYTES + 2) / 3) * 4) { + throw invalid(); + } + byte[] bytes = Base64.getUrlDecoder().decode(encoded); + if (bytes.length > ManagedExtensionPackages.MAX_MANIFEST_BYTES) { + throw invalid(); + } + Manifest manifest = json.readValue(bytes, Manifest.class); + if (manifest == null || manifest.schemaVersion() != 1 || manifest.compilerVersion() != 1 + || manifest.name() == null || !manifest.name().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}") + || manifest.version() == null || manifest.version().isBlank() || manifest.version().length() > 128 + || manifest.target() == null || manifest.target().platform() == null || manifest.target().arch() == null + || !Set.of("linux", "darwin").contains(manifest.target().platform()) + || !Set.of("x64", "arm64", "any").contains(manifest.target().arch()) + || manifest.components() == null || manifest.components().size() > ManagedExtensionPackages.COMPONENTS.size() + || manifest.components().stream().anyMatch(java.util.Objects::isNull) + || !ManagedExtensionPackages.COMPONENTS.containsAll(manifest.components()) + || Set.copyOf(manifest.components()).size() != manifest.components().size() + || (manifest.sourceType() == null) != (manifest.sourceRevision() == null) + || manifest.sourceType() != null && (!Set.of("git", "github-release", "npm", "directory", "archive", + "archive-url", "link", "snapshot").contains(manifest.sourceType()) + || manifest.sourceRevision().isBlank() || manifest.sourceRevision().length() > 256 + || manifest.sourceRevision().chars().anyMatch(Character::isISOControl))) { + throw invalid(); + } + return manifest; + } catch (IOException | IllegalArgumentException error) { + throw invalid(); + } + } + + public Operation publish(String tenant, String actor, String workspace, String extensionId, + String digest, String idempotencyKey, Manifest manifest, InputStream archive) throws IOException { + ManagedAgentService.validateIdempotencyKey(idempotencyKey); + requireDigest(digest); + String operationId = operationId(tenant, actor, workspace, "publish", idempotencyKey); + String id = extensionId == null ? "ext_" + UUID.nameUUIDFromBytes(operationId.getBytes(StandardCharsets.UTF_8)) : extensionId; + requireId(id); + String encodedManifest = encode(manifest); + String requestDigest = DigestUtils.sha256Hex(encode(List.of(id, digest, manifest))); + Operation operation = transaction.execute(status -> { + lockWorkspace(tenant, actor, workspace); + Operation existing = existingOperation(tenant, actor, workspace, operationId, requestDigest); + if (existing != null) { + return existing; + } + if (extensionId != null) { + requireExtension(tenant, workspace, id); + } else if (jdbc.queryForObject("SELECT COUNT(DISTINCT extension_id) FROM ma_extension_package WHERE scope_key = ?", + Long.class, scope(tenant, workspace)) >= 256) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_catalog_too_large", + "The Workspace extension catalog is limited to 256 extensions."); + } + long revision = nextPackageRevision(tenant, workspace, id); + jdbc.update("INSERT INTO ma_extension_package (package_key, scope_key, tenant_id, workspace_id, extension_id," + + " package_revision, package_digest, manifest_json, byte_length, state, gmt_create)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 'PREPARING', " + ManagedSchemaTime.SQL_NOW + ")", + packageKey(tenant, workspace, id, revision), scope(tenant, workspace), tenant, workspace, id, revision, + digest, encodedManifest); + insertOperation(tenant, actor, workspace, operationId, id, "publish", revision, digest, requestDigest, "pending"); + return new Operation(operationId, id, "publish", "pending", revision, digest); + }); + if ("settled".equals(operation.state())) { + return operation; + } + long length = archives.put(tenant, workspace, digest, archive); + return transaction.execute(status -> { + lockWorkspace(tenant, actor, workspace); + jdbc.update("UPDATE ma_extension_package SET state = 'PUBLISHED', byte_length = ? WHERE package_key = ?", + length, packageKey(tenant, workspace, id, operation.revision())); + jdbc.update("UPDATE ma_extension_operation SET state = 'settled' WHERE operation_key = ?", + operationKey(tenant, workspace, operationId)); + return new Operation(operationId, id, "publish", "settled", operation.revision(), digest); + }); + } + + public Operation operation(String tenant, String actor, String workspace, String operationId) { + requireAccess(tenant, actor, workspace, false); + var rows = jdbc.query("SELECT extension_id, operation_kind, state, revision, package_digest" + + " FROM ma_extension_operation WHERE operation_key = ? AND actor_key = ?", + (row, index) -> new Operation(operationId, row.getString(1), row.getString(2), row.getString(3), + row.getLong(4), row.getString(5)), operationKey(tenant, workspace, operationId), actorKey(tenant, actor)); + if (rows.size() != 1) { + throw missing(); + } + return rows.getFirst(); + } + + public Package get(String tenant, String actor, String workspace, String extensionId, long revision, String digest) { + requireAccess(tenant, actor, workspace, false); + return get(tenant, workspace, new Pin(extensionId, revision, digest, null)); + } + + public Path archive(String tenant, String actor, String workspace, String extensionId, long revision, + String digest) throws IOException { + Package saved = get(tenant, actor, workspace, extensionId, revision, digest); + return archives.verify(tenant, workspace, digest, saved.byteLength()); + } + + public Settings settings(String tenant, String actor, String workspace, String extensionId) { + requireAccess(tenant, actor, workspace, false); + requireExtension(tenant, workspace, extensionId); + var rows = jdbc.query("SELECT settings_revision, configured_keys_json FROM ma_extension_settings" + + " WHERE scope_key = ? AND extension_id = ? ORDER BY settings_revision DESC LIMIT 1", + (row, index) -> new Settings(row.getLong(1), readKeys(row.getString(2))), scope(tenant, workspace), extensionId); + return rows.isEmpty() ? new Settings(0, List.of()) : rows.getFirst(); + } + + public Operation saveSettings(String tenant, String actor, String workspace, String extensionId, + String idempotencyKey, SettingsCommand command) { + ManagedAgentService.validateIdempotencyKey(idempotencyKey); + requireId(extensionId); + if (command == null || command.expectedRevision() < 0 || command.expectedRevision() >= 9_007_199_254_740_991L + || command.values() == null || command.values().size() > 128 + || command.values().entrySet().stream().anyMatch(entry -> entry.getKey() == null + || !entry.getKey().matches("[A-Za-z_][A-Za-z0-9_]{0,127}") || entry.getValue() == null)) { + throw invalid(); + } + byte[] plaintext = encode(new TreeMap<>(command.values())).getBytes(StandardCharsets.UTF_8); + if (plaintext.length > ManagedExtensionPackages.MAX_SETTINGS_BYTES) { + throw new ManagedAgentException(HttpStatus.PAYLOAD_TOO_LARGE, "extension_settings_too_large", + "Extension settings exceed 8 KiB."); + } + String operationId = operationId(tenant, actor, workspace, "settings", idempotencyKey); + String requestDigest = DigestUtils.sha256Hex(encode(List.of(extensionId, command.expectedRevision()))); + return transaction.execute(status -> { + lockWorkspace(tenant, actor, workspace); + requireExtension(tenant, workspace, extensionId); + Operation existing = existingOperation(tenant, actor, workspace, operationId, requestDigest); + if (existing != null) { + if (!settingsValues(tenant, workspace, extensionId, existing.revision()).equals(command.values())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "idempotency_conflict", + "The extension operation key was used for another request."); + } + return existing; + } + long current = settings(tenant, actor, workspace, extensionId).settingsRevision(); + if (command.expectedRevision() != current) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_settings_revision_conflict", + "Extension settings changed. Refresh before applying settings."); + } + if (secrets == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_settings_unavailable", + "Encrypted extension settings are unavailable."); + } + long revision = current + 1; + ProtectedSecret protectedValues = secrets.protect(settingsContext(tenant, workspace, extensionId, revision), plaintext); + jdbc.update("INSERT INTO ma_extension_settings (settings_key, scope_key, tenant_id, workspace_id, extension_id," + + " settings_revision, encryption_key_id, encrypted_values, configured_keys_json, gmt_create)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, " + ManagedSchemaTime.SQL_NOW + ")", + settingsKey(tenant, workspace, extensionId, revision), scope(tenant, workspace), tenant, workspace, + extensionId, revision, protectedValues.getKeyId(), protectedValues.getCiphertext(), + encode(new ArrayList<>(new TreeMap<>(command.values()).keySet()))); + insertOperation(tenant, actor, workspace, operationId, extensionId, "settings", revision, null, + requestDigest, "settled"); + return new Operation(operationId, extensionId, "settings", "settled", revision, null); + }); + } + + public ResolvedPackage resolvePin(String tenant, String workspace, Pin pin) throws IOException { + Package saved = get(tenant, workspace, pin); + Map settings = pin.settingsRevision() == null ? Map.of() + : settingsValues(tenant, workspace, pin.extensionId(), pin.settingsRevision()); + return new ResolvedPackage(saved, archives.verify(tenant, workspace, pin.packageDigest(), saved.byteLength()), settings); + } + + private Map settingsValues(String tenant, String workspace, String extensionId, long revision) { + var rows = jdbc.query("SELECT encryption_key_id, encrypted_values FROM ma_extension_settings WHERE settings_key = ?", + (row, index) -> new ProtectedSecret(row.getString(1), row.getString(2)), settingsKey(tenant, workspace, extensionId, revision)); + if (rows.size() != 1) { + throw missing(); + } + if (secrets == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_settings_unavailable", + "Encrypted extension settings are unavailable."); + } + byte[] plaintext = secrets.unprotect(settingsContext(tenant, workspace, extensionId, revision), rows.getFirst()); + try { + return Map.copyOf(json.readValue(plaintext, SETTINGS)); + } catch (IOException invalid) { + throw new IllegalStateException("Invalid saved extension settings", invalid); + } + } + + public record ResolvedPackage(Package descriptor, Path archive, Map settings) { + @Override + public String toString() { + return "ResolvedPackage[descriptor=" + descriptor + ", settings=redacted]"; + } + } + + private Package get(String tenant, String workspace, Pin pin) { + requireId(pin.extensionId()); + requireDigest(pin.packageDigest()); + if (pin.packageRevision() < 1 || pin.packageRevision() > 9_007_199_254_740_991L + || pin.settingsRevision() != null && (pin.settingsRevision() < 1 || pin.settingsRevision() > 9_007_199_254_740_991L)) { + throw invalid(); + } + var rows = jdbc.query("SELECT manifest_json, byte_length FROM ma_extension_package WHERE package_key = ?" + + " AND package_digest = ? AND state = 'PUBLISHED'", + (row, index) -> new Package(pin, decode(row.getString(1), Manifest.class), row.getLong(2)), + packageKey(tenant, workspace, pin.extensionId(), pin.packageRevision()), pin.packageDigest()); + if (rows.size() != 1) { + throw missing(); + } + return rows.getFirst(); + } + + private void requireAccess(String tenant, String actor, String workspace, boolean write) { + WorkspaceAccess access = workspaces.accessOf(tenant, actor, workspace); + if (!access.canRead()) { + throw missing(); + } + if (write && (!access.atLeast(WorkspaceAccess.OPERATOR) + || !properties.getExtensions().isEnabled() || !archives.configured() + || !(properties.getExtensions().getAllowedActors().contains(actor) + || properties.getExtensions().getAllowedActors().contains("*")))) { + throw new ManagedAgentException(HttpStatus.FORBIDDEN, "extension_management_forbidden", + "Extension management requires a Workspace operator and deployment permission."); + } + } + + private void lockWorkspace(String tenant, String actor, String workspace) { + var states = jdbc.queryForList("SELECT state FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ? FOR UPDATE", + String.class, tenant, workspace); + requireAccess(tenant, actor, workspace, true); + if (states.size() != 1 || !"ACTIVE".equals(states.getFirst())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_unavailable", "Workspace is not active."); + } + } + + private void requireExtension(String tenant, String workspace, String extensionId) { + requireId(extensionId); + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ma_extension_package WHERE scope_key = ?" + + " AND extension_id = ? AND state = 'PUBLISHED'", Integer.class, scope(tenant, workspace), extensionId); + if (count == null || count == 0) { + throw missing(); + } + } + + private long nextPackageRevision(String tenant, String workspace, String extensionId) { + Long maximum = jdbc.queryForObject("SELECT COALESCE(MAX(package_revision), 0) FROM ma_extension_package" + + " WHERE scope_key = ? AND extension_id = ?", Long.class, scope(tenant, workspace), extensionId); + return (maximum == null ? 0 : maximum) + 1; + } + + private Operation existingOperation(String tenant, String actor, String workspace, String operationId, String digest) { + var rows = jdbc.queryForList("SELECT request_digest FROM ma_extension_operation WHERE operation_key = ?", + String.class, operationKey(tenant, workspace, operationId)); + if (rows.isEmpty()) { + return null; + } + if (!digest.equals(rows.getFirst())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "idempotency_conflict", + "The extension operation key was used for another request."); + } + return operation(tenant, actor, workspace, operationId); + } + + private void insertOperation(String tenant, String actor, String workspace, String operationId, String extensionId, + String kind, long revision, String packageDigest, String requestDigest, String state) { + jdbc.update("INSERT INTO ma_extension_operation (operation_key, scope_key, tenant_id, workspace_id, actor_key," + + " operation_id, extension_id, operation_kind, revision, package_digest, request_digest, state, gmt_create)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + ManagedSchemaTime.SQL_NOW + ")", + operationKey(tenant, workspace, operationId), scope(tenant, workspace), tenant, workspace, actorKey(tenant, actor), + operationId, extensionId, kind, revision, packageDigest, requestDigest, state); + } + + private String encode(Object value) { + try { + return json.writeValueAsString(value); + } catch (IOException error) { + throw new IllegalStateException("Cannot encode extension record", error); + } + } + + private T decode(String value, Class type) { + try { + return json.readValue(value, type); + } catch (IOException error) { + throw new IllegalStateException("Invalid saved extension record", error); + } + } + + private List readKeys(String value) { + try { + return json.readValue(value, KEYS); + } catch (IOException error) { + throw new IllegalStateException("Invalid saved extension settings keys", error); + } + } + + private static String scope(String tenant, String workspace) { return ManagedSchemaKeys.key(tenant, workspace); } + private static String actorKey(String tenant, String actor) { return ManagedSchemaKeys.key(tenant, actor); } + private static String operationKey(String tenant, String workspace, String id) { return ManagedSchemaKeys.key(tenant, workspace, id); } + private static String packageKey(String tenant, String workspace, String id, long revision) { + return ManagedSchemaKeys.key(tenant, workspace, id, Long.toString(revision)); + } + private static String settingsKey(String tenant, String workspace, String id, long revision) { + return ManagedSchemaKeys.key("extension-settings", tenant, workspace, id, Long.toString(revision)); + } + private static String settingsContext(String tenant, String workspace, String id, long revision) { + return "managed-extension-settings:" + settingsKey(tenant, workspace, id, revision); + } + private static String operationId(String tenant, String actor, String workspace, String kind, String key) { + return "eop_" + ManagedSchemaKeys.key(tenant, actor, workspace, kind, key); + } + private static void requireId(String id) { + if (id == null || !id.matches("ext_[a-f0-9-]{36}")) { + throw invalid(); + } + } + private static void requireDigest(String digest) { + if (digest == null || !digest.matches("[a-f0-9]{64}")) { + throw invalid(); + } + } + private static ManagedAgentException invalid() { + return new ManagedAgentException(HttpStatus.BAD_REQUEST, "invalid_request", "Invalid extension package request."); + } + private static ManagedAgentException missing() { + return new ManagedAgentException(HttpStatus.NOT_FOUND, "extension_package_not_found", "Extension package was not found."); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackages.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackages.java new file mode 100644 index 00000000000..14e2e2669cc --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackages.java @@ -0,0 +1,73 @@ +package com.alibaba.qwen.code.managedagent.extensions; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Map; + +public final class ManagedExtensionPackages { + public static final long MAX_ARCHIVE_BYTES = 100L * 1024 * 1024; + public static final int MAX_MANIFEST_BYTES = 64 * 1024; + public static final int MAX_SETTINGS_BYTES = 8 * 1024; + public static final List COMPONENTS = + List.of("skills", "commands", "agents", "context", "mcp", "hooks", "settings"); + + private ManagedExtensionPackages() { } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Pin(String extensionId, long packageRevision, String packageDigest, + Long settingsRevision) { + @JsonAnySetter + public void rejectUnknown(String name, JsonNode value) { + throw new IllegalArgumentException("Unknown extension pin field"); + } + } + + public record Target(String platform, String arch) { + @JsonAnySetter + public void rejectUnknown(String name, JsonNode value) { + throw new IllegalArgumentException("Unknown extension target field"); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Manifest(int schemaVersion, int compilerVersion, String name, String version, + Target target, List components, String sourceType, String sourceRevision) { + public Manifest(int schemaVersion, int compilerVersion, String name, String version, + Target target, List components) { + this(schemaVersion, compilerVersion, name, version, target, components, null, null); + } + + @JsonAnySetter + public void rejectUnknown(String name, JsonNode value) { + throw new IllegalArgumentException("Unknown extension manifest field"); + } + } + + public record Package(Pin pin, Manifest manifest, long byteLength) { + } + + public record Catalog(boolean supported, List extensions) { + } + + public record Settings(long settingsRevision, List configuredKeys) { + } + + public record SettingsCommand(long expectedRevision, Map values) { + @Override + public String toString() { + return "SettingsCommand[expectedRevision=" + expectedRevision + ", values=redacted]"; + } + + @JsonAnySetter + public void rejectUnknown(String name, JsonNode value) { + throw new IllegalArgumentException("Unknown extension settings field"); + } + } + + public record Operation(String operationId, String extensionId, String kind, + String state, long revision, String packageDigest) { + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java index 22941b2c833..6c9795611a2 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java @@ -90,6 +90,16 @@ public interface HarnessConnector extends AutoCloseable { throw new UnsupportedOperationException("MCP management is unavailable"); } + default boolean supportsSessionExtensions() { return false; } + + default Map applyExtensions(String tenantId, String sessionId, Map command) { + throw new UnsupportedOperationException("Extension management is unavailable"); + } + + default Map continueExtensionOperation(String tenantId, String sessionId, String operationId, boolean recoverPrevious) { + throw new UnsupportedOperationException("Extension management is unavailable"); + } + default boolean isWorkspaceFilesAvailable() { return false; } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java index 63754e8cd7d..aa1fe970e3f 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java @@ -382,6 +382,21 @@ public class HarnessPoolConnector implements HarnessConnector { return feature("session-skills/2"); } + @Override + public boolean supportsSessionExtensions() { + return feature("session-extension-packages/1"); + } + + @Override + public Map applyExtensions(String tenant, String session, Map command) { + return call(tenant, session, leaf -> leaf.applyExtensions(tenant, session, command)); + } + + @Override + public Map continueExtensionOperation(String tenant, String session, String operationId, boolean recoverPrevious) { + return call(tenant, session, leaf -> leaf.continueExtensionOperation(tenant, session, operationId, recoverPrevious)); + } + @Override public boolean isWorkspaceFilesAvailable() { return properties.getHarness().isWorkspaceFilesEnabled(); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index afc3ae84d26..c8ba9473946 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -62,6 +62,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector { private ManagedSessionSkills sessionSkills; private final ManagedSessionStore resources; @org.springframework.beans.factory.annotation.Autowired + private com.fasterxml.jackson.databind.ObjectMapper extensionJson; + @org.springframework.beans.factory.annotation.Autowired private com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore extensions; private final WriterCredentialPolicy credentials; private final SshWorkspaceStore ssh; @@ -86,6 +88,12 @@ public class QwenHostedHarnessConnector implements HarnessConnector { this.client = negotiated; } + public void configureExtensionResources(com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore records, + com.fasterxml.jackson.databind.ObjectMapper json) { + this.extensions = records; + this.extensionJson = json; + } + T withPlacement(PoolCall call, Supplier action) { PoolCall previous = poolCall.get(); poolCall.set(call); @@ -351,12 +359,14 @@ public class QwenHostedHarnessConnector implements HarnessConnector { .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) .withSessionSkillsRef(sessionSkillsRef(session, false)) + .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) .withHookCatalog(hookCatalog(session)) .withPublicChildAgentsVersion(session.publicChildAgentsVersion()) .withPublicChildModelRoute(publicChildModelRoute(session)) + .withPublicChildExtensionAgent(publicChildExtensionAgent(session)) .withForegroundOnly(foregroundOnly(session)) - .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)) + .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)) .forLifecycle(operation.operationId(), operation.claimGeneration(), operation.lifecycleProtocolVersion() == 2 ? 2 : 1)); attachments.put(key, ref); } @@ -726,6 +736,49 @@ public class QwenHostedHarnessConnector implements HarnessConnector { } } + @Override + public boolean supportsSessionExtensions() { + try { + return sessionStore.isEnabled() && client().capabilities().getFeatures().contains("session-extension-packages/1"); + } catch (RuntimeException unavailable) { + return false; + } + } + + @Override + public Map applyExtensions(String tenantId, String sessionId, Map command) { + try { + requireReadyForNewWork(tenantId, sessionId, false); + return client().applyExtensions(extensionAttachment(tenantId, sessionId, true), command); + } catch (HostedHarnessGenerationException error) { + adoptGeneration(error); + throw error; + } + } + + @Override + public Map continueExtensionOperation(String tenantId, String sessionId, String operationId, boolean recoverPrevious) { + try { + return client().continueExtensionOperation(extensionAttachment(tenantId, sessionId, true), operationId, recoverPrevious); + } catch (HostedHarnessGenerationException error) { + adoptGeneration(error); + throw error; + } + } + + private HarnessSessionRef extensionAttachment(String tenantId, String sessionId, boolean load) { + HarnessSessionRef attached = attachments.get(attachmentKey(tenantId, sessionId)); + if (attached == null && load) { + SessionRecord session = sessions.requireSession(tenantId, sessionId); + createOrLoad(tenantId, sessionId, session.harnessBootId() != null, true); + attached = attachments.get(attachmentKey(tenantId, sessionId)); + } + if (attached == null) { + throw new com.alibaba.qwen.code.daemon.DaemonException("The original Session must be resumed first"); + } + return attached; + } + @Override public boolean supportsInputAttachments() { try { @@ -1203,12 +1256,14 @@ public class QwenHostedHarnessConnector implements HarnessConnector { .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) .withSessionSkillsRef(sessionSkillsRef(session, false)) + .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) .withHookCatalog(hookCatalog(session)) .withPublicChildAgentsVersion(session.publicChildAgentsVersion()) .withPublicChildModelRoute(publicChildModelRoute(session)) + .withPublicChildExtensionAgent(publicChildExtensionAgent(session)) .withForegroundOnly(foregroundOnly(session)) - .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)); + .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)); HarnessSessionRef attached = client().loadSession( stopMessages ? load.withStoppedMessages() : load); if (session.workspace() != null && !actions.approvalMode(tenantId, @@ -1478,9 +1533,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector { .approvalTimeoutMs(properties.getApprovalTimeout().toMillis()) .toolProfile(profile) .foregroundOnly(foregroundOnly(session)) - .childWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)) - .childSnapshots(childSnapshots && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)); - builder.publicChildAgentsVersion(session.publicChildAgentsVersion()).publicChildModelRoute(publicChildModelRoute(session)); + .childWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)) + .childSnapshots(childSnapshots && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)); + builder.publicChildAgentsVersion(session.publicChildAgentsVersion()).publicChildModelRoute(publicChildModelRoute(session)).publicChildExtensionAgent(publicChildExtensionAgent(session)); if (WorkspaceToolProfiles.isPublicShell(profile) && !publicChildLaunchAvailable(session)) { builder.suppressChildAgents(); } @@ -1506,7 +1561,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { if (definition != null) { builder.agentDefinition(definition); } - builder.sessionSkillsRef(sessionSkillsRef(session, true)); + builder.sessionSkillsRef(sessionSkillsRef(session, true)).initialSelection(initialSelection(session)); StoreModels.SessionLineage lineage = sessions.findChildLineage( session.tenantId(), session.sessionId()); if (lineage != null) { @@ -1561,12 +1616,14 @@ public class QwenHostedHarnessConnector implements HarnessConnector { .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) .withSessionSkillsRef(sessionSkillsRef(session, false)) + .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) .withHookCatalog(hookCatalog(session)) .withPublicChildAgentsVersion(session.publicChildAgentsVersion()) .withPublicChildModelRoute(publicChildModelRoute(session)) + .withPublicChildExtensionAgent(publicChildExtensionAgent(session)) .withForegroundOnly(foregroundOnly(session)) - .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)); + .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)); if (promptId != null) { request.withCancellationPromptId(promptId); } @@ -1721,10 +1778,12 @@ public class QwenHostedHarnessConnector implements HarnessConnector { .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) .withSessionSkillsRef(sessionSkillsRef(session, false)) + .withInitialSelection(initialSelection(session)) .withPublicChildAgentsVersion(session.publicChildAgentsVersion()) .withPublicChildModelRoute(publicChildModelRoute(session)) + .withPublicChildExtensionAgent(publicChildExtensionAgent(session)) .withForegroundOnly(foregroundOnly(session)) - .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !foregroundOnly(session)) + .withChildWorkspaces(childWorkspaces && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session), childSnapshots && session.publicChildAgentsVersion() == 0 && !sshWorkspace(session)) .forRecoveryCleanup(promptId)); attachments.compute(key, (ignored, held) -> { if (held != null) { @@ -1960,6 +2019,39 @@ public class QwenHostedHarnessConnector implements HarnessConnector { return result; } + private Map publicChildExtensionAgent(SessionRecord session) { + if (session.publicChildAgentsVersion() != 1) { + return null; + } + var lineage = sessions.findChildLineage(session.tenantId(), session.sessionId()); + if (lineage == null) { + return null; + } + var run = extensions.findRecord(session.tenantId(), lineage.parentSessionId(), "child_run", lineage.parentChildRunId()).orElseThrow(); + var envelope = extensions.readRecordResource(session.tenantId(), lineage.parentSessionId(), run.required("inputRef")); + var launch = envelope.path("publicLaunch"); + if (launch.path("version").asInt() != 2) { + return null; + } + var view = launch.required("extensionAgent"); + var bundle = extensions.readRecordResource(session.tenantId(), lineage.parentSessionId(), view.required("bundleRef")); + var copied = resources.publishInheritedExtensionResource(session.tenantId(), session.workspace().getWorkspaceId(), session.sessionId(), + extensions.readCommittedResource(session.tenantId(), lineage.parentSessionId(), view.get("bundleRef"))); + for (var entry : bundle.required("packages")) { + var ref = entry.required("contributionsRef"); + var manifest = extensions.readRecordResource(session.tenantId(), lineage.parentSessionId(), ref); + resources.publishInheritedExtensionResource(session.tenantId(), session.workspace().getWorkspaceId(), session.sessionId(), + extensions.readCommittedResource(session.tenantId(), lineage.parentSessionId(), ref)); + for (var part : manifest.required("parts")) { + resources.publishInheritedExtensionResource(session.tenantId(), session.workspace().getWorkspaceId(), session.sessionId(), + extensions.readCommittedResource(session.tenantId(), lineage.parentSessionId(), part)); + } + } + return Map.of("extensionAgent", extensionJson.convertValue(view, Map.class), + "effectiveConfigRef", Map.of("resourceId", copied.resourceId(), "kind", copied.kind(), "schemaVersion", copied.schemaVersion(), + "byteLength", copied.byteLength(), "digest", copied.digest())); + } + private boolean publicChildLaunchAvailable(SessionRecord session) { return session.publicChildAgentsVersion() == 1 && settings.admitsPublicChildAgents() && supportsPublicChildAgents() @@ -1967,6 +2059,11 @@ public class QwenHostedHarnessConnector implements HarnessConnector { } private boolean foregroundOnly(SessionRecord session) { + return sshWorkspace(session) && !(session.publicChildAgentsVersion() == 1 + && settings.getExtensions().isEnabled() && settings.getManagedExtensions().isEnabled()); + } + + private boolean sshWorkspace(SessionRecord session) { return session.workspace() != null && ssh != null && ssh.runtimeStorage(session.tenantId(), session.workspace().getStorageId()) != null; } @@ -2024,6 +2121,10 @@ public class QwenHostedHarnessConnector implements HarnessConnector { return pin; } + private Map initialSelection(SessionRecord session) { + return sessionSkills == null ? null : sessionSkills.initialSelection(session.tenantId(), session.sessionId()); + } + private Map sessionSkillsRef(SessionRecord session, boolean create) { var skills = sessionSkills == null ? null : sessionSkills.snapshot(session.tenantId(), session.sessionId()); if (skills == null) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java index b622b98bb75..5383121ea17 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.managedagent.model; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Pin; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -71,7 +72,12 @@ public final class ApiModels { Map metadata, Boolean stream, JsonNode workspace, - @Size(max = 256) List<@NotNull Map> skills) { + @Size(max = 256) List<@NotNull Map> skills, + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins) { + public CreateSessionRequest(String agentId, String agentRevision, List input, + Map metadata, Boolean stream, JsonNode workspace, List> skills) { + this(agentId, agentRevision, input, metadata, stream, workspace, skills, null); + } } public record SessionEventRequest(@NotBlank String type, @@ -412,7 +418,13 @@ public final class ApiModels { @Size(max = 100) List<@Valid InputBlock> input, Map metadata, JsonNode workspace, String approvalMode, Boolean planMode, String modelId, - @Size(max = 256) List<@NotNull Map> skills) { + @Size(max = 256) List<@NotNull Map> skills, + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins) { + public WebShellCreateRequest(String requestId, String idempotencyKey, String agentId, String title, + List input, Map metadata, JsonNode workspace, String approvalMode, + Boolean planMode, String modelId, List> skills) { + this(requestId, idempotencyKey, agentId, title, input, metadata, workspace, approvalMode, planMode, modelId, skills, null); + } } public record WebShellSessionOptionsRequest(@NotBlank @Size(max = 128) String agentId) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java index 33e03b0d408..156e2933e10 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java @@ -259,7 +259,21 @@ public class ManagedActionStore { require(text(option.path("label"))); } if (options.has("inputRef")) { - resource(options.path("inputRef"), "managed-tool-input", resources); + String inputKind = options.path("inputRef").path("kind").asText(); + boolean command = options.path("v").asLong() == 3 && "managed-extension-command-input".equals(inputKind); + resource(options.path("inputRef"), command ? inputKind : "managed-tool-input", resources); + if (command) { + JsonNode continuation = read(new String(resource(options.path("continuationRef"), + "hosted-approval-continuation", resources).bytes(), StandardCharsets.UTF_8)); + JsonNode step = continuation.path("commandStep"); + String toolName = "shell".equals(step.path("toolName").asText()) ? "run_shell_command" : "read_file"; + int ordinal = continuation.path("approvalOrdinal").asInt(-1); + require(ordinal >= 0 && ordinal < continuation.path("calls").size() + && options.path("inputRef").equals(step.path("inputRef")) + && toolName.equals(options.path("toolName").asText()) + && id.equals(continuation.path("actionId").asText()) + && options.path("functionCallId").equals(continuation.path("calls").get(ordinal).path("call").path("callId"))); + } } if (options.path("v").asLong() == 3) { ManagedHostedRecoveryRecords.reference(options.path("continuationRef"), "hosted-approval-continuation", tenantId, workspaceId, sessionId, resources, hostedReferences); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java index ec9a5bec694..31bd3ac752d 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java @@ -61,6 +61,7 @@ import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; @@ -126,6 +127,9 @@ public class ManagedAgentStore implements AgentStateStore { private static final String QUALIFIED_TURN_SUMMARY_COLUMNS = "turn_record." + String.join(", turn_record.", TURN_SUMMARY_COLUMNS.split(", ")); + @org.springframework.beans.factory.annotation.Autowired(required = false) + @org.springframework.context.annotation.Lazy + private ManagedExtensionRecordStore configRecords; private final JdbcTemplate jdbc; private WorkspaceLifecycleStore lifecycle; @@ -152,6 +156,12 @@ public class ManagedAgentStore implements AgentStateStore { private final List modelCatalog; private ManagedAgentDefinitionStore definitions; private ManagedSessionSkills creationSkills; + private com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService extensionPackages; + + @org.springframework.beans.factory.annotation.Autowired + public void setExtensionPackages(com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService value) { + this.extensionPackages = value; + } @org.springframework.beans.factory.annotation.Autowired public void setManagedSessionSkills(ManagedSessionSkills skills) { @@ -162,6 +172,7 @@ public class ManagedAgentStore implements AgentStateStore { private final boolean workspaceFilesEnabled; private final boolean workspaceShellEnabled; private final boolean workspaceChildAgentsEnabled; + private final boolean managedExtensionExecutionEnabled; private final long lifecycleHandoffMillis; private final List workspaceMounts; private final ResidentWorkspaces residentWorkspaces; @@ -347,6 +358,7 @@ public class ManagedAgentStore implements AgentStateStore { this.workspaceFilesEnabled = properties.getHarness().isWorkspaceFilesEnabled(); this.workspaceShellEnabled = properties.getHarness().isWorkspaceShellEnabled(); this.workspaceChildAgentsEnabled = properties.admitsPublicChildAgents(); + this.managedExtensionExecutionEnabled = properties.getExtensions().isEnabled() && properties.getManagedExtensions().isEnabled(); this.lifecycleHandoffMillis = properties.getSessionStore().getWriterLeaseDuration().toMillis(); this.workspaceMounts = properties.getRuntimeBroker().getWorkspaceMounts(); this.residentWorkspaces = new ResidentWorkspaces(properties.getRuntimeBroker().getResidentWorkspaces()); @@ -453,6 +465,20 @@ public class ManagedAgentStore implements AgentStateStore { String title, List> input, String payloadDigest, WorkspaceSelection selection, String requestedApprovalMode, boolean approvalSelectionSupported, Boolean requestedPlanMode, boolean planModeSupported, List> skills, boolean publicChildrenSupported, String modelId) { + return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, agentId, requestedRevision, + title, input, payloadDigest, selection, requestedApprovalMode, approvalSelectionSupported, + requestedPlanMode, planModeSupported, skills, publicChildrenSupported, modelId, null, Set.of()); + } + + @Override + @Transactional + public Admission insertWorkspaceSessionCommand(String tenantId, String actorId, + String idempotencyKey, String requestDigest, String agentId, String requestedRevision, + String title, List> input, String payloadDigest, WorkspaceSelection selection, + String requestedApprovalMode, boolean approvalSelectionSupported, Boolean requestedPlanMode, + boolean planModeSupported, List> skills, boolean publicChildrenSupported, String modelId, + List extensionPins, + Set extensionLifecycleProfiles) { if (!input.isEmpty() && !workspaceFilesEnabled) { throw workspaceExecutionUnavailable(); } @@ -484,6 +510,17 @@ public class ManagedAgentStore implements AgentStateStore { requireCreationScope(tenantId, idempotencyKey, true); ResolvedBinding workspace = workspaces.resolveForCreation( tenantId, actorId, selection); + if (extensionPins != null) { + if (extensionPackages == null) { + throw workspaceExecutionUnavailable(); + } + if ((!WorkspaceToolProfiles.SHELL.equals(pin.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(pin.toolProfile())) + || !extensionLifecycleProfiles.contains(pin.toolProfile())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", + "Extension configuration requires a negotiated Shell or MCP Session lifecycle profile."); + } + extensionPackages.requireSelection(tenantId, actorId, workspace.binding().getWorkspaceId(), extensionPins); + } WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId()); boolean sshWorkspace = isSshWorkspace(workspace.binding()); if (sshWorkspace) { @@ -517,8 +554,8 @@ public class ManagedAgentStore implements AgentStateStore { Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest, agentId, title, input, payloadDigest, workspace, actorId, pin, null, planMode, modelId); - if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && !sshWorkspace - && "qwen-code".equals(agentId) && pin.digest() == null + if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && (!sshWorkspace || managedExtensionExecutionEnabled) + && (managedExtensionExecutionEnabled || "qwen-code".equals(agentId) && pin.digest() == null) && WorkspaceToolProfiles.SHELL.equals(pin.toolProfile())) { jdbc.update("UPDATE ma_session SET public_child_agents_version=1 WHERE tenant_id=? AND session_id=?", tenantId, admission.sessionId()); @@ -526,6 +563,9 @@ public class ManagedAgentStore implements AgentStateStore { if (!skills.isEmpty()) { creationSkills.save(tenantId, admission.sessionId(), effectiveSkills); } + if (extensionPins != null) { + creationSkills.saveInitialSelection(tenantId, admission.sessionId(), extensionPins); + } return admission; } @@ -738,8 +778,8 @@ public class ManagedAgentStore implements AgentStateStore { "The parent Session cannot admit a child."); } if (parent.publicChildAgentsVersion() == 1) { - if (snapshot == null || !snapshot.path("version").isIntegralNumber() || snapshot.path("version").asInt() != 1 || lineage.depth() != 1 - || childCwdRelative != null || snapshot.size() != 8 + if (snapshot == null || !snapshot.path("version").isIntegralNumber() || !Set.of(1, 2).contains(snapshot.path("version").asInt()) || lineage.depth() != 1 + || childCwdRelative != null || snapshot.size() != (snapshot.path("version").asInt() == 2 ? 9 : 8) || !snapshot.path("sourcePromptId").isTextual() || snapshot.path("sourcePromptId").asText().isBlank() || snapshot.path("sourcePromptId").asText().length() > 144 || !Set.of("default", "auto-edit", "yolo").contains(snapshot.path("approvalMode").asText()) @@ -758,6 +798,12 @@ public class ManagedAgentStore implements AgentStateStore { || !snapshot.path("modelRoute").path("providerPin").asText().matches("[a-f0-9]{64}")) { throw new ManagedAgentException(HttpStatus.CONFLICT, "child_launch_snapshot_invalid", "The public child launch snapshot is invalid"); } + if (snapshot.path("version").asInt() == 2) { + if (configRecords == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); + } + ManagedExtensionAgentLaunch.require(jdbc, configRecords, parent, snapshot); + } if (snapshot.path("modelRoute").has("origin")) { var origin = snapshot.path("modelRoute").get("origin"); if (origin.size() != 4 || !origin.path("modelId").isTextual() || origin.path("modelId").asText().isBlank() @@ -1209,6 +1255,7 @@ public class ManagedAgentStore implements AgentStateStore { + " WHERE tenant_id=? AND session_id=? AND command_id=? AND operation='manageMcp' LIMIT 1", tenantId, sessionId, operationId).isEmpty(); if (operationId != null && !replay) { + ManagedConfigInstall.requireOpen(jdbc, tenantId, sessionId); requireMcpManagementIdle(session); } return session; @@ -1227,6 +1274,120 @@ public class ManagedAgentStore implements AgentStateStore { requireMcpManagementIdle(session); } + @Override + @Transactional + public SessionRecord requireExtensionManagement(String tenantId, String actorId, String sessionId, String operationId, boolean mutation) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || "DELETED".equals(session.status())) { + throw new ManagedAgentException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."); + } + if (mutation && !access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(HttpStatus.FORBIDDEN, + "session_operation_forbidden", "Only a Workspace operator may apply extensions."); + } + var lineage = findChildLineage(tenantId, sessionId); + JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + boolean same = ManagedConfigInstall.isPackageConfig(current) && operationId != null + && operationId.equals(current.path("operationId").asText()); + if (lineage != null && mutation && (!same || !childExtensionBootstrapCommand(session, lineage).equals(current.path("command"))) + || !workspaceFilesEnabled + || !Set.of(WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP, WorkspaceToolProfiles.FILES, WorkspaceToolProfiles.READ) + .contains(session.toolProfile())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "Extension management is unavailable."); + } + if (mutation) { + requireSessionStatus(session.status(), "ACTIVE"); + requireCwdChangeRegistryFacts(session); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + if (!same) { + ManagedConfigInstall.requireOpen(jdbc, tenantId, sessionId); + requireMcpManagementIdle(session); + } + } + return session; + } + + @Override + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void requireConfigurationCommit(String tenantId, String workspaceId, String sessionId, JsonNode body) { + SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || !workspaceId.equals(session.workspace().getWorkspaceId())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "Extension management is unavailable."); + } + requireSessionStatus(session.status(), "ACTIVE"); + var lineage = findChildLineage(tenantId, sessionId); + if (lineage != null) { + requireChildExtensionBootstrap(session, lineage, body); + return; + } + Map initial = creationSkills.initialSelection(tenantId, sessionId); + if (initial != null) { + if (initial.get("operationId").equals(body.path("command").path("operationId").asText())) { + boolean beforeModel = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, + tenantId, sessionId) <= 1 + && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", + tenantId, sessionId).isEmpty(); + if (!beforeModel || !isBootstrapRevision(body) + || !objectMapper.valueToTree(initial).equals(body.path("command"))) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_initialization_conflict", "Initial extension selection conflicts with its creation receipt."); + } + return; + } + JsonNode configuration = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + if (configuration == null || !Set.of("active", "failed").contains(configuration.path("phase").asText())) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "extension_initialization_required", "Initial extension configuration must settle first."); + } + } + // Every phase observes the same locked Session row as Turn admission. + // A precommit stage may lose its idle window; it cannot commit over a new Turn. + requireMcpManagementIdle(session); + } + + private void requireChildExtensionBootstrap(SessionRecord child, StoreModels.SessionLineage lineage, JsonNode body) { + JsonNode command = childExtensionBootstrapCommand(child, lineage); + boolean onlyInitialTurn = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, + child.tenantId(), child.sessionId()) <= 1 + && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", + child.tenantId(), child.sessionId()).isEmpty(); + if (!onlyInitialTurn || !isBootstrapRevision(body) || !command.equals(body.path("command"))) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "child_launch_snapshot_invalid", "Child extension bootstrap conflicts with its launch snapshot"); + } + } + + private static boolean isBootstrapRevision(JsonNode body) { + return "apply".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 0 + && body.path("configRevision").asLong(-1) == 1 + || "recoverPrevious".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 1 + && body.path("configRevision").asLong(-1) == 2; + } + + private JsonNode childExtensionBootstrapCommand(SessionRecord child, StoreModels.SessionLineage lineage) { + if (configRecords == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); + } + var run = configRecords.findRecord(child.tenantId(), lineage.parentSessionId(), "child_run", lineage.parentChildRunId()).orElseThrow(); + var envelope = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), run.required("inputRef")); + var launch = envelope.path("publicLaunch"); + if (launch.path("version").asInt() != 2) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "Child extension updates are unavailable"); + } + var ref = launch.required("extensionAgent").required("bundleRef"); + var bundle = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), ref); + var pins = objectMapper.createArrayNode(); + for (var entry : bundle.required("packages")) { + pins.add(entry.required("pin")); + } + String operation = ManagedConfigInstall.derivedOperationId("managed-extension-child:", child.sessionId() + ":" + ref.required("digest").asText()); + return objectMapper.createObjectNode().put("operationId", operation) + .put("actorKey", DigestUtils.sha256Hex("managed-extension-child:" + child.sessionId())) + .put("expectedRevision", 0).set("extensions", pins); + } + private void requireMcpManagementIdle(SessionRecord session) { String tenantId = session.tenantId(); String sessionId = session.sessionId(); @@ -1240,6 +1401,12 @@ public class ManagedAgentStore implements AgentStateStore { + " AND status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') LIMIT 1 FOR UPDATE", tenantId, sessionId).isEmpty()) { throw new ManagedAgentException(HttpStatus.CONFLICT, "turn_active", "The Session has an active Turn or approval."); } + if (!jdbc.queryForList("SELECT record_id FROM ma_session_record WHERE record_kind='DOMAIN'" + + " AND session_scope_key=? AND tenant_id=? AND session_id=? AND domain='child_run'" + + " AND settled_at IS NULL LIMIT 1 FOR UPDATE", ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "session_context_busy", "The Session has an unsettled child run."); + } long now = lifecycleDatabaseTime(); for (String options : jdbc.queryForList("SELECT options_json FROM ma_session_record WHERE session_scope_key=?" + " AND record_kind='ACTION' AND state='requested' AND tenant_id=? AND session_id=? FOR UPDATE", @@ -1452,6 +1619,7 @@ public class ManagedAgentStore implements AgentStateStore { throw new ManagedAgentException(HttpStatus.CONFLICT, "session_not_active", "The Session does not accept new Turns."); } + ManagedConfigInstall.requireOpen(jdbc, tenantId, sessionId); if (hasActiveTurn(tenantId, sessionId)) { throw new ManagedAgentException(HttpStatus.CONFLICT, "turn_active", "The Session already has an active Turn."); @@ -4007,6 +4175,9 @@ public class ManagedAgentStore implements AgentStateStore { // matching the recovery scan's own population. private boolean hasOpenExecutionOperation(String tenantId, String sessionId) { + if (ManagedConfigInstall.blocked(jdbc, tenantId, sessionId)) { + return true; + } Integer operations = jdbc.queryForObject("SELECT COUNT(*) FROM" + " ma_operation WHERE scope_key = ? AND" + " target_key = ? AND operation_kind IN" @@ -4018,6 +4189,9 @@ public class ManagedAgentStore implements AgentStateStore { private boolean hasOpenOperation(String tenantId, String sessionId, String excludingOperationId) { + if (ManagedConfigInstall.blocked(jdbc, tenantId, sessionId)) { + return true; + } Integer commands = jdbc.queryForObject( "SELECT COUNT(*) FROM ma_request_receipt WHERE namespace =" diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedConfigInstall.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedConfigInstall.java new file mode 100644 index 00000000000..7396b8b4a5c --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedConfigInstall.java @@ -0,0 +1,214 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** The package-aware body of the existing config_install domain. */ +public final class ManagedConfigInstall { + public static final String KIND = "hosted-extension-config"; + private static final long MAX = 9_007_199_254_740_991L; + private static final Set PHASES = Set.of("preparing", "prepared", "committed", "active", "recovery_required", "failed"); + private static final Map> TRANSITIONS = Map.of( + "preparing", Set.of("prepared", "failed"), "prepared", Set.of("committed", "failed"), + "committed", Set.of("active", "recovery_required"), "recovery_required", Set.of("active", "recovery_required"), + "active", Set.of(), "failed", Set.of()); + + private ManagedConfigInstall() { } + + public static boolean isPackageConfig(JsonNode body) { + return body != null && KIND.equals(body.path("kind").textValue()); + } + + public static void require(JsonNode body) { + ManagedExtensionRecords.closed(body, Set.of("kind", "formatVersion", "command", "mode", "phaseOperationId", "configRevision", + "previousConfigRevision", "phase", "previousBundleRef", "restoresBundleRef", "bundleRef", "rootSnapshotRef", + "stageAckRefs", "enableAckRefs", "error", "operationId", "revision", "previousRecordRef"), "Configuration install"); + check(KIND.equals(body.path("kind").textValue()) && body.path("formatVersion").isIntegralNumber() + && body.path("formatVersion").asLong() == 1, "Invalid configuration install kind"); + long revision = count(body.get("revision"), 1); + JsonNode predecessor = body.get("previousRecordRef"); + ref(predecessor, true); + check((revision == 1) == predecessor.isNull(), "Invalid configuration predecessor"); + check(predecessor.isNull() || "managed-config_install".equals(predecessor.path("kind").textValue()), "Invalid configuration predecessor kind"); + String phase = body.path("phase").asText(); + check(PHASES.contains(phase), "Invalid configuration phase"); + boolean compensation = "recoverPrevious".equals(body.path("mode").textValue()); + check(compensation || "apply".equals(body.path("mode").textValue()), "Invalid configuration mode"); + long before = count(body.get("previousConfigRevision"), 0); + check(count(body.get("configRevision"), 1) == before + 1, "Invalid effective configuration revision"); + JsonNode command = body.get("command"); + ManagedExtensionRecords.closed(command, command.has("mcpChange") + ? Set.of("operationId", "actorKey", "expectedRevision", "extensions", "mcpChange") + : Set.of("operationId", "actorKey", "expectedRevision", "extensions"), "Extension apply command"); + if (command.has("mcpChange")) { + mcpChange(command.get("mcpChange")); + } + check(uuid(command.path("operationId").asText()) && command.get("operationId").equals(body.get("operationId")) + && command.path("actorKey").asText().matches("[a-f0-9]{64}"), "Invalid extension apply identity"); + check(count(command.get("expectedRevision"), 0) + (compensation ? 1 : 0) == before, "Extension apply revision conflicts"); + check(body.path("phaseOperationId").asText().equals(compensation ? derivedOperationId("managed-extension-recoverPrevious:", command.path("operationId").asText().toLowerCase(java.util.Locale.ROOT)) + : command.path("operationId").asText()), "Configuration phase operation conflicts"); + pins(command.get("extensions")); + ref(body.get("previousBundleRef"), true); + ref(body.get("restoresBundleRef"), true); + check(!body.get("restoresBundleRef").isNull() == compensation, "Invalid compensation content"); + boolean mayBeUnprepared = Set.of("preparing", "failed").contains(phase); + ref(body.get("bundleRef"), mayBeUnprepared); + ref(body.get("rootSnapshotRef"), false); + refs(body.get("stageAckRefs")); + refs(body.get("enableAckRefs")); + check(!Set.of("prepared", "committed", "active").contains(phase) || body.get("stageAckRefs").size() >= 2, + "Both stage acknowledgements are required"); + check(!"active".equals(phase) || body.get("enableAckRefs").size() >= 2, "Both enable acknowledgements are required"); + check(!Set.of("preparing", "prepared", "failed").contains(phase) || body.get("enableAckRefs").isEmpty(), "Unexpected enable acknowledgement"); + JsonNode error = body.get("error"); + check((Set.of("failed", "recovery_required").contains(phase)) == !error.isNull(), "Invalid configuration error"); + if (!error.isNull()) { + ManagedExtensionRecords.closed(error, Set.of("code"), "Configuration error"); + check(error.path("code").asText().matches("[a-z][a-z0-9_]{0,127}"), "Invalid configuration error code"); + } + } + + public static void pins(JsonNode pins) { + check(pins != null && pins.isArray() && pins.size() <= 32, "Invalid package selection"); + Set ids = new HashSet<>(); + for (JsonNode pin : pins) { + ManagedExtensionRecords.closed(pin, pin.has("settingsRevision") + ? Set.of("extensionId", "packageRevision", "packageDigest", "settingsRevision") + : Set.of("extensionId", "packageRevision", "packageDigest"), "Extension pin"); + check(pin.path("extensionId").asText().matches("[A-Za-z0-9._:-]{1,128}") + && ids.add(pin.get("extensionId").asText()) && pin.path("packageDigest").asText().matches("[a-f0-9]{64}"), "Invalid extension pin"); + count(pin.get("packageRevision"), 1); + if (pin.has("settingsRevision")) { + count(pin.get("settingsRevision"), 1); + } + } + } + + private static void mcpChange(JsonNode change) { + ManagedExtensionRecords.closed(change, Set.of("action", "serverId", "expectedRevision", "servers"), "Independent MCP change"); + check(Set.of("add", "edit", "remove", "enable", "disable", "reload", "restart").contains(change.path("action").asText()) + && change.path("serverId").asText().matches("[A-Za-z0-9._:-]{1,128}"), "Invalid independent MCP command"); + count(change.get("expectedRevision"), 0); + JsonNode servers = change.get("servers"); + check(servers.isArray() && servers.size() <= 32, "Invalid independent MCP selection"); + Set ids = new HashSet<>(); + int enabled = 0; + for (JsonNode pin : servers) { + ManagedExtensionRecords.closed(pin, Set.of("serverId", "serverRevision", "definitionDigest", "state"), "Independent MCP pin"); + check(pin.path("serverId").asText().matches("[A-Za-z0-9._:-]{1,128}") && ids.add(pin.get("serverId").asText()) + && pin.path("definitionDigest").asText().matches("[a-f0-9]{64}") + && Set.of("enabled", "disabled", "removed").contains(pin.path("state").asText()), "Invalid independent MCP pin"); + count(pin.get("serverRevision"), 1); + if ("enabled".equals(pin.path("state").asText())) { + enabled++; + } + } + check(enabled <= 16 && ids.contains(change.path("serverId").asText()), "Independent MCP selection conflicts"); + } + + public static void successor(JsonNode previous, JsonNode next) { + require(next); + if (!isPackageConfig(previous)) { + check("preparing".equals(next.path("phase").asText()) && "apply".equals(next.path("mode").asText()) + && next.path("previousConfigRevision").asLong() == 0, "Invalid initial package configuration"); + return; + } + require(previous); + String phase = previous.path("phase").asText(); + if (!previous.get("operationId").equals(next.get("operationId"))) { + check(Set.of("active", "failed").contains(phase) + && !("failed".equals(phase) && "recoverPrevious".equals(previous.path("mode").asText())) + && "preparing".equals(next.path("phase").asText()) && "apply".equals(next.path("mode").asText()) + && next.path("previousConfigRevision").asLong() == previous.path("failed".equals(phase) + ? "previousConfigRevision" : "configRevision").asLong(), "Configuration operation conflicts"); + return; + } + boolean compensation = "recovery_required".equals(phase) && "apply".equals(previous.path("mode").asText()) + && "recoverPrevious".equals(next.path("mode").asText()) && "preparing".equals(next.path("phase").asText()); + check(previous.get("command").equals(next.get("command")), "Configuration command changed"); + if (compensation) { + check(next.path("configRevision").asLong() == previous.path("configRevision").asLong() + 1 + && !previous.path("previousBundleRef").isNull() + && next.get("previousBundleRef").equals(previous.get("bundleRef")) + && next.get("restoresBundleRef").equals(previous.get("previousBundleRef")), "Invalid compensation revision"); + return; + } + check(TRANSITIONS.get(phase).contains(next.path("phase").asText()), "Configuration phase conflicts"); + for (String field : List.of("mode", "phaseOperationId", "configRevision", "previousConfigRevision", "previousBundleRef", "restoresBundleRef", "rootSnapshotRef")) { + check(previous.get(field).equals(next.get(field)), "Configuration content changed"); + } + if (!"preparing".equals(phase) || !previous.path("bundleRef").isNull()) { + check(previous.get("bundleRef").equals(next.get("bundleRef")), + "Prepared configuration changed"); + } + } + + public static boolean barrier(JsonNode body) { + if (!isPackageConfig(body)) { + return false; + } + String phase = body.path("phase").asText(); + return Set.of("committed", "recovery_required").contains(phase) + || "recoverPrevious".equals(body.path("mode").asText()) && !"active".equals(phase); + } + + public static void requireOpen(JdbcTemplate jdbc, String tenantId, String sessionId) { + if (blocked(jdbc, tenantId, sessionId)) { + throw new ManagedAgentException(HttpStatus.CONFLICT, + "session_config_recovery_required", "The Session configuration operation must be reconciled first."); + } + } + + public static boolean blocked(JdbcTemplate jdbc, String tenantId, String sessionId) { + return !jdbc.queryForList("SELECT record_key FROM ma_session_record WHERE session_scope_key=? AND tenant_id=?" + + " AND session_id=? AND record_kind='DOMAIN' AND domain='config_install' AND record_id=?" + + " AND settled_at IS NULL LIMIT 1 FOR UPDATE", ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + tenantId, sessionId, ManagedMcpSettings.RECORD_ID).isEmpty(); + } + + public static String derivedOperationId(String prefix, String identity) { + byte[] bytes = org.apache.commons.codec.digest.DigestUtils.sha256(prefix + identity); + bytes[6] = (byte) ((bytes[6] & 15) | 80); + bytes[8] = (byte) ((bytes[8] & 63) | 128); + String hex = java.util.HexFormat.of().formatHex(bytes, 0, 16); + return hex.substring(0, 8) + "-" + hex.substring(8, 12) + "-" + hex.substring(12, 16) + "-" + hex.substring(16, 20) + "-" + hex.substring(20); + } + + public static boolean uuid(String id) { + return id != null && id.matches("(?i)[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"); + } + + private static long count(JsonNode value, long min) { + return ManagedExtensionRecords.count(value, min, MAX, "Configuration revision"); + } + + private static void ref(JsonNode value, boolean nullable) { + check(value != null, "Missing configuration reference"); + if (!nullable || !value.isNull()) { + ManagedExtensionRecords.durableRef(value, "Configuration reference"); + } + } + + private static void refs(JsonNode value) { + check(value != null && value.isArray() && value.size() <= 8, "Invalid configuration acknowledgement list"); + Set ids = new HashSet<>(); + for (JsonNode entry : value) { + ref(entry, false); + check(ids.add(entry.path("resourceId").asText()), "Duplicate configuration acknowledgement"); + } + } + + private static void check(boolean valid, String message) { + if (!valid) { + throw new ManagedExtensionRecords.InvalidRecordException(message); + } + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunch.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunch.java new file mode 100644 index 00000000000..e05bcee23d7 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunch.java @@ -0,0 +1,194 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceToolProfiles; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; + +/** Resolves an immutable package descriptor under the locked parent Session. */ +public final class ManagedExtensionAgentLaunch { + private ManagedExtensionAgentLaunch() { } + + public static void require(JdbcTemplate jdbc, ManagedExtensionRecordStore records, + SessionRecord parent, JsonNode launch) { + try { + ManagedExtensionRecords.closed(launch, Set.of("version", "sourcePromptId", "approvalMode", "approvalRevision", + "modelId", "modelRevision", "modelRoute", "skillsRef", "extensionAgent"), "Package agent launch"); + JsonNode agent = launch.get("extensionAgent"); + ManagedExtensionRecords.closed(agent, Set.of("configRevision", "bundleRef", "pin", "contributionsRef", "agentName", "toolNames"), "Package agent binding"); + ManagedConfigInstall.requireOpen(jdbc, parent.tenantId(), parent.sessionId()); + JsonNode current = ManagedMcpSettings.find(jdbc, parent.tenantId(), parent.sessionId()); + for (int step = 0; ManagedConfigInstall.isPackageConfig(current) && !"active".equals(current.path("phase").asText()); step++) { + if (step >= 4096 || current.path("previousRecordRef").isNull()) { + throw refused(); + } + current = records.readRecordResource(parent.tenantId(), parent.sessionId(), current.get("previousRecordRef")); + } + if (!ManagedConfigInstall.isPackageConfig(current) || !current.get("configRevision").equals(agent.get("configRevision")) + || !current.get("bundleRef").equals(agent.get("bundleRef"))) { + throw refused(); + } + JsonNode ownerRef = current.get("rootSnapshotRef"); + if (!"managed-extension-root".equals(ownerRef.path("kind").asText()) || ownerRef.path("schemaVersion").asInt() != 1) { + throw refused(); + } + requireRuntimeOwner(jdbc, parent, records.readRecordResource(parent.tenantId(), parent.sessionId(), ownerRef)); + JsonNode bundle = records.readRecordResource(parent.tenantId(), parent.sessionId(), agent.get("bundleRef")); + JsonNode selected = null; + for (JsonNode entry : bundle.path("packages")) { + if (entry.path("pin").equals(agent.get("pin"))) { + selected = entry; + } + } + if (selected == null || !selected.path("contributionsRef").equals(agent.get("contributionsRef"))) { + throw refused(); + } + JsonNode contributions = readContributions(records, parent.tenantId(), parent.sessionId(), agent.get("contributionsRef")); + JsonNode descriptor = null; + for (JsonNode entry : contributions.path("agents")) { + if (entry.path("name").equals(agent.get("agentName"))) { + descriptor = entry; + } + } + if (descriptor == null || !descriptor.path("systemPrompt").isTextual() || descriptor.path("systemPrompt").asText().isBlank()) { + throw refused(); + } + JsonNode tools = agent.get("toolNames"); + if (tools == null || !tools.isArray() || tools.size() > 256) { + throw refused(); + } + Set seen = new HashSet<>(); + Set allow = names(descriptor.path("tools")); + Set deny = names(descriptor.path("disallowedTools")); + for (JsonNode tool : tools) { + String name = tool.asText(); + if (!tool.isTextual() || !seen.add(name) || !profileTool(parent.toolProfile(), name) + || !allow.isEmpty() && !allow.contains(name) || deny.contains(name)) { + throw refused(); + } + } + var policies = jdbc.queryForList("SELECT COALESCE(selected_approval_mode, approval_mode) AS mode, approval_revision" + + " FROM ma_session WHERE tenant_id=? AND session_id=? FOR UPDATE", parent.tenantId(), parent.sessionId()); + if (policies.size() != 1 || !launch.path("approvalMode").asText().equals(policies.getFirst().get("mode")) + || !(policies.getFirst().get("approval_revision") instanceof Number revision) + || launch.path("approvalRevision").asLong(-1) != revision.longValue()) { + throw refused(); + } + } catch (ManagedExtensionRecords.InvalidRecordException error) { + throw refused(); + } + } + + static void requireRuntimeOwner(JdbcTemplate jdbc, SessionRecord parent, JsonNode owner) { + JsonNode runtime = owner.path("runtime"); + String generation = runtime.path("generation").asText(); + if (parent.workspace() == null || !runtime.path("bindingId").isTextual() + || runtime.path("bindingId").asText().isBlank() || !runtime.path("generation").isTextual() + || !runtime.path("workspaceGeneration").isTextual() || !generation.matches("[1-9][0-9]{0,18}") + || !Long.toString(parent.workspace().getWorkspaceGeneration()).equals(runtime.path("workspaceGeneration").asText())) { + throw refused(); + } + long selectedGeneration; + try { + selectedGeneration = Long.parseLong(generation); + } catch (NumberFormatException invalid) { + throw refused(); + } + var capabilities = jdbc.queryForList("SELECT b.capability_digest FROM ma_runtime_binding b" + + " JOIN ma_runtime_slot s ON s.request_key=b.request_key AND s.active_binding_id=b.binding_id" + + " AND s.last_generation=b.runtime_generation WHERE b.binding_id=? AND b.runtime_generation=?" + + " AND b.tenant_id=? AND b.workspace_id=? AND b.workspace_generation=? AND b.storage_id=?" + + " AND b.isolation_class='session' AND b.isolation_key=? AND b.binding_state='READY'" + + " AND b.drain_requested=FALSE AND b.runtime_instance_id IS NOT NULL AND b.runtime_lease_id IS NOT NULL FOR UPDATE", + String.class, runtime.path("bindingId").asText(), selectedGeneration, parent.tenantId(), + parent.workspace().getWorkspaceId(), Long.toString(parent.workspace().getWorkspaceGeneration()), + parent.workspace().getStorageId(), parent.sessionId()); + if (capabilities.size() != 1) { + throw refused(); + } + String capability = capabilities.getFirst(); + String mode = owner.path("executionMode").asText(); + if (!(com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST.equals(capability) + && "linux-cgroup".equals(mode) + || com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST.equals(capability) + && "developer-process-group".equals(mode))) { + throw refused(); + } + } + + public static JsonNode readContributions(ManagedExtensionRecordStore records, String tenant, String session, JsonNode ref) { + JsonNode manifest = records.readRecordResource(tenant, session, ref); + ManagedExtensionRecords.closed(manifest, Set.of("kind", "formatVersion", "byteLength", "digest", "parts"), "Contribution manifest"); + long length = ManagedExtensionRecords.count(manifest.get("byteLength"), 1, 2 * 1024 * 1024, "Contribution length"); + if (!"managed-extension-contributions".equals(manifest.path("kind").asText()) || manifest.path("formatVersion").asInt() != 1 + || !manifest.path("parts").isArray() || manifest.path("parts").isEmpty() || manifest.path("parts").size() > 32) { + throw refused(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Set ids = new HashSet<>(); + for (JsonNode part : manifest.get("parts")) { + if (!"managed-extension-contributions-part".equals(part.path("kind").asText()) || !ids.add(part.path("resourceId").asText())) { + throw refused(); + } + var resource = records.readCommittedResource(tenant, session, part); + if (resource.byteLength() > ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES || output.size() + resource.byteLength() > length) { + throw refused(); + } + output.writeBytes(resource.bytes()); + } + byte[] bytes = output.toByteArray(); + if (bytes.length != length || !DigestUtils.sha256Hex(bytes).equals(manifest.path("digest").asText())) { + throw refused(); + } + JsonNode contributions = ManagedExtensionRecordStore.parse(new String(bytes, StandardCharsets.UTF_8)); + if (contributions == null) { + throw refused(); + } + return contributions; + } + + private static Set names(JsonNode values) { + Set result = new HashSet<>(); + if (values.isMissingNode()) { + return result; + } + if (!values.isArray()) { + throw refused(); + } + for (JsonNode value : values) { + if (!value.isTextual()) { + throw refused(); + } + result.add(value.asText()); + } + return result; + } + + private static boolean profileTool(String profile, String name) { + if (Set.of("read_file", "glob", "skill").contains(name)) { + return true; + } + if (WorkspaceToolProfiles.READ.equals(profile)) { + return false; + } + if (Set.of("write_file", "edit", "todo_write").contains(name)) { + return true; + } + if (WorkspaceToolProfiles.SHELL.equals(profile) && Set.of("run_shell_command", "monitor", "web_fetch").contains(name)) { + return true; + } + return WorkspaceToolProfiles.isShellOrMcp(profile) && name.matches("mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+"); + } + + private static ManagedAgentException refused() { + return new ManagedAgentException(HttpStatus.CONFLICT, "child_launch_snapshot_invalid", "The package agent does not belong to the parent's active configuration."); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionCommandVerifier.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionCommandVerifier.java new file mode 100644 index 00000000000..d236b3b69e4 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionCommandVerifier.java @@ -0,0 +1,131 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeExtensionCommandVerifier; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.transport.ManagedExtensionCommandProtocol; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Reads command permissions from the committed journal, never from caller-supplied approval data. */ +@Component +public final class ManagedExtensionCommandVerifier implements RuntimeExtensionCommandVerifier { + private final JdbcTemplate jdbc; + private final ManagedExtensionRecordStore records; + private final AgentStateStore sessions; + private final ObjectMapper json; + + public ManagedExtensionCommandVerifier(JdbcTemplate jdbc, ManagedExtensionRecordStore records, AgentStateStore sessions, ObjectMapper json) { + this.jdbc = jdbc; + this.records = records; + this.sessions = sessions; + this.json = json; + } + + @Override + public void verify(RuntimeBindingRecord runtime, RuntimeSession session, Map operation) { + ManagedExtensionCommandProtocol.validateSession(session, operation); + try { + verifyCommitted(runtime, session, json.valueToTree(operation)); + } catch (ManagedExtensionRecords.InvalidRecordException | ManagedAgentException error) { + throw denied(); + } + } + + private void verifyCommitted(RuntimeBindingRecord runtime, RuntimeSession session, JsonNode operation) { + String tenant = session.getScope().getTenantId(); + String harness = session.getHarnessSessionId(); + String stageId = operation.get("operationId").textValue(); + JsonNode grant = operation.get("grant"); + ManagedExtensionRecords.requireOperationGrant(grant); + long now = jdbc.queryForObject("SELECT " + ManagedSchemaTime.SQL_NOW, Long.class); + check(grant.get("expiresAt").longValue() > now); + check(java.util.stream.StreamSupport.stream(grant.get("resourceScope").get("phases").spliterator(), false) + .anyMatch(phase -> phase.equals(operation.get("action")))); + JsonNode recordRef = grant.get("resourceScope").get("recordRef"); + var projection = jdbc.query("SELECT revision, record_resource_id, operation_hash FROM ma_session_record" + + " WHERE record_kind='DOMAIN' AND session_scope_key=? AND tenant_id=? AND workspace_id=?" + + " AND session_id=? AND domain='tool_stage' AND record_id=?", + (row, index) -> new Projection(row.getLong(1), row.getString(2), row.getString(3)), + ManagedSessionStore.sessionScopeKey(tenant, harness), tenant, session.getScope().getWorkspaceId(), harness, stageId); + check(projection.size() == 1); + Projection latest = projection.getFirst(); + check(latest.revision() == grant.get("operationRevision").longValue() + && latest.resourceId().equals(recordRef.get("resourceId").textValue()) + && latest.operationHash().equals(DigestUtils.sha256Hex(grant.get("operationId").textValue()))); + JsonNode stage = records.readRecordResource(tenant, harness, recordRef); + ManagedToolStageRecords.requireStage(stage); + check(stageId.equals(stage.get("toolStageId").textValue()) && operation.get("toolName").equals(stage.get("toolName"))); + JsonNode run = stage.get("run"); + check(runtime.getBindingId().equals(run.path("runtime").path("runtimeBindingId").asText()) + && Long.toString(runtime.getGeneration()).equals(run.path("runtime").path("generation").asText())); + JsonNode inputRef = stage.get("inputRef"); + check("managed-extension-command-input".equals(inputRef.get("kind").textValue()) && inputRef.get("schemaVersion").asInt() == 1); + JsonNode input = records.readRecordResource(tenant, harness, inputRef); + check(input.isObject() && ManagedExtensionRecords.same(input, operation.get("input"))); + JsonNode permissionRef = stage.get("permissionRef"); + check(!permissionRef.isNull() && "managed-extension-command-permission".equals(permissionRef.get("kind").textValue()) + && permissionRef.get("schemaVersion").asInt() == 1); + JsonNode permission = records.readRecordResource(tenant, harness, permissionRef); + ManagedExtensionRecords.closed(permission, Set.of("allowed", "toolName", "inputRef", "approvalMode", + "preHookOccurrence", "permissionHookOccurrence", "actionRequestId"), "Command permission"); + check(permission.get("allowed").isBoolean() && permission.get("allowed").booleanValue() + && permission.get("toolName").equals(stage.get("toolName")) + && ManagedExtensionRecords.same(inputRef, permission.get("inputRef")) + && WorkspaceToolProfiles.supportsApprovalMode(permission.path("approvalMode").asText())); + for (String field : List.of("preHookOccurrence", "permissionHookOccurrence", "actionRequestId")) { + if (!permission.get(field).isNull()) { + ManagedExtensionRecords.id(permission.get(field), field); + } + } + // Recovery reads only this committed stage and its original owner, including while configuration admission is closed. + if ("execute".equals(operation.get("action").textValue())) { + var owner = sessions.requireSession(tenant, harness); + var approval = jdbc.queryForList("SELECT COALESCE(selected_approval_mode, approval_mode) FROM ma_session" + + " WHERE tenant_id=? AND session_id=?", String.class, tenant, harness); + check(owner.workspace() != null && owner.workspace().getWorkspaceId().equals(session.getScope().getWorkspaceId()) + && approval.size() == 1 && approval.getFirst().equals(permission.get("approvalMode").textValue()) + && (!"shell".equals(stage.get("toolName").textValue()) || WorkspaceToolProfiles.isPublicShell(owner.toolProfile()))); + check("running".equals(run.get("state").textValue()) && "dispatch_started".equals(run.get("execution").textValue())); + ManagedConfigInstall.requireOpen(jdbc, tenant, harness); + JsonNode configuration = ManagedMcpSettings.find(jdbc, tenant, harness); + for (int step = 0; ManagedConfigInstall.isPackageConfig(configuration) + && !"active".equals(configuration.path("phase").asText()); step++) { + check(step < 4096 && !configuration.path("previousRecordRef").isNull()); + configuration = records.readRecordResource(tenant, harness, configuration.get("previousRecordRef")); + } + check(ManagedConfigInstall.isPackageConfig(configuration) && "active".equals(configuration.path("phase").asText()) + && configuration.get("configRevision").equals(operation.get("revision"))); + JsonNode bundle = records.readRecordResource(tenant, harness, configuration.get("bundleRef")); + check("managed-extension-config-bundle".equals(bundle.path("kind").asText()) + && bundle.path("formatVersion").asInt() == 1 && bundle.get("configRevision").equals(operation.get("revision")) + && bundle.path("packages").isArray() + && java.util.stream.StreamSupport.stream(bundle.get("packages").spliterator(), false) + .anyMatch(entry -> operation.get("extensionId").equals(entry.path("pin").get("extensionId")))); + } + } + + private record Projection(long revision, String resourceId, String operationHash) { } + + private static void check(boolean condition) { + if (!condition) { + throw denied(); + } + } + + private static RuntimeBrokerException denied() { + return new RuntimeBrokerException(403, "extension_command_permission_invalid", "The committed extension command permission is invalid or stale.", false); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionProjection.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionProjection.java index f947ab4747a..18946c3c770 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionProjection.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionProjection.java @@ -24,6 +24,11 @@ import static java.util.Map.entry; public final class ManagedExtensionProjection { /** The record bodies defined so far; a slice adds its body here. ofEntries, since Map.of caps ten pairs. */ public static final Map RECORD_BODIES = Map.ofEntries( + entry("tool_stage", new Body(record -> null, + ManagedToolStageRecords::requireStage, + body -> body.get("toolStageId").textValue(), + ManagedToolStageRecords::isStart, + ManagedToolStageRecords::isSuccessor)), entry("hosted_harness_release", new Body(record -> null, ManagedHarnessReleaseRecords::require, body -> HarnessPlacementStore.releaseId(body.path("placementEpoch").asLong()), diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionRecordStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionRecordStore.java index b8abe1c5429..5d714984dcb 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionRecordStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionRecordStore.java @@ -529,7 +529,7 @@ public class ManagedExtensionRecordStore { requireDomainCommitted(payload, domain, body != null); if (ManagedMcpSettings.DOMAIN.equals(domain)) { projectMcpSettings(tenantId, workspaceId, sessionId, payload, resources, - firstSequence + index, occurredAt); + firstSequence + index, occurredAt, lines); } if (body != null) { require(applied == 0, "A transaction carries at most one" @@ -674,9 +674,8 @@ public class ManagedExtensionRecordStore { } private void projectMcpSettings(String tenantId, String workspaceId, String sessionId, JsonNode payload, - Function resources, long sequence, long occurredAt) { - require(sessions != null, "MCP management admission is unavailable"); - sessions.requireMcpSettingsCommit(tenantId, workspaceId, sessionId); + Function resources, long sequence, long occurredAt, String[] lines) { + require(sessions != null, "Configuration management admission is unavailable"); JsonNode ref = payload.get("recordRef"); StoredResource resource = resources.apply(ref.get("resourceId").textValue()); requireReference(resource, ref); @@ -686,8 +685,21 @@ public class ManagedExtensionRecordStore { } catch (InvalidRecordException error) { throw rejected(error.getMessage()); } - require(settings.get("operationId").equals(payload.get("operationId")), "MCP command identity conflicts"); + require(settings.get("operationId").equals(payload.get("operationId")), "Configuration command identity conflicts"); JsonNode previous = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + if (ManagedConfigInstall.isPackageConfig(settings)) { + sessions.requireConfigurationCommit(tenantId, workspaceId, sessionId, settings); + try { + ManagedConfigInstall.successor(previous, settings); + } catch (InvalidRecordException error) { + throw rejected(error.getMessage()); + } + requireConfigurationBinding(settings, lines); + } else { + require(!ManagedConfigInstall.isPackageConfig(previous), "Legacy MCP writes cannot replace package configuration"); + sessions.requireMcpSettingsCommit(tenantId, workspaceId, sessionId); + } + Long settledAt = ManagedConfigInstall.barrier(settings) ? null : occurredAt; long revision = settings.get("revision").longValue(); require(revision == (previous == null ? 1 : previous.get("revision").longValue() + 1), "MCP settings revision conflicts"); String scope = ManagedSessionStore.sessionScopeKey(tenantId, sessionId); @@ -697,7 +709,7 @@ public class ManagedExtensionRecordStore { + " session_id, domain, record_id, revision, record_resource_id, gmt_create, settled_at, first_sequence)" + " VALUES ('DOMAIN', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", scope, key, tenantId, workspaceId, sessionId, ManagedMcpSettings.DOMAIN, ManagedMcpSettings.RECORD_ID, revision, resource.resourceId(), - occurredAt, occurredAt, sequence); + occurredAt, settledAt, sequence); } else { JsonNode predecessor = settings.get("previousRecordRef"); String previousResource = jdbc.queryForObject("SELECT record_resource_id FROM ma_session_record" @@ -706,7 +718,7 @@ public class ManagedExtensionRecordStore { requireReference(resources.apply(previousResource), predecessor); jdbc.update("UPDATE ma_session_record SET revision=?, record_resource_id=?, settled_at=?" + " WHERE session_scope_key=? AND record_key=? AND revision=?", - revision, resource.resourceId(), occurredAt, scope, key, revision - 1); + revision, resource.resourceId(), settledAt, scope, key, revision - 1); } jdbc.update("DELETE FROM ma_resource_ref WHERE session_scope_key=? AND owner_kind='SESSION_RECORD' AND owner_key=?", scope, key); jdbc.update("INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key," @@ -715,6 +727,24 @@ public class ManagedExtensionRecordStore { ManagedSchemaKeys.key(scope, resource.resourceId()), tenantId, workspaceId, sessionId, resource.resourceId()); } + private static void requireConfigurationBinding(JsonNode settings, String[] lines) { + List bindings = java.util.Arrays.stream(lines).map(ManagedExtensionRecordStore::parse) + .filter(Objects::nonNull).map(line -> line.path("managedSession")) + .filter(event -> "config.bound".equals(event.path("kind").asText())).map(event -> event.path("payload")).toList(); + if (!"committed".equals(settings.path("phase").asText())) { + require(bindings.isEmpty(), "Only a committed configuration may bind"); + return; + } + require(bindings.size() == 1, "Configuration binding must commit atomically"); + JsonNode binding = bindings.getFirst(); + ManagedExtensionRecords.closed(binding, Set.of("revision", "previousRevision", "bundleRef", "rootSnapshotRef"), "Configuration binding"); + require(binding.path("revision").equals(settings.get("configRevision")) + && (settings.path("previousConfigRevision").asLong() == 0 + ? binding.path("previousRevision").isNull() : binding.path("previousRevision").equals(settings.get("previousConfigRevision"))) + && binding.get("bundleRef").equals(settings.get("bundleRef")) + && binding.get("rootSnapshotRef").equals(settings.get("rootSnapshotRef")), "Configuration binding conflicts"); + } + /** * A never-started verdict and the creation mint share one seam: this lock is the same row the * creation fence reads FOR UPDATE, so the mint's lineage write and the verdict's name serialize @@ -805,9 +835,9 @@ public class ManagedExtensionRecordStore { boolean hookDomain = "hook_execution".equals(domain) || "hook_registration".equals(domain); if (v2 && (resourcePhase || List.of("monitor_run", "mcp_configuration", "mcp_operation", - "schedule", "automation_run", "session_message", "channel_delivery").contains(domain))) { + "schedule", "automation_run", "session_message", "channel_delivery", "tool_stage").contains(domain))) { if (!List.of("monitor_run", "child_run", "child_acceptance", "mcp_configuration", "mcp_operation", - "hook_execution", "hook_registration", "schedule", "automation_run", "session_message", "channel_delivery").contains(domain)) { + "hook_execution", "hook_registration", "schedule", "automation_run", "session_message", "channel_delivery", "tool_stage").contains(domain)) { throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); } JsonNode next = readBody(resources.apply(payload.path("recordRef").path("resourceId").asText())); @@ -1132,8 +1162,8 @@ public class ManagedExtensionRecordStore { } catch (InvalidRecordException error) { throw rejected(error.getMessage()); } - if (List.of("mcp_configuration", "mcp_operation", "hook_registration", "hook_execution").contains(domain)) { - for (String field : List.of("catalogRef", "argsRef", "resultRef", "planRef", "inputRef")) { + if (List.of("mcp_configuration", "mcp_operation", "hook_registration", "hook_execution", "tool_stage").contains(domain)) { + for (String field : List.of("catalogRef", "argsRef", "resultRef", "planRef", "inputRef", "permissionRef")) { JsonNode ref = record.get(field); if (ref != null && !ref.isNull()) { requireReference(resources.apply(ref.get("resourceId").textValue()), ref); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedHostedRecoveryRecords.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedHostedRecoveryRecords.java index bbd8d265ab3..bea574e45f3 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedHostedRecoveryRecords.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedHostedRecoveryRecords.java @@ -200,6 +200,9 @@ public final class ManagedHostedRecoveryRecords { refs.add(call.path("definitionRef")); } } + if (body.has("commandStep")) { + refs.add(body.path("commandStep").path("inputRef")); + } return refs; } @@ -253,6 +256,18 @@ public final class ManagedHostedRecoveryRecords { require(route.path(hash).isTextual() && route.path(hash).asText().matches("[a-f0-9]{64}"), "Invalid public child route digest."); } } + if (!model && body.has("commandStep")) { + fields.add("commandStep"); + JsonNode step = body.get("commandStep"); + ManagedExtensionRecords.closed(step, Set.of("toolStageId", "toolName", "inputRef", "configRevision"), "command step"); + ManagedExtensionRecords.id(step.get("toolStageId"), "toolStageId"); + ManagedExtensionRecords.oneOf(step.get("toolName"), List.of("read_file", "shell"), "command tool"); + ManagedExtensionRecords.count(step.get("configRevision"), 1, ManagedSessionStoreModels.MAX_SAFE_COUNTER, "command configuration revision"); + ManagedExtensionRecords.durableRef(step.get("inputRef"), "command input"); + require("managed-extension-command-input".equals(step.path("inputRef").path("kind").asText()) + && step.path("inputRef").path("schemaVersion").asInt() == 1 + && "approval".equals(body.path("stage").asText()), "Command continuation conflicts."); + } ManagedExtensionRecords.closed(body, fields, "Hosted recovery descriptor"); JsonNode source = body.get("sourceActivation"); ManagedExtensionRecords.closed(source, Set.of("activationId", "epoch"), "source activation"); @@ -301,13 +316,18 @@ public final class ManagedHostedRecoveryRecords { ManagedExtensionRecords.oneOf(body.get("stage"), List.of("approval", "final"), "stage"); JsonNode runtime = body.get("runtime"); ManagedExtensionRecords.closed(runtime, Set.of("runtimeSessionId", "bindingId", "generation", "workspaceGeneration"), "native Runtime"); - require(runtimeSessionId(body.get("promptId").asText()).equals(runtime.path("runtimeSessionId").asText()), "Native Runtime owner conflicts."); + if (body.has("commandStep")) { + ManagedExtensionRecords.id(runtime.get("runtimeSessionId"), "command Runtime owner"); + } else { + require(runtimeSessionId(body.get("promptId").asText()).equals(runtime.path("runtimeSessionId").asText()), "Native Runtime owner conflicts."); + } ManagedExtensionRecords.id(runtime.get("bindingId"), "bindingId"); generation(runtime.get("generation")); generation(runtime.get("workspaceGeneration")); require(body.path("calls").isArray() && !body.path("calls").isEmpty(), "Native batch is empty."); ManagedExtensionRecords.count(body.get("approvalOrdinal"), 0, body.path("calls").size() - 1, "approvalOrdinal"); for (JsonNode call : body.get("calls")) { + require(!body.has("commandStep") || "skill".equals(call.path("call").path("name").asText()), "Command continuation must resume its Skill call."); Set callFields = new HashSet<>(Set.of("call", "runtimeCallId", "inputRef", "definitionRef", "requestDigest", "argsDigest", "prepareKey", "prepareReference", "partIndex", "refusal", "actionId")); if (body.has("publicChildModel") && call.has("publicationId")) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedMcpSettings.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedMcpSettings.java index 0b3e6ccb75d..50abaf6c985 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedMcpSettings.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedMcpSettings.java @@ -17,6 +17,10 @@ public final class ManagedMcpSettings { private ManagedMcpSettings() { } public static void require(JsonNode body) { + if (ManagedConfigInstall.isPackageConfig(body)) { + ManagedConfigInstall.require(body); + return; + } ManagedExtensionRecords.closed(body, Set.of("kind", "schemaVersion", "servers", "command", "operationId", "revision", "previousRecordRef"), "MCP settings"); check("hosted-mcp-settings".equals(body.path("kind").textValue()) diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionSkills.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionSkills.java index 6d846dd8e98..3cdd69ad5d0 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionSkills.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionSkills.java @@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -59,6 +60,10 @@ public class ManagedSessionSkills { } public List> snapshot(String tenant, String session) { + return decode(creationJson(tenant, session), json, digests); + } + + private String creationJson(String tenant, String session) { var rows = jdbc.query("SELECT result_json FROM ma_request_receipt WHERE namespace = 'SESSION_CREATE_ACTOR'" + " AND tenant_key = ? AND tenant_id = ? AND session_id = ?", (row, index) -> row.getString("result_json"), ManagedSchemaKeys.key(tenant), tenant, session); @@ -68,7 +73,7 @@ public class ManagedSessionSkills { if (rows.size() != 1) { throw conflict(); } - return decode(rows.getFirst(), json, digests); + return rows.getFirst(); } static List> decode(String value, ObjectMapper json, RequestDigests digests) { @@ -77,6 +82,10 @@ public class ManagedSessionSkills { } try { var snapshot = json.readValue(value, new TypeReference>() { }); + if (Integer.valueOf(2).equals(snapshot.get("version"))) { + requireCreationIntent(snapshot, json, digests); + return snapshot.get("skills") == null ? null : AgentDefinitionExtensions.sessionSkills(snapshot.get("skills")); + } if (!snapshot.keySet().equals(java.util.Set.of("version", "skills", "digest")) || !Integer.valueOf(1).equals(snapshot.get("version"))) { throw conflict(); @@ -91,6 +100,84 @@ public class ManagedSessionSkills { } } + public Map initialSelection(String tenant, String session) { + String value = creationJson(tenant, session); + if (value == null) { + return null; + } + try { + var snapshot = json.readValue(value, new TypeReference>() { }); + if (!Integer.valueOf(2).equals(snapshot.get("version"))) { + decode(value, json, digests); + return null; + } + Map command = requireCreationIntent(snapshot, json, digests); + String identity = tenant + ":" + session; + if (!ManagedConfigInstall.derivedOperationId("managed-extension-initial:", identity).equals(command.get("operationId")) + || !org.apache.commons.codec.digest.DigestUtils.sha256Hex("managed-extension-initial:" + identity).equals(command.get("actorKey"))) { + throw conflict(); + } + return command; + } catch (java.io.IOException error) { + throw conflict(); + } + } + + public void saveInitialSelection(String tenant, String session, + List pins) { + String previous = creationJson(tenant, session); + List> skills = decode(previous, json, digests); + if (initialSelection(tenant, session) != null) { + throw conflict(); + } + String identity = tenant + ":" + session; + Map command = Map.of("operationId", ManagedConfigInstall.derivedOperationId("managed-extension-initial:", identity), + "actorKey", org.apache.commons.codec.digest.DigestUtils.sha256Hex("managed-extension-initial:" + identity), + "expectedRevision", 0, "extensions", json.convertValue(pins, new TypeReference>>() { })); + Map content = new LinkedHashMap<>(); + content.put("skills", skills); + content.put("initialSelection", command); + var snapshot = new LinkedHashMap<>(content); + snapshot.put("version", 2); + snapshot.put("digest", digests.digest(content)); + try { + String value = json.writeValueAsString(snapshot); + if (jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE namespace='SESSION_CREATE_ACTOR'" + + " AND tenant_key=? AND tenant_id=? AND session_id=? AND (result_json IS NULL OR result_json=?)", + value, ManagedSchemaKeys.key(tenant), tenant, session, previous) != 1) { + throw conflict(); + } + } catch (com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException("Cannot save initial extension selection", error); + } + } + + private static Map requireCreationIntent(Map snapshot, ObjectMapper json, RequestDigests digests) { + if (!snapshot.keySet().equals(java.util.Set.of("version", "skills", "initialSelection", "digest")) + || !(snapshot.get("initialSelection") instanceof Map raw)) { + throw conflict(); + } + @SuppressWarnings("unchecked") + Map command = (Map) raw; + if (!command.keySet().equals(java.util.Set.of("operationId", "actorKey", "expectedRevision", "extensions")) + || !(command.get("operationId") instanceof String operation) || !operation.matches("[a-f0-9-]{36}") + || !(command.get("actorKey") instanceof String actor) || !actor.matches("[a-f0-9]{64}") + || !Integer.valueOf(0).equals(command.get("expectedRevision"))) { + throw conflict(); + } + ManagedConfigInstall.pins(json.valueToTree(command.get("extensions"))); + if (snapshot.get("skills") != null) { + AgentDefinitionExtensions.sessionSkills(snapshot.get("skills")); + } + Map content = new LinkedHashMap<>(); + content.put("skills", snapshot.get("skills")); + content.put("initialSelection", command); + if (!digests.digest(content).equals(snapshot.get("digest"))) { + throw conflict(); + } + return command; + } + public void save(String tenant, String session, List> skills) { try { String value = json.writeValueAsString(Map.of("version", 1, "skills", skills, diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java index 8609dd190fa..ed4deacb146 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java @@ -384,6 +384,16 @@ public class ManagedSessionStore { if (!"legacy-close".equals(request.kind()) || !WorkspaceLifecycleStore.legacyClose(jdbc, tenantId, sessionId)) { requireOrdinaryAdmission(tenantId, sessionId); } + if (request.kind() != null && request.kind().startsWith("configuration:")) { + String operation = request.kind().substring("configuration:".length()); + JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + if (!ManagedConfigInstall.uuid(operation) || ManagedConfigInstall.blocked(jdbc, tenantId, sessionId) + && (!ManagedConfigInstall.isPackageConfig(current) || !operation.equals(current.path("operationId").asText()))) { + throw conflict("session_config_recovery_required", "Only the original configuration operation may continue."); + } + } else { + ManagedConfigInstall.requireOpen(jdbc, tenantId, sessionId); + } HeadRow head = requireHeadForUpdate(tenantId, sessionId); requireHeadScope(head, tenantId, request.workspaceId(), sessionId); requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); @@ -2069,10 +2079,26 @@ public class ManagedSessionStore { "managed-agent-skills", "agent-skills-", AgentDefinitionExtensions.skillsBytes(skills)); } + @Transactional + public ToolResultResourceRef publishInheritedExtensionResource(String tenantId, String workspaceId, String sessionId, StoredResource source) { + validateScope(tenantId, workspaceId, sessionId); + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); + if (!Set.of("managed-extension-config-bundle", "managed-extension-contributions", "managed-extension-contributions-part").contains(source.kind()) + || source.schemaVersion() != 1 || source.byteLength() > ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES + || source.bytes().length != source.byteLength() || !sha256(source.bytes()).equals(source.digest())) { + throw invalid("Invalid inherited extension resource"); + } + return publishAgentResource(tenantId, workspaceId, sessionId, source.kind(), source.bytes(), source.resourceId()); + } + private ToolResultResourceRef publishAgentResource(String tenantId, String workspaceId, String sessionId, String kind, String prefix, byte[] bytes) { + return publishAgentResource(tenantId, workspaceId, sessionId, kind, bytes, prefix + sha256(bytes)); + } + + private ToolResultResourceRef publishAgentResource(String tenantId, String workspaceId, String sessionId, + String kind, byte[] bytes, String resourceId) { String digest = sha256(bytes); - String resourceId = prefix + digest; String scopeKey = sessionScopeKey(tenantId, sessionId); Timestamp now = databaseNow(); ResourceRow existing = findResource(scopeKey, resourceId); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedToolStageRecords.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedToolStageRecords.java new file mode 100644 index 00000000000..f83c6420083 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedToolStageRecords.java @@ -0,0 +1,81 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecords.InvalidRecordException; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Set; +import java.util.function.BooleanSupplier; + +/** One ordinary tool invocation produced by an extension command expansion. */ +public final class ManagedToolStageRecords { + private static final Set KEYS = Set.of("kind", "toolStageId", "expansionId", "stepIndex", "toolName", + "inputRef", "permissionRef", "resultRef", "run"); + private static final List FIXED = List.of("kind", "toolStageId", "expansionId", "stepIndex", "toolName", "inputRef"); + + private ManagedToolStageRecords() { } + + public static void requireStage(JsonNode record) { + ManagedExtensionRecords.closed(record, KEYS, "Tool stage"); + require("tool_stage".equals(record.get("kind").textValue()), "Tool stage kind must be tool_stage"); + ManagedExtensionRecords.id(record.get("toolStageId"), "toolStageId"); + ManagedExtensionRecords.id(record.get("expansionId"), "expansionId"); + ManagedExtensionRecords.count(record.get("stepIndex"), 0, 9_007_199_254_740_991L, "stepIndex"); + String tool = ManagedExtensionRecords.oneOf(record.get("toolName"), List.of("read_file", "shell"), "toolName"); + ManagedExtensionRecords.durableRef(record.get("inputRef"), "inputRef"); + for (String field : List.of("permissionRef", "resultRef")) { + if (!record.get(field).isNull()) { + ManagedExtensionRecords.durableRef(record.get(field), field); + } + } + JsonNode run = record.get("run"); + ManagedExtensionRecords.requireRun(run); + require(!run.get("definition").isNull() + && tool.equals(run.get("definition").get("definitionId").textValue()), + "Tool stage must pin its ordinary tool definition"); + require(ManagedExtensionRecords.same(record.get("toolStageId"), run.get("executionCallId")), + "Tool stage run must name its invocation"); + } + + public static boolean isStart(JsonNode record) { + return accepts(() -> { + requireStage(record); + return record.get("resultRef").isNull() && ManagedExtensionRecords.isRunStart(record.get("run")); + }); + } + + public static boolean isSuccessor(JsonNode before, JsonNode after) { + return accepts(() -> { + requireStage(before); + requireStage(after); + if (ManagedExtensionRecords.isTerminalRunState(before.get("run").get("state").textValue())) { + return ManagedExtensionRecords.same(before, after); + } + for (String field : FIXED) { + if (!ManagedExtensionRecords.same(before.get(field), after.get(field))) { + return false; + } + } + for (String field : List.of("permissionRef", "resultRef")) { + if (!before.get(field).isNull() && !ManagedExtensionRecords.same(before.get(field), after.get(field))) { + return false; + } + } + return ManagedExtensionRecords.isRunSuccessor(before.get("run"), after.get("run")); + }); + } + + private static boolean accepts(BooleanSupplier check) { + try { + return check.getAsBoolean(); + } catch (InvalidRecordException | NullPointerException | ClassCastException refused) { + return false; + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new InvalidRecordException(message); + } + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStore.java index 9f800a3c78b..fb89d8b7730 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStore.java @@ -92,7 +92,7 @@ public class WakeRecoveryStore { + " AND retired_at IS NULL AND csi_guard = FALSE" + " AND (workspace_id IS NULL OR workspace_id = ?) FOR UPDATE", String.class, candidate.tenantId(), candidate.sessionId(), candidate.workspaceId()); - if (session.isEmpty()) { + if (session.isEmpty() || ManagedConfigInstall.blocked(jdbc, candidate.tenantId(), candidate.sessionId())) { return false; } return jdbc.update("UPDATE ma_session_head h SET wake_retry_at = " + ManagedSchemaTime.SQL_NOW + " + " + RETRY_MS + " WHERE" diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java index a552e04fe3a..07b6c2e9bd1 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java @@ -92,6 +92,16 @@ final class WorkspaceCsiApplicationInventory { + "projection_version:INT,snapshot_stale_since:BIGINT,current_snapshot_id:VARBINARY," + "artifact_refs_resource_key:CHAR,output_ranges_json:LONGTEXT,projection_pending:BOOLEAN," + "gmt_modified:BIGINT"), + declaration("ma_extension_operation", "operation_key", + "operation_key:CHAR,scope_key:CHAR,tenant_id:VARBINARY,workspace_id:VARBINARY,actor_key:CHAR," + + "operation_id:VARCHAR,extension_id:VARBINARY,operation_kind:VARCHAR,revision:BIGINT," + + "package_digest:CHAR,request_digest:CHAR,state:VARCHAR,gmt_create:BIGINT"), + declaration("ma_extension_package", "package_key", + "package_key:CHAR,scope_key:CHAR,tenant_id:VARBINARY,workspace_id:VARBINARY,extension_id:VARBINARY," + + "package_revision:BIGINT,package_digest:CHAR,manifest_json:LONGTEXT,byte_length:BIGINT,state:VARCHAR,gmt_create:BIGINT"), + declaration("ma_extension_settings", "settings_key", + "settings_key:CHAR,scope_key:CHAR,tenant_id:VARBINARY,workspace_id:VARBINARY,extension_id:VARBINARY," + + "settings_revision:BIGINT,encryption_key_id:VARBINARY,encrypted_values:LONGTEXT,configured_keys_json:LONGTEXT,gmt_create:BIGINT"), declaration("ma_message", "tenant_id,session_id,item_id", "tenant_id:VARBINARY,session_id:VARBINARY,item_id:VARBINARY,turn_id:VARBINARY,item_type:VARCHAR," + "item_role:VARCHAR,item_status:VARCHAR,attributes_json:LONGTEXT,first_sequence:BIGINT," @@ -284,6 +294,16 @@ final class WorkspaceCsiApplicationInventory { + "retry_count:INT,consecutive_failures:INT,budget_exempt_failures:INT,retry_after:BIGINT," + "model_id:VARBINARY,model_revision:BIGINT,approval_mode:VARCHAR,approval_revision:BIGINT," + "plan_enabled:BOOLEAN,permission_rules_json:LONGTEXT"), + declaration("ma_user_message", "tenant_id,session_id,message_id", + "tenant_id:VARBINARY,session_id:VARBINARY,message_id:VARBINARY,sequence_number:BIGINT," + + "revision:BIGINT,actor_id:VARBINARY,input_json:LONGTEXT,state:VARCHAR,context_revision:BIGINT," + + "turn_id:VARBINARY,prompt_id:VARBINARY,consumed_sequence:BIGINT"), + declaration("ma_user_message_command", "tenant_id,session_id,command_key", + "tenant_id:VARBINARY,session_id:VARBINARY,command_key:CHAR,actor_id:VARBINARY," + + "request_digest:VARCHAR,result_json:LONGTEXT"), + declaration("ma_user_message_queue", "tenant_id,session_id", + "tenant_id:VARBINARY,session_id:VARBINARY,paused:BOOLEAN,resumed_turn_id:VARBINARY," + + "next_sequence:BIGINT,last_dispatch_at:BIGINT"), declaration("ma_workspace", "tenant_id,workspace_id", "tenant_id:VARBINARY,workspace_id:VARBINARY,workspace_generation:BIGINT,storage_id:VARBINARY," + "display_name:VARCHAR,config_ref:VARBINARY,policy_ref:VARBINARY,state:VARCHAR,workspace_kind:VARCHAR," @@ -2319,13 +2339,15 @@ final class WorkspaceCsiApplicationInventory { case "ma_session" -> query(table, "tenant_id=T&session_id=sessions", "runtime_request_key=requests", "tenant_id=T&parent_session_id=sessions", "tenant_id=T&root_session_id=sessions"); case "ma_session_head" -> query(table, "tenant_key=BT&harness_key=BS", "tenant_id=T&session_id=sessions"); - case "ma_public_event", "ma_turn", "ma_session_record" -> + case "ma_public_event", "ma_turn", "ma_session_record", "ma_user_message", + "ma_user_message_command", "ma_user_message_queue" -> query(table, "tenant_id=T&session_id=sessions"); case "ma_event_stream" -> query(table, "tenant_id=T&session_id=sessions", "artifact_refs_resource_key=resourceKeys"); case "ma_message" -> query(table, "tenant_id=T&session_id=sessions", "body_resource_key=resourceKeys"); case "ma_snapshot" -> query(table, "tenant_id=T&session_id=sessions", "manifest_resource_key=resourceKeys"); case "ma_agent_definition" -> query(table, "tenant_id=T&agent_id=agent&revision=agentRevision"); - case "ma_workspace_access", "ma_channel_instance" -> query(table, "tenant_id=T&workspace_id=W"); + case "ma_workspace_access", "ma_channel_instance", "ma_extension_operation", "ma_extension_package", + "ma_extension_settings" -> query(table, "tenant_id=T&workspace_id=W"); case "ma_workspace" -> query(table, "tenant_id=T&workspace_id=W", "tenant_id=T&storage_id=D", "tenant_id=T&parent_session_id=sessions"); case "ma_storage" -> query(table, "alias_key=A", "physical_key=F", "tenant_id=T&storage_id=D"); @@ -2382,7 +2404,8 @@ final class WorkspaceCsiApplicationInventory { } return switch (table) { case "ma_tenant_guard", "ma_agent_definition" -> true; - case "ma_workspace_access", "ma_channel_instance" -> workspace.equals(row.text("workspace_id")); + case "ma_workspace_access", "ma_channel_instance", "ma_extension_operation", "ma_extension_package", + "ma_extension_settings" -> workspace.equals(row.text("workspace_id")); case "ma_workspace" -> "ROOT".equals(row.text("workspace_kind")) && row.cell("parent_session_id").isNull() && row.cell("parent_workspace_id").isNull(); case "ma_request_receipt" -> "SESSION_CREATE_MODE".equals(row.text("namespace")) @@ -3189,7 +3212,8 @@ final class WorkspaceCsiApplicationInventory { } case "ma_tenant_guard" -> expect(table, row, tenant.equals(row.text("tenant_id")) && has("BT", row.text("tenant_key")) && oneOf(row.text("guard_domain"), "runtime_placement", "output_publication")); - case "ma_workspace_access", "ma_channel_instance" -> expect(table, row, tenant.equals(row.text("tenant_id")) && workspace.equals(row.text("workspace_id"))); + case "ma_workspace_access", "ma_channel_instance", "ma_extension_operation", "ma_extension_package", + "ma_extension_settings" -> expect(table, row, tenant.equals(row.text("tenant_id")) && workspace.equals(row.text("workspace_id"))); case "ma_agent_definition" -> { Row parent = originalRow("ma_session"); expect(table, row, parent != null && tenant.equals(row.text("tenant_id")) diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java index 8e23a18a904..1c8adbe1636 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java @@ -35,6 +35,7 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnPage; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnSummary; import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Duration; import java.util.List; @@ -235,6 +236,22 @@ public interface AgentStateStore { approvalSelectionSupported, planMode, planModeSupported, skills, publicChildrenSupported); } + @org.springframework.transaction.annotation.Transactional + default Admission insertWorkspaceSessionCommand(String tenantId, String actorId, + String idempotencyKey, String requestDigest, String agentId, String requestedRevision, + String title, List> input, String payloadDigest, WorkspaceSelection selection, + String approvalMode, boolean approvalSelectionSupported, Boolean planMode, boolean planModeSupported, + List> skills, boolean publicChildrenSupported, String modelId, + List extensionPins, + Set extensionLifecycleProfiles) { + if (extensionPins != null) { + throw new UnsupportedOperationException("Initial extension selection is unavailable"); + } + return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, + agentId, requestedRevision, title, input, payloadDigest, selection, approvalMode, + approvalSelectionSupported, planMode, planModeSupported, skills, publicChildrenSupported, modelId); + } + Admission replayWorkspaceSessionCommand(String tenantId, String actorId, String idempotencyKey, String requestDigest); @@ -728,6 +745,14 @@ public interface AgentStateStore { throw new UnsupportedOperationException("MCP management is unavailable"); } + default SessionRecord requireExtensionManagement(String tenantId, String actorId, String sessionId, String operationId, boolean mutation) { + throw new UnsupportedOperationException("Extension management is unavailable"); + } + + default void requireConfigurationCommit(String tenantId, String workspaceId, String sessionId, JsonNode body) { + throw new UnsupportedOperationException("Extension management is unavailable"); + } + default Set findApprovalOwners(String tenantId, String actorId, List sessionIds) { return Set.of(); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java index b14f5a3122c..71277567876 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java @@ -347,7 +347,9 @@ public class ManagedActionService { } try { JsonNode ref = options.path("inputRef"); - if (!"managed-tool-input".equals(ref.path("kind").asText()) + boolean command = options.path("v").asLong() == 3 + && "managed-extension-command-input".equals(ref.path("kind").asText()); + if ((!command && !"managed-tool-input".equals(ref.path("kind").asText())) || ref.path("schemaVersion").asLong() != 1 || ref.path("byteLength").asLong() < 1 || ref.path("byteLength").asLong() @@ -356,21 +358,28 @@ public class ManagedActionService { } var resource = resources.readCommittedResource(tenant, session, ref); JsonNode wrapper = ToolPublicationContract.readJson(resource.bytes()); - if (wrapper == null || !wrapper.isObject() - || wrapper.size() != 3 - || !session.equals(wrapper.path("harnessSessionId").asText()) - || !wrapper.path("runtimeSessionId").isTextual() - || wrapper.path("runtimeSessionId").asText().isBlank() - || !wrapper.path("payloadJson").isTextual()) { + if (wrapper == null || !wrapper.isObject()) { return null; } - String text = wrapper.path("payloadJson").asText(); - JsonNode payload = ManagedExtensionRecordStore.parse(text); - if (payload == null - || payload.size() != 2 - || !tool.equals(payload.path("toolName").asText()) - || !payload.path("input").isObject()) { - return null; + String text; + if (command) { + text = wrapper.toString(); + } else { + if (wrapper.size() != 3 + || !session.equals(wrapper.path("harnessSessionId").asText()) + || !wrapper.path("runtimeSessionId").isTextual() + || wrapper.path("runtimeSessionId").asText().isBlank() + || !wrapper.path("payloadJson").isTextual()) { + return null; + } + text = wrapper.path("payloadJson").asText(); + JsonNode payload = ManagedExtensionRecordStore.parse(text); + if (payload == null + || payload.size() != 2 + || !tool.equals(payload.path("toolName").asText()) + || !payload.path("input").isObject()) { + return null; + } } byte[] bytes = text.getBytes(StandardCharsets.UTF_8); if (!text.equals(new String(bytes, StandardCharsets.UTF_8))) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java index 9805ee7a7f6..93c9d00ea46 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java @@ -367,6 +367,16 @@ public class ManagedAgentService { String agentRevision, String title, Map metadata, List blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode, List> skills, String modelId) { + return createWorkspaceSession(tenantId, actorId, idempotencyKey, agentId, agentRevision, + title, metadata, blocks, selection, approvalMode, planMode, skills, modelId, null); + } + + public CommandAdmission createWorkspaceSession(String tenantId, + String actorId, String idempotencyKey, String agentId, + String agentRevision, String title, Map metadata, + List blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode, + List> skills, String modelId, + List extensionPins) { validateIdempotencyKey(idempotencyKey); var additions = AgentDefinitionExtensions.sessionSkills(skills); if (actorId == null || actorId.isEmpty()) { @@ -398,6 +408,9 @@ public class ManagedAgentService { if (modelId != null) { semantic.put("modelId", modelId); } + if (extensionPins != null) { + semantic.put("extensionPins", extensionPins); + } String requestDigest = digests.digest(semantic); String payloadDigest = input.isEmpty() ? null : SubmitHarnessTurn.computePayloadDigest(input); @@ -417,19 +430,26 @@ public class ManagedAgentService { throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "The Hosted Harness does not support Session Skills."); } + if (extensionPins != null && !harness.supportsSessionExtensions()) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature", "The Hosted Harness does not support extension packages."); + } if (!input.isEmpty() && !harness.isWorkspaceFilesAvailable()) { throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_unavailable", "Hosted Workspace execution is not available."); } Admission admission; + Set extensionLifecycleProfiles = extensionPins == null ? Set.of() + : java.util.stream.Stream.of(WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP) + .filter(harness::supportsLifecycle).collect(java.util.stream.Collectors.toSet()); try { admission = store.insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, agentId, agentRevision, effectiveTitle, input, payloadDigest, selection, approvalMode, harness.supportsApprovalSelection() && ("qwen-code".equals(agentId) || harness.supportsAgentDefinitionApprovalSelection()), - planMode, harness.supportsPlanMode(), additions, harness.supportsPublicChildAgents(), modelId); + planMode, harness.supportsPlanMode(), additions, harness.supportsPublicChildAgents(), modelId, extensionPins, + extensionLifecycleProfiles); } catch (DuplicateKeyException error) { admission = store.replayWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/EmbeddedRuntimeBroker.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/EmbeddedRuntimeBroker.java index e5fa83f2462..d7f6af65c55 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/EmbeddedRuntimeBroker.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/EmbeddedRuntimeBroker.java @@ -67,6 +67,8 @@ public class EmbeddedRuntimeBroker implements RuntimeWarmer, AutoCloseable { "CLOSED", "ARCHIVING", "ARCHIVED", "DELETING", "DELETED"); private final SshWorkspaceService ssh; private final RuntimeProvisioner localProvisioner; + private final RuntimeBindingRepository extensionBindings; + private final HttpRuntimeTransport extensionTransport; public EmbeddedRuntimeBroker(AgentStateStore store, ManagedAgentProperties properties, @@ -180,6 +182,8 @@ public class EmbeddedRuntimeBroker implements RuntimeWarmer, AutoCloseable { throw new IllegalStateException("Resident Workspace and SSH nodes require separate Broker configurations"); } HttpRuntimeTransport http = remote ? new HttpRuntimeTransport(ssh.connections()::resolveEndpoint) : new HttpRuntimeTransport(); + this.extensionBindings = bindingRepository; + this.extensionTransport = http; this.workspaces = workspaceExecutionStore == null ? null : new WorkspaceRuntimeResolver(store, workspaceExecutionStore, properties, ssh); WorkspaceRuntimeResolver workspaces = this.workspaces; @@ -211,11 +215,17 @@ public class EmbeddedRuntimeBroker implements RuntimeWarmer, AutoCloseable { } baseProvisioner = provisioner(broker, remote ? new HttpRuntimeTransport() : http); } + if (properties.getManagedExtensions().isEnabled() && baseProvisioner instanceof LocalProcessRuntimeProvisioner local) { + local.withManagedExtensionCacheDirectory(properties.getManagedExtensions().getCacheDirectory(), properties.getManagedExtensions().getCgroupRoot()); + } this.localProvisioner = baseProvisioner; if (remote) { RuntimeProvisioner local = baseProvisioner; var remoteProvisioner = new com.alibaba.qwen.code.runtimebroker.provisioning.SshRuntimeProvisioner( ssh::node, ssh::runtimeStorage, ssh.connections(), http); + if (properties.getManagedExtensions().isEnabled()) { + remoteProvisioner.withManagedExtensionCacheDirectory(properties.getManagedExtensions().getCacheDirectory(), properties.getManagedExtensions().getCgroupRoot()); + } baseProvisioner = new com.alibaba.qwen.code.runtimebroker.provisioning.RoutingRuntimeProvisioner( java.util.Map.of(local.kind(), local, com.alibaba.qwen.code.runtimebroker.provisioning.SshRuntimeProvisioner.KIND, remoteProvisioner), scope -> ssh.runtimeStorage(scope) == null ? local.kind() : com.alibaba.qwen.code.runtimebroker.provisioning.SshRuntimeProvisioner.KIND, @@ -366,6 +376,88 @@ public class EmbeddedRuntimeBroker implements RuntimeWarmer, AutoCloseable { return true; } + public void configureExtensions(com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService packages) { + extensionTransport.configureExtensionPreparation((binding, sessionId, operation) -> { + String tenant = binding.getRequest().getScope().getTenantId(); + @SuppressWarnings("unchecked") + var selected = (List>) operation.get("extensions"); + CompletionStage>> delivery = + CompletableFuture.completedFuture(new java.util.LinkedHashMap<>()); + java.util.Map manifests = new java.util.LinkedHashMap<>(); + for (var pin : selected) { + delivery = delivery.thenCompose(settings -> { + var parsed = new com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Pin( + (String) pin.get("extensionId"), ((Number) pin.get("packageRevision")).longValue(), + (String) pin.get("packageDigest"), pin.get("settingsRevision") == null ? null + : ((Number) pin.get("settingsRevision")).longValue()); + try { + var resolved = packages.resolvePin(tenant, binding.getRequest().getScope().getWorkspaceId(), parsed); + var manifest = resolved.descriptor().manifest(); + java.util.Map metadata = new java.util.LinkedHashMap<>(java.util.Map.of( + "schemaVersion", manifest.schemaVersion(), "compilerVersion", manifest.compilerVersion(), + "name", manifest.name(), "version", manifest.version(), + "target", java.util.Map.of("platform", manifest.target().platform(), "arch", manifest.target().arch()), + "components", manifest.components())); + if (manifest.sourceType() != null) { + metadata.put("sourceType", manifest.sourceType()); + metadata.put("sourceRevision", manifest.sourceRevision()); + } + manifests.put(parsed.extensionId(), metadata); + return installExtensionPackage(tenant, sessionId, binding, parsed.packageDigest(), resolved.archive()) + .thenApply(ignored -> { + settings.put(parsed.extensionId(), resolved.settings()); + return settings; + }); + } catch (IOException error) { + return CompletableFuture.failedFuture(error); + } + }); + } + return delivery.thenApply(settings -> { + java.util.Map prepared = new java.util.LinkedHashMap<>(operation); + prepared.put("settings", settings); + prepared.put("packageManifests", manifests); + prepared.put("toolProfile", store.requireSession(tenant, sessionId).toolProfile()); + return prepared; + }); + }); + } + + private CompletionStage> installExtensionPackage(String tenantId, + String sessionId, com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord expected, String digest, Path archive) { + SessionRecord session = store.requireSession(tenantId, sessionId); + var binding = extensionBindings.findById(expected.getBindingId()); + if (binding == null || binding.getGeneration() != expected.getGeneration() + || !binding.getRequest().equals(expected.getRequest()) + || !java.util.Objects.equals(binding.getProvisionSeed(), expected.getProvisionSeed()) + || !sameExtensionLease(binding.getLease(), expected.getLease()) + || binding.getState() != com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord.State.READY + || binding.isDrainRequested() || binding.getLease() == null || session.workspace() == null + || !tenantId.equals(binding.getRequest().getScope().getTenantId()) + || !session.workspace().getWorkspaceId().equals(binding.getRequest().getScope().getWorkspaceId()) + || !"session".equals(binding.getRequest().getScope().getIsolationClass()) + || !sessionId.equals(binding.getRequest().getIsolationKey())) { + return CompletableFuture.failedFuture(WorkspaceExecutionStore.unavailable()); + } + return extensionTransport.installExtensionPackage(binding, digest, archive).thenApply(result -> { + var current = extensionBindings.findById(expected.getBindingId()); + if (current == null || current.getGeneration() != binding.getGeneration() + || !current.getRequest().equals(binding.getRequest()) + || !java.util.Objects.equals(current.getProvisionSeed(), binding.getProvisionSeed()) + || current.getState() != com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord.State.READY + || current.isDrainRequested() || !sameExtensionLease(current.getLease(), binding.getLease())) { + throw WorkspaceExecutionStore.unavailable(); + } + return result; + }); + } + + private static boolean sameExtensionLease(RuntimeLease left, RuntimeLease right) { + return left != null && right != null && left.getRuntimeInstanceId().equals(right.getRuntimeInstanceId()) + && left.getLeaseId().equals(right.getLeaseId()) && left.getEpoch() == right.getEpoch() + && left.getEndpoint().equals(right.getEndpoint()) && left.getToken().equals(right.getToken()); + } + @Override public CompletionStage warm(String sessionId) { if (retired.contains(sessionId)) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java index 2a31a591a2f..b3422a8b223 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java @@ -33,7 +33,7 @@ final class WorkspaceRuntimeProvisioner implements RuntimeProvisioner { @Override public RuntimeProvisionRequest createRequest(RuntimeScope scope, String isolationKey, String harnessSessionId) { - if (!WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(scope.getCapabilityDigest())) { + if (!WorkspaceExecutionProfile.admitsCapabilityDigest(scope.getCapabilityDigest())) { return delegate.createRequest(scope, isolationKey); } var resolved = resolver.resolve(harnessSessionId, scope.getLifecycleAuthority()); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java index 926f6069c77..baae5926188 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java @@ -27,6 +27,7 @@ final class WorkspaceRuntimeResolver { private final com.alibaba.qwen.code.managedagent.persistence.ResidentWorkspaces residents; private final Map mounts = new LinkedHashMap<>(); private final String boundSessionIsolationClass; + private final ManagedAgentProperties.ManagedExtensions managedExtensions; WorkspaceRuntimeResolver(AgentStateStore sessions, WorkspaceExecutionStore authority, ManagedAgentProperties properties) { @@ -36,6 +37,10 @@ final class WorkspaceRuntimeResolver { WorkspaceRuntimeResolver(AgentStateStore sessions, WorkspaceExecutionStore authority, ManagedAgentProperties properties, SshWorkspaceService ssh) { this.ssh = ssh; + this.managedExtensions = properties.getManagedExtensions(); + if (managedExtensions.isEnabled() && !properties.getExtensions().isEnabled()) { + throw new IllegalStateException("Managed extension execution requires extensions to be enabled"); + } this.sessions = sessions; this.authority = authority; var broker = properties.getRuntimeBroker(); @@ -102,13 +107,13 @@ final class WorkspaceRuntimeResolver { } ContextBinding binding = session.workspace(); if (ssh != null && ssh.runtimeStorage(binding) != null) { - if (hook) { + if (hook && !managedExtensions.isEnabled()) { throw WorkspaceExecutionStore.unavailable(); } ssh.verifyBinding(binding); return new Resolved(binding, new RuntimeScope(session.tenantId(), binding.getWorkspaceId(), Long.toString(binding.getWorkspaceGeneration()), ssh.runtimeStorage(binding).mountRoot(), - WorkspaceExecutionProfile.CAPABILITY_DIGEST, "session").withLifecycleAuthority(lifecycle)); + extensionDigest(String.valueOf(ssh.runtimeStorage(binding).registration().get("platform"))), "session").withLifecycleAuthority(lifecycle)); } var resident = residents.find(binding); if (resident != null) { @@ -122,7 +127,20 @@ final class WorkspaceRuntimeResolver { verifyMountIntact(mount); return new Resolved(binding, new RuntimeScope(session.tenantId(), binding.getWorkspaceId(), Long.toString(binding.getWorkspaceGeneration()), mount.root().toString(), - WorkspaceExecutionProfile.CAPABILITY_DIGEST, boundSessionIsolationClass).withLifecycleAuthority(lifecycle)); + extensionDigest(System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("mac") ? "darwin" : System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT)), boundSessionIsolationClass).withLifecycleAuthority(lifecycle)); + } + + private String extensionDigest(String platform) { + if (!managedExtensions.isEnabled()) { + return WorkspaceExecutionProfile.CAPABILITY_DIGEST; + } + if ("linux".equals(platform)) { + return com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST; + } + if ("darwin".equals(platform) && "developer-process-group".equals(managedExtensions.getMode())) { + return com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST; + } + throw new IllegalStateException("Native extension execution requires an explicit supported platform mode"); } /** diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java index da1a3920abc..720e3e07b81 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java @@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.workspace.runtime; import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; import com.alibaba.qwen.code.runtimebroker.HarnessOwnership; import com.alibaba.qwen.code.runtimebroker.JdbcCsiFilesRetirementGuard; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; import com.alibaba.qwen.code.runtimebroker.ResidentWorkspaceProfile; import com.alibaba.qwen.code.runtimebroker.RuntimeAttestation; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; @@ -22,6 +23,8 @@ import com.alibaba.qwen.code.runtimebroker.provisioning.LocalProcessRuntimeProvi import com.alibaba.qwen.code.runtimebroker.provisioning.SshRuntimeProvisioner; import com.alibaba.qwen.code.runtimebroker.transport.HttpRuntimeTransport; import com.alibaba.qwen.code.runtimebroker.transport.ManagedCsiProtocol; +import com.alibaba.qwen.code.runtimebroker.transport.ManagedExtensionCommandProtocol; +import com.alibaba.qwen.code.runtimebroker.transport.ManagedExtensionProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedHookProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedMcpProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedMonitorProtocol; @@ -86,12 +89,37 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport { || current.isDrainRequested() || current.getLease() == null || !sameLease(current.getLease(), runtime.getLease()) || !LocalProcessRuntimeProvisioner.KIND.equals(current.getRequest().getProvisionerKind()) - || !WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(current.getRequest().getScope().getCapabilityDigest())) { + && !(SshRuntimeProvisioner.KIND.equals(current.getRequest().getProvisionerKind()) + && ManagedExtensionExecutionProfile.admits(current.getRequest().getScope().getCapabilityDigest())) + || !WorkspaceExecutionProfile.admitsCapabilityDigest(current.getRequest().getScope().getCapabilityDigest())) { throw WorkspaceExecutionStore.unavailable(); } return delegate.mcpDefinitions(current); } + @Override + public CompletionStage> extensionControl(RuntimeBindingRecord runtime, + String harnessSessionId, Map operation) { + RuntimeBindingRecord current = bindings.findById(runtime.getBindingId()); + ContextBinding saved = savedBindings.apply(harnessSessionId); + boolean recovery = ManagedExtensionProtocol.isRecovery(operation); + if (current == null || current.getGeneration() != runtime.getGeneration() + || !current.getRequest().equals(runtime.getRequest()) + || !java.util.Objects.equals(current.getProvisionSeed(), runtime.getProvisionSeed()) + || current.getLease() == null || !sameLease(current.getLease(), runtime.getLease()) + || current.getState() != RuntimeBindingRecord.State.READY + && !(recovery && current.getState() == RuntimeBindingRecord.State.DRAINING) + || !recovery && current.isDrainRequested() || saved == null + || !saved.getTenantId().equals(current.getRequest().getScope().getTenantId()) + || !saved.getWorkspaceId().equals(current.getRequest().getScope().getWorkspaceId()) + || !Long.toString(saved.getWorkspaceGeneration()).equals(current.getRequest().getScope().getWorkspaceGeneration()) + || !saved.getStorageId().equals(current.getRequest().getStorageId()) + || !harnessSessionId.equals(current.getRequest().getIsolationKey())) { + throw WorkspaceExecutionStore.unavailable(); + } + return delegate.extensionControl(current, harnessSessionId, saved, operation); + } + @Override public CompletionStage acquire(RuntimeLease lease, RuntimeSession session) { if (!managed(session)) { @@ -371,9 +399,17 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport { @Override public CompletionStage control(RuntimeLease lease, RuntimeSession session, Map operation) { + if (ManagedExtensionProtocol.isOperation(operation)) { + throw new RuntimeBrokerException(400, "runtime_control_operation_invalid", + "Extensions require the metadata control route.", false); + } if (managed(session)) { Context selected = context(lease, session, false); + boolean extensionControl = ManagedExtensionExecutionProfile.admits(session.getScope().getCapabilityDigest()) + && (ManagedMcpProtocol.isOperation(operation) || ManagedHookProtocol.isOperation(operation) + || ManagedExtensionCommandProtocol.isOperation(operation)); if (com.alibaba.qwen.code.runtimebroker.provisioning.SshRuntimeProvisioner.KIND.equals(selected.runtime().getRequest().getProvisionerKind()) + && !extensionControl && !java.util.Set.of("history", "raw-file-history", "workspace-context").contains(operation.get("kind"))) { throw new RuntimeBrokerException(501, "ssh_runtime_control_unsupported", "SSH Runtime supports files and foreground Shell only.", false); @@ -384,12 +420,15 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport { "Generic CSI runtime controls are not qualified.", false); } if (ManagedMcpProtocol.isOperation(operation) || ManagedHookProtocol.isOperation(operation) + || ManagedExtensionCommandProtocol.isOperation(operation) || ManagedShellProtocol.isOperation(operation) || ManagedMonitorProtocol.isOperation(operation)) { if (!managed(session)) { throw WorkspaceExecutionStore.unavailable(); } - if (ManagedHookProtocol.isOperation(operation)) { + if (ManagedExtensionCommandProtocol.isOperation(operation)) { + ManagedExtensionCommandProtocol.validateSession(session, operation); + } else if (ManagedHookProtocol.isOperation(operation)) { ManagedHookProtocol.validateSession(session, operation); } else if (ManagedShellProtocol.isOperation(operation)) { ManagedShellProtocol.validateSession(session, operation); @@ -399,6 +438,7 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport { ManagedMcpProtocol.validateSession(session, operation); } boolean recovery = ManagedMcpProtocol.isRecovery(operation) || ManagedHookProtocol.isRecovery(operation) + || ManagedExtensionCommandProtocol.isRecovery(operation) || ManagedShellProtocol.isRecovery(operation) || ManagedMonitorProtocol.isRecovery(operation); Context context = context(lease, session, !recovery, ManagedHookProtocol.isOperation(operation)); @@ -576,7 +616,7 @@ final class WorkspaceRuntimeTransport implements RuntimeTransport { } private static boolean managed(RuntimeSession session) { - return WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(session.getScope().getCapabilityDigest()) + return WorkspaceExecutionProfile.admitsCapabilityDigest(session.getScope().getCapabilityDigest()) || ResidentWorkspaceProfile.selects(session.getScope()); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessOwnership.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessOwnership.java index c1128790f91..1042d1471b8 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessOwnership.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessOwnership.java @@ -18,6 +18,6 @@ public final class HarnessOwnership { && "workspace".equals(request.getScope().getIsolationClass()) && request.isManagedContext() && LocalProcessRuntimeProvisioner.KIND.equals(request.getProvisionerKind()) - && WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(request.getScope().getCapabilityDigest()); + && WorkspaceExecutionProfile.admitsCapabilityDigest(request.getScope().getCapabilityDigest()); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfile.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfile.java new file mode 100644 index 00000000000..23c51545c50 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfile.java @@ -0,0 +1,42 @@ +package com.alibaba.qwen.code.runtimebroker; + +import java.nio.file.Path; +import java.util.Map; + +/** Explicit package execution contracts; developer process groups are not production tree proofs. */ +public final class ManagedExtensionExecutionProfile { + public static final String LINUX_CAPABILITY_DIGEST = "sha256:6210cbb69834af346af934d3a6db53827548365c247898bbdd23c323b459cb2c"; + public static final String DEVELOPER_CAPABILITY_DIGEST = "sha256:24be30e28acb08bb97a1ddb1ce5c74f830561a41dc171903d80929d1716b62bd"; + + private ManagedExtensionExecutionProfile() {} + + public static boolean admits(String digest) { + return LINUX_CAPABILITY_DIGEST.equals(digest) || DEVELOPER_CAPABILITY_DIGEST.equals(digest); + } + + public static String mode(String digest) { + if (LINUX_CAPABILITY_DIGEST.equals(digest)) { + return "linux-cgroup"; + } + if (DEVELOPER_CAPABILITY_DIGEST.equals(digest)) { + return "developer-process-group"; + } + throw new IllegalArgumentException("Unsupported extension execution capability"); + } + + public static Map configuration(String digest, String cacheDirectory, String cgroupRoot) { + if (cacheDirectory == null || !Path.of(cacheDirectory).isAbsolute()) { + throw new IllegalArgumentException("Managed extension cache directory must be absolute"); + } + if (LINUX_CAPABILITY_DIGEST.equals(digest)) { + if (cgroupRoot == null || !Path.of(cgroupRoot).isAbsolute()) { + throw new IllegalArgumentException("Linux extensions require a delegated cgroup root"); + } + return Map.of("enabled", true, "mode", mode(digest), "cacheDirectory", cacheDirectory, "cgroupRoot", cgroupRoot); + } + if (cgroupRoot != null && !cgroupRoot.isBlank()) { + throw new IllegalArgumentException("Developer process-group mode does not accept a cgroup root"); + } + return Map.of("enabled", true, "mode", mode(digest), "cacheDirectory", cacheDirectory); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java index 9391f2a3c87..a504eadf866 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java @@ -7,6 +7,8 @@ import com.alibaba.qwen.code.runtimebroker.transport.CsiFileHistoryProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedHoldFamily; import com.alibaba.qwen.code.runtimebroker.transport.ManagedHookProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedMcpProtocol; +import com.alibaba.qwen.code.runtimebroker.transport.ManagedExtensionProtocol; +import com.alibaba.qwen.code.runtimebroker.transport.ManagedExtensionCommandProtocol; import com.alibaba.qwen.code.runtimebroker.transport.ManagedMonitorProtocol; import java.nio.charset.StandardCharsets; @@ -85,6 +87,7 @@ public final class RuntimeBrokerService implements AutoCloseable { Duration.ofMinutes(30); private final HarnessSessionResolver sessionResolver; + private RuntimeExtensionCommandVerifier extensionCommandVerifier; private final RuntimeProvisioner provisioner; private final RuntimeTransport transport; private final RuntimeBindingRepository bindingRepository; @@ -1000,6 +1003,115 @@ public final class RuntimeBrokerService implements AutoCloseable { }); } + public CompletionStage> extensionControl(String harnessSessionId, String expectedBindingId, + Long expectedGeneration, Map operation) { + requireOpen(); + String harnessId = BrokerValues.requirePathSafe(BrokerValues.requireId(harnessSessionId, "harnessSessionId"), "harnessSessionId"); + Map immutable = immutableMap(operation, "extension operation"); + ManagedExtensionProtocol.validateOperation(immutable); + boolean recovery = ManagedExtensionProtocol.isRecovery(immutable); + boolean status = "extension-status".equals(immutable.get("kind")); + if ((expectedBindingId == null) != (expectedGeneration == null) || expectedBindingId == null && !status) { + throw invalid("runtime_control_operation_invalid", "Extension mutations require the original Runtime identity."); + } + return resolveScope(harnessId, !recovery, null).thenCompose(scope -> { + if (!ManagedExtensionExecutionProfile.admits(scope.getCapabilityDigest())) { + throw conflict("managed_extension_unavailable", "The selected Runtime profile does not support extensions."); + } + RuntimeBindingRecord original; + if (expectedBindingId != null) { + original = bindingRepository.findById(expectedBindingId); + } else { + RuntimeBindingRecord found = null; + String after = null; + for (;;) { + var candidates = bindingRepository.findByHarnessSession(scope.getTenantId(), harnessId, after, 100); + for (var candidate : candidates) { + if (candidate.getState() == RuntimeBindingRecord.State.READY && !candidate.isDrainRequested() + && candidate.getRequest().getScope().equals(scope)) { + if (found != null) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime is ambiguous."); + } + found = candidate; + } + } + if (candidates.size() < 100) { + break; + } + after = candidates.getLast().getBindingId(); + } + if (found == null) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime is unavailable or ambiguous."); + } + original = found; + } + if (original == null || expectedGeneration != null && original.getGeneration() != expectedGeneration + || !original.getRequest().getScope().equals(scope) + || !harnessId.equals(original.getRequest().getIsolationKey()) + || original.getLease() == null + || original.getState() != RuntimeBindingRecord.State.READY + && !(recovery && original.getState() == RuntimeBindingRecord.State.DRAINING) + || !recovery && original.isDrainRequested()) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime changed."); + } + if (recovery) { + bindingRepository.requireHarnessRecovery(original.getRequest(), scope, harnessId, null); + } else { + bindingRepository.requireHarnessAdmission(original.getRequest(), scope, harnessId, null); + } + return observeExtensionOwner(original).thenCompose(ignored -> { + requireExtensionOwner(original, recovery); + return transport.extensionControl(original, harnessId, immutable); + }).thenApply(result -> { + requireExtensionOwner(original, recovery); + return Map.of("protocolVersion", 1, "harnessSessionId", harnessId, + "scope", Map.of("tenantId", scope.getTenantId(), "workspaceId", scope.getWorkspaceId(), + "workspaceGeneration", scope.getWorkspaceGeneration(), "isolationClass", scope.getIsolationClass(), + "capabilityDigest", scope.getCapabilityDigest()), + "runtime", Map.of("bindingId", original.getBindingId(), "generation", Long.toString(original.getGeneration())), + "result", result); + }); + }); + } + + private void requireExtensionOwner(RuntimeBindingRecord original, boolean recovery) { + RuntimeBindingRecord current = bindingRepository.findById(original.getBindingId()); + if (current == null || !current.sameIdentity(original) || current.getLease() == null + || !sameLease(current.getLease(), original.getLease()) + || current.getState() != RuntimeBindingRecord.State.READY + && !(recovery && current.getState() == RuntimeBindingRecord.State.DRAINING) + || !recovery && current.isDrainRequested()) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime changed."); + } + } + + private CompletionStage observeExtensionOwner(RuntimeBindingRecord original) { + if (matchingLiveBinding(original) != null && provisioner.isUsable(original.getLease())) { + return CompletableFuture.completedFuture(null); + } + if (original.getProvisionSeed() == null || original.getResourceHandle() == null) { + return failed(conflict("managed_extension_owner_unavailable", "The original extension Runtime cannot be observed.")); + } + return safeStage(() -> provisioner.reconcile(original.getRequest(), original.getProvisionSeed(), + original.getResourceHandle(), original.getLease())).thenCompose(observation -> { + RuntimeLease lease = original.getLease(); + if (observation == null || observation.getOutcome() != RuntimeObservation.Outcome.READY + || !original.getResourceHandle().equals(observation.getHandle()) + || !lease.getRuntimeInstanceId().equals(observation.getRuntimeInstanceId()) + || !lease.getLeaseId().equals(observation.getLeaseId()) + || lease.getEpoch() != observation.getEpoch() + || !lease.getEndpoint().equals(observation.getEndpoint())) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime cannot be observed."); + } + return safeStage(() -> transport.attest(lease, original.getRequest(), original.getProvisionSeed())) + .thenAccept(attestation -> { + if (!validAttestation(attestation, lease, original.getProvisionSeed(), original.getRequest())) { + throw conflict("managed_extension_owner_unavailable", "The original extension Runtime identity changed."); + } + }); + }); + } + public CompletionStage warm(String harnessSessionId, RuntimeLifecycleAuthority authority) { requireOpen(); String harnessId = BrokerValues.requirePathSafe( @@ -1141,6 +1253,10 @@ public final class RuntimeBrokerService implements AutoCloseable { }); } + public void configureExtensionCommandVerifier(RuntimeExtensionCommandVerifier verifier) { + this.extensionCommandVerifier = java.util.Objects.requireNonNull(verifier, "verifier"); + } + public CompletionStage control(String harnessSessionId, String runtimeSessionId, Map operation) { return control(harnessSessionId, runtimeSessionId, operation, null); @@ -1151,6 +1267,9 @@ public final class RuntimeBrokerService implements AutoCloseable { requireOpen(); Map immutable = immutableMap(operation, "operation"); + if (ManagedExtensionProtocol.isOperation(immutable)) { + throw invalid("runtime_control_operation_invalid", "Extensions require the metadata control route."); + } if (CsiFileHistoryProtocol.isOperation(immutable)) { CsiFileHistoryProtocol.operation(immutable); if (authority != null) { @@ -1159,6 +1278,7 @@ public final class RuntimeBrokerService implements AutoCloseable { return csiFileHistory(harnessSessionId, runtimeSessionId, immutable); } if (!ManagedMcpProtocol.isOperation(immutable) && !ManagedHookProtocol.isOperation(immutable) + && !ManagedExtensionCommandProtocol.isOperation(immutable) && !ManagedMonitorProtocol.isOperation(immutable)) { ProviderRuntimeProtocol.control(immutable, harnessSessionId, runtimeSessionId); } @@ -1174,9 +1294,13 @@ public final class RuntimeBrokerService implements AutoCloseable { if (ManagedHookProtocol.isOperation(immutable)) { ManagedHookProtocol.validateSession(context.session(), immutable); } + if (ManagedExtensionCommandProtocol.isOperation(immutable)) { + ManagedExtensionCommandProtocol.validateSession(context.session(), immutable); + } ToolExecutionRecord watch = ManagedMonitorProtocol.isOperation(immutable) ? requireMonitorWatch(context, immutable) : null; boolean recovery = ManagedMcpProtocol.isRecovery(immutable) || ManagedHookProtocol.isRecovery(immutable) + || ManagedExtensionCommandProtocol.isRecovery(immutable) || ManagedMonitorProtocol.isRecovery(immutable); if (recovery && bindingRepository.requireHarnessRecovery(context.binding().getRequest(), context.session().getScope(), harnessSessionId, authority)) { @@ -1206,6 +1330,13 @@ public final class RuntimeBrokerService implements AutoCloseable { context.lock(); try { requireReadySessionRecord(context); + if (ManagedExtensionCommandProtocol.isOperation(immutable)) { + if (extensionCommandVerifier == null) { + throw new RuntimeBrokerException(501, "managed_extension_command_unavailable", + "Extension command permission verification is unavailable.", false); + } + extensionCommandVerifier.verify(context.binding(), context.session(), immutable); + } if (fenced) { context.beginControl(); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeDrainReceipt.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeDrainReceipt.java index c45b8fe0b41..8273aeffea9 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeDrainReceipt.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeDrainReceipt.java @@ -7,8 +7,18 @@ import java.util.Map; /** Trusted proof of clean retirement of one original worker, not journal loss. */ public record RuntimeDrainReceipt(String bindingId, long generation, String provisionRequestId, RuntimeResourceHandle resourceHandle, - Instant stoppedAt) { + Instant stoppedAt, String executionMode, String stopScope) { + public RuntimeDrainReceipt(String bindingId, long generation, String provisionRequestId, + RuntimeResourceHandle resourceHandle, Instant stoppedAt) { + this(bindingId, generation, provisionRequestId, resourceHandle, stoppedAt, null, null); + } + public RuntimeDrainReceipt { + if (executionMode == null ? stopScope != null + : !("linux-cgroup".equals(executionMode) && "process_tree".equals(stopScope) + || "developer-process-group".equals(executionMode) && "process_group".equals(stopScope))) { + throw new IllegalArgumentException("Invalid extension drain receipt scope"); + } BrokerValues.requireId(bindingId, "bindingId"); BrokerValues.requireId(provisionRequestId, "provisionRequestId"); if (generation <= 0 || resourceHandle == null || stoppedAt == null) { @@ -17,6 +27,16 @@ public record RuntimeDrainReceipt(String bindingId, long generation, } public boolean matches(RuntimeBindingRecord binding) { + String digest = binding.getRequest().getScope().getCapabilityDigest(); + boolean extended = ManagedExtensionExecutionProfile.admits(digest); + if (extended) { + if (!ManagedExtensionExecutionProfile.mode(digest).equals(executionMode) + || !("linux-cgroup".equals(executionMode) ? "process_tree" : "process_group").equals(stopScope)) { + return false; + } + } else if (executionMode != null || stopScope != null) { + return false; + } return bindingId.equals(binding.getBindingId()) && generation == binding.getGeneration() && binding.getProvisionSeed() != null && provisionRequestId.equals(binding.getProvisionSeed().getProvisionRequestId()) @@ -24,10 +44,20 @@ public record RuntimeDrainReceipt(String bindingId, long generation, } String toJson() { - return new String(JsonCodec.encode(Map.of("version", 1, "bindingId", bindingId, - "generation", generation, "provisionRequestId", provisionRequestId, - "handleKind", resourceHandle.getKind(), "handleVersion", resourceHandle.getVersion(), - "handle", resourceHandle.toJson(), "stoppedAt", stoppedAt.toString())), StandardCharsets.UTF_8); + Map value = new java.util.LinkedHashMap<>(); + value.put("version", executionMode == null ? 1 : 2); + value.put("bindingId", bindingId); + value.put("generation", generation); + value.put("provisionRequestId", provisionRequestId); + value.put("handleKind", resourceHandle.getKind()); + value.put("handleVersion", resourceHandle.getVersion()); + value.put("handle", resourceHandle.toJson()); + value.put("stoppedAt", stoppedAt.toString()); + if (executionMode != null) { + value.put("executionMode", executionMode); + value.put("stopScope", stopScope); + } + return new String(JsonCodec.encode(value), StandardCharsets.UTF_8); } static RuntimeDrainReceipt fromJson(String json) { @@ -35,13 +65,18 @@ public record RuntimeDrainReceipt(String bindingId, long generation, return null; } var value = JsonCodec.parseObject(json.getBytes(StandardCharsets.UTF_8), "Runtime drain receipt"); - if (!Integer.valueOf(1).equals(value.get("version")) || value.size() != 8) { + boolean extended = Integer.valueOf(2).equals(value.get("version")); + if (extended && (!java.util.Set.of("linux-cgroup", "developer-process-group").contains(value.get("executionMode")) + || !("linux-cgroup".equals(value.get("executionMode")) ? "process_tree" : "process_group").equals(value.get("stopScope")))) { + throw new IllegalArgumentException("Invalid extension stop scope"); + } + if (extended ? !value.keySet().equals(java.util.Set.of("version", "bindingId", "generation", "provisionRequestId", "handleKind", "handleVersion", "handle", "stoppedAt", "executionMode", "stopScope")) : !Integer.valueOf(1).equals(value.get("version")) || !value.keySet().equals(java.util.Set.of("version", "bindingId", "generation", "provisionRequestId", "handleKind", "handleVersion", "handle", "stoppedAt"))) { throw new IllegalArgumentException("Unsupported drain receipt"); } return new RuntimeDrainReceipt((String) value.get("bindingId"), ((Number) value.get("generation")).longValue(), (String) value.get("provisionRequestId"), RuntimeResourceHandle.fromJson((String) value.get("handleKind"), ((Number) value.get("handleVersion")).intValue(), (String) value.get("handle")), - Instant.parse((String) value.get("stoppedAt"))); + Instant.parse((String) value.get("stoppedAt")), extended ? (String) value.get("executionMode") : null, extended ? (String) value.get("stopScope") : null); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionCommandVerifier.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionCommandVerifier.java new file mode 100644 index 00000000000..109e5826393 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionCommandVerifier.java @@ -0,0 +1,9 @@ +package com.alibaba.qwen.code.runtimebroker; + +import java.util.Map; + +/** Checks the committed ordinary-tool permission before an extension command dispatch. */ +@FunctionalInterface +public interface RuntimeExtensionCommandVerifier { + void verify(RuntimeBindingRecord runtime, RuntimeSession session, Map operation); +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java index 729d508a300..6f58926315e 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java @@ -18,6 +18,12 @@ import java.util.concurrent.CompletionStage; * completion only with {@code true}. */ public interface RuntimeTransport { + default CompletionStage> extensionControl(RuntimeBindingRecord runtime, + String harnessSessionId, Map operation) { + return CompletableFuture.failedFuture(new RuntimeBrokerException(501, + "managed_extension_unavailable", "Extension metadata controls are unavailable.", false)); + } + default CompletionStage>> mcpDefinitions(RuntimeBindingRecord runtime) { return CompletableFuture.failedFuture(new RuntimeBrokerException(501, "managed_mcp_management_unavailable", "Runtime MCP definitions are unavailable.", false)); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java index ad77cde7a66..7b920bf06c0 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java @@ -22,8 +22,12 @@ public final class WorkspaceExecutionProfile { * Whether the scope is a bound-Session Workspace placement (the local shared * profile), which participates in Harness Session admission fencing. */ + public static boolean admitsCapabilityDigest(String digest) { + return CAPABILITY_DIGEST.equals(digest) || ManagedExtensionExecutionProfile.admits(digest); + } + public static boolean isBoundWorkspaceScope(RuntimeScope scope) { - return CAPABILITY_DIGEST.equals(scope.getCapabilityDigest()) + return admitsCapabilityDigest(scope.getCapabilityDigest()) && "workspace".equals(scope.getIsolationClass()); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchema.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchema.java index f163ba9a1cd..e2ebdc086f6 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchema.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchema.java @@ -8,7 +8,7 @@ import java.util.Objects; import javax.sql.DataSource; -/** Explicit initializer for the fresh 32-table database, independent of legacy bootstrap. */ +/** Explicit initializer for the fresh Managed database, independent of legacy bootstrap. */ public final class ManagedSchema { public static final String FLYWAY_LOCATION = "classpath:db/managed-schema"; public static final String RESOURCE = "/db/managed-schema/V1__managed_schema.sql"; diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaCatalog.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaCatalog.java index b1a56709f1e..745aebf4317 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaCatalog.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaCatalog.java @@ -104,7 +104,8 @@ final class ManagedSchemaCatalog { || actual.values().stream() .anyMatch(type -> type.toUpperCase(Locale.ROOT).contains("VIEW"))) { throw new SQLException( - "Managed schema must contain exactly the 32 target tables: " + actual.keySet()); + "Managed schema must contain exactly the " + ManagedSchemaContract.TABLES.size() + + " target tables: " + actual.keySet()); } if ("MySQL".equals(connection.getMetaData().getDatabaseProductName()) || isMariaDb(connection)) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java index 895aef482f7..afa4dbdcc4b 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java @@ -19,6 +19,9 @@ public final class ManagedSchemaContract { "ma_snapshot", "ma_session_record", "ma_agent_definition", + "ma_extension_package", + "ma_extension_settings", + "ma_extension_operation", "ma_resource", "ma_resource_ref", "ma_output_capture", diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/LocalProcessRuntimeProvisioner.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/LocalProcessRuntimeProvisioner.java index 8bba205d15a..5f185b7f398 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/LocalProcessRuntimeProvisioner.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/LocalProcessRuntimeProvisioner.java @@ -75,6 +75,8 @@ public final class LocalProcessRuntimeProvisioner private final Function storageResolver; private final LocalRuntimeStore store; private final boolean trustedRebootRecovery; + private String managedExtensionCacheDirectory; + private String managedExtensionCgroupRoot; private final ExecutorService executor = Executors.newCachedThreadPool( task -> { Thread thread = new Thread(task, "runtime-provisioner"); @@ -161,6 +163,16 @@ public final class LocalProcessRuntimeProvisioner exitHook = store == null ? registerExitHook() : null; } + public LocalProcessRuntimeProvisioner withManagedExtensionCacheDirectory(String directory, String cgroupRoot) { + if (directory == null || !Path.of(directory).isAbsolute()) { + throw new IllegalArgumentException("Extension cache must be absolute"); + } + this.managedExtensionCacheDirectory = directory; + this.managedExtensionCgroupRoot = cgroupRoot; + transport.withManagedExtensionCacheDirectory(directory, cgroupRoot); + return this; + } + private Thread registerExitHook() { Thread hook = new Thread(this::terminateAll, "runtime-provisioner-exit"); Runtime.getRuntime().addShutdownHook(hook); @@ -348,7 +360,7 @@ public final class LocalProcessRuntimeProvisioner if (store == null || !binding.isDrainRequested() || !binding.getRequest().isManagedContext() || !(WorkspaceExecutionProfile.admitsIsolationClass(binding.getRequest().getScope().getIsolationClass()) || HarnessOwnership.sharesRuntimeSessions(binding.getRequest())) - || !WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(binding.getRequest().getScope().getCapabilityDigest()) + || !WorkspaceExecutionProfile.admitsCapabilityDigest(binding.getRequest().getScope().getCapabilityDigest()) || binding.getProvisionSeed() == null) { return RuntimeProvisioner.super.stopDrained(binding); } @@ -369,6 +381,24 @@ public final class LocalProcessRuntimeProvisioner if (!originalBootStopped && !neverStarted && worker == null && !registration.processAbsent()) { throw LocalRuntimeStore.blocked(); } + boolean extended = com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(binding.getRequest().getScope().getCapabilityDigest()); + String mode = extended ? com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.mode(binding.getRequest().getScope().getCapabilityDigest()) : null; + String proofScope = extended ? ("linux-cgroup".equals(mode) ? "process_tree" : "process_group") : null; + if (extended && !neverStarted && !originalBootStopped) { + if (binding.getLease() == null || worker == null) { + throw LocalRuntimeStore.blocked(); + } + String retirementId = UUID.nameUUIDFromBytes(("managed-extension-retire/1:" + binding.getBindingId() + ":" + binding.getGeneration()).getBytes(StandardCharsets.UTF_8)).toString(); + Map proof = transport.extensionStop(binding.getLease(), retirementId).toCompletableFuture().join(); + if (!proof.keySet().equals(Set.of("protocolVersion", "retirementId", "executionMode", "scope", "state", "runtimeInstanceId", "runtimeIncarnation", "leaseId", "epoch")) + || BrokerValues.exactLong(proof.get("protocolVersion")) != 1 + || !retirementId.equals(proof.get("retirementId")) || !mode.equals(proof.get("executionMode")) || !proofScope.equals(proof.get("scope")) + || !"stopped".equals(proof.get("state")) || !binding.getLease().getRuntimeInstanceId().equals(proof.get("runtimeInstanceId")) + || !binding.getProvisionSeed().getGatewayIncarnation().equals(proof.get("runtimeIncarnation")) + || !binding.getLease().getLeaseId().equals(proof.get("leaseId")) || BrokerValues.exactLong(proof.get("epoch")) != binding.getLease().getEpoch()) { + throw LocalRuntimeStore.blocked(); + } + } resource.save(registration.withState(LocalRuntimeStore.State.RETIRED)); if (worker != null) { worker.destroy(); @@ -384,7 +414,7 @@ public final class LocalProcessRuntimeProvisioner "Original worker has not stopped.", true); } return new RuntimeDrainReceipt(binding.getBindingId(), binding.getGeneration(), - binding.getProvisionSeed().getProvisionRequestId(), registration.handle(), Instant.now()); + binding.getProvisionSeed().getProvisionRequestId(), registration.handle(), Instant.now(), mode, proofScope); }), executor); } @@ -579,7 +609,7 @@ public final class LocalProcessRuntimeProvisioner } } - private static Map boot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + private Map boot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { String runtimeInstanceId = seed.getProvisionalRuntimeId(); String runtimeIncarnation = seed.getGatewayIncarnation(); String leaseId = seed.getLeaseId(); @@ -609,7 +639,7 @@ public final class LocalProcessRuntimeProvisioner boot.put("workspaceGeneration", scope.getWorkspaceGeneration()); boot.put("workspaceId", scope.getWorkspaceId()); Map document = request.isManagedContext() - ? ManagedContextProtocol.boot(request, seed) : boot; + ? ManagedContextProtocol.boot(request, seed, com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(scope.getCapabilityDigest()) ? com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.configuration(scope.getCapabilityDigest(), managedExtensionCacheDirectory, managedExtensionCgroupRoot) : null) : boot; byte[] encoded = JsonCodec.encode(document); if (encoded.length > READY_RECORD_LIMIT) { throw new IllegalArgumentException("Managed Runtime boot exceeds 32 KiB."); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/ManagedContextProtocol.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/ManagedContextProtocol.java index 1ef252aa5b4..2d4af5666a4 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/ManagedContextProtocol.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/ManagedContextProtocol.java @@ -57,6 +57,10 @@ public final class ManagedContextProtocol { public static Map boot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + return boot(request, seed, null); + } + + public static Map boot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed, Map extensions) { require(request != null && request.isManagedContext() && seed != null); RuntimeScope scope = request.getScope(); Map boot = new LinkedHashMap<>(); @@ -76,12 +80,19 @@ public final class ManagedContextProtocol { boot.put("mountRoot", scope.getCanonicalCwd()); boot.put("capabilityDigest", scope.getCapabilityDigest()); boot.put("isolationClass", scope.getIsolationClass()); + if (extensions != null) { + boot.put("managedExtensions", extensions); + } validateBoot(boot); return Map.copyOf(boot); } public static void validateBoot(Map boot) { - require(boot.keySet().equals(BOOT_KEYS) + java.util.Set expectedKeys = new java.util.HashSet<>(BOOT_KEYS); + if (boot.containsKey("managedExtensions")) { + expectedKeys.add("managedExtensions"); + } + require(boot.keySet().equals(expectedKeys) && "boot".equals(boot.get("type")) && exactNumber(boot.get("version"), 2) && PROTOCOL.equals(boot.get("managedContext"))); @@ -101,6 +112,18 @@ public final class ManagedContextProtocol { .matches("sha256:[0-9a-f]{64}")); require(Set.of("session", "workspace") .contains(string(boot, "isolationClass"))); + boolean extended = com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(string(boot, "capabilityDigest")); + require(extended == boot.containsKey("managedExtensions")); + if (extended) { + require(boot.get("managedExtensions") instanceof Map); + Map config = (Map) boot.get("managedExtensions"); + if (com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST.equals(string(boot, "capabilityDigest"))) { + require(config.get("cgroupRoot") instanceof String cgroup && java.nio.file.Path.of(cgroup).isAbsolute()); + } + require((config.keySet().equals(Set.of("enabled", "mode", "cacheDirectory")) || config.keySet().equals(Set.of("enabled", "mode", "cacheDirectory", "cgroupRoot"))) && Boolean.TRUE.equals(config.get("enabled")) + && com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.mode(string(boot, "capabilityDigest")).equals(config.get("mode")) + && config.get("cacheDirectory") instanceof String directory && java.nio.file.Path.of(directory).isAbsolute()); + } } static URI ready(Map ready, Map boot) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisioner.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisioner.java index bc568dcc238..30d24c6fad7 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisioner.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisioner.java @@ -49,6 +49,8 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { new ArrayBlockingQueue<>(64), Thread.ofPlatform().daemon().name("runtime-ssh-provisioner-", 0).factory()); private final ConcurrentMap, Owned> owned = new ConcurrentHashMap<>(); private volatile boolean closed; + private String managedExtensionCacheDirectory; + private String managedExtensionCgroupRoot; public SshRuntimeProvisioner(Function nodes, Function storages, SshConnectionManager connections, @@ -59,6 +61,16 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { this.transport = java.util.Objects.requireNonNull(transport, "transport"); } + public SshRuntimeProvisioner withManagedExtensionCacheDirectory(String directory, String cgroupRoot) { + if (directory == null || !java.nio.file.Path.of(directory).isAbsolute()) { + throw new IllegalArgumentException("Extension cache must be absolute"); + } + this.managedExtensionCacheDirectory = directory; + this.managedExtensionCgroupRoot = cgroupRoot; + transport.withManagedExtensionCacheDirectory(directory, cgroupRoot); + return this; + } + @Override public String kind() { return KIND; @@ -174,7 +186,7 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { return binding != null && KIND.equals(binding.getRequest().getProvisionerKind()) && binding.getRequest().isManagedContext() && WorkspaceExecutionProfile.admitsIsolationClass(binding.getRequest().getScope().getIsolationClass()) - && WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(binding.getRequest().getScope().getCapabilityDigest()); + && WorkspaceExecutionProfile.admitsCapabilityDigest(binding.getRequest().getScope().getCapabilityDigest()); } @Override @@ -201,8 +213,17 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { throw stopUnconfirmed(); } RuntimeProvisionSeed seed = binding.getProvisionSeed(); - if (!proof.keySet().equals(PROOF_FIELDS) - || !number(proof.get("version"), 1) + boolean extended = com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(binding.getRequest().getScope().getCapabilityDigest()); + Set proofFields = new java.util.HashSet<>(PROOF_FIELDS); + if (extended) { + proofFields.add("executionMode"); + proofFields.add("scope"); + } + String mode = extended ? com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.mode(binding.getRequest().getScope().getCapabilityDigest()) : null; + String proofScope = extended ? ("linux-cgroup".equals(mode) ? "process_tree" : "process_group") : null; + if (!proof.keySet().equals(proofFields) + || !number(proof.get("version"), extended ? 2 : 1) + || extended && (!mode.equals(proof.get("executionMode")) || !proofScope.equals(proof.get("scope"))) || !text(handle, "resourceId").equals(proof.get("resourceId")) || !binding.getBindingId().equals(proof.get("bindingId")) || !number(proof.get("bindingGeneration"), binding.getGeneration()) @@ -226,7 +247,7 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { connections.forget(binding.getLease()); } return new RuntimeDrainReceipt(binding.getBindingId(), binding.getGeneration(), - seed.getProvisionRequestId(), handle, stoppedAt); + seed.getProvisionRequestId(), handle, stoppedAt, mode, proofScope); }); } @@ -248,12 +269,20 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { } } + private Map contextBoot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + String digest = request.getScope().getCapabilityDigest(); + return ManagedContextProtocol.boot(request, seed, + com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(digest) + ? com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.configuration(digest, + managedExtensionCacheDirectory, managedExtensionCgroupRoot) : null); + } + private RuntimeLease ready(RuntimeProvisionRequest request, RuntimeProvisionSeed seed, RuntimeResourceHandle handle, SshRuntimeNode node, Map result, RuntimeLease lastLease) { if (!(result.get("ready") instanceof Map raw)) { throw conflict(); } - URI endpoint = ManagedContextProtocol.ready(object(raw), ManagedContextProtocol.boot(request, seed)); + URI endpoint = ManagedContextProtocol.ready(object(raw), contextBoot(request, seed)); RuntimeLease lease = new RuntimeLease(seed.getProvisionalRuntimeId(), endpoint, seed.getToken(), seed.getLeaseId(), seed.getEpoch()); if (lastLease != null && (!seed.matches(lastLease) || !lastLease.getEndpoint().equals(lease.getEndpoint()))) { @@ -325,7 +354,7 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { Map registration = object((Map) handle.getValue().get("registration")); command.put("bindingId", seed.getProvisionalRuntimeId()); command.put("bindingGeneration", seed.getEpoch()); - command.put("boot", ManagedContextProtocol.boot(request, seed)); + command.put("boot", contextBoot(request, seed)); command.put("identity", registration.get("identity")); command.put("marker", registration.get("marker")); command.put("expectedQwenVersion", registration.get("qwenVersion")); @@ -354,7 +383,7 @@ public final class SshRuntimeProvisioner implements RuntimeProvisioner { private static void requireRequest(RuntimeProvisionRequest request) { if (request == null || !KIND.equals(request.getProvisionerKind()) || !request.isManagedContext() || !WorkspaceExecutionProfile.admitsIsolationClass(request.getScope().getIsolationClass()) - || !WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(request.getScope().getCapabilityDigest())) { + || !WorkspaceExecutionProfile.admitsCapabilityDigest(request.getScope().getCapabilityDigest())) { throw conflict(); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/HttpRuntimeTransport.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/HttpRuntimeTransport.java index 654d380c26a..172b25da6fa 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/HttpRuntimeTransport.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/HttpRuntimeTransport.java @@ -104,6 +104,70 @@ public final class HttpRuntimeTransport implements RuntimeTransport { private final HttpClient client; private final Duration requestTimeout; private final Function endpointResolver; + private ExtensionPreparation extensionPreparation; + private String managedExtensionCacheDirectory; + private String managedExtensionCgroupRoot; + + public HttpRuntimeTransport withManagedExtensionCacheDirectory(String directory, String cgroupRoot) { + if (directory == null || !java.nio.file.Path.of(directory).isAbsolute()) { + throw new IllegalArgumentException("Extension cache must be absolute"); + } + this.managedExtensionCacheDirectory = directory; + this.managedExtensionCgroupRoot = cgroupRoot; + return this; + } + + private Map contextBoot(RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + String digest = request.getScope().getCapabilityDigest(); + return ManagedContextProtocol.boot(request, seed, + com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.admits(digest) + ? com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.configuration(digest, + managedExtensionCacheDirectory, managedExtensionCgroupRoot) : null); + } + + @FunctionalInterface + public interface ExtensionPreparation { + CompletionStage> prepare(RuntimeBindingRecord runtime, String harnessSessionId, + Map operation); + } + + public void configureExtensionPreparation(ExtensionPreparation preparation) { + this.extensionPreparation = java.util.Objects.requireNonNull(preparation, "preparation"); + } + + public CompletionStage> extensionControl(RuntimeBindingRecord runtime, String harnessSessionId, + ContextBinding binding, Map operation) { + ManagedExtensionProtocol.validateOperation(operation); + Map immutable = BrokerValues.immutableMap(operation); + CompletionStage> prepared; + if ("extension-prepare".equals(immutable.get("kind"))) { + if (extensionPreparation == null) { + return CompletableFuture.failedFuture(new RuntimeBrokerException(501, + "managed_extension_unavailable", "Managed extension package preparation is unavailable.", false)); + } + prepared = extensionPreparation.prepare(runtime, harnessSessionId, immutable); + } else { + prepared = CompletableFuture.completedFuture(immutable); + } + var scope = runtime.getRequest().getScope(); + Map sessionKey = Map.of("tenantId", scope.getTenantId(), "workspaceId", scope.getWorkspaceId(), + "sessionId", harnessSessionId); + Map context = Map.of("tenantId", binding.getTenantId(), "workspaceId", binding.getWorkspaceId(), + "workspaceGeneration", Long.toString(binding.getWorkspaceGeneration()), "storageId", binding.getStorageId(), + "cwdRelative", binding.getCwdRelative(), "contextConfigRef", binding.getContextConfigRef(), + "contextRevision", Long.toString(binding.getContextRevision())); + return prepared.thenCompose(privateOperation -> post(runtime.getLease(), ManagedExtensionProtocol.PATH, + encodeToolRequest(Map.of("protocolVersion", 1, "sessionKey", sessionKey, "contextBinding", context, + "operation", privateOperation), ManagedExtensionProtocol.REQUEST_LIMIT), ManagedExtensionProtocol.RESPONSE_LIMIT)) + .thenApply(bytes -> ManagedExtensionProtocol.response(bytes, scope, harnessSessionId, immutable)); + } + + public CompletionStage> extensionStop(RuntimeLease lease, String retirementId) { + BrokerValues.requireId(retirementId, "retirementId"); + return post(lease, ManagedExtensionProtocol.PATH + "/retire", + encodeToolRequest(Map.of("protocolVersion", 1, "retirementId", retirementId), BODY_LIMIT_BYTES), BODY_LIMIT_BYTES) + .thenApply(ManagedContextProtocol::parse); + } public HttpRuntimeTransport() { this(HttpClient.newBuilder() @@ -296,7 +360,7 @@ public final class HttpRuntimeTransport implements RuntimeTransport { if (files && client.followRedirects() != HttpClient.Redirect.NEVER) { throw new IllegalArgumentException("CSI file attestation requires redirects disabled."); } - Map boot = ManagedContextProtocol.boot(request, seed); + Map boot = contextBoot(request, seed); Map body = ManagedContextProtocol.attestationRequest(boot); return post(lease, files ? ManagedCsiFilesProtocol.CONTEXT_ATTEST_PATH : ManagedContextProtocol.ATTEST_PATH, encodeToolRequest(files ? ManagedCsiFilesProtocol.wrapContext(request, body) : body, @@ -403,7 +467,7 @@ public final class HttpRuntimeTransport implements RuntimeTransport { || client.followRedirects() != HttpClient.Redirect.NEVER)) { throw new IllegalArgumentException("CSI file context must belong to the original Session profile."); } - ManagedContextProtocol.boot(request, seed); + contextBoot(request, seed); Map body = ManagedContextProtocol.installation(request, operationId, sessionId, binding); Map expected = ManagedContextProtocol.receipt(seed, @@ -434,7 +498,7 @@ public final class HttpRuntimeTransport implements RuntimeTransport { || !(ResidentWorkspaceProfile.selects(request) || (HarnessOwnership.sharesRuntimeSessions(request) || session.getHarnessSessionId().equals(request.getIsolationKey())) - && WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(session.getScope().getCapabilityDigest())) + && WorkspaceExecutionProfile.admitsCapabilityDigest(session.getScope().getCapabilityDigest())) || !WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals( binding.getContextConfigRef())) { throw new IllegalArgumentException("Workspace activation identity is invalid"); @@ -486,7 +550,9 @@ public final class HttpRuntimeTransport implements RuntimeTransport { body.put("protocolVersion", 2); body.put("reference", referenceIdentity(reference)); body.put("toolName", referenceToolName(reference)); - body.put("input", referenceInput(reference)); + Object input = referenceInput(reference); + ManagedExtensionProtocol.requireBoundInput(session, input); + body.put("input", input); byte[] encoded = encodeToolRequest(body, TOOL_REQUEST_LIMIT_BYTES); return post(lease, EXECUTE_PATH, encoded, TOOL_RESULT_LIMIT_BYTES) .thenApply(bytes -> { @@ -615,7 +681,9 @@ public final class HttpRuntimeTransport implements RuntimeTransport { } Map body = v3Body(reference); body.put("toolName", referenceToolName(reference)); - body.put("input", referenceInput(reference)); + Object input = referenceInput(reference); + ManagedExtensionProtocol.requireBoundInput(session, input); + body.put("input", input); body.put("capture", capture); return post(lease, V3_EXECUTE_PATH, encodeToolRequest(body, TOOL_REQUEST_LIMIT_BYTES), @@ -655,6 +723,7 @@ public final class HttpRuntimeTransport implements RuntimeTransport { requireClosed(capture, V3_CAPTURE_REQUEST_FIELDS, "capture"); Map body = v3Body(reference); body.put("toolName", payload.get("toolName")); + ManagedExtensionProtocol.requireBoundInput(session, payload.get("input")); body.put("input", payload.get("input")); body.put("capture", capture); return post(lease, V3_EXECUTE_PATH, @@ -863,6 +932,56 @@ public final class HttpRuntimeTransport implements RuntimeTransport { .thenApply(ManagedMcpProtocol::definitions); } + public CompletionStage> installExtensionPackage(RuntimeBindingRecord runtime, + String digest, java.nio.file.Path archive) { + if (runtime == null || runtime.getLease() == null || digest == null || !digest.matches("[a-f0-9]{64}")) { + throw new IllegalArgumentException("An original Runtime and extension archive digest are required"); + } + HttpRequest.BodyPublisher body; + try { + long length = java.nio.file.Files.size(archive); + if (length < 1 || length > 100L * 1024 * 1024 + || !java.nio.file.Files.isRegularFile(archive, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("Extension archive exceeds its limit"); + } + body = HttpRequest.BodyPublishers.ofFile(archive); + } catch (java.io.IOException error) { + return CompletableFuture.failedFuture(unavailable(error)); + } + RuntimeLease lease = runtime.getLease(); + HttpRequest request = HttpRequest.newBuilder(endpoint(lease).resolve( + "/internal/managed-runtime/v3/extensions/packages/" + digest)) + .timeout(Duration.ofMinutes(5)) + .header("Authorization", "Bearer " + lease.getToken()) + .header("Cache-Control", "no-store") + .header("Content-Type", "application/gzip") + .header("X-Qwen-Managed-Lease-Id", lease.getLeaseId()) + .header("X-Qwen-Managed-Lease-Epoch", Long.toString(lease.getEpoch())) + .PUT(body).build(); + var exchange = client.sendAsync(request, info -> new BoundedBodySubscriber(BODY_LIMIT_BYTES)); + var result = exchange.orTimeout(5, TimeUnit.MINUTES).thenApply(response -> { + if (response.statusCode() != 200 || response.body().overflow() + || !"no-store".equals(response.headers().firstValue("Cache-Control").orElse("")) + || !jsonContentType(response.headers().firstValue("Content-Type").orElse("")) + || response.headers().firstValue("Content-Encoding").isPresent()) { + throw protocol("Extension package installation was not confirmed."); + } + Map receipt = JsonCodec.parseObject(response.body().bytes(), "extension package receipt"); + if (!receipt.keySet().equals(Set.of("protocolVersion", "packageDigest")) + || !Integer.valueOf(1).equals(receipt.get("protocolVersion")) + || !digest.equals(receipt.get("packageDigest"))) { + throw protocol("Extension package installation digest conflicts."); + } + return receipt; + }); + result.whenCompleteAsync((value, error) -> { + if (error != null || result.isCancelled()) { + exchange.cancel(true); + } + }); + return result; + } + /** Session acquisition is Broker-local until the provider is used. */ @Override public CompletionStage acquire(RuntimeLease lease, @@ -880,6 +999,17 @@ public final class HttpRuntimeTransport implements RuntimeTransport { throw new IllegalArgumentException("lease and session are required"); } Map immutable = BrokerValues.immutableMap(operation); + if (ManagedExtensionProtocol.isOperation(immutable)) { + return CompletableFuture.failedFuture(new RuntimeBrokerException(400, + "runtime_control_operation_invalid", "Extensions require the metadata control route.", false)); + } + if (ManagedExtensionCommandProtocol.isOperation(immutable)) { + ManagedExtensionCommandProtocol.validateSession(session, immutable); + return post(lease, ManagedExtensionCommandProtocol.PATH, + encodeToolRequest(Map.of("protocolVersion", 1, "runtimeSessionId", session.getRuntimeSessionId(), "operation", immutable), + TOOL_REQUEST_LIMIT_BYTES), TOOL_RESULT_LIMIT_BYTES) + .thenApply(bytes -> ManagedExtensionCommandProtocol.response(bytes, session, immutable)); + } if (ManagedMcpProtocol.isOperation(immutable)) { ManagedMcpProtocol.validateSession(session, immutable); Map body = Map.of("protocolVersion", 1, diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionCommandProtocol.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionCommandProtocol.java new file mode 100644 index 00000000000..f0aa4885164 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionCommandProtocol.java @@ -0,0 +1,68 @@ +package com.alibaba.qwen.code.runtimebroker.transport; + +import com.alibaba.qwen.code.runtimebroker.BrokerValues; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.provisioning.ManagedContextProtocol; + +import java.util.Map; +import java.util.Set; + +/** Extension preprocessing retains the ordinary tool Session and its workspace mount. */ +public final class ManagedExtensionCommandProtocol { + static final String PATH = "/internal/managed-runtime/v3/extensions/commands"; + + private ManagedExtensionCommandProtocol() { } + + public static boolean isOperation(Map operation) { + return "extension-command".equals(operation.get("kind")); + } + + public static boolean isRecovery(Map operation) { + return isOperation(operation) && !"execute".equals(operation.get("action")); + } + + public static void validateSession(RuntimeSession session, Map operation) { + var scope = session.getScope(); + Long revision = BrokerValues.exactLong(operation.get("revision")); + Map expected = Map.of("tenantId", scope.getTenantId(), "workspaceId", scope.getWorkspaceId(), + "sessionId", session.getHarnessSessionId()); + if (!BrokerValues.isWellFormedJson(operation) || !isOperation(operation) + || !ManagedExtensionExecutionProfile.admits(scope.getCapabilityDigest()) + || !operation.keySet().equals(Set.of("kind", "operationId", "action", "grant", "toolName", "input", "revision", "extensionId")) + || !(operation.get("operationId") instanceof String id) || id.isBlank() || id.length() > 512 + || !Set.of("execute", "status", "cancel").contains(String.valueOf(operation.get("action"))) + || !Set.of("read_file", "shell").contains(String.valueOf(operation.get("toolName"))) + || !(operation.get("input") instanceof Map) || revision == null || revision < 1 || revision > 9_007_199_254_740_991L + || !(operation.get("extensionId") instanceof String extensionId) || !extensionId.matches("ext_[a-f0-9-]{36}") + || !(operation.get("grant") instanceof Map grant) || !expected.equals(grant.get("sessionKey")) + || !"tool_stage".equals(grant.get("domain")) || !session.getRuntimeSessionId().equals(grant.get("ownerId")) + || !scope.getWorkspaceGeneration().equals(grant.get("workspaceGeneration"))) { + throw invalid(); + } + } + + static Map response(byte[] bytes, RuntimeSession session, Map request) { + var envelope = ManagedContextProtocol.parse(bytes); + if (!envelope.keySet().equals(Set.of("protocolVersion", "runtimeSessionId", "result")) + || !Integer.valueOf(1).equals(envelope.get("protocolVersion")) + || !session.getRuntimeSessionId().equals(envelope.get("runtimeSessionId")) + || !(envelope.get("result") instanceof Map result) + || !Set.of("operationId", "state", "result", "receipt").containsAll(result.keySet()) + || !request.get("operationId").equals(result.get("operationId")) + || !(result.get("state") instanceof String state) || !Set.of("settled", "running", "outcome_unknown").contains(state) + || result.containsKey("result") && !(result.get("result") instanceof Map) + || result.containsKey("receipt") && !(result.get("receipt") instanceof Map) + || !"settled".equals(state) && result.containsKey("result")) { + throw new RuntimeBrokerException(502, "managed_extension_command_response_invalid", "Invalid extension command response.", false); + } + @SuppressWarnings("unchecked") + var value = (Map) envelope.get("result"); + return BrokerValues.immutableMap(value); + } + + private static RuntimeBrokerException invalid() { + return new RuntimeBrokerException(400, "runtime_control_operation_invalid", "Invalid extension command control.", false); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocol.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocol.java new file mode 100644 index 00000000000..0b74feb416d --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocol.java @@ -0,0 +1,232 @@ +package com.alibaba.qwen.code.runtimebroker.transport; + +import com.alibaba.qwen.code.runtimebroker.BrokerValues; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.provisioning.ManagedContextProtocol; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Narrow Hosted controls; executable settings are added only by the Java preparation gateway. */ +public final class ManagedExtensionProtocol { + static final String PATH = "/internal/managed-runtime/v3/extensions"; + static final int REQUEST_LIMIT = 512 * 1024; + static final int RESPONSE_LIMIT = 8 * 1024 * 1024; + private static final Set KINDS = Set.of("extension-prepare", "extension-activate", "extension-status", "extension-reconcile", + "extension-asset-read", "extension-asset-glob"); + + private ManagedExtensionProtocol() { } + + public static boolean isOperation(Map operation) { + return operation.get("kind") instanceof String kind && KINDS.contains(kind); + } + + public static boolean isRecovery(Map operation) { + return "extension-status".equals(operation.get("kind")) || "extension-reconcile".equals(operation.get("kind")); + } + + public static void requireBoundInput(RuntimeSession session, Object input) { + if (!(input instanceof Map fields) || !fields.containsKey("managedExtension")) { + return; + } + if (!ManagedExtensionExecutionProfile.admits(session.getScope().getCapabilityDigest()) + || !(fields.get("managedExtension") instanceof Map extension) + || !extension.keySet().equals(Set.of("harnessSessionId", "configRevision", "pin")) + || !session.getHarnessSessionId().equals(extension.get("harnessSessionId")) + || !counter(extension.get("configRevision"), 1) + || !(extension.get("pin") instanceof Map pin) + || !pin.keySet().containsAll(Set.of("extensionId", "packageRevision", "packageDigest")) + || !Set.of("extensionId", "packageRevision", "packageDigest", "settingsRevision").containsAll(pin.keySet()) + || !(pin.get("extensionId") instanceof String id) || !id.matches("ext_[a-f0-9-]{36}") + || !counter(pin.get("packageRevision"), 1) || !digest(pin.get("packageDigest")) + || pin.containsKey("settingsRevision") && !counter(pin.get("settingsRevision"), 1)) { + throw invalid(); + } + } + + public static void validateOperation(Map operation) { + if (!BrokerValues.isWellFormedJson(operation) || !isOperation(operation) + || !(operation.get("operationId") instanceof String operationId) + || !operationId.matches("(?i)[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}")) { + throw invalid(); + } + String kind = (String) operation.get("kind"); + if ("extension-prepare".equals(kind)) { + if (!operation.keySet().equals(Set.of("kind", "operationId", "command", "expectedRevision", "extensions", + "baseMcpServers", "baseHookCatalog")) + || !(operation.get("command") instanceof Map command) + || !command.keySet().containsAll(Set.of("operationId", "actorKey", "expectedRevision", "extensions")) + || !Set.of("operationId", "actorKey", "expectedRevision", "extensions", "mcpChange").containsAll(command.keySet()) + || !operationId.equals(command.get("operationId")) + || !(command.get("actorKey") instanceof String actor) || !actor.matches("[a-f0-9]{64}") + || !counter(operation.get("expectedRevision"), 0) + || BrokerValues.exactLong(operation.get("expectedRevision")) == 9_007_199_254_740_991L + || !java.util.Objects.equals(operation.get("expectedRevision"), command.get("expectedRevision")) + || !java.util.Objects.equals(operation.get("extensions"), command.get("extensions")) + || !(operation.get("extensions") instanceof List pins) || pins.size() > 32 + || !(operation.get("baseMcpServers") instanceof List servers) || servers.size() > 32 + || operation.get("baseHookCatalog") != null && !(operation.get("baseHookCatalog") instanceof Map)) { + throw invalid(); + } + Set identities = new HashSet<>(); + for (Object item : pins) { + if (!(item instanceof Map pin) + || !Set.of("extensionId", "packageRevision", "packageDigest", "settingsRevision").containsAll(pin.keySet()) + || !(pin.get("extensionId") instanceof String id) || !id.matches("ext_[a-f0-9-]{36}") + || !identities.add(id) || !counter(pin.get("packageRevision"), 1) + || !(pin.get("packageDigest") instanceof String digest) || !digest.matches("[a-f0-9]{64}") + || pin.containsKey("settingsRevision") && !counter(pin.get("settingsRevision"), 1)) { + throw invalid(); + } + } + mcpPins(servers, false); + if (operation.get("baseHookCatalog") instanceof Map hook + && (!hook.keySet().equals(Set.of("catalogId", "catalogRevision", "definitionDigest")) + || !identity(hook.get("catalogId")) || !counter(hook.get("catalogRevision"), 1) + || !digest(hook.get("definitionDigest")))) { + throw invalid(); + } + if (command.containsKey("mcpChange")) { + mcpChange(command.get("mcpChange")); + } + } else if ("extension-activate".equals(kind) || "extension-reconcile".equals(kind)) { + if (!operation.keySet().equals(Set.of("kind", "operationId", "expectedRevision", "revision")) + || !counter(operation.get("expectedRevision"), 0) || !counter(operation.get("revision"), 1) + || ((Number) operation.get("revision")).longValue() != ((Number) operation.get("expectedRevision")).longValue() + 1) { + throw invalid(); + } + } else if ("extension-status".equals(kind)) { + if (!operation.keySet().equals(Set.of("kind", "operationId"))) { + throw invalid(); + } + } else { + String field = "extension-asset-read".equals(kind) ? "path" : "pattern"; + if (!operation.keySet().equals(Set.of("kind", "operationId", "revision", "extensionId", field)) + || !counter(operation.get("revision"), 1) + || !(operation.get("extensionId") instanceof String id) || !id.matches("ext_[a-f0-9-]{36}") + || !(operation.get(field) instanceof String path) || path.isEmpty() || path.length() > 4096 + || path.indexOf('\0') >= 0 || path.startsWith("/") || path.contains("\\") + || java.util.Arrays.stream(path.split("/", -1)).anyMatch(segment -> segment.equals(".."))) { + throw invalid(); + } + } + } + + static Map response(byte[] bytes, RuntimeScope scope, String harnessSessionId, Map request) { + Map envelope = ManagedContextProtocol.parse(bytes); + Map sessionKey = Map.of("tenantId", scope.getTenantId(), "workspaceId", scope.getWorkspaceId(), + "sessionId", harnessSessionId); + if (!envelope.keySet().equals(Set.of("protocolVersion", "sessionKey", "result")) + || !Integer.valueOf(1).equals(envelope.get("protocolVersion")) + || !sessionKey.equals(envelope.get("sessionKey")) + || !(envelope.get("result") instanceof Map result) + || !request.get("operationId").equals(result.get("operationId"))) { + throw invalidResponse(); + } + if (!String.valueOf(request.get("kind")).startsWith("extension-asset-")) { + if (!(result.get("state") instanceof String state) + || !("extension-reconcile".equals(request.get("kind")) + ? Set.of("stopped", "outcome_unknown") : Set.of("prepared", "active", "failed", "outcome_unknown")).contains(state) + || !counter(result.get("revision"), 0) + || !(result.get("executionMode") instanceof String mode) + || !ManagedExtensionExecutionProfile.mode(scope.getCapabilityDigest()).equals(mode)) { + throw invalidResponse(); + } + if (("extension-activate".equals(request.get("kind")) || "extension-reconcile".equals(request.get("kind"))) + && !java.util.Objects.equals(BrokerValues.exactLong(request.get("revision")), BrokerValues.exactLong(result.get("revision")))) { + throw invalidResponse(); + } + if ("extension-reconcile".equals(request.get("kind")) && "stopped".equals(result.get("state"))) { + if (!(result.get("receipts") instanceof List receipts) || receipts.isEmpty() || receipts.size() > 100_000) { + throw invalidResponse(); + } + Set owners = new HashSet<>(); + boolean coverage = false; + for (Object receipt : receipts) { + if (!(receipt instanceof Map proof) + || !proof.keySet().containsAll(Set.of("ownerId", "sessionId", "configRevision", "mode", "scope", "state")) + || !Set.of("ownerId", "sessionId", "configRevision", "mode", "scope", "state", "coverage", "journalId").containsAll(proof.keySet()) + || !(proof.get("ownerId") instanceof String id) || id.isBlank() || !owners.add(id) + || !harnessSessionId.equals(proof.get("sessionId")) || !counter(proof.get("configRevision"), 1) + || BrokerValues.exactLong(proof.get("configRevision")) > BrokerValues.exactLong(result.get("revision")) + || !result.get("executionMode").equals(proof.get("mode")) + || !("linux-cgroup".equals(proof.get("mode")) ? "process_tree" : "process_group").equals(proof.get("scope")) + || !"stopped".equals(proof.get("state"))) { + throw invalidResponse(); + } + if (proof.containsKey("coverage") || proof.containsKey("journalId")) { + if (coverage || !"registered-owners".equals(proof.get("coverage")) + || !(proof.get("journalId") instanceof String journalId) || journalId.isBlank()) { + throw invalidResponse(); + } + coverage = true; + } + } + if (!coverage) { + throw invalidResponse(); + } + } + } + @SuppressWarnings("unchecked") + Map value = (Map) result; + return BrokerValues.immutableMap(value); + } + + private static boolean counter(Object value, long minimum) { + Long number = BrokerValues.exactLong(value); + return number != null && number >= minimum && number <= 9_007_199_254_740_991L; + } + + private static void mcpChange(Object value) { + if (!(value instanceof Map change) + || !change.keySet().equals(Set.of("action", "serverId", "expectedRevision", "servers")) + || !Set.of("add", "edit", "remove", "enable", "disable", "reload", "restart").contains(String.valueOf(change.get("action"))) + || !identity(change.get("serverId")) || !counter(change.get("expectedRevision"), 0) + || !(change.get("servers") instanceof List servers) || servers.size() > 32) { + throw invalid(); + } + mcpPins(servers, true); + if (servers.stream().noneMatch(item -> change.get("serverId").equals(((Map) item).get("serverId")))) { + throw invalid(); + } + } + + private static void mcpPins(List servers, boolean selection) { + Set ids = new HashSet<>(); + int enabled = 0; + for (Object item : servers) { + if (!(item instanceof Map pin) + || !pin.keySet().equals(selection ? Set.of("serverId", "serverRevision", "definitionDigest", "state") + : Set.of("serverId", "serverRevision", "definitionDigest")) + || !identity(pin.get("serverId")) || !ids.add((String) pin.get("serverId")) + || !counter(pin.get("serverRevision"), 1) || !digest(pin.get("definitionDigest")) + || selection && !Set.of("enabled", "disabled", "removed").contains(String.valueOf(pin.get("state")))) { + throw invalid(); + } + if ("enabled".equals(pin.get("state")) && ++enabled > 16) { + throw invalid(); + } + } + } + + private static boolean identity(Object value) { + return value instanceof String text && text.matches("[A-Za-z0-9._:-]{1,128}"); + } + + private static boolean digest(Object value) { + return value instanceof String text && text.matches("[a-f0-9]{64}"); + } + + private static RuntimeBrokerException invalidResponse() { + return new RuntimeBrokerException(502, "managed_extension_response_invalid", "Invalid Managed extension Runtime response.", false); + } + + private static RuntimeBrokerException invalid() { + return new RuntimeBrokerException(400, "runtime_control_operation_invalid", "Invalid Managed extension control.", false); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/RuntimeBrokerHttpServer.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/RuntimeBrokerHttpServer.java index ae8f86d186a..e6999ef03ec 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/RuntimeBrokerHttpServer.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/transport/RuntimeBrokerHttpServer.java @@ -217,6 +217,29 @@ public final class RuntimeBrokerHttpServer implements AutoCloseable { throw notFound(); } String relative = path.substring(ROUTE_PREFIX.length()); + if ("POST".equals(exchange.getRequestMethod()) && relative.startsWith("/harness-sessions/") + && relative.endsWith("/extensions/control")) { + String sessionId = pathId(relative.substring("/harness-sessions/".length(), + relative.length() - "/extensions/control".length())); + Map body = requestBody(exchange, "extension metadata control"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "extension metadata control"); + if (!sessionId.equals(JsonCodec.requiredString(body, "harnessSessionId", "extension metadata control")) + || lifecycleAuthority(exchange) != null + || !Set.of("protocolVersion", "requestId", "harnessSessionId", "operation", + "expectedRuntimeBindingId", "expectedRuntimeGeneration").containsAll(body.keySet()) + || body.containsKey("expectedRuntimeBindingId") != body.containsKey("expectedRuntimeGeneration")) { + throw new IllegalArgumentException("Invalid extension metadata control"); + } + String binding = body.containsKey("expectedRuntimeBindingId") + ? JsonCodec.requiredString(body, "expectedRuntimeBindingId", "extension metadata control") : null; + Long generation = body.containsKey("expectedRuntimeGeneration") + ? Long.parseLong(JsonCodec.requiredString(body, "expectedRuntimeGeneration", "extension metadata control")) : null; + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + complete(exchange, service.extensionControl(sessionId, binding, generation, + requiredObject(body, "operation", "extension metadata control")), result -> result); + return; + } if ("POST".equals(exchange.getRequestMethod()) && relative.startsWith("/harness-sessions/") && relative.endsWith("/mcp-definitions")) { String sessionId = pathId(relative.substring("/harness-sessions/".length(), diff --git a/packages/managed-agent-server/src/main/resources/application.yml b/packages/managed-agent-server/src/main/resources/application.yml index 972e71e0b22..ff6a834269b 100644 --- a/packages/managed-agent-server/src/main/resources/application.yml +++ b/packages/managed-agent-server/src/main/resources/application.yml @@ -33,6 +33,15 @@ management: qwen: managed-agent: + extensions: + enabled: '${QWEN_MANAGED_AGENT_EXTENSION_PACKAGES_ENABLED:false}' + artifact-directory: '${QWEN_MANAGED_AGENT_EXTENSION_ARTIFACT_DIRECTORY:}' + allowed-actors: '${QWEN_MANAGED_AGENT_EXTENSION_ALLOWED_ACTORS:}' + managed-extensions: + enabled: '${QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_ENABLED:false}' + mode: '${QWEN_MANAGED_AGENT_EXTENSION_EXECUTION_MODE:linux-cgroup}' + cache-directory: '${QWEN_MANAGED_AGENT_EXTENSION_CACHE_DIRECTORY:}' + cgroup-root: '${QWEN_MANAGED_AGENT_EXTENSION_CGROUP_ROOT:}' agent-revision: '${QWEN_MANAGED_AGENT_REVISION:1}' definition-models: '${QWEN_MANAGED_AGENT_DEFINITION_MODELS:}' definition-tool-profiles: '${QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES:hosted-workspace-files/1}' diff --git a/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql b/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql index 5d1c6887c73..760e5033356 100644 --- a/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql +++ b/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql @@ -800,6 +800,64 @@ CREATE TABLE ma_workspace_access ( CONSTRAINT ck_ma_access_role CHECK (CASE role WHEN 'READER' THEN 6 WHEN 'OPERATOR' THEN 8 WHEN 'OWNER' THEN 5 ELSE -1 END = CHAR_LENGTH(role)) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; +CREATE TABLE ma_extension_package ( + package_key CHAR(64) NOT NULL, + scope_key CHAR(64) NOT NULL, + tenant_id VARBINARY(512) NOT NULL, + workspace_id VARBINARY(512) NOT NULL, + extension_id VARBINARY(128) NOT NULL, + package_revision BIGINT NOT NULL, + package_digest CHAR(64) NOT NULL, + manifest_json LONGTEXT NOT NULL, + byte_length BIGINT NOT NULL, + state VARCHAR(16) NOT NULL, + gmt_create BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (package_key), + CONSTRAINT uq_ma_extension_package UNIQUE (scope_key, extension_id, package_revision), + FOREIGN KEY (tenant_id, workspace_id) REFERENCES ma_workspace (tenant_id, workspace_id), + CONSTRAINT ck_ma_extension_package_revision CHECK (package_revision > 0), + CONSTRAINT ck_ma_extension_package_bytes CHECK (byte_length >= 0 AND byte_length <= 104857600), + CONSTRAINT ck_ma_extension_package_state CHECK (CASE state WHEN 'PREPARING' THEN 9 WHEN 'PUBLISHED' THEN 9 ELSE -1 END = CHAR_LENGTH(state)) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE ma_extension_settings ( + settings_key CHAR(64) NOT NULL, + scope_key CHAR(64) NOT NULL, + tenant_id VARBINARY(512) NOT NULL, + workspace_id VARBINARY(512) NOT NULL, + extension_id VARBINARY(128) NOT NULL, + settings_revision BIGINT NOT NULL, + encryption_key_id VARBINARY(128) NOT NULL, + encrypted_values LONGTEXT NOT NULL, + configured_keys_json LONGTEXT NOT NULL, + gmt_create BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (settings_key), + CONSTRAINT uq_ma_extension_settings UNIQUE (scope_key, extension_id, settings_revision), + FOREIGN KEY (tenant_id, workspace_id) REFERENCES ma_workspace (tenant_id, workspace_id), + CONSTRAINT ck_ma_extension_settings_revision CHECK (settings_revision > 0) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE ma_extension_operation ( + operation_key CHAR(64) NOT NULL, + scope_key CHAR(64) NOT NULL, + tenant_id VARBINARY(512) NOT NULL, + workspace_id VARBINARY(512) NOT NULL, + actor_key CHAR(64) NOT NULL, + operation_id VARCHAR(128) NOT NULL, + extension_id VARBINARY(128) NOT NULL, + operation_kind VARCHAR(16) NOT NULL, + revision BIGINT NOT NULL, + package_digest CHAR(64), + request_digest CHAR(64) NOT NULL, + state VARCHAR(16) NOT NULL, + gmt_create BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (operation_key), + FOREIGN KEY (tenant_id, workspace_id) REFERENCES ma_workspace (tenant_id, workspace_id), + CONSTRAINT ck_ma_extension_operation_revision CHECK (revision > 0), + CONSTRAINT ck_ma_extension_operation_kind CHECK (CASE operation_kind WHEN 'publish' THEN 7 WHEN 'settings' THEN 8 ELSE -1 END = CHAR_LENGTH(operation_kind)), + CONSTRAINT ck_ma_extension_operation_state CHECK (CASE state WHEN 'pending' THEN 7 WHEN 'settled' THEN 7 ELSE -1 END = CHAR_LENGTH(state)) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + CREATE TABLE ma_storage ( alias_key CHAR(64) NOT NULL, tenant_key CHAR(64) NOT NULL, diff --git a/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 11db22d0e18..7ff2aa668c5 100644 --- a/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Qwen Managed Agent Public and WebShell API", - "version": "1.92.2", + "version": "1.93.0", "description": "Canonical first-release contract for the public Managed Agent resources and the WebShell adapter. Implemented routes and fields describe the current matching Server, Broker, Harness and WebShell build. Partial entries have remaining implementation or contract-test work; planned entries are not current server capabilities and must be excluded from production SDKs. Turn submission and cancellation always return the durable operation identifier and its persisted admission and delivery state. Session-list cursors are bound to the normalized archive state and title query. Earlier development API shapes, checkpoints and database catalogs are unsupported; initialize fresh state for this build. Runtime feature capabilities and deployment policy still govern optional execution modes." }, "servers": [ @@ -5593,6 +5593,1393 @@ }, "x-qwen-implementation-status": "partial" } + }, + "/v1/agents/workspaces/{workspaceId}/extensions": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "listWorkspaceExtensionPackages", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageCatalog" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + } + }, + "post": { + "tags": ["Public Resources"], + "operationId": "createWorkspaceExtensionPackage", + "x-qwen-implementation-status": "implemented", + "description": "Requires an ACTIVE Workspace, an authenticated operator and the deployment extension allowlist. The Idempotency-Key binds an actor-scoped durable operation. Publication retains immutable tar.gz bytes under a server-assigned stable extension identity; it does not activate a Session. The manifest query is base64url JSON and is covered by the existing request signature. Archive contents and Runtime compatibility are validated by the target compiler before activation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "packageDigest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + { + "name": "manifest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Base64url-encoded ManagedExtensionPackageManifest JSON. Canonical schemaVersion/compilerVersion are 1." + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary", + "maxLength": 104857600 + } + } + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageDigest}": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "extensionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "packageDigest", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + ], + "put": { + "tags": ["Public Resources"], + "operationId": "publishWorkspaceExtensionPackage", + "x-qwen-implementation-status": "implemented", + "description": "Requires an ACTIVE Workspace, an authenticated operator and the deployment extension allowlist. The Idempotency-Key binds an actor-scoped durable operation. Publication retains immutable tar.gz bytes under a server-assigned stable extension identity; it does not activate a Session. The manifest query is base64url JSON and is covered by the existing request signature. Archive contents and Runtime compatibility are validated by the target compiler before activation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "manifest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Base64url-encoded ManagedExtensionPackageManifest JSON. Canonical schemaVersion/compilerVersion are 1." + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary", + "maxLength": 104857600 + } + } + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "extensionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "packageRevision", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "packageDigest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "getWorkspaceExtensionPackage", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}/archive": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "extensionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "packageRevision", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "packageDigest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "readWorkspaceExtensionArchive", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "The exact immutable archive named by the pin. Private no-store binary stream, bounded to 100 MiB.", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "extensionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "getWorkspaceExtensionPackageSettings", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + } + }, + "post": { + "tags": ["Public Resources"], + "operationId": "setWorkspaceExtensionPackageSettings", + "x-qwen-implementation-status": "implemented", + "description": "Requires the same Workspace operator and deployment permission as publication. Writes a complete encrypted settings revision with optimistic concurrency; old pinned revisions remain available. No values are returned.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageSettingsCommand" + } + } + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/extensions/operations/{operationId}": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "getWorkspaceExtensionPackageOperation", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. The querying actor must own the original publication or settings operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/query": { + "post": { + "tags": ["WebShell"], + "operationId": "queryWorkspaceExtensionPackages", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageCatalog" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionWorkspaceQuery" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/packages/query": { + "post": { + "tags": ["WebShell"], + "operationId": "queryWorkspaceExtensionPackage", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionPackageQuery" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/settings/query": { + "post": { + "tags": ["WebShell"], + "operationId": "queryWorkspaceExtensionPackageSettings", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionQuery" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/settings/submit": { + "post": { + "tags": ["WebShell"], + "operationId": "submitWorkspaceExtensionPackageSettings", + "x-qwen-implementation-status": "implemented", + "description": "Requires the same Workspace operator and deployment permission as publication. Writes a complete encrypted settings revision with optimistic concurrency; old pinned revisions remain available. No values are returned.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionSettingsSubmit" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/operations/query": { + "post": { + "tags": ["WebShell"], + "operationId": "queryWorkspaceExtensionPackageOperation", + "x-qwen-implementation-status": "implemented", + "description": "Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. The querying actor must own the original publication or settings operation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionOperationQuery" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/workspace-extensions/publish": { + "post": { + "tags": ["WebShell"], + "operationId": "publishWebShellWorkspaceExtensionPackage", + "x-qwen-implementation-status": "implemented", + "description": "Requires an ACTIVE Workspace, an authenticated operator and the deployment extension allowlist. The Idempotency-Key binds an actor-scoped durable operation. Publication retains immutable tar.gz bytes under a server-assigned stable extension identity; it does not activate a Session. The manifest query is base64url JSON and is covered by the existing request signature. Archive contents and Runtime compatibility are validated by the target compiler before activation.", + "responses": { + "200": { + "description": "Persisted Workspace extension resource. Contains no settings values or executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionPackageOperation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "extensionId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "packageDigest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + { + "name": "manifest", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Base64url-encoded ManagedExtensionPackageManifest JSON. Canonical schemaVersion/compilerVersion are 1." + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary", + "maxLength": 104857600 + } + } + } + } + } + }, + "/v1/agents/sessions/{sessionId}/extensions": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "getSessionExtensionConfiguration", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, + "post": { + "tags": ["Public Resources"], + "operationId": "applySessionExtensionConfiguration", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionApplySelection" + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + }, + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "tags": ["Public Resources"], + "operationId": "getSessionExtensionOperation", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/continue": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + }, + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "tags": ["Public Resources"], + "operationId": "continueSessionExtensionOperation", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/recover-previous": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + }, + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "tags": ["Public Resources"], + "operationId": "recoverPreviousSessionExtensions", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/api/agent/web-shell/v1/session-extensions/query": { + "post": { + "tags": ["WebShell"], + "operationId": "webShellSessionExtensionsQuery", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionSessionQuery" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/session-extensions/submit": { + "post": { + "tags": ["WebShell"], + "operationId": "webShellSessionExtensionsSubmit", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionSessionSubmit" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/session-extensions/continue": { + "post": { + "tags": ["WebShell"], + "operationId": "webShellSessionExtensionsContinue", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionSessionOperation" + } + } + } + } + } + }, + "/api/agent/web-shell/v1/session-extensions/recover-previous": { + "post": { + "tags": ["WebShell"], + "operationId": "webShellSessionExtensionsRecoverPrevious", + "x-qwen-implementation-status": "implemented", + "description": "Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision.", + "responses": { + "200": { + "description": "Persisted or reconciled Session configuration; contains no settings values or private executable recipes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedExtensionManagementSnapshot" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "503": { + "description": "The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellExtensionSessionOperation" + } + } + } + } + } } }, "components": { @@ -6061,6 +7448,14 @@ "$ref": "#/components/schemas/SessionCreationSkill" }, "description": "Optional static Skills imported from the enabled management catalog or added explicitly to the pinned AgentDefinition catalog. The merged catalog has unique names, at most 256 entries, 512 KiB per body and 2 MiB compact JSON. Requires session-skills/2; persisted immutably and included in creation idempotency." + }, + "extensionPins": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + }, + "description": "Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime." } } }, @@ -7063,6 +8458,14 @@ "$ref": "#/components/schemas/SessionCreationSkill" }, "description": "Optional static Skills imported from the enabled management catalog or added explicitly to the pinned AgentDefinition catalog. The merged catalog has unique names, at most 256 entries, 512 KiB per body and 2 MiB compact JSON. Requires session-skills/2; persisted immutably and included in creation idempotency." + }, + "extensionPins": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + }, + "description": "Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime." } } }, @@ -13986,6 +15389,463 @@ "$ref": "#/components/schemas/UserMessageCommand" } } + }, + "ManagedExtensionPackagePin": { + "type": "object", + "additionalProperties": false, + "properties": { + "extensionId": { + "type": "string", + "pattern": "^ext_[a-f0-9-]{36}$" + }, + "packageRevision": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 9007199254740991 + }, + "packageDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "settingsRevision": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["extensionId", "packageRevision", "packageDigest"] + }, + "ManagedExtensionPackageManifest": { + "type": "object", + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "enum": [1] + }, + "compilerVersion": { + "type": "integer", + "enum": [1] + }, + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "target": { + "type": "object", + "additionalProperties": false, + "properties": { + "platform": { + "type": "string", + "enum": ["linux", "darwin"] + }, + "arch": { + "type": "string", + "enum": ["x64", "arm64", "any"] + } + }, + "required": ["platform", "arch"] + }, + "components": { + "type": "array", + "maxItems": 7, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "skills", + "commands", + "agents", + "context", + "mcp", + "hooks", + "settings" + ] + } + }, + "sourceType": { + "type": "string", + "enum": [ + "git", + "github-release", + "npm", + "directory", + "archive", + "archive-url", + "link", + "snapshot" + ] + }, + "sourceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Exact source commit, resolved npm version, or imported content revision. Supplied together with sourceType." + } + }, + "required": [ + "schemaVersion", + "compilerVersion", + "name", + "version", + "target", + "components" + ] + }, + "ManagedExtensionPackage": { + "type": "object", + "additionalProperties": false, + "properties": { + "pin": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + }, + "manifest": { + "$ref": "#/components/schemas/ManagedExtensionPackageManifest" + }, + "byteLength": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 104857600 + } + }, + "required": ["pin", "manifest", "byteLength"] + }, + "ManagedExtensionPackageCatalog": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { + "type": "boolean" + }, + "extensions": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackage" + } + } + }, + "required": ["supported", "extensions"] + }, + "ManagedExtensionPackageSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "settingsRevision": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "configuredKeys": { + "type": "array", + "maxItems": 128, + "items": { + "type": "string" + } + } + }, + "required": ["settingsRevision", "configuredKeys"] + }, + "ManagedExtensionPackageSettingsCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "expectedRevision": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 9007199254740990 + }, + "values": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "string" + }, + "description": "Complete settings revision, bounded to 8 KiB UTF-8 JSON. Values are encrypted at rest and never returned by metadata or operation queries." + } + }, + "required": ["expectedRevision", "values"] + }, + "ManagedExtensionPackageOperation": { + "type": "object", + "additionalProperties": false, + "properties": { + "operationId": { + "type": "string" + }, + "extensionId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["publish", "settings"] + }, + "state": { + "type": "string", + "enum": ["pending", "settled"] + }, + "revision": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 9007199254740991 + }, + "packageDigest": { + "type": ["string", "null"], + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "operationId", + "extensionId", + "kind", + "state", + "revision", + "packageDigest" + ] + }, + "WebShellExtensionWorkspaceQuery": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string" + } + } + }, + "WebShellExtensionQuery": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "extensionId"], + "properties": { + "workspaceId": { + "type": "string" + }, + "extensionId": { + "type": "string" + } + } + }, + "WebShellExtensionOperationQuery": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "operationId"], + "properties": { + "workspaceId": { + "type": "string" + }, + "operationId": { + "type": "string" + } + } + }, + "WebShellExtensionPackageQuery": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "pin"], + "properties": { + "workspaceId": { + "type": "string" + }, + "pin": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + } + } + }, + "WebShellExtensionSettingsSubmit": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspaceId", + "extensionId", + "idempotencyKey", + "expectedRevision", + "values" + ], + "properties": { + "workspaceId": { + "type": "string" + }, + "extensionId": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "expectedRevision": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "values": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "string" + }, + "description": "Complete settings revision, bounded to 8 KiB UTF-8 JSON. Values are encrypted at rest and never returned by metadata or operation queries." + } + } + }, + "ManagedExtensionApplySelection": { + "type": "object", + "additionalProperties": false, + "required": ["expectedConfigRevision", "extensions"], + "properties": { + "expectedConfigRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "extensions": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + } + } + } + }, + "ManagedExtensionApplyOperation": { + "type": "object", + "additionalProperties": false, + "required": ["operationId", "state", "mode", "revision"], + "properties": { + "operationId": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "preparing", + "prepared", + "committed", + "active", + "recovery_required", + "failed" + ] + }, + "mode": { + "type": "string", + "enum": ["apply", "recoverPrevious"] + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": { + "code": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,127}$" + } + } + } + } + }, + "ManagedExtensionManagementSnapshot": { + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "supported", + "revision", + "extensions", + "requestedExtensions" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "supported": { + "type": "boolean" + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "extensions": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + } + }, + "requestedExtensions": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/ManagedExtensionPackagePin" + } + }, + "operation": { + "$ref": "#/components/schemas/ManagedExtensionApplyOperation" + } + } + }, + "WebShellExtensionSessionQuery": { + "type": "object", + "additionalProperties": false, + "required": ["sessionId"], + "properties": { + "sessionId": { + "type": "string", + "maxLength": 128 + }, + "operationId": { + "type": "string", + "format": "uuid" + } + } + }, + "WebShellExtensionSessionOperation": { + "type": "object", + "additionalProperties": false, + "required": ["sessionId", "operationId"], + "properties": { + "sessionId": { + "type": "string", + "maxLength": 128 + }, + "operationId": { + "type": "string", + "format": "uuid" + } + } + }, + "WebShellExtensionSessionSubmit": { + "type": "object", + "additionalProperties": false, + "required": ["sessionId", "idempotencyKey", "command"], + "properties": { + "sessionId": { + "type": "string", + "maxLength": 128 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 128 + }, + "command": { + "$ref": "#/components/schemas/ManagedExtensionApplySelection" + } + } } } } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java index b1748ac5efa..c95a597f450 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java @@ -30,7 +30,7 @@ class BackfillWitnessesTest { + " table_schema = 'public' AND table_type='BASE" + " TABLE'", Integer.class)) - .isEqualTo(36); + .isEqualTo(39); assertThat( new JdbcTemplate(source) .queryForObject( diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java index 99cc5657c76..10a8996a0aa 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java @@ -210,7 +210,7 @@ public final class ExtensionRecordJournal { editEvent, editRecords, extraEvents, domain, List.of()); } - private CommitTransactionRequest request(String operation, String commandId, + CommitTransactionRequest request(String operation, String commandId, byte[] body, long occurredAt, Consumer editEvent, UnaryOperator editRecords, int extraEvents, String domain, List resources) { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java index d47ce02799e..cbd3d92cb47 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java @@ -119,6 +119,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -1760,6 +1761,7 @@ class ManagedAgentApiContractTest { exchange(drift, "webShellCommandUserMessages", 404, post(WEB_SHELL + "/user-messages/command").header(TENANT, tenant).principal(actor(tenant)), "{\"sessionId\":\"missing\",\"idempotencyKey\":\"queue-missing\",\"command\":{\"action\":\"resume\"}}"); + exchangeExtensions(drift, workspaceTenant, actor, publicBoundId); assertThat(exercised).containsExactlyInAnyOrderElementsOf( CONTRACT.operations().stream() .filter(operation -> !"planned".equals( @@ -3392,6 +3394,62 @@ class ManagedAgentApiContractTest { assertThat(drift).isEmpty(); } + private void exchangeExtensions(Map drift, String tenant, + AuthenticatedTenantActor actor, String sessionId) throws Exception { + String base = "/v1/agents/workspaces/ws-default/extensions"; + String id = "ext_11111111-1111-4111-8111-111111111111"; + String operation = "11111111-1111-4111-8111-111111111111"; + String digest = "a".repeat(64); + String encodedManifest = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(("{\"schemaVersion\":1,\"compilerVersion\":1," + + "\"name\":\"fixture\",\"version\":\"1\",\"target\":{\"platform\":\"linux\",\"arch\":\"x64\"},\"components\":[\"skills\"]}") + .getBytes(StandardCharsets.UTF_8)); + exchange(drift, "listWorkspaceExtensionPackages", 200, get(base).header(TENANT, tenant).principal(actor), null); + exchange(drift, "createWorkspaceExtensionPackage", 401, post(base).header(TENANT, tenant) + .header(IDEMPOTENCY_KEY, "extension-publish").param("manifest", encodedManifest).param("packageDigest", digest) + .contentType(MediaType.APPLICATION_OCTET_STREAM).content("archive"), null); + exchange(drift, "publishWorkspaceExtensionPackage", 401, put(base + "/" + id + "/packages/" + digest) + .header(TENANT, tenant).header(IDEMPOTENCY_KEY, "extension-update").param("manifest", encodedManifest) + .contentType(MediaType.APPLICATION_OCTET_STREAM).content("archive"), null); + exchange(drift, "getWorkspaceExtensionPackage", 404, get(base + "/" + id + "/packages/1") + .param("packageDigest", digest).header(TENANT, tenant).principal(actor), null); + exchange(drift, "readWorkspaceExtensionArchive", 404, get(base + "/" + id + "/packages/1/archive") + .param("packageDigest", digest).header(TENANT, tenant).principal(actor), null); + exchange(drift, "getWorkspaceExtensionPackageSettings", 404, get(base + "/" + id + "/settings") + .header(TENANT, tenant).principal(actor), null); + exchange(drift, "setWorkspaceExtensionPackageSettings", 401, post(base + "/" + id + "/settings") + .header(TENANT, tenant).header(IDEMPOTENCY_KEY, "extension-settings"), "{\"expectedRevision\":0,\"values\":{}}"); + exchange(drift, "getWorkspaceExtensionPackageOperation", 404, get(base + "/operations/" + operation) + .header(TENANT, tenant).principal(actor), null); + String web = WEB_SHELL + "/workspace-extensions"; + String workspace = "{\"workspaceId\":\"ws-default\""; + String extension = workspace + ",\"extensionId\":\"" + id + "\""; + exchange(drift, "queryWorkspaceExtensionPackages", 200, post(web + "/query").header(TENANT, tenant).principal(actor), workspace + "}"); + exchange(drift, "queryWorkspaceExtensionPackage", 404, post(web + "/packages/query").header(TENANT, tenant).principal(actor), + workspace + ",\"pin\":{\"extensionId\":\"" + id + "\",\"packageRevision\":1,\"packageDigest\":\"" + digest + "\"}}"); + exchange(drift, "queryWorkspaceExtensionPackageSettings", 404, post(web + "/settings/query").header(TENANT, tenant).principal(actor), extension + "}"); + exchange(drift, "submitWorkspaceExtensionPackageSettings", 401, post(web + "/settings/submit").header(TENANT, tenant), + extension + ",\"idempotencyKey\":\"extension-settings-web\",\"expectedRevision\":0,\"values\":{}}"); + exchange(drift, "queryWorkspaceExtensionPackageOperation", 404, post(web + "/operations/query").header(TENANT, tenant).principal(actor), + workspace + ",\"operationId\":\"" + operation + "\"}"); + exchange(drift, "publishWebShellWorkspaceExtensionPackage", 401, post(web + "/publish").header(TENANT, tenant) + .header(IDEMPOTENCY_KEY, "extension-publish-web").param("workspaceId", "ws-default").param("packageDigest", digest).param("manifest", encodedManifest) + .contentType(MediaType.APPLICATION_OCTET_STREAM).content("archive"), null); + String session = "/v1/agents/sessions/" + sessionId + "/extensions"; + exchange(drift, "getSessionExtensionConfiguration", 200, get(session).header(TENANT, tenant).principal(actor), null); + exchange(drift, "applySessionExtensionConfiguration", 409, post(session).header(TENANT, tenant).principal(actor) + .header(IDEMPOTENCY_KEY, "extension-apply"), "{\"expectedConfigRevision\":0,\"extensions\":[]}"); + exchange(drift, "getSessionExtensionOperation", 404, get(session + "/operations/" + operation).header(TENANT, tenant).principal(actor), null); + exchange(drift, "continueSessionExtensionOperation", 409, post(session + "/operations/" + operation + "/continue").header(TENANT, tenant).principal(actor), null); + exchange(drift, "recoverPreviousSessionExtensions", 409, post(session + "/operations/" + operation + "/recover-previous").header(TENANT, tenant).principal(actor), null); + String query = "{\"sessionId\":\"" + sessionId + "\""; + String control = query + ",\"operationId\":\"" + operation + "\"}"; + exchange(drift, "webShellSessionExtensionsQuery", 200, post(WEB_SHELL + "/session-extensions/query").header(TENANT, tenant).principal(actor), query + "}"); + exchange(drift, "webShellSessionExtensionsSubmit", 409, post(WEB_SHELL + "/session-extensions/submit").header(TENANT, tenant).principal(actor), + query + ",\"idempotencyKey\":\"extension-apply-web\",\"command\":{\"expectedConfigRevision\":0,\"extensions\":[]}}"); + exchange(drift, "webShellSessionExtensionsContinue", 409, post(WEB_SHELL + "/session-extensions/continue").header(TENANT, tenant).principal(actor), control); + exchange(drift, "webShellSessionExtensionsRecoverPrevious", 409, post(WEB_SHELL + "/session-extensions/recover-previous").header(TENANT, tenant).principal(actor), control); + } + private String exchange(Map drift, String operationId, int expectedStatus, MockHttpServletRequestBuilder request, String body) throws Exception { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedConfigInstallContractTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedConfigInstallContractTest.java new file mode 100644 index 00000000000..8ab7064f308 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedConfigInstallContractTest.java @@ -0,0 +1,101 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.persistence.ManagedConfigInstall; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecords; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedConfigInstallContractTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void consumesEverySharedValidAndInvalidContentWithoutBroadeningTheClosedRecord() throws IOException { + JsonNode fixtures = fixtures(); + assertEquals(1, fixtures.required("schemaVersion").intValue()); + for (JsonNode fixture : fixtures.required("valid")) { + assertDoesNotThrow(() -> ManagedConfigInstall.require(record(fixture.required("content"), 1)), fixture.path("name").asText()); + } + for (JsonNode fixture : fixtures.required("invalid")) { + assertThrows(ManagedExtensionRecords.InvalidRecordException.class, + () -> ManagedConfigInstall.require(record(fixture.required("content"), 1)), fixture.path("name").asText()); + } + for (JsonNode fixture : fixtures.required("legacy")) { + assertFalse(ManagedConfigInstall.isPackageConfig(fixture.required("content")), fixture.path("name").asText()); + } + } + + @Test + void permitsStagedCommitAndEnableButRejectsSkippingOrRewritingAcknowledgedContent() throws IOException { + ObjectNode preparing = record(content("nullable_preparing"), 1); + ObjectNode prepared = record(content("prepared"), 2); + ObjectNode committed = record(content("prepared"), 3).put("phase", "committed"); + ObjectNode active = record(content("active"), 4); + assertDoesNotThrow(() -> ManagedConfigInstall.successor(null, preparing)); + assertDoesNotThrow(() -> ManagedConfigInstall.successor(preparing, prepared)); + assertDoesNotThrow(() -> ManagedConfigInstall.successor(prepared, committed)); + assertDoesNotThrow(() -> ManagedConfigInstall.successor(committed, active)); + assertThrows(ManagedExtensionRecords.InvalidRecordException.class, () -> ManagedConfigInstall.successor(prepared, active)); + ObjectNode changed = committed.deepCopy(); + ((ObjectNode) changed.path("bundleRef")).put("digest", "c".repeat(64)); + assertThrows(ManagedExtensionRecords.InvalidRecordException.class, () -> ManagedConfigInstall.successor(prepared, changed)); + assertTrue(ManagedConfigInstall.barrier(committed)); + assertFalse(ManagedConfigInstall.barrier(active)); + } + + @Test + void consumesSharedSuccessorsAndDerivedPhaseIdentities() throws IOException { + for (JsonNode fixture : fixtures().required("successors")) { + ObjectNode previous = fixture.path("previous").isNull() ? null : record(content(fixture.path("previous").asText()), 1); + ObjectNode next = record(content(fixture.path("next").asText()), 1); + if (fixture.path("valid").asBoolean()) { + assertDoesNotThrow(() -> ManagedConfigInstall.successor(previous, next), fixture.path("name").asText()); + } else { + assertThrows(ManagedExtensionRecords.InvalidRecordException.class, () -> ManagedConfigInstall.successor(previous, next), fixture.path("name").asText()); + } + } + for (JsonNode fixture : fixtures().required("phaseOperationIds")) { + assertEquals(fixture.path("expected").asText(), ManagedConfigInstall.derivedOperationId( + "managed-extension-recoverPrevious:", fixture.path("operationId").asText())); + } + } + + static ObjectNode content(String name) throws IOException { + for (JsonNode fixture : fixtures().required("valid")) { + if (name.equals(fixture.path("name").asText())) { + return fixture.required("content").deepCopy(); + } + } + throw new AssertionError("Missing configuration fixture: " + name); + } + + static ObjectNode record(JsonNode content, long revision) { + ObjectNode record = content.deepCopy(); + record.put("operationId", content.path("command").path("operationId").asText()).put("revision", revision); + if (revision == 1) { + record.putNull("previousRecordRef"); + } else { + record.putObject("previousRecordRef").put("resourceId", "previous").put("kind", "managed-config_install") + .put("schemaVersion", 1).put("byteLength", 2).put("digest", "a".repeat(64)); + } + return record; + } + + private static JsonNode fixtures() throws IOException { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++, current = current.getParent()) { + Path file = current.resolve("packages/core/src/managed-runtime/contracts/managed-config-install-v1.fixtures.json"); + if (Files.isRegularFile(file)) { + return JSON.readTree(file.toFile()); + } + } + throw new AssertionError("Missing shared managed configuration fixtures"); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCommandVerifierTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCommandVerifierTest.java new file mode 100644 index 00000000000..0addfe87d01 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCommandVerifierTest.java @@ -0,0 +1,205 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionCommandVerifier; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeLease; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ManagedExtensionCommandVerifierTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String EXTENSION = "ext_550e8400-e29b-41d4-a716-446655440001"; + private static final RuntimeScope SCOPE = new RuntimeScope("tenant", "workspace", "1", "/workspace", + ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST, "session"); + private final RuntimeSession session = new RuntimeSession("harness", "runtime", "bootstrap", SCOPE); + private final RuntimeBindingRecord runtime = new RuntimeBindingRecord("binding", new RuntimeProvisionRequest(SCOPE, "harness"), 1, + RuntimeBindingRecord.State.READY, new RuntimeLease("worker", URI.create("http://127.0.0.1:9"), "token", "lease", 1), + false, null, null, 0, 0, Instant.now(), Instant.now()); + private JdbcTemplate jdbc; + private ManagedExtensionCommandVerifier verifier; + private ObjectNode stage; + private ObjectNode grant; + private ObjectNode operation; + private ObjectNode activeConfig; + private ObjectNode activeConfigRef; + + @BeforeEach + void setup() throws IOException { + var source = new DriverManagerDataSource("jdbc:h2:mem:command-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); + ManagedSchema.initialize(source); + jdbc = new ManagedSchemaJdbcTemplate(source); + jdbc.update("INSERT INTO ma_session (tenant_id, session_id, agent_id, status, approval_mode, gmt_create, gmt_modified)" + + " VALUES ('tenant', 'harness', 'agent', 'ACTIVE', 'default', 0, 0)"); + jdbc.update("UPDATE ma_session SET selected_approval_mode='yolo', approval_revision=1" + + " WHERE tenant_id=? AND session_id=?", "tenant".getBytes(java.nio.charset.StandardCharsets.UTF_8), + "harness".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var sessions = mock(AgentStateStore.class); + when(sessions.requireSession("tenant", "harness")).thenReturn(new SessionRecord("tenant", "harness", "agent", null, "Session", + "READY", null, null, 0, 0, 0, 0, 0, null, 0, + new ContextBinding("tenant", "workspace", 1, "storage", ".", "config", 1), "default", WorkspaceToolProfiles.SHELL)); + verifier = new ManagedExtensionCommandVerifier(jdbc, new ManagedExtensionRecordStore(jdbc), sessions, JSON); + ObjectNode input = JSON.createObjectNode().put("command", "pwd").put("description", "cwd"); + ObjectNode inputRef = resource("managed-extension-command-input", input); + ObjectNode permission = JSON.createObjectNode().put("allowed", true).put("toolName", "shell").put("approvalMode", "yolo") + .putNull("preHookOccurrence").putNull("permissionHookOccurrence").putNull("actionRequestId"); + permission.set("inputRef", inputRef); + stage = (ObjectNode) fixtures("managed-tool-stage-v1.fixtures.json").required("valid").get(1).required("record").deepCopy(); + ((ObjectNode) stage.path("run").path("runtime")).put("runtimeBindingId", "binding"); + stage.set("inputRef", inputRef); + stage.set("permissionRef", resource("managed-extension-command-permission", permission)); + ObjectNode ref = resource("managed-tool_stage", stage); + project("tool_stage", "stage-1", "opening-command", ref); + grant = JSON.createObjectNode().put("operationId", "opening-command").put("domain", "tool_stage").put("operationRevision", 1) + .put("ownerId", "runtime").put("workspaceGeneration", "1").put("leaseDurationMs", 60_000).put("expiresAt", System.currentTimeMillis() + 60_000); + grant.set("sessionKey", JSON.valueToTree(Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness"))); + grant.set("resourceScope", JSON.valueToTree(Map.of("recordRef", ref, "phases", List.of("execute", "status", "cancel")))); + operation = JSON.createObjectNode().put("kind", "extension-command").put("operationId", "stage-1").put("action", "execute") + .put("toolName", "shell").put("revision", 1).put("extensionId", EXTENSION); + operation.set("grant", grant); + operation.set("input", JSON.createObjectNode().put("description", "cwd").put("command", "pwd")); + ObjectNode bundle = JSON.createObjectNode().put("kind", "managed-extension-config-bundle").put("formatVersion", 1).put("configRevision", 1); + bundle.set("packages", JSON.valueToTree(List.of(Map.of("pin", Map.of("extensionId", EXTENSION))))); + ObjectNode config = (ObjectNode) fixtures("managed-config-install-v1.fixtures.json").required("valid").get(2).required("content").deepCopy(); + config.put("operationId", config.required("command").required("operationId").textValue()).put("revision", 1).putNull("previousRecordRef"); + config.set("bundleRef", resource("managed-extension-config-bundle", bundle)); + activeConfig = config; + activeConfigRef = resource("managed-config_install", config); + project("config_install", "hosted-mcp-settings", "config-command", activeConfigRef); + jdbc.update("UPDATE ma_session_record SET settled_at=0 WHERE domain='config_install'"); + } + + @Test + void readsTheCommittedInputAndPermissionAndRejectsSubstitutionOrStaleGrants() { + verify(); + ((ObjectNode) operation.get("input")).put("command", "different-command"); + assertEquals(403, assertThrows(RuntimeBrokerException.class, this::verify).getStatusCode()); + ((ObjectNode) operation.get("input")).put("command", "pwd"); + grant.put("operationRevision", 2); + assertThrows(RuntimeBrokerException.class, this::verify); + grant.put("operationRevision", 1).put("ownerId", "another-runtime"); + assertEquals(400, assertThrows(RuntimeBrokerException.class, this::verify).getStatusCode()); + } + + @Test + void recoveryReadsTheOriginalStageWhileConfigurationAdmissionIsClosed() { + jdbc.update("DELETE FROM ma_session_record WHERE domain='config_install'"); + assertThrows(RuntimeBrokerException.class, this::verify); + operation.put("action", "status"); + verify(); + operation.put("action", "cancel"); + verify(); + } + + @Test + void dispatchUsesTheLiveSqlSelectionAndRecoveryRetainsTheCommittedDecision() { + verify(); + jdbc.update("UPDATE ma_session SET selected_approval_mode=NULL, approval_revision=0 WHERE tenant_id=? AND session_id=?", + "tenant".getBytes(java.nio.charset.StandardCharsets.UTF_8), "harness".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThrows(RuntimeBrokerException.class, this::verify); + operation.put("action", "status"); + verify(); + operation.put("action", "cancel"); + verify(); + } + + @Test + void failedOrUncommittedApplyKeepsOldCommandsButCommittedBarrierRefusesExecution() throws IOException { + for (String phase : List.of("failed", "preparing", "prepared", "committed")) { + ObjectNode next = activeConfig.deepCopy(); + next.put("phase", phase).put("revision", 2).put("configRevision", 2).put("previousConfigRevision", 1); + String applyId = UUID.randomUUID().toString(); + next.put("operationId", applyId).put("phaseOperationId", applyId); + ((ObjectNode) next.get("command")).put("operationId", applyId).put("expectedRevision", 1); + next.set("previousRecordRef", activeConfigRef); + next.set("previousBundleRef", activeConfig.get("bundleRef")); + next.putArray("enableAckRefs"); + if ("failed".equals(phase)) { + next.set("error", JSON.createObjectNode().put("code", "extension_prepare_failed")); + } else { + next.putNull("error"); + } + ObjectNode ref = resource("managed-config_install", next); + jdbc.update("UPDATE ma_session_record SET revision=2, record_resource_id=?, settled_at=? WHERE domain='config_install'", + ref.get("resourceId").asText(), "committed".equals(phase) ? null : 0L); + if ("committed".equals(phase)) { + assertEquals(403, assertThrows(RuntimeBrokerException.class, this::verify).getStatusCode()); + operation.put("action", "status"); + verify(); + operation.put("action", "cancel"); + verify(); + operation.put("action", "execute"); + } else { + verify(); + } + } + } + + private void verify() { verifier.verify(runtime, session, JSON.convertValue(operation, new TypeReference<>() { })); } + + private ObjectNode resource(String kind, JsonNode body) throws IOException { + byte[] bytes = JSON.writeValueAsBytes(body); + String id = UUID.randomUUID().toString(); + String scope = ManagedSessionStore.sessionScopeKey("tenant", "harness"); + jdbc.update("INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, workspace_id, session_id, resource_id, kind," + + " schema_version, byte_length, sha256, storage_kind, inline_bytes, state, gmt_create)" + + " VALUES (?, ?, 'tenant', 'workspace', 'harness', ?, ?, 1, ?, ?, 'MYSQL_INLINE', ?, 'REFERENCED', 0)", + ManagedSchemaKeys.key(scope, id), scope, id, kind, bytes.length, DigestUtils.sha256Hex(bytes), bytes); + return JSON.createObjectNode().put("resourceId", id).put("kind", kind).put("schemaVersion", 1).put("byteLength", bytes.length) + .put("digest", DigestUtils.sha256Hex(bytes)); + } + + private void project(String domain, String id, String opening, JsonNode ref) { + jdbc.update("INSERT INTO ma_session_record (session_scope_key, record_key, tenant_id, workspace_id, session_id, domain, record_id," + + " operation_hash, revision, record_resource_id, gmt_create) VALUES (?, ?, 'tenant', 'workspace', 'harness', ?, ?, ?, 1, ?, 0)", + ManagedSessionStore.sessionScopeKey("tenant", "harness"), ManagedExtensionProjection.recordKey("harness", domain, id), + domain, id, DigestUtils.sha256Hex(opening), ref.get("resourceId").textValue()); + } + + private static JsonNode fixtures(String name) throws IOException { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++, current = current.getParent()) { + Path path = current.resolve("packages/core/src/managed-runtime/contracts").resolve(name); + if (Files.isRegularFile(path)) { + return JSON.readTree(path.toFile()); + } + } + throw new AssertionError("Missing shared Managed extension fixtures"); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java new file mode 100644 index 00000000000..36af7eaf1c9 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java @@ -0,0 +1,187 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.persistence.ManagedConfigInstall; +import com.alibaba.qwen.code.managedagent.persistence.ManagedMcpSettings; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.session.AgentStateStore; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:managed-extension-admission;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false" +}) +class ManagedExtensionConfigurationAdmissionTest { + @Autowired private ManagedSessionStore store; + @Autowired private AgentStateStore sessions; + @Autowired private JdbcTemplate jdbc; + @Autowired private ObjectMapper json; + @Autowired private PlatformTransactionManager transactionManager; + + @BeforeEach + void enableOnlyTheStoreAdmissionBranch() { + Object target = AopTestUtils.getUltimateTargetObject(sessions); + ReflectionTestUtils.setField(target, "workspaceFilesEnabled", true); + } + + @Test + void liveChildBlocksBothOperatorSelectionAndConfigurationCommitButNotASiblingSession() throws Exception { + String workspace = registerWorkspace(); + String parent = UUID.randomUUID().toString(); + String sibling = UUID.randomUUID().toString(); + var journal = publicJournal(parent, workspace); + publicJournal(sibling, workspace); + ObjectNode child = ManagedChildRunRecordContractTest.fixtures().required("templates").required("child_agent").deepCopy(); + child.put("childRunId", "live-child").put("rootSessionId", parent); + byte[] inputBytes = "{}".getBytes(StandardCharsets.UTF_8); + var input = new CommitResource("child-input", "managed-input", 1, inputBytes.length, DigestUtils.sha256Hex(inputBytes), + Base64.getEncoder().encodeToString(inputBytes)); + child.putObject("inputRef").put("resourceId", input.resourceId()).put("kind", input.kind()).put("schemaVersion", input.schemaVersion()) + .put("byteLength", input.byteLength()).put("digest", input.digest()); + commitChild(journal, child, input, "admitted", 1_000); + String operation = UUID.randomUUID().toString(); + ObjectNode content = ManagedConfigInstallContractTest.record(ManagedConfigInstallContractTest.content("nullable_preparing"), 1); + assertBusy(() -> transaction(() -> sessions.requireExtensionManagement("tenant", "actor", parent, operation, true))); + assertBusy(() -> transaction(() -> { + sessions.requireConfigurationCommit("tenant", workspace, parent, content); + return null; + })); + assertThatCode(() -> transaction(() -> sessions.requireExtensionManagement("tenant", "actor", sibling, operation, true))).doesNotThrowAnyException(); + assertThatCode(() -> transaction(() -> { + sessions.requireConfigurationCommit("tenant", workspace, sibling, content); + return null; + })).doesNotThrowAnyException(); + child.put("stopRequested", true).put("stopReason", "stop_requested"); + ((ObjectNode) child.required("run")).put("state", "cancelled").put("execution", "not_started_proven"); + ((ObjectNode) child.path("run").required("delivery")).put("state", "cancelled"); + commitChild(journal, child, input, "cancelled", 1_001); + assertThatCode(() -> transaction(() -> sessions.requireExtensionManagement("tenant", "actor", parent, operation, true))).doesNotThrowAnyException(); + } + + private void commitChild(ExtensionRecordJournal journal, ObjectNode child, CommitResource input, String phase, long occurredAt) { + var request = journal.request("commitDomainRecord", "live-child:" + phase, ExtensionRecordJournal.bytes(child), occurredAt, + event -> ((ObjectNode) event.path("payload")).put("operationId", "live-child"), records -> records, + 0, "child_run", List.of(input)); + journal.commit(request); + journal.committed(request); + } + + @Test + void committedPhaseRequiresAtomicMatchingConfigBindingAndItsBarrierStaysSessionScoped() throws Exception { + String workspace = registerWorkspace(); + String id = UUID.randomUUID().toString(); + String sibling = UUID.randomUUID().toString(); + var journal = publicJournal(id, workspace); + publicJournal(sibling, workspace); + ObjectNode preparing = phase("nullable_preparing", 1, null); + commit(journal, id, workspace, preparing, false, false); + ObjectNode prepared = phase("prepared", 2, preparing); + commit(journal, id, workspace, prepared, false, false); + ObjectNode committed = phase("prepared", 3, prepared).put("phase", "committed"); + assertThatThrownBy(() -> commit(journal, id, workspace, committed, false, false)).isInstanceOf(ManagedAgentException.class); + assertThat(ManagedMcpSettings.find(jdbc, "tenant", id).path("phase").asText()).isEqualTo("prepared"); + assertThatThrownBy(() -> commit(journal, id, workspace, committed, true, true)).isInstanceOf(ManagedAgentException.class); + assertThat(ManagedMcpSettings.find(jdbc, "tenant", id).path("phase").asText()).isEqualTo("prepared"); + commit(journal, id, workspace, committed, true, false); + assertThat(transaction(() -> ManagedConfigInstall.blocked(jdbc, "tenant", id))).isTrue(); + assertThat(transaction(() -> ManagedConfigInstall.blocked(jdbc, "tenant", sibling))).isFalse(); + assertThatCode(() -> transaction(() -> sessions.requireExtensionManagement("tenant", "actor", sibling, UUID.randomUUID().toString(), true))).doesNotThrowAnyException(); + assertThatThrownBy(() -> transaction(() -> sessions.requireExtensionManagement("tenant", "actor", id, UUID.randomUUID().toString(), true))) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("session_config_recovery_required")); + } + + private void commit(ExtensionRecordJournal journal, String id, String workspace, ObjectNode body, + boolean binding, boolean mismatch) { + String operation = body.path("operationId").asText(); + var request = journal.request("commitDomainRecord", operation + ":phase:" + body.path("revision").asLong(), ExtensionRecordJournal.bytes(body), 1000, + event -> ((ObjectNode) event.path("payload")).put("operationId", operation), records -> { + if (!binding) { + return records; + } + ObjectNode event = json.createObjectNode().put("v", 1).put("sequence", journal.committedSequence() + 2) + .put("eventId", "config-bound:" + body.path("revision").asLong()).put("kind", "config.bound").put("occurredAt", 1000); + event.putObject("sessionKey").put("tenantId", "tenant").put("workspaceId", workspace).put("sessionId", id); + ObjectNode payload = event.putObject("payload").put("revision", mismatch ? 99 : body.path("configRevision").asLong()); + payload.putNull("previousRevision").set("bundleRef", body.get("bundleRef")); + payload.set("rootSnapshotRef", body.get("rootSnapshotRef")); + int cut = records.indexOf('\n') + 1; + return records.substring(0, cut) + ExtensionRecordJournal.line(id, "managed_session_event_v1", event) + records.substring(cut); + }, binding ? 1 : 0, "config_install", resources()); + journal.commit(request); + journal.committed(request); + } + + private ObjectNode phase(String name, long revision, JsonNode previous) throws Exception { + ObjectNode body = ManagedConfigInstallContractTest.record(ManagedConfigInstallContractTest.content(name), revision); + if (previous != null) { + byte[] bytes = ExtensionRecordJournal.bytes(previous); + body.putObject("previousRecordRef").put("resourceId", ExtensionRecordJournal.resourceId(bytes)).put("kind", "managed-config_install") + .put("schemaVersion", 1).put("byteLength", bytes.length).put("digest", DigestUtils.sha256Hex(bytes)); + } + for (String field : List.of("rootSnapshotRef", "bundleRef")) { + if (!body.path(field).isNull()) { + ((ObjectNode) body.path(field)).put("digest", DigestUtils.sha256Hex("{}")); + } + } + for (String field : List.of("stageAckRefs", "enableAckRefs")) { + for (JsonNode ref : body.path(field)) { + ((ObjectNode) ref).put("digest", DigestUtils.sha256Hex("{}")); + } + } + return body; + } + + private List resources() { + List resources = new ArrayList<>(); + for (String id : List.of("root", "bundle", "hstage", "rstage", "henable", "renable")) { + resources.add(new CommitResource(id, "managed-test", 1, 2, DigestUtils.sha256Hex("{}"), Base64.getEncoder().encodeToString("{}".getBytes(StandardCharsets.UTF_8)))); + } + return resources; + } + + private String registerWorkspace() { + String workspace = UUID.randomUUID().toString(); + jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation, storage_id, display_name, config_ref, policy_ref, state)" + + " VALUES ('tenant', ?, 1, 'storage', ?, ?, ?, 'ACTIVE')", workspace, workspace, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES ('tenant', ?, ?, 'OPERATOR')", workspace, "actor".getBytes(StandardCharsets.UTF_8)); + return workspace; + } + + private ExtensionRecordJournal publicJournal(String id, String workspace) { + var journal = new ExtensionRecordJournal(store, "tenant", workspace, id).open(); + jdbc.update("UPDATE ma_session SET session_kind='PUBLIC', agent_id='qwen-code', workspace_id=?, workspace_generation=1, workspace_storage_id='storage'," + + " cwd_relative='.', context_config_ref=?, context_revision=1, workspace_config_ref=?, workspace_policy_ref=?, creator_actor_key=?," + + " tool_profile='hosted-workspace-shell/1' WHERE tenant_id=? AND session_id=?", workspace, WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, + WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF, "actor".getBytes(StandardCharsets.UTF_8), + "tenant".getBytes(StandardCharsets.UTF_8), id.getBytes(StandardCharsets.UTF_8)); + return journal; + } + + private T transaction(java.util.function.Supplier work) { return new TransactionTemplate(transactionManager).execute(status -> work.get()); } + private void assertBusy(org.assertj.core.api.ThrowableAssert.ThrowingCallable work) { + assertThatThrownBy(work).isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("session_context_busy")); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationMySqlIT.java new file mode 100644 index 00000000000..98d356171e7 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationMySqlIT.java @@ -0,0 +1,13 @@ +package com.alibaba.qwen.code.managedagent; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = { + "spring.datasource.url=${mysql.url}", + "spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver", + "spring.datasource.username=${mysql.user}", + "spring.datasource.password=${mysql.password:}", + "qwen.managed-agent.harness.enabled=false" +}) +class ManagedExtensionConfigurationMySqlIT extends ManagedExtensionConfigurationAdmissionTest { +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCreationMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCreationMySqlIT.java new file mode 100644 index 00000000000..b16b3b6e225 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionCreationMySqlIT.java @@ -0,0 +1,13 @@ +package com.alibaba.qwen.code.managedagent; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = { + "spring.datasource.url=${mysql.url}", + "spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver", + "spring.datasource.username=${mysql.user}", + "spring.datasource.password=${mysql.password:}", + "qwen.managed-agent.harness.enabled=false" +}) +class ManagedExtensionCreationMySqlIT extends ManagedSessionCreationSkillsTest { +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java index a015f83a3b3..b6e8c1afef1 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java @@ -2,13 +2,18 @@ package com.alibaba.qwen.code.managedagent; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages; import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionSkills; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -18,6 +23,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.UUID; @@ -41,8 +47,13 @@ class ManagedSessionCreationSkillsTest { @Autowired private ObjectMapper json; @Autowired private ManagedAgentStore store; @Autowired private ManagedSessionSkills snapshots; + @Autowired private ManagedAgentProperties properties; + @Autowired private ManagedExtensionPackageService extensionPackages; + @Autowired private org.springframework.transaction.PlatformTransactionManager transactionManager; + @TempDir private Path archiveDirectory; @MockitoBean private HarnessConnector harness; private String tenant; + private Boolean previousShellEnabled; private final Map skill = Map.of("name", "review", "description", "Review changes", "body", "ORIGINAL_INSTRUCTIONS", "base_directory", "docs"); @@ -50,12 +61,186 @@ class ManagedSessionCreationSkillsTest { void setup() { tenant = "skills-" + UUID.randomUUID(); when(harness.supportsSessionSkills()).thenReturn(true); + when(harness.supportsSessionExtensions()).thenReturn(true); + when(harness.supportsLifecycle("hosted-workspace-shell/1")).thenReturn(true); + properties.getExtensions().setEnabled(false); jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation, storage_id," + " display_name, config_ref, policy_ref, state) VALUES (?, 'ws', 1, 'storage', 'Test', 'config', 'policy', 'ACTIVE')", tenant); jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES (?, 'ws', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8)); } + @Test + void pinsInitialPackagesInTheCreationReceiptAndOnlyPermitsTheirFirstBootstrap() throws Exception { + enableBuiltinShell(); + properties.getExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); + properties.getExtensions().setAllowedActors(List.of("actor")); + byte[] archive = "source archive".getBytes(StandardCharsets.UTF_8); + var published = extensionPackages.publish(tenant, "actor", "ws", null, + org.apache.commons.codec.digest.DigestUtils.sha256Hex(archive), "publication", + new ManagedExtensionPackages.Manifest(1, 1, "context", "1.0", new ManagedExtensionPackages.Target("linux", "any"), List.of("context")), + new java.io.ByteArrayInputStream(archive)); + var pins = List.of(new ManagedExtensionPackages.Pin(published.extensionId(), 1, published.packageDigest(), null)); + var body = request("qwen-code", List.of(skill)); + body.set("extensionPins", json.valueToTree(pins)); + String session = create(body, "with-extensions", true).required("sessionId").asText(); + assertThat(store.requireSession(tenant, session).toolProfile()).isEqualTo("hosted-workspace-shell/1"); + assertThat(create(body, "with-extensions", true).required("sessionId").asText()).isEqualTo(session); + assertThat(snapshots.effective(store.requireSession(tenant, session))).containsExactly(skill); + var initial = snapshots.initialSelection(tenant, session); + assertThat(initial).containsEntry("expectedRevision", 0); + JsonNode selectedPins = json.valueToTree(initial.get("extensions")); + assertThat(selectedPins).isEqualTo(json.readTree(json.writeValueAsString(pins))); + var bootstrap = json.createObjectNode().put("mode", "apply").put("previousConfigRevision", 0).put("configRevision", 1); + bootstrap.set("command", json.valueToTree(initial)); + var transaction = new org.springframework.transaction.support.TransactionTemplate(transactionManager); + transaction.executeWithoutResult(ignored -> store.requireConfigurationCommit(tenant, "ws", session, bootstrap)); + bootstrap.put("mode", "recoverPrevious").put("previousConfigRevision", 1).put("configRevision", 2); + transaction.executeWithoutResult(ignored -> store.requireConfigurationCommit(tenant, "ws", session, bootstrap)); + bootstrap.put("configRevision", 3); + assertThatThrownBy(() -> transaction.executeWithoutResult(ignored -> store.requireConfigurationCommit(tenant, "ws", session, bootstrap))) + .hasMessageContaining("creation receipt"); + bootstrap.put("mode", "apply").put("previousConfigRevision", 0).put("configRevision", 1); + ((com.fasterxml.jackson.databind.node.ObjectNode) bootstrap.get("command")).put("expectedRevision", 1); + assertThatThrownBy(() -> transaction.executeWithoutResult(ignored -> store.requireConfigurationCommit(tenant, "ws", session, bootstrap))) + .hasMessageContaining("creation receipt"); + body.set("extensionPins", json.createArrayNode()); + submit(body, "with-extensions", true).andExpect(status().isConflict()); + jdbc.update("UPDATE ma_request_receipt SET result_json=REPLACE(result_json, ?, ?)" + + " WHERE tenant_id=? AND session_id=? AND namespace='SESSION_CREATE_ACTOR'", + published.packageDigest(), "b".repeat(64), tenant, session); + assertThatThrownBy(() -> snapshots.initialSelection(tenant, session)).isInstanceOf(RuntimeException.class); + } + + @Test + void explicitEmptySelectionUsesTheNewContractAndStillRequiresDeploymentPermission() throws Exception { + enableBuiltinShell(); + var body = request("qwen-code", List.of()); + body.putArray("extensionPins"); + submit(body, "denied", true).andExpect(status().isForbidden()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Long.class, tenant)).isZero(); + properties.getExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); + properties.getExtensions().setAllowedActors(List.of("actor")); + String session = create(body, "empty", false).required("id").asText(); + assertThat(snapshots.initialSelection(tenant, session).get("extensions")).isEqualTo(List.of()); + assertThat(snapshots.snapshot(tenant, session)).isNull(); + when(harness.supportsSessionExtensions()).thenReturn(false); + submit(body, "unsupported-package-harness", true).andExpect(status().isConflict()); + body.remove("workspace"); + submit(body, "unbound-package", true).andExpect(status().isBadRequest()); + } + + @Test + void refusesInitialSelectionsOnFilesProfilesBeforeCreatingASessionOrReceipt() throws Exception { + properties.getExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); + properties.getExtensions().setAllowedActors(List.of("actor")); + var body = request("qwen-code", List.of()); + body.putArray("extensionPins"); + for (boolean web : List.of(true, false)) { + submit(body, "unsupported-profile-" + web, web).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("unsupported_feature")); + } + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Long.class, tenant)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_request_receipt WHERE tenant_id=?", Long.class, tenant)).isZero(); + body.remove("extensionPins"); + String ordinary = create(body, "ordinary-files", true).required("sessionId").asText(); + assertThat(store.requireSession(tenant, ordinary).toolProfile()).isEqualTo("hosted-workspace-files/1"); + } + + @Test + void requiresTheActualMcpLifecycleNegotiationBeforePersistingAnInitialSelection() throws Exception { + properties.getExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); + properties.getExtensions().setAllowedActors(List.of("actor")); + Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); + Object profiles = org.springframework.test.util.ReflectionTestUtils.getField(target, "definitionToolProfiles"); + org.springframework.test.util.ReflectionTestUtils.setField(target, "definitionToolProfiles", List.of("hosted-workspace-mcp/1")); + try { + var definition = json.createObjectNode(); + definition.putObject("model"); + definition.put("instructions", ""); + definition.putArray("tools").addObject().put("type", "hosted_profile").put("profile", "hosted-workspace-mcp/1"); + definition.putArray("mcp_servers").addObject().put("server_id", "docs").put("server_revision", 1) + .put("definition_digest", "a".repeat(64)); + definition.putObject("permission_policy").put("approval_mode", "default"); + var response = mvc.perform(post("/v1/agents").principal(actor("actor")) + .header(TenantContextFilter.HEADER, tenant).header("Idempotency-Key", "mcp-definition") + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsBytes(definition))) + .andExpect(status().isAccepted()).andReturn(); + String agent = json.readTree(response.getResponse().getContentAsString()).required("id").asText(); + var body = request(agent, List.of()); + body.putArray("extensionPins"); + for (boolean web : List.of(true, false)) { + submit(body, "unnegotiated-mcp-" + web, web).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("unsupported_feature")); + } + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Long.class, tenant)).isZero(); + when(harness.supportsLifecycle("hosted-workspace-mcp/1")).thenReturn(true); + String session = create(body, "negotiated-mcp", true).required("sessionId").asText(); + assertThat(store.requireSession(tenant, session).toolProfile()).isEqualTo("hosted-workspace-mcp/1"); + assertThat(snapshots.initialSelection(tenant, session).get("extensions")).isEqualTo(List.of()); + } finally { + org.springframework.test.util.ReflectionTestUtils.setField(target, "definitionToolProfiles", profiles); + } + } + + @Test + void newExtensionShellDefinitionsFreezeChildAdmissionWithoutPromotingOlderOrFilesSessions() throws Exception { + Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); + Map values = Map.of("workspaceFilesEnabled", true, "workspaceShellEnabled", true, + "workspaceChildAgentsEnabled", true, "managedExtensionExecutionEnabled", false, + "definitionToolProfiles", List.of("hosted-workspace-files/1", "hosted-workspace-shell/1")); + Map previous = new java.util.LinkedHashMap<>(); + for (var entry : values.entrySet()) { + previous.put(entry.getKey(), org.springframework.test.util.ReflectionTestUtils.getField(target, entry.getKey())); + org.springframework.test.util.ReflectionTestUtils.setField(target, entry.getKey(), entry.getValue()); + } + boolean previousManaged = properties.getManagedExtensions().isEnabled(); + try { + when(harness.supportsPublicChildAgents()).thenReturn(true); + var definition = json.createObjectNode(); + definition.putObject("model"); + definition.put("instructions", ""); + definition.putArray("tools").addObject().put("type", "hosted_profile").put("profile", "hosted-workspace-shell/1"); + definition.putObject("permission_policy").put("approval_mode", "default"); + var created = mvc.perform(post("/v1/agents").principal(actor("actor")) + .header(TenantContextFilter.HEADER, tenant).header("Idempotency-Key", "shell-definition") + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsBytes(definition))) + .andExpect(status().isAccepted()).andReturn(); + String agent = json.readTree(created.getResponse().getContentAsString()).required("id").asText(); + String legacy = create(request(agent, List.of()), "old-shell", false).required("id").asText(); + assertThat(store.requireSession(tenant, legacy).publicChildAgentsVersion()).isZero(); + properties.getExtensions().setEnabled(true); + properties.getManagedExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); + properties.getExtensions().setAllowedActors(List.of("actor")); + org.springframework.test.util.ReflectionTestUtils.setField(target, "managedExtensionExecutionEnabled", true); + var selection = request(agent, List.of()); + selection.putArray("extensionPins"); + String selected = create(selection, "extension-shell", false).required("id").asText(); + assertThat(store.requireSession(tenant, selected).publicChildAgentsVersion()).isEqualTo(1); + assertThat(store.requireSession(tenant, selected).toolProfile()).isEqualTo("hosted-workspace-shell/1"); + assertThat(store.requireSession(tenant, legacy).publicChildAgentsVersion()).isZero(); + ((com.fasterxml.jackson.databind.node.ObjectNode) definition.withArray("tools").get(0)).put("profile", "hosted-workspace-files/1"); + var files = mvc.perform(post("/v1/agents").principal(actor("actor")) + .header(TenantContextFilter.HEADER, tenant).header("Idempotency-Key", "files-definition") + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsBytes(definition))) + .andExpect(status().isAccepted()).andReturn(); + String filesAgent = json.readTree(files.getResponse().getContentAsString()).required("id").asText(); + String filesSession = create(request(filesAgent, List.of()), "files-session", false).required("id").asText(); + assertThat(store.requireSession(tenant, filesSession).publicChildAgentsVersion()).isZero(); + } finally { + for (var entry : previous.entrySet()) { + org.springframework.test.util.ReflectionTestUtils.setField(target, entry.getKey(), entry.getValue()); + } + properties.getManagedExtensions().setEnabled(previousManaged); + properties.getExtensions().setEnabled(false); + } + } + @Test void builtinSnapshotParticipatesInIdempotencyAndMetadataCatalog() throws Exception { var body = request("qwen-code", List.of(skill)); @@ -145,6 +330,21 @@ class ManagedSessionCreationSkillsTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id = ?", Long.class, tenant)).isZero(); } + private void enableBuiltinShell() { + Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); + previousShellEnabled = (Boolean) org.springframework.test.util.ReflectionTestUtils.getField(target, "workspaceShellEnabled"); + org.springframework.test.util.ReflectionTestUtils.setField(target, "workspaceShellEnabled", true); + } + + @AfterEach + void restoreBuiltinShell() { + if (previousShellEnabled != null) { + Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); + org.springframework.test.util.ReflectionTestUtils.setField(target, "workspaceShellEnabled", previousShellEnabled); + previousShellEnabled = null; + } + } + private com.fasterxml.jackson.databind.node.ObjectNode request(String agent, List> skills) { var request = json.createObjectNode().put("agentId", agent).put("agent_id", agent); request.putArray("input"); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolStageContractTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolStageContractTest.java new file mode 100644 index 00000000000..f75907d2b0b --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolStageContractTest.java @@ -0,0 +1,60 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecords.InvalidRecordException; +import com.alibaba.qwen.code.managedagent.persistence.ManagedToolStageRecords; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ManagedToolStageContractTest { + @Test + void replaysTheSharedOrdinaryToolPinAndRevisionCases() throws IOException { + JsonNode fixtures = fixtures(); + var body = ManagedExtensionProjection.RECORD_BODIES.get("tool_stage"); + for (JsonNode fixture : fixtures.required("valid")) { + JsonNode record = fixture.required("record"); + body.require().accept(record); + assertNull(body.taskKindOf().apply(record)); + assertEquals(record.required("toolStageId").textValue(), body.recordId().apply(record)); + } + for (JsonNode fixture : fixtures.required("invalid")) { + assertThrows(InvalidRecordException.class, + () -> body.require().accept(fixture.required("record")), fixture.required("name").textValue()); + } + for (JsonNode fixture : fixtures.required("successors")) { + assertEquals(fixture.required("valid").booleanValue(), + body.isSuccessor().test(fixture.required("previous"), fixture.required("next")), + fixture.required("name").textValue()); + } + JsonNode admitted = fixtures.required("valid").get(0).required("record"); + assertTrue(ManagedToolStageRecords.isStart(admitted)); + JsonNode settled = fixtures.required("valid").get(2).required("record"); + assertFalse(ManagedToolStageRecords.isStart(settled)); + var substituted = settled.deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) substituted.get("resultRef")).put("resourceId", "other-result"); + assertFalse(ManagedToolStageRecords.isSuccessor(settled, substituted)); + assertTrue(ManagedToolStageRecords.isSuccessor(settled, settled)); + } + + private static JsonNode fixtures() throws IOException { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++, current = current.getParent()) { + Path candidate = current.resolve("packages/core/src/managed-runtime/contracts/managed-tool-stage-v1.fixtures.json"); + if (Files.isRegularFile(candidate)) { + return new ObjectMapper().readTree(candidate.toFile()); + } + } + throw new AssertionError("Shared Managed tool stage fixtures are unavailable"); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerFlywaySchemaTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerFlywaySchemaTest.java index 74b3e056529..50e5f0986ab 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerFlywaySchemaTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerFlywaySchemaTest.java @@ -38,7 +38,7 @@ class RuntimeBrokerFlywaySchemaTest { Map actual = describe(migrate(dataSource(), MigrationVersion.LATEST)); assertThat(expected) - .hasSize(35) + .hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()) .containsKeys( "ma_runtime_slot", "ma_runtime_binding", @@ -81,7 +81,7 @@ class RuntimeBrokerFlywaySchemaTest { JdbcRuntimeBrokerSchema.initialize(source); assertThat(executions.findByIdempotencyKey("retention-key")).usingRecursiveComparison().isEqualTo(settled); Map shape = describe(source); - assertThat(shape).hasSize(35); + assertThat(shape).hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()); assertThat(shape.get("ma_runtime_binding").indexes()).contains( "INDEX [binding_state, last_active_at, binding_id]", "INDEX [tenant_id, binding_state]"); assertThat(shape.get("ma_storage_owner").indexes()) diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java index 865e68e3d0e..a5d111e12bf 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java @@ -61,6 +61,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -370,6 +371,29 @@ class SurfaceAdmissionAcceptanceTest { void belowReadAndBelowFamilyProbesFollowTheRuleClass(SurfaceRegistry entry) throws Exception { switch (entry.ruleClass()) { + case SESSION_EXTENSION_READER -> { + expect(entry, null, 401, "actor_required"); + expect(entry, STRANGER, 404, "session_not_found"); + expectAdmitted(entry, READER); + } + case SESSION_EXTENSION_OPERATOR -> { + expect(entry, null, 401, "actor_required"); + expect(entry, STRANGER, 404, "session_not_found"); + expect(entry, READER, 403, "session_operation_forbidden"); + expect(entry, OPERATOR, 409, "unsupported_feature"); + expect(entry, OWNER_RANK, 409, "unsupported_feature"); + } + case WORKSPACE_EXTENSION_READER -> { + expect(entry, null, 401, "actor_required"); + expect(entry, STRANGER, 404, "extension_package_not_found"); + expectAdmitted(entry, READER); + } + case WORKSPACE_EXTENSION_OPERATOR -> { + expect(entry, null, 401, "actor_required"); + expect(entry, STRANGER, 404, "extension_package_not_found"); + expect(entry, READER, 403, "extension_management_forbidden"); + expect(entry, OPERATOR, 403, "extension_management_forbidden"); + } case WORKSPACE_CREATE -> { expect(entry, null, 401, "actor_required"); expect(entry, STRANGER, 404, "workspace_not_found"); @@ -1468,7 +1492,8 @@ class SurfaceAdmissionAcceptanceTest { String session = artifactArm ? ARTIFACT_SESSION : bound; Map variables = new LinkedHashMap<>(); variables.put("sessionId", session); - variables.put("operationId", entry.capabilities().contains(Capability.MCP_MANAGEMENT) + variables.put("operationId", (entry.capabilities().contains(Capability.MCP_MANAGEMENT) || entry.capabilities().contains(Capability.EXTENSION_CONFIG_GET) + || entry.capabilities().contains(Capability.EXTENSION_CONFIG_APPLY)) ? "11111111-1111-4111-8111-111111111111" : "op_0000000000000000"); variables.put("taskId", entry.capabilities().contains( Capability.TASK_CANCEL) ? settledTask @@ -1487,6 +1512,9 @@ class SurfaceAdmissionAcceptanceTest { ? pendingActionPublic : pendingActionWeb : pendingAction); variables.put("workspaceId", "ws"); + variables.put("extensionId", "ext_11111111-1111-4111-8111-111111111111"); + variables.put("packageRevision", "1"); + variables.put("packageDigest", digest()); variables.put("agentId", "agent-missing"); variables.put("automationId", automation); variables.put("channelId", "channel-missing"); @@ -1507,6 +1535,23 @@ class SurfaceAdmissionAcceptanceTest { } } List query = new ArrayList<>(); + if (entry.capabilities().contains(Capability.EXTENSION_PUBLISH)) { + String manifest = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + ("{\"schemaVersion\":1,\"compilerVersion\":1,\"name\":\"example\",\"version\":\"1\"," + + "\"target\":{\"platform\":\"linux\",\"arch\":\"any\"},\"components\":[]}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + query.add("manifest=" + manifest); + if ("POST".equals(entry.method())) { + query.add("packageDigest=" + digest()); + } + if (entry.surface() == Surface.WEBSHELL) { + query.add("workspaceId=ws"); + } + } else if (entry.surface() == Surface.PUBLIC + && (entry.capabilities().contains(Capability.EXTENSION_GET) + || entry.capabilities().contains(Capability.EXTENSION_ARCHIVE))) { + query.add("packageDigest=" + digest()); + } if (path.startsWith("/internal/managed-tool-publications/") && !path.endsWith("/grants")) { query.add("workspaceId=ws"); @@ -1521,14 +1566,15 @@ class SurfaceAdmissionAcceptanceTest { MockHttpServletRequestBuilder request = switch (entry.method()) { case "GET" -> get(path); case "PATCH" -> patch(path); + case "PUT" -> put(path); case "DELETE" -> delete(path); default -> post(path); }; request.header(TenantContextFilter.HEADER, headerTenant); - if ("POST".equals(entry.method()) || "PATCH".equals(entry.method())) { + if ("POST".equals(entry.method()) || "PATCH".equals(entry.method()) || "PUT".equals(entry.method())) { boolean octets = entry.capabilities().contains( Capability.PUB_SEGMENT) || entry.capabilities().contains( - Capability.PUB_RESOURCE); + Capability.PUB_RESOURCE) || entry.capabilities().contains(Capability.EXTENSION_PUBLISH); request.contentType(octets ? MediaType.APPLICATION_OCTET_STREAM : MediaType.APPLICATION_JSON); request.content(bodyFor(entry, headerTenant, session)); @@ -1553,7 +1599,7 @@ class SurfaceAdmissionAcceptanceTest { request.principal(actor(headerTenant, actor)); } if (!"GET".equals(entry.method()) - && entry.surface() == Surface.PUBLIC) { + && (entry.surface() == Surface.PUBLIC || entry.capabilities().contains(Capability.EXTENSION_PUBLISH))) { request.header("Idempotency-Key", nextKey()); } return request; @@ -1573,6 +1619,22 @@ class SurfaceAdmissionAcceptanceTest { boolean publicSurface = entry.surface() == Surface.PUBLIC; return switch (capability) { case STORE_USER_MESSAGE_CLAIM -> "{\"workspaceId\":\"ws\",\"writerId\":\"writer-probe\",\"writerGeneration\":1,\"promptId\":\"prompt-probe\",\"initialOnly\":false}"; + case EXTENSION_CONFIG_GET -> "{\"sessionId\":\"" + session + "\"}"; + case EXTENSION_CONFIG_APPLY -> { + if (entry == SurfaceRegistry.PUBLIC_SESSION_EXTENSIONS_APPLY) { + yield "{\"expectedConfigRevision\":0,\"extensions\":[]}"; + } + if (entry == SurfaceRegistry.WEBSHELL_SESSION_EXTENSIONS_APPLY) { + yield "{\"sessionId\":\"" + session + "\",\"idempotencyKey\":\"extensions-probe\",\"command\":{\"expectedConfigRevision\":0,\"extensions\":[]}}"; + } + yield "{\"sessionId\":\"" + session + "\",\"operationId\":\"11111111-1111-4111-8111-111111111111\"}"; + } + case EXTENSION_LIST -> "{\"workspaceId\":\"ws\"}"; + case EXTENSION_GET -> "{\"workspaceId\":\"ws\",\"pin\":{\"extensionId\":\"ext_11111111-1111-4111-8111-111111111111\",\"packageRevision\":1,\"packageDigest\":\"" + digest() + "\"}}"; + case EXTENSION_SETTINGS_GET -> "{\"workspaceId\":\"ws\",\"extensionId\":\"ext_11111111-1111-4111-8111-111111111111\"}"; + case EXTENSION_SETTINGS_SET -> publicSurface ? "{\"expectedRevision\":0,\"values\":{}}" + : "{\"workspaceId\":\"ws\",\"extensionId\":\"ext_11111111-1111-4111-8111-111111111111\",\"idempotencyKey\":\"settings-probe\",\"expectedRevision\":0,\"values\":{}}"; + case EXTENSION_OPERATION -> "{\"workspaceId\":\"ws\",\"operationId\":\"operation-probe\"}"; case SESSION_CREATE -> publicSurface ? "{\"agent_id\":\"qwen-code\",\"input\":[]," + "\"workspace\":{\"workspace_id\":\"ws\"}}" diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilterTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilterTest.java index 0e0318d386a..4993092a337 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilterTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SignatureAuthFilterTest.java @@ -28,6 +28,41 @@ class SignatureAuthFilterTest { "0123456789abcdef0123456789abcdef"; private static final ObjectMapper JSON = new ObjectMapper(); + @Test + void authenticatesExtensionArchivesFromADiskSpoolAndSignsTheirManifestQuery() throws Exception { + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getAuth().setMode("signed"); + properties.getAuth().setSigningKey(KEY); + properties.getAuth().setMaxSignedBodyBytes(1); + ServerProperties server = new ServerProperties(); + server.setAddress(InetAddress.getByName("127.0.0.1")); + SignatureAuthFilter filter = filter(new BrokerSecurity(properties, server, new WebMvcProperties())); + byte[] archive = "a binary archive larger than the JSON body limit".getBytes(StandardCharsets.UTF_8); + String path = "/v1/agents/workspaces/workspace/extensions"; + String query = "packageDigest=" + sha256Hex(archive) + "&manifest=eyJzY2hlbWFWZXJzaW9uIjoxfQ"; + String timestamp = now(); + String signature = sign("POST", path, query, "tenant-a", "actor-a", timestamp, archive, "publish"); + for (boolean tamper : new boolean[] {false, true}) { + MockHttpServletRequest request = request("POST", path); + request.setContentType("application/octet-stream"); + request.setContent(archive); + request.setQueryString(tamper ? query + "changed" : query); + request.addHeader(SignatureAuthFilter.ACTOR_HEADER, "actor-a"); + request.addHeader(SignatureAuthFilter.TIMESTAMP_HEADER, timestamp); + request.addHeader(SignatureAuthFilter.SIGNATURE_HEADER, signature); + request.addHeader("Idempotency-Key", "publish"); + MockHttpServletResponse response = new MockHttpServletResponse(); + java.util.concurrent.atomic.AtomicBoolean accepted = new java.util.concurrent.atomic.AtomicBoolean(); + filter.doFilter(request, response, (wrapped, result) -> { + assertThat(wrapped.getInputStream().readAllBytes()).isEqualTo(archive); + assertThat(((HttpServletRequest) wrapped).getUserPrincipal()).isInstanceOf(AuthenticatedTenantActor.class); + accepted.set(true); + }); + assertThat(accepted.get()).isEqualTo(!tamper); + assertThat(response.getStatus()).isEqualTo(tamper ? 401 : 200); + } + } + @Test void staysOutOfOpenModeAndRunsBeforeTheHeaderStandIn() throws Exception { SignatureAuthFilter filter = filter(open()); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java index 92bfa9baa97..b6ed4a7c036 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java @@ -162,6 +162,52 @@ public enum SurfaceRegistry { PUBLIC_WORKSPACE_GET(Method.GET, "/v1/agents/workspaces/{workspaceId}", Surface.PUBLIC, RuleClass.WORKSPACE_DISCOVERY, EnumSet.of(Capability.WORKSPACE_GET)), + PUBLIC_SESSION_EXTENSIONS_GET(Method.GET, "/v1/agents/sessions/{sessionId}/extensions", + Surface.PUBLIC, RuleClass.SESSION_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_CONFIG_GET)), + PUBLIC_SESSION_EXTENSIONS_APPLY(Method.POST, "/v1/agents/sessions/{sessionId}/extensions", + Surface.PUBLIC, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + PUBLIC_SESSION_EXTENSIONS_OPERATION(Method.GET, "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}", + Surface.PUBLIC, RuleClass.SESSION_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_CONFIG_GET)), + PUBLIC_SESSION_EXTENSIONS_CONTINUE(Method.POST, "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/continue", + Surface.PUBLIC, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + PUBLIC_SESSION_EXTENSIONS_RECOVER(Method.POST, "/v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/recover-previous", + Surface.PUBLIC, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + WEBSHELL_SESSION_EXTENSIONS_GET(Method.POST, "/api/agent/web-shell/v1/session-extensions/query", + Surface.WEBSHELL, RuleClass.SESSION_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_CONFIG_GET)), + WEBSHELL_SESSION_EXTENSIONS_APPLY(Method.POST, "/api/agent/web-shell/v1/session-extensions/submit", + Surface.WEBSHELL, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + WEBSHELL_SESSION_EXTENSIONS_CONTINUE(Method.POST, "/api/agent/web-shell/v1/session-extensions/continue", + Surface.WEBSHELL, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + WEBSHELL_SESSION_EXTENSIONS_RECOVER(Method.POST, "/api/agent/web-shell/v1/session-extensions/recover-previous", + Surface.WEBSHELL, RuleClass.SESSION_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_CONFIG_APPLY)), + PUBLIC_EXTENSION_LIST(Method.GET, "/v1/agents/workspaces/{workspaceId}/extensions", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_LIST)), + PUBLIC_EXTENSION_CREATE(Method.POST, "/v1/agents/workspaces/{workspaceId}/extensions", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_PUBLISH)), + PUBLIC_EXTENSION_PUBLISH(Method.PUT, "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageDigest}", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_PUBLISH)), + PUBLIC_EXTENSION_GET(Method.GET, "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_GET)), + PUBLIC_EXTENSION_ARCHIVE(Method.GET, "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}/archive", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_ARCHIVE)), + PUBLIC_EXTENSION_SETTINGS_GET(Method.GET, "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_SETTINGS_GET)), + PUBLIC_EXTENSION_SETTINGS_SET(Method.POST, "/v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_SETTINGS_SET)), + PUBLIC_EXTENSION_OPERATION(Method.GET, "/v1/agents/workspaces/{workspaceId}/extensions/operations/{operationId}", + Surface.PUBLIC, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_OPERATION)), + WEBSHELL_EXTENSION_LIST(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/query", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_LIST)), + WEBSHELL_EXTENSION_PUBLISH(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/publish", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_PUBLISH)), + WEBSHELL_EXTENSION_GET(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/packages/query", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_GET)), + WEBSHELL_EXTENSION_SETTINGS_GET(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/settings/query", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_SETTINGS_GET)), + WEBSHELL_EXTENSION_SETTINGS_SET(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/settings/submit", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_OPERATOR, EnumSet.of(Capability.EXTENSION_SETTINGS_SET)), + WEBSHELL_EXTENSION_OPERATION(Method.POST, "/api/agent/web-shell/v1/workspace-extensions/operations/query", + Surface.WEBSHELL, RuleClass.WORKSPACE_EXTENSION_READER, EnumSet.of(Capability.EXTENSION_OPERATION)), // ManagedChannelController: tenant-scoped channel resources (H5c), read // over the registered connections and filtered below the Workspace read // grant inside the service. @@ -589,7 +635,7 @@ public enum SurfaceRegistry { /** HTTP methods as the mapping annotations declare them. */ private enum Method { - GET("GET"), POST("POST"), PATCH("PATCH"), DELETE("DELETE"); + GET("GET"), POST("POST"), PUT("PUT"), PATCH("PATCH"), DELETE("DELETE"); private final String value; @@ -664,6 +710,15 @@ public enum SurfaceRegistry { SESSION_MODEL_CHANGE, WORKSPACE_LIST, WORKSPACE_GET, + EXTENSION_CONFIG_GET, + EXTENSION_CONFIG_APPLY, + EXTENSION_LIST, + EXTENSION_PUBLISH, + EXTENSION_GET, + EXTENSION_ARCHIVE, + EXTENSION_SETTINGS_GET, + EXTENSION_SETTINGS_SET, + EXTENSION_OPERATION, RUNTIME_NODE_LIST, WORKSPACE_DIRECTORY_CHECK, WORKSPACE_REGISTER, @@ -714,6 +769,12 @@ public enum SurfaceRegistry { * acceptance suite walks these classes to build its probe matrix. */ public enum RuleClass { + /** Trusted actor with a read grant on the persisted Workspace; operation reads also retain actor ownership. */ + SESSION_EXTENSION_READER, + SESSION_EXTENSION_OPERATOR, + WORKSPACE_EXTENSION_READER, + /** ACTIVE Workspace operator plus the explicit extension publication deployment allowlist. */ + WORKSPACE_EXTENSION_OPERATOR, /** * Bound-Session creation: a trusted actor ({@code 401 * actor_required} without one) holding READER or above ({@code diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageServiceTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageServiceTest.java new file mode 100644 index 00000000000..b5af513497c --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/extensions/ManagedExtensionPackageServiceTest.java @@ -0,0 +1,206 @@ +package com.alibaba.qwen.code.managedagent.extensions; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Manifest; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Pin; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.SettingsCommand; +import com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackages.Target; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; +import com.alibaba.qwen.code.runtimebroker.security.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.security.SecretProtector; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ManagedExtensionPackageServiceTest { + @TempDir Path directory; + private JdbcTemplate jdbc; + private DataSourceTransactionManager manager; + private ManagedAgentProperties properties; + private ManagedWorkspaceRegistry workspaces; + private ManagedExtensionArchiveStore archives; + private DefaultListableBeanFactory beans; + private ManagedExtensionPackageService service; + private final ObjectMapper json = new ObjectMapper(); + private final Manifest manifest = new Manifest(1, 1, "complete", "1.0.0", + new Target("linux", "any"), List.of("skills", "settings")); + private final byte[] bytes = "immutable archive bytes".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + @BeforeEach + void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:extensions-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); + ManagedSchema.initialize(source); + jdbc = new ManagedSchemaJdbcTemplate(source); + manager = new DataSourceTransactionManager(source); + jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation, storage_id, display_name," + + " config_ref, policy_ref, state) VALUES ('tenant', 'workspace', 1, 'storage', 'Workspace', 'config', 'policy', 'ACTIVE')"); + properties = new ManagedAgentProperties(); + properties.getExtensions().setEnabled(true); + properties.getExtensions().setArtifactDirectory(directory.toString()); + properties.getExtensions().setAllowedActors(List.of("operator")); + workspaces = mock(ManagedWorkspaceRegistry.class); + when(workspaces.accessOf(anyString(), anyString(), anyString())).thenReturn(WorkspaceAccess.NONE); + when(workspaces.accessOf("tenant", "operator", "workspace")).thenReturn(WorkspaceAccess.OPERATOR); + when(workspaces.accessOf("tenant", "reader", "workspace")).thenReturn(WorkspaceAccess.READER); + archives = new ManagedExtensionArchiveStore(properties); + beans = new DefaultListableBeanFactory(); + beans.registerSingleton("secrets", new AesGcmSecretProtector("key", new byte[32])); + service = service(); + } + + @Test + void keepsFullArchiveAndEncryptedSettingsAcrossControlPlaneRestart() throws Exception { + String digest = DigestUtils.sha256Hex(bytes); + var publication = service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, new ByteArrayInputStream(bytes)); + assertThat(publication.state()).isEqualTo("settled"); + var configured = service.saveSettings("tenant", "operator", "workspace", publication.extensionId(), "settings", + new SettingsCommand(0, Map.of("API_KEY", "private-value", "REGION", "test"))); + assertThat(configured.revision()).isEqualTo(1); + assertThat(service.saveSettings("tenant", "operator", "workspace", publication.extensionId(), "settings", + new SettingsCommand(0, Map.of("API_KEY", "private-value", "REGION", "test")))).isEqualTo(configured); + assertThatThrownBy(() -> service.saveSettings("tenant", "operator", "workspace", publication.extensionId(), "settings", + new SettingsCommand(0, Map.of("API_KEY", "changed-value", "REGION", "test")))) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("idempotency_conflict")); + assertThat(jdbc.queryForObject("SELECT encrypted_values FROM ma_extension_settings", String.class)) + .doesNotContain("private-value"); + service = service(); + var pin = new Pin(publication.extensionId(), 1, digest, 1L); + var restored = service.resolvePin("tenant", "workspace", pin); + assertThat(Files.readAllBytes(restored.archive())).isEqualTo(bytes); + assertThat(restored.settings()).containsEntry("API_KEY", "private-value"); + assertThat(restored.toString()).doesNotContain("private-value"); + assertThat(json.writeValueAsString(service.settings("tenant", "reader", "workspace", publication.extensionId()))) + .contains("API_KEY").doesNotContain("private-value"); + assertThat(service.list("tenant", "reader", "workspace").extensions()).hasSize(1); + } + + @Test + void distinguishesUnavailableEncryptionFromMissingSettingsAfterRestart() throws Exception { + String digest = DigestUtils.sha256Hex(bytes); + var publication = service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, new ByteArrayInputStream(bytes)); + var command = new SettingsCommand(0, Map.of("API_KEY", "private-value")); + service.saveSettings("tenant", "operator", "workspace", publication.extensionId(), "settings", command); + beans.destroySingleton("secrets"); + service = service(); + var pin = new Pin(publication.extensionId(), 1, digest, 1L); + assertThatThrownBy(() -> service.resolvePin("tenant", "workspace", pin)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getCode()).isEqualTo("extension_settings_unavailable"); + }); + assertThatThrownBy(() -> service.saveSettings("tenant", "operator", "workspace", + publication.extensionId(), "settings", command)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getCode()).isEqualTo("extension_settings_unavailable"); + }); + assertThatThrownBy(() -> service.resolvePin("tenant", "workspace", + new Pin(publication.extensionId(), 1, digest, 2L))) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(error.getCode()).isEqualTo("extension_package_not_found"); + }); + assertThat(service.resolvePin("tenant", "workspace", + new Pin(publication.extensionId(), 1, digest, null)).settings()).isEmpty(); + } + + @Test + void reservesOneStableIdentityAndRevisionAndResumesInterruptedUpload() throws Exception { + String digest = DigestUtils.sha256Hex(bytes); + InputStream interrupted = new InputStream() { + @Override public int read() throws IOException { throw new IOException("interrupted"); } + }; + assertThatThrownBy(() -> service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, interrupted)).isInstanceOf(IOException.class); + assertThat(jdbc.queryForObject("SELECT state FROM ma_extension_operation", String.class)).isEqualTo("pending"); + assertThat(service.list("tenant", "reader", "workspace").extensions()).isEmpty(); + var resumed = service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, new ByteArrayInputStream(bytes)); + assertThat(resumed.revision()).isEqualTo(1); + assertThat(service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, InputStream.nullInputStream())).isEqualTo(resumed); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_extension_package", Integer.class)).isEqualTo(1); + assertThatThrownBy(() -> service.publish("tenant", "operator", "workspace", null, "a".repeat(64), + "create", manifest, new ByteArrayInputStream(bytes))).isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("idempotency_conflict")); + } + + @Test + void requiresDeploymentPermissionInAdditionToOperatorAndDoesNotConsumeDeniedUpload() { + var stream = new ByteArrayInputStream(bytes); + assertThatThrownBy(() -> service.publish("tenant", "reader", "workspace", null, DigestUtils.sha256Hex(bytes), + "create", manifest, stream)).isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("extension_management_forbidden")); + assertThat(stream.available()).isEqualTo(bytes.length); + properties.getExtensions().setAllowedActors(List.of()); + assertThatThrownBy(() -> service.publish("tenant", "operator", "workspace", null, DigestUtils.sha256Hex(bytes), + "create", manifest, stream)).isInstanceOf(ManagedAgentException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_extension_operation", Integer.class)).isZero(); + assertThatThrownBy(() -> service.list("other-tenant", "operator", "workspace")).isInstanceOf(ManagedAgentException.class); + } + + @Test + void rejectsSettingsRevisionConflictsCrossScopePinsAndArchiveDrift() throws Exception { + String digest = DigestUtils.sha256Hex(bytes); + var published = service.publish("tenant", "operator", "workspace", null, digest, + "create", manifest, new ByteArrayInputStream(bytes)); + service.saveSettings("tenant", "operator", "workspace", published.extensionId(), "settings", + new SettingsCommand(0, Map.of("API_KEY", "secret"))); + assertThatThrownBy(() -> service.saveSettings("tenant", "operator", "workspace", published.extensionId(), "stale", + new SettingsCommand(0, Map.of("API_KEY", "next")))) + .isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("extension_settings_revision_conflict")); + var pin = new Pin(published.extensionId(), 1, digest, 1L); + assertThatThrownBy(() -> service.resolvePin("other-tenant", "workspace", pin)).isInstanceOf(ManagedAgentException.class); + var restored = service.resolvePin("tenant", "workspace", pin); + Files.writeString(restored.archive(), "changed archive"); + assertThatThrownBy(() -> service.resolvePin("tenant", "workspace", pin)).isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("extension_package_unavailable")); + } + + @Test + void validatesManifestWithoutResolvingHostEnvironment() throws Exception { + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(manifest)); + assertThat(service.decodeManifest(encoded)).isEqualTo(manifest); + String invalid = Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(Map.of( + "schemaVersion", 1, "compilerVersion", 1, "name", "complete", "version", "1.0", + "target", Map.of("platform", "windows", "arch", "x64"), "components", List.of("skills")))); + assertThatThrownBy(() -> service.decodeManifest(invalid)).isInstanceOf(ManagedAgentException.class); + assertThat(new SettingsCommand(0, Map.of("API_KEY", "private-value")).toString()).doesNotContain("private-value"); + } + + private ManagedExtensionPackageService service() { + return new ManagedExtensionPackageService(jdbc, manager, workspaces, properties, archives, + json, beans.getBeanProvider(SecretProtector.class)); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnectorTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnectorTest.java index 08457c684bc..19335da33a1 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnectorTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnectorTest.java @@ -44,6 +44,41 @@ import static org.mockito.Mockito.when; class HarnessPoolConnectorTest { private static final String DIGEST = "sha256:" + "a".repeat(64); + @Test + void extensionCommandsKeepTheSavedOwnerAndNegotiatedCapability() { + var placements = mock(HarnessPlacementStore.class); + var a = endpoint("a", 4101); + var b = endpoint("b", 4102); + var leafA = leaf(); + var leafB = leaf(); + var clientA = client("a"); + var clientB = client("b"); + var features = List.of("harness-pool-owner/1", "harness-instance-lifecycle/1", "session-extension-packages/1"); + when(clientA.capabilities().getFeatures()).thenReturn(features); + when(clientB.capabilities().getFeatures()).thenReturn(features); + String ownerBoot = clientB.capabilities().getBootId(); + when(placements.get("tenant", "held")).thenReturn(new Placement("tenant", "held", "workspace", "b", + ownerBoot, 2, "ACTIVE")); + Map command = Map.of("operationId", "original"); + Map result = Map.of("phase", "active"); + when(leafB.applyExtensions("tenant", "held", command)).thenReturn(result); + when(leafB.continueExtensionOperation("tenant", "held", "original", true)).thenReturn(result); + try (var pool = new HarnessPoolConnector(settings(), placements, + () -> new HarnessDiscovery.Snapshot(List.of(a, b), true), + endpoint -> endpoint.instanceId().equals("a") ? leafA : leafB, null, null, + endpoint -> endpoint.instanceId().equals("a") ? clientA : clientB)) { + assertThat(pool.supportsSessionExtensions()).isFalse(); + pool.refresh(); + assertThat(pool.supportsSessionExtensions()).isTrue(); + assertThat(pool.applyExtensions("tenant", "held", command)).isEqualTo(result); + assertThat(pool.continueExtensionOperation("tenant", "held", "original", true)).isEqualTo(result); + verify(leafA, never()).applyExtensions(anyString(), anyString(), any()); + verify(leafA, never()).continueExtensionOperation(anyString(), anyString(), anyString(), eq(true)); + verify(placements, never()).assign(anyString(), anyString(), anyString(), anyString()); + verify(placements, never()).takeover(anyString(), anyString(), any(), anyString(), anyString()); + } + } + @Test void everyVerbKeepsItsPlacementAndObservationNeverCreatesOne() { var settings = settings(); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index 062e6e0ab1a..eecd4b78a30 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -1270,6 +1270,62 @@ class QwenHostedHarnessConnectorTest { } } + @ParameterizedTest + @CsvSource({"false,0", "false,1", "true,0", "true,1"}) + void sshPackageExecutionRequiresItsPersistedContractAndNeverEnablesRemoteWorktrees(boolean enabled, int contractVersion) { + var client = mock(HostedHarnessClient.class); + var capabilities = mock(HostedHarnessCapabilities.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(BOOT_ID); + when(capabilities.getFeatures()).thenReturn(List.of("public-child-agents/1", "approval-selection/1", "session-extension-packages/1")); + when(capabilities.getLifecycleV2Profiles()).thenReturn(List.of("hosted-workspace-shell/1")); + var attached = mock(HarnessSessionRef.class); + when(attached.getHarnessBootId()).thenReturn(BOOT_ID); + when(attached.getApprovalMode()).thenReturn("default"); + when(client.createSession(any())).thenReturn(attached); + when(client.loadSession(any())).thenReturn(attached); + var sessions = sessions(); + var session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null, null, "ACTIVE", + null, null, 0, 0, 0, 1, 1, null, 1, + new ContextBinding("tenant-a", "selected-workspace", 1, "storage", ".", + WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "default", "hosted-workspace-shell/1", null, contractVersion); + when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session); + var settings = properties(); + settings.getExtensions().setEnabled(enabled); + settings.getManagedExtensions().setEnabled(enabled); + settings.getHarness().setWorkspaceFilesEnabled(true); + settings.getHarness().setWorkspaceShellEnabled(true); + settings.getHarness().setWorkspaceChildAgentsEnabled(true); + settings.getRuntimeBroker().setDurableLocalProcess(true); + settings.getRuntimeBroker().setChildWorkspacesEnabled(true); + settings.getRuntimeBroker().setChildSnapshotsEnabled(true); + var actions = mock(ManagedActionStore.class); + when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default"); + var ssh = mock(SshWorkspaceStore.class); + when(ssh.runtimeStorage("tenant-a", "storage")).thenReturn(new SshRuntimeStorage("node", "storage", "/remote", + Map.of("identity", Map.of("root", "/remote"), "marker", Map.of("root", "/remote", "storageId", "storage"), + "qwenVersion", "0.25.1"))); + for (boolean loadExisting : List.of(false, true)) { + var connector = new QwenHostedHarnessConnector(settings, sessions, mock(WorkspaceExecutionStore.class), actions, null, null, ssh); + ReflectionTestUtils.setField(connector, "client", client); + connector.createOrLoad("tenant-a", SESSION_ID, loadExisting); + } + var create = ArgumentCaptor.forClass(CreateHarnessSession.class); + var load = ArgumentCaptor.forClass(LoadHarnessSession.class); + verify(client).createSession(create.capture()); + verify(client).loadSession(load.capture()); + Map created = ReflectionTestUtils.invokeMethod(create.getValue(), "toJson"); + Map loaded = ReflectionTestUtils.invokeMethod(load.getValue(), "toJson"); + for (var wire : List.of(created, loaded)) { + assertThat(wire).doesNotContainKeys("childWorkspaces", "childSnapshots"); + if (enabled && contractVersion == 1) { + assertThat(wire).containsEntry("publicChildAgentsVersion", 1).doesNotContainKey("foregroundOnly"); + } else { + assertThat(wire).containsEntry("foregroundOnly", true); + } + } + } + @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "plan", "unknown"}) diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunchTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunchTest.java new file mode 100644 index 00000000000..09ad516427f --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedExtensionAgentLaunchTest.java @@ -0,0 +1,85 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ManagedExtensionAgentLaunchTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private final SessionRecord parent = new SessionRecord("tenant", "parent", "agent", null, "Parent", "ACTIVE", + null, null, 0, 0, 0, 0, 0, null, 0, new ContextBinding("tenant", "workspace", 1, "storage", ".", "config", 1), + "yolo", WorkspaceToolProfiles.SHELL); + private JdbcTemplate jdbc; + private ObjectNode owner; + + @BeforeEach + void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:extension-agent-owner-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); + ManagedSchema.initialize(source); + jdbc = new JdbcTemplate(source); + String request = DigestUtils.sha256Hex("request"); + String capability = ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST; + jdbc.update("INSERT INTO ma_runtime_slot (request_key, tenant_id, workspace_id, workspace_generation," + + " canonical_cwd, capability_digest, isolation_class, isolation_key, provisioner_kind, storage_id," + + " last_generation, active_binding_id) VALUES (?, 'tenant', 'workspace', '1', '/workspace', ?," + + " 'session', 'parent', 'local-process', 'storage', 1, 'binding')", request, capability); + jdbc.update("INSERT INTO ma_runtime_binding (binding_id, request_key, scope_key, tenant_id, workspace_id," + + " workspace_generation, canonical_cwd, capability_digest, isolation_class, isolation_key, provisioner_kind," + + " storage_id, runtime_generation, binding_state, runtime_instance_id, runtime_lease_id," + + " attestation_generation, drain_requested, operation_generation, record_version, last_active_at)" + + " VALUES ('binding', ?, ?, 'tenant', 'workspace', '1', '/workspace', ?, 'session', 'parent'," + + " 'local-process', 'storage', 1, 'READY', 'worker', 'lease', 1, FALSE, 0, 1, 0)", + request, DigestUtils.sha256Hex("scope"), capability); + owner = JSON.createObjectNode().put("executionMode", "developer-process-group"); + owner.putObject("runtime").put("bindingId", "binding").put("generation", "1").put("workspaceGeneration", "1"); + } + + @Test + void onlyTheOriginalReadyParentOwnerCanLaunchFromTheActivePackageSnapshot() { + verify(); + jdbc.update("UPDATE ma_runtime_slot SET active_binding_id='replacement'"); + assertThrows(ManagedAgentException.class, this::verify); + jdbc.update("UPDATE ma_runtime_slot SET active_binding_id='binding'"); + jdbc.update("UPDATE ma_runtime_binding SET drain_requested=TRUE"); + assertThrows(ManagedAgentException.class, this::verify); + jdbc.update("UPDATE ma_runtime_binding SET drain_requested=FALSE, runtime_generation=2"); + jdbc.update("UPDATE ma_runtime_slot SET last_generation=2"); + assertThrows(ManagedAgentException.class, this::verify); + } + + @Test + void rejectsAnotherHarnessStorageOrExecutionProfileWithoutBorrowingItsOwner() { + jdbc.update("UPDATE ma_runtime_binding SET isolation_key='child'"); + assertThrows(ManagedAgentException.class, this::verify); + jdbc.update("UPDATE ma_runtime_binding SET isolation_key='parent', storage_id='other-storage'"); + assertThrows(ManagedAgentException.class, this::verify); + jdbc.update("UPDATE ma_runtime_binding SET storage_id='storage', capability_digest=?", WorkspaceExecutionProfile.CAPABILITY_DIGEST); + assertThrows(ManagedAgentException.class, this::verify); + jdbc.update("UPDATE ma_runtime_binding SET capability_digest=?", ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST); + owner.put("executionMode", "linux-cgroup"); + assertThrows(ManagedAgentException.class, this::verify); + owner.put("executionMode", "developer-process-group"); + ((ObjectNode) owner.get("runtime")).put("workspaceGeneration", "2"); + assertThrows(ManagedAgentException.class, this::verify); + } + + private void verify() { + ManagedExtensionAgentLaunch.requireRuntimeOwner(jdbc, parent, owner); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java index 2522d73fb66..2cd5937acc3 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java @@ -135,7 +135,7 @@ class SessionRowLockOrderTest { // unaudited. private static final Set SIBLING_SESSION_LOCKERS = Set.of( "HarnessPlacementStore.java", - "ManagedActionStore.java", "ManagedExtensionRecordStore.java", + "ManagedActionStore.java", "ManagedExtensionAgentLaunch.java", "ManagedExtensionRecordStore.java", "ManagedLegacySessionGuard.java", "ManagedProjectionResourceCollector.java", "ManagedRuntimeRetentionGuard.java", @@ -207,10 +207,15 @@ class SessionRowLockOrderTest { // rows or accesses Turn rows. Map.entry("ManagedHostedRecoveryRecords.java", Set.of("HistoricalResources.checkpointHistory/1")), + // Package configuration delegates to a same-named JSON-only validator. + Map.entry("ManagedMcpSettings.java", + Set.of("ManagedMcpSettings.require/1")), Map.entry("ManagedSchemaJdbcTemplate.java", Set.of("ManagedSchemaJdbcTemplate.getSingleColumnRowMapper/1")), + // Both publisher overloads avoid Session and Turn rows. Map.entry("ManagedSessionStore.java", - Set.of("ManagedSessionStore.sha256/1", + Set.of("ManagedSessionStore.publishAgentResource/6", + "ManagedSessionStore.sha256/1", "ManagedSessionStore.storedTransaction/1", "ManagedSessionStore.usesDataSource/1")), Map.entry("ManagedSnapshotStore.java", @@ -398,6 +403,7 @@ class SessionRowLockOrderTest { assertThat(audit.lockers()).containsExactlyInAnyOrder( "insertTurnCommand", "insertCancelCommand", "changeSessionModel", "changeSessionApproval", "requireMcpManagement", "requireMcpSettingsCommit", + "requireConfigurationCommit", "requireExtensionManagement", "insertSessionCommand", "insertWorkspaceSessionCommand", "insertChildSessionCommand", "insertPublicChildSessionCommand", "insertInternalTeamLeadSessionCommand", @@ -489,6 +495,7 @@ class SessionRowLockOrderTest { "recordHarnessEvents", "recordRecoveryAdmission", "releaseTurnLease", "renewTurn", "requireTurn", "requireTurnForUpdate", "requirePolicyChange", "requireMcpManagement", "requireMcpSettingsCommit", + "requireConfigurationCommit", "requireExtensionManagement", "requireChildExtensionBootstrap", "requireMcpManagementIdle", "retractContinuationOutput", "retractHarnessTurnOutput", "scheduleTurnRetry", "settleTurnCommands", diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java index 91b6c57f815..e374aa7d541 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java @@ -769,8 +769,8 @@ class WorkspaceCsiApplicationInventoryTest { @Test void inventoriesEveryDeclaredTableAndPreservesRealPrivateCreationFacts() throws Exception { var snapshot = inspect(); - assertThat(snapshot.tables()).hasSize(32); - assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(749); + assertThat(snapshot.tables()).hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()); + assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(807); assertThat(snapshot.blockers()).noneMatch(blocker -> blocker.code().equals("schema_drift") || blocker.code().equals("query_unavailable")); for (String table : new String[] {"ma_session", "ma_public_event", "ma_event_stream", "ma_storage", "ma_operation", @@ -784,6 +784,32 @@ class WorkspaceCsiApplicationInventoryTest { && blocker.code().equals("missing_or_extra_original")); } + @Test + void originalUserMessageRowsRemainOutsideTheNativeFirstLane() throws Exception { + jdbc.update("INSERT INTO ma_user_message_queue (tenant_id, session_id) VALUES ('tenant', ?)", session); + jdbc.update("INSERT INTO ma_user_message (tenant_id, session_id, message_id, sequence_number, revision," + + " actor_id, input_json, state) VALUES ('tenant', ?, 'message', 1, 1, 'actor', '[]', 'queued')", + session); + jdbc.update("INSERT INTO ma_user_message_command (tenant_id, session_id, command_key, actor_id," + + " request_digest, result_json) VALUES ('tenant', ?, ?, 'actor', ?, '{}')", + session, "a".repeat(64), "sha256:" + "b".repeat(64)); + var before = List.of( + jdbc.queryForList("SELECT * FROM ma_user_message_queue"), + jdbc.queryForList("SELECT * FROM ma_user_message"), + jdbc.queryForList("SELECT * FROM ma_user_message_command")); + var snapshot = inspect(); + for (String table : List.of("ma_user_message_queue", "ma_user_message", "ma_user_message_command")) { + assertThat(snapshot.table(table).rows()).hasSize(1) + .allMatch(row -> row.membership() == WorkspaceCsiApplicationInventory.Membership.ORIGINAL); + assertThat(snapshot.blockers()).anyMatch(blocker -> blocker.table().equals(table) + && blocker.code().equals("unsupported_first_lane")); + } + assertThat(List.of( + jdbc.queryForList("SELECT * FROM ma_user_message_queue"), + jdbc.queryForList("SELECT * FROM ma_user_message"), + jdbc.queryForList("SELECT * FROM ma_user_message_command"))).usingRecursiveComparison().isEqualTo(before); + } + @Test void mainModelAndRetryColumnsRetainTheirActualTypedValues() throws Exception { var before = inspect(); @@ -1215,7 +1241,7 @@ class WorkspaceCsiApplicationInventoryTest { var valueOnly = WorkspaceCsiApplicationInventory.expectedImage(observed.tables()); var restored = WorkspaceCsiApplicationInventory.restoreCanonical(valueOnly.bytes()); assertThat(restored.bytes()).isEqualTo(valueOnly.bytes()); - assertThat(restored.tables()).hasSize(32); + assertThat(restored.tables()).hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()); assertThat(restored.rowCount()).isEqualTo(observed.rowCount()); assertThat(observed.blockers()).isNotEmpty(); byte[] trailing = java.util.Arrays.copyOf(valueOnly.bytes(), valueOnly.bytes().length + 1); @@ -1423,7 +1449,8 @@ class WorkspaceCsiApplicationInventoryTest { var result = readKeys(expected, queries); assertThat(result.blockers()).isEmpty(); assertThat(result.bytes()).isEqualTo(expected.bytes()); - assertThat(queries.stream().filter(sql -> sql.startsWith("SELECT t.* FROM ma_"))).hasSize(32); + assertThat(queries.stream().filter(sql -> sql.startsWith("SELECT t.* FROM ma_"))) + .hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()); assertThat(queries).noneMatch(sql -> sql.contains("FOR UPDATE")); assertThat(queries).anyMatch(sql -> sql.startsWith("SELECT t.* FROM ma_storage_owner t WHERE") && sql.contains("storage_key = ?")); assertThat(queries).noneMatch(sql -> sql.contains("physical_key = ?")); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java index b7bf1cd35cd..4c3a507a570 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java @@ -235,7 +235,7 @@ class WorkspaceCsiNativeActivationGateTest { .map(row -> JSON.valueToTree(new TreeMap<>(row)).toString()).sorted().toList()); } assertThat(rows).containsKeys("ma_delivery", "ma_session", "ma_session_head", "ma_request_receipt"); - assertThat(rows).hasSize(36); + assertThat(rows).hasSize(39); return rows; } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java index f7e74c01bba..f41e7c99dfc 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java @@ -645,7 +645,7 @@ class WorkspaceCsiSessionMainTest { .sorted(Comparator.comparing(JsonNode::toString)).toList(); result.put(table, json.valueToTree(rows)); } - assertThat(result).hasSize(36); + assertThat(result).hasSize(39); return result; } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java index 4a64a8e0d0c..7f79e5f7922 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java @@ -461,7 +461,7 @@ class WorkspaceCsiSqlWorkerMySqlIT { } } assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ?" - + " AND table_type = 'BASE TABLE'", Integer.class, schema)).isEqualTo(36); + + " AND table_type = 'BASE TABLE'", Integer.class, schema)).isEqualTo(39); verify(provisioner).ensureResource(any(), any(), any()); verify(provisioner).provision(any(), any()); String evidence = System.getProperty("csi.acceptance.evidence"); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfileTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfileTest.java new file mode 100644 index 00000000000..80bc327adff --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/ManagedExtensionExecutionProfileTest.java @@ -0,0 +1,19 @@ +package com.alibaba.qwen.code.runtimebroker; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedExtensionExecutionProfileTest { + @Test + void requiresExplicitDelegationAndKeepsDeveloperModeDistinct() { + String linux = ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST; + String developer = ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST; + assertThrows(IllegalArgumentException.class, () -> ManagedExtensionExecutionProfile.configuration(linux, "/private/cache", "")); + assertThrows(IllegalArgumentException.class, () -> ManagedExtensionExecutionProfile.configuration(developer, "/private/cache", "/cgroup")); + assertEquals("linux-cgroup", ManagedExtensionExecutionProfile.configuration(linux, "/private/cache", "/sys/fs/cgroup/extensions").get("mode")); + assertEquals("developer-process-group", ManagedExtensionExecutionProfile.configuration(developer, "/private/cache", "").get("mode")); + assertFalse(ManagedExtensionExecutionProfile.admits(WorkspaceExecutionProfile.CAPABILITY_DIGEST)); + assertThrows(IllegalArgumentException.class, () -> ManagedExtensionExecutionProfile.mode(WorkspaceExecutionProfile.CAPABILITY_DIGEST)); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionMetadataControlTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionMetadataControlTest.java new file mode 100644 index 00000000000..ac00a1432d2 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeExtensionMetadataControlTest.java @@ -0,0 +1,150 @@ +package com.alibaba.qwen.code.runtimebroker; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimeExtensionMetadataControlTest { + private static final Instant NOW = Instant.parse("2026-10-24T00:00:00Z"); + private static final String OPERATION = "550e8400-e29b-41d4-a716-446655440000"; + private static final RuntimeScope SCOPE = new RuntimeScope("tenant", "workspace", "1", "/workspace", + ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST, "session"); + + @Test + void aStatusProbeDoesNotCreateAPlacementOrToolSession() { + try (var fixture = new Fixture(false)) { + assertEquals("managed_extension_owner_unavailable", failure(fixture.broker.extensionControl("harness", null, null, status())).getCode()); + assertTrue(fixture.bindings.findByHarnessSession("tenant", "harness", null, 100).isEmpty()); + assertEquals(0, fixture.observations.get()); + assertEquals(0, fixture.controls.get()); + } + } + + @Test + void restoredStatusObservesOnlyTheOriginalLeaseWithoutAcquiringItsMount() { + try (var fixture = new Fixture(true)) { + var before = fixture.original; + var result = fixture.broker.extensionControl("harness", null, null, status()).toCompletableFuture().join(); + assertEquals(Map.of("bindingId", before.getBindingId(), "generation", Long.toString(before.getGeneration())), result.get("runtime")); + assertEquals(1, fixture.observations.get()); + assertEquals(1, fixture.attestations.get()); + assertEquals(1, fixture.controls.get()); + assertSame(before, fixture.bindings.findById(before.getBindingId())); + assertEquals(0, fixture.sessions.countActiveByBinding(before.getBindingId(), before.getGeneration())); + fixture.observable = false; + assertEquals("managed_extension_owner_unavailable", failure(fixture.broker.extensionControl("harness", + before.getBindingId(), before.getGeneration(), status())).getCode()); + assertEquals(1, fixture.controls.get()); + } + } + + @Test + void originalIdentityAndRecoveryAdmissionCannotFallBackToAnotherOwner() { + try (var fixture = new Fixture(true)) { + var binding = fixture.original; + assertEquals("managed_extension_owner_unavailable", failure(fixture.broker.extensionControl("harness", + binding.getBindingId(), binding.getGeneration() + 1, status())).getCode()); + assertEquals("managed_extension_owner_unavailable", failure(fixture.broker.extensionControl("another-harness", + binding.getBindingId(), binding.getGeneration(), status())).getCode()); + var reconcile = Map.of("kind", "extension-reconcile", "operationId", OPERATION, "expectedRevision", 0, "revision", 1); + assertThrows(RuntimeBrokerException.class, () -> fixture.broker.extensionControl("harness", null, null, reconcile)); + var draining = fixture.bindings.compareAndSet(binding, + binding.withState(RuntimeBindingRecord.State.DRAINING, binding.getLease(), NOW).withDrainRequested(true, NOW)); + assertEquals("managed_extension_owner_unavailable", failure(fixture.broker.extensionControl("harness", + binding.getBindingId(), binding.getGeneration(), Map.of("kind", "extension-activate", "operationId", OPERATION, + "expectedRevision", 0, "revision", 1))).getCode()); + fixture.broker.extensionControl("harness", binding.getBindingId(), binding.getGeneration(), reconcile).toCompletableFuture().join(); + assertSame(draining, fixture.bindings.findById(binding.getBindingId())); + assertEquals(1, fixture.controls.get()); + } + } + + private static Map status() { + return Map.of("kind", "extension-status", "operationId", OPERATION); + } + + private static RuntimeBrokerException failure(CompletionStage result) { + return (RuntimeBrokerException) assertThrows(CompletionException.class, + () -> result.toCompletableFuture().join()).getCause(); + } + + private static final class Fixture implements AutoCloseable { + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + private final InMemoryRuntimeBindingRepository bindings = new InMemoryRuntimeBindingRepository(clock, () -> "binding"); + private final InMemoryRuntimeSessionRepository sessions = new InMemoryRuntimeSessionRepository(); + private final AtomicInteger observations = new AtomicInteger(); + private final AtomicInteger attestations = new AtomicInteger(); + private final AtomicInteger controls = new AtomicInteger(); + private final RuntimeBindingRecord original; + private final RuntimeBrokerService broker; + private boolean observable = true; + + Fixture(boolean installed) { + if (installed) { + var candidate = bindings.findOrCreate(new RuntimeProvisionRequest(SCOPE, "harness", "fixture")); + candidate = bindings.claimOperation(candidate.getBindingId(), "original-broker", Duration.ofMinutes(1)); + var seed = candidate.getProvisionSeed(); + var lease = new RuntimeLease(seed.getProvisionalRuntimeId(), URI.create("http://127.0.0.1:9"), + seed.getToken(), seed.getLeaseId(), seed.getEpoch()); + original = bindings.compareAndSet(candidate, candidate.withAttestation(lease, + new RuntimeResourceHandle("fixture", 1, Map.of("worker", "original")), NOW, NOW)); + } else { + original = null; + } + RuntimeProvisioner provisioner = new RuntimeProvisioner() { + @Override + public CompletionStage provision(RuntimeProvisionRequest request) { + throw new AssertionError("Extension metadata must never provision a Runtime"); + } + + @Override + public CompletionStage reconcile(RuntimeProvisionRequest request, RuntimeProvisionSeed seed, + RuntimeResourceHandle handle, RuntimeLease lease) { + observations.incrementAndGet(); + assertEquals(original.getRequest(), request); + assertEquals(original.getResourceHandle(), handle); + assertSame(original.getLease(), lease); + return CompletableFuture.completedFuture(observable ? RuntimeObservation.ready(handle, lease.getEndpoint(), + lease.getRuntimeInstanceId(), lease.getLeaseId(), lease.getEpoch()) : RuntimeObservation.unknown(handle)); + } + }; + var transport = (RuntimeTransport) Proxy.newProxyInstance(RuntimeTransport.class.getClassLoader(), new Class[] {RuntimeTransport.class}, + (proxy, method, args) -> { + if ("attest".equals(method.getName())) { + attestations.incrementAndGet(); + var lease = original.getLease(); + var seed = original.getProvisionSeed(); + return CompletableFuture.completedFuture(new RuntimeAttestation(lease.getRuntimeInstanceId(), seed.getGatewayIncarnation(), + lease.getLeaseId(), lease.getEpoch(), SCOPE, seed.getProvisionRequestId())); + } + if ("extensionControl".equals(method.getName())) { + controls.incrementAndGet(); + assertEquals(original.getBindingId(), ((RuntimeBindingRecord) args[0]).getBindingId()); + assertEquals("harness", args[1]); + return CompletableFuture.completedFuture(Map.of("state", "outcome_unknown")); + } + throw new AssertionError("Extension metadata must not acquire or execute a tool: " + method.getName()); + }); + broker = new RuntimeBrokerService(id -> CompletableFuture.completedFuture(SCOPE), provisioner, transport, + bindings, sessions, new InMemoryToolExecutionRepository(clock), "new-broker", Duration.ofMinutes(1), Duration.ofMinutes(1)); + } + + @Override + public void close() { broker.close(); } + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java index 2dc6e809877..ff77d83ac4a 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java @@ -55,7 +55,7 @@ class ManagedSchemaTest { } @Test - void initializesExactly35TablesAndValidatesWithoutRewriting() throws Exception { + void initializesExactlyTheTargetTablesAndValidatesWithoutRewriting() throws Exception { DataSource source = source(); ManagedSchema.initialize(source); ManagedSchema.initialize(source); @@ -64,7 +64,7 @@ class ManagedSchemaTest { try (var connection = source.getConnection()) { assertEquals( ManagedSchemaContract.TABLES, ManagedSchemaCatalog.tables(connection).keySet()); - assertEquals(35, ManagedSchemaCatalog.tables(connection).size()); + assertEquals(ManagedSchemaContract.TABLES.size(), ManagedSchemaCatalog.tables(connection).size()); } } @@ -565,8 +565,10 @@ class ManagedSchemaTest { !tables.isEmpty() && ManagedSchemaContract.TABLES.containsAll( tables))); + Set originalTargets = new java.util.HashSet<>(ManagedSchemaContract.TABLES); + originalTargets.removeAll(Set.of("ma_extension_package", "ma_extension_settings", "ma_extension_operation")); assertEquals( - ManagedSchemaContract.TABLES, + originalTargets, inventory.values().stream() .flatMap(java.util.Collection::stream) .collect(java.util.stream.Collectors.toSet())); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisionerTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisionerTest.java index 816a26c7a52..0f04850ae89 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisionerTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/SshRuntimeProvisionerTest.java @@ -55,6 +55,7 @@ class SshRuntimeProvisionerTest { WorkspaceExecutionProfile.CAPABILITY_DIGEST, "session"); private final List> commands = new ArrayList<>(); private Map boot; + private Map attestation; private String state = "INTENT"; private int startingObservations; private boolean corruptProof; @@ -68,7 +69,7 @@ class SshRuntimeProvisionerTest { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/internal/managed-runtime/v3/attest", exchange -> { assertEquals("Bearer " + boot.get("token"), exchange.getRequestHeaders().getFirst("Authorization")); - exchange.getRequestBody().readAllBytes(); + attestation = JsonCodec.parseObject(exchange.getRequestBody().readAllBytes(), "test attestation"); byte[] response = JsonCodec.encode(ManagedContextProtocol.attestationResponse(boot)); exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.getResponseHeaders().set("Content-Type", "application/json"); @@ -219,6 +220,23 @@ class SshRuntimeProvisionerTest { } } + @Test + void extendedStartupReadyAndHttpAttestationShareTheOriginalConfiguration() throws Exception { + String directory = controlDirectory.resolve("extension-cache").toString(); + provisioner.withManagedExtensionCacheDirectory(directory, null); + var scope = new RuntimeScope("tenant", "workspace", "1", "/workspace/project", + com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST, "session"); + var request = provisioner.createRequest(scope, "session"); + var seed = RuntimeProvisionSeed.create("extended-binding", 1); + await(provisioner.ensureResource(request, seed, null)); + var lease = await(provisioner.provision(request, seed)); + Map expected = com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.configuration(scope.getCapabilityDigest(), directory, null); + assertEquals(expected, boot.get("managedExtensions")); + assertEquals(expected, attestation.get("managedExtensions")); + assertEquals(URI.create("http://127.0.0.1:30001/"), lease.getEndpoint()); + assertEquals(1, commands.stream().filter(command -> Boolean.TRUE.equals(command.get("launch"))).count()); + } + @Test void persistsIntentBeforeLaunchAndKeepsTheRemoteLeaseOrigin() throws Exception { var request = provisioner.createRequest(SCOPE, "session"); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocolTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocolTest.java new file mode 100644 index 00000000000..956c568315b --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionProtocolTest.java @@ -0,0 +1,108 @@ +package com.alibaba.qwen.code.runtimebroker.transport; + +import com.alibaba.qwen.code.runtimebroker.JsonCodec; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeLease; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ManagedExtensionProtocolTest { + private static final String OPERATION = "550e8400-e29b-41d4-a716-446655440000"; + private static final RuntimeScope SCOPE = new RuntimeScope("tenant", "workspace", "1", "/workspace", + ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST, "session"); + private static final Map PIN = Map.of("extensionId", "ext_550e8400-e29b-41d4-a716-446655440001", + "packageRevision", 1, "packageDigest", "a".repeat(64)); + + @Test + @SuppressWarnings("unchecked") + void acceptsOnlyClosedPreparationAndOriginalReconciliationCommands() { + var prepare = prepare(); + ManagedExtensionProtocol.validateOperation(prepare); + for (String privateField : List.of("settings", "packageManifests", "toolProfile", "archiveBase64")) { + var forged = new LinkedHashMap<>(prepare); + forged.put(privateField, Map.of()); + assertEquals(400, assertThrows(RuntimeBrokerException.class, + () -> ManagedExtensionProtocol.validateOperation(forged)).getStatusCode()); + } + var command = new LinkedHashMap<>((Map) prepare.get("command")); + command.put("mcpChange", Map.of("action", "enable", "serverId", "docs", "expectedRevision", 0, + "servers", List.of(Map.of("serverId", "docs", "serverRevision", 1, "definitionDigest", "b".repeat(64), "state", "enabled")))); + prepare.put("command", command); + ManagedExtensionProtocol.validateOperation(prepare); + var reconcile = Map.of("kind", "extension-reconcile", "operationId", OPERATION, + "expectedRevision", 3, "revision", 4); + ManagedExtensionProtocol.validateOperation(reconcile); + assertTrue(ManagedExtensionProtocol.isRecovery(reconcile)); + var substituted = new LinkedHashMap<>(reconcile); + substituted.put("revision", 5); + assertThrows(RuntimeBrokerException.class, () -> ManagedExtensionProtocol.validateOperation(substituted)); + } + + @Test + void validatesStopReceiptsAgainstTheOriginalSessionRevisionAndExecutionMode() { + var request = Map.of("kind", "extension-reconcile", "operationId", OPERATION, + "expectedRevision", 3, "revision", 4); + var receipt = new LinkedHashMap(Map.of("ownerId", "owner-1", "sessionId", "harness", + "configRevision", 4, "mode", "developer-process-group", "scope", "process_group", "state", "stopped")); + receipt.put("coverage", "registered-owners"); + receipt.put("journalId", "journal-1"); + var result = new LinkedHashMap(Map.of("operationId", OPERATION, "state", "stopped", "revision", 4, + "executionMode", "developer-process-group", "receipts", List.of(receipt))); + assertEquals("stopped", ManagedExtensionProtocol.response(response(result), SCOPE, "harness", request).get("state")); + for (String invalidField : List.of("sessionId", "scope", "mode", "state")) { + Object saved = receipt.put(invalidField, "different-owner"); + assertEquals(502, assertThrows(RuntimeBrokerException.class, + () -> ManagedExtensionProtocol.response(response(result), SCOPE, "harness", request)).getStatusCode()); + receipt.put(invalidField, saved); + } + result.put("receipts", List.of()); + assertThrows(RuntimeBrokerException.class, () -> ManagedExtensionProtocol.response(response(result), SCOPE, "harness", request)); + } + + @Test + void everyInvokePathRejectsAnotherHarnessExtensionContextBeforeSending() { + RuntimeSession session = new RuntimeSession("harness", "runtime", "bootstrap", SCOPE); + RuntimeLease lease = new RuntimeLease("worker", URI.create("http://127.0.0.1:9"), "token", "lease", 1); + var input = Map.of("managedExtension", Map.of("harnessSessionId", "another-harness", "configRevision", 1, "pin", PIN)); + var reference = Map.of("sessionId", "runtime", "promptId", "prompt", "callId", "call", + "argsDigest", "sha256:" + "b".repeat(64), "toolName", "read_file", "input", input); + var capture = Map.of("tenantId", "tenant", "sessionId", "harness", "turnId", "turn", + "executionCallId", "execution", "bindingGeneration", "1", "capturePolicy", "complete_required"); + var transport = new HttpRuntimeTransport(); + assertThrows(RuntimeBrokerException.class, () -> transport.execute(lease, session, reference)); + assertThrows(RuntimeBrokerException.class, () -> transport.executeV3(lease, session, reference, capture)); + assertThrows(RuntimeBrokerException.class, () -> transport.executeV3(lease, session, reference, + Map.of("toolName", "read_file", "input", input), capture)); + ManagedExtensionProtocol.requireBoundInput(session, Map.of("path", "README.md")); + ManagedExtensionProtocol.requireBoundInput(session, Map.of("managedExtension", + Map.of("harnessSessionId", "harness", "configRevision", 1, "pin", PIN))); + } + + private static Map prepare() { + var operation = new LinkedHashMap(); + operation.put("kind", "extension-prepare"); + operation.put("operationId", OPERATION); + operation.put("command", Map.of("operationId", OPERATION, "actorKey", "a".repeat(64), "expectedRevision", 0, "extensions", List.of(PIN))); + operation.put("expectedRevision", 0); + operation.put("extensions", List.of(PIN)); + operation.put("baseMcpServers", List.of()); + operation.put("baseHookCatalog", null); + return operation; + } + + private static byte[] response(Map result) { + return JsonCodec.encode(Map.of("protocolVersion", 1, + "sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness"), "result", result)); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionTransportTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionTransportTest.java new file mode 100644 index 00000000000..e81cf28042e --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/transport/ManagedExtensionTransportTest.java @@ -0,0 +1,164 @@ +package com.alibaba.qwen.code.runtimebroker.transport; + +import com.alibaba.qwen.code.runtimebroker.JsonCodec; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; +import com.alibaba.qwen.code.runtimebroker.RuntimeLease; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionSeed; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.alibaba.qwen.code.runtimebroker.provisioning.ManagedContextProtocol; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ManagedExtensionTransportTest { + private static final String OPERATION = "550e8400-e29b-41d4-a716-446655440000"; + private static final RuntimeScope SCOPE = new RuntimeScope("tenant", "workspace", "1", "/workspace", + ManagedExtensionExecutionProfile.DEVELOPER_CAPABILITY_DIGEST, "session"); + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void extendedAttestationUsesTheActualLaunchConfigurationAndRejectsADifferentCache(@TempDir Path directory) throws Exception { + var request = new RuntimeProvisionRequest(SCOPE, "harness", "local-process", "storage"); + var seed = RuntimeProvisionSeed.create("extension-attest", 1); + var configuration = ManagedExtensionExecutionProfile.configuration(SCOPE.getCapabilityDigest(), directory.toString(), null); + var boot = ManagedContextProtocol.boot(request, seed, configuration); + AtomicReference captured = new AtomicReference<>(); + AtomicReference> response = new AtomicReference<>(ManagedContextProtocol.attestationResponse(boot)); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/internal/managed-runtime/v3/attest", exchange -> { + assertEquals("Bearer " + seed.getToken(), exchange.getRequestHeaders().getFirst("Authorization")); + captured.set(exchange.getRequestBody().readAllBytes()); + byte[] bytes = JsonCodec.encode(response.get()); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + try { + var lease = new RuntimeLease(seed.getProvisionalRuntimeId(), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + seed.getToken(), seed.getLeaseId(), seed.getEpoch()); + assertThrows(IllegalArgumentException.class, () -> new HttpRuntimeTransport().attest(lease, request, seed)); + var transport = new HttpRuntimeTransport().withManagedExtensionCacheDirectory(directory.toString(), null); + assertEquals(SCOPE, transport.attest(lease, request, seed).toCompletableFuture().get(5, TimeUnit.SECONDS).getScope()); + assertEquals(JSON.readTree(JsonCodec.encode(ManagedContextProtocol.attestationRequest(boot))), JSON.readTree(captured.get())); + var changed = new LinkedHashMap<>(ManagedContextProtocol.attestationResponse(boot)); + changed.put("managedExtensions", ManagedExtensionExecutionProfile.configuration(SCOPE.getCapabilityDigest(), directory.resolve("other").toString(), null)); + response.set(changed); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> transport.attest(lease, request, seed).toCompletableFuture().get(5, TimeUnit.SECONDS)); + } finally { + server.stop(0); + } + } + + @Test + void streamsBinaryBytesAndInjectsPrivateSettingsOnEveryExactPreparation(@TempDir Path directory) throws Exception { + AtomicReference requestBytes = new AtomicReference<>(); + AtomicReference method = new AtomicReference<>(); + AtomicReference path = new AtomicReference<>(); + AtomicReference contentType = new AtomicReference<>(); + AtomicReference response = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + method.set(exchange.getRequestMethod()); + path.set(exchange.getRequestURI().getRawPath()); + contentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + assertEquals("Bearer token", exchange.getRequestHeaders().getFirst("Authorization")); + assertEquals("lease", exchange.getRequestHeaders().getFirst("X-Qwen-Managed-Lease-Id")); + assertEquals("1", exchange.getRequestHeaders().getFirst("X-Qwen-Managed-Lease-Epoch")); + requestBytes.set(exchange.getRequestBody().readAllBytes()); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.get().length); + exchange.getResponseBody().write(response.get()); + exchange.close(); + }); + server.start(); + try { + var lease = new RuntimeLease("worker", URI.create("http://127.0.0.1:" + server.getAddress().getPort()), "token", "lease", 1); + var binding = new RuntimeBindingRecord("binding", new RuntimeProvisionRequest(SCOPE, "harness"), 1, + RuntimeBindingRecord.State.READY, lease, false, null, null, 0, 0, Instant.now(), Instant.now()); + var transport = new HttpRuntimeTransport(); + byte[] binary = new byte[128 * 1024]; + new java.util.Random(1).nextBytes(binary); + String digest = DigestUtils.sha256Hex(binary); + Path archive = directory.resolve("extension.tar.gz"); + Files.write(archive, binary); + response.set(JsonCodec.encode(Map.of("protocolVersion", 1, "packageDigest", digest))); + transport.installExtensionPackage(binding, digest, archive).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals("PUT", method.get()); + assertEquals("/internal/managed-runtime/v3/extensions/packages/" + digest, path.get()); + assertEquals("application/gzip", contentType.get()); + assertArrayEquals(binary, requestBytes.get()); + + AtomicInteger resolutions = new AtomicInteger(); + transport.configureExtensionPreparation((owner, harness, operation) -> { + assertEquals(binding, owner); + assertEquals("harness", harness); + assertFalse(operation.containsKey("settings")); + var privateOperation = new LinkedHashMap<>(operation); + privateOperation.put("settings", Map.of("extension", Map.of("API_TOKEN", "private-setting"))); + privateOperation.put("packageManifests", Map.of()); + privateOperation.put("toolProfile", "SHELL"); + resolutions.incrementAndGet(); + return CompletableFuture.completedFuture(privateOperation); + }); + var operation = new LinkedHashMap(); + operation.put("kind", "extension-prepare"); + operation.put("operationId", OPERATION); + operation.put("command", Map.of("operationId", OPERATION, "actorKey", "a".repeat(64), "expectedRevision", 0, "extensions", List.of())); + operation.put("expectedRevision", 0); + operation.put("extensions", List.of()); + operation.put("baseMcpServers", List.of()); + operation.put("baseHookCatalog", null); + response.set(JsonCodec.encode(Map.of("protocolVersion", 1, + "sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness"), + "result", Map.of("operationId", OPERATION, "state", "prepared", "revision", 1, "executionMode", "developer-process-group")))); + var context = new ContextBinding("tenant", "workspace", 1, "storage", "nested/project", "context-config", 3); + for (int attempt = 0; attempt < 2; attempt++) { + transport.extensionControl(binding, "harness", context, operation).toCompletableFuture().get(5, TimeUnit.SECONDS); + } + assertEquals(2, resolutions.get()); + assertEquals("POST", method.get()); + JsonNode wire = JSON.readTree(requestBytes.get()); + assertFalse(wire.has("runtimeSessionId")); + assertEquals("harness", wire.required("sessionKey").required("sessionId").textValue()); + assertEquals("nested/project", wire.required("contextBinding").required("cwdRelative").textValue()); + assertEquals("3", wire.required("contextBinding").required("contextRevision").textValue()); + assertEquals("private-setting", wire.required("operation").required("settings").required("extension").required("API_TOKEN").textValue()); + + response.set(JsonCodec.encode(Map.of("protocolVersion", 1, "retirementId", "retirement", "state", "stopped"))); + transport.extensionStop(lease, "retirement").toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals("/internal/managed-runtime/v3/extensions/retire", path.get()); + assertEquals(Map.of("protocolVersion", 1, "retirementId", "retirement"), JSON.readValue(requestBytes.get(), Map.class)); + } finally { + server.stop(0); + } + } +} diff --git a/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt b/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt index 05bcdec62d6..2ca47e06015 100644 --- a/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt +++ b/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt @@ -1,5 +1,5 @@ # Generated by ManagedSurfaceCaptureTest (capture). -# sha256 of the route lines below: fa9544118b901d736dafe742efe0eacfd01940a6df607582055b92187842f3b2 +# sha256 of the route lines below: d9a9309deb7c45b81616f53167ed1968da3d105280ad54b8f778085aaa175092 AUTOMATION_RETIRE DELETE /v1/agent-automations/{automationId} public SESSION_DELETE DELETE /v1/agents/sessions/{sessionId} public STORE_RESOURCE_GET GET /internal/managed-session-store/v1/sessions/{sessionId}/resources/{resourceId} internal @@ -21,6 +21,8 @@ ARTIFACT_LIST GET /v1/agents/sessions/{sessionId}/artifacts public ARTIFACT_GET GET /v1/agents/sessions/{sessionId}/artifacts/{artifactId} public ARTIFACT_CONTENT GET /v1/agents/sessions/{sessionId}/artifacts/{artifactId}/content public TAIL_EVENTS GET /v1/agents/sessions/{sessionId}/events public +EXTENSION_CONFIG_GET GET /v1/agents/sessions/{sessionId}/extensions public +EXTENSION_CONFIG_GET GET /v1/agents/sessions/{sessionId}/extensions/operations/{operationId} public HOOK_CATALOG GET /v1/agents/sessions/{sessionId}/hook-catalog public ITEM_LIST GET /v1/agents/sessions/{sessionId}/items public TOOL_RESULT_GET GET /v1/agents/sessions/{sessionId}/items/{itemId}/tool-result public @@ -37,6 +39,11 @@ TURN_GET GET /v1/agents/sessions/{sessionId}/turns/{turnId} public USER_MESSAGE_QUERY GET /v1/agents/sessions/{sessionId}/user-messages public WORKSPACE_LIST GET /v1/agents/workspaces public WORKSPACE_GET GET /v1/agents/workspaces/{workspaceId} public +EXTENSION_LIST GET /v1/agents/workspaces/{workspaceId}/extensions public +EXTENSION_OPERATION GET /v1/agents/workspaces/{workspaceId}/extensions/operations/{operationId} public +EXTENSION_GET GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision} public +EXTENSION_ARCHIVE GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}/archive public +EXTENSION_SETTINGS_GET GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings public AGENT_DEFINITION_GET GET /v1/agents/{agentId} public SESSION_RENAME PATCH /v1/agents/sessions/{sessionId} public ACTION_GET POST /api/agent/web-shell/v1/actions/get webshell @@ -49,6 +56,10 @@ SESSION_OPERATION_GET POST /api/agent/web-shell/v1/operations/query webshell RUNTIME_NODE_LIST POST /api/agent/web-shell/v1/runtime-nodes/query webshell SESSION_APPROVAL_CHANGE POST /api/agent/web-shell/v1/session-approval/change webshell SESSION_APPROVAL POST /api/agent/web-shell/v1/session-approval/query webshell +EXTENSION_CONFIG_APPLY POST /api/agent/web-shell/v1/session-extensions/continue webshell +EXTENSION_CONFIG_GET POST /api/agent/web-shell/v1/session-extensions/query webshell +EXTENSION_CONFIG_APPLY POST /api/agent/web-shell/v1/session-extensions/recover-previous webshell +EXTENSION_CONFIG_APPLY POST /api/agent/web-shell/v1/session-extensions/submit webshell MCP_MANAGEMENT POST /api/agent/web-shell/v1/session-mcp/management/operations/query webshell MCP_MANAGEMENT POST /api/agent/web-shell/v1/session-mcp/management/query webshell MCP_MANAGEMENT POST /api/agent/web-shell/v1/session-mcp/management/submit webshell @@ -81,6 +92,12 @@ TURN_CANCEL POST /api/agent/web-shell/v1/turns/cancel webshell TURN_SUBMIT POST /api/agent/web-shell/v1/turns/submit webshell USER_MESSAGE_COMMAND POST /api/agent/web-shell/v1/user-messages/command webshell USER_MESSAGE_QUERY POST /api/agent/web-shell/v1/user-messages/query webshell +EXTENSION_OPERATION POST /api/agent/web-shell/v1/workspace-extensions/operations/query webshell +EXTENSION_GET POST /api/agent/web-shell/v1/workspace-extensions/packages/query webshell +EXTENSION_PUBLISH POST /api/agent/web-shell/v1/workspace-extensions/publish webshell +EXTENSION_LIST POST /api/agent/web-shell/v1/workspace-extensions/query webshell +EXTENSION_SETTINGS_GET POST /api/agent/web-shell/v1/workspace-extensions/settings/query webshell +EXTENSION_SETTINGS_SET POST /api/agent/web-shell/v1/workspace-extensions/settings/submit webshell WORKSPACE_DIRECTORY_CHECK POST /api/agent/web-shell/v1/workspaces/check-directory webshell WORKSPACE_CREATE POST /api/agent/web-shell/v1/workspaces/create webshell WORKSPACE_DIRECTORIES POST /api/agent/web-shell/v1/workspaces/directories/query webshell @@ -126,9 +143,15 @@ SESSION_CLOSE POST /v1/agents/sessions/{sessionId}/close public SESSION_CONTEXT_COMPRESSION POST /v1/agents/sessions/{sessionId}/compress public SESSION_CWD_CHANGE POST /v1/agents/sessions/{sessionId}/cwd public TURN_SUBMIT POST /v1/agents/sessions/{sessionId}/events public +EXTENSION_CONFIG_APPLY POST /v1/agents/sessions/{sessionId}/extensions public +EXTENSION_CONFIG_APPLY POST /v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/continue public +EXTENSION_CONFIG_APPLY POST /v1/agents/sessions/{sessionId}/extensions/operations/{operationId}/recover-previous public MCP_MANAGEMENT POST /v1/agents/sessions/{sessionId}/mcp-management/commands public SESSION_RECOVERY_RECHECK POST /v1/agents/sessions/{sessionId}/recovery/recheck public TASK_CANCEL POST /v1/agents/sessions/{sessionId}/tasks/{taskId}/cancel public SESSION_UNARCHIVE POST /v1/agents/sessions/{sessionId}/unarchive public USER_MESSAGE_COMMAND POST /v1/agents/sessions/{sessionId}/user-messages public +EXTENSION_PUBLISH POST /v1/agents/workspaces/{workspaceId}/extensions public +EXTENSION_SETTINGS_SET POST /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings public AGENT_DEFINITION_UPDATE POST /v1/agents/{agentId} public +EXTENSION_PUBLISH PUT /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageDigest} public diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java index 483e38bdae1..dec4b51f1e8 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java @@ -16,12 +16,14 @@ public final class CreateHarnessSession { private final Long captureBytes; private final Map agentDefinition; private final Map sessionSkillsRef; + private final Map initialSelection; private final List> mcpServers; private final Map hookCatalog; private final Map lineage; private final boolean suppressChildAgents; private final int publicChildAgentsVersion; private final Map publicChildModelRoute; + private final Map publicChildExtensionAgent; private final boolean childWorkspaces; private final boolean foregroundOnly; private final boolean childSnapshots; @@ -37,12 +39,14 @@ public final class CreateHarnessSession { this.captureBytes = builder.captureBytes; this.agentDefinition = builder.agentDefinition; this.sessionSkillsRef = builder.sessionSkillsRef; + this.initialSelection = builder.initialSelection; this.mcpServers = builder.mcpServers; this.hookCatalog = builder.hookCatalog; this.lineage = builder.lineage; this.suppressChildAgents = builder.suppressChildAgents; this.publicChildAgentsVersion = builder.publicChildAgentsVersion; this.publicChildModelRoute = builder.publicChildModelRoute; + this.publicChildExtensionAgent = builder.publicChildExtensionAgent; this.childWorkspaces = builder.childWorkspaces; this.foregroundOnly = builder.foregroundOnly; this.childSnapshots = builder.childWorkspaces && builder.childSnapshots; @@ -72,6 +76,9 @@ public final class CreateHarnessSession { if (toolProfile != null) { result.put("toolProfile", toolProfile); } + if (publicChildExtensionAgent != null) { + result.put("publicChildExtensionAgent", publicChildExtensionAgent); + } if (publicChildModelRoute != null) { result.put("publicChildModelRoute", publicChildModelRoute); } @@ -90,6 +97,9 @@ public final class CreateHarnessSession { if (agentDefinition != null) { result.put("agentDefinition", agentDefinition); } + if (initialSelection != null) { + result.put("initialSelection", initialSelection); + } if (sessionSkillsRef != null) { result.put("sessionSkillsRef", sessionSkillsRef); } @@ -124,12 +134,14 @@ public final class CreateHarnessSession { private Long captureBytes; private Map agentDefinition; private Map sessionSkillsRef; + private Map initialSelection; private List> mcpServers; private Map hookCatalog; private Map lineage; private boolean suppressChildAgents; private int publicChildAgentsVersion; private Map publicChildModelRoute; + private Map publicChildExtensionAgent; private boolean childWorkspaces; private boolean foregroundOnly; private boolean childSnapshots; @@ -230,6 +242,11 @@ public final class CreateHarnessSession { return new CreateHarnessSession(this); } + public Builder initialSelection(Map value) { + this.initialSelection = value == null ? null : Map.copyOf(value); + return this; + } + public Builder sessionSkillsRef(Map value) { this.sessionSkillsRef = value == null ? null : Map.copyOf(value); return this; @@ -240,6 +257,11 @@ public final class CreateHarnessSession { return this; } + public Builder publicChildExtensionAgent(Map value) { + this.publicChildExtensionAgent = value == null ? null : Map.copyOf(value); + return this; + } + public Builder publicChildModelRoute(Map value) { this.publicChildModelRoute = value == null ? null : Map.copyOf(value); return this; diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index 4e3a1987af0..c3fbbce6d31 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -868,6 +868,48 @@ public final class HostedHarnessClient implements AutoCloseable { return result; } + /** Applies a full package selection to an idle, already bound Session. */ + public Map applyExtensions(HarnessSessionRef session, Map body) { + return extensionMutation(session, "/extensions/management", body); + } + + public Map continueExtensionOperation(HarnessSessionRef session, String operationId, boolean recoverPrevious) { + String id = requireUuid(operationId, "operationId"); + return extensionMutation(session, "/extensions/management/operations/" + id + + (recoverPrevious ? "/recover-previous" : "/continue"), Map.of("operationId", id)); + } + + public Map extensionManagement(HarnessSessionRef session) { + HarnessSessionRef ref = requireSessionRef(session); + String operation = "GET /session/:id/extensions/management"; + HttpSupport.Response response = sendRead(sessionPath(ref.getHarnessSessionId()) + "/extensions/management", ref, operation); + DaemonClient.requireStatus(response, 200, operation); + Map result = JsonSupport.parseObject(response.getBody(), operation); + if (!ref.getHarnessSessionId().equals(result.get("sessionId"))) { + throw new DaemonProtocolException("Extension snapshot names another Session"); + } + return result; + } + + private Map extensionMutation(HarnessSessionRef session, String suffix, Map body) { + HarnessSessionRef ref = requireSessionRef(session); + String id = requireUuid(JsonSupport.requiredString(body, "operationId", "Extension apply"), "operationId"); + String operation = "POST /session/:id" + suffix; + HttpSupport.Response response = sendMutation(sessionPath(ref.getHarnessSessionId()) + suffix, + body, ref.getHarnessSessionId(), ref.getHarnessClientId(), operation); + try { + DaemonClient.requireStatus(response, 202, operation); + Map result = JsonSupport.parseObject(response.getBody(), operation); + if (!ref.getHarnessSessionId().equals(result.get("sessionId")) + || !id.equals(JsonSupport.requiredObject(result, "operation", operation).get("operationId"))) { + throw new DaemonProtocolException("Extension snapshot names another operation"); + } + return result; + } catch (DaemonProtocolException error) { + throw new MutationOutcomeUnknownException(operation, error); + } + } + public Map compressContext(HarnessSessionRef session, Map body) { HarnessSessionRef ref = requireSessionRef(session); if (!capabilities().getFeatures().contains("context-compression/1")) { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java index f9e087d9940..bee5dfac3f8 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java @@ -19,10 +19,12 @@ public final class LoadHarnessSession { private int lifecycleProtocolVersion = 1; private Map agentDefinition; private Map sessionSkillsRef; + private Map initialSelection; private List> mcpServers; private Map hookCatalog; private int publicChildAgentsVersion; private Map publicChildModelRoute; + private Map publicChildExtensionAgent; private boolean stopMessages; private boolean foregroundOnly; private String recoveryCleanupPromptId; @@ -64,6 +66,11 @@ public final class LoadHarnessSession { /** #13753 I3: with child Workspaces, the control plane serves snapshots too. */ private boolean childSnapshots; + public LoadHarnessSession withPublicChildExtensionAgent(Map value) { + this.publicChildExtensionAgent = value == null ? null : Map.copyOf(value); + return this; + } + public LoadHarnessSession withPublicChildModelRoute(Map value) { this.publicChildModelRoute = value == null ? null : Map.copyOf(value); return this; @@ -87,6 +94,11 @@ public final class LoadHarnessSession { return this; } + public LoadHarnessSession withInitialSelection(Map value) { + this.initialSelection = value == null ? null : Map.copyOf(value); + return this; + } + public LoadHarnessSession withSessionSkillsRef(Map value) { this.sessionSkillsRef = value == null ? null : Map.copyOf(value); return this; @@ -122,6 +134,7 @@ public final class LoadHarnessSession { copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; copy.sessionSkillsRef = sessionSkillsRef; + copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; copy.hookCatalog = hookCatalog; copy.lifecycleAuthority = lifecycleAuthority; @@ -134,6 +147,7 @@ public final class LoadHarnessSession { copy.cancellationPromptId = cancellationPromptId; copy.publicChildAgentsVersion = publicChildAgentsVersion; copy.publicChildModelRoute = publicChildModelRoute; + copy.publicChildExtensionAgent = publicChildExtensionAgent; return copy; } @@ -145,6 +159,7 @@ public final class LoadHarnessSession { copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; copy.sessionSkillsRef = sessionSkillsRef; + copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; copy.hookCatalog = hookCatalog; copy.lifecycleAuthority = lifecycleAuthority; @@ -157,6 +172,7 @@ public final class LoadHarnessSession { copy.cancellationPromptId = cancellationPromptId; copy.publicChildAgentsVersion = publicChildAgentsVersion; copy.publicChildModelRoute = publicChildModelRoute; + copy.publicChildExtensionAgent = publicChildExtensionAgent; return copy; } @@ -180,6 +196,7 @@ public final class LoadHarnessSession { copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; copy.sessionSkillsRef = sessionSkillsRef; + copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; copy.hookCatalog = hookCatalog; copy.lifecycleAuthority = Map.of("operationId", operationId, "claimGeneration", claimGeneration); @@ -192,6 +209,7 @@ public final class LoadHarnessSession { copy.cancellationPromptId = cancellationPromptId; copy.publicChildAgentsVersion = publicChildAgentsVersion; copy.publicChildModelRoute = publicChildModelRoute; + copy.publicChildExtensionAgent = publicChildExtensionAgent; return copy; } @@ -204,6 +222,7 @@ public final class LoadHarnessSession { copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; copy.sessionSkillsRef = sessionSkillsRef; + copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; copy.hookCatalog = hookCatalog; copy.lifecycleAuthority = lifecycleAuthority; @@ -216,6 +235,7 @@ public final class LoadHarnessSession { copy.cancellationPromptId = cancellationPromptId; copy.publicChildAgentsVersion = publicChildAgentsVersion; copy.publicChildModelRoute = publicChildModelRoute; + copy.publicChildExtensionAgent = publicChildExtensionAgent; return copy; } @@ -306,6 +326,9 @@ public final class LoadHarnessSession { if (toolProfile != null) { result.put("toolProfile", toolProfile); } + if (publicChildExtensionAgent != null) { + result.put("publicChildExtensionAgent", publicChildExtensionAgent); + } if (publicChildModelRoute != null) { result.put("publicChildModelRoute", publicChildModelRoute); } @@ -330,6 +353,9 @@ public final class LoadHarnessSession { if (agentDefinition != null) { result.put("agentDefinition", agentDefinition); } + if (initialSelection != null) { + result.put("initialSelection", initialSelection); + } if (sessionSkillsRef != null) { result.put("sessionSkillsRef", sessionSkillsRef); } diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClient.java new file mode 100644 index 00000000000..380674f01cd --- /dev/null +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClient.java @@ -0,0 +1,221 @@ +package com.alibaba.qwen.code.daemon; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** Signed public Managed package, configuration and recovery APIs. No mutation is retried automatically. */ +public final class ManagedExtensionsClient { + private static final long MAXIMUM_ARCHIVE_BYTES = 100L * 1024 * 1024; + private final URI baseUri; + private final String tenantId; + private final String actorId; + private final byte[] signingKey; + private final HttpClient http = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER).build(); + + public ManagedExtensionsClient(URI baseUri, String tenantId, String actorId, byte[] signingKey) { + if (baseUri == null || !List.of("http", "https").contains(baseUri.getScheme()) + || baseUri.getHost() == null || baseUri.getUserInfo() != null + || baseUri.getRawQuery() != null || baseUri.getRawFragment() != null) { + throw new IllegalArgumentException("Invalid Managed API base URI"); + } + this.baseUri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/"); + this.tenantId = header(tenantId); + this.actorId = header(actorId); + if (signingKey == null || signingKey.length == 0) { + throw new IllegalArgumentException("A signing key is required"); + } + this.signingKey = signingKey.clone(); + } + + public Map catalog(String workspaceId) { + return json("GET", workspace(workspaceId), null, null); + } + + public Map publish(String workspaceId, String extensionId, Path archive, + Map manifest, String idempotencyKey) { + Path snapshot = null; + try { + snapshot = Files.createTempFile("qwen-managed-package-", ".archive"); + String digest = archiveDigest(archive, snapshot); + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(JsonSupport.encode(manifest).getBytes(StandardCharsets.UTF_8)); + String route = extensionId == null ? workspace(workspaceId) + "?packageDigest=" + digest + "&manifest=" + encoded + : workspace(workspaceId) + "/" + segment(extensionId) + "/packages/" + digest + "?manifest=" + encoded; + return send(extensionId == null ? "POST" : "PUT", route, digest, + HttpRequest.BodyPublishers.ofFile(snapshot), "application/octet-stream", header(idempotencyKey)); + } catch (IOException error) { + throw new DaemonTransportException("The package archive could not be read", error); + } finally { + if (snapshot != null) { + try { + Files.deleteIfExists(snapshot); + } catch (IOException error) { + snapshot.toFile().deleteOnExit(); + } + } + } + } + + public Map packageVersion(String workspaceId, String extensionId, long revision, String digest) { + return json("GET", workspace(workspaceId) + "/" + segment(extensionId) + "/packages/" + revision + + "?packageDigest=" + segment(digest), null, null); + } + + public Map settings(String workspaceId, String extensionId) { + return json("GET", workspace(workspaceId) + "/" + segment(extensionId) + "/settings", null, null); + } + + public Map saveSettings(String workspaceId, String extensionId, long expectedRevision, + Map values, String idempotencyKey) { + return json("POST", workspace(workspaceId) + "/" + segment(extensionId) + "/settings", + Map.of("expectedRevision", expectedRevision, "values", values), header(idempotencyKey)); + } + + public Map packageOperation(String workspaceId, String operationId) { + return json("GET", workspace(workspaceId) + "/operations/" + segment(operationId), null, null); + } + + public Map configuration(String sessionId) { + return json("GET", session(sessionId), null, null); + } + + public Map apply(String sessionId, long expectedConfigRevision, + List> pins, String idempotencyKey) { + return json("POST", session(sessionId), Map.of("expectedConfigRevision", expectedConfigRevision, "extensions", pins), + header(idempotencyKey)); + } + + public Map configurationOperation(String sessionId, String operationId) { + return json("GET", session(sessionId) + "/operations/" + segment(operationId), null, null); + } + + public Map continueOperation(String sessionId, String operationId) { + return json("POST", session(sessionId) + "/operations/" + segment(operationId) + "/continue", Map.of(), null); + } + + public Map recoverPrevious(String sessionId, String operationId) { + return json("POST", session(sessionId) + "/operations/" + segment(operationId) + "/recover-previous", Map.of(), null); + } + + private Map json(String method, String route, Map body, String key) { + byte[] bytes = body == null ? new byte[0] : JsonSupport.encode(body).getBytes(StandardCharsets.UTF_8); + if (bytes.length > HttpSupport.MAXIMUM_JSON_BYTES) { + throw new IllegalArgumentException("Managed request exceeds its JSON limit"); + } + return send(method, route, hex(sha256().digest(bytes)), HttpRequest.BodyPublishers.ofByteArray(bytes), + "application/json; charset=utf-8", key); + } + + private Map send(String method, String route, String digest, HttpRequest.BodyPublisher body, + String contentType, String key) { + URI target = baseUri.resolve(route); + String timestamp = Long.toString(System.currentTimeMillis() / 1000); + String canonical = "qwen-broker-auth-v1\n" + method + "\n" + target.getRawPath() + "\n" + + (target.getRawQuery() == null ? "" : target.getRawQuery()) + "\n" + tenantId + "\n" + actorId + + "\n" + timestamp + "\n" + digest + "\n" + (key == null ? "" : key); + HttpRequest.Builder request = HttpRequest.newBuilder(target).timeout(Duration.ofMinutes(5)) + .header("X-Qwen-Tenant-Id", tenantId).header("X-Qwen-Actor-Id", actorId) + .header("X-Qwen-Signature-Timestamp", timestamp).header("X-Qwen-Signature", "v1=" + signature(canonical)) + .header("Content-Type", contentType).method(method, body); + if (key != null) { + request.header("Idempotency-Key", key); + } + String operation = method + " Managed extensions"; + try { + HttpSupport.Response response = HttpSupport.consume(http.send(request.build(), HttpSupport.bodyHandler()), operation); + DaemonClient.requireStatus(response, 200, operation); + return JsonSupport.parseObject(response.getBody(), operation); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new DaemonTransportException(operation + " interrupted; query the original operation", error); + } catch (IOException error) { + throw new DaemonTransportException(operation + " outcome is unconfirmed; query or retry the original operation", error); + } + } + + private String signature(String canonical) { + try { + Mac hmac = Mac.getInstance("HmacSHA256"); + hmac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + return hex(hmac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException error) { + throw new IllegalStateException("HmacSHA256 is unavailable", error); + } + } + + private static String archiveDigest(Path archive, Path snapshot) throws IOException { + if (!Files.isRegularFile(archive) || Files.size(archive) > MAXIMUM_ARCHIVE_BYTES) { + throw new IllegalArgumentException("Archive must be a regular file of at most 100 MiB"); + } + MessageDigest digest = sha256(); + long count = 0; + byte[] buffer = new byte[64 * 1024]; + try (InputStream input = Files.newInputStream(archive); + var output = Files.newOutputStream(snapshot, StandardOpenOption.TRUNCATE_EXISTING)) { + int read; + while ((read = input.read(buffer)) != -1) { + count += read; + if (count > MAXIMUM_ARCHIVE_BYTES) { + throw new IllegalArgumentException("Archive exceeds 100 MiB"); + } + digest.update(buffer, 0, read); + output.write(buffer, 0, read); + } + } + return hex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static String hex(byte[] bytes) { + StringBuilder value = new StringBuilder(bytes.length * 2); + for (byte item : bytes) { + value.append(Character.forDigit((item >>> 4) & 15, 16)).append(Character.forDigit(item & 15, 16)); + } + return value.toString(); + } + + private static String header(String value) { + if (value == null || value.isEmpty() || value.length() > 512 || value.chars().anyMatch(c -> c < 32 || c == 127)) { + throw new IllegalArgumentException("Invalid Managed request identity"); + } + return value; + } + + private static String segment(String value) { + return URLEncoder.encode(header(value), StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String workspace(String id) { + return "v1/agents/workspaces/" + segment(id) + "/extensions"; + } + + private static String session(String id) { + return "v1/agents/sessions/" + segment(id) + "/extensions"; + } +} diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedAgentDefinitionRequestTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedAgentDefinitionRequestTest.java index e2caae2bc38..72a8b144320 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedAgentDefinitionRequestTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedAgentDefinitionRequestTest.java @@ -10,6 +10,20 @@ import org.junit.jupiter.api.Test; class HostedAgentDefinitionRequestTest { private static final String SESSION = "22222222-2222-4222-8222-222222222222"; + @Test + void initialExtensionSelectionSurvivesCreateAndEveryLoadCopy() { + var selection = Map.of("operationId", SESSION, "actorKey", "a".repeat(64), + "expectedRevision", 0, "extensions", java.util.List.of()); + assertFalse(CreateHarnessSession.builder().harnessSessionId(SESSION).build().toJson().containsKey("initialSelection")); + assertEquals(selection, CreateHarnessSession.builder().harnessSessionId(SESSION) + .initialSelection(selection).build().toJson().get("initialSelection")); + var original = new LoadHarnessSession(SESSION).withInitialSelection(selection); + for (var load : java.util.List.of(original, original.withChildWorkspaces(true), original.withForegroundOnly(true), + original.forLifecycle("close", 1), original.withStoppedMessages())) { + assertEquals(selection, load.toJson().get("initialSelection")); + } + } + @Test void sessionSkillReferencesSurviveEveryLoadCopy() { var ref = Map.of("resourceId", "skills", "kind", "managed-agent-skills", diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClientTest.java new file mode 100644 index 00000000000..d17668f9e02 --- /dev/null +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/ManagedExtensionsClientTest.java @@ -0,0 +1,108 @@ +package com.alibaba.qwen.code.daemon; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ManagedExtensionsClientTest { + @TempDir Path directory; + + @Test + void streamsAndSignsArchiveBytesAndKeepsConfigurationRecoveryOnTheOriginalOperation() throws Exception { + byte[] signingKey = "fixture-key-with-newline\n".getBytes(StandardCharsets.UTF_8); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + List paths = new CopyOnWriteArrayList<>(); + List errors = new CopyOnWriteArrayList<>(); + List bodies = new CopyOnWriteArrayList<>(); + server.createContext("/prefix/", exchange -> { + byte[] body = exchange.getRequestBody().readAllBytes(); + bodies.add(body); + paths.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath()); + try { + var headers = exchange.getRequestHeaders(); + String canonical = "qwen-broker-auth-v1\n" + exchange.getRequestMethod() + "\n" + + exchange.getRequestURI().getRawPath() + "\n" + + (exchange.getRequestURI().getRawQuery() == null ? "" : exchange.getRequestURI().getRawQuery()) + + "\ntenant\nactor\n" + headers.getFirst("X-Qwen-Signature-Timestamp") + + "\n" + hex(MessageDigest.getInstance("SHA-256").digest(body)) + "\n" + + (headers.getFirst("Idempotency-Key") == null ? "" : headers.getFirst("Idempotency-Key")); + Mac hmac = Mac.getInstance("HmacSHA256"); + hmac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + assertEquals("v1=" + hex(hmac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))), headers.getFirst("X-Qwen-Signature")); + assertEquals("tenant", headers.getFirst("X-Qwen-Tenant-Id")); + assertEquals("actor", headers.getFirst("X-Qwen-Actor-Id")); + if ("application/octet-stream".equals(headers.getFirst("Content-Type"))) { + assertTrue(exchange.getRequestURI().getRawQuery().contains("manifest=" + + Base64.getUrlEncoder().withoutPadding().encodeToString("{\"schemaVersion\":1}".getBytes(StandardCharsets.UTF_8)))); + } + } catch (Throwable error) { + errors.add(error); + } + byte[] result = "{\"operationId\":\"original\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, result.length); + exchange.getResponseBody().write(result); + exchange.close(); + }); + server.start(); + try { + var client = new ManagedExtensionsClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/prefix"), + "tenant", "actor", signingKey); + Path archive = directory.resolve("package.tar.gz"); + byte[] binary = new byte[] {0, 1, 2, (byte) 255}; + Files.write(archive, binary); + client.publish("workspace", null, archive, Map.of("schemaVersion", 1), "publish-original"); + assertTrue(java.util.Arrays.equals(binary, bodies.get(0))); + client.saveSettings("workspace", "extension", 0, Map.of("TOKEN", "fixture"), "settings-original"); + client.apply("session", 2, new ArrayList<>(), "apply-original"); + client.configurationOperation("session", "original"); + client.continueOperation("session", "original"); + client.recoverPrevious("session", "original"); + assertEquals(List.of("POST /prefix/v1/agents/workspaces/workspace/extensions", + "POST /prefix/v1/agents/workspaces/workspace/extensions/extension/settings", + "POST /prefix/v1/agents/sessions/session/extensions", + "GET /prefix/v1/agents/sessions/session/extensions/operations/original", + "POST /prefix/v1/agents/sessions/session/extensions/operations/original/continue", + "POST /prefix/v1/agents/sessions/session/extensions/operations/original/recover-previous"), paths); + assertTrue(errors.isEmpty(), errors.toString()); + } finally { + server.stop(0); + } + } + + @Test + void refusesOversizedArchivesBeforeMakingAnyRequest() throws Exception { + Path archive = directory.resolve("large"); + try (var file = new java.io.RandomAccessFile(archive.toFile(), "rw")) { + file.setLength(100L * 1024 * 1024 + 1); + } + var client = new ManagedExtensionsClient(URI.create("http://127.0.0.1:1"), "tenant", "actor", new byte[] {1}); + assertThrows(IllegalArgumentException.class, () -> client.publish("workspace", null, archive, Map.of(), "key")); + } + + private static String hex(byte[] bytes) { + StringBuilder value = new StringBuilder(); + for (byte item : bytes) { + value.append(Character.forDigit((item >>> 4) & 15, 16)).append(Character.forDigit(item & 15, 16)); + } + return value.toString(); + } +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 4fe397f6a7d..d9bd1fdca9e 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { + ManagedPackagePreparationRequest, + ManagedPackagePreparation, +} from './managed-extension-package-types.js'; import { validateStartupConfigRequest, assertStartupConfigApplied, @@ -1903,6 +1907,67 @@ export class DaemonClient { ); } + async prepareManagedPackage( + request: ManagedPackagePreparationRequest, + options?: { clientId?: string; signal?: AbortSignal }, + ): Promise { + return this.jsonRequest( + '/workspace/extensions/managed-packages/prepare', + 'Prepare Managed extension package', + { + method: 'POST', + body: request, + clientId: options?.clientId, + signal: options?.signal, + timeoutMs: 10 * 60_000, + mode: 'rest', + }, + ); + } + + async readPreparedManagedPackage( + archiveUrl: string, + signal?: AbortSignal, + ): Promise { + if ( + !/^\/workspace\/extensions\/managed-packages\/[0-9a-f-]{36}\/archive$/.test( + archiveUrl, + ) + ) + throw new Error('Invalid Managed package preparation reference'); + return this.fetchWithTimeout( + `${this.baseUrl}${archiveUrl}`, + { method: 'GET', headers: this.headers({}), signal }, + async (response) => { + if (!response.ok) + throw await this.failOnError( + response, + 'Read prepared Managed package', + ); + const limit = 100 * 1024 * 1024; + const reader = response.body?.getReader(); + if (!reader) throw new Error('Managed package stream is missing'); + const chunks: Array> = []; + let length = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + length += value.length; + if (length > limit) + throw new Error('Managed package exceeds 100 MiB'); + chunks.push(new Uint8Array(value)); + } + } finally { + await reader.cancel(); + } + return new Blob(chunks, { type: 'application/octet-stream' }); + }, + undefined, + 'rest', + ); + } + async workspaceExtensions(): Promise { return await this.jsonRequest( '/workspace/extensions', diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index d6b8626b5f8..361135e0bce 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +export type { + ManagedPackageManifest, + ManagedPackagePreparationRequest, + ManagedPackagePreparation, +} from './managed-extension-package-types.js'; export { DaemonAttachmentUploadError } from './DaemonAttachmentUploadError.js'; export { DaemonClient, diff --git a/packages/sdk-typescript/src/daemon/managed-extension-package-types.ts b/packages/sdk-typescript/src/daemon/managed-extension-package-types.ts new file mode 100644 index 00000000000..9003d3e3955 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/managed-extension-package-types.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ManagedPackageManifest { + schemaVersion: 1; + compilerVersion: 1; + name: string; + version: string; + target: { platform: 'linux' | 'darwin'; arch: 'arm64' | 'x64' | 'any' }; + components: Array< + 'skills' | 'commands' | 'agents' | 'context' | 'mcp' | 'hooks' | 'settings' + >; + sourceType?: string; + sourceRevision?: string; +} +export interface ManagedPackagePreparationRequest { + source: string; + ref?: string; + target: ManagedPackageManifest['target']; + consent: true; +} +export interface ManagedPackagePreparation { + packageDigest: string; + manifest: ManagedPackageManifest; + sourceRevision: string; + archiveUrl: string; +} diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 09b3931cb40..74e032695be 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -22729,6 +22729,7 @@ export function App({ ) : activePanel === 'extensions' ? ( diff --git a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx index 49a35efccc6..381b4440182 100644 --- a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx +++ b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx @@ -1,3 +1,5 @@ +import { ManagedExtensionsManagerPage } from '../managed/ManagedExtensionsManagerPage'; +import type { ManagedAgentProvider } from '../managed/managed-agent-provider'; import { useCallback, useEffect, @@ -145,6 +147,11 @@ type PendingInteractionState = { const UPDATE_AVAILABLE: DaemonExtensionUpdateState = 'update available'; interface ExtensionsManagerPageProps { + managed?: { + provider: ManagedAgentProvider; + workspaceId?: string; + sessionId?: string; + }; onClose: () => void; initialFocusRef?: Ref; embedded?: EmbeddedManagerPage; @@ -431,7 +438,15 @@ function ExtensionInteractionDialog({ ); } -export function ExtensionsManagerPage({ +export function ExtensionsManagerPage(props: ExtensionsManagerPageProps) { + return props.managed ? ( + + ) : ( + + ); +} + +function NativeExtensionsManagerPage({ onClose, initialFocusRef, embedded, diff --git a/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.test.tsx b/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.test.tsx new file mode 100644 index 00000000000..f43d146e501 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.test.tsx @@ -0,0 +1,353 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ManagedAgentProvider } from './managed-agent-provider'; +import type { + ManagedExtensionCatalog, + ManagedExtensionSnapshot, +} from './managed-extension-types'; +import { I18nProvider } from '../../i18n'; +import { ManagedExtensionsManagerPage } from './ManagedExtensionsManagerPage'; +import { JavaManagedAgentHttpError } from './java-managed-agent-client'; + +vi.mock('../../daemon-react-sdk', () => ({ + useOptionalWorkspace: () => undefined, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let container: HTMLDivElement; +let root: Root; +const pin = { + extensionId: 'ext_00000000-0000-4000-8000-000000000001', + packageRevision: 1, + packageDigest: 'a'.repeat(64), +}; +const catalog: ManagedExtensionCatalog = { + supported: true, + extensions: [ + { + pin, + manifest: { + schemaVersion: 1, + compilerVersion: 1, + name: 'Example', + version: '1', + target: { platform: 'linux', arch: 'x64' }, + components: ['skills'], + }, + }, + ], +}; + +function snapshot(sessionId: string): ManagedExtensionSnapshot { + return { + sessionId, + supported: true, + revision: 1, + extensions: [pin], + requestedExtensions: [pin], + }; +} +function provider() { + const extensions = { + storageScope: 'test', + list: vi.fn(async () => catalog), + query: vi.fn(async (id: string) => snapshot(id)), + apply: vi.fn(), + resume: vi.fn(), + publish: vi.fn(), + settings: vi.fn(async () => ({ settingsRevision: 5, configuredKeys: [] })), + saveSettings: vi.fn(), + }; + return { + extensions, + value: { extensions } as unknown as ManagedAgentProvider, + }; +} +async function render(value: ManagedAgentProvider, sessionId = 'session-a') { + await act(async () => + root.render( + + + , + ), + ); +} +async function ready() { + await vi.waitFor(() => + expect( + container.querySelector('[aria-label="Example"]') + ?.disabled, + ).toBe(false), + ); +} +beforeEach(() => { + sessionStorage.clear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +it('limits checkbox and saved-settings additions to 32 selected packages', async () => { + const target = provider(); + const entries = Array.from({ length: 33 }, (_, index) => ({ + ...catalog.extensions[0]!, + pin: { + ...pin, + extensionId: `ext_00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + }, + manifest: { + ...catalog.extensions[0]!.manifest, + name: `Package ${index + 1}`, + }, + })); + target.extensions.list.mockResolvedValue({ ...catalog, extensions: entries }); + target.extensions.query.mockImplementation(async (id) => ({ + ...snapshot(id), + extensions: entries.slice(0, 32).map((entry) => entry.pin), + requestedExtensions: entries.slice(0, 32).map((entry) => entry.pin), + })); + await render(target.value); + const last = () => + container.querySelector('[aria-label="Package 33"]')!; + await vi.waitFor(() => expect(last()).toBeDefined()); + expect(last().disabled).toBe(true); + const buttons = [ + ...container.querySelectorAll('button'), + ].filter((item) => item.textContent === 'Settings'); + await act(async () => buttons.at(-1)!.click()); + const useSettings = () => + [...document.querySelectorAll('button')].find( + (item) => item.textContent === 'Use saved settings revision 5', + )!; + await vi.waitFor(() => expect(useSettings()).toBeDefined()); + expect(useSettings().disabled).toBe(true); + const close = [ + ...document.querySelectorAll('button'), + ].find((item) => item.textContent === 'Close')!; + await act(async () => close.click()); + await act(async () => + container + .querySelector('[aria-label="Package 1"]')! + .click(), + ); + expect(last().disabled).toBe(false); + await act(async () => last().click()); + expect(last().checked).toBe(true); + expect( + [ + ...container.querySelectorAll('input[type="checkbox"]'), + ].filter((item) => item.checked), + ).toHaveLength(32); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe('Managed extension selection', () => { + it('preserves an explicitly empty pending selection while old packages remain active', async () => { + const target = provider(); + target.extensions.query.mockImplementation(async (id) => ({ + ...snapshot(id), + requestedExtensions: [], + operation: { + operationId: '00000000-0000-4000-8000-000000000001', + state: 'recovery_required', + mode: 'apply', + revision: 2, + }, + })); + await render(target.value); + await vi.waitFor(() => + expect(container.textContent).toContain('Recovery blocked'), + ); + expect( + container.querySelector('[aria-label="Example"]') + ?.checked, + ).toBe(false); + expect(target.extensions.apply).not.toHaveBeenCalled(); + }); + + it('aborts the previous target and discards its late response', async () => { + const target = provider(); + let resolveOld!: (value: ManagedExtensionSnapshot) => void; + target.extensions.query.mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveOld = resolve; + }), + ); + await render(target.value); + await render(target.value, 'session-b'); + await ready(); + const oldSignal = target.extensions.query.mock.calls[0]?.[1] as + | { signal?: AbortSignal } + | undefined; + expect(oldSignal?.signal?.aborted).toBe(true); + await act(async () => + resolveOld({ ...snapshot('session-a'), requestedExtensions: [] }), + ); + expect( + container.querySelector('[aria-label="Example"]') + ?.checked, + ).toBe(true); + }); + + it('retries an unknown apply with the original selection and idempotency key', async () => { + const target = provider(); + target.extensions.apply + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce(snapshot('session-a')); + await render(target.value); + await ready(); + await act(async () => + container + .querySelector('[aria-label="Example"]')! + .click(), + ); + const button = (label: string) => + [...container.querySelectorAll('button')].find( + (item) => item.textContent === label, + )!; + await act(async () => button('Apply selection').click()); + await vi.waitFor(() => + expect(container.textContent).toContain('Retry original request'), + ); + await act(async () => button('Retry original request').click()); + await vi.waitFor(() => + expect(target.extensions.apply).toHaveBeenCalledTimes(2), + ); + expect(target.extensions.apply.mock.calls[0]?.slice(0, 2)).toEqual( + target.extensions.apply.mock.calls[1]?.slice(0, 2), + ); + expect(target.extensions.apply.mock.calls[0]?.[2].idempotencyKey).toBe( + target.extensions.apply.mock.calls[1]?.[2].idempotencyKey, + ); + }); + + it('keeps the exact settings revision when selecting a newer package', async () => { + const target = provider(); + target.extensions.list.mockResolvedValue({ + ...catalog, + extensions: catalog.extensions.map((entry) => ({ + ...entry, + pin: { ...pin, packageRevision: 2, packageDigest: 'b'.repeat(64) }, + })), + }); + target.extensions.query.mockImplementation(async (id) => ({ + ...snapshot(id), + extensions: [{ ...pin, settingsRevision: 5 }], + requestedExtensions: [{ ...pin, settingsRevision: 5 }], + })); + target.extensions.apply.mockResolvedValue(snapshot('session-a')); + await render(target.value); + await ready(); + const button = (label: string) => + [...container.querySelectorAll('button')].find( + (item) => item.textContent === label, + )!; + await act(async () => button('Update selected extension').click()); + await act(async () => button('Apply selection').click()); + await vi.waitFor(() => expect(target.extensions.apply).toHaveBeenCalled()); + expect(target.extensions.apply.mock.calls[0]?.[1]).toEqual({ + expectedConfigRevision: 1, + extensions: [ + { + ...pin, + packageRevision: 2, + packageDigest: 'b'.repeat(64), + settingsRevision: 5, + }, + ], + }); + }); +}); + +it('refreshes and unlocks a definitive stale-revision rejection for a new request', async () => { + const target = provider(); + target.extensions.apply.mockRejectedValue( + new JavaManagedAgentHttpError( + 409, + 'extension_config_revision_conflict', + 'Refresh current configuration', + ), + ); + target.extensions.query + .mockResolvedValueOnce(snapshot('session-a')) + .mockResolvedValue({ ...snapshot('session-a'), revision: 2 }); + await render(target.value); + await ready(); + const checkbox = () => + container.querySelector('[aria-label="Example"]')!; + await act(async () => checkbox().click()); + const apply = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'Apply selection', + )!; + await act(async () => apply.click()); + await vi.waitFor(() => + expect(target.extensions.apply).toHaveBeenCalledTimes(1), + ); + await vi.waitFor(() => + expect(target.extensions.query).toHaveBeenCalledTimes(2), + ); + expect(checkbox().disabled).toBe(false); + expect(container.textContent).not.toContain('Retry original request'); + expect( + sessionStorage.getItem('managed-extension-apply:test:workspace:session-a'), + ).toBeNull(); + target.extensions.apply.mockResolvedValueOnce(snapshot('session-a')); + await act(async () => checkbox().click()); + await act(async () => apply.click()); + expect( + target.extensions.apply.mock.calls[1]?.[1].expectedConfigRevision, + ).toBe(2); + expect(target.extensions.apply.mock.calls[1]?.[2].idempotencyKey).not.toBe( + target.extensions.apply.mock.calls[0]?.[2].idempotencyKey, + ); +}); + +it.each([ + new JavaManagedAgentHttpError( + 409, + 'hosted_extension_configuration_conflict', + 'Commit conflict', + ), + new JavaManagedAgentHttpError( + 503, + 'session_config_outcome_unknown', + 'Query original operation', + ), +])( + 'preserves an original apply when admission or commit is uncertain: $code', + async (error) => { + const target = provider(); + target.extensions.apply + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(snapshot('session-a')); + await render(target.value); + await ready(); + const button = (label: string) => + [...container.querySelectorAll('button')].find( + (item) => item.textContent === label, + )!; + await act(async () => button('Apply selection').click()); + await vi.waitFor(() => + expect(container.textContent).toContain('Retry original request'), + ); + expect( + container.querySelector('[aria-label="Example"]')! + .disabled, + ).toBe(true); + await act(async () => button('Retry original request').click()); + expect(target.extensions.apply.mock.calls[1]?.[2].idempotencyKey).toBe( + target.extensions.apply.mock.calls[0]?.[2].idempotencyKey, + ); + }, +); diff --git a/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.tsx b/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.tsx new file mode 100644 index 00000000000..147cadb8b28 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedExtensionsManagerPage.tsx @@ -0,0 +1,749 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ArrowLeftIcon, RefreshCwIcon } from 'lucide-react'; +import { useOptionalWorkspace } from '../../daemon-react-sdk'; +import { useI18n } from '../../i18n'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { Card, CardHeader, CardTitle, CardContent } from '../ui/card'; +import { Input } from '../ui/input'; +import { Alert, AlertDescription } from '../ui/alert'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '../ui/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '../ui/dialog'; +import type { ManagedAgentProvider } from './managed-agent-provider'; +import { JavaManagedAgentHttpError } from './java-managed-agent-client'; +import type { + ManagedExtensionCatalog, + ManagedExtensionPin, + ManagedExtensionSelection, + ManagedExtensionSnapshot, + ManagedExtensionManifest, +} from './managed-extension-types'; +import { isManagedExtensionPins } from './managed-extension-types'; +import { + getManagedClientId, + managedRequestId, +} from './managed-session-storage'; + +export interface ManagedExtensionsManagerPageProps { + provider: ManagedAgentProvider; + workspaceId?: string; + sessionId?: string; + onClose: () => void; +} + +function samePin(a: ManagedExtensionPin, b: ManagedExtensionPin) { + return JSON.stringify(a) === JSON.stringify(b); +} +interface PendingApply { + selection: ManagedExtensionSelection; + key: string; +} + +export function ManagedExtensionsManagerPage({ + provider, + workspaceId, + sessionId, + onClose, +}: ManagedExtensionsManagerPageProps) { + const { t } = useI18n(); + const daemon = useOptionalWorkspace(); + const management = provider.extensions; + const [catalog, setCatalog] = useState(); + const [snapshot, setSnapshot] = useState(); + const [selection, setSelection] = useState([]); + const [pending, setPending] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const [source, setSource] = useState(''); + const [ref, setRef] = useState(''); + const [target, setTarget] = useState('linux:x64'); + const [updateId, setUpdateId] = useState('new'); + const [settings, setSettings] = useState<{ + id: string; + revision: number; + entries: Array<{ key: string; value: string }>; + }>(); + const generation = useRef(0); + const request = useRef(undefined); + const publication = useRef<{ identity: string; key: string } | undefined>( + undefined, + ); + const settingsSubmission = useRef< + { identity: string; key: string } | undefined + >(undefined); + const clientId = getManagedClientId( + management?.storageScope ?? 'managed-extensions', + ); + const storageKey = + management?.storageScope && workspaceId && sessionId + ? `managed-extension-apply:${management.storageScope}:${workspaceId}:${sessionId}` + : undefined; + const blocked = + !!snapshot?.operation && + (!['active', 'failed'].includes(snapshot.operation.state) || + (snapshot.operation.mode === 'recoverPrevious' && + snapshot.operation.state === 'failed')); + + const load = useCallback( + async (signal: AbortSignal, current: number) => { + if (!management || !workspaceId || !sessionId) return; + const [nextCatalog, nextSnapshot] = await Promise.all([ + management.list(workspaceId, { clientId, signal }), + management.query(sessionId, { clientId, signal }), + ]); + if (current !== generation.current || signal.aborted) return; + setCatalog(nextCatalog); + setSnapshot(nextSnapshot); + setSelection( + nextSnapshot.operation + ? nextSnapshot.requestedExtensions + : nextSnapshot.extensions, + ); + }, + [management, workspaceId, sessionId, clientId], + ); + + useEffect(() => { + const current = ++generation.current; + const controller = new AbortController(); + request.current = controller; + setCatalog(undefined); + setSnapshot(undefined); + setSelection([]); + setSettings(undefined); + setPending(undefined); + setError(false); + publication.current = undefined; + settingsSubmission.current = undefined; + setBusy(true); + if (storageKey) { + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { + const saved = JSON.parse(raw) as PendingApply; + if ( + saved.key && + isManagedExtensionPins(saved.selection?.extensions) && + Number.isSafeInteger(saved.selection.expectedConfigRevision) + ) + setPending(saved); + else sessionStorage.removeItem(storageKey); + } + } catch { + /* Storage is optional. */ + } + } + void load(controller.signal, current) + .catch(() => { + if (!controller.signal.aborted && current === generation.current) + setError(true); + }) + .finally(() => { + if (current === generation.current) setBusy(false); + }); + return () => { + controller.abort(); + request.current?.abort(); + generation.current = current + 1; + }; + }, [load, storageKey]); + + const run = async ( + action: (signal: AbortSignal) => Promise, + refresh = true, + ) => { + if (busy) return; + const current = generation.current; + const controller = new AbortController(); + request.current?.abort(); + request.current = controller; + setBusy(true); + setError(false); + try { + await action(controller.signal); + if ( + refresh && + !controller.signal.aborted && + current === generation.current + ) + await load(controller.signal, current); + } catch { + if (!controller.signal.aborted && current === generation.current) + setError(true); + } finally { + if (current === generation.current) setBusy(false); + } + }; + const apply = () => + run(async (signal) => { + if (!management || !sessionId || !snapshot) return; + const input = pending ?? { + key: managedRequestId(), + selection: { + expectedConfigRevision: snapshot.revision, + extensions: selection, + }, + }; + setPending(input); + if (storageKey) { + try { + sessionStorage.setItem(storageKey, JSON.stringify(input)); + } catch { + /* Storage is optional. */ + } + } + const clearPending = () => { + setPending(undefined); + if (storageKey) { + try { + sessionStorage.removeItem(storageKey); + } catch { + /* Storage is optional. */ + } + } + }; + const current = generation.current; + try { + await management.apply(sessionId, input.selection, { + clientId, + signal, + idempotencyKey: input.key, + }); + } catch (cause) { + if ( + !signal.aborted && + current === generation.current && + cause instanceof JavaManagedAgentHttpError && + cause.status === 409 && + cause.code === 'extension_config_revision_conflict' + ) { + clearPending(); + await load(signal, current); + } + throw cause; + } + if (!signal.aborted && current === generation.current) clearPending(); + }); + + return ( +
+
+
+

+ {t('managed.extensions.title')} +

+

+ {t('managed.extensions.description')} +

+
+
+ + +
+
+ {error && ( + + + {t('managed.extensions.failedRequest')} + + + )} + {!workspaceId || !sessionId ? ( +

{t('managed.extensions.selectSession')}

+ ) : !management || + catalog?.supported === false || + snapshot?.supported === false ? ( +

{t('managed.extensions.unsupported')}

+ ) : ( + <> + {snapshot?.operation && ( +
+ + {t(`managed.extensions.${snapshot.operation.state}`)} + {' '} + r{snapshot.revision} + {blocked && ( + + )} + {snapshot.operation.state === 'recovery_required' && ( + + )} +
+ )} +
+ {!catalog?.extensions.length && ( +

+ {t('managed.extensions.empty')} +

+ )} + {catalog?.extensions.map((entry) => { + const chosen = selection.find( + (pin) => pin.extensionId === entry.pin.extensionId, + ); + const active = snapshot?.extensions.find( + (pin) => pin.extensionId === entry.pin.extensionId, + ); + return ( + + + + = 32) + } + onChange={(event) => + setSelection((old) => + event.target.checked && + (old.length < 32 || + old.some( + (pin) => + pin.extensionId === entry.pin.extensionId, + )) + ? [ + ...old.filter( + (pin) => + pin.extensionId !== entry.pin.extensionId, + ), + entry.pin, + ] + : event.target.checked + ? old + : old.filter( + (pin) => + pin.extensionId !== entry.pin.extensionId, + ), + ) + } + /> + {entry.manifest.name} + + {active && chosen && samePin(active, chosen) + ? t('managed.extensions.active') + : chosen + ? t('managed.extensions.pending') + : t('managed.extensions.installed')} + + + + +

+ {entry.manifest.version} ·{' '} + {entry.manifest.target.platform}/ + {entry.manifest.target.arch} ·{' '} + {entry.manifest.components.join(', ')} +

+ + {entry.pin.packageDigest} + +
+ {chosen && + chosen.packageRevision !== + entry.pin.packageRevision && ( + + )} + +
+
+
+ ); + })} +
+ + + + {t('managed.extensions.publish')} + + + setSource(event.target.value)} + /> + setRef(event.target.value)} + /> + + + {!daemon && ( +

+ {t('managed.extensions.localPublisher')} +

+ )} + +
+
+ + )} + {settings && ( + { + if (!open) setSettings(undefined); + }} + > + + + {t('managed.extensions.settings')} + + {t('managed.extensions.settingsDescription')} + + + {settings.entries.map((entry, index) => ( +
+ + setSettings( + (old) => + old && { + ...old, + entries: old.entries.map((value, position) => + position === index + ? { ...value, key: event.target.value } + : value, + ), + }, + ) + } + /> + + setSettings( + (old) => + old && { + ...old, + entries: old.entries.map((value, position) => + position === index + ? { ...value, value: event.target.value } + : value, + ), + }, + ) + } + /> +
+ ))} + + + {settings.revision > 0 && ( + + )} + + +
+
+ )} +
+ ); +} diff --git a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx index 8cfa5c29255..57f3f5e12de 100644 --- a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx +++ b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx @@ -2918,6 +2918,104 @@ describe('WorkspaceBindingCreator', () => { }, ); + it.each([32, 33, 256])( + 'displays a valid %i-package catalog and caps the Session selection at 32', + async (count) => { + provider = { + ...provider, + extensions: { + list: vi.fn().mockResolvedValue({ + supported: true, + extensions: Array.from({ length: count }, (_, index) => ({ + pin: { + extensionId: `ext_00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }, + manifest: { name: `Review package ${index + 1}`, version: '1' }, + })), + }), + } as unknown as ManagedAgentProvider['extensions'], + }; + await render(); + const choices = [ + ...container.querySelectorAll( + 'input[type="checkbox"]', + ), + ].filter((input) => + input.closest('label')?.textContent?.includes('Review package'), + ); + expect(choices).toHaveLength(count); + for (const choice of choices.slice(0, 32)) + await act(async () => choice.click()); + expect(choices.filter((choice) => choice.checked)).toHaveLength(32); + if (count > 32) { + expect(choices[32]!.disabled).toBe(true); + await act(async () => choices[32]!.click()); + expect(choices[32]!.checked).toBe(false); + await act(async () => choices[0]!.click()); + expect(choices[32]!.disabled).toBe(false); + await act(async () => choices[32]!.click()); + expect(choices.filter((choice) => choice.checked)).toHaveLength(32); + } + }, + ); + + it('freezes selected package and settings revisions across creation retries', async () => { + const pin = { + extensionId: 'ext_00000000-0000-4000-8000-000000000001', + packageRevision: 2, + packageDigest: 'a'.repeat(64), + }; + provider = { + ...provider, + extensions: { + list: vi.fn().mockResolvedValue({ + supported: true, + extensions: [ + { pin, manifest: { name: 'Frozen package', version: '2' } }, + ], + }), + } as unknown as ManagedAgentProvider['extensions'], + }; + createEmpty + .mockRejectedValueOnce(new TypeError('response lost')) + .mockResolvedValueOnce({ sessionId: 'session-a' }); + getSession.mockResolvedValue({ + sessionId: 'session-a', + workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, + }); + await render(); + const checkbox = [ + ...container.querySelectorAll('input[type="checkbox"]'), + ].find((input) => + input.closest('label')?.textContent?.includes('Frozen package'), + )!; + expect(checkbox).toBeDefined(); + await act(async () => checkbox.click()); + const revision = container.querySelector( + '[aria-label="Settings revision (optional): Frozen package"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(revision, '7'); + revision.dispatchEvent(new Event('input', { bubbles: true })); + }); + await click('Create session'); + expect(createEmpty.mock.calls[0]?.[0].extensionPins).toEqual([ + { ...pin, settingsRevision: 7 }, + ]); + await click('Retry the same request'); + expect(createEmpty.mock.calls[1]?.[0]).toEqual( + createEmpty.mock.calls[0]?.[0], + ); + expect(createEmpty.mock.calls[1]?.[1].idempotencyKey).toEqual( + createEmpty.mock.calls[0]?.[1].idempotencyKey, + ); + }); + it('retries the frozen request after a lost response and confirms binding', async () => { createEmpty .mockRejectedValueOnce(new TypeError('connection lost')) diff --git a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx index 1b2606543a6..cd9201ad390 100644 --- a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx +++ b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx @@ -71,6 +71,11 @@ import { import { isManagedInitialApprovalMode } from './managed-agent-provider'; import { managedRequestId } from './managed-session-storage'; import { RemoteWorkspaceRegistration } from './RemoteWorkspaceRegistration'; +import { + isManagedExtensionPins, + type ManagedExtensionPin, + type ManagedExtensionCatalog, +} from './managed-extension-types'; interface PendingCreation { agentId: string; @@ -84,6 +89,7 @@ interface PendingCreation { approvalMode?: ManagedApprovalMode; planMode?: boolean; skills?: ManagedSessionSkill[]; + extensionPins?: ManagedExtensionPin[]; } interface PendingWorkspace { @@ -161,6 +167,8 @@ function readPending( typeof item['planMode'] !== 'boolean') || (item['skills'] !== undefined && !isManagedSessionSkills(item['skills'])) || + (item['extensionPins'] !== undefined && + !isManagedExtensionPins(item['extensionPins'])) || !isManagedInput(input) || (item['sessionId'] !== undefined && typeof item['sessionId'] !== 'string') ) @@ -272,6 +280,32 @@ export function WorkspaceBindingCreator({ return ''; } }); + const [extensionCatalog, setExtensionCatalog] = + useState(); + const [extensionPins, setExtensionPins] = useState([]); + useEffect(() => { + const abort = new AbortController(); + setExtensionCatalog(undefined); + setExtensionPins([]); + if (selected && provider.extensions) { + void provider.extensions + .list(selected, { clientId, signal: abort.signal }) + .then((catalog) => { + if ( + !abort.signal.aborted && + catalog.extensions.length <= 256 && + catalog.extensions.every((item) => + isManagedExtensionPins([item.pin]), + ) && + new Set(catalog.extensions.map((item) => item.pin.extensionId)) + .size === catalog.extensions.length + ) + setExtensionCatalog(catalog); + }) + .catch(() => undefined); + } + return () => abort.abort(); + }, [selected, provider.extensions, clientId]); const selectedRef = useRef(selected); selectedRef.current = selected; const requestedWorkspaceRef = useRef(workspaceId); @@ -709,6 +743,9 @@ export function WorkspaceBindingCreator({ clientId, idempotencyKey: managedRequestId(), ...(skills.length ? { skills: structuredClone(skills) } : {}), + ...(extensionPins.length + ? { extensionPins: structuredClone(extensionPins) } + : {}), ...(supportsApprovalModeSelection && currentOptions ? { approvalMode: currentOptions.approvalMode } : {}), @@ -743,6 +780,9 @@ export function WorkspaceBindingCreator({ workspaceId: attempt.workspaceId, cwdRelative: attempt.cwdRelative, ...(attempt.skills?.length ? { skills: attempt.skills } : {}), + ...(attempt.extensionPins === undefined + ? {} + : { extensionPins: attempt.extensionPins }), ...(attempt.approvalMode === undefined ? {} : { approvalMode: attempt.approvalMode }), @@ -1208,6 +1248,89 @@ export function WorkspaceBindingCreator({ {t('managed.newWorkspaceResume')} )} + {extensionCatalog?.supported && + extensionCatalog.extensions.length > 0 && ( +
+ {t('managed.extensions.title')} + {extensionCatalog.extensions.map((entry) => ( +
+ + {extensionPins.some( + (pin) => pin.extensionId === entry.pin.extensionId, + ) && ( + + )} +
+ ))} +
+ )} {(supportedSkillsSource || pending?.skills?.length) && (

{pending?.skills?.length diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 99f0b93ebed..e9131281722 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -866,6 +866,176 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/web-shell/v1/workspace-extensions/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. */ + post: operations["queryWorkspaceExtensionPackages"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/workspace-extensions/packages/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. */ + post: operations["queryWorkspaceExtensionPackage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/workspace-extensions/settings/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. */ + post: operations["queryWorkspaceExtensionPackageSettings"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/workspace-extensions/settings/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires the same Workspace operator and deployment permission as publication. Writes a complete encrypted settings revision with optimistic concurrency; old pinned revisions remain available. No values are returned. */ + post: operations["submitWorkspaceExtensionPackageSettings"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/workspace-extensions/operations/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires an authenticated actor with a read grant on the persisted Workspace. Reads durable metadata only; never creates a Runtime, loads a Harness Session, or retries an original operation. The querying actor must own the original publication or settings operation. */ + post: operations["queryWorkspaceExtensionPackageOperation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/workspace-extensions/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires an ACTIVE Workspace, an authenticated operator and the deployment extension allowlist. The Idempotency-Key binds an actor-scoped durable operation. Publication retains immutable tar.gz bytes under a server-assigned stable extension identity; it does not activate a Session. The manifest query is base64url JSON and is covered by the existing request signature. Archive contents and Runtime compatibility are validated by the target compiler before activation. */ + post: operations["publishWebShellWorkspaceExtensionPackage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/session-extensions/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision. */ + post: operations["webShellSessionExtensionsQuery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/session-extensions/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision. */ + post: operations["webShellSessionExtensionsSubmit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/session-extensions/continue": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision. */ + post: operations["webShellSessionExtensionsContinue"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/web-shell/v1/session-extensions/recover-previous": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Requires a Workspace reader for status and OPERATOR for changes. Status reads durable configuration and never loads a Harness or retries an operation. Apply requires an idle Session and the original Runtime with the package execution contract; a committed unknown outcome fences new execution until the original operation is reconciled. Explicit recovery compiles previous content into a new revision. */ + post: operations["webShellSessionExtensionsRecoverPrevious"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -995,6 +1165,8 @@ export interface components { planMode?: boolean; /** @description Optional static Skills imported from the enabled management catalog or added explicitly to the pinned AgentDefinition catalog. The merged catalog has unique names, at most 256 entries, 512 KiB per body and 2 MiB compact JSON. Requires session-skills/2; persisted immutably and included in creation idempotency. */ skills?: components["schemas"]["SessionCreationSkill"][]; + /** @description Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime. */ + extensionPins?: components["schemas"]["ManagedExtensionPackagePin"][]; }; WebShellSubmitRequest: { /** @description Trace correlation only. */ @@ -2194,6 +2366,124 @@ export interface components { idempotencyKey: string; command: components["schemas"]["UserMessageCommand"]; }; + ManagedExtensionPackagePin: { + extensionId: string; + /** Format: int64 */ + packageRevision: number; + packageDigest: string; + /** Format: int64 */ + settingsRevision?: number; + }; + ManagedExtensionPackageManifest: { + /** @enum {integer} */ + schemaVersion: 1; + /** @enum {integer} */ + compilerVersion: 1; + name: string; + version: string; + target: { + /** @enum {string} */ + platform: "linux" | "darwin"; + /** @enum {string} */ + arch: "x64" | "arm64" | "any"; + }; + components: ("skills" | "commands" | "agents" | "context" | "mcp" | "hooks" | "settings")[]; + /** @enum {string} */ + sourceType?: "git" | "github-release" | "npm" | "directory" | "archive" | "archive-url" | "link" | "snapshot"; + /** @description Exact source commit, resolved npm version, or imported content revision. Supplied together with sourceType. */ + sourceRevision?: string; + }; + ManagedExtensionPackage: { + pin: components["schemas"]["ManagedExtensionPackagePin"]; + manifest: components["schemas"]["ManagedExtensionPackageManifest"]; + /** Format: int64 */ + byteLength: number; + }; + ManagedExtensionPackageCatalog: { + supported: boolean; + extensions: components["schemas"]["ManagedExtensionPackage"][]; + }; + ManagedExtensionPackageSettings: { + /** Format: int64 */ + settingsRevision: number; + configuredKeys: string[]; + }; + ManagedExtensionPackageOperation: { + operationId: string; + extensionId: string; + /** @enum {string} */ + kind: "publish" | "settings"; + /** @enum {string} */ + state: "pending" | "settled"; + /** Format: int64 */ + revision: number; + packageDigest: string | null; + }; + WebShellExtensionWorkspaceQuery: { + workspaceId: string; + }; + WebShellExtensionQuery: { + workspaceId: string; + extensionId: string; + }; + WebShellExtensionOperationQuery: { + workspaceId: string; + operationId: string; + }; + WebShellExtensionPackageQuery: { + workspaceId: string; + pin: components["schemas"]["ManagedExtensionPackagePin"]; + }; + WebShellExtensionSettingsSubmit: { + workspaceId: string; + extensionId: string; + idempotencyKey: string; + /** Format: int64 */ + expectedRevision: number; + /** @description Complete settings revision, bounded to 8 KiB UTF-8 JSON. Values are encrypted at rest and never returned by metadata or operation queries. */ + values: { + [key: string]: string; + }; + }; + ManagedExtensionApplySelection: { + expectedConfigRevision: number; + extensions: components["schemas"]["ManagedExtensionPackagePin"][]; + }; + ManagedExtensionApplyOperation: { + /** Format: uuid */ + operationId: string; + /** @enum {string} */ + state: "preparing" | "prepared" | "committed" | "active" | "recovery_required" | "failed"; + /** @enum {string} */ + mode: "apply" | "recoverPrevious"; + revision: number; + error?: { + code: string; + }; + }; + ManagedExtensionManagementSnapshot: { + sessionId: string; + supported: boolean; + revision: number; + extensions: components["schemas"]["ManagedExtensionPackagePin"][]; + requestedExtensions: components["schemas"]["ManagedExtensionPackagePin"][]; + operation?: components["schemas"]["ManagedExtensionApplyOperation"]; + }; + WebShellExtensionSessionQuery: { + sessionId: string; + /** Format: uuid */ + operationId?: string; + }; + WebShellExtensionSessionOperation: { + sessionId: string; + /** Format: uuid */ + operationId: string; + }; + WebShellExtensionSessionSubmit: { + sessionId: string; + idempotencyKey: string; + command: components["schemas"]["ManagedExtensionApplySelection"]; + }; }; responses: { /** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */ @@ -2278,7 +2568,9 @@ export interface components { }; }; }; - parameters: never; + parameters: { + IdempotencyKey: string; + }; requestBodies: never; headers: { /** @description True when an accepted command or operation was replayed for the same idempotency key and digest without repeating its mutation. Session mutation responses represent the recorded outcome on the Session as last visible — a rename or unarchive replay after delete answers with the Session's pre-delete status rather than 404, while a create replay after delete answers 404 on the public create route, which re-reads the Session before responding, and answers the recorded 202 admission carrying the tombstoned sessionId on the WebShell create route, which does not re-read it; operation responses represent the original operation at its current state. */ @@ -3997,4 +4289,347 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + queryWorkspaceExtensionPackages: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionWorkspaceQuery"]; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackageCatalog"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + queryWorkspaceExtensionPackage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionPackageQuery"]; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackage"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + queryWorkspaceExtensionPackageSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionQuery"]; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackageSettings"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + submitWorkspaceExtensionPackageSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionSettingsSubmit"]; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackageOperation"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + queryWorkspaceExtensionPackageOperation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionOperationQuery"]; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackageOperation"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + publishWebShellWorkspaceExtensionPackage: { + parameters: { + query: { + workspaceId: string; + extensionId?: string; + packageDigest: string; + manifest: string; + }; + header: { + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Persisted Workspace extension resource. Contains no settings values or executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionPackageOperation"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + }; + }; + webShellSessionExtensionsQuery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionSessionQuery"]; + }; + }; + responses: { + /** @description Persisted or reconciled Session configuration; contains no settings values or private executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionManagementSnapshot"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + /** @description The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + webShellSessionExtensionsSubmit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionSessionSubmit"]; + }; + }; + responses: { + /** @description Persisted or reconciled Session configuration; contains no settings values or private executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionManagementSnapshot"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + /** @description The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + webShellSessionExtensionsContinue: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionSessionOperation"]; + }; + }; + responses: { + /** @description Persisted or reconciled Session configuration; contains no settings values or private executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionManagementSnapshot"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + /** @description The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + webShellSessionExtensionsRecoverPrevious: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellExtensionSessionOperation"]; + }; + }; + responses: { + /** @description Persisted or reconciled Session configuration; contains no settings values or private executable recipes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedExtensionManagementSnapshot"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + /** @description The original MCP Runtime or management operation cannot be confirmed. Retry the same operation identity. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; } diff --git a/packages/web-shell/client/components/managed/java-managed-agent-client.ts b/packages/web-shell/client/components/managed/java-managed-agent-client.ts index 9aa1e59df3f..73fc90fe11c 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-client.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-client.ts @@ -1,3 +1,12 @@ +import type { + ManagedExtensionManifest, + ManagedExtensionPin, + ManagedExtensionCatalog, + ManagedExtensionPackageOperation, + ManagedExtensionSettings, + ManagedExtensionSnapshot, + ManagedExtensionSelection, +} from './managed-extension-types'; import type { components } from './generated/managed-agent-api'; import type { ManagedMcpManagementSnapshot } from './managed-mcp-management-types'; import type { @@ -420,6 +429,133 @@ export class JavaManagedAgentClient { ); } + listExtensionPackages(workspaceId: string, signal?: AbortSignal) { + return this.post( + '/workspace-extensions/query', + { workspaceId }, + signal, + ); + } + + async publishExtensionPackage( + workspaceId: string, + extensionId: string | undefined, + packageDigest: string, + manifest: ManagedExtensionManifest, + archive: Blob, + idempotencyKey: string, + signal?: AbortSignal, + ): Promise { + if (archive.size > 100 * 1024 * 1024) + throw new Error('Managed package exceeds 100 MiB'); + const encoded = btoa( + String.fromCharCode( + ...new TextEncoder().encode(JSON.stringify(manifest)), + ), + ) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); + const query = new URLSearchParams({ + workspaceId, + packageDigest, + manifest: encoded, + }); + if (extensionId) query.set('extensionId', extensionId); + const headers = new Headers(await this.options.getHeaders?.()); + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Idempotency-Key', idempotencyKey); + const response = await this.fetchImpl( + `${this.baseUrl}${API_PREFIX}/workspace-extensions/publish?${query}`, + { + method: 'POST', + headers, + body: archive, + credentials: this.credentials, + signal, + }, + ); + if (!response.ok) throw await toHttpError(response); + return response.json(); + } + + getExtensionSettings( + workspaceId: string, + extensionId: string, + signal?: AbortSignal, + ) { + return this.post( + '/workspace-extensions/settings/query', + { workspaceId, extensionId }, + signal, + ); + } + + saveExtensionSettings( + workspaceId: string, + extensionId: string, + expectedRevision: number, + values: Record, + idempotencyKey: string, + signal?: AbortSignal, + ) { + return this.post( + '/workspace-extensions/settings/submit', + { workspaceId, extensionId, expectedRevision, values, idempotencyKey }, + signal, + ); + } + + queryExtensionConfiguration( + sessionId: string, + operationId?: string, + signal?: AbortSignal, + ) { + return this.post( + '/session-extensions/query', + { sessionId, ...(operationId ? { operationId } : {}) }, + signal, + ); + } + + applyExtensionConfiguration( + sessionId: string, + command: ManagedExtensionSelection, + idempotencyKey: string, + signal?: AbortSignal, + ) { + return this.post( + '/session-extensions/submit', + { sessionId, command, idempotencyKey }, + signal, + ); + } + + resumeExtensionConfiguration( + sessionId: string, + operationId: string, + recoverPrevious: boolean, + signal?: AbortSignal, + ) { + return this.post( + `/session-extensions/${recoverPrevious ? 'recover-previous' : 'continue'}`, + { sessionId, operationId }, + signal, + ); + } + + getExtensionPackage( + workspaceId: string, + pin: ManagedExtensionPin, + signal?: AbortSignal, + ) { + return this.post( + '/workspace-extensions/packages/query', + { workspaceId, pin }, + signal, + ); + } + queryMcpManagement( request: Schemas['WebShellMcpManagementQuery'], signal?: AbortSignal, diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index 6f8e1082684..ad3dedfdd49 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -1,3 +1,4 @@ +import { requireExtensionSnapshot } from './managed-extension-types'; import type { components } from './generated/managed-agent-api'; import { isJavaAgentResyncRequired, @@ -113,6 +114,7 @@ export function createJavaManagedAgentProvider( approvalMode?: ManagedApprovalMode; planMode?: boolean; skills?: import('./managed-agent-provider').ManagedSessionSkill[]; + extensionPins?: import('./managed-extension-types').ManagedExtensionPin[]; }, command: ManagedAgentCommandOptions, ) { @@ -126,6 +128,9 @@ export function createJavaManagedAgentProvider( agentId: request.agentId, ...(request.modelId === undefined ? {} : { modelId: request.modelId }), ...(request.skills?.length ? { skills: request.skills } : {}), + ...(request.extensionPins === undefined + ? {} + : { extensionPins: request.extensionPins }), // Without a title the Session list and header fall back to a UUID // prefix, unlike the standard shell's prompt-derived title. ...(request.text === undefined @@ -259,6 +264,74 @@ export function createJavaManagedAgentProvider( list: (sessionId, request) => client.getSessionMcp(sessionId, request.signal), }, + extensions: { + storageScope: options.productScope?.trim() + ? storageKey(options) + : undefined, + list: (workspaceId, request) => + client.listExtensionPackages(workspaceId, request.signal), + publish: (workspaceId, extensionId, digest, manifest, archive, request) => + client.publishExtensionPackage( + workspaceId, + extensionId, + digest, + manifest, + archive, + request.idempotencyKey, + request.signal, + ), + settings: (workspaceId, extensionId, request) => + client.getExtensionSettings(workspaceId, extensionId, request.signal), + saveSettings: (workspaceId, extensionId, revision, values, request) => + client.saveExtensionSettings( + workspaceId, + extensionId, + revision, + values, + request.idempotencyKey, + request.signal, + ), + query: async (sessionId, request) => + requireExtensionSnapshot( + await client.queryExtensionConfiguration( + sessionId, + undefined, + request.signal, + ), + sessionId, + ), + apply: async (sessionId, selection, request) => + requireExtensionSnapshot( + await client.applyExtensionConfiguration( + sessionId, + selection, + request.idempotencyKey, + request.signal, + ), + sessionId, + ), + operation: async (sessionId, operationId, request) => + requireExtensionSnapshot( + await client.queryExtensionConfiguration( + sessionId, + operationId, + request.signal, + ), + sessionId, + ), + resume: async (sessionId, operationId, recoverPrevious, request) => + requireExtensionSnapshot( + await client.resumeExtensionConfiguration( + sessionId, + operationId, + recoverPrevious, + request.signal, + ), + sessionId, + ), + package: (workspaceId, pin, request) => + client.getExtensionPackage(workspaceId, pin, request.signal), + }, mcpManagement: { storageScope: options.productScope?.trim() ? storageKey(options) diff --git a/packages/web-shell/client/components/managed/managed-agent-provider.ts b/packages/web-shell/client/components/managed/managed-agent-provider.ts index 2d5c95b1bec..e1b337caed5 100644 --- a/packages/web-shell/client/components/managed/managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/managed-agent-provider.ts @@ -1,3 +1,12 @@ +import type { + ManagedExtensionManifest, + ManagedExtensionPin, + ManagedExtensionCatalog, + ManagedExtensionPackageOperation, + ManagedExtensionSettings, + ManagedExtensionSnapshot, + ManagedExtensionSelection, +} from './managed-extension-types'; import type { components } from './generated/managed-agent-api'; import type { ManagedMcpManagementCommand, @@ -374,6 +383,58 @@ export interface ManagedAgentProvider { options: ManagedAgentRequestOptions, ): Promise; }; + readonly extensions?: { + readonly storageScope?: string; + list( + workspaceId: string, + options: ManagedAgentRequestOptions, + ): Promise; + publish( + workspaceId: string, + extensionId: string | undefined, + packageDigest: string, + manifest: ManagedExtensionManifest, + archive: Blob, + options: ManagedAgentCommandOptions, + ): Promise; + settings( + workspaceId: string, + extensionId: string, + options: ManagedAgentRequestOptions, + ): Promise; + saveSettings( + workspaceId: string, + extensionId: string, + expectedRevision: number, + values: Record, + options: ManagedAgentCommandOptions, + ): Promise; + query( + sessionId: string, + options: ManagedAgentRequestOptions, + ): Promise; + apply( + sessionId: string, + selection: ManagedExtensionSelection, + options: ManagedAgentCommandOptions, + ): Promise; + operation( + sessionId: string, + operationId: string, + options: ManagedAgentRequestOptions, + ): Promise; + resume( + sessionId: string, + operationId: string, + recoverPrevious: boolean, + options: ManagedAgentRequestOptions, + ): Promise; + package( + workspaceId: string, + pin: ManagedExtensionPin, + options: ManagedAgentRequestOptions, + ): Promise; + }; readonly mcpManagement?: { /** Explicit tenant/actor namespace for pending commands saved in this tab. */ readonly storageScope?: string; @@ -551,6 +612,7 @@ export interface ManagedAgentProvider { approvalMode?: ManagedApprovalMode; planMode?: boolean; skills?: ManagedSessionSkill[]; + extensionPins?: ManagedExtensionPin[]; }, options: ManagedAgentCommandOptions, ): Promise<{ sessionId: string }>; @@ -565,6 +627,7 @@ export interface ManagedAgentProvider { approvalMode?: ManagedApprovalMode; planMode?: boolean; skills?: ManagedSessionSkill[]; + extensionPins?: ManagedExtensionPin[]; }, options: ManagedAgentCommandOptions, ): Promise<{ sessionId: string }>; diff --git a/packages/web-shell/client/components/managed/managed-extension-messages.ts b/packages/web-shell/client/components/managed/managed-extension-messages.ts new file mode 100644 index 00000000000..c73e3ca605b --- /dev/null +++ b/packages/web-shell/client/components/managed/managed-extension-messages.ts @@ -0,0 +1,82 @@ +export const MANAGED_EXTENSION_MESSAGES_EN = { + 'managed.extensions.title': 'Managed extensions', + 'managed.extensions.description': + 'Publish a fixed package, select versions, then apply to an idle session.', + 'managed.extensions.selectSession': + 'Select a workspace and session to manage extensions.', + 'managed.extensions.unsupported': + 'Package extensions are unavailable for this target.', + 'managed.extensions.source': 'Original Git, npm, directory or archive source', + 'managed.extensions.ref': 'Source revision (optional)', + 'managed.extensions.target': 'Target platform', + 'managed.extensions.publish': 'Publish package', + 'managed.extensions.new': 'New extension', + 'managed.extensions.update': 'Update selected extension', + 'managed.extensions.apply': 'Apply selection', + 'managed.extensions.retry': 'Retry original request', + 'managed.extensions.continue': 'Continue original operation', + 'managed.extensions.recover': 'Restore previous version', + 'managed.extensions.settings': 'Settings', + 'managed.extensions.settingsDescription': + 'Enter the complete set of environment settings. Saved values are never displayed.', + 'managed.extensions.addSetting': 'Add setting', + 'managed.extensions.key': 'Environment variable', + 'managed.extensions.value': 'Value', + 'managed.extensions.saveSettings': 'Save settings revision', + 'managed.extensions.useSettings': 'Use saved settings revision', + 'managed.extensions.settingsRevision': 'Settings revision (optional)', + 'managed.extensions.installed': 'Installed', + 'managed.extensions.pending': 'Pending apply', + 'managed.extensions.active': 'Active', + 'managed.extensions.preparing': 'Preparing', + 'managed.extensions.prepared': 'Ready to apply', + 'managed.extensions.committed': 'Applying', + 'managed.extensions.recovery_required': 'Recovery blocked', + 'managed.extensions.failed': 'Failed', + 'managed.extensions.empty': 'No packages published in this workspace.', + 'managed.extensions.failedRequest': + 'The request could not be confirmed. Refresh status or retry the original request.', + 'managed.extensions.localPublisher': + 'A connected daemon is required to prepare an original source.', +} as const; +export const MANAGED_EXTENSION_MESSAGES_ZH: Record< + keyof typeof MANAGED_EXTENSION_MESSAGES_EN, + string +> = { + 'managed.extensions.title': 'Managed 扩展', + 'managed.extensions.description': + '发布固定版本的包,选择版本后,在会话空闲时显式应用。', + 'managed.extensions.selectSession': '请选择 Workspace 和会话以管理扩展。', + 'managed.extensions.unsupported': '当前目标尚未开放整包扩展。', + 'managed.extensions.source': '原始 Git、npm、目录或 archive 来源', + 'managed.extensions.ref': '来源修订(可选)', + 'managed.extensions.target': '目标平台', + 'managed.extensions.publish': '发布扩展包', + 'managed.extensions.new': '新扩展', + 'managed.extensions.update': '更新所选扩展', + 'managed.extensions.apply': '应用所选版本', + 'managed.extensions.retry': '重试原请求', + 'managed.extensions.continue': '继续原操作', + 'managed.extensions.recover': '恢复上一版', + 'managed.extensions.settings': '设置', + 'managed.extensions.settingsDescription': + '输入完整的环境设置。已保存的值不会回显。', + 'managed.extensions.addSetting': '添加设置', + 'managed.extensions.key': '环境变量名', + 'managed.extensions.value': '值', + 'managed.extensions.saveSettings': '保存设置修订', + 'managed.extensions.useSettings': '使用已保存的设置修订', + 'managed.extensions.settingsRevision': '设置修订(可选)', + 'managed.extensions.installed': '已安装', + 'managed.extensions.pending': '待应用', + 'managed.extensions.active': '已生效', + 'managed.extensions.preparing': '准备中', + 'managed.extensions.prepared': '已准备', + 'managed.extensions.committed': '应用中', + 'managed.extensions.recovery_required': '恢复阻塞', + 'managed.extensions.failed': '失败', + 'managed.extensions.empty': '此 Workspace 尚未发布扩展包。', + 'managed.extensions.failedRequest': + '请求结果尚未确认。请刷新状态或重试原请求。', + 'managed.extensions.localPublisher': '需要连接 daemon 才能准备原始来源。', +}; diff --git a/packages/web-shell/client/components/managed/managed-extension-types.ts b/packages/web-shell/client/components/managed/managed-extension-types.ts new file mode 100644 index 00000000000..b00a4def6c4 --- /dev/null +++ b/packages/web-shell/client/components/managed/managed-extension-types.ts @@ -0,0 +1,87 @@ +import type { components } from './generated/managed-agent-api'; + +export type ManagedExtensionPin = + components['schemas']['ManagedExtensionPackagePin']; +export type ManagedExtensionManifest = + components['schemas']['ManagedExtensionPackageManifest']; +export type ManagedExtensionCatalog = + components['schemas']['ManagedExtensionPackageCatalog']; +export type ManagedExtensionPackageOperation = + components['schemas']['ManagedExtensionPackageOperation']; +export type ManagedExtensionSettings = + components['schemas']['ManagedExtensionPackageSettings']; +export type ManagedExtensionSnapshot = + components['schemas']['ManagedExtensionManagementSnapshot']; +export type ManagedExtensionSelection = + components['schemas']['ManagedExtensionApplySelection']; + +export function isManagedExtensionPins( + value: unknown, +): value is ManagedExtensionPin[] { + if (!Array.isArray(value) || value.length > 32) return false; + const ids = new Set(); + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return false; + const pin = item as Record; + if ( + Object.keys(pin).some( + (key) => + ![ + 'extensionId', + 'packageRevision', + 'packageDigest', + 'settingsRevision', + ].includes(key), + ) || + typeof pin['extensionId'] !== 'string' || + !/^ext_[a-f0-9-]{36}$/.test(pin['extensionId']) || + ids.has(pin['extensionId']) || + !Number.isSafeInteger(pin['packageRevision']) || + Number(pin['packageRevision']) < 1 || + typeof pin['packageDigest'] !== 'string' || + !/^[a-f0-9]{64}$/.test(pin['packageDigest']) || + (pin['settingsRevision'] !== undefined && + (!Number.isSafeInteger(pin['settingsRevision']) || + Number(pin['settingsRevision']) < 1)) + ) + return false; + ids.add(pin['extensionId']); + } + return true; +} + +export function requireExtensionSnapshot( + value: ManagedExtensionSnapshot, + sessionId: string, +): ManagedExtensionSnapshot { + if ( + value.sessionId !== sessionId || + typeof value.supported !== 'boolean' || + !Number.isSafeInteger(value.revision) || + value.revision < 0 || + !Array.isArray(value.extensions) || + !Array.isArray(value.requestedExtensions) + ) + throw new Error('Invalid Managed extension snapshot'); + for (const pins of [value.extensions, value.requestedExtensions]) { + if (!isManagedExtensionPins(pins)) + throw new Error('Invalid Managed extension selection'); + } + if ( + value.operation && + (!/^[0-9a-f-]{36}$/i.test(value.operation.operationId) || + ![ + 'preparing', + 'prepared', + 'committed', + 'active', + 'recovery_required', + 'failed', + ].includes(value.operation.state) || + !['apply', 'recoverPrevious'].includes(value.operation.mode) || + !Number.isSafeInteger(value.operation.revision) || + value.operation.revision < 1) + ) + throw new Error('Invalid Managed extension operation'); + return value; +} diff --git a/packages/web-shell/client/e2e/managed-mcp.spec.ts b/packages/web-shell/client/e2e/managed-mcp.spec.ts index 86b359bcc28..c3f82895988 100644 --- a/packages/web-shell/client/e2e/managed-mcp.spec.ts +++ b/packages/web-shell/client/e2e/managed-mcp.spec.ts @@ -24,6 +24,8 @@ for (const width of [1280, 390]) { const body = route.request().postDataJSON(); if (path.endsWith('/user-messages/query')) return route.fulfill({ json: { paused: false, messages: [] } }); + if (path.endsWith('/workspace-extensions/query')) + return route.fulfill({ json: { supported: false, extensions: [] } }); if (path.endsWith('/runtime-nodes/query')) return route.fulfill({ json: { supported: false, data: [] } }); if (path.endsWith('/sessions/query')) diff --git a/packages/web-shell/client/e2e/managed-recovery.spec.ts b/packages/web-shell/client/e2e/managed-recovery.spec.ts index 4028337f5ec..fbfdd40dc9b 100644 --- a/packages/web-shell/client/e2e/managed-recovery.spec.ts +++ b/packages/web-shell/client/e2e/managed-recovery.spec.ts @@ -101,6 +101,8 @@ async function installRecovery(page: Page, running = false) { const body = route.request().postDataJSON(); if (path.endsWith('/user-messages/query')) return route.fulfill({ json: { paused: false, messages: [] } }); + if (path.endsWith('/workspace-extensions/query')) + return route.fulfill({ json: { supported: false, extensions: [] } }); if (path.endsWith('/runtime-nodes/query')) return route.fulfill({ json: { supported: false, data: [] } }); if (path.endsWith('/sessions/query')) diff --git a/packages/web-shell/client/e2e/managed-ssh-workspace-registration.spec.ts b/packages/web-shell/client/e2e/managed-ssh-workspace-registration.spec.ts index 81a7addd09c..4d1e113b4c8 100644 --- a/packages/web-shell/client/e2e/managed-ssh-workspace-registration.spec.ts +++ b/packages/web-shell/client/e2e/managed-ssh-workspace-registration.spec.ts @@ -34,6 +34,8 @@ for (const width of [1280, 390]) { capabilities: { workspaceBinding: true }, }, }); + if (path.endsWith('/workspace-extensions/query')) + return route.fulfill({ json: { supported: false, extensions: [] } }); if (path.endsWith('/runtime-nodes/query')) return route.fulfill({ json: { diff --git a/packages/web-shell/client/e2e/managed-usability.spec.ts b/packages/web-shell/client/e2e/managed-usability.spec.ts index c04e7914e9e..9525a136bd3 100644 --- a/packages/web-shell/client/e2e/managed-usability.spec.ts +++ b/packages/web-shell/client/e2e/managed-usability.spec.ts @@ -26,6 +26,8 @@ for (const width of [1280, 390]) { const body = request.postDataJSON(); if (path.endsWith('/user-messages/query')) return route.fulfill({ json: { paused: false, messages: [] } }); + if (path.endsWith('/workspace-extensions/query')) + return route.fulfill({ json: { supported: false, extensions: [] } }); if (path.endsWith('/runtime-nodes/query')) return route.fulfill({ json: { supported: false, data: [] } }); if (path.endsWith('/sessions/query')) @@ -257,6 +259,8 @@ test('abandoning an unconfirmed first input warns about execution and refreshes const body = route.request().postDataJSON(); if (path.endsWith('/user-messages/query')) return route.fulfill({ json: { paused: false, messages: [] } }); + if (path.endsWith('/workspace-extensions/query')) + return route.fulfill({ json: { supported: false, extensions: [] } }); if (path.endsWith('/runtime-nodes/query')) return route.fulfill({ json: { supported: false, data: [] } }); if (path.endsWith('/sessions/query')) { diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 8f2116c07df..08c05e098ba 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1,3 +1,7 @@ +import { + MANAGED_EXTENSION_MESSAGES_EN, + MANAGED_EXTENSION_MESSAGES_ZH, +} from './components/managed/managed-extension-messages.js'; import { MANAGED_LIFECYCLE_MESSAGES_EN, MANAGED_LIFECYCLE_MESSAGES_ZH, @@ -4616,6 +4620,7 @@ const EN: Messages = { ...MANAGED_LIFECYCLE_MESSAGES_EN, ...WORKSPACE_REGISTRATION_MESSAGES_EN, ...MANAGED_MCP_MESSAGES_EN, + ...MANAGED_EXTENSION_MESSAGES_EN, ...TRAJECTORY_EXECUTION_MESSAGES_EN, ...TRAJECTORY_FILTER_MESSAGES_EN, ...TRAJECTORY_WINDOW_MESSAGES_EN, @@ -8878,6 +8883,7 @@ const ZH: Messages = { ...MANAGED_LIFECYCLE_MESSAGES_ZH, ...WORKSPACE_REGISTRATION_MESSAGES_ZH, ...MANAGED_MCP_MESSAGES_ZH, + ...MANAGED_EXTENSION_MESSAGES_ZH, ...TRAJECTORY_EXECUTION_MESSAGES_ZH, ...TRAJECTORY_FILTER_MESSAGES_ZH, ...TRAJECTORY_WINDOW_MESSAGES_ZH,