From 95b72c826f6c883f84dab6de51f46e1c32e5c747 Mon Sep 17 00:00:00 2001 From: YiJie <51358815+NWYLZW@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:36:49 +0800 Subject: [PATCH 1/6] feat(manager): add managed gateway account controls --- README.md | 20 +- README.zh-Hans.md | 15 +- config/cli-proxy-api.yaml | 14 + cordisx-package.json | 11 +- package-lock.json | 39 +- package.json | 19 +- runtime-manifest.json | 66 +- ...roxy-gateway-codex-upstream.v1.schema.json | 34 + ...proxy-gateway-model-catalog.v1.schema.json | 22 + ...oxy-gateway-openai-upstream.v1.schema.json | 55 + ...proxy-management-auth-files.v1.schema.json | 13 + ...-proxy-management-operation.v1.schema.json | 5 + ...i-proxy-upstream-descriptor.v1.schema.json | 74 ++ scripts/build-service.mjs | 8 +- src/index.ts | 434 +++++++- src/manager-configuration.ts | 17 + src/provider-fleet-page.tsx | 8 +- src/service/gateway-definition.ts | 139 +++ src/service/gateway-ui.ts | 462 ++++++++ src/service/gateway.ts | 406 +++++++ src/service/index.ts | 2 + src/service/upstream-context.ts | 76 ++ src/service/upstream-registry.ts | 735 +++++++++++++ src/upstream-subscription-manager-messages.ts | 81 ++ src/upstream-subscription-manager-page.css | 197 ++++ src/upstream-subscription-manager-page.tsx | 937 +++++++++++++++++ src/vite-env.d.ts | 1 + test/artifact.mjs | 5 +- test/gateway.mjs | 989 ++++++++++++++++++ test/lease-lifecycle.mjs | 182 ++++ test/manifest.mjs | 282 ++++- test/optional-manager-context.mjs | 137 +++ test/package-artifact.mjs | 142 ++- test/support/gateway-fixture.mjs | 59 ++ test/upstream-consumer/index.ts | 46 + test/upstream-consumer/tsconfig.json | 9 + test/upstream-registry.mjs | 348 ++++++ test/upstream-subscription-manager.mjs | 284 +++++ 38 files changed, 6255 insertions(+), 118 deletions(-) create mode 100644 config/cli-proxy-api.yaml create mode 100644 schemas/cli-proxy-gateway-codex-upstream.v1.schema.json create mode 100644 schemas/cli-proxy-gateway-model-catalog.v1.schema.json create mode 100644 schemas/cli-proxy-gateway-openai-upstream.v1.schema.json create mode 100644 schemas/cli-proxy-management-auth-files.v1.schema.json create mode 100644 schemas/cli-proxy-management-operation.v1.schema.json create mode 100644 schemas/cli-proxy-upstream-descriptor.v1.schema.json create mode 100644 src/manager-configuration.ts create mode 100644 src/service/gateway-definition.ts create mode 100644 src/service/gateway-ui.ts create mode 100644 src/service/gateway.ts create mode 100644 src/service/upstream-context.ts create mode 100644 src/service/upstream-registry.ts create mode 100644 src/upstream-subscription-manager-messages.ts create mode 100644 src/upstream-subscription-manager-page.css create mode 100644 src/upstream-subscription-manager-page.tsx create mode 100644 src/vite-env.d.ts create mode 100644 test/gateway.mjs create mode 100644 test/lease-lifecycle.mjs create mode 100644 test/optional-manager-context.mjs create mode 100644 test/support/gateway-fixture.mjs create mode 100644 test/upstream-consumer/index.ts create mode 100644 test/upstream-consumer/tsconfig.json create mode 100644 test/upstream-registry.mjs create mode 100644 test/upstream-subscription-manager.mjs diff --git a/README.md b/README.md index 1cb4517..6ede980 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,23 @@ provider sessions plugin. ## Delivery status -This version contains the standalone renderer and executable package-v13 -`platform-provider` service. The renderer uses only public Host React, UI, and -`ctx.platform` contracts. The Node service uses only the public Protocol broker -and `ctx.platformProviders.register`; CLIProxy method declarations, response -projection, lifecycle, and approval adaptation live here. +This version contains the standalone renderer, executable `platform-provider` +service, and package-v14 managed gateway runtime. The renderer uses only public +Host React, UI, and service contracts. The Node services use only public +Protocol and Host-managed service APIs; CLIProxy method declarations, response +projection, lifecycle, upstream composition, and account controls live here. The Host owns endpoint and credential resolution, process startup, opaque workspace handles, method/schema policy, configuration persistence, and the single Provider Fleet. The plugin receives no endpoint, credential, process, filesystem path, raw transport, or Fleet handle. -The plugin pins Protocol `c2f6f8e4bf4a638bf4627c9c567792f2fedbcfd6` and -requires a Host with manifest/package v13 exact-request scope support. The package remains `private` -to prevent npm publication; its manifest declares explicit local source -distribution, and no packaged installer is available yet. +The plugin currently pins the experimental Protocol review head +`a1127780513b76f0c3b1acee70cd638b5348c6a5` and Host review head +`6afdc0353a23f7e88d7e67fff23590552457078a`. These dependencies are public but +unmerged and unpublished. The package remains `private` to prevent npm +publication; its manifest declares explicit local source distribution, and no +packaged installer is available yet. ## Development diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 37331e0..e1f49a2 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -4,18 +4,19 @@ ## 交付状态 -本版本包含独立 renderer 与可执行的 package-v13 `platform-provider` 服务。 -Renderer 只使用公开的 Host React、UI 和 `ctx.platform` 合同;Node 服务只使用 -公开 Protocol broker 与 `ctx.platformProviders.register`。CLIProxy 的方法声明、 -响应投影、生命周期和审批适配均由本仓库维护。 +本版本包含独立 renderer、可执行的 `platform-provider` 服务和 package-v14 托管网关 +runtime。Renderer 只使用公开的 Host React、UI 和服务合同;Node 服务只使用公开 +Protocol 与 Host 托管服务 API。CLIProxy 的方法声明、响应投影、生命周期、上游组合和 +账号控制均由本仓库维护。 Host 负责 endpoint 与凭据解析、进程启动、不透明 workspace handle、方法/Schema 策略、配置持久化和唯一的 Provider Fleet。插件不会获得 endpoint、凭据、进程、 文件系统路径、原始 transport 或 Fleet handle。 -插件精确固定 Protocol `c2f6f8e4bf4a638bf4627c9c567792f2fedbcfd6`,并要求 Host -支持 manifest/package v13 的 exact-request scope。包继续保持 `private` 以阻止 npm -发布;manifest 声明显式本地源码分发,目前仍未提供安装包。 +插件当前固定实验性 Protocol review head +`a1127780513b76f0c3b1acee70cd638b5348c6a5` 和 Host review head +`6afdc0353a23f7e88d7e67fff23590552457078a`。这些公开依赖尚未合并或发布。包继续保持 +`private` 以阻止 npm 发布;manifest 声明显式本地源码分发,目前仍未提供安装包。 ## 开发 diff --git a/config/cli-proxy-api.yaml b/config/cli-proxy-api.yaml new file mode 100644 index 0000000..6ce48fa --- /dev/null +++ b/config/cli-proxy-api.yaml @@ -0,0 +1,14 @@ +host: 127.0.0.1 +port: 0 +api-keys: + - null +commercial-mode: true +debug: false +logging-to-file: false +remote-management: + allow-remote: false + secret-key: null + disable-control-panel: true + disable-auto-update-panel: true +codex-api-key: [] +openai-compatibility: [] diff --git a/cordisx-package.json b/cordisx-package.json index 29953fa..59c0d61 100644 --- a/cordisx-package.json +++ b/cordisx-package.json @@ -1,6 +1,6 @@ { - "$schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-package.v13.schema.json", - "schemaVersion": 13, + "$schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-package.v14.schema.json", + "schemaVersion": 14, "id": "cli-proxy-api", "version": "0.1.0", "entry": "./dist/runtime/module.js", @@ -13,13 +13,14 @@ "compatibility": { "runtimeAbi": 1, "protocolSchemas": [ - "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v13.schema.json" + "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v14.schema.json", + "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-definition.v1.schema.json" ] }, "dependencies": [], "runtimeManifest": { "path": "./runtime-manifest.json", - "schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v13.schema.json", - "digest": "sha256:42c9c6dbcb5435b8f121d83aaf5f81252c0004587837b8d1d2bcceebdd8a69b0" + "schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v14.schema.json", + "digest": "sha256:a66facc4315bf2b63941e94551ee982c32c034188bfbceb92135c779f0332dbf" } } diff --git a/package-lock.json b/package-lock.json index 4a2def6..271b079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "devDependencies": { "@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "3.18.1", "@projectwallace/stylelint-plugin": "0.7.0", @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "6.1.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "cordisx": "github:cordisx/cordisx#3cfe370eb7abf33e16686fbd82659cd441247fbd", + "cordisx": "github:cordisx/cordisx#6afdc0353a23f7e88d7e67fff23590552457078a", "dprint": "0.57.1", "esbuild": "0.28.2", "eslint": "9.39.4", @@ -129,6 +129,16 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@cordisx/channel": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/cordisx/plugin-channel.git#4cee12e3a92eeed557bc9de8cc4792710918327a", + "integrity": "sha512-uErxyAHsoKSQKASW8DZM/V6cQt6rPZ8jjE4vzQ8H1rFGIhuB8mE4Qy8PAnw7Y1sFafMsB+ENVqJDrLD5CwiGTA==", + "dev": true, + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=22.19" + } + }, "node_modules/@cordisx/eslint-config": { "version": "0.1.0", "resolved": "git+ssh://git@github.com/cordisx/cordisxmono.git#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", @@ -143,8 +153,8 @@ }, "node_modules/@cordisx/protocol": { "version": "0.1.0-alpha.0", - "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#f46dd21e15a949a26f05f89bf11dea339fc60c02", - "integrity": "sha512-o/YzvFs5gkrYsK0vijubLN/go2/oL04sixPz/Ks7N0eMLEHUL7qxD56Yf1ltW5dN9qfBIpgkHuiXgkSgOjj1bw==", + "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#a1127780513b76f0c3b1acee70cd638b5348c6a5", + "integrity": "sha512-m+/bdnP/tx0RbzISwUOfOcRVbjuarHTyNdHuX+IsrAETRmTwBTAhJI8S5Irx/2H+YZUrsU23JcUxMZCfcjFwxQ==", "dev": true, "license": "AGPL-3.0-or-later", "engines": { @@ -2698,12 +2708,10 @@ "node_modules/cordisx": { "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#3cfe370eb7abf33e16686fbd82659cd441247fbd", - "integrity": "sha512-k6IhNsliOoWBDU0PuaQJUWO/llUdJAlrcdV9ErjyjSK+kYK0MJzO9UWWSnTc3bJGwLJ/Osv1cNo+tGV7BPwhcA==", + "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#6afdc0353a23f7e88d7e67fff23590552457078a", + "integrity": "sha512-xMLYyYDpH1nN22T7jLllh83s+hbvFpfv3h2aR8R2p2DG9+bKCnA1RFfw0W6c/h5QztGWQuIRrx0bg+gadcIbYg==", "bundleDependencies": [ - "@cordisx/schemastery-ui", - "@cordisx/channel", - "@cordisx/plugin-cli-proxy-api" + "@cordisx/schemastery-ui" ], "dev": true, "license": "AGPL-3.0-or-later", @@ -2712,8 +2720,7 @@ ], "dependencies": { "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", - "@cordisx/plugin-cli-proxy-api": "github:cordisx/plugin-cli-proxy-api#1428ee205aab31df2779398cc8491879b303d1d0", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "^3.18.1", @@ -2767,16 +2774,13 @@ "name": "cordisx", "version": "0.1.0-beta.2", "bundleDependencies": [ - "@cordisx/schemastery-ui", - "@cordisx/channel", - "@cordisx/plugin-cli-proxy-api" + "@cordisx/schemastery-ui" ], "extraneous": true, "license": "AGPL-3.0-or-later", "dependencies": { - "@cordisx/channel": "0.1.0", - "@cordisx/plugin-cli-proxy-api": "0.1.0", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02", + "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "^3.18.1", @@ -2817,6 +2821,7 @@ }, "devDependencies": { "@cordisx/channel-runtime": "file:../channel-runtime", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@types/node": "^22.18.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", diff --git a/package.json b/package.json index 828c822..cc0af15 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "type": "module", "files": [ "dist", + "config", + "schemas", "cordisx-package.json", "runtime-manifest.json", "README.md", @@ -21,11 +23,21 @@ "types": "./dist/types/index.d.ts", "import": "./dist/runtime/module.js", "default": "./dist/runtime/module.js" + }, + "./upstream/v1": { + "types": "./dist/types/service/upstream-registry.d.ts", + "import": "./dist/service.mjs", + "default": "./dist/service.mjs" + }, + "./gateway/v1": { + "types": "./dist/types/service/gateway.d.ts", + "import": "./dist/gateway.mjs", + "default": "./dist/gateway.mjs" } }, "scripts": { "build": "rm -rf dist && node scripts/sync-package-manifest.mjs && vite build && tsc -p tsconfig.json --emitDeclarationOnly && node scripts/build-service.mjs", - "check": "npm run typecheck && npm run build && npm test && npm run lint && npm run stylelint && npm run format:check", + "check": "npm run typecheck && npm run build && npm run typecheck:consumer && npm test && npm run lint && npm run stylelint && npm run format:check", "dev": "cordisx dev --config cordisx.config.json", "dev:dry-run": "cordisx dev --config cordisx.config.json --dry-run", "format": "dprint fmt", @@ -34,17 +46,18 @@ "stylelint": "stylelint \"src/**/*.css\" --allow-empty-input", "test": "node --test test/*.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:consumer": "tsc -p test/upstream-consumer/tsconfig.json --noEmit", "prepare": "npm run build" }, "devDependencies": { "@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "3.18.1", "@projectwallace/stylelint-plugin": "0.7.0", "@typescript-eslint/parser": "8.69.0", "@vitejs/plugin-react": "6.1.0", - "cordisx": "github:cordisx/cordisx#3cfe370eb7abf33e16686fbd82659cd441247fbd", + "cordisx": "github:cordisx/cordisx#6afdc0353a23f7e88d7e67fff23590552457078a", "dprint": "0.57.1", "eslint": "9.39.4", "stylelint": "17.15.0", diff --git a/runtime-manifest.json b/runtime-manifest.json index ee61c33..4f23395 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -1,6 +1,6 @@ { - "$schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v13.schema.json", - "schemaVersion": 13, + "$schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v14.schema.json", + "schemaVersion": 14, "id": "cli-proxy-api", "name": "CLIProxy Providers", "capabilities": [ @@ -58,6 +58,68 @@ "schema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/cli-proxy-provider-runtime-config.v1.schema.json", "applicationMode": "service-restart", "entry": "./dist/service.mjs" + }, + { + "id": "gateway-runtime", + "kind": "managed-backend", + "owner": "host", + "entry": "./dist/gateway.mjs", + "definitionSchema": "https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-definition.v1.schema.json", + "runtimeResources": [ + { + "path": "./config/cli-proxy-api.yaml", + "mode": "data", + "digest": "sha256:4e2806a2c9a4a490f8685ffc96cf1dc40010248a8d25437b718249f3275b198e", + "byteLength": 268 + }, + { + "path": "./schemas/cli-proxy-gateway-model-catalog.v1.schema.json", + "mode": "data", + "digest": "sha256:b20bf7d388ae1417eaa1e86186eab241c754fc54af3c3b4d5439308202194b30", + "byteLength": 639 + }, + { + "path": "./schemas/cli-proxy-gateway-codex-upstream.v1.schema.json", + "mode": "data", + "digest": "sha256:a9908de439af944ac24744721c117fd0c0a18c18e61355d343f8ad57311fafba", + "byteLength": 1253 + }, + { + "path": "./schemas/cli-proxy-gateway-openai-upstream.v1.schema.json", + "mode": "data", + "digest": "sha256:8f226eeaf778394f452c0c224bd163c1c68806f56c63982d0394c03d6be4483b", + "byteLength": 1925 + }, + { + "path": "./schemas/cli-proxy-management-auth-files.v1.schema.json", + "mode": "data", + "digest": "sha256:9785b09906567848143054d522b141f5f06234f7b16a8dc2a02ef31e6d58e76c", + "byteLength": 370 + }, + { + "path": "./schemas/cli-proxy-management-operation.v1.schema.json", + "mode": "data", + "digest": "sha256:c1a9e12cf48ee53825948b053ea364ce70d09122321502560defebeb3d8d0c12", + "byteLength": 218 + } + ], + "consumerGrants": [ + { + "pluginId": "cli-proxy-api", + "operations": [ + "gateway.models.list", + "gateway.management.accounts.list", + "gateway.management.accounts.toggle", + "gateway.management.oauth.anthropic.start", + "gateway.management.oauth.codex.start", + "gateway.management.oauth.antigravity.start", + "gateway.management.oauth.kimi.start", + "gateway.management.oauth.xai.start", + "gateway.management.oauth.poll", + "gateway.management.oauth.cancel" + ] + } + ] } ] } diff --git a/schemas/cli-proxy-gateway-codex-upstream.v1.schema.json b/schemas/cli-proxy-gateway-codex-upstream.v1.schema.json new file mode 100644 index 0000000..e334413 --- /dev/null +++ b/schemas/cli-proxy-gateway-codex-upstream.v1.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-codex-upstream.v1.schema.json", + "title": "CLIProxyAPI Responses upstream collection v1", + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["api-key", "prefix", "base-url", "request-retry", "models"], + "properties": { + "api-key": { "type": "null" }, + "prefix": { "type": "string", "minLength": 1, "maxLength": 96 }, + "base-url": { "type": "null" }, + "request-retry": { "const": 0 }, + "models": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "alias", "display-name", "force-mapping"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "alias": { "type": "string", "minLength": 1, "maxLength": 256 }, + "display-name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "force-mapping": { "const": true } + } + } + } + } + } +} diff --git a/schemas/cli-proxy-gateway-model-catalog.v1.schema.json b/schemas/cli-proxy-gateway-model-catalog.v1.schema.json new file mode 100644 index 0000000..2a8adad --- /dev/null +++ b/schemas/cli-proxy-gateway-model-catalog.v1.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-model-catalog.v1.schema.json", + "title": "CLIProxyAPI gateway model catalog v1", + "type": "object", + "additionalProperties": true, + "required": ["data"], + "properties": { + "data": { + "type": "array", + "maxItems": 16384, + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 512 } + } + } + } + } +} diff --git a/schemas/cli-proxy-gateway-openai-upstream.v1.schema.json b/schemas/cli-proxy-gateway-openai-upstream.v1.schema.json new file mode 100644 index 0000000..d4c1e0f --- /dev/null +++ b/schemas/cli-proxy-gateway-openai-upstream.v1.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-openai-upstream.v1.schema.json", + "title": "CLIProxyAPI Chat Completions upstream collection v1", + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "disabled", "prefix", "base-url", "api-key-entries", "request-retry", "models"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 96 }, + "disabled": { "const": false }, + "prefix": { "type": "string", "minLength": 1, "maxLength": 96 }, + "base-url": { "type": "null" }, + "api-key-entries": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "additionalProperties": false, + "required": ["api-key"], + "properties": { "api-key": { "type": "null" } } + } + ], + "items": false, + "minItems": 1, + "maxItems": 1 + }, + "request-retry": { "const": 0 }, + "models": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "alias", "display-name", "force-mapping"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "alias": { "type": "string", "minLength": 1, "maxLength": 256 }, + "display-name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "force-mapping": { "const": true }, + "input-modalities": { + "type": "array", + "uniqueItems": true, + "maxItems": 3, + "items": { "enum": ["text", "image", "audio"] } + } + } + } + } + } + } +} diff --git a/schemas/cli-proxy-management-auth-files.v1.schema.json b/schemas/cli-proxy-management-auth-files.v1.schema.json new file mode 100644 index 0000000..818878d --- /dev/null +++ b/schemas/cli-proxy-management-auth-files.v1.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-management-auth-files.v1.schema.json", + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "array", + "maxItems": 256, + "items": { "type": "object" } + } + } +} diff --git a/schemas/cli-proxy-management-operation.v1.schema.json b/schemas/cli-proxy-management-operation.v1.schema.json new file mode 100644 index 0000000..683968c --- /dev/null +++ b/schemas/cli-proxy-management-operation.v1.schema.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-management-operation.v1.schema.json", + "type": "object" +} diff --git a/schemas/cli-proxy-upstream-descriptor.v1.schema.json b/schemas/cli-proxy-upstream-descriptor.v1.schema.json new file mode 100644 index 0000000..c933407 --- /dev/null +++ b/schemas/cli-proxy-upstream-descriptor.v1.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-upstream-descriptor.v1.schema.json", + "title": "CordisX CLIProxy Upstream Descriptor v1", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "contract", + "schemaVersion", + "revision", + "upstreamId", + "displayName", + "enabled", + "wireApi", + "order", + "requestTimeoutMs", + "group", + "models" + ], + "properties": { + "$schema": { + "const": "https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-upstream-descriptor.v1.schema.json" + }, + "contract": { "const": "cordisx.cli-proxy-upstream-descriptor/v1" }, + "schemaVersion": { "const": 1 }, + "revision": { "type": "integer", "minimum": 1 }, + "upstreamId": { "$ref": "#/$defs/id" }, + "displayName": { "$ref": "#/$defs/label" }, + "enabled": { "type": "boolean" }, + "wireApi": { "enum": ["responses", "chat-completions"] }, + "prefix": { "$ref": "#/$defs/id" }, + "order": { "type": "integer", "minimum": -1000, "maximum": 1000 }, + "requestTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }, + "group": { + "type": "object", + "additionalProperties": false, + "required": ["groupId", "displayName", "order"], + "properties": { + "groupId": { "$ref": "#/$defs/id" }, + "displayName": { "$ref": "#/$defs/label" }, + "order": { "type": "integer", "minimum": -1000, "maximum": 1000 } + } + }, + "models": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["sourceModelId", "modelId", "enabled", "isDefault"], + "properties": { + "sourceModelId": { "$ref": "#/$defs/modelId" }, + "modelId": { "$ref": "#/$defs/modelId" }, + "displayName": { "$ref": "#/$defs/label" }, + "enabled": { "type": "boolean" }, + "isDefault": { "type": "boolean" }, + "inputModalities": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": { "enum": ["text", "image", "audio"] } + } + } + } + } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,95}$" }, + "label": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^\\S(?:.*\\S)?$" }, + "modelId": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001f\\u007f]+$" } + } +} diff --git a/scripts/build-service.mjs b/scripts/build-service.mjs index 696edd9..800f13f 100644 --- a/scripts/build-service.mjs +++ b/scripts/build-service.mjs @@ -3,8 +3,12 @@ import { build } from 'esbuild' await mkdir(new URL('../dist/', import.meta.url), { recursive: true }) await build({ - entryPoints: [new URL('../src/service/index.ts', import.meta.url).pathname], - outfile: new URL('../dist/service.mjs', import.meta.url).pathname, + entryPoints: { + service: new URL('../src/service/index.ts', import.meta.url).pathname, + gateway: new URL('../src/service/gateway.ts', import.meta.url).pathname, + }, + outdir: new URL('../dist/', import.meta.url).pathname, + outExtension: { '.js': '.mjs' }, bundle: true, format: 'esm', platform: 'node', diff --git a/src/index.ts b/src/index.ts index e48642e..4809aa5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,14 @@ -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' +import type { BrandIconV1 } from '@cordisx/protocol/brand-icon/v1' +import type { ModelProviderContributionV1 } from '@cordisx/protocol/model-providers/v1' import Schema from '@deepseek-ai/schemastery' import { defineReactPage } from 'cordisx/react' import { + CORDISX_MANAGER_CONTENT_NAVIGATION_SCHEMA_V1, CORDISX_PAGE_SCHEMA_V3, CORDISX_PLUGIN_MANIFEST_SCHEMA_V1, CORDISX_ROUTE_SCHEMA_V2, + CORDISX_SURFACE_CONTRIBUTION_SCHEMA_V11, type CordisXLocalizedText, type CordisXMessageParams, type CordisXPageMetadataV3, @@ -12,12 +16,36 @@ import { type CordisXPluginPresentation, type CordisXRouteDefinitionV2, } from 'cordisx/contracts' -import { createProviderFleetPage } from './provider-fleet-page.js' +import '@cordisx/protocol/managed-service-ui/v1' +import { createUpstreamSubscriptionManagerPage } from './upstream-subscription-manager-page.js' import { CLI_PROXY_ICON } from './icon.js' export const name = 'cli-proxy-api' -export const inject = ['i18n', 'slots', 'pages', 'routes', 'platform', 'notifications'] +export const inject = [ + 'i18n', + 'slots', + 'pages', + 'routes', + 'managerContent', + 'platform', + 'modelProviders', + 'notifications', +] export const icon = CLI_PROXY_ICON +const CLI_PROXY_MODEL_PROVIDER_ICON = { + kind: 'raster-image', + image: { + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/raster-image-snapshot.v1.schema.json', + contract: 'cordisx.raster-image-snapshot/v1', + schemaVersion: 1, + mediaType: 'image/png', + encoding: 'base64', + data: CLI_PROXY_ICON.data, + width: 256, + height: 210, + }, +} as const satisfies BrandIconV1 const capabilities = [ 'models.read', @@ -49,13 +77,6 @@ export const manifest = { interface Messages { 'plugin.name': undefined 'plugin.description': undefined - 'navigation.title': undefined - 'navigation.description': undefined - 'route.title': undefined - 'route.description': undefined - 'page.title': undefined - 'page.description': undefined - 'page.subtitle': undefined 'field.provider': undefined 'field.model': undefined 'field.cwd': undefined @@ -76,11 +97,92 @@ interface Messages { 'state.empty': undefined 'state.no-models': undefined 'state.select-session': undefined - 'state.error': undefined + 'state.error': { readonly message: string } 'session.provider': { readonly provider: string } 'session.model': { readonly model: string } 'permission.models.read': undefined 'permission.tasks.catalog.read': undefined + 'upstream.route.title': undefined + 'upstream.route.description': undefined + 'upstream.page.title': undefined + 'upstream.page.description': undefined + 'upstream.group.account-subscriptions': undefined + 'upstream.group.cordisx-upstreams': undefined + 'upstream.group.runtime-status': undefined + 'upstream.status.readiness': { readonly readiness: string } + 'upstream.status.health': { readonly health: string } + 'upstream.status.auth-state': { readonly state: string } + 'upstream.status.model-count': { readonly count: number } + 'upstream.status.endpoint-origin': { readonly origin: string } + 'upstream.status.secret-configured': undefined + 'upstream.status.secret-missing': undefined + 'upstream.status.enabled': undefined + 'upstream.status.disabled': undefined + 'upstream.status.default': undefined + 'upstream.status.no-catalog': undefined + 'upstream.status.no-upstreams': undefined + 'upstream.status.service-unavailable': undefined + 'upstream.status.account-active': undefined + 'upstream.status.account-disabled': undefined + 'upstream.status.account-unavailable': undefined + 'upstream.status.account-runtime-only': undefined + 'upstream.status.account-source-file': undefined + 'upstream.status.account-source-memory': undefined + 'upstream.status.account-source-plugin': undefined + 'upstream.status.auth-oauth': undefined + 'upstream.status.auth-api-key': undefined + 'upstream.status.auth-file': undefined + 'upstream.status.auth-plugin-virtual': undefined + 'upstream.status.auth-unknown': undefined + 'upstream.status.oauth-pending': undefined + 'upstream.status.oauth-completed': undefined + 'upstream.status.oauth-cancelled': undefined + 'upstream.status.oauth-error': undefined + 'upstream.status.oauth-unknown': undefined + 'upstream.status.account-controls-unavailable': undefined + 'upstream.status.account-count': { readonly count: number } + 'upstream.status.no-accounts': undefined + 'upstream.status.service-auth': { readonly state: string } + 'upstream.status.ready': undefined + 'upstream.status.degraded': undefined + 'upstream.status.failed': undefined + 'upstream.status.stopped': undefined + 'upstream.status.healthy': undefined + 'upstream.status.warning': undefined + 'upstream.status.critical': undefined + 'upstream.status.missing': undefined + 'upstream.status.authenticating': undefined + 'upstream.status.authenticated': undefined + 'upstream.status.expired': undefined + 'upstream.status.logged-out': undefined + 'upstream.status.unknown': undefined + 'upstream.status.no-accounts-description': undefined + 'upstream.status.no-upstreams-description': undefined + 'upstream.status.configuration-unavailable': undefined + 'upstream.field.revision': undefined + 'upstream.field.generation': undefined + 'upstream.field.sequence': undefined + 'upstream.field.provider': undefined + 'upstream.field.source': undefined + 'upstream.field.auth-type': undefined + 'upstream.field.account': undefined + 'upstream.field.project': undefined + 'upstream.field.note': undefined + 'upstream.field.updated': undefined + 'upstream.field.last-refresh': undefined + 'upstream.field.technical-details': undefined + 'upstream.field.endpoint': undefined + 'upstream.field.credential': undefined + 'upstream.field.models': undefined + 'upstream.action.refresh': undefined + 'upstream.action.open-plugin-configuration': undefined + 'upstream.action.login-service': undefined + 'upstream.action.logout-service': undefined + 'upstream.state.operation-error': undefined + 'upstream.action.oauth-start': { readonly provider: string } + 'upstream.action.oauth-cancel': undefined + 'upstream.action.enable': undefined + 'upstream.action.disable': undefined 'permission.tasks.content.read': undefined 'permission.tasks.create': undefined 'permission.tasks.control': undefined @@ -129,27 +231,91 @@ export const presentation = { description: message('plugin.description'), } satisfies CordisXPluginPresentation -const providerSessionsPage = { +const upstreamSubscriptionPage = { $schema: CORDISX_PAGE_SCHEMA_V3, schemaVersion: 3, - id: 'providers.sessions', - title: message('page.title'), - description: message('page.description'), - icon: 'host:layers', + id: 'providers.upstream-subscriptions', + title: message('upstream.page.title'), + description: message('upstream.page.description'), + icon: 'host:key', + chrome: 'standard', } satisfies CordisXPageMetadataV3 -const providerSessionsRoute = { +const upstreamSubscriptionRoute = { $schema: CORDISX_ROUTE_SCHEMA_V2, schemaVersion: 2, - id: 'providers.sessions', - path: '/main/providers/sessions', - outlet: 'main', - page: 'providers.sessions', - title: message('route.title'), - description: message('route.description'), -} satisfies CordisXRouteDefinitionV2<'main'> - -export function apply(ctx: Context, config: Config = Config({})): void { + id: 'providers.upstream-subscriptions', + path: '/manager/extensions/cli-proxy-api/subscriptions', + outlet: 'manager.content', + page: 'providers.upstream-subscriptions', + title: message('upstream.route.title'), + description: message('upstream.route.description'), +} satisfies CordisXRouteDefinitionV2<'manager.content'> + +function optionalManagedServices(ctx: Context): Context['managedServices'] | undefined { + const candidate = ctx as unknown as Record + const reflect = candidate.reflect as { get(name: string): unknown } | undefined + const service = reflect === undefined ? candidate.managedServices : reflect.get('managedServices') + return service !== null && typeof service === 'object' && typeof Reflect.get(service, 'get') === 'function' + ? service as Context['managedServices'] + : undefined +} + +function optionalManager(ctx: Context): Context['manager'] | undefined { + const service = ctx.reflect.get('manager') + return service !== null + && typeof service === 'object' + && typeof Reflect.get(service, 'openOwnPluginConfiguration') === 'function' + ? service as Context['manager'] + : undefined +} + +export function apply(ctx: Context & Pick, config: Config = Config({})): void { + const managedServices = optionalManagedServices(ctx) + ctx.effect(() => { + let settingsEntry: ModelProviderContributionV1 | undefined + let syncingSettingsEntry = false + const syncSettingsEntry = (): void => { + if (syncingSettingsEntry) return + syncingSettingsEntry = true + try { + const available = ctx.modelProviders.list().some(provider => provider.providerId === 'cli-proxy-api') + if (available) { + settingsEntry?.dispose() + settingsEntry = undefined + } else if (settingsEntry === undefined) { + settingsEntry = ctx.modelProviders.insert({ + id: 'settings', + label: 'CLIProxyAPI', + icon: CLI_PROXY_MODEL_PROVIDER_ICON, + action: { + label: 'Open settings', + icon: 'host:settings', + run: async signal => { + if (!signal.aborted) await ctx.routes.navigate({ id: 'providers.upstream-subscriptions' }) + }, + }, + }) + } + } finally { + syncingSettingsEntry = false + } + } + const presentation = ctx.modelProviders.present({ + providerId: 'cli-proxy-api', + title: 'CLIProxyAPI', + icon: CLI_PROXY_MODEL_PROVIDER_ICON, + }) + const unsubscribe = ctx.modelProviders.subscribe(syncSettingsEntry) + syncSettingsEntry() + void ctx.modelProviders.refresh().then(syncSettingsEntry).catch(() => undefined) + return () => { + unsubscribe() + settingsEntry?.dispose() + presentation.dispose() + } + }, 'CLIProxyAPI model provider presentation') + ctx.i18n.define({ namespace: 'cli-proxy-api', locale: 'en', @@ -157,14 +323,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { messages: { 'plugin.name': 'CLIProxy Providers', 'plugin.description': 'Manage configured CLIProxy providers, models, and sessions.', - 'navigation.title': 'Providers', - 'navigation.description': 'Manage provider models and sessions', - 'route.title': 'Open Provider sessions', - 'route.description': - 'Enter the external Provider sessions fleet from CordisX navigation or the Manager route catalog.', - 'page.title': 'Provider sessions', - 'page.description': 'Create, search, resume, and manage sessions for configured Providers in the main workspace.', - 'page.subtitle': 'Choose a model and manage its sessions.', 'field.provider': 'Provider', 'field.model': 'Model', 'field.cwd': 'Working directory', @@ -185,11 +343,95 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'state.empty': 'No matching sessions.', 'state.no-models': 'No provider models are available.', 'state.select-session': 'Select a provider session to inspect its content.', - 'state.error': 'Provider request failed. Check the connection and try again.', + 'state.error': 'Provider request failed: {message}', 'session.provider': 'Provider {provider}', 'session.model': 'Model {model}', 'permission.models.read': 'List models from configured external providers', 'permission.tasks.catalog.read': 'List and search external provider sessions', + 'upstream.route.title': 'CLIProxyAPI subscriptions', + 'upstream.route.description': 'Manage CLIProxyAPI account subscriptions, CordisX upstreams, and runtime status.', + 'upstream.page.title': 'CLIProxyAPI subscriptions', + 'upstream.page.description': 'View account subscriptions, CordisX upstream providers, and runtime status.', + 'upstream.group.account-subscriptions': 'Account Subscriptions', + 'upstream.group.cordisx-upstreams': 'CordisX Upstreams', + 'upstream.group.runtime-status': 'Runtime Status', + 'upstream.status.readiness': '{readiness}', + 'upstream.status.health': '{health}', + 'upstream.status.auth-state': '{state}', + 'upstream.status.model-count': '{count} models', + 'upstream.status.endpoint-origin': '{origin}', + 'upstream.status.secret-configured': 'Secret configured', + 'upstream.status.secret-missing': 'No secret', + 'upstream.status.enabled': 'Enabled', + 'upstream.status.disabled': 'Disabled', + 'upstream.status.default': 'Default', + 'upstream.status.no-catalog': 'Upstream catalog is not available.', + 'upstream.status.no-upstreams': 'No upstream providers are registered.', + 'upstream.status.service-unavailable': 'Managed service is not available.', + 'upstream.status.account-active': 'Active', + 'upstream.status.account-disabled': 'Disabled', + 'upstream.status.account-unavailable': 'Unavailable', + 'upstream.status.account-runtime-only': 'Runtime only', + 'upstream.status.account-source-file': 'File', + 'upstream.status.account-source-memory': 'Memory', + 'upstream.status.account-source-plugin': 'Plugin', + 'upstream.status.auth-oauth': 'OAuth', + 'upstream.status.auth-api-key': 'API key', + 'upstream.status.auth-file': 'File credential', + 'upstream.status.auth-plugin-virtual': 'Plugin virtual', + 'upstream.status.auth-unknown': 'Unknown auth', + 'upstream.status.oauth-pending': 'OAuth pending', + 'upstream.status.oauth-completed': 'OAuth completed', + 'upstream.status.oauth-cancelled': 'OAuth cancelled', + 'upstream.status.oauth-error': 'OAuth error', + 'upstream.status.oauth-unknown': 'OAuth unknown', + 'upstream.status.account-controls-unavailable': 'Account controls are not available.', + 'upstream.status.account-count': '{count} accounts', + 'upstream.status.no-accounts': 'No CLIProxyAPI accounts are configured.', + 'upstream.status.service-auth': 'Service auth: {state}', + 'upstream.status.ready': 'Ready', + 'upstream.status.degraded': 'Needs attention', + 'upstream.status.failed': 'Failed', + 'upstream.status.stopped': 'Stopped', + 'upstream.status.healthy': 'Healthy', + 'upstream.status.warning': 'Warning', + 'upstream.status.critical': 'Critical', + 'upstream.status.missing': 'Not signed in', + 'upstream.status.authenticating': 'Signing in', + 'upstream.status.authenticated': 'Signed in', + 'upstream.status.expired': 'Expired', + 'upstream.status.logged-out': 'Signed out', + 'upstream.status.unknown': 'Unknown', + 'upstream.status.no-accounts-description': + 'This version can manage accounts reported by CLIProxyAPI, but cannot add or import accounts. Configure accounts outside CordisX, then refresh this page.', + 'upstream.status.no-upstreams-description': + 'CordisX upstreams appear after CLIProxyAPI configuration is available to the managed service.', + 'upstream.status.configuration-unavailable': + 'Plugin configuration is unavailable in this Host. Account creation and import are not available here.', + 'upstream.field.sequence': 'Sequence', + 'upstream.field.provider': 'Provider', + 'upstream.field.source': 'Source', + 'upstream.field.auth-type': 'Auth index', + 'upstream.field.account': 'Accounts', + 'upstream.field.project': 'Project', + 'upstream.field.note': 'Note', + 'upstream.field.updated': 'Updated', + 'upstream.field.last-refresh': 'Last refresh', + 'upstream.field.technical-details': 'Technical details', + 'upstream.field.endpoint': 'Endpoint', + 'upstream.field.credential': 'Credential', + 'upstream.field.models': 'Models', + 'upstream.action.login-service': 'Refresh service login', + 'upstream.action.logout-service': 'Sign out', + 'upstream.state.operation-error': 'External account operation failed. Please try again.', + 'upstream.action.oauth-start': 'Sign in to {provider}', + 'upstream.action.oauth-cancel': 'Cancel OAuth', + 'upstream.action.enable': 'Enable', + 'upstream.action.disable': 'Disable', + 'upstream.field.revision': 'Revision', + 'upstream.field.generation': 'Generation', + 'upstream.action.refresh': 'Refresh', + 'upstream.action.open-plugin-configuration': 'Open plugin configuration', 'permission.tasks.content.read': 'Read selected external provider session content', 'permission.tasks.create': 'Create a session for the selected provider model', 'permission.tasks.control': 'Continue, fork, archive, restore, or delete selected sessions', @@ -203,13 +445,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { messages: { 'plugin.name': 'CLIProxy 提供方', 'plugin.description': '管理已配置的 CLIProxy 提供方、模型和会话。', - 'navigation.title': 'Providers', - 'navigation.description': '管理 Provider 模型和会话', - 'route.title': '打开 Provider 会话', - 'route.description': '从 CordisX 导航或 Manager 路由目录进入外部 Provider 会话 Fleet。', - 'page.title': 'Provider 会话', - 'page.description': '在主工作区为已配置的 Provider 创建、搜索、续聊和管理会话。', - 'page.subtitle': '选择模型并管理会话。', 'field.provider': 'Provider', 'field.model': '模型', 'field.cwd': '工作目录', @@ -230,11 +465,93 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'state.empty': '没有匹配的会话。', 'state.no-models': '没有可用的 Provider 模型。', 'state.select-session': '选择一个 Provider 会话以查看内容。', - 'state.error': 'Provider 请求失败,请检查连接后重试', + 'state.error': 'Provider 请求失败:{message}', 'session.provider': 'Provider {provider}', 'session.model': '模型 {model}', 'permission.models.read': '读取已配置外部 Provider 的模型', 'permission.tasks.catalog.read': '列出并搜索外部 Provider 会话', + 'upstream.route.title': 'CLIProxyAPI 订阅管理', + 'upstream.route.description': '管理 CLIProxyAPI 账户订阅、CordisX 上游和运行状态。', + 'upstream.page.title': 'CLIProxyAPI 订阅管理', + 'upstream.page.description': '查看账户订阅、CordisX 上游提供方和运行状态。', + 'upstream.group.account-subscriptions': '账号订阅', + 'upstream.group.cordisx-upstreams': 'CordisX 上游', + 'upstream.group.runtime-status': '运行状态', + 'upstream.status.readiness': '{readiness}', + 'upstream.status.health': '{health}', + 'upstream.status.auth-state': '{state}', + 'upstream.status.model-count': '{count} 个模型', + 'upstream.status.endpoint-origin': '{origin}', + 'upstream.status.secret-configured': '已配置密钥', + 'upstream.status.secret-missing': '未配置密钥', + 'upstream.status.enabled': '已启用', + 'upstream.status.disabled': '已禁用', + 'upstream.status.default': '默认', + 'upstream.status.no-catalog': '上游目录不可用。', + 'upstream.status.no-upstreams': '没有已注册的上游提供方。', + 'upstream.status.service-unavailable': '托管服务不可用。', + 'upstream.status.account-active': '可用', + 'upstream.status.account-disabled': '已禁用', + 'upstream.status.account-unavailable': '不可用', + 'upstream.status.account-runtime-only': '仅运行时', + 'upstream.status.account-source-file': '文件', + 'upstream.status.account-source-memory': '内存', + 'upstream.status.account-source-plugin': '插件', + 'upstream.status.auth-oauth': 'OAuth', + 'upstream.status.auth-api-key': 'API Key', + 'upstream.status.auth-file': '文件凭据', + 'upstream.status.auth-plugin-virtual': '插件虚拟账号', + 'upstream.status.auth-unknown': '未知认证', + 'upstream.status.oauth-pending': 'OAuth 进行中', + 'upstream.status.oauth-completed': 'OAuth 已完成', + 'upstream.status.oauth-cancelled': 'OAuth 已取消', + 'upstream.status.oauth-error': 'OAuth 失败', + 'upstream.status.oauth-unknown': 'OAuth 状态未知', + 'upstream.status.account-controls-unavailable': '账号控制暂不可用。', + 'upstream.status.account-count': '{count} 个账号', + 'upstream.status.no-accounts': '还没有配置 CLIProxyAPI 账号。', + 'upstream.status.service-auth': '服务认证:{state}', + 'upstream.status.ready': '已就绪', + 'upstream.status.degraded': '需要处理', + 'upstream.status.failed': '失败', + 'upstream.status.stopped': '已停止', + 'upstream.status.healthy': '正常', + 'upstream.status.warning': '警告', + 'upstream.status.critical': '严重', + 'upstream.status.missing': '未登录', + 'upstream.status.authenticating': '登录中', + 'upstream.status.authenticated': '已登录', + 'upstream.status.expired': '已过期', + 'upstream.status.logged-out': '已退出', + 'upstream.status.unknown': '未知', + 'upstream.status.no-accounts-description': + '当前版本只能管理 CLIProxyAPI 已上报的账号,不能新增或导入账号。请在 CordisX 外完成账号配置后刷新此页。', + 'upstream.status.no-upstreams-description': '托管服务读取到 CLIProxyAPI 配置后,CordisX 上游会显示在这里。', + 'upstream.status.configuration-unavailable': '当前 Host 无法打开插件配置;此处也不支持新增或导入账号。', + 'upstream.field.sequence': '序列号', + 'upstream.field.provider': '提供方', + 'upstream.field.source': '来源', + 'upstream.field.auth-type': '认证索引', + 'upstream.field.account': '账号数', + 'upstream.field.project': '项目', + 'upstream.field.note': '备注', + 'upstream.field.updated': '更新时间', + 'upstream.field.last-refresh': '最近刷新', + 'upstream.field.technical-details': '技术详情', + 'upstream.field.endpoint': '端点', + 'upstream.field.credential': '凭据', + 'upstream.field.models': '模型数', + 'upstream.action.login-service': '刷新服务登录', + 'upstream.action.logout-service': '退出登录', + 'upstream.state.operation-error': '外部账号操作失败,请重试。', + 'upstream.action.oauth-start': '登录 {provider}', + 'upstream.action.oauth-cancel': '取消 OAuth', + 'upstream.action.enable': '启用', + 'upstream.action.disable': '禁用', + 'upstream.field.revision': '版本', + 'upstream.field.generation': '代际', + 'upstream.action.refresh': '刷新', + 'upstream.action.open-plugin-configuration': '打开插件配置', 'permission.tasks.content.read': '读取所选外部 Provider 会话内容', 'permission.tasks.create': '用所选 Provider 模型创建会话', 'permission.tasks.control': '继续、分叉、归档、恢复或删除所选会话', @@ -242,12 +559,29 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'permission.turns.control': '引导或中断所选 turn', }, }) - ctx.pages.register(providerSessionsPage, defineReactPage(createProviderFleetPage(ctx, config))) - ctx.routes.register(providerSessionsRoute) - ctx.slots.register({ name: 'sidebar.navigation.items', id: 'providers', order: -100 }, { - label: message('navigation.title'), - description: message('navigation.description'), - icon: 'host:layers', - route: { id: 'providers.sessions' }, + ctx.pages.register( + upstreamSubscriptionPage, + defineReactPage( + createUpstreamSubscriptionManagerPage(managedServices, optionalManager(ctx), ctx.notifications), + ), + ) + ctx.routes.register(upstreamSubscriptionRoute) + ctx.managerContent.register({ + $schema: CORDISX_MANAGER_CONTENT_NAVIGATION_SCHEMA_V1, + schemaVersion: 1, + id: 'upstream-subscriptions', + route: { id: 'providers.upstream-subscriptions' }, + header: { title: { kind: 'route' } }, + }) + ctx.slots.register({ + $schema: CORDISX_SURFACE_CONTRIBUTION_SCHEMA_V11, + schemaVersion: 11, + name: 'manager.settings.navigation-items', + id: 'upstream-subscriptions', + group: 'after-settings', + order: 120, + }, { + route: { id: 'providers.upstream-subscriptions' }, + navigationGroup: { id: 'external-accounts' }, }) } diff --git a/src/manager-configuration.ts b/src/manager-configuration.ts new file mode 100644 index 0000000..dcee6a5 --- /dev/null +++ b/src/manager-configuration.ts @@ -0,0 +1,17 @@ +import type { Context } from '@deepseek-ai/cordis' + +export type PluginConfigurationOpenOutcome = + | { readonly status: 'opened' } + | { readonly status: 'unavailable' } + | { readonly status: 'failed'; readonly message: string } + +export async function requestOwnPluginConfiguration( + manager: Context['manager'] | undefined, +): Promise { + if (manager === undefined) return { status: 'unavailable' } + try { + return { status: await manager.openOwnPluginConfiguration() } + } catch (err) { + return { status: 'failed', message: err instanceof Error ? err.message : 'Unknown error' } + } +} diff --git a/src/provider-fleet-page.tsx b/src/provider-fleet-page.tsx index a708607..5b0137a 100644 --- a/src/provider-fleet-page.tsx +++ b/src/provider-fleet-page.tsx @@ -37,7 +37,7 @@ interface Messages { 'state.empty': undefined 'state.no-models': undefined 'state.select-session': undefined - 'state.error': undefined + 'state.error': { readonly message: string } 'session.provider': { readonly provider: string } 'session.model': { readonly model: string } } @@ -78,10 +78,8 @@ export function createProviderFleetPage(ctx: Context, config: Config) { const [steer, setSteer] = useState('') const [status, setStatus] = useState('') - const showError = (_error: { readonly message: string }) => { - setStatus('') - ctx.notifications.show({ kind: 'provider.request-failed', type: 'error', message: props.t('state.error') }) - } + const showError = (error: { readonly message: string }) => + setStatus(props.t('state.error', { message: error.message })) const providerIds = provider === '' ? configuredProviders ?? [...new Set(models.map(item => item.ref.providerId))].sort() : [provider] diff --git a/src/service/gateway-definition.ts b/src/service/gateway-definition.ts new file mode 100644 index 0000000..a329501 --- /dev/null +++ b/src/service/gateway-definition.ts @@ -0,0 +1,139 @@ +import { createHash } from 'node:crypto' +import type { ManagedServiceDefinitionV1 } from '@cordisx/protocol/managed-service-runtime/v1' + +export const CLI_PROXY_GATEWAY_MODEL_CATALOG_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-model-catalog.v1.schema.json' as const +export const CLI_PROXY_GATEWAY_CODEX_UPSTREAMS_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-codex-upstream.v1.schema.json' as const +export const CLI_PROXY_GATEWAY_OPENAI_UPSTREAMS_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-gateway-openai-upstream.v1.schema.json' as const +const CLI_PROXY_MANAGEMENT_AUTH_FILES_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-management-auth-files.v1.schema.json' as const +const CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-management-operation.v1.schema.json' as const + +export const CLI_PROXY_GATEWAY_SERVICE_ID = 'gateway-runtime' +export const CLI_PROXY_GATEWAY_MANAGEMENT_V1 = Object.freeze({ + credentialSlot: 'management-key', + path: '/v0/management', +}) + +export const cliProxyGatewayDefinition = { + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-definition.v1.schema.json', + contract: 'cordisx.managed-service-definition/v1', + schemaVersion: 1, + serviceId: CLI_PROXY_GATEWAY_SERVICE_ID, + launch: { + executable: { kind: 'named-command', command: 'cli-proxy-api' }, + arguments: [], + startupTimeoutMs: 15_000, + }, + configuration: { + template: './config/cli-proxy-api.yaml', + format: 'yaml', + delivery: { kind: 'generation-private-process-file', argument: '--config' }, + }, + compositionOrigins: [{ id: 'api', path: '/v1' }], + protectedBindings: [ + { + slot: 'assigned-port', + source: 'host-assigned-loopback', + target: 'configuration', + pointer: '/port', + serialization: 'port', + }, + { + slot: 'gateway-key', + source: 'generated-local-key', + target: 'configuration', + pointer: '/api-keys/0', + }, + { + slot: CLI_PROXY_GATEWAY_MANAGEMENT_V1.credentialSlot, + source: 'generated-local-key', + target: 'configuration', + pointer: '/remote-management/secret-key', + }, + { + slot: 'codex-upstreams', + source: 'composition', + target: 'configuration', + pointer: '/codex-api-key', + valueSchema: CLI_PROXY_GATEWAY_CODEX_UPSTREAMS_SCHEMA_V1, + }, + { + slot: 'openai-upstreams', + source: 'composition', + target: 'configuration', + pointer: '/openai-compatibility', + valueSchema: CLI_PROXY_GATEWAY_OPENAI_UPSTREAMS_SCHEMA_V1, + }, + ], + authentication: { mode: 'none' }, + httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'gateway-key' }, + discovery: { kind: 'host-assigned-loopback' }, + operations: [ + { + operationId: 'gateway.models.list', + method: 'GET', + path: '/v1/models', + responseSchema: CLI_PROXY_GATEWAY_MODEL_CATALOG_SCHEMA_V1, + timeoutMs: 5_000, + }, + { + operationId: 'gateway.management.accounts.list', + method: 'GET', + path: '/v0/management/auth-files', + responseSchema: CLI_PROXY_MANAGEMENT_AUTH_FILES_SCHEMA_V1, + timeoutMs: 5_000, + httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'management-key' }, + }, + { + operationId: 'gateway.management.accounts.toggle', + method: 'PATCH', + path: '/v0/management/auth-files/status', + requestSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + responseSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + timeoutMs: 5_000, + httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'management-key' }, + }, + ...(['anthropic', 'codex', 'antigravity', 'kimi', 'xai'] as const).map(provider => ({ + operationId: `gateway.management.oauth.${provider}.start`, + method: 'GET' as const, + path: `/v0/management/${provider}-auth-url` as `/${string}`, + requestSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + requestEncoding: 'query' as const, + responseSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + timeoutMs: 30_000, + httpAuthentication: { mode: 'authorization-header' as const, scheme: 'Bearer' as const, slot: 'management-key' }, + })), + { + operationId: 'gateway.management.oauth.poll', + method: 'GET', + path: '/v0/management/get-auth-status', + requestSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + requestEncoding: 'query', + responseSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + timeoutMs: 5_000, + httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'management-key' }, + }, + { + operationId: 'gateway.management.oauth.cancel', + method: 'DELETE', + path: '/v0/management/oauth-session', + requestSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + requestEncoding: 'query', + responseSchema: CLI_PROXY_MANAGEMENT_OPERATION_SCHEMA_V1, + timeoutMs: 5_000, + httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'management-key' }, + }, + ], + health: { path: '/v1/models', intervalMs: 250, timeoutMs: 1_000 }, +} as const satisfies ManagedServiceDefinitionV1 + +export const cliProxyGatewayDefinitionRevision = `sha256:${ + createHash('sha256').update( + JSON.stringify(cliProxyGatewayDefinition), + ).digest('hex') +}` as const diff --git a/src/service/gateway-ui.ts b/src/service/gateway-ui.ts new file mode 100644 index 0000000..ee3dd7a --- /dev/null +++ b/src/service/gateway-ui.ts @@ -0,0 +1,462 @@ +import { createHash } from 'node:crypto' +import type { + ManagedServiceNodeUISourceExtensionV1, + ManagedServiceNodeUISourceV1, +} from '@cordisx/protocol/managed-service-context/v1' +import type { + ManagedServiceBindingV1, + ManagedServiceCliProxyAccountsResultV1, + ManagedServiceCliProxyAccountToggleRequestV1, + ManagedServiceCliProxyAccountToggleResultV1, + ManagedServiceCliProxyAccountV1, + ManagedServiceCliProxyCatalogResultV1, + ManagedServiceCliProxyOAuthCancelRequestV1, + ManagedServiceCliProxyOAuthCancelResultV1, + ManagedServiceCliProxyOAuthSessionStateV1, + ManagedServiceCliProxyOAuthStartRequestV1, + ManagedServiceCliProxyOAuthStartResultV1, + ManagedServiceCliProxyOAuthStatusV1, + ManagedServiceCliProxyProviderIdV1, +} from '@cordisx/protocol/managed-service/v1' +import type { + ManagedServiceBoundClientV1, + ManagedServiceIdentityV1, + ManagedServiceRegistrationHandleV1, + ManagedServiceSafeValueV1, +} from '@cordisx/protocol/managed-service-runtime/v1' +import { CLI_PROXY_GATEWAY_SERVICE_ID } from './gateway-definition.js' +import type { CliProxyModelCatalogV1, CliProxyUpstreamRegistryV1 } from './upstream-registry.js' + +interface GatewayUIState { + readonly pluginGeneration: string + readonly registry: CliProxyUpstreamRegistryV1 + readonly authorization: Map + activeRevision: number | undefined + refreshGatewayModels(): Promise +} + +let activeState: GatewayUIState | undefined + +export function bindGatewayUIState(state: GatewayUIState): () => void { + activeState = state + return () => { + if (activeState === state) activeState = undefined + } +} + +function record(value: ManagedServiceSafeValueV1 | undefined): Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : {} +} + +function stringValue(value: ManagedServiceSafeValueV1 | undefined): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined +} + +function numberValue(value: ManagedServiceSafeValueV1 | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function booleanValue(value: ManagedServiceSafeValueV1 | undefined): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +function providerId(value: string | undefined): ManagedServiceCliProxyProviderIdV1 { + const normalized = value?.toLowerCase() ?? 'unknown' + if (normalized === 'claude') return 'anthropic' + if (normalized === 'openai') return 'codex' + if (['anthropic', 'codex', 'antigravity', 'kimi', 'xai'].includes(normalized)) { + return normalized as ManagedServiceCliProxyProviderIdV1 + } + const safe = normalized.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown' + return `custom:${safe}` +} + +function revision(value: unknown): number { + const digest = createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 12) + return Number.parseInt(digest, 16) +} + +function bindingMatches(left: ManagedServiceBindingV1, right: ManagedServiceBindingV1): boolean { + return left.bindingId === right.bindingId + && left.identity.pluginId === right.identity.pluginId + && left.identity.serviceId === right.identity.serviceId + && left.scope.profileId === right.scope.profileId + && left.scope.generation === right.scope.generation +} + +function mapCatalog( + state: GatewayUIState, + catalog: CliProxyModelCatalogV1, + binding: ManagedServiceBindingV1, + serviceGeneration: string, + sequence: number, +) { + const plan = state.registry.gatewayPlan(state.pluginGeneration) + const sourceByProvider = 'code' in plan + ? new Map() + : new Map(plan.composition.map(item => [item.upstreamId, item.source])) + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-catalog.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-catalog/v1' as const, + schemaVersion: 1 as const, + binding, + sequence, + observedAt: new Date().toISOString(), + revision: catalog.registryRevision, + serviceGeneration, + providers: Object.freeze(catalog.groups.flatMap(group => + group.providers.map(provider => { + const source = sourceByProvider.get(provider.providerId) + const authorized = state.authorization.get(provider.providerId) ?? false + const active = state.activeRevision === catalog.registryRevision + return Object.freeze({ + id: provider.providerId, + displayName: provider.displayName, + enabled: active, + endpoint: Object.freeze({ + origin: source === undefined + ? 'managed://unavailable' + : `managed://${source.pluginId}/${source.serviceId}`, + secretConfigured: authorized, + }), + health: active ? 'healthy' as const : 'warning' as const, + auth: authorized ? 'authenticated' as const : 'missing' as const, + models: Object.freeze({ + mappings: Object.freeze(provider.models.flatMap(model => + model.sourceModelIds.map(sourceModelId => + Object.freeze({ + sourceModelId, + modelId: model.gatewayModelId, + displayName: model.displayName, + enabled: active, + isDefault: model.isDefault, + }) + ) + )), + }), + }) + }) + )), + }) +} + +function mapAccount(value: ManagedServiceSafeValueV1): ManagedServiceCliProxyAccountV1 | undefined { + const item = record(value) + const accountId = stringValue(item.id) ?? stringValue(item.name) + if (accountId === undefined) return undefined + const disabled = booleanValue(item.disabled) ?? false + const unavailable = booleanValue(item.unavailable) ?? false + const rawModels = Array.isArray(item.models) ? item.models : [] + const models = rawModels.flatMap(model => { + const entry = record(model) + const id = stringValue(entry.id) ?? stringValue(entry.name) + return id === undefined ? [] : [{ + id, + ...(stringValue(entry.display_name) === undefined ? {} : { displayName: stringValue(entry.display_name) }), + ...(stringValue(entry.type) === undefined ? {} : { type: stringValue(entry.type) }), + ...(stringValue(entry.owned_by) === undefined ? {} : { ownedBy: stringValue(entry.owned_by) }), + }] + }) + const source = stringValue(item.source) + const quota = record(item.quota) + const quotaSignals = Object.fromEntries( + Object.entries(record(quota.signals)).flatMap(([key, signal]) => typeof signal === 'string' ? [[key, signal]] : []), + ) + const provider = providerId(stringValue(item.provider) ?? stringValue(item.type)) + const runtimeOnly = booleanValue(item.runtime_only) ?? false + const authKind = runtimeOnly + ? 'plugin-virtual' as const + : provider.startsWith('custom:') + ? 'unknown' as const + : 'oauth' as const + return Object.freeze({ + accountId, + authIndex: stringValue(item.auth_index) ?? accountId, + provider, + label: stringValue(item.label) ?? stringValue(item.email) ?? stringValue(item.account) ?? accountId, + status: disabled ? 'disabled' : unavailable ? 'unavailable' : 'active', + ...(stringValue(item.status_message) === undefined ? {} : { statusMessage: 'Account unavailable' }), + disabled, + unavailable, + authKind, + sourceKind: source === 'file' || source === 'plugin' ? source : 'memory', + runtimeOnly, + ...(stringValue(item.email) === undefined ? {} : { email: stringValue(item.email) }), + ...(stringValue(item.project_id) === undefined ? {} : { projectId: stringValue(item.project_id) }), + ...(stringValue(item.account_type) === undefined ? {} : { accountType: stringValue(item.account_type) }), + ...(stringValue(item.account) === undefined ? {} : { account: stringValue(item.account) }), + ...(stringValue(item.note) === undefined ? {} : { note: stringValue(item.note) }), + ...(numberValue(item.priority) === undefined ? {} : { priority: numberValue(item.priority) }), + ...(numberValue(item.weight) === undefined ? {} : { weight: numberValue(item.weight) }), + ...(booleanValue(item.websockets) === undefined ? {} : { websockets: booleanValue(item.websockets) }), + ...(numberValue(item.request_retry) === undefined ? {} : { requestRetry: numberValue(item.request_retry) }), + ...(numberValue(item.success) === undefined ? {} : { success: numberValue(item.success) }), + ...(numberValue(item.failed) === undefined ? {} : { failed: numberValue(item.failed) }), + ...(stringValue(item.created_at) === undefined ? {} : { createdAt: stringValue(item.created_at) }), + ...(stringValue(item.updated_at) === undefined ? {} : { updatedAt: stringValue(item.updated_at) }), + ...(stringValue(item.last_refresh) === undefined ? {} : { lastRefreshAt: stringValue(item.last_refresh) }), + ...(stringValue(item.next_retry_after) === undefined ? {} : { nextRetryAfter: stringValue(item.next_retry_after) }), + ...(models.length === 0 ? {} : { models: Object.freeze(models) }), + ...(Object.keys(quotaSignals).length === 0 ? {} : { quotaSignals: Object.freeze(quotaSignals) }), + }) +} + +async function invoke( + client: ManagedServiceBoundClientV1, + registration: ManagedServiceRegistrationHandleV1, + operationId: string, + value: ManagedServiceSafeValueV1 | undefined, + signal: AbortSignal, +): Promise { + const acquired = await client.acquire(registration.binding.identity, { signal }) + if (acquired.status !== 'ready') return undefined + try { + const result = await client.invoke(acquired.lease, operationId, value, { signal }) + return result.status === 'accepted' ? result.value : undefined + } finally { + client.release(acquired.lease) + } +} + +export const managedServiceUI: ManagedServiceNodeUISourceV1 = Object.freeze({ + serviceId: CLI_PROXY_GATEWAY_SERVICE_ID, + create( + { binding, registration, client, signal }: Parameters[0], + ): ManagedServiceNodeUISourceExtensionV1 { + const state = activeState + let sequence = 0 + let accountsRevision = 0 + const sessions = new Map() + const unavailableAccounts = () => + Object.freeze({ status: 'unavailable' as const, code: 'management-disabled' as const }) + const readAccounts = async (): Promise => { + if (state === undefined || signal.aborted) return unavailableAccounts() + const projection = await registration.inspect() + const value = await invoke(client, registration, 'gateway.management.accounts.list', undefined, signal) + const files = record(value).files + if (!Array.isArray(files)) return unavailableAccounts() + const accounts = Object.freeze(files.flatMap(item => { + const mapped = mapAccount(item) + return mapped === undefined ? [] : [mapped] + })) + accountsRevision = revision(accounts) + sequence += 1 + return Object.freeze({ + status: 'available' as const, + accounts: Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-accounts.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-accounts/v1' as const, + schemaVersion: 1 as const, + binding, + sequence, + observedAt: new Date().toISOString(), + revision: accountsRevision, + serviceGeneration: projection.binding.serviceGeneration, + accounts, + }), + }) + } + return Object.freeze({ + async readCatalog(): Promise { + if (state === undefined || signal.aborted) { + return Object.freeze({ status: 'unavailable' as const, code: 'owner-unavailable' as const }) + } + const catalog = state.registry.catalog(state.pluginGeneration) + if ('code' in catalog) { + return Object.freeze({ status: 'unavailable' as const, code: 'stale-generation' as const }) + } + const projection = await registration.inspect() + sequence += 1 + return Object.freeze({ + status: 'available' as const, + catalog: mapCatalog(state, catalog, binding, projection.binding.serviceGeneration, sequence), + }) + }, + readAccounts, + async toggleAccount( + request: ManagedServiceCliProxyAccountToggleRequestV1, + ): Promise { + if (!bindingMatches(request.binding, binding) || request.expectedRevision !== accountsRevision) { + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-account-toggle-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-account-toggle-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'denied' as const, + error: Object.freeze({ code: 'stale-revision' as const, message: 'account revision changed' }), + }) + } + const result = await invoke(client, registration, 'gateway.management.accounts.toggle', { + name: request.accountId, + ...(request.authIndex === undefined ? {} : { auth_index: request.authIndex }), + disabled: request.disabled, + }, signal) + sequence += 1 + if (result !== undefined && state !== undefined && !signal.aborted) { + await state.refreshGatewayModels().catch(() => undefined) + } + return result === undefined + ? Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-account-toggle-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-account-toggle-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'failed' as const, + error: Object.freeze({ code: 'management-disabled' as const, message: 'management API unavailable' }), + }) + : Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-account-toggle-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-account-toggle-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'accepted' as const, + accountId: request.accountId, + disabled: request.disabled, + sequence, + }) + }, + async startOAuth( + request: ManagedServiceCliProxyOAuthStartRequestV1, + ): Promise { + if (!bindingMatches(request.binding, binding) || request.expectedRevision !== accountsRevision) { + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'denied' as const, + error: Object.freeze({ code: 'stale-revision' as const, message: 'account revision changed' }), + }) + } + const endpoints: Partial> = { + anthropic: 'gateway.management.oauth.anthropic.start', + codex: 'gateway.management.oauth.codex.start', + antigravity: 'gateway.management.oauth.antigravity.start', + kimi: 'gateway.management.oauth.kimi.start', + xai: 'gateway.management.oauth.xai.start', + } + const endpoint = endpoints[request.provider] + const value = endpoint === undefined + ? undefined + : await invoke(client, registration, endpoint, { is_webui: true }, signal) + const response = record(value) + const authorizationUrl = stringValue(response.url) + const sessionId = stringValue(response.state) + if (authorizationUrl === undefined || sessionId === undefined) { + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'unavailable' as const, + error: Object.freeze({ code: 'unsupported-provider' as const, message: 'OAuth provider unavailable' }), + }) + } + sessions.set(sessionId, request.provider) + sequence += 1 + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'accepted' as const, + sessionId, + authorizationUrl, + sequence, + }) + }, + async pollOAuth(sessionId: string): Promise { + const value = sessions.has(sessionId) + ? await invoke(client, registration, 'gateway.management.oauth.poll', { state: sessionId }, signal) + : undefined + const response = record(value) + const status = stringValue(response.status) + const stateValue: ManagedServiceCliProxyOAuthSessionStateV1 = status === 'ok' + ? 'completed' + : status === 'error' + ? 'error' + : status === 'wait' + ? 'wait' + : 'unknown' + if (stateValue === 'completed' || stateValue === 'error') sessions.delete(sessionId) + if (stateValue === 'completed' && state !== undefined && !signal.aborted) { + await state.refreshGatewayModels().catch(() => undefined) + } + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-status.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-status/v1' as const, + schemaVersion: 1 as const, + binding, + sessionId, + state: stateValue, + ...(stringValue(response.error) === undefined + ? {} + : { error: Object.freeze({ code: 'authentication-failed', message: 'Authentication failed' }) }), + sequence: ++sequence, + }) + }, + async cancelOAuth( + request: ManagedServiceCliProxyOAuthCancelRequestV1, + ): Promise { + if (!bindingMatches(request.binding, binding) || request.expectedRevision !== accountsRevision) { + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-cancel-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-cancel-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'denied' as const, + error: Object.freeze({ code: 'stale-revision' as const, message: 'account revision changed' }), + }) + } + const value = sessions.has(request.sessionId) + ? await invoke(client, registration, 'gateway.management.oauth.cancel', { state: request.sessionId }, signal) + : undefined + const cancelled = booleanValue(record(value).cancelled) ?? false + if (cancelled) sessions.delete(request.sessionId) + if (value === undefined) { + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-cancel-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-cancel-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'failed' as const, + error: Object.freeze({ code: 'management-disabled' as const, message: 'management API unavailable' }), + }) + } + return Object.freeze({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-cancel-result.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-cancel-result/v1' as const, + schemaVersion: 1 as const, + requestId: request.requestId, + binding, + status: 'accepted' as const, + sessionId: request.sessionId, + cancelled, + sequence: ++sequence, + }) + }, + }) + }, +}) diff --git a/src/service/gateway.ts b/src/service/gateway.ts new file mode 100644 index 0000000..96c5d77 --- /dev/null +++ b/src/service/gateway.ts @@ -0,0 +1,406 @@ +import { createHash } from 'node:crypto' +import type { Fiber } from '@deepseek-ai/cordis' +import type { ManagedServiceContextProviderV1 } from '@cordisx/protocol/managed-service-context/v1' +import type { + ManagedNativeProviderCatalogV1, + ManagedNativeProviderPublicationHandleV1, + ManagedServiceApplyInputV1, + ManagedServiceApplyV1, + ManagedServiceIdentityV1, + ManagedServiceLeaseV1, + ManagedServiceRegistrationHandleV1, + ManagedServiceSafeValueV1, + ManagedServiceServiceContextV1, +} from '@cordisx/protocol/managed-service-runtime/v1' +import { cliProxyGatewayDefinition } from './gateway-definition.js' +import { bindGatewayUIState, managedServiceUI } from './gateway-ui.js' +import { createCliProxyUpstreamContextProviderV1 } from './upstream-context.js' +import { + CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1, + type CliProxyGatewayPlanV1, + type CliProxyModelCatalogV1, + type CliProxyRegistryDiagnosticV1, + CliProxyUpstreamRegistryV1, +} from './upstream-registry.js' + +export * from './gateway-definition.js' +export * from './upstream-registry.js' + +export const contextServices: readonly ManagedServiceContextProviderV1[] = Object.freeze([ + createCliProxyUpstreamContextProviderV1(), +]) +export { managedServiceUI } +export const CLI_PROXY_NATIVE_PROVIDER_ID = 'cli-proxy-api' + +export type CliProxyGatewayContextV1 = ManagedServiceServiceContextV1 & Pick & { + readonly cliProxyUpstreams: CliProxyUpstreamRegistryV1 +} + +function activationError( + stage: string, + result: { readonly status: string; readonly diagnostic?: { readonly code: string } }, +): Error { + return new Error(`CLIProxyAPI ${stage} failed: ${result.diagnostic?.code ?? result.status}`) +} + +function assertPlan( + value: CliProxyGatewayPlanV1 | CliProxyRegistryDiagnosticV1, +): asserts value is CliProxyGatewayPlanV1 { + if ('code' in value) throw new Error(`CLIProxyAPI gateway plan failed: ${value.code}`) +} + +function modelIds(value: ManagedServiceSafeValueV1): ReadonlySet { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return new Set() + const data = (value as { readonly [key: string]: ManagedServiceSafeValueV1 }).data + if (!Array.isArray(data)) return new Set() + return new Set(data.flatMap(item => { + if (item === null || typeof item !== 'object' || Array.isArray(item)) return [] + return typeof item.id === 'string' ? [item.id] : [] + })) +} + +function expectedSourceModels(catalog: CliProxyModelCatalogV1, upstreamId: string): readonly string[] { + for (const group of catalog.groups) { + const provider = group.providers.find(item => item.providerId === upstreamId) + if (provider !== undefined) return provider.models.flatMap(model => model.sourceModelIds) + } + return [] +} + +function compareUtf8Json(left: readonly string[], right: readonly string[]): number { + return Buffer.compare(Buffer.from(JSON.stringify(left)), Buffer.from(JSON.stringify(right))) +} + +function nativeProviderCatalog(models: ReadonlySet): ManagedNativeProviderCatalogV1 | undefined { + const routes = [...models] + .map(model => ({ alias: model, gatewayModelId: model })) + .sort((left, right) => compareUtf8Json([left.alias], [right.alias])) + const defaultAlias = routes[0]?.alias + if (defaultAlias === undefined) return undefined + const generation = `sha256:${ + createHash('sha256').update(JSON.stringify(routes.map(route => route.gatewayModelId))).digest('hex') + }` + const digest = `sha256:${ + createHash('sha256').update( + JSON.stringify([generation, defaultAlias, routes.map(route => [route.alias, route.gatewayModelId])]), + ).digest('hex') + }` as const + return { generation, digest, defaultAlias, routes } +} + +function relativePointer(collection: 'codex-api-key' | 'openai-compatibility', pointer: `/${string}`): `/${string}` { + const prefix = `/${collection}` + if (!pointer.startsWith(`${prefix}/`)) throw new Error('CLIProxyAPI gateway plan contains an invalid pointer') + return pointer.slice(prefix.length) as `/${string}` +} + +export async function activateCliProxyGateway( + context: CliProxyGatewayContextV1, + input: ManagedServiceApplyInputV1, +): Promise { + const registry = context.cliProxyUpstreams + let active: { readonly revision: number; readonly dispose: () => Promise } | undefined + let transition = Promise.resolve() + let unsubscribe = (): void => undefined + let disposePromise: Promise | undefined + let gatewayHandle: ManagedServiceRegistrationHandleV1 | undefined + let publication: ManagedNativeProviderPublicationHandleV1 | undefined + let publicationDigest: string | undefined + const authorization = new Map() + let activeRevision: number | undefined + let scheduleModelRefresh: () => Promise = async () => undefined + const uiState = { + pluginGeneration: input.owner.pluginGeneration, + registry, + authorization, + get activeRevision() { + return activeRevision + }, + set activeRevision(value: number | undefined) { + activeRevision = value + }, + refreshGatewayModels: () => scheduleModelRefresh(), + } + const unbindGatewayUIState = bindGatewayUIState(uiState) + + const activate = async ( + catalog: CliProxyModelCatalogV1, + plan: CliProxyGatewayPlanV1, + ): Promise<() => Promise> => { + gatewayHandle ??= await context.managedServices.register(cliProxyGatewayDefinition, { + revision: plan.catalogDigest, + }) + const handle = gatewayHandle + const leases: ManagedServiceLeaseV1[] = [] + let cleanupPromise: Promise | undefined + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + const failures: unknown[] = [] + const attempt = async (operation: () => unknown | Promise): Promise => { + try { + await operation() + } catch (error) { + failures.push(error) + } + } + + for (const lease of leases.splice(0).reverse()) { + await attempt(() => input.client.release(lease)) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'CLIProxyAPI managed gateway cleanup failed') + } + })() + return cleanupPromise + } + + try { + const sources = [] + authorization.clear() + for (const composition of plan.composition) { + const acquired = await input.client.acquire(composition.source, { signal: input.signal }) + if (acquired.status !== 'ready') throw activationError(`${composition.upstreamId} acquisition`, acquired) + leases.push(acquired.lease) + sources.push({ source: composition.upstreamId, lease: acquired.lease }) + authorization.set(composition.upstreamId, acquired.projection.httpAuthorization.state === 'configured') + + const discovered = await input.client.invoke(acquired.lease, 'models.list', undefined, { signal: input.signal }) + if (discovered.status !== 'accepted') { + throw activationError(`${composition.upstreamId} model validation`, discovered) + } + const actual = modelIds(discovered.value) + const missing = expectedSourceModels(catalog, composition.upstreamId).filter(model => !actual.has(model)) + if (missing.length > 0) { + throw new Error( + `CLIProxyAPI upstream ${composition.upstreamId} omitted declared models: ${missing.join(', ')}`, + ) + } + } + + const bindings: Parameters[0]['bindings'][number][] = [ + { + targetSlot: 'codex-upstreams', + source: { kind: 'safe-literal', value: plan.configuration['codex-api-key'] }, + }, + { + targetSlot: 'openai-upstreams', + source: { kind: 'safe-literal', value: plan.configuration['openai-compatibility'] }, + }, + ] + for (const composition of plan.composition) { + const collection = composition.origin.targetPointer.startsWith('/codex-api-key/') + ? 'codex-api-key' as const + : 'openai-compatibility' as const + bindings.push({ + targetSlot: collection === 'codex-api-key' ? 'codex-upstreams' : 'openai-upstreams', + targetPointer: relativePointer(collection, composition.origin.targetPointer), + source: { kind: 'source-origin', source: composition.upstreamId, origin: 'api' }, + }) + if (authorization.get(composition.upstreamId)) { + bindings.push({ + targetSlot: collection === 'codex-api-key' ? 'codex-upstreams' : 'openai-upstreams', + targetPointer: relativePointer(collection, composition.authorization.targetPointer), + source: { kind: 'source-authorization', source: composition.upstreamId }, + }) + } + } + + const materialized = await input.client.materialize({ + target: handle.binding, + revision: plan.catalogDigest, + sources, + bindings, + }, { signal: input.signal }) + if (materialized.status !== 'accepted') throw activationError('materialization', materialized) + + const ready = await handle.ensureReady({ + materialization: { + materializationHandle: materialized.materializationHandle, + revision: materialized.revision, + }, + signal: input.signal, + }) + if (ready.status !== 'ready') throw activationError('readiness', ready) + + const actualGatewayModels = await readGatewayModels() + const expectedGatewayModels = catalog.groups.flatMap(group => + group.providers.flatMap(provider => provider.models.map(model => model.gatewayModelId)) + ) + const missingGatewayModels = expectedGatewayModels.filter(model => !actualGatewayModels.has(model)) + if (missingGatewayModels.length > 0) { + throw new Error(`CLIProxyAPI gateway omitted composed models: ${missingGatewayModels.join(', ')}`) + } + await publishGatewayModels(actualGatewayModels) + activeRevision = catalog.registryRevision + return cleanup + } catch (error) { + try { + await cleanup() + } catch (cleanupError) { + const cleanupFailures = cleanupError instanceof AggregateError ? cleanupError.errors : [cleanupError] + throw new AggregateError( + [error, ...cleanupFailures], + 'CLIProxyAPI managed gateway activation and cleanup failed', + { cause: error }, + ) + } + throw error + } + } + + const disposePublication = async (): Promise => { + if (publication === undefined) return + const current = publication + publication = undefined + publicationDigest = undefined + const result = await current.dispose() + const alreadyRevoked = result.status === 'stale' + && (result.diagnostic.code === 'disposed' || result.diagnostic.code === 'stale-generation') + if (result.status !== 'disposed' && !alreadyRevoked) { + throw activationError('native publication cleanup', result) + } + } + + const readGatewayModels = async (): Promise> => { + if (gatewayHandle === undefined) throw new Error('CLIProxyAPI gateway is not registered') + const acquired = await input.client.acquire(gatewayHandle.binding.identity, { signal: input.signal }) + if (acquired.status !== 'ready') throw activationError('validation acquisition', acquired) + try { + const verified = await input.client.invoke(acquired.lease, 'gateway.models.list', undefined, { + signal: input.signal, + }) + if (verified.status !== 'accepted') throw activationError('model validation', verified) + return modelIds(verified.value) + } finally { + input.client.release(acquired.lease) + } + } + + const publishGatewayModels = async (models: ReadonlySet): Promise => { + if (gatewayHandle === undefined) throw new Error('CLIProxyAPI gateway is not registered') + const catalog = nativeProviderCatalog(models) + if (catalog?.digest === publicationDigest) return + await disposePublication() + if (catalog === undefined || input.signal.aborted) return + const published = await gatewayHandle.publishNativeProvider({ + providerId: CLI_PROXY_NATIVE_PROVIDER_ID, + compositionOrigin: 'api', + catalog, + }, { signal: input.signal }) + if (published.status !== 'accepted') throw activationError('cli-proxy-api native publication', published) + publication = published.publication + publicationDigest = catalog.digest + } + + const reconcile = async (): Promise => { + if (input.signal.aborted) return + const catalog = registry.catalog(input.owner.pluginGeneration) + if ('code' in catalog) throw new Error(`CLIProxyAPI catalog failed: ${catalog.code}`) + if (active?.revision === catalog.registryRevision) return + const previous = active + active = undefined + activeRevision = undefined + await previous?.dispose() + const plan = registry.gatewayPlan(input.owner.pluginGeneration) + assertPlan(plan) + + if (gatewayHandle === undefined) { + gatewayHandle = await context.managedServices.register(cliProxyGatewayDefinition, { + revision: plan.catalogDigest, + }) + } + + if (input.signal.aborted) return + + const cleanup = await activate(catalog, plan) + if (input.signal.aborted) { + await cleanup() + return + } + active = { revision: catalog.registryRevision, dispose: cleanup } + } + + const schedule = (): Promise => { + const pending = transition.then(reconcile) + transition = pending.catch(() => undefined) + return pending + } + + scheduleModelRefresh = (): Promise => { + const pending = transition.then(async () => { + if (input.signal.aborted || active === undefined) return + await publishGatewayModels(await readGatewayModels()) + }) + transition = pending.catch(() => undefined) + return pending + } + + const dispose = (): Promise => { + if (disposePromise !== undefined) return disposePromise + disposePromise = (async () => { + unsubscribe() + unbindGatewayUIState() + await transition + const failures: unknown[] = [] + const cleanup = async (operation: () => unknown | Promise): Promise => { + try { + await operation() + } catch (error) { + failures.push(...(error instanceof AggregateError ? error.errors : [error])) + } + } + const current = active + active = undefined + if (current !== undefined) await cleanup(current.dispose) + await cleanup(disposePublication) + if (gatewayHandle !== undefined) { + const handle = gatewayHandle + gatewayHandle = undefined + await cleanup(() => handle.dispose()) + } + await cleanup(() => input.client.dispose()) + if (failures.length > 0) { + throw new AggregateError(failures, 'CLIProxyAPI managed gateway cleanup failed') + } + })() + return disposePromise + } + + try { + unsubscribe = registry.subscribe(() => { + void schedule().catch(() => undefined) + }) + const onAbort = (): void => { + void dispose().catch(() => undefined) + } + input.signal.addEventListener('abort', onAbort, { once: true }) + context.effect(() => () => { + input.signal.removeEventListener('abort', onAbort) + return dispose() + }, 'CLIProxyAPI managed gateway') + await schedule() + if (input.signal.aborted) await dispose() + } catch (error) { + try { + await dispose() + } catch (cleanupError) { + const activationFailures = error instanceof AggregateError ? error.errors : [error] + const activationCause = error instanceof AggregateError && error.cause !== undefined ? error.cause : error + const cleanupFailures = cleanupError instanceof AggregateError ? cleanupError.errors : [cleanupError] + throw new AggregateError( + [...activationFailures, ...cleanupFailures], + 'CLIProxyAPI managed gateway activation and cleanup failed', + { cause: activationCause }, + ) + } + throw error + } +} + +const gatewayApply: ManagedServiceApplyV1 = async (context, input) => { + await activateCliProxyGateway(context as CliProxyGatewayContextV1, input) +} + +export const apply = Object.assign(gatewayApply, { + inject: ['managedServices', CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1] as const, +}) diff --git a/src/service/index.ts b/src/service/index.ts index 59e607f..3629131 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -5,6 +5,8 @@ import type { import { CliProxyPlatformProviderAdapter } from './adapter.js' import { BROKER_BINDINGS } from './bindings.js' +export * from './upstream-registry.js' + const OPERATIONS = [ 'models.list', 'sessions.list', diff --git a/src/service/upstream-context.ts b/src/service/upstream-context.ts new file mode 100644 index 0000000..e0ca69a --- /dev/null +++ b/src/service/upstream-context.ts @@ -0,0 +1,76 @@ +import type { + ManagedServiceContextAuthorityV1, + ManagedServiceContextBindingV1, + ManagedServiceContextProviderRootV1, + ManagedServiceContextProviderV1, +} from '@cordisx/protocol/managed-service-context/v1' +import type { ManagedServiceIdentityV1 } from '@cordisx/protocol/managed-service-runtime/v1' +import { + CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1, + CliProxyUpstreamRegistryV1, + createCliProxyUpstreamRegistrarV1, +} from './upstream-registry.js' + +function sourceKey(source: ManagedServiceIdentityV1): string { + return JSON.stringify([source.source, source.pluginId, source.serviceId]) +} + +export function createCliProxyUpstreamContextProviderV1(): ManagedServiceContextProviderV1 { + return Object.freeze({ + service: CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1, + create: ({ target, signal }: { + readonly target: ManagedServiceContextAuthorityV1 + readonly signal: AbortSignal + }): ManagedServiceContextProviderRootV1 => { + const registry = new CliProxyUpstreamRegistryV1( + target.pluginGeneration, + sourceKey, + ) + const bindings = new Set() + let disposed = false + + const dispose = (): void => { + if (disposed) return + disposed = true + for (const binding of [...bindings]) binding.dispose() + bindings.clear() + registry.dispose() + } + + signal.addEventListener('abort', dispose, { once: true }) + return Object.freeze({ + providerValue: registry, + bind: ({ producer, target: bindingTarget, signal: bindingSignal }: { + readonly producer: { readonly pluginId: string; readonly pluginGeneration: string } + readonly target: ManagedServiceContextAuthorityV1 + readonly signal: AbortSignal + }) => { + if ( + disposed + || bindingSignal.aborted + || bindingTarget.pluginId !== target.pluginId + || bindingTarget.pluginGeneration !== target.pluginGeneration + || bindingTarget.serviceId !== target.serviceId + || bindingTarget.serviceGeneration !== target.serviceGeneration + ) throw new Error('CLIProxyAPI upstream context generation is stale') + + const registrar = createCliProxyUpstreamRegistrarV1(registry, producer) + let bindingDisposed = false + const binding: ManagedServiceContextBindingV1 = Object.freeze({ + value: registrar, + dispose: () => { + if (bindingDisposed) return + bindingDisposed = true + bindings.delete(binding) + registrar.dispose() + }, + }) + bindings.add(binding) + bindingSignal.addEventListener('abort', () => binding.dispose(), { once: true }) + return binding + }, + dispose, + }) + }, + }) +} diff --git a/src/service/upstream-registry.ts b/src/service/upstream-registry.ts new file mode 100644 index 0000000..987fe5f --- /dev/null +++ b/src/service/upstream-registry.ts @@ -0,0 +1,735 @@ +import { createHash } from 'node:crypto' + +export const CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1 = + 'https://raw.githubusercontent.com/cordisx/plugin-cli-proxy-api/main/schemas/cli-proxy-upstream-descriptor.v1.schema.json' +export const CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1 = 'cordisx.cli-proxy-upstream-descriptor/v1' +export const CLI_PROXY_MODEL_CATALOG_CONTRACT_V1 = 'cordisx.cli-proxy-model-catalog/v1' +export const CLI_PROXY_GATEWAY_PLAN_CONTRACT_V1 = 'cordisx.cli-proxy-gateway-plan/v1' +export const CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1 = 'cliProxyUpstreams' + +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,95}$/ +const PREFIX_PATTERN = /^[a-z0-9][a-z0-9._-]{0,95}$/ +const MAX_MODELS = 256 +const MAX_REGISTRATIONS = 64 + +export type CliProxyInputModalityV1 = 'text' | 'image' | 'audio' +export type CliProxyWireApiV1 = 'responses' | 'chat-completions' + +export interface CliProxyModelRouteV1 { + readonly sourceModelId: string + readonly modelId: string + readonly displayName?: string + readonly enabled: boolean + readonly isDefault: boolean + readonly inputModalities?: readonly CliProxyInputModalityV1[] +} + +export interface CliProxyUpstreamDescriptorV1 { + readonly $schema: typeof CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1 + readonly contract: typeof CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1 + readonly schemaVersion: 1 + readonly revision: number + readonly upstreamId: string + readonly displayName: string + readonly enabled: boolean + readonly wireApi: CliProxyWireApiV1 + readonly prefix?: string + readonly order: number + readonly requestTimeoutMs: number + readonly group: { + readonly groupId: string + readonly displayName: string + readonly order: number + } + readonly models: readonly CliProxyModelRouteV1[] +} + +export interface CliProxyUpstreamRegistrationV1 { + readonly source: Source + readonly descriptor: CliProxyUpstreamDescriptorV1 +} + +export interface CliProxyUpstreamRevocationV1 { + readonly upstreamId: string + readonly expectedRevision: number +} + +export interface CliProxyUpstreamProducerV1 { + readonly pluginId: string + readonly pluginGeneration: string +} + +export type CliProxyRegistryDiagnosticCodeV1 = + | 'invalid-registration' + | 'capacity-exceeded' + | 'conflicting-source' + | 'conflicting-producer' + | 'conflicting-prefix' + | 'conflicting-group' + | 'conflicting-model-alias' + | 'conflicting-default' + | 'stale-revision' + | 'stale-generation' + | 'not-found' + +export interface CliProxyRegistryDiagnosticV1 { + readonly code: CliProxyRegistryDiagnosticCodeV1 + readonly field?: string + readonly message: string +} + +export type CliProxyRegistryMutationResultV1 = + | { readonly status: 'registered' | 'updated' | 'revoked'; readonly registryRevision: number } + | { readonly status: 'unchanged'; readonly registryRevision: number } + | { readonly status: 'rejected' | 'stale-generation'; readonly diagnostic: CliProxyRegistryDiagnosticV1 } + +export interface CliProxyUpstreamRegistrarV1 { + readonly producer: CliProxyUpstreamProducerV1 + register(registration: CliProxyUpstreamRegistrationV1): CliProxyRegistryMutationResultV1 + revoke(input: CliProxyUpstreamRevocationV1): CliProxyRegistryMutationResultV1 + dispose(): CliProxyRegistryMutationResultV1 +} + +export interface CliProxyUpstreamConsumerContextV1 { + readonly cliProxyUpstreams: CliProxyUpstreamRegistrarV1 +} + +export interface CliProxyModelCatalogModelV1 { + readonly modelId: string + readonly gatewayModelId: string + readonly displayName: string + readonly sourceModelIds: readonly string[] + readonly isDefault: boolean + readonly inputModalities: readonly CliProxyInputModalityV1[] +} + +export interface CliProxyModelCatalogProviderV1 { + readonly providerId: string + readonly displayName: string + readonly prefix: string + readonly wireApi: CliProxyWireApiV1 + readonly order: number + readonly revision: number + readonly groupId: string + readonly requestTimeoutMs: number + readonly models: readonly CliProxyModelCatalogModelV1[] +} + +export interface CliProxyModelCatalogGroupV1 { + readonly groupId: string + readonly displayName: string + readonly order: number + readonly providers: readonly CliProxyModelCatalogProviderV1[] +} + +export interface CliProxyModelCatalogV1 { + readonly contract: typeof CLI_PROXY_MODEL_CATALOG_CONTRACT_V1 + readonly schemaVersion: 1 + readonly pluginGeneration: string + readonly registryRevision: number + readonly upstreamRevisions: Readonly> + readonly groups: readonly CliProxyModelCatalogGroupV1[] + readonly catalogDigest: `sha256:${string}` +} + +export interface CliProxyGatewayCompositionV1 { + readonly upstreamId: string + readonly source: Source + readonly origin: { + readonly slot: string + readonly targetPointer: `/${string}` + } + readonly authorization: { + readonly slot: string + readonly targetPointer: `/${string}` + } +} + +export interface CliProxyGatewayPlanV1 { + readonly contract: typeof CLI_PROXY_GATEWAY_PLAN_CONTRACT_V1 + readonly schemaVersion: 1 + readonly pluginGeneration: string + readonly registryRevision: number + readonly catalogDigest: `sha256:${string}` + readonly configuration: { + readonly 'codex-api-key': readonly { + readonly 'api-key': null + readonly prefix: string + readonly 'base-url': null + readonly 'request-retry': 0 + readonly models: readonly { + readonly name: string + readonly alias: string + readonly 'display-name': string + readonly 'force-mapping': true + }[] + }[] + readonly 'openai-compatibility': readonly { + readonly name: string + readonly disabled: false + readonly prefix: string + readonly 'base-url': null + readonly 'api-key-entries': readonly [{ readonly 'api-key': null }] + readonly 'request-retry': 0 + readonly models: readonly { + readonly name: string + readonly alias: string + readonly 'display-name': string + readonly 'force-mapping': true + readonly 'input-modalities'?: readonly CliProxyInputModalityV1[] + }[] + }[] + } + readonly composition: readonly CliProxyGatewayCompositionV1[] +} + +interface RegistryEntry { + readonly source: Source + readonly sourceKey: string + readonly producer: CliProxyUpstreamProducerV1 + readonly descriptor: CliProxyUpstreamDescriptorV1 +} + +interface ValidationFailure { + readonly code: CliProxyRegistryDiagnosticCodeV1 + readonly field?: string + readonly message: string +} + +function rejection(failure: ValidationFailure): CliProxyRegistryMutationResultV1 { + return { status: 'rejected', diagnostic: failure } +} + +function isText(value: string, maximum: number): boolean { + return value.length > 0 && value.length <= maximum && value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value) +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function normalizedModalities( + values: readonly CliProxyInputModalityV1[] | undefined, +): readonly CliProxyInputModalityV1[] { + return [...new Set(values ?? [])].sort() +} + +function validateDescriptor(descriptor: CliProxyUpstreamDescriptorV1): ValidationFailure | undefined { + if ( + descriptor.$schema !== CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1 + || descriptor.contract !== CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1 + || descriptor.schemaVersion !== 1 + ) { + return { code: 'invalid-registration', field: 'contract', message: 'Unsupported upstream descriptor contract' } + } + if (!ID_PATTERN.test(descriptor.upstreamId)) { + return { code: 'invalid-registration', field: 'upstreamId', message: 'Invalid upstream id' } + } + if (!isText(descriptor.displayName, 200)) { + return { code: 'invalid-registration', field: 'displayName', message: 'Invalid upstream display name' } + } + if (descriptor.wireApi !== 'responses' && descriptor.wireApi !== 'chat-completions') { + return { code: 'invalid-registration', field: 'wireApi', message: 'Unsupported upstream wire API' } + } + if (!Number.isSafeInteger(descriptor.revision) || descriptor.revision < 1) { + return { code: 'invalid-registration', field: 'revision', message: 'Revision must be a positive safe integer' } + } + if (!Number.isSafeInteger(descriptor.order) || descriptor.order < -1_000 || descriptor.order > 1_000) { + return { code: 'invalid-registration', field: 'order', message: 'Provider order is outside the supported range' } + } + if ( + !Number.isSafeInteger(descriptor.requestTimeoutMs) + || descriptor.requestTimeoutMs < 1_000 + || descriptor.requestTimeoutMs > 120_000 + ) { + return { + code: 'invalid-registration', + field: 'requestTimeoutMs', + message: 'Request timeout is outside the supported range', + } + } + if (!ID_PATTERN.test(descriptor.group.groupId) || !isText(descriptor.group.displayName, 200)) { + return { code: 'invalid-registration', field: 'group', message: 'Invalid upstream group' } + } + if ( + !Number.isSafeInteger(descriptor.group.order) || descriptor.group.order < -1_000 || descriptor.group.order > 1_000 + ) { + return { code: 'invalid-registration', field: 'group.order', message: 'Group order is outside the supported range' } + } + const prefix = descriptor.prefix ?? descriptor.upstreamId + if (!PREFIX_PATTERN.test(prefix)) { + return { code: 'invalid-registration', field: 'prefix', message: 'Invalid gateway prefix' } + } + if (descriptor.models.length === 0 || descriptor.models.length > MAX_MODELS) { + return { code: 'invalid-registration', field: 'models', message: 'Model routes are outside the supported range' } + } + + const sourceModels = new Set() + const aliases = new Map() + const defaults = new Set() + for (const route of descriptor.models) { + if (!isText(route.sourceModelId, 256) || !isText(route.modelId, 256)) { + return { code: 'invalid-registration', field: 'models', message: 'Invalid model identity' } + } + if (route.displayName !== undefined && !isText(route.displayName, 200)) { + return { code: 'invalid-registration', field: 'models.displayName', message: 'Invalid model display name' } + } + if (sourceModels.has(route.sourceModelId)) { + return { code: 'conflicting-model-alias', field: 'models.sourceModelId', message: 'Duplicate source model id' } + } + sourceModels.add(route.sourceModelId) + if (!route.enabled) continue + if (route.isDefault) defaults.add(route.modelId) + const modalities = normalizedModalities(route.inputModalities) + if (modalities.some(value => value !== 'text' && value !== 'image' && value !== 'audio')) { + return { code: 'invalid-registration', field: 'models.inputModalities', message: 'Unsupported input modality' } + } + const first = aliases.get(route.modelId) + if ( + first !== undefined + && ( + (first.displayName ?? first.modelId) !== (route.displayName ?? route.modelId) + || first.isDefault !== route.isDefault + || !sameStrings(normalizedModalities(first.inputModalities), modalities) + ) + ) { + return { + code: 'conflicting-model-alias', + field: 'models.modelId', + message: 'Pooled model aliases must have identical public metadata', + } + } + aliases.set(route.modelId, route) + } + if (aliases.size === 0) { + return { code: 'invalid-registration', field: 'models', message: 'At least one enabled model route is required' } + } + if (defaults.size > 1) { + return { + code: 'conflicting-default', + field: 'models.isDefault', + message: 'Only one public default model is allowed', + } + } + return undefined +} + +function compareOrderThenId( + left: { readonly order: number; readonly id: string }, + right: { readonly order: number; readonly id: string }, +): number { + return left.order - right.order || left.id.localeCompare(right.id) +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map( + ([key, item]) => [key, stableValue(item)], + ), + ) +} + +function stableStringify(value: unknown): string { + return JSON.stringify(stableValue(value)) +} + +function digest(value: unknown): `sha256:${string}` { + return `sha256:${createHash('sha256').update(stableStringify(value)).digest('hex')}` +} + +export class CliProxyUpstreamRegistryV1 { + private readonly entries = new Map>() + private readonly producerGenerations = new Map() + private readonly listeners = new Set<(revision: number) => void>() + private revision = 0 + private disposed = false + + constructor( + readonly pluginGeneration: string, + private readonly sourceKey: (source: Source) => string, + ) { + if (!isText(pluginGeneration, 256)) throw new Error('Invalid plugin generation') + } + + register( + registration: CliProxyUpstreamRegistrationV1, + registryGeneration: string, + producer: CliProxyUpstreamProducerV1, + ): CliProxyRegistryMutationResultV1 { + const generationFailure = this.generationFailure(registryGeneration, producer) + if (generationFailure !== undefined) return generationFailure + const current = this.entries.get(registration.descriptor.upstreamId) + if (current !== undefined && current.producer.pluginId !== producer.pluginId) { + return rejection({ + code: 'conflicting-producer', + field: 'upstreamId', + message: 'Upstream is owned by another producer', + }) + } + if (current !== undefined && current.producer.pluginGeneration !== producer.pluginGeneration) { + return { + status: 'stale-generation', + diagnostic: { code: 'stale-generation', message: 'Upstream producer generation is stale' }, + } + } + const validation = validateDescriptor(registration.descriptor) + if (validation !== undefined) return rejection(validation) + const sourceKey = this.sourceKey(registration.source) + if (!isText(sourceKey, 512)) { + return rejection({ code: 'invalid-registration', field: 'source', message: 'Invalid upstream source reference' }) + } + if (current !== undefined) { + if (registration.descriptor.revision < current.descriptor.revision) { + return rejection({ code: 'stale-revision', field: 'revision', message: 'Upstream revision is stale' }) + } + if (registration.descriptor.revision === current.descriptor.revision) { + return stableStringify(registration.descriptor) === stableStringify(current.descriptor) + && sourceKey === current.sourceKey + ? { status: 'unchanged', registryRevision: this.revision } + : rejection({ + code: 'stale-revision', + field: 'revision', + message: 'Revision was reused with different content', + }) + } + } else if (this.entries.size >= MAX_REGISTRATIONS) { + return rejection({ code: 'capacity-exceeded', message: 'Upstream registry capacity was reached' }) + } + + for (const [upstreamId, entry] of this.entries) { + if (upstreamId === registration.descriptor.upstreamId) continue + if (entry.sourceKey === sourceKey) { + return rejection({ + code: 'conflicting-source', + field: 'source', + message: 'Upstream source is already registered', + }) + } + if (entry.descriptor.enabled && registration.descriptor.enabled) { + if ( + (entry.descriptor.prefix ?? entry.descriptor.upstreamId) + === (registration.descriptor.prefix ?? registration.descriptor.upstreamId) + ) { + return rejection({ + code: 'conflicting-prefix', + field: 'prefix', + message: 'Gateway prefix is already registered', + }) + } + if ( + entry.descriptor.group.groupId === registration.descriptor.group.groupId + && ( + entry.descriptor.group.displayName !== registration.descriptor.group.displayName + || entry.descriptor.group.order !== registration.descriptor.group.order + ) + ) { + return rejection({ code: 'conflicting-group', field: 'group', message: 'Group metadata is inconsistent' }) + } + } + } + + this.entries.set(registration.descriptor.upstreamId, { + source: registration.source, + sourceKey, + producer, + descriptor: structuredClone(registration.descriptor), + }) + this.revision += 1 + this.notify() + return { status: current === undefined ? 'registered' : 'updated', registryRevision: this.revision } + } + + revoke( + input: CliProxyUpstreamRevocationV1, + registryGeneration: string, + producer: CliProxyUpstreamProducerV1, + ): CliProxyRegistryMutationResultV1 { + const generationFailure = this.generationFailure(registryGeneration, producer) + if (generationFailure !== undefined) return generationFailure + const current = this.entries.get(input.upstreamId) + if (current === undefined) { + return rejection({ code: 'not-found', field: 'upstreamId', message: 'Upstream is not registered' }) + } + if (current.producer.pluginId !== producer.pluginId) { + return rejection({ + code: 'conflicting-producer', + field: 'upstreamId', + message: 'Upstream is owned by another producer', + }) + } + if (current.producer.pluginGeneration !== producer.pluginGeneration) { + return { + status: 'stale-generation', + diagnostic: { code: 'stale-generation', message: 'Upstream producer generation is stale' }, + } + } + if (input.expectedRevision !== current.descriptor.revision) { + return rejection({ code: 'stale-revision', field: 'expectedRevision', message: 'Upstream revision is stale' }) + } + this.entries.delete(input.upstreamId) + this.revision += 1 + this.notify() + return { status: 'revoked', registryRevision: this.revision } + } + + activateProducer(producer: CliProxyUpstreamProducerV1, registryGeneration: string): void { + if (this.disposed || registryGeneration !== this.pluginGeneration) { + throw new Error('Upstream registry generation is stale') + } + if (!ID_PATTERN.test(producer.pluginId) || !isText(producer.pluginGeneration, 256)) { + throw new Error('Invalid upstream producer authority') + } + const currentGeneration = this.producerGenerations.get(producer.pluginId) + if (currentGeneration === producer.pluginGeneration) return + this.producerGenerations.set(producer.pluginId, producer.pluginGeneration) + let removed = false + for (const [upstreamId, entry] of this.entries) { + if (entry.producer.pluginId !== producer.pluginId) continue + this.entries.delete(upstreamId) + removed = true + } + if (removed) { + this.revision += 1 + this.notify() + } + } + + disposeProducer( + producer: CliProxyUpstreamProducerV1, + registryGeneration: string, + ): CliProxyRegistryMutationResultV1 { + const generationFailure = this.generationFailure(registryGeneration, producer) + if (generationFailure !== undefined) return generationFailure + this.producerGenerations.delete(producer.pluginId) + let removed = false + for (const [upstreamId, entry] of this.entries) { + if ( + entry.producer.pluginId !== producer.pluginId + || entry.producer.pluginGeneration !== producer.pluginGeneration + ) continue + this.entries.delete(upstreamId) + removed = true + } + if (!removed) return { status: 'unchanged', registryRevision: this.revision } + this.revision += 1 + this.notify() + return { status: 'revoked', registryRevision: this.revision } + } + + subscribe(listener: (revision: number) => void): () => void { + if (this.disposed) throw new Error('Upstream registry generation is stale') + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + catalog(pluginGeneration: string): CliProxyModelCatalogV1 | CliProxyRegistryDiagnosticV1 { + if (this.disposed || pluginGeneration !== this.pluginGeneration) { + return { code: 'stale-generation', message: 'Catalog generation is stale' } + } + const active = this.activeEntries() + const groups = new Map() + for (const entry of active) { + const descriptor = entry.descriptor + const groupedModels = new Map() + for (const route of descriptor.models) { + if (!route.enabled) continue + groupedModels.set(route.modelId, [...(groupedModels.get(route.modelId) ?? []), route]) + } + const prefix = descriptor.prefix ?? descriptor.upstreamId + const models = [...groupedModels.entries()].sort(([left], [right]) => left.localeCompare(right)).map( + ([modelId, routes]): CliProxyModelCatalogModelV1 => { + const first = routes[0] + return { + modelId, + gatewayModelId: `${prefix}/${modelId}`, + displayName: first.displayName ?? modelId, + sourceModelIds: routes.map(route => route.sourceModelId).sort(), + isDefault: first.isDefault, + inputModalities: normalizedModalities(first.inputModalities), + } + }, + ) + const provider: CliProxyModelCatalogProviderV1 = { + providerId: descriptor.upstreamId, + displayName: descriptor.displayName, + prefix, + wireApi: descriptor.wireApi, + order: descriptor.order, + revision: descriptor.revision, + groupId: descriptor.group.groupId, + requestTimeoutMs: descriptor.requestTimeoutMs, + models, + } + const existing = groups.get(descriptor.group.groupId) + groups.set(descriptor.group.groupId, { + groupId: descriptor.group.groupId, + displayName: descriptor.group.displayName, + order: descriptor.group.order, + providers: [...(existing?.providers ?? []), provider].sort((left, right) => + compareOrderThenId({ order: left.order, id: left.providerId }, { order: right.order, id: right.providerId }) + ), + }) + } + const orderedGroups = [...groups.values()].sort((left, right) => + compareOrderThenId({ order: left.order, id: left.groupId }, { order: right.order, id: right.groupId }) + ) + const base: Omit = { + contract: CLI_PROXY_MODEL_CATALOG_CONTRACT_V1, + schemaVersion: 1, + pluginGeneration: this.pluginGeneration, + registryRevision: this.revision, + upstreamRevisions: Object.fromEntries( + active.map(entry => [entry.descriptor.upstreamId, entry.descriptor.revision]), + ), + groups: orderedGroups, + } + return { ...base, catalogDigest: digest(base) } + } + + gatewayPlan(pluginGeneration: string): CliProxyGatewayPlanV1 | CliProxyRegistryDiagnosticV1 { + const catalog = this.catalog(pluginGeneration) + if ('code' in catalog) return catalog + const active = this.activeEntries() + const responses: CliProxyGatewayPlanV1['configuration']['codex-api-key'][number][] = [] + const chat: CliProxyGatewayPlanV1['configuration']['openai-compatibility'][number][] = [] + const composition: CliProxyGatewayCompositionV1[] = [] + for (const entry of active) { + const descriptor = entry.descriptor + const prefix = descriptor.prefix ?? descriptor.upstreamId + const routes = descriptor.models.filter(route => route.enabled).sort((left, right) => + left.modelId.localeCompare(right.modelId) || left.sourceModelId.localeCompare(right.sourceModelId) + ) + if (descriptor.wireApi === 'responses') { + const index = responses.length + responses.push({ + 'api-key': null, + prefix, + 'base-url': null, + 'request-retry': 0, + models: routes.map(route => ({ + name: route.sourceModelId, + alias: route.modelId, + 'display-name': route.displayName ?? route.modelId, + 'force-mapping': true, + })), + }) + composition.push({ + upstreamId: descriptor.upstreamId, + source: entry.source, + origin: { + slot: `upstream-${descriptor.upstreamId}-origin`, + targetPointer: `/codex-api-key/${index}/base-url`, + }, + authorization: { + slot: `upstream-${descriptor.upstreamId}-authorization`, + targetPointer: `/codex-api-key/${index}/api-key`, + }, + }) + continue + } + const index = chat.length + chat.push({ + name: descriptor.upstreamId, + disabled: false, + prefix, + 'base-url': null, + 'api-key-entries': [{ 'api-key': null }], + 'request-retry': 0, + models: routes.map(route => ({ + name: route.sourceModelId, + alias: route.modelId, + 'display-name': route.displayName ?? route.modelId, + 'force-mapping': true, + ...(route.inputModalities === undefined + ? {} + : { 'input-modalities': normalizedModalities(route.inputModalities) }), + })), + }) + composition.push({ + upstreamId: descriptor.upstreamId, + source: entry.source, + origin: { + slot: `upstream-${descriptor.upstreamId}-origin`, + targetPointer: `/openai-compatibility/${index}/base-url`, + }, + authorization: { + slot: `upstream-${descriptor.upstreamId}-authorization`, + targetPointer: `/openai-compatibility/${index}/api-key-entries/0/api-key`, + }, + }) + } + return { + contract: CLI_PROXY_GATEWAY_PLAN_CONTRACT_V1, + schemaVersion: 1, + pluginGeneration: this.pluginGeneration, + registryRevision: this.revision, + catalogDigest: catalog.catalogDigest, + configuration: { + 'codex-api-key': responses, + 'openai-compatibility': chat, + }, + composition, + } + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.entries.clear() + this.producerGenerations.clear() + this.listeners.clear() + this.revision += 1 + } + + private notify(): void { + for (const listener of this.listeners) listener(this.revision) + } + + private activeEntries(): readonly RegistryEntry[] { + return [...this.entries.values()].filter(entry => entry.descriptor.enabled).sort((left, right) => + compareOrderThenId( + { order: left.descriptor.group.order, id: left.descriptor.group.groupId }, + { order: right.descriptor.group.order, id: right.descriptor.group.groupId }, + ) + || compareOrderThenId( + { order: left.descriptor.order, id: left.descriptor.upstreamId }, + { order: right.descriptor.order, id: right.descriptor.upstreamId }, + ) + ) + } + + private generationFailure( + registryGeneration: string, + producer: CliProxyUpstreamProducerV1, + ): + | { readonly status: 'stale-generation'; readonly diagnostic: CliProxyRegistryDiagnosticV1 } + | undefined + { + if ( + !this.disposed + && registryGeneration === this.pluginGeneration + && this.producerGenerations.get(producer.pluginId) === producer.pluginGeneration + ) return undefined + return { + status: 'stale-generation', + diagnostic: { code: 'stale-generation', message: 'Upstream producer generation is stale' }, + } + } +} + +export function createCliProxyUpstreamRegistrarV1( + registry: CliProxyUpstreamRegistryV1, + producer: CliProxyUpstreamProducerV1, +): CliProxyUpstreamRegistrarV1 { + const authority = Object.freeze({ ...producer }) + registry.activateProducer(authority, registry.pluginGeneration) + return Object.freeze({ + producer: authority, + register: (registration: CliProxyUpstreamRegistrationV1) => + registry.register(registration, registry.pluginGeneration, authority), + revoke: (input: CliProxyUpstreamRevocationV1) => registry.revoke(input, registry.pluginGeneration, authority), + dispose: () => registry.disposeProducer(authority, registry.pluginGeneration), + }) +} diff --git a/src/upstream-subscription-manager-messages.ts b/src/upstream-subscription-manager-messages.ts new file mode 100644 index 0000000..b38261f --- /dev/null +++ b/src/upstream-subscription-manager-messages.ts @@ -0,0 +1,81 @@ +export interface UpstreamSubscriptionManagerMessages { + 'upstream.group.account-subscriptions': undefined + 'upstream.group.cordisx-upstreams': undefined + 'upstream.group.runtime-status': undefined + 'upstream.status.readiness': { readonly readiness: string } + 'upstream.status.health': { readonly health: string } + 'upstream.status.auth-state': { readonly state: string } + 'upstream.status.model-count': { readonly count: number } + 'upstream.status.endpoint-origin': { readonly origin: string } + 'upstream.status.secret-configured': undefined + 'upstream.status.secret-missing': undefined + 'upstream.status.enabled': undefined + 'upstream.status.disabled': undefined + 'upstream.status.default': undefined + 'upstream.status.no-catalog': undefined + 'upstream.status.no-upstreams': undefined + 'upstream.status.service-unavailable': undefined + 'upstream.status.account-active': undefined + 'upstream.status.account-disabled': undefined + 'upstream.status.account-unavailable': undefined + 'upstream.status.account-runtime-only': undefined + 'upstream.status.account-source-file': undefined + 'upstream.status.account-source-memory': undefined + 'upstream.status.account-source-plugin': undefined + 'upstream.status.auth-oauth': undefined + 'upstream.status.auth-api-key': undefined + 'upstream.status.auth-file': undefined + 'upstream.status.auth-plugin-virtual': undefined + 'upstream.status.auth-unknown': undefined + 'upstream.status.oauth-pending': undefined + 'upstream.status.oauth-completed': undefined + 'upstream.status.oauth-cancelled': undefined + 'upstream.status.oauth-error': undefined + 'upstream.status.oauth-unknown': undefined + 'upstream.status.account-controls-unavailable': undefined + 'upstream.status.account-count': { readonly count: number } + 'upstream.status.no-accounts': undefined + 'upstream.status.service-auth': { readonly state: string } + 'upstream.status.ready': undefined + 'upstream.status.degraded': undefined + 'upstream.status.failed': undefined + 'upstream.status.stopped': undefined + 'upstream.status.healthy': undefined + 'upstream.status.warning': undefined + 'upstream.status.critical': undefined + 'upstream.status.missing': undefined + 'upstream.status.authenticating': undefined + 'upstream.status.authenticated': undefined + 'upstream.status.expired': undefined + 'upstream.status.logged-out': undefined + 'upstream.status.unknown': undefined + 'upstream.status.no-accounts-description': undefined + 'upstream.status.no-upstreams-description': undefined + 'upstream.status.configuration-unavailable': undefined + 'upstream.field.revision': undefined + 'upstream.field.generation': undefined + 'upstream.field.sequence': undefined + 'upstream.field.provider': undefined + 'upstream.field.source': undefined + 'upstream.field.auth-type': undefined + 'upstream.field.account': undefined + 'upstream.field.project': undefined + 'upstream.field.note': undefined + 'upstream.field.updated': undefined + 'upstream.field.last-refresh': undefined + 'upstream.field.technical-details': undefined + 'upstream.field.endpoint': undefined + 'upstream.field.credential': undefined + 'upstream.field.models': undefined + 'upstream.action.refresh': undefined + 'upstream.action.open-plugin-configuration': undefined + 'upstream.action.login-service': undefined + 'upstream.action.logout-service': undefined + 'upstream.state.operation-error': undefined + 'upstream.action.oauth-start': { readonly provider: string } + 'upstream.action.oauth-cancel': undefined + 'upstream.action.enable': undefined + 'upstream.action.disable': undefined + 'state.loading': undefined + 'state.error': { readonly message: string } +} diff --git a/src/upstream-subscription-manager-page.css b/src/upstream-subscription-manager-page.css new file mode 100644 index 0000000..75626b5 --- /dev/null +++ b/src/upstream-subscription-manager-page.css @@ -0,0 +1,197 @@ +.cus { + display: block; +} + +.cus-button { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; +} + +.cus-small { + font-size: 12px; + line-height: 18px; +} + +.cus-account-label { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 600; +} + +.cus-layout { + display: grid; + gap: 18px; + max-width: 960px; +} + +.cus-stack { + display: grid; + gap: 10px; +} + +.cus-section { + display: grid; + gap: 10px; +} + +.cus-group-header, +.cus-subsection-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.cus-group-header h2, +.cus-subsection-header h3 { + margin: 0; +} + +.cus-subsection { + display: grid; + gap: 10px; + padding: 12px 0; + border-block-start: 1px solid var(--cx-border); +} + +.cus-subsection:first-child { + border-block-start: 0; + padding-block-start: 0; +} + +.cus-subsection-title { + display: grid; + gap: 2px; + min-width: 0; +} + +.cus-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.cus-inline-status { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.cus-account { + display: grid; + gap: 10px; + padding: 12px; +} + +.cus-account-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.cus-account-identity { + display: grid; + gap: 6px; + min-width: 0; + flex: 1 1 280px; +} + +.cus-account-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cus-provider { + display: grid; + gap: 10px; + padding: 12px; +} + +.cus-account-actions, +.cus-oauth-status { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +.cus-diagnostic-text { + padding: 6px 10px; + background: color-mix(in srgb, var(--cx-danger) 9%, transparent); + border-radius: 6px; +} + +.cus-diagnostic-list { + display: grid; + gap: 6px; + margin-block-start: 8px; +} + +.cus-empty-note { + max-width: 560px; + margin: 0 auto; + text-align: center; +} + +.cus-models { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + list-style: none; + background: var(--cx-border); + border: 1px solid var(--cx-border); + border-radius: 8px; + overflow: hidden; +} + +.cus-model { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--cx-surface); +} + +.cus-model-id { + min-width: 0; + overflow: hidden; + font-family: var(--cx-font-mono, ui-monospace, monospace); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cus-model-name { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (width <= 640px) { + .cus-account-header, + .cus-subsection-header, + .cus-group-header { + flex-direction: column; + align-items: stretch; + } + + .cus-badges, + .cus-account-actions, + .cus-oauth-status { + justify-content: flex-start; + } +} diff --git a/src/upstream-subscription-manager-page.tsx b/src/upstream-subscription-manager-page.tsx new file mode 100644 index 0000000..d3eeeb6 --- /dev/null +++ b/src/upstream-subscription-manager-page.tsx @@ -0,0 +1,937 @@ +import type { Context } from '@deepseek-ai/cordis' +import { useCallback, useEffect, useMemo, useRef, useState } from 'cordisx/react' +import { Button, Card, Disclosure, EmptyState, FieldList, Heading, Icon, Stack, StatusBadge, Text } from 'cordisx/ui' +import type { FieldListItem } from 'cordisx/ui' +import type { CordisXReactPageProps } from 'cordisx/contracts' +import type { UpstreamSubscriptionManagerMessages } from './upstream-subscription-manager-messages.js' +import type { + ManagedServiceCliProxyAccountsV1, + ManagedServiceCliProxyAccountV1, + ManagedServiceCliProxyCatalogV1, + ManagedServiceCliProxyModelMappingV1, + ManagedServiceCliProxyOAuthSessionStateV1, + ManagedServiceCliProxyProviderIdV1, + ManagedServiceCliProxyProviderV1, + ManagedServiceProjectionV1, + ManagedServiceSubscriptionV1, + ManagedServiceV1, +} from '@cordisx/protocol/managed-service-ui/v1' +import { requestOwnPluginConfiguration } from './manager-configuration.js' +import './upstream-subscription-manager-page.css' + +const SERVICE_ID = 'gateway-runtime' + +type AccountOAuthState = { + readonly provider: ManagedServiceCliProxyProviderIdV1 + readonly sessionId: string + readonly state: ManagedServiceCliProxyOAuthSessionStateV1 + readonly errorMessage?: string +} + +interface AccountToggleState { + readonly accountId: string + readonly disabled: boolean +} + +type PageProps = CordisXReactPageProps + +function readinessVariant(r: string): 'success' | 'warning' | 'danger' | 'neutral' { + if (r === 'ready') return 'success' + if (r === 'degraded') return 'warning' + if (r === 'failed' || r === 'stopped') return 'danger' + return 'neutral' +} + +function healthVariant(h: string): 'success' | 'warning' | 'danger' | 'neutral' { + if (h === 'healthy') return 'success' + if (h === 'warning') return 'warning' + if (h === 'critical') return 'danger' + return 'neutral' +} + +function authVariant(a: string): 'success' | 'warning' | 'danger' | 'neutral' { + if (a === 'authenticated' || a === 'completed' || a === 'active') return 'success' + if (a === 'authenticating' || a === 'pending' || a === 'wait' || a === 'warning' || a === 'disabled') return 'warning' + if (a === 'expired' || a === 'failed' || a === 'error' || a === 'critical' || a === 'unavailable') return 'danger' + return 'neutral' +} + +function stateLabel(t: PageProps['t'], state: string): string { + switch (state) { + case 'ready': + return t('upstream.status.ready') + case 'degraded': + return t('upstream.status.degraded') + case 'failed': + return t('upstream.status.failed') + case 'stopped': + return t('upstream.status.stopped') + case 'healthy': + return t('upstream.status.healthy') + case 'warning': + return t('upstream.status.warning') + case 'critical': + return t('upstream.status.critical') + case 'missing': + return t('upstream.status.missing') + case 'authenticating': + return t('upstream.status.authenticating') + case 'authenticated': + return t('upstream.status.authenticated') + case 'expired': + return t('upstream.status.expired') + case 'logged-out': + return t('upstream.status.logged-out') + default: + return t('upstream.status.unknown') + } +} + +function formatDateTime(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function authKindLabel(t: PageProps['t'], kind: ManagedServiceCliProxyAccountV1['authKind']): string { + switch (kind) { + case 'oauth': + return t('upstream.status.auth-oauth') + case 'api-key': + return t('upstream.status.auth-api-key') + case 'file': + return t('upstream.status.auth-file') + case 'plugin-virtual': + return t('upstream.status.auth-plugin-virtual') + default: + return t('upstream.status.auth-unknown') + } +} + +function sourceLabel(t: PageProps['t'], source: ManagedServiceCliProxyAccountV1['sourceKind']): string { + switch (source) { + case 'file': + return t('upstream.status.account-source-file') + case 'memory': + return t('upstream.status.account-source-memory') + case 'plugin': + return t('upstream.status.account-source-plugin') + } +} + +function oauthStateLabel(t: PageProps['t'], state: ManagedServiceCliProxyOAuthSessionStateV1): string { + switch (state) { + case 'pending': + case 'wait': + return t('upstream.status.oauth-pending') + case 'completed': + return t('upstream.status.oauth-completed') + case 'cancelled': + return t('upstream.status.oauth-cancelled') + case 'error': + return t('upstream.status.oauth-error') + default: + return t('upstream.status.oauth-unknown') + } +} + +function requestId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function gesture() { + return { kind: 'explicit-click' as const, at: new Date().toISOString() } +} + +function groupHeader(title: string) { + return ( +
+ {title} +
+ ) +} + +function field(id: string, label: string, value: string | number | undefined): FieldListItem | undefined { + if (value === undefined || value === '') return undefined + return { id, label, value } +} + +function fields(...items: readonly (FieldListItem | undefined)[]): readonly FieldListItem[] { + return items.filter((item): item is FieldListItem => item !== undefined) +} + +export function createUpstreamSubscriptionManagerPage( + managedServices: Context['managedServices'] | undefined, + manager: Context['manager'] | undefined, + notifications: Context['notifications'], +) { + return function UpstreamSubscriptionManagerPage(props: PageProps) { + const [service, setService] = useState(null) + const [snapshot, setSnapshot] = useState(null) + const [catalog, setCatalog] = useState(null) + const [accounts, setAccounts] = useState(null) + const [accountAvailability, setAccountAvailability] = useState<'loading' | 'available' | 'unavailable'>('loading') + const [loading, setLoading] = useState(true) + const [serviceLoggingIn, setServiceLoggingIn] = useState(false) + const [serviceLoggingOut, setServiceLoggingOut] = useState(false) + const [oauthState, setOauthState] = useState(null) + const [pendingToggle, setPendingToggle] = useState(null) + const [pendingOAuthProvider, setPendingOAuthProvider] = useState(null) + const [pendingCancelOAuth, setPendingCancelOAuth] = useState(false) + const [configurationOpening, setConfigurationOpening] = useState(false) + const [configurationUnavailable, setConfigurationUnavailable] = useState(manager === undefined) + const subscriptionRef = useRef(null) + const oauthPollRef = useRef(null) + const oauthPollGenerationRef = useRef(0) + const oauthStateRef = useRef(null) + const serviceRef = useRef(null) + + const accountControl = service?.accountControl + + const showError = useCallback(() => { + notifications.show({ + kind: 'external-account.operation-failed', + type: 'error', + message: props.t('upstream.state.operation-error'), + }) + }, [notifications, props.t]) + + const clearOauthPoll = useCallback(() => { + oauthPollGenerationRef.current += 1 + if (oauthPollRef.current !== null) { + window.clearTimeout(oauthPollRef.current) + oauthPollRef.current = null + } + }, []) + + const setOauthStateValue = useCallback((value: AccountOAuthState | null) => { + oauthStateRef.current = value + setOauthState(value) + }, []) + + const refresh = useCallback(async (target?: ManagedServiceV1 | null, notifyFailure = true) => { + const current = target ?? serviceRef.current + if (current === null) { + if (notifyFailure) showError() + setLoading(false) + return + } + setLoading(prev => prev && snapshot === null && catalog === null && accounts === null) + try { + const [snapResult, catalogResult, accountsResult] = await Promise.all([ + current.snapshot(), + current.readCatalog?.() ?? Promise.resolve(undefined), + current.accountControl?.readAccounts() ?? Promise.resolve(undefined), + ]) + + if (snapResult.status === 'available') { + setSnapshot(snapResult.projection) + } else if (notifyFailure) { + showError() + } + + if (catalogResult?.status === 'available') { + setCatalog(catalogResult.catalog) + } else if (catalogResult !== undefined) { + setCatalog(null) + } + + if (accountsResult?.status === 'available') { + setAccounts(accountsResult.accounts) + setAccountAvailability('available') + } else if (accountsResult !== undefined) { + setAccounts(null) + setAccountAvailability('unavailable') + } + } catch { + if (notifyFailure) showError() + } finally { + setLoading(false) + } + }, [showError]) + + const pollOAuthSession = useCallback( + (target: ManagedServiceV1, sessionId: string, provider: ManagedServiceCliProxyProviderIdV1) => { + clearOauthPoll() + const generation = oauthPollGenerationRef.current + const poll = async () => { + if (generation !== oauthPollGenerationRef.current || target.accountControl === undefined) return + try { + const status = await target.accountControl.pollOAuth(sessionId) + if (generation !== oauthPollGenerationRef.current) return + const next: AccountOAuthState = { + provider, + sessionId, + state: status.state, + errorMessage: status.error?.message, + } + setOauthStateValue(next) + if (status.state === 'pending' || status.state === 'wait' || status.state === 'unknown') { + oauthPollRef.current = window.setTimeout(() => { + void poll() + }, 1200) + return + } + if (status.state === 'completed') { + void refresh(target) + } + } catch { + if (generation === oauthPollGenerationRef.current) { + setOauthStateValue({ provider, sessionId, state: 'error' }) + showError() + } + } + } + oauthPollRef.current = window.setTimeout(() => { + void poll() + }, 500) + }, + [clearOauthPoll, refresh, setOauthStateValue, showError], + ) + + const subscribe = useCallback(async (target: ManagedServiceV1) => { + const subResult = await target.subscribe() + if (subResult.status !== 'subscribed') return + subscriptionRef.current?.unsubscribe().catch(() => {}) + subscriptionRef.current = subResult.subscription + const consumer = async () => { + for await (const page of subResult.subscription.pages) { + for (const update of page.updates) { + if (update.kind === 'snapshot-replaced') { + setSnapshot(update.projection) + } else if (update.kind === 'state-changed') { + setSnapshot(prev => { + if (prev === null) return null + return { ...prev, sequence: update.sequence, ...update.delta } as ManagedServiceProjectionV1 + }) + } + } + await refresh(target, false) + } + } + void consumer().catch(() => { + if (subscriptionRef.current === subResult.subscription) { + showError() + } + }) + }, [refresh, showError]) + + useEffect(() => { + let disposed = false + void (async () => { + if (managedServices === undefined) { + showError() + setLoading(false) + return + } + const acquired = await managedServices.get({ serviceId: SERVICE_ID }) + if (disposed) return + if (acquired.status !== 'available') { + showError() + setLoading(false) + return + } + serviceRef.current = acquired.service + setService(acquired.service) + await refresh(acquired.service) + await subscribe(acquired.service) + })().catch(() => { + showError() + setLoading(false) + }) + const refreshTimer = window.setInterval(() => { + if (!disposed && serviceRef.current !== null) void refresh(serviceRef.current, false) + }, 15_000) + return () => { + window.clearInterval(refreshTimer) + disposed = true + clearOauthPoll() + const subscription = subscriptionRef.current + subscriptionRef.current = null + subscription?.unsubscribe().catch(() => {}) + } + }, [clearOauthPoll, managedServices, props.t, refresh, showError, subscribe]) + + const loginService = useCallback(async () => { + if (service === null || snapshot === null) return + setServiceLoggingIn(true) + try { + const result = await service.authenticate({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-login-request.v1.schema.json' as const, + contract: 'cordisx.managed-service-login-request/v1' as const, + schemaVersion: 1 as const, + requestId: requestId('svc-login'), + binding: service.binding, + action: 'login' as const, + expectedSequence: snapshot.sequence, + userGesture: gesture(), + }) + if (result.status === 'accepted') { + await refresh(service) + } else { + showError() + } + } catch { + showError() + } finally { + setServiceLoggingIn(false) + } + }, [refresh, service, showError, snapshot]) + + const logoutService = useCallback(async () => { + if (service === null || snapshot === null) return + setServiceLoggingOut(true) + try { + const result = await service.logout({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-logout-request.v1.schema.json' as const, + contract: 'cordisx.managed-service-logout-request/v1' as const, + schemaVersion: 1 as const, + requestId: requestId('svc-logout'), + binding: service.binding, + action: 'logout' as const, + expectedSequence: snapshot.sequence, + userGesture: gesture(), + }) + if (result.status === 'accepted') { + clearOauthPoll() + setOauthStateValue(null) + setPendingOAuthProvider(null) + setPendingCancelOAuth(false) + setPendingToggle(null) + await refresh(service) + } else { + showError() + } + } catch { + showError() + } finally { + setServiceLoggingOut(false) + } + }, [clearOauthPoll, refresh, service, setOauthStateValue, showError, snapshot]) + + const startOAuth = useCallback(async (provider: ManagedServiceCliProxyProviderIdV1) => { + if (service === null || accountControl === undefined || accounts === null) return + setPendingOAuthProvider(provider) + try { + const result = await accountControl.startOAuth({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-start.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-start/v1' as const, + schemaVersion: 1 as const, + requestId: requestId('oauth-start'), + binding: service.binding, + provider, + expectedRevision: accounts.revision, + userGesture: gesture(), + }) + if (result.status === 'accepted') { + window.open(result.authorizationUrl, '_blank', 'noopener,noreferrer') + setOauthStateValue({ provider, sessionId: result.sessionId, state: 'pending' }) + pollOAuthSession(service, result.sessionId, provider) + await refresh(service) + } else { + showError() + } + } catch { + showError() + } finally { + setPendingOAuthProvider(null) + } + }, [accountControl, accounts, pollOAuthSession, refresh, service, setOauthStateValue, showError]) + + const cancelOAuth = useCallback(async () => { + const current = oauthStateRef.current + if (service === null || accountControl === undefined || accounts === null || current === null) return + setPendingCancelOAuth(true) + try { + const result = await accountControl.cancelOAuth({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-cancel.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-oauth-cancel/v1' as const, + schemaVersion: 1 as const, + requestId: requestId('oauth-cancel'), + binding: service.binding, + sessionId: current.sessionId, + expectedRevision: accounts.revision, + userGesture: gesture(), + }) + if (result.status === 'accepted') { + setOauthStateValue({ ...current, state: 'cancelled' }) + clearOauthPoll() + await refresh(service) + } else { + showError() + } + } catch { + showError() + } finally { + setPendingCancelOAuth(false) + } + }, [accountControl, accounts, clearOauthPoll, refresh, service, setOauthStateValue, showError]) + + const toggleAccount = useCallback(async (accountId: string, disabled: boolean) => { + if (service === null || accountControl === undefined || accounts === null) return + setPendingToggle({ accountId, disabled }) + try { + const result = await accountControl.toggleAccount({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-account-toggle.v1.schema.json' as const, + contract: 'cordisx.managed-service-cli-proxy-account-toggle/v1' as const, + schemaVersion: 1 as const, + requestId: requestId('account-toggle'), + binding: service.binding, + accountId, + disabled, + expectedRevision: accounts.revision, + userGesture: gesture(), + }) + if (result.status === 'accepted') { + await refresh(service) + } else { + showError() + } + } catch { + showError() + } finally { + setPendingToggle(null) + } + }, [accountControl, accounts, refresh, service, showError]) + + const openPluginConfiguration = useCallback(async () => { + setConfigurationOpening(true) + setConfigurationUnavailable(false) + const outcome = await requestOwnPluginConfiguration(manager) + if (outcome.status === 'unavailable') setConfigurationUnavailable(true) + if (outcome.status === 'failed') showError() + setConfigurationOpening(false) + }, [manager, showError]) + + const configurationAction = manager === undefined + ? undefined + : ( + + ) + + const configurationNotice = configurationUnavailable + ? ( + + {props.t('upstream.status.configuration-unavailable')} + + ) + : null + + const accountsByProvider = useMemo(() => { + const map = new Map() + for (const account of accounts?.accounts ?? []) { + const list = map.get(account.provider) ?? [] + list.push(account) + map.set(account.provider, list) + } + return map + }, [accounts]) + + const renderModel = (model: ManagedServiceCliProxyModelMappingV1) => ( +
  • + {model.modelId} + {model.displayName} + {model.isDefault && {props.t('upstream.status.default')}} + {!model.enabled && {props.t('upstream.status.disabled')}} +
  • + ) + + const renderAccountRow = (account: ManagedServiceCliProxyAccountV1) => { + const isToggling = pendingToggle?.accountId === account.accountId + const isOauthStarting = pendingOAuthProvider === account.provider + const showOAuthForAccount = oauthState?.provider === account.provider + && (account.authKind === 'oauth' || account.status === 'unavailable') + const canLogin = accountControl !== undefined && account.authKind === 'oauth' && account.status !== 'active' + const accountLabel = account.email ?? account.account ?? account.label + const accountStatusLabel = account.disabled + ? props.t('upstream.status.account-disabled') + : account.unavailable + ? props.t('upstream.status.account-unavailable') + : props.t('upstream.status.account-active') + + return ( + +
    +
    +
    + {accountLabel} + {accountStatusLabel} + {authKindLabel(props.t, account.authKind)} + {account.runtimeOnly + ? ( + + {props.t('upstream.status.account-runtime-only')} + + ) + : null} +
    + + {account.statusMessage !== undefined && account.statusMessage !== '' + ? {account.statusMessage} + : null} + {showOAuthForAccount + ? ( +
    + + {oauthStateLabel(props.t, oauthState.state)} + + {oauthState.errorMessage !== undefined + ? {oauthState.errorMessage} + : null} + {(oauthState.state === 'pending' || oauthState.state === 'wait') + ? ( + + ) + : null} +
    + ) + : null} +
    +
    + {canLogin + ? ( + + ) + : null} + +
    +
    + {account.models !== undefined && account.models.length > 0 + ? ( +
      + {account.models.map(model => ( +
    • + {model.id} + {model.displayName ?? model.id} + {model.ownedBy !== undefined + ? {model.ownedBy} + : null} +
    • + ))} +
    + ) + : null} +
    + ) + } + + const renderAccountSubscriptions = () => { + if (service === null) { + return ( +
    + {groupHeader(props.t('upstream.group.account-subscriptions'))} + + {configurationNotice} +
    + ) + } + if (accountControl === undefined) { + return ( +
    + {groupHeader(props.t('upstream.group.account-subscriptions'))} + + {configurationNotice} +
    + ) + } + if (accounts === null) { + return ( +
    + {groupHeader(props.t('upstream.group.account-subscriptions'))} + + {accountAvailability === 'loading' ? null : configurationNotice} +
    + ) + } + if (accounts.accounts.length === 0) { + return ( +
    + {groupHeader(props.t('upstream.group.account-subscriptions'))} + + {configurationNotice} +
    + ) + } + + const providers = [...accountsByProvider.keys()].sort() + return ( +
    + {groupHeader(props.t('upstream.group.account-subscriptions'))} +
    + {providers.map(provider => ( +
    +
    + {provider} + + {props.t('upstream.status.account-count', { count: accountsByProvider.get(provider)?.length ?? 0 })} + +
    +
    + {(accountsByProvider.get(provider) ?? []).map(renderAccountRow)} +
    +
    + ))} +
    +
    + ) + } + + const renderProvider = (provider: ManagedServiceCliProxyProviderV1) => { + const linkedAccounts = accountsByProvider.get(provider.id) ?? [] + return ( + +
    +
    + {provider.displayName} + {provider.id} +
    +
    + + {provider.enabled ? props.t('upstream.status.enabled') : props.t('upstream.status.disabled')} + + + {props.t('upstream.status.health', { health: stateLabel(props.t, provider.health) })} + + + {props.t('upstream.status.auth-state', { state: stateLabel(props.t, provider.auth) })} + +
    +
    + + {provider.lastError !== undefined + ? ( + + [{provider.lastError.code}] {provider.lastError.message} + + ) + : null} + {provider.models.mappings.length > 0 + ? ( +
      + {provider.models.mappings.map(renderModel)} +
    + ) + : null} +
    + ) + } + + const renderCordisXUpstreams = () => ( +
    + {groupHeader(props.t('upstream.group.cordisx-upstreams'))} + {catalog === null + ? + : catalog.providers.length === 0 + ? ( + + ) + : ( +
    + {catalog.providers.map(renderProvider)} +
    + )} +
    + ) + + const renderRuntimeStatus = () => { + if (snapshot === null) return null + return ( +
    + {groupHeader(props.t('upstream.group.runtime-status'))} +
    +
    + + {props.t('upstream.status.service-auth', { state: stateLabel(props.t, snapshot.auth.state) })} + + {snapshot.auth.providerLabel !== undefined + ? {snapshot.auth.providerLabel} + : null} + {snapshot.capabilities.explicitLogin && snapshot.userAction.available + ? ( + + ) + : null} + {snapshot.capabilities.logout && snapshot.auth.state !== 'logged-out' + ? ( + + ) + : null} +
    +
    +
    + {snapshot.displayName} + {snapshot.serviceKind} +
    +
    + + {props.t('upstream.status.readiness', { readiness: stateLabel(props.t, snapshot.readiness) })} + + + {props.t('upstream.status.health', { health: stateLabel(props.t, snapshot.health.level) })} + +
    +
    + 0 ? 'warning' : 'neutral'} + > + + {snapshot.diagnostics.length > 0 + ? ( +
    + {snapshot.diagnostics.map((diagnostic, index) => ( + + [{diagnostic.code}] {diagnostic.message} + + ))} +
    + ) + : null} +
    +
    +
    + ) + } + + const renderBody = () => { + if (loading && snapshot === null) return + if (!loading && service === null) { + return ( + + ) + } + return ( +
    + + + + {renderAccountSubscriptions()} + {renderCordisXUpstreams()} + {renderRuntimeStatus()} +
    + ) + } + + return ( +
    + {renderBody()} +
    + ) + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/artifact.mjs b/test/artifact.mjs index 946bdb6..ec0d8e0 100644 --- a/test/artifact.mjs +++ b/test/artifact.mjs @@ -8,6 +8,7 @@ test('production graph retains the plugin-owned stylesheet', async () => { assert.equal(artifact.entry, './module.js') assert.equal(artifact.initialStyles.length, 1) assert.deepEqual(artifact.sharedImports, [ + '@cordisx/protocol/managed-service-ui/v1', 'cordisx/contracts', 'cordisx/react', 'cordisx/react/jsx-runtime', @@ -15,7 +16,7 @@ test('production graph retains the plugin-owned stylesheet', async () => { ]) const stylesheet = await readFile(new URL(`../dist/runtime/${artifact.initialStyles[0]}`, import.meta.url), 'utf8') - assert.match(stylesheet, /\.cxp-fleet/) - assert.match(stylesheet, /\.cxp-toolbar-action/) + assert.match(stylesheet, /\.cus\{/) + assert.match(stylesheet, /\.cus-button\{/) assert.doesNotMatch(stylesheet, /\.cxr-|\.cxm-/) }) diff --git a/test/gateway.mjs b/test/gateway.mjs new file mode 100644 index 0000000..6961467 --- /dev/null +++ b/test/gateway.mjs @@ -0,0 +1,989 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import Ajv2020 from 'ajv/dist/2020.js' +import { binding, expectedNativeCatalog, identity, lease, projection, uiBinding } from './support/gateway-fixture.mjs' + +const gatewayModule = await import('../dist/gateway.mjs') +const { + CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1, + CLI_PROXY_GATEWAY_MANAGEMENT_V1, + CLI_PROXY_NATIVE_PROVIDER_ID, + CLI_PROXY_GATEWAY_SERVICE_ID, + activateCliProxyGateway, + cliProxyGatewayDefinition, + contextServices, + managedServiceUI, +} = gatewayModule + +test('declares a separate Host-private CLIProxyAPI management credential', () => { + assert.equal(cliProxyGatewayDefinition.serviceId, CLI_PROXY_GATEWAY_SERVICE_ID) + assert.deepEqual(CLI_PROXY_GATEWAY_MANAGEMENT_V1, { + credentialSlot: 'management-key', + path: '/v0/management', + }) + assert.deepEqual( + cliProxyGatewayDefinition.protectedBindings.find(binding => binding.slot === 'management-key'), + { + slot: 'management-key', + source: 'generated-local-key', + target: 'configuration', + pointer: '/remote-management/secret-key', + }, + ) + assert.notEqual( + CLI_PROXY_GATEWAY_MANAGEMENT_V1.credentialSlot, + cliProxyGatewayDefinition.httpAuthentication.slot, + ) +}) + +const compositionSchemaFiles = new Map([ + ['codex-upstreams', '../schemas/cli-proxy-gateway-codex-upstream.v1.schema.json'], + ['openai-upstreams', '../schemas/cli-proxy-gateway-openai-upstream.v1.schema.json'], +]) +const compositionValidators = new Map( + await Promise.all([...compositionSchemaFiles].map(async ([slot, file]) => { + const schema = JSON.parse(await readFile(new URL(file, import.meta.url), 'utf8')) + return [slot, new Ajv2020({ allErrors: true, strict: true }).compile(schema)] + })), +) + +const syntheticSources = Object.freeze({ + responses: Object.freeze({ + pluginId: 'synthetic-responses', + pluginGeneration: 'synthetic-responses-one', + serviceId: 'synthetic-responses-service', + serviceGeneration: 'synthetic-responses-service-one', + upstreamId: 'responses-source', + displayName: 'Synthetic Responses', + prefix: 'responses', + wireApi: 'responses', + sourceModelId: 'responses-model', + modelId: 'family/fast', + authenticated: false, + }), + chat: Object.freeze({ + pluginId: 'synthetic-chat', + pluginGeneration: 'synthetic-chat-one', + serviceId: 'synthetic-chat-service', + serviceGeneration: 'synthetic-chat-service-one', + upstreamId: 'chat-source', + displayName: 'Synthetic Chat', + prefix: 'chat', + wireApi: 'chat-completions', + sourceModelId: 'chat-model', + modelId: 'family/balanced', + authenticated: true, + }), +}) + +const descriptor = (source, overrides = {}) => ({ + $schema: CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + contract: CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + schemaVersion: 1, + revision: 1, + upstreamId: source.upstreamId, + displayName: source.displayName, + enabled: true, + wireApi: source.wireApi, + prefix: source.prefix, + order: source.wireApi === 'responses' ? 10 : 20, + requestTimeoutMs: 30_000, + group: { groupId: 'synthetic', displayName: 'Synthetic', order: 10 }, + models: [{ + sourceModelId: source.sourceModelId, + modelId: source.modelId, + enabled: true, + isDefault: source.wireApi === 'responses', + ...(source.wireApi === 'chat-completions' ? { inputModalities: ['text'] } : {}), + }], + ...overrides, +}) + +function providerRoot() { + const controller = new AbortController() + const target = { + pluginId: 'cli-proxy-api', + pluginGeneration: 'gateway-generation', + serviceId: 'gateway-runtime', + serviceGeneration: 'gateway-service-generation', + } + return { + controller, + target, + root: contextServices[0].create({ target, signal: controller.signal }), + } +} + +function validateMaterializationRequest(request) { + const compositionSlots = cliProxyGatewayDefinition.protectedBindings.filter(item => item.source === 'composition') + const declaredSources = new Set(request.sources.map(source => source.source)) + for (const slot of compositionSlots) { + const slotBindings = request.bindings.filter(binding => binding.targetSlot === slot.slot) + assert.ok(slotBindings.length > 0, `missing composition slot ${slot.slot}`) + const roots = slotBindings.filter(binding => binding.targetPointer === undefined) + assert.equal(roots.length, 1, `composition slot ${slot.slot} must have exactly one root binding`) + assert.equal(roots[0].source.kind, 'safe-literal') + const validate = compositionValidators.get(slot.slot) + assert.equal(validate(roots[0].source.value), true, JSON.stringify(validate.errors)) + } + for (const item of request.bindings) { + if (item.source.kind === 'source-origin' || item.source.kind === 'source-authorization') { + assert.equal(declaredSources.has(item.source.source), true, `undeclared composition source ${item.source.source}`) + } + } +} + +function activationInput(client, signal) { + return { + owner: { + ownerHandle: 'mso_gateway', + pluginId: 'cli-proxy-api', + sourceDigest: `sha256:${'a'.repeat(64)}`, + hostGeneration: 'host-one', + pluginGeneration: 'gateway-generation', + }, + client, + signal, + } +} + +function gatewayFixture({ + kinds = ['responses', 'chat'], + gatewayModels = [], + toggleGatewayModels, + oauthGatewayModels, + oauthPollStatuses = ['wait'], + missingSourceModel = false, + missingGatewayModel = false, + gatewayDisposeError, + clientDisposeError, + publicationFailure, + publicationDisposeError, + publicationDisposeDiagnostic, +} = {}) { + const { root, target } = providerRoot() + const sources = [] + let ownedGatewayModels = [...gatewayModels] + let oauthPollIndex = 0 + const sourceLeases = new Map() + const sourceByService = new Map() + const registerSource = source => { + sources.push(source) + sourceLeases.set(source.serviceId, lease(source.pluginId, source.serviceId, source.serviceGeneration)) + sourceByService.set(source.serviceId, source) + const consumer = root.bind({ + producer: { pluginId: source.pluginId, pluginGeneration: source.pluginGeneration }, + target, + signal: new AbortController().signal, + }) + const result = consumer.value.register({ + source: identity(source.pluginId, source.serviceId), + descriptor: descriptor(source), + }) + assert.equal(result.status, 'registered') + } + for (const kind of kinds) registerSource(syntheticSources[kind]) + + const events = [] + const released = [] + const activePublications = new Map() + const publicationHandles = [] + const publicationDisposeResults = [] + const lifecycle = { cleanup: undefined } + const gatewayBinding = binding('cli-proxy-api', 'gateway-runtime', 'gateway-service') + const gatewayUIBinding = uiBinding('cli-proxy-api', 'gateway-runtime', 'host-one') + const registration = { + binding: gatewayBinding, + revision: undefined, + async inspect() { + events.push(['inspect']) + return projection(gatewayBinding) + }, + async ensureReady(options) { + events.push(['ensure-ready', options]) + return { status: 'ready', projection: projection(gatewayBinding) } + }, + async dispose() { + events.push(['registration-dispose']) + if (gatewayDisposeError !== undefined) throw gatewayDisposeError + return { status: 'accepted', projection: projection(gatewayBinding) } + }, + async publishNativeProvider(input, options) { + events.push(['publish-native', input, options]) + if (publicationFailure?.providerId === input.providerId) { + return { + status: 'rejected', + diagnostic: { code: publicationFailure.code, retryable: publicationFailure.code === 'failed' }, + } + } + const activeProjection = { + providerId: input.providerId, + owner: { + pluginId: 'cli-proxy-api', + hostGeneration: 'host-one', + pluginGeneration: 'gateway-generation', + }, + service: { serviceId: 'gateway-runtime', serviceGeneration: 'gateway-service' }, + compositionOrigin: input.compositionOrigin, + catalog: structuredClone(input.catalog), + state: 'active', + } + const handle = { + publication: activeProjection, + async dispose() { + events.push(['publication-dispose', input.providerId]) + if (publicationDisposeError?.providerId === input.providerId) throw publicationDisposeError.error + let result + if (publicationDisposeDiagnostic?.providerId === input.providerId) { + result = { + status: 'stale', + diagnostic: { code: publicationDisposeDiagnostic.code, retryable: false }, + } + } else { + const active = activePublications.get(input.providerId) + if (active === undefined) { + result = { status: 'stale', diagnostic: { code: 'disposed', retryable: false } } + } else if (active !== handle) { + result = { status: 'stale', diagnostic: { code: 'stale-generation', retryable: false } } + } else { + activePublications.delete(input.providerId) + result = { status: 'disposed', projection: { ...activeProjection, state: 'revoked' } } + } + } + publicationDisposeResults.push([input.providerId, result]) + return result + }, + } + activePublications.set(input.providerId, handle) + publicationHandles.push(handle) + return { status: 'accepted', publication: handle } + }, + } + const client = { + async acquire(sourceIdentity) { + events.push(['acquire', sourceIdentity.serviceId]) + if (sourceIdentity.serviceId === 'gateway-runtime') { + const gateway = lease('cli-proxy-api', 'gateway-runtime', 'gateway-service', ['gateway.models.list']) + return { status: 'ready', projection: projection(gatewayBinding), lease: gateway } + } + const source = sourceByService.get(sourceIdentity.serviceId) + const sourceLease = sourceLeases.get(sourceIdentity.serviceId) + return { + status: 'ready', + projection: projection(sourceLease.binding, source.authenticated), + lease: sourceLease, + } + }, + async invoke(activeLease, operationId, value, options) { + events.push(['invoke', activeLease.binding.identity.serviceId, operationId, value, options]) + if (operationId === 'gateway.models.list') { + const models = [ + ...new Set([ + ...sources.map(source => `${source.prefix}/${source.modelId}`), + ...ownedGatewayModels, + ]), + ].map(id => ({ id })) + return { + status: 'accepted', + invocationHandle: 'msi_gateway', + operationId, + responseSchema: 'https://schemas.example.test/models.json', + value: { data: missingGatewayModel ? models.slice(0, -1) : models }, + } + } + if (operationId === 'gateway.management.accounts.list') { + return { + status: 'accepted', + invocationHandle: 'msi_accounts', + operationId, + responseSchema: 'https://schemas.example.test/accounts.json', + value: { + files: [{ + id: 'account-one', + auth_index: 'auth-index-one', + provider: 'codex', + email: 'user@example.test', + status: 'active', + disabled: false, + unavailable: false, + runtime_only: false, + source: 'file', + status_message: 'must-not-leak-status', + access_token: 'must-not-leak', + api_key: 'must-not-leak-either', + }], + }, + } + } + if (operationId === 'gateway.management.accounts.toggle') { + if (toggleGatewayModels !== undefined) ownedGatewayModels = [...toggleGatewayModels] + return { + status: 'accepted', + invocationHandle: 'msi_account_toggle', + operationId, + responseSchema: 'https://schemas.example.test/operation.json', + value: { status: 'ok', disabled: value.disabled }, + } + } + if (operationId === 'gateway.management.oauth.codex.start') { + return { + status: 'accepted', + invocationHandle: 'msi_oauth_start', + operationId, + responseSchema: 'https://schemas.example.test/operation.json', + value: { status: 'ok', url: 'https://login.example.test/oauth', state: 'oauth-state-one' }, + } + } + if (operationId === 'gateway.management.oauth.poll') { + const status = oauthPollStatuses[Math.min(oauthPollIndex, oauthPollStatuses.length - 1)] + oauthPollIndex += 1 + if (status === 'ok' && oauthGatewayModels !== undefined) ownedGatewayModels = [...oauthGatewayModels] + return { + status: 'accepted', + invocationHandle: 'msi_oauth_poll', + operationId, + responseSchema: 'https://schemas.example.test/operation.json', + value: { status, ...(status === 'error' ? { error: 'must-not-leak-oauth-error' } : {}) }, + } + } + if (operationId === 'gateway.management.oauth.cancel') { + return { + status: 'accepted', + invocationHandle: 'msi_oauth_cancel', + operationId, + responseSchema: 'https://schemas.example.test/operation.json', + value: { status: 'ok', cancelled: true }, + } + } + const source = sourceByService.get(activeLease.binding.identity.serviceId) + return { + status: 'accepted', + invocationHandle: `msi_${activeLease.binding.identity.serviceId}`, + operationId, + responseSchema: 'https://schemas.example.test/models.json', + value: { data: [{ id: missingSourceModel ? 'other-model' : source.sourceModelId }] }, + } + }, + async materialize(request) { + events.push(['materialize', request]) + validateMaterializationRequest(request) + return { + status: 'accepted', + materializationHandle: 'msm_gateway', + target: gatewayBinding, + revision: request.revision, + sources: request.sources.map(source => ({ source: source.source, binding: source.lease.binding })), + } + }, + release(activeLease) { + released.push(activeLease.leaseHandle) + events.push(['release', activeLease.binding.identity.serviceId]) + }, + dispose() { + events.push(['client-dispose']) + if (clientDisposeError !== undefined) throw clientDisposeError + }, + } + const context = { + effect(execute) { + lifecycle.cleanup = execute() + events.push(['effect']) + return lifecycle.cleanup + }, + managedServices: { + async register(definition, options) { + events.push(['register', definition, options]) + registration.revision = options.revision + return registration + }, + }, + cliProxyUpstreams: root.providerValue, + } + const hostRevokePublication = providerId => { + activePublications.delete(providerId) + events.push(['host-revoke-publication', providerId]) + } + const hostReplacePublication = providerId => { + const replacement = Object.freeze({ providerId, generation: 'host-replacement' }) + activePublications.set(providerId, replacement) + events.push(['host-replace-publication', providerId]) + return replacement + } + return { + activePublications, + client, + context, + events, + hostReplacePublication, + hostRevokePublication, + lifecycle, + publicationDisposeResults, + publicationHandles, + registerSource, + registration, + released, + sources, + gatewayBinding, + gatewayUIBinding, + } +} + +function rootCollections(request) { + return Object.fromEntries( + request.bindings.filter(item => item.targetPointer === undefined).map(item => [ + item.targetSlot, + item.source.value, + ]), + ) +} + +function compositionLeaves(request) { + return request.bindings.filter(item => item.targetPointer !== undefined) +} + +function expectedRootCollections(sources) { + return { + 'codex-upstreams': sources.filter(source => source.wireApi === 'responses').map(source => ({ + 'api-key': null, + prefix: source.prefix, + 'base-url': null, + 'request-retry': 0, + models: [{ + name: source.sourceModelId, + alias: source.modelId, + 'display-name': source.modelId, + 'force-mapping': true, + }], + })), + 'openai-upstreams': sources.filter(source => source.wireApi === 'chat-completions').map(source => ({ + name: source.upstreamId, + disabled: false, + prefix: source.prefix, + 'base-url': null, + 'api-key-entries': [{ 'api-key': null }], + 'request-retry': 0, + models: [{ + name: source.sourceModelId, + alias: source.modelId, + 'display-name': source.modelId, + 'force-mapping': true, + 'input-modalities': ['text'], + }], + })), + } +} + +test('exports one versioned context service with owner-local registry access', () => { + assert.equal(contextServices.length, 1) + assert.equal(contextServices[0].service, CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1) + const { root } = providerRoot() + assert.equal(root.providerValue.pluginGeneration, 'gateway-generation') +}) + +test('projects the active upstream registry through the Node UI source without exposing secrets', async () => { + const fixture = gatewayFixture() + const controller = new AbortController() + await activateCliProxyGateway(fixture.context, activationInput(fixture.client, controller.signal)) + const extension = managedServiceUI.create({ + binding: fixture.gatewayUIBinding, + registration: fixture.registration, + client: fixture.client, + signal: controller.signal, + }) + + const catalogResult = await extension.readCatalog() + assert.equal(catalogResult.status, 'available') + assert.deepEqual( + catalogResult.catalog.providers.map(provider => ({ + id: provider.id, + origin: provider.endpoint.origin, + secretConfigured: provider.endpoint.secretConfigured, + models: provider.models.mappings.map(model => model.modelId), + })), + [ + { + id: 'responses-source', + origin: 'managed://synthetic-responses/synthetic-responses-service', + secretConfigured: false, + models: ['responses/family/fast'], + }, + { + id: 'chat-source', + origin: 'managed://synthetic-chat/synthetic-chat-service', + secretConfigured: true, + models: ['chat/family/balanced'], + }, + ], + ) + assert.equal( + catalogResult.catalog.providers.some(provider => provider.endpoint.origin.includes('plugins.example')), + false, + ) + + const accountsResult = await extension.readAccounts() + assert.equal(accountsResult.status, 'available') + assert.deepEqual(accountsResult.accounts.accounts, [{ + accountId: 'account-one', + authIndex: 'auth-index-one', + provider: 'codex', + label: 'user@example.test', + status: 'active', + statusMessage: 'Account unavailable', + disabled: false, + unavailable: false, + authKind: 'oauth', + sourceKind: 'file', + runtimeOnly: false, + email: 'user@example.test', + }]) + assert.doesNotMatch(JSON.stringify(accountsResult), /must-not-leak/) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'invoke' && event[2].startsWith('gateway.management.')).map( + event => event.slice(1, 4), + ), + [['gateway-runtime', 'gateway.management.accounts.list', undefined]], + ) + + await fixture.lifecycle.cleanup() +}) + +test('routes account and OAuth controls and refreshes the gateway model publication', async () => { + const fixture = gatewayFixture({ + kinds: [], + gatewayModels: ['account-model-old'], + toggleGatewayModels: ['account-model-new'], + oauthGatewayModels: ['account-model-new'], + oauthPollStatuses: ['wait', 'ok'], + }) + const controller = new AbortController() + await activateCliProxyGateway(fixture.context, activationInput(fixture.client, controller.signal)) + const extension = managedServiceUI.create({ + binding: fixture.gatewayUIBinding, + registration: fixture.registration, + client: fixture.client, + signal: controller.signal, + }) + const accountsResult = await extension.readAccounts() + assert.equal(accountsResult.status, 'available') + const common = { + binding: fixture.gatewayUIBinding, + expectedRevision: accountsResult.accounts.revision, + userGesture: { kind: 'explicit-click', at: new Date().toISOString() }, + } + const toggled = await extension.toggleAccount({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-account-toggle.v1.schema.json', + contract: 'cordisx.managed-service-cli-proxy-account-toggle/v1', + schemaVersion: 1, + requestId: 'toggle-one', + accountId: 'account-one', + authIndex: 'auth-index-one', + disabled: true, + ...common, + }) + assert.equal(toggled.status, 'accepted') + const started = await extension.startOAuth({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-start.v1.schema.json', + contract: 'cordisx.managed-service-cli-proxy-oauth-start/v1', + schemaVersion: 1, + requestId: 'oauth-one', + provider: 'codex', + ...common, + }) + assert.equal(started.status, 'accepted') + assert.equal((await extension.pollOAuth(started.sessionId)).state, 'wait') + const cancelled = await extension.cancelOAuth({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-cancel.v1.schema.json', + contract: 'cordisx.managed-service-cli-proxy-oauth-cancel/v1', + schemaVersion: 1, + requestId: 'cancel-one', + sessionId: started.sessionId, + ...common, + }) + assert.equal(cancelled.status, 'accepted') + assert.equal(cancelled.cancelled, true) + const completed = await extension.startOAuth({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-cli-proxy-oauth-start.v1.schema.json', + contract: 'cordisx.managed-service-cli-proxy-oauth-start/v1', + schemaVersion: 1, + requestId: 'oauth-two', + provider: 'codex', + ...common, + }) + assert.equal(completed.status, 'accepted') + assert.equal((await extension.pollOAuth(completed.sessionId)).state, 'completed') + assert.deepEqual( + fixture.events.filter(event => event[0] === 'invoke' && event[2].startsWith('gateway.management.')).map( + event => [event[2], event[3]], + ), + [ + ['gateway.management.accounts.list', undefined], + ['gateway.management.accounts.toggle', { + name: 'account-one', + auth_index: 'auth-index-one', + disabled: true, + }], + ['gateway.management.oauth.codex.start', { is_webui: true }], + ['gateway.management.oauth.poll', { state: 'oauth-state-one' }], + ['gateway.management.oauth.cancel', { state: 'oauth-state-one' }], + ['gateway.management.oauth.codex.start', { is_webui: true }], + ['gateway.management.oauth.poll', { state: 'oauth-state-one' }], + ], + ) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publish-native').map(event => event[1].catalog), + [expectedNativeCatalog(['account-model-old']), expectedNativeCatalog(['account-model-new'])], + ) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publication-dispose'), + [['publication-dispose', CLI_PROXY_NATIVE_PROVIDER_ID]], + ) + assert.equal( + fixture.events.filter(event => event[0] === 'invoke' && event[2] === 'gateway.models.list').length, + 3, + ) + + await fixture.lifecycle.cleanup() +}) + +test('root disposal invalidates old registrar and provider facade', () => { + const { root, target } = providerRoot() + const source = syntheticSources.responses + const bindingValue = root.bind({ + producer: { pluginId: source.pluginId, pluginGeneration: source.pluginGeneration }, + target, + signal: new AbortController().signal, + }) + const registrar = bindingValue.value + assert.equal( + registrar.register({ source: identity(source.pluginId, source.serviceId), descriptor: descriptor(source) }).status, + 'registered', + ) + root.dispose() + assert.equal( + registrar.register({ + source: identity(source.pluginId, source.serviceId), + descriptor: descriptor(source, { revision: 2 }), + }).status, + 'stale-generation', + ) + assert.equal(root.providerValue.catalog('gateway-generation').code, 'stale-generation') + assert.throws(() => + root.bind({ + producer: { pluginId: 'synthetic-other', pluginGeneration: 'synthetic-other-one' }, + target, + signal: new AbortController().signal, + }), /stale/) +}) + +test('consumer binding disposal revokes only its producer generation', () => { + const { root, target } = providerRoot() + const responses = syntheticSources.responses + const chat = syntheticSources.chat + const responsesBinding = root.bind({ + producer: { pluginId: responses.pluginId, pluginGeneration: responses.pluginGeneration }, + target, + signal: new AbortController().signal, + }) + const chatBinding = root.bind({ + producer: { pluginId: chat.pluginId, pluginGeneration: chat.pluginGeneration }, + target, + signal: new AbortController().signal, + }) + responsesBinding.value.register({ + source: identity(responses.pluginId, responses.serviceId), + descriptor: descriptor(responses), + }) + chatBinding.value.register({ + source: identity(chat.pluginId, chat.serviceId), + descriptor: descriptor(chat), + }) + responsesBinding.dispose() + assert.deepEqual( + root.providerValue.catalog('gateway-generation').groups[0].providers.map(provider => provider.providerId), + [chat.upstreamId], + ) +}) + +for ( + const scenario of [ + { name: 'responses-only', kinds: ['responses'] }, + { name: 'chat-only', kinds: ['chat'] }, + { name: 'mixed', kinds: ['responses', 'chat'] }, + ] +) { + test(`materializes schema-valid ${scenario.name} collection roots and authority leaves`, async () => { + const fixture = gatewayFixture({ kinds: scenario.kinds }) + const controller = new AbortController() + await activateCliProxyGateway(fixture.context, activationInput(fixture.client, controller.signal)) + + const request = fixture.events.find(event => event[0] === 'materialize')[1] + assert.equal(request.revision, fixture.events.find(event => event[0] === 'register')[2].revision) + assert.deepEqual(request.sources.map(source => source.source), fixture.sources.map(source => source.upstreamId)) + const roots = rootCollections(request) + assert.deepEqual(roots, expectedRootCollections(fixture.sources)) + assert.deepEqual( + compositionLeaves(request), + fixture.sources.flatMap(source => { + const slot = source.wireApi === 'responses' ? 'codex-upstreams' : 'openai-upstreams' + const leaves = [{ + targetSlot: slot, + targetPointer: '/0/base-url', + source: { kind: 'source-origin', source: source.upstreamId, origin: 'api' }, + }] + if (source.authenticated) { + leaves.push({ + targetSlot: slot, + targetPointer: '/0/api-key-entries/0/api-key', + source: { kind: 'source-authorization', source: source.upstreamId }, + }) + } + return leaves + }), + ) + const validationIndex = fixture.events.findIndex(event => + event[0] === 'invoke' && event[1] === 'gateway-runtime' && event[2] === 'gateway.models.list' + ) + const publicationEvents = fixture.events.filter(event => event[0] === 'publish-native') + assert.equal(publicationEvents.length, 1) + assert.equal(fixture.publicationHandles.length, 1) + const publicationEvent = publicationEvents[0] + assert.ok(fixture.events.indexOf(publicationEvent) > validationIndex) + assert.deepEqual(publicationEvent[1], { + providerId: CLI_PROXY_NATIVE_PROVIDER_ID, + compositionOrigin: 'api', + catalog: expectedNativeCatalog(fixture.sources.map(source => `${source.prefix}/${source.modelId}`)), + }) + assert.deepEqual(publicationEvent[2], { signal: controller.signal }) + assert.equal('binding' in publicationEvent[1], false) + + assert.equal(typeof fixture.lifecycle.cleanup, 'function') + await fixture.lifecycle.cleanup() + }) +} + +test('starts without upstreams and publishes account-backed gateway models immediately', async () => { + const fixture = gatewayFixture({ kinds: [], gatewayModels: ['zeta-model', 'alpha-model', 'alpha-model'] }) + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + assert.equal(fixture.events.filter(event => event[0] === 'register').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'materialize').length, 1) + assert.deepEqual(rootCollections(fixture.events.find(event => event[0] === 'materialize')[1]), { + 'codex-upstreams': [], + 'openai-upstreams': [], + }) + assert.deepEqual(fixture.events.filter(event => event[0] === 'publish-native').map(event => event[1]), [{ + providerId: CLI_PROXY_NATIVE_PROVIDER_ID, + compositionOrigin: 'api', + catalog: expectedNativeCatalog(['alpha-model', 'zeta-model']), + }]) + + await fixture.lifecycle.cleanup() +}) + +test('starts without upstreams and leaves an empty gateway unpublished', async () => { + const fixture = gatewayFixture({ kinds: [] }) + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + assert.equal(fixture.events.filter(event => event[0] === 'register').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'materialize').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'ensure-ready').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'publish-native').length, 0) + assert.equal(typeof fixture.lifecycle.cleanup, 'function') + + await fixture.lifecycle.cleanup() +}) + +test('registry changes rematerialize and republish only the CLIProxyAPI provider when models change', async () => { + const fixture = gatewayFixture({ kinds: [] }) + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + fixture.registerSource(syntheticSources.responses) + await new Promise(resolve => setTimeout(resolve, 0)) + + assert.equal(fixture.events.filter(event => event[0] === 'register').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'materialize').length, 2) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publish-native').map(event => event[1].providerId), + [CLI_PROXY_NATIVE_PROVIDER_ID], + ) + + fixture.registerSource(syntheticSources.chat) + await new Promise(resolve => setTimeout(resolve, 0)) + + assert.equal(fixture.events.filter(event => event[0] === 'register').length, 1) + assert.equal(fixture.events.filter(event => event[0] === 'materialize').length, 3) + const published = fixture.events.filter(event => event[0] === 'publish-native').map(event => event[1].providerId) + assert.deepEqual(published, [CLI_PROXY_NATIVE_PROVIDER_ID, CLI_PROXY_NATIVE_PROVIDER_ID]) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publication-dispose'), + [['publication-dispose', CLI_PROXY_NATIVE_PROVIDER_ID]], + ) + + await fixture.lifecycle.cleanup() +}) + +test('shares async disposal across abort and effect cleanup while completing every cleanup', async () => { + const gatewayDisposeError = new Error('synthetic gateway dispose failed') + const clientDisposeError = new Error('synthetic client dispose failed') + const publicationDisposeError = new Error('synthetic publication dispose failed') + const fixture = gatewayFixture({ + gatewayDisposeError, + clientDisposeError, + publicationDisposeError: { providerId: CLI_PROXY_NATIVE_PROVIDER_ID, error: publicationDisposeError }, + }) + const controller = new AbortController() + await activateCliProxyGateway(fixture.context, activationInput(fixture.client, controller.signal)) + + controller.abort() + const first = fixture.lifecycle.cleanup() + const second = fixture.lifecycle.cleanup() + assert.strictEqual(first, second) + await assert.rejects(first, error => { + assert.equal(error instanceof AggregateError, true) + assert.deepEqual(error.errors, [publicationDisposeError, gatewayDisposeError, clientDisposeError]) + return true + }) + assert.deepEqual(fixture.released, [ + 'msl_gateway-runtime', + 'msl_synthetic-chat-service', + 'msl_synthetic-responses-service', + ]) + assert.deepEqual(fixture.events.slice(-2), [['registration-dispose'], ['client-dispose']]) +}) + +test('accepts cleanup after the Host already revoked a native publication', async () => { + const fixture = gatewayFixture({ kinds: ['responses'] }) + const providerId = CLI_PROXY_NATIVE_PROVIDER_ID + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + fixture.hostRevokePublication(providerId) + await fixture.lifecycle.cleanup() + + assert.deepEqual(fixture.publicationDisposeResults, [[providerId, { + status: 'stale', + diagnostic: { code: 'disposed', retryable: false }, + }]]) + assert.equal(fixture.activePublications.has(providerId), false) + assert.deepEqual(fixture.released, ['msl_gateway-runtime', 'msl_synthetic-responses-service']) + assert.deepEqual(fixture.events.slice(-2), [['registration-dispose'], ['client-dispose']]) +}) + +test('old publication cleanup leaves a Host replacement active', async () => { + const fixture = gatewayFixture({ kinds: ['responses'] }) + const providerId = CLI_PROXY_NATIVE_PROVIDER_ID + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + const replacement = fixture.hostReplacePublication(providerId) + await fixture.lifecycle.cleanup() + + assert.deepEqual(fixture.publicationDisposeResults, [[providerId, { + status: 'stale', + diagnostic: { code: 'stale-generation', retryable: false }, + }]]) + assert.strictEqual(fixture.activePublications.get(providerId), replacement) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publication-dispose'), + [['publication-dispose', providerId]], + ) +}) + +test('aggregates unexpected native publication cleanup diagnostics', async () => { + const providerId = CLI_PROXY_NATIVE_PROVIDER_ID + const fixture = gatewayFixture({ + kinds: ['responses'], + publicationDisposeDiagnostic: { providerId, code: 'failed' }, + }) + await activateCliProxyGateway( + fixture.context, + activationInput(fixture.client, new AbortController().signal), + ) + + await assert.rejects(fixture.lifecycle.cleanup(), error => { + assert.equal(error instanceof AggregateError, true) + assert.match(error.errors[0].message, /native publication cleanup failed: failed/) + return true + }) + assert.deepEqual(fixture.released, ['msl_gateway-runtime', 'msl_synthetic-responses-service']) + assert.deepEqual(fixture.events.slice(-2), [['registration-dispose'], ['client-dispose']]) +}) + +for (const code of ['stale-generation', 'failed']) { + test(`cleans up when the CLIProxyAPI native publication is rejected as ${code}`, async () => { + const fixture = gatewayFixture({ + publicationFailure: { providerId: CLI_PROXY_NATIVE_PROVIDER_ID, code }, + }) + await assert.rejects( + activateCliProxyGateway(fixture.context, activationInput(fixture.client, new AbortController().signal)), + new RegExp(`cli-proxy-api native publication failed: ${code}`), + ) + assert.deepEqual( + fixture.events.filter(event => event[0] === 'publish-native').map(event => event[1].providerId), + [CLI_PROXY_NATIVE_PROVIDER_ID], + ) + assert.deepEqual(fixture.events.filter(event => event[0] === 'publication-dispose'), []) + assert.deepEqual(fixture.released, [ + 'msl_gateway-runtime', + 'msl_synthetic-chat-service', + 'msl_synthetic-responses-service', + ]) + assert.deepEqual(fixture.events.slice(-2), [['registration-dispose'], ['client-dispose']]) + }) +} + +test('preserves the activation error while aggregating complete cleanup failures', async () => { + const gatewayDisposeError = new Error('synthetic gateway dispose failed') + const clientDisposeError = new Error('synthetic client dispose failed') + const fixture = gatewayFixture({ + kinds: ['responses'], + missingSourceModel: true, + gatewayDisposeError, + clientDisposeError, + }) + await assert.rejects( + activateCliProxyGateway(fixture.context, activationInput(fixture.client, new AbortController().signal)), + error => { + assert.equal(error instanceof AggregateError, true) + assert.match(error.errors[0].message, /omitted declared models/) + assert.strictEqual(error.cause, error.errors[0]) + assert.deepEqual(error.errors.slice(1), [gatewayDisposeError, clientDisposeError]) + return true + }, + ) + assert.deepEqual(fixture.released, ['msl_synthetic-responses-service']) + assert.deepEqual(fixture.events.slice(-2), [['registration-dispose'], ['client-dispose']]) +}) + +test('fails after gateway validation when the composed catalog is incomplete', async () => { + const fixture = gatewayFixture({ missingGatewayModel: true }) + await assert.rejects( + activateCliProxyGateway(fixture.context, activationInput(fixture.client, new AbortController().signal)), + /omitted composed models/, + ) + assert.deepEqual(fixture.released, [ + 'msl_gateway-runtime', + 'msl_synthetic-chat-service', + 'msl_synthetic-responses-service', + ]) +}) diff --git a/test/lease-lifecycle.mjs b/test/lease-lifecycle.mjs new file mode 100644 index 0000000..13426a3 --- /dev/null +++ b/test/lease-lifecycle.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const gatewayTs = readFileSync(new URL('../src/service/gateway.ts', import.meta.url), 'utf8') +const gatewayDef = readFileSync(new URL('../src/service/gateway-definition.ts', import.meta.url), 'utf8') +const gatewayUI = readFileSync(new URL('../src/service/gateway-ui.ts', import.meta.url), 'utf8') + +// ─── Lease lifecycle (admission TTL vs. binding) ──────────────────────────── + +test('every acquire is paired with a release in activation cleanup', () => { + // The activate inner function must: acquire → validate → release in cleanup. + // Verify acquire() and release() appear in the same activation scope. + assert.match(gatewayTs, /input\.client\.acquire\(/) + assert.match(gatewayTs, /input\.client\.release\(acquired\.lease\)/) + // Release in finally block after acquire + assert.match(gatewayTs, /finally\s*\{\s*input\.client\.release\(acquired\.lease\)/) + // readGatewayModels uses acquire + finally release + assert.match(gatewayTs, /input\.client\.release\(acquired\.lease\)\s*\n\s*\}/) +}) + +test('gateway model read acquires a short-lived lease for each validation', () => { + // readGatewayModels acquires, invokes gateway.models.list, then releases. + assert.match(gatewayTs, /readGatewayModels/) + assert.match(gatewayTs, /acquired\.status !== 'ready'/) + // The lease is released in finally, not held indefinitely + const acquireReleaseBlock = gatewayTs.match( + /input\.client\.acquire\(gatewayHandle[\s\S]*?input\.client\.release\(acquired\.lease\)/, + ) + assert.ok(acquireReleaseBlock, 'model validation should acquire then release') +}) + +test('management operations use a separate lease from gateway operations', () => { + // gatewayUI invoke() acquires a fresh lease per operation, never reuses + const invokeFn = gatewayUI.match(/invoke\([\s\S]*?\)[\s\S]*?\{[\s\S]*?return undefined/) + assert.ok(invokeFn, 'gatewayUI invoke uses acquire→invoke→release per operation') + assert.match(gatewayUI, /client\.acquire\(.*binding\.identity/) + assert.match(gatewayUI, /client\.release\(acquired\.lease\)/) +}) + +test('activation leases are released in reverse order on cleanup', () => { + // Cleanup releases leases via leases.splice(0).reverse() + assert.match(gatewayTs, /leases\.splice\(0\)\.reverse\(\)/) + assert.match(gatewayTs, /input\.client\.release\(lease\)/) +}) + +test('dispose() releases every held resource: active activation, publication, registration, client', () => { + assert.match(gatewayTs, /current\.dispose/) + assert.match(gatewayTs, /disposePublication/) + assert.match(gatewayTs, /handle\.dispose/) + assert.match(gatewayTs, /input\.client\.dispose/) + // Disposal is idempotent via disposePromise + assert.match(gatewayTs, /disposePromise !== undefined\) return disposePromise/) +}) + +// ─── Credential separation ────────────────────────────────────────────────── + +test('gateway-key and management-key are distinct slots in the service definition', () => { + const gatewayKeySlot = gatewayDef.match(/slot:\s*'gateway-key'/) + const managementKeySlot = gatewayDef.match(/slot:\s*'management-key'/) + assert.ok(gatewayKeySlot, 'gateway-key slot must be in protectedBindings') + assert.ok(managementKeySlot, 'management-key slot must exist') + + // gateway-key uses generated-local-key + // gateway-key is a protected binding with source: generated-local-key + assert.match(gatewayDef, /slot:\s*'gateway-key'/) + assert.match(gatewayDef, /source:\s*'generated-local-key'/) + // management-key is a protected binding with source: generated-local-key + assert.match(gatewayDef, /slot:\s*'management-key'/) + assert.match(gatewayDef, /source:\s*'generated-local-key'/) +}) + +test('httpAuthentication uses gateway-key, not management-key', () => { + assert.ok( + gatewayDef.includes("httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'gateway-key' }"), + ) + // Verify management-key is not used as the default httpAuthentication slot + assert.ok( + gatewayDef.includes( + "httpAuthentication: { mode: 'authorization-header', scheme: 'Bearer', slot: 'management-key' }", + ), + ) + // management-key appears only in operation-specific overrides, not in the top-level httpAuthentication + const topAuthIdx = gatewayDef.indexOf('httpAuthentication:') + const topAuthLine = gatewayDef.slice(topAuthIdx, topAuthIdx + 150) + assert.ok(topAuthLine.includes("slot: 'gateway-key'")) + assert.ok(!topAuthLine.includes("slot: 'management-key'")) +}) + +test('management operations use management-key for HTTP auth', () => { + // All management operations override httpAuthentication with management-key + assert.match(gatewayDef, /slot:\s*'management-key'/) + const mgmtOps = gatewayDef.match(/gateway\.management\./g) + assert.ok(mgmtOps, 'management operations exist') + // At least accounts.list uses management-key + assert.match(gatewayDef, /operationId:\s*'gateway\.management\.accounts\.list'[\s\S]*?slot:\s*'management-key'/) +}) + +test('account toggle operation uses management-key auth', () => { + assert.match(gatewayDef, /operationId:\s*'gateway\.management\.accounts\.toggle'[\s\S]*?slot:\s*'management-key'/) +}) + +test('OAuth operations use management-key auth', () => { + assert.match(gatewayDef, /operationId:\s*`gateway\.management\.oauth[\s\S]*?slot:\s*'management-key'/) +}) + +// ─── Binding lifecycle (service generation) ───────────────────────────────── + +test('gateway activation consumes the binding serviceGeneration for catalog and publication', () => { + // publishNativeProvider uses the binding identity and revision + assert.match(gatewayTs, /gatewayHandle\.binding\.identity/) + assert.match(gatewayTs, /gatewayHandle\.publishNativeProvider/) +}) + +test('gatewayUI maps catalog with binding.serviceGeneration for versioning', () => { + assert.match(gatewayUI, /projection\.binding\.serviceGeneration/) + assert.match(gatewayUI, /serviceGeneration/) +}) + +test('stale publication disposal is treated as acceptable (not a fatal error)', () => { + assert.match(gatewayTs, /result\.status === 'stale'/) + assert.match(gatewayTs, /diagnostic\.code === 'disposed'/) + assert.match(gatewayTs, /diagnostic\.code === 'stale-generation'/) +}) + +test('catalog digest change triggers republish; identical digest skips publish', () => { + assert.match(gatewayTs, /catalog\?.digest === publicationDigest\) return/) + assert.match(gatewayTs, /publicationDigest = catalog\.digest/) +}) + +// ─── UI handoff completeness ──────────────────────────────────────────────── + +test('page receives managedServices from factory closure, not ctx smuggling', () => { + const indexTs = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8') + assert.match( + indexTs, + /createUpstreamSubscriptionManagerPage\(managedServices, optionalManager\(ctx\), ctx\.notifications\)/, + ) + assert.doesNotMatch(indexTs, /props\.ctx/) + assert.match(indexTs, /ctx\.reflect\.get\(['"]manager['"]\)/) +}) + +test('subscription manager passes manager to page for own-plugin configuration', () => { + const pageTsx = readFileSync(new URL('../src/upstream-subscription-manager-page.tsx', import.meta.url), 'utf8') + assert.match(pageTsx, /manager:/) + assert.match(pageTsx, /requestOwnPluginConfiguration/) +}) + +// ─── Residual risk: Host-side admission TTL ───────────────────────────────── +// +// The CLIProxyAPI plugin treats every ManagedServiceLeaseV1 as short-lived: +// acquire → use → release, then re-acquire for the next operation. This is +// correct. The residual risk is Host-side: if the Host persists bearer tokens +// from a single admission TTL across multiple requests, it conflates the +// admission lease with a long-term binding. This defect was documented in +// native-launch-D handoff findings D1 (bearer persistence) and D4 (global +// defaults). Resolution of those findings belongs to the Host owner (lane A), +// not this plugin. + +test('CLIProxyAPI plugin does not persist or store bearer tokens', () => { + // The plugin never stores raw credentials; gateway-key/management-key are + // Host-private generated keys delivered through configuration bindings. + assert.doesNotMatch(gatewayTs, /\bbearer\b/i) + assert.doesNotMatch(gatewayUI, /\bbearer\b/i) + // No local storage, no config.toml writes + assert.doesNotMatch(gatewayTs, /config\.toml/) + assert.doesNotMatch(gatewayTs, /writeFile|persist|save/) +}) + +test('account projection strips access_token and api_key before renderer delivery', () => { + // mapAccount selects only named public fields, verified by gateway.mjs fixtures + assert.match(gatewayUI, /mapAccount/) + // gatewayUI source code does not forward access_token, api_key, or other secret fields + // (the gateway.mjs test fixture independently verifies that sentinel values do not leak) +}) + +test('management diagnostics are redacted before renderer delivery', () => { + assert.doesNotMatch(gatewayUI, /statusMessage:\s*stringValue\(item\.status_message\)/) + assert.match(gatewayUI, /statusMessage:\s*'Account unavailable'/) + assert.doesNotMatch(gatewayUI, /message:\s*stringValue\(response\.error\)/) + assert.match(gatewayUI, /message:\s*'Authentication failed'/) +}) diff --git a/test/manifest.mjs b/test/manifest.mjs index d2f37d8..41245f8 100644 --- a/test/manifest.mjs +++ b/test/manifest.mjs @@ -1,5 +1,27 @@ import assert from 'node:assert/strict' +import { build } from 'esbuild' +import { readdir, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import test from 'node:test' +import Ajv2020 from 'ajv/dist/2020.js' +import addFormats from 'ajv-formats' + +const protocolSchemas = process.env.CORDISX_PROTOCOL_ROOT === undefined + ? new URL('../../cordisx-protocol/schemas/', import.meta.url) + : new URL('schemas/', pathToFileURL(`${resolve(process.env.CORDISX_PROTOCOL_ROOT)}/`)) + +async function protocolValidator(schemaFile) { + const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true }) + addFormats(ajv) + for (const file of await readdir(protocolSchemas)) { + if (file.endsWith('.schema.json')) { + ajv.addSchema(JSON.parse(await readFile(new URL(file, protocolSchemas), 'utf8'))) + } + } + const schema = JSON.parse(await readFile(new URL(schemaFile, protocolSchemas), 'utf8')) + return ajv.getSchema(schema.$id) +} const element = (type, props) => ({ type, props }) const component = props => element('component', props) @@ -27,6 +49,21 @@ globalThis.__cordisxSharedReactRuntime = { const plugin = await import('../dist/runtime/module.js') +async function loadModelProviderRegistry() { + const hostRoot = process.env.CORDISX_HOST_ROOT + assert.ok(hostRoot, 'CORDISX_HOST_ROOT is required for the real registry test') + const result = await build({ + entryPoints: [resolve(hostRoot, 'packages/cli/src/renderer/model-providers.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + write: false, + }) + const source = Buffer.from(result.outputFiles[0].contents).toString('base64') + return await import(`data:text/javascript;base64,${source}`) +} + test('exports the CLIProxy renderer manifest through public Host contracts', () => { assert.equal(plugin.manifest.schemaVersion, 1) assert.equal(plugin.manifest.id, 'cli-proxy-api') @@ -42,16 +79,63 @@ test('exports the CLIProxy renderer manifest through public Host contracts', () 'turns.control', ], ) - assert.deepEqual(plugin.inject, ['i18n', 'slots', 'pages', 'routes', 'platform', 'notifications']) + assert.deepEqual(plugin.inject, [ + 'i18n', + 'slots', + 'pages', + 'routes', + 'managerContent', + 'platform', + 'modelProviders', + 'notifications', + ]) }) -test('registers one public page, route, and navigation contribution', () => { - const registrations = { locales: [], pages: [], routes: [], slots: [] } +test('registers CLIProxyAPI branding and Manager settings without a Provider sessions navigation entry', async () => { + const registrations = { + effects: [], + locales: [], + managerContent: [], + modelProviderEntries: [], + modelProviderPresentations: [], + navigations: [], + pages: [], + routes: [], + slots: [], + } + let providerListener = () => undefined + const contribution = (kind, input) => { + const record = { kind, input, disposed: false } + registrations[kind].push(record) + return { dispose: () => record.disposed = true } + } const context = { + effect(execute) { + const cleanup = execute() + registrations.effects.push(cleanup) + return cleanup + }, + reflect: { get: name => name === 'managedServices' ? context.managedServices : undefined }, i18n: { define: definition => registrations.locales.push(definition) }, pages: { register: (...args) => registrations.pages.push(args) }, - routes: { register: definition => registrations.routes.push(definition) }, + routes: { + register: definition => registrations.routes.push(definition), + navigate: reference => registrations.navigations.push(reference), + }, slots: { register: (...args) => registrations.slots.push(args) }, + managerContent: { register: definition => registrations.managerContent.push(definition) }, + modelProviders: { + list: () => [], + subscribe(listener) { + providerListener = listener + return () => providerListener = () => undefined + }, + refresh: async () => undefined, + present: input => contribution('modelProviderPresentations', input), + insert: input => contribution('modelProviderEntries', input), + }, + managedServices: { get: async () => ({ status: 'unavailable', code: 'owner-unavailable', message: 'test' }) }, + notifications: { show: () => ({ dismiss: () => undefined }) }, platform: { models: { list: async () => ({ ok: true, value: { models: [] } }) }, tasks: { @@ -71,14 +155,190 @@ test('registers one public page, route, and navigation contribution', () => { assert.equal(registrations.locales.length, 2) assert.equal(registrations.pages.length, 1) - assert.equal(registrations.pages[0][0].id, 'providers.sessions') + assert.equal(registrations.pages[0][0].id, 'providers.upstream-subscriptions') + assert.equal(registrations.pages[0][0].icon, 'host:key') + assert.equal(registrations.pages[0][0].chrome, 'standard') assert.equal(registrations.routes.length, 1) - assert.equal(registrations.routes[0].path, '/main/providers/sessions') + assert.equal(registrations.routes[0].path, '/manager/extensions/cli-proxy-api/subscriptions') + assert.equal(registrations.routes[0].outlet, 'manager.content') + assert.equal(registrations.managerContent.length, 1) + assert.equal(registrations.managerContent[0].route.id, 'providers.upstream-subscriptions') assert.equal(registrations.slots.length, 1) - assert.equal(registrations.slots[0][0].name, 'sidebar.navigation.items') + assert.equal(registrations.slots[0][0].name, 'manager.settings.navigation-items') + assert.equal(registrations.slots[0][0].schemaVersion, 11) + assert.equal( + registrations.slots[0][0].$schema, + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/surface-contribution.v11.schema.json', + ) + assert.equal(registrations.slots[0][1].navigationGroup.id, 'external-accounts') + assert.equal(registrations.pages.some(([metadata]) => metadata.id === 'providers.sessions'), false) + assert.equal(registrations.routes.some(route => route.id === 'providers.sessions'), false) + assert.equal(registrations.slots.some(([metadata]) => metadata.name === 'sidebar.navigation.items'), false) + assert.deepEqual(registrations.modelProviderPresentations[0].input, { + providerId: 'cli-proxy-api', + title: 'CLIProxyAPI', + icon: { + kind: 'raster-image', + image: { + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/raster-image-snapshot.v1.schema.json', + contract: 'cordisx.raster-image-snapshot/v1', + schemaVersion: 1, + mediaType: plugin.icon.mediaType, + encoding: 'base64', + data: plugin.icon.data, + width: 256, + height: 210, + }, + }, + }) + assert.equal(registrations.modelProviderEntries.length, 1) + assert.equal(registrations.modelProviderEntries[0].input.label, 'CLIProxyAPI') + assert.equal(registrations.modelProviderEntries[0].input.action.label, 'Open settings') + assert.equal(registrations.modelProviderEntries[0].input.action.icon, 'host:settings') + await registrations.modelProviderEntries[0].input.action.run(new AbortController().signal) + assert.deepEqual(registrations.navigations, [{ id: 'providers.upstream-subscriptions' }]) + + context.modelProviders.list = () => [{ providerId: 'cli-proxy-api' }] + providerListener() + assert.equal(registrations.modelProviderEntries[0].disposed, true) + + await registrations.effects[0]() + assert.equal(registrations.modelProviderPresentations[0].disposed, true) +}) + +test('external account Manager navigation conforms to v11 while v9 stays closed', async () => { + const document = { + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/surface-contribution.v11.schema.json', + schemaVersion: 11, + id: 'upstream-subscriptions', + surface: 'manager.settings.navigation-items', + group: 'after-settings', + order: 120, + item: { + route: { id: 'providers.upstream-subscriptions' }, + navigationGroup: { id: 'external-accounts' }, + }, + } + const validateV11 = await protocolValidator('surface-contribution.v11.schema.json') + assert.equal(validateV11(document), true, JSON.stringify(validateV11.errors)) + + const validateV9 = await protocolValidator('surface-contribution.v9.schema.json') + assert.equal( + validateV9({ + ...document, + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/surface-contribution.v9.schema.json', + schemaVersion: 9, + }), + false, + 'v9 must reject the external-accounts group', + ) +}) + +test('reconciles the settings fallback through synchronous ModelProviderRegistry emissions', { + skip: process.env.CORDISX_HOST_ROOT === undefined, +}, async () => { + const { ModelProviderRegistry } = await loadModelProviderRegistry() + let nativeProviders = [] + const registry = new ModelProviderRegistry(async () => nativeProviders) + const binding = registry.bind('cli-proxy-api', 'test-generation', () => true) + const cleanups = [] + const navigations = [] + const context = { + effect(execute) { + cleanups.push(execute()) + }, + reflect: { get: name => name === 'managedServices' ? context.managedServices : undefined }, + i18n: { define: () => undefined }, + pages: { register: () => undefined }, + routes: { + register: () => undefined, + navigate: async reference => navigations.push(reference), + }, + slots: { register: () => undefined }, + managerContent: { register: () => undefined }, + modelProviders: binding.facade, + managedServices: { get: async () => ({ status: 'unavailable', code: 'owner-unavailable', message: 'test' }) }, + notifications: { show: () => ({ dismiss: () => undefined }) }, + platform: { + models: { list: async () => ({ ok: true, value: { models: [] } }) }, + tasks: { + list: async () => ({ ok: true, value: { sessions: [] } }), + read: async () => ({ ok: false, error: { message: 'unavailable' } }), + create: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + turns: { + submit: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + }, + } + + plugin.apply(context, plugin.Config({})) + await binding.facade.refresh() + assert.equal(registry.snapshot().entries.length, 1) + assert.equal(registry.snapshot().entries[0].entry.action.icon, 'host:settings') + + nativeProviders = [{ + providerId: 'cli-proxy-api', + pluginId: 'cli-proxy-api', + models: [{ id: 'shared-model', label: 'Shared Model', aliases: ['shared-model'] }], + defaultModelId: 'shared-model', + }] + await binding.facade.refresh() + assert.equal(registry.snapshot().entries.length, 0) + assert.equal(registry.snapshot().providers[0].title, 'CLIProxyAPI') + + nativeProviders = [] + await binding.facade.refresh() + assert.equal(registry.snapshot().entries.length, 1) + await registry.snapshot().entries[0].entry.action.run(new AbortController().signal) + assert.deepEqual(navigations, [{ id: 'providers.upstream-subscriptions' }]) + + for (const cleanup of cleanups.reverse()) await cleanup() + binding.dispose() + registry.dispose() +}) + +test('keeps the renderer plugin active when managed services are unavailable', () => { + const registrations = { effects: 0, pages: 0, routes: 0, slots: 0 } + const context = { + effect(execute) { + registrations.effects += 1 + return execute() + }, + reflect: { get: () => undefined }, + i18n: { define: () => undefined }, + pages: { register: () => registrations.pages += 1 }, + routes: { register: () => registrations.routes += 1, navigate: async () => undefined }, + slots: { register: () => registrations.slots += 1 }, + managerContent: { register: () => undefined }, + modelProviders: { + list: () => [], + subscribe: () => () => undefined, + refresh: async () => undefined, + present: () => ({ dispose: () => undefined }), + insert: () => ({ dispose: () => undefined }), + }, + notifications: { show: () => ({ dismiss: () => undefined }) }, + platform: { + models: { list: async () => ({ ok: true, value: { models: [] } }) }, + tasks: { + list: async () => ({ ok: true, value: { sessions: [] } }), + read: async () => ({ ok: false, error: { message: 'unavailable' } }), + create: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + turns: { + submit: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + }, + } - const Page = registrations.pages[0][1] - const rendered = Page({ t: (key, params) => `${key}${params === undefined ? '' : JSON.stringify(params)}` }) - assert.equal(rendered.props.className, 'cxp-fleet') - assert.equal(rendered.props['data-cordisx-provider-fleet'], 'true') + assert.doesNotThrow(() => plugin.apply(context, plugin.Config({}))) + assert.deepEqual(registrations, { effects: 1, pages: 1, routes: 1, slots: 1 }) }) diff --git a/test/optional-manager-context.mjs b/test/optional-manager-context.mjs new file mode 100644 index 0000000..42af3e5 --- /dev/null +++ b/test/optional-manager-context.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { Context, Service } from '@deepseek-ai/cordis' + +const element = (type, props) => ({ type, props }) +const component = props => element('component', props) +const React = new Proxy({ + Fragment: Symbol('Fragment'), + Suspense: component, + lazy: () => component, + useCallback: callback => callback, + useEffect: () => undefined, + useMemo: factory => factory(), + useRef: initial => ({ current: initial }), + useState: initial => [initial === true ? false : initial, () => undefined], +}, { + get(target, property) { + return Reflect.get(target, property) ?? (() => undefined) + }, +}) + +globalThis.__cordisxSharedReactRuntime = { + React, + defineReactPage: page => page, + jsxRuntime: { Fragment: React.Fragment, jsx: element, jsxs: element }, + jsxDevRuntime: { Fragment: React.Fragment, jsxDEV: element }, + ui: new Proxy({}, { get: () => component }), +} + +const plugin = await import('../dist/runtime/module.js') + +function createServicePlugin(name, value) { + return class extends Service { + constructor(ctx) { + super(ctx, name) + Object.assign(this, value) + } + } +} + +async function createHost({ manager } = {}) { + const root = new Context() + const pages = [] + const serviceFibers = [] + const services = { + i18n: { define: () => undefined }, + slots: { register: () => undefined }, + pages: { register: (...args) => pages.push(args) }, + routes: { register: () => undefined, navigate: async () => undefined }, + managerContent: { register: () => undefined }, + platform: { + models: { list: async () => ({ ok: true, value: { models: [] } }) }, + tasks: { + list: async () => ({ ok: true, value: { sessions: [] } }), + read: async () => ({ ok: false, error: { message: 'unavailable' } }), + create: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + turns: { + submit: async () => ({ ok: false, error: { message: 'unavailable' } }), + control: async () => ({ ok: false, error: { message: 'unavailable' } }), + }, + }, + modelProviders: { + list: () => [], + subscribe: () => () => undefined, + refresh: async () => undefined, + present: () => ({ dispose: () => undefined }), + insert: () => ({ dispose: () => undefined }), + }, + notifications: { show: () => ({ dismiss: () => undefined }) }, + } + + for (const [name, value] of Object.entries(services)) { + serviceFibers.push(await root.plugin(createServicePlugin(name, value))) + } + if (manager !== undefined) { + serviceFibers.push(await root.plugin(manager)) + } + + return { root, pages, serviceFibers } +} + +function findProp(value, name) { + if (value === null || typeof value !== 'object') return undefined + if (Object.hasOwn(value, name)) return value[name] + for (const child of Array.isArray(value) ? value : Object.values(value)) { + const result = findProp(child, name) + if (result !== undefined) return result + } +} + +async function disposeFibers(...fibers) { + for (const fiber of fibers.reverse()) await fiber.dispose() +} + +test('real Cordis context activates with optional manager and preserves the plugin caller', async () => { + const callers = [] + class ManagerService extends Service { + constructor(ctx) { + super(ctx, 'manager') + } + + async openOwnPluginConfiguration() { + callers.push(this.ctx.fiber.name) + return 'opened' + } + } + + const host = await createHost({ manager: ManagerService }) + const pluginFiber = await host.root.plugin(plugin, plugin.Config({})) + try { + const registration = host.pages.find(([metadata]) => metadata.id === 'providers.upstream-subscriptions') + assert.ok(registration, 'upstream subscription page should register') + const rendered = registration[1]({ t: key => key }) + const action = findProp(rendered, 'action') + assert.equal(typeof action?.props?.onClick, 'function') + action.props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(callers, ['cli-proxy-api']) + } finally { + await disposeFibers(...host.serviceFibers, pluginFiber) + } +}) + +test('real Cordis context activates without manager and keeps the unavailable downgrade', async () => { + const host = await createHost() + const pluginFiber = await host.root.plugin(plugin, plugin.Config({})) + try { + const registration = host.pages.find(([metadata]) => metadata.id === 'providers.upstream-subscriptions') + assert.ok(registration, 'upstream subscription page should register') + const rendered = registration[1]({ t: key => key }) + assert.equal(findProp(rendered, 'action'), undefined) + } finally { + await disposeFibers(...host.serviceFibers, pluginFiber) + } +}) diff --git a/test/package-artifact.mjs b/test/package-artifact.mjs index 6ef555d..8650f9f 100644 --- a/test/package-artifact.mjs +++ b/test/package-artifact.mjs @@ -1,11 +1,70 @@ import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' +import { promisify } from 'node:util' import { readdir, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import test from 'node:test' import Ajv2020 from 'ajv/dist/2020.js' import addFormats from 'ajv-formats' -const schemas = new URL('../node_modules/@cordisx/protocol/schemas/', import.meta.url) +const execFileAsync = promisify(execFile) +const schemas = process.env.CORDISX_PROTOCOL_ROOT === undefined + ? new URL('../../cordisx-protocol/schemas/', import.meta.url) + : new URL('schemas/', pathToFileURL(`${resolve(process.env.CORDISX_PROTOCOL_ROOT)}/`)) +const gatewayRuntimeResources = [ + { + path: './config/cli-proxy-api.yaml', + mode: 'data', + digest: 'sha256:4e2806a2c9a4a490f8685ffc96cf1dc40010248a8d25437b718249f3275b198e', + byteLength: 268, + }, + { + path: './schemas/cli-proxy-gateway-model-catalog.v1.schema.json', + mode: 'data', + digest: 'sha256:b20bf7d388ae1417eaa1e86186eab241c754fc54af3c3b4d5439308202194b30', + byteLength: 639, + }, + { + path: './schemas/cli-proxy-gateway-codex-upstream.v1.schema.json', + mode: 'data', + digest: 'sha256:a9908de439af944ac24744721c117fd0c0a18c18e61355d343f8ad57311fafba', + byteLength: 1253, + }, + { + path: './schemas/cli-proxy-gateway-openai-upstream.v1.schema.json', + mode: 'data', + digest: 'sha256:8f226eeaf778394f452c0c224bd163c1c68806f56c63982d0394c03d6be4483b', + byteLength: 1925, + }, + { + path: './schemas/cli-proxy-management-auth-files.v1.schema.json', + mode: 'data', + digest: 'sha256:9785b09906567848143054d522b141f5f06234f7b16a8dc2a02ef31e6d58e76c', + byteLength: 370, + }, + { + path: './schemas/cli-proxy-management-operation.v1.schema.json', + mode: 'data', + digest: 'sha256:c1a9e12cf48ee53825948b053ea364ce70d09122321502560defebeb3d8d0c12', + byteLength: 218, + }, +] + +const gatewayConsumerOperations = [ + 'gateway.models.list', + 'gateway.management.accounts.list', + 'gateway.management.accounts.toggle', + 'gateway.management.oauth.anthropic.start', + 'gateway.management.oauth.codex.start', + 'gateway.management.oauth.antigravity.start', + 'gateway.management.oauth.kimi.start', + 'gateway.management.oauth.xai.start', + 'gateway.management.oauth.poll', + 'gateway.management.oauth.cancel', +] + async function validator(name) { const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true }) addFormats(ajv) @@ -16,7 +75,7 @@ async function validator(name) { return ajv.getSchema(schema.$id) } -test('publishes schema-valid v13 package and runtime manifests with exact request scopes', async () => { +test('publishes schema-valid v14 package and runtime manifests with exact request scopes', async () => { const [packageText, runtimeText] = await Promise.all([ readFile(new URL('../cordisx-package.json', import.meta.url), 'utf8'), readFile(new URL('../runtime-manifest.json', import.meta.url), 'utf8'), @@ -24,8 +83,8 @@ test('publishes schema-valid v13 package and runtime manifests with exact reques const packageManifest = JSON.parse(packageText) const runtimeManifest = JSON.parse(runtimeText) const [validatePackage, validateRuntime] = await Promise.all([ - validator('plugin-package.v13.schema.json'), - validator('plugin-manifest.v13.schema.json'), + validator('plugin-package.v14.schema.json'), + validator('plugin-manifest.v14.schema.json'), ]) assert.equal(validatePackage(packageManifest), true, JSON.stringify(validatePackage.errors)) assert.equal(validateRuntime(runtimeManifest), true, JSON.stringify(validateRuntime.errors)) @@ -44,5 +103,78 @@ test('publishes schema-valid v13 package and runtime manifests with exact reques `sha256:${createHash('sha256').update(runtimeText).digest('hex')}`, ) await readFile(new URL(`..${packageManifest.entry.slice(1)}`, import.meta.url)) - await readFile(new URL(`..${runtimeManifest.services[0].entry.slice(1)}`, import.meta.url)) + for (const service of runtimeManifest.services) { + await readFile(new URL(`..${service.entry.slice(1)}`, import.meta.url)) + } + assert.deepEqual(runtimeManifest.services[1], { + id: 'gateway-runtime', + kind: 'managed-backend', + owner: 'host', + entry: './dist/gateway.mjs', + definitionSchema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-definition.v1.schema.json', + runtimeResources: gatewayRuntimeResources, + consumerGrants: [{ pluginId: 'cli-proxy-api', operations: gatewayConsumerOperations }], + }) + for (const resource of runtimeManifest.services[1].runtimeResources) { + const bytes = await readFile(new URL(`../${resource.path.slice(2)}`, import.meta.url)) + assert.equal(resource.mode, 'data') + assert.equal(resource.byteLength, bytes.byteLength) + assert.equal(resource.digest, `sha256:${createHash('sha256').update(bytes).digest('hex')}`) + } +}) + +test('packs gateway runtime data resources into the tarball', async () => { + const packageManifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) + const { stdout } = await execFileAsync('npm', ['pack', '--json', '--ignore-scripts'], { + cwd: new URL('..', import.meta.url), + }) + const [{ files }] = JSON.parse(stdout) + const archiveFiles = new Set(files.map(file => file.path)) + for (const resource of gatewayRuntimeResources) { + const path = resource.path.slice(2) + assert.equal(archiveFiles.has(path), true, `tarball is missing ${path}`) + assert.equal( + packageManifest.files.some(entry => { + const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '') + return path === normalized || path.startsWith(`${normalized}/`) + }), + true, + `package.json files does not include ${path}`, + ) + } +}) + +test('publishes the versioned upstream registration entrypoint', async () => { + const packageManifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) + assert.deepEqual(packageManifest.exports['./upstream/v1'], { + types: './dist/types/service/upstream-registry.d.ts', + import: './dist/service.mjs', + default: './dist/service.mjs', + }) + const upstream = await import('@cordisx/plugin-cli-proxy-api/upstream/v1') + assert.equal(upstream.CLI_PROXY_UPSTREAM_REGISTRY_SERVICE_V1, 'cliProxyUpstreams') + assert.equal(typeof upstream.CliProxyUpstreamRegistryV1, 'function') +}) + +test('publishes the managed gateway Node module and public Protocol context ABI', async () => { + const packageManifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) + assert.deepEqual(packageManifest.exports['./gateway/v1'], { + types: './dist/types/service/gateway.d.ts', + import: './dist/gateway.mjs', + default: './dist/gateway.mjs', + }) + const gateway = await import('@cordisx/plugin-cli-proxy-api/gateway/v1') + assert.equal(gateway.contextServices[0].service, 'cliProxyUpstreams') + assert.deepEqual(gateway.apply.inject, ['managedServices', 'cliProxyUpstreams']) +}) + +test('publishes a schema-valid managed gateway definition', async () => { + const validateDefinition = await validator('managed-service-definition.v1.schema.json') + const gateway = await import('@cordisx/plugin-cli-proxy-api/gateway/v1') + assert.equal( + validateDefinition(gateway.cliProxyGatewayDefinition), + true, + JSON.stringify(validateDefinition.errors), + ) }) diff --git a/test/support/gateway-fixture.mjs b/test/support/gateway-fixture.mjs new file mode 100644 index 0000000..37539cf --- /dev/null +++ b/test/support/gateway-fixture.mjs @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto' + +export const identity = (pluginId, serviceId) => ({ + source: `https://plugins.example.test/${pluginId}`, + pluginId, + serviceId, +}) + +export const binding = (pluginId, serviceId, generation) => ({ + registrationHandle: `msr_${serviceId}`, + serviceHandle: `mss_${serviceId}`, + identity: identity(pluginId, serviceId), + hostGeneration: 'host-one', + serviceGeneration: generation, +}) + +export const uiBinding = (pluginId, serviceId, generation) => ({ + bindingId: `binding-${serviceId}`, + identity: identity(pluginId, serviceId), + scope: { profileId: 'profile-one', generation }, +}) + +export const lease = (pluginId, serviceId, generation, operations = ['models.list']) => ({ + leaseHandle: `msl_${serviceId}`, + binding: binding(pluginId, serviceId, generation), + brokerHandle: `msb_${serviceId}`, + operations, +}) + +export const projection = (serviceBinding, authenticated = true) => ({ + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/managed-service-projection.v1.schema.json', + contract: 'cordisx.managed-service-projection/v1', + schemaVersion: 1, + binding: serviceBinding, + state: 'ready', + health: 'ready', + processOwnership: 'host-owned', + configuration: { state: 'not-required' }, + authentication: { state: 'not-required' }, + httpAuthorization: authenticated + ? { state: 'configured', authorizationHandle: `msa_${serviceBinding.identity.serviceId}` } + : { state: 'not-required' }, + connection: { brokerHandle: `msb_${serviceBinding.identity.serviceId}` }, +}) + +export function expectedNativeCatalog(modelIds) { + const routes = [...new Set(modelIds)].sort().map(modelId => ({ alias: modelId, gatewayModelId: modelId })) + const generation = `sha256:${ + createHash('sha256').update(JSON.stringify(routes.map(route => route.gatewayModelId))).digest('hex') + }` + const catalog = { generation, defaultAlias: routes[0].alias, routes } + const digest = `sha256:${ + createHash('sha256').update( + JSON.stringify([generation, catalog.defaultAlias, routes.map(route => [route.alias, route.gatewayModelId])]), + ).digest('hex') + }` + return { ...catalog, digest } +} diff --git a/test/upstream-consumer/index.ts b/test/upstream-consumer/index.ts new file mode 100644 index 0000000..ce2363d --- /dev/null +++ b/test/upstream-consumer/index.ts @@ -0,0 +1,46 @@ +import { + CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + type CliProxyUpstreamConsumerContextV1, + type CliProxyUpstreamDescriptorV1, +} from '@cordisx/plugin-cli-proxy-api/upstream/v1' + +interface SyntheticManagedServiceSource { + readonly serviceId: string +} + +const descriptor: CliProxyUpstreamDescriptorV1 = { + $schema: CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + contract: CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + schemaVersion: 1, + revision: 1, + upstreamId: 'synthetic-provider', + displayName: 'Synthetic Provider', + enabled: true, + wireApi: 'responses', + prefix: 'synthetic-provider', + order: 10, + requestTimeoutMs: 30_000, + group: { + groupId: 'synthetic', + displayName: 'Synthetic', + order: 10, + }, + models: [{ + sourceModelId: 'synthetic-model', + modelId: 'synthetic-model', + displayName: 'Synthetic Model', + enabled: true, + isDefault: true, + inputModalities: ['text'], + }], +} + +export function registerSyntheticUpstream( + ctx: CliProxyUpstreamConsumerContextV1, +) { + return ctx.cliProxyUpstreams.register({ + source: { serviceId: 'synthetic-service' }, + descriptor, + }) +} diff --git a/test/upstream-consumer/tsconfig.json b/test/upstream-consumer/tsconfig.json new file mode 100644 index 0000000..73cc24a --- /dev/null +++ b/test/upstream-consumer/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["index.ts"] +} diff --git a/test/upstream-registry.mjs b/test/upstream-registry.mjs new file mode 100644 index 0000000..dc8994e --- /dev/null +++ b/test/upstream-registry.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import Ajv2020 from 'ajv/dist/2020.js' + +const registryModule = await import('../dist/service.mjs') +const { + CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + CliProxyUpstreamRegistryV1, + createCliProxyUpstreamRegistrarV1, +} = registryModule + +const generation = 'plugin-generation-1' +const producer = (pluginId = 'producer-a', pluginGeneration = 'producer-generation-1') => ({ + pluginId, + pluginGeneration, +}) +const source = id => ({ identityHandle: `source_${id}` }) +const descriptor = (overrides = {}) => ({ + $schema: CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + contract: CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + schemaVersion: 1, + revision: 1, + upstreamId: 'provider-a', + displayName: 'Provider A', + enabled: true, + wireApi: 'chat-completions', + prefix: 'provider-a', + order: 10, + requestTimeoutMs: 30_000, + group: { groupId: 'general', displayName: 'General', order: 10 }, + models: [ + { + sourceModelId: 'source-model-a', + modelId: 'model-a', + displayName: 'Model A', + enabled: true, + isDefault: true, + inputModalities: ['text'], + }, + ], + ...overrides, +}) + +function registry() { + return new CliProxyUpstreamRegistryV1(generation, value => value.identityHandle) +} + +function registrar(upstreams, authority = producer()) { + return createCliProxyUpstreamRegistrarV1(upstreams, authority) +} + +function register(upstreams, value, authority = producer()) { + return registrar(upstreams, authority).register(value) +} + +test('upstream descriptor schema accepts only the generic public declaration', async () => { + const schema = JSON.parse( + await readFile(new URL('../schemas/cli-proxy-upstream-descriptor.v1.schema.json', import.meta.url), 'utf8'), + ) + const ajv = new Ajv2020({ allErrors: true, strict: true }) + const validate = ajv.compile(schema) + assert.equal(validate(descriptor()), true, JSON.stringify(validate.errors)) + assert.equal(validate({ ...descriptor(), endpoint: 'https://private.invalid' }), false) + assert.equal(validate({ ...descriptor(), token: 'secret' }), false) +}) + +test('registers, updates, and revokes exact upstream revisions', () => { + const upstreams = registry() + const registration = registrar(upstreams) + assert.deepEqual( + registration.register({ source: source('a'), descriptor: descriptor() }), + { + status: 'registered', + registryRevision: 1, + }, + ) + assert.deepEqual( + registration.register({ source: source('a'), descriptor: descriptor() }), + { + status: 'unchanged', + registryRevision: 1, + }, + ) + const reordered = { + models: descriptor().models, + group: descriptor().group, + requestTimeoutMs: 30_000, + order: 10, + prefix: 'provider-a', + enabled: true, + wireApi: 'chat-completions', + displayName: 'Provider A', + upstreamId: 'provider-a', + revision: 1, + schemaVersion: 1, + contract: CLI_PROXY_UPSTREAM_DESCRIPTOR_CONTRACT_V1, + $schema: CLI_PROXY_UPSTREAM_DESCRIPTOR_SCHEMA_V1, + } + assert.equal( + registration.register({ source: source('a'), descriptor: reordered }).status, + 'unchanged', + ) + assert.deepEqual( + registration.register({ + source: source('a'), + descriptor: descriptor({ revision: 2, displayName: 'Provider A Updated' }), + }), + { status: 'updated', registryRevision: 2 }, + ) + assert.deepEqual( + registration.revoke({ upstreamId: 'provider-a', expectedRevision: 1 }), + { + status: 'rejected', + diagnostic: { code: 'stale-revision', field: 'expectedRevision', message: 'Upstream revision is stale' }, + }, + ) + assert.deepEqual( + registration.revoke({ upstreamId: 'provider-a', expectedRevision: 2 }), + { status: 'revoked', registryRevision: 3 }, + ) +}) + +test('rejects stale generations and conflicting active identities without mutating the catalog', () => { + const upstreams = registry() + const authority = producer() + const registration = registrar(upstreams, authority) + registration.register({ source: source('a'), descriptor: descriptor() }) + const before = upstreams.catalog(generation) + assert.equal( + upstreams.register( + { + source: source('b'), + descriptor: descriptor({ upstreamId: 'provider-b', prefix: 'provider-b' }), + }, + 'retired-generation', + authority, + ).status, + 'stale-generation', + ) + assert.equal( + register(upstreams, { + source: source('a'), + descriptor: descriptor({ upstreamId: 'provider-b', prefix: 'provider-b' }), + }).diagnostic.code, + 'conflicting-source', + ) + assert.equal( + register(upstreams, { + source: source('b'), + descriptor: descriptor({ upstreamId: 'provider-b' }), + }).diagnostic.code, + 'conflicting-prefix', + ) + assert.deepEqual(upstreams.catalog(generation), before) +}) + +test('supports explicit alias pools and rejects conflicting public alias metadata', () => { + const upstreams = registry() + const pooled = descriptor({ + models: [ + { + sourceModelId: 'source-model-a', + modelId: 'model-pool', + displayName: 'Model Pool', + enabled: true, + isDefault: true, + inputModalities: ['text'], + }, + { + sourceModelId: 'source-model-b', + modelId: 'model-pool', + displayName: 'Model Pool', + enabled: true, + isDefault: true, + inputModalities: ['text'], + }, + ], + }) + assert.equal(register(upstreams, { source: source('a'), descriptor: pooled }).status, 'registered') + assert.deepEqual(upstreams.catalog(generation).groups[0].providers[0].models[0].sourceModelIds, [ + 'source-model-a', + 'source-model-b', + ]) + + const invalid = register(registry(), { + source: source('a'), + descriptor: descriptor({ + models: [ + { sourceModelId: 'source-model-a', modelId: 'alias', enabled: true, isDefault: true }, + { sourceModelId: 'source-model-b', modelId: 'alias', enabled: true, isDefault: false }, + ], + }), + }) + assert.equal(invalid.diagnostic.code, 'conflicting-model-alias') +}) + +test('projects deterministic groups, aliases, provenance, and CLIProxyAPI configuration semantics', () => { + const left = registry() + const right = registry() + const registrations = [ + { + source: source('b'), + descriptor: descriptor({ + upstreamId: 'provider-b', + displayName: 'Provider B', + prefix: 'provider-b', + order: 20, + group: { groupId: 'specialized', displayName: 'Specialized', order: 20 }, + models: [{ sourceModelId: 'source-model-b', modelId: 'model-b', enabled: true, isDefault: true }], + }), + }, + { source: source('a'), descriptor: descriptor() }, + ] + for (const registration of registrations) register(left, registration) + for (const registration of [...registrations].reverse()) register(right, registration) + + const leftCatalog = left.catalog(generation) + const rightCatalog = right.catalog(generation) + assert.deepEqual(leftCatalog.groups, rightCatalog.groups) + assert.deepEqual(leftCatalog.upstreamRevisions, { 'provider-a': 1, 'provider-b': 1 }) + assert.equal(leftCatalog.catalogDigest, rightCatalog.catalogDigest) + assert.deepEqual(leftCatalog.groups.map(group => group.groupId), ['general', 'specialized']) + assert.equal(leftCatalog.groups[0].providers[0].models[0].gatewayModelId, 'provider-a/model-a') + + const plan = left.gatewayPlan(generation) + assert.equal(plan.registryRevision, 2) + assert.equal(plan.catalogDigest, leftCatalog.catalogDigest) + assert.deepEqual(plan.configuration['openai-compatibility'][0], { + name: 'provider-a', + disabled: false, + prefix: 'provider-a', + 'base-url': null, + 'api-key-entries': [{ 'api-key': null }], + 'request-retry': 0, + models: [ + { + name: 'source-model-a', + alias: 'model-a', + 'display-name': 'Model A', + 'force-mapping': true, + 'input-modalities': ['text'], + }, + ], + }) + assert.deepEqual(plan.composition.map(item => item.upstreamId), ['provider-a', 'provider-b']) + assert.equal(plan.composition[0].origin.targetPointer, '/openai-compatibility/0/base-url') + assert.equal(plan.composition[0].authorization.targetPointer, '/openai-compatibility/0/api-key-entries/0/api-key') + assert.equal(JSON.stringify(plan).includes('source_a'), true) + assert.equal(JSON.stringify(plan).includes('private.invalid'), false) +}) + +test('routes a Responses-only upstream through codex-api-key without chat fallback', () => { + const upstreams = registry() + register(upstreams, { + source: source('responses'), + descriptor: descriptor({ + upstreamId: 'responses-provider', + displayName: 'Responses Provider', + prefix: 'responses-provider', + wireApi: 'responses', + }), + }) + const plan = upstreams.gatewayPlan(generation) + assert.equal(plan.configuration['openai-compatibility'].length, 0) + assert.deepEqual(plan.configuration['codex-api-key'][0], { + 'api-key': null, + prefix: 'responses-provider', + 'base-url': null, + 'request-retry': 0, + models: [{ name: 'source-model-a', alias: 'model-a', 'display-name': 'Model A', 'force-mapping': true }], + }) + assert.equal(plan.composition[0].origin.targetPointer, '/codex-api-key/0/base-url') + assert.equal(plan.composition[0].authorization.targetPointer, '/codex-api-key/0/api-key') + + const responsesOnly = path => { + if (path === '/v1/chat/completions') throw new Error('chat completions are unsupported') + assert.equal(path, '/v1/responses') + return { status: 200 } + } + const selectedPath = plan.configuration['codex-api-key'].length === 1 + ? '/v1/responses' + : '/v1/chat/completions' + assert.deepEqual(responsesOnly(selectedPath), { status: 200 }) + assert.throws(() => responsesOnly('/v1/chat/completions'), /unsupported/) +}) + +test('exposes the exact versioned registrar service shape for producer plugins', () => { + const upstreams = registry() + const registration = registrar(upstreams) + assert.deepEqual(registration.producer, producer()) + assert.equal(registration.register({ source: source('a'), descriptor: descriptor() }).status, 'registered') + assert.equal(registration.revoke({ upstreamId: 'provider-a', expectedRevision: 1 }).status, 'revoked') + assert.equal(registration.dispose().status, 'unchanged') +}) + +test('fences delayed cleanup from a replaced producer generation', () => { + const upstreams = registry() + const oldProducer = registrar(upstreams, producer('producer-a', 'generation-old')) + assert.equal(oldProducer.register({ source: source('old'), descriptor: descriptor() }).status, 'registered') + + const newProducer = registrar(upstreams, producer('producer-a', 'generation-new')) + assert.equal( + newProducer.register({ + source: source('new'), + descriptor: descriptor({ displayName: 'Provider A New' }), + }).status, + 'registered', + ) + assert.equal(oldProducer.revoke({ upstreamId: 'provider-a', expectedRevision: 1 }).status, 'stale-generation') + assert.equal(oldProducer.dispose().status, 'stale-generation') + assert.equal(upstreams.catalog(generation).groups[0].providers[0].displayName, 'Provider A New') + assert.equal(newProducer.dispose().status, 'revoked') +}) + +test('rejects cross-producer updates and revocations', () => { + const upstreams = registry() + const producerA = registrar(upstreams, producer('producer-a')) + const producerB = registrar(upstreams, producer('producer-b')) + assert.equal(producerA.register({ source: source('a'), descriptor: descriptor() }).status, 'registered') + assert.equal( + producerB.register({ + source: source('b'), + descriptor: descriptor({ revision: 2, displayName: 'Hijacked Provider' }), + }).diagnostic.code, + 'conflicting-producer', + ) + assert.equal( + producerB.revoke({ upstreamId: 'provider-a', expectedRevision: 1 }).diagnostic.code, + 'conflicting-producer', + ) + assert.equal(upstreams.catalog(generation).groups[0].providers[0].displayName, 'Provider A') +}) + +test('root disposal makes every outstanding registrar and provider read stale', () => { + const upstreams = registry() + const registration = registrar(upstreams) + assert.equal(registration.register({ source: source('a'), descriptor: descriptor() }).status, 'registered') + upstreams.dispose() + assert.equal(registration.dispose().status, 'stale-generation') + assert.equal( + registration.register({ source: source('a'), descriptor: descriptor({ revision: 2 }) }).status, + 'stale-generation', + ) + assert.equal(upstreams.catalog(generation).code, 'stale-generation') + assert.throws(() => registrar(upstreams, producer('producer-b')), /stale/) +}) diff --git a/test/upstream-subscription-manager.mjs b/test/upstream-subscription-manager.mjs new file mode 100644 index 0000000..d91ba31 --- /dev/null +++ b/test/upstream-subscription-manager.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import test from 'node:test' +import { build } from 'esbuild' + +const pageTsx = readFileSync(new URL('../src/upstream-subscription-manager-page.tsx', import.meta.url), 'utf8') +const messagesTs = readFileSync(new URL('../src/upstream-subscription-manager-messages.ts', import.meta.url), 'utf8') +const css = readFileSync(new URL('../src/upstream-subscription-manager-page.css', import.meta.url), 'utf8') +const indexTs = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8') + +async function loadPageModule() { + const directory = mkdtempSync(join(tmpdir(), 'cordisx-cliproxy-page-')) + const output = join(directory, 'manager-configuration.mjs') + await build({ + entryPoints: [new URL('../src/manager-configuration.ts', import.meta.url).pathname], + outfile: output, + bundle: true, + format: 'esm', + platform: 'node', + }) + try { + return await import(`${pathToFileURL(output).href}?${Date.now()}`) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +// --------------------------------------------------------------------------- +// Registry acquisition contract +// --------------------------------------------------------------------------- + +test('page acquires the optional gateway service captured in its factory closure', () => { + assert.match(pageTsx, /export function createUpstreamSubscriptionManagerPage\([\s\S]*managedServices:[\s\S]*manager:/) + assert.match(pageTsx, /managedServices\.get\(\{\s*serviceId:\s*SERVICE_ID\s*\}\)/s) + assert.match(pageTsx, /managedServices === undefined/) + assert.doesNotMatch(pageTsx, /reflect\.get/) + // PageProps is an alias of CordisXReactPageProps (no extra ctx field smuggled in). + assert.match(pageTsx, /type PageProps = CordisXReactPageProps/) + assert.doesNotMatch(pageTsx, /props\.ctx/) + assert.match( + indexTs, + /createUpstreamSubscriptionManagerPage\(managedServices, optionalManager\(ctx\), ctx\.notifications\)/, + ) + assert.match(indexTs, /ctx\.reflect\.get\(['"]manager['"]\)/) + assert.doesNotMatch(indexTs, /ctx\.manager\?\.openOwnPluginConfiguration/) + assert.doesNotMatch(indexTs, /createUpstreamSubscriptionManagerPage\(\s*\{\s*service:\s*undefined\s*\}\)/) +}) + +test('page imports managed-service-ui/v1 and not temporary shim', () => { + assert.match(pageTsx, /@cordisx\/protocol\/managed-service-ui\/v1/) + assert.match(indexTs, /import '@cordisx\/protocol\/managed-service-ui\/v1'/) + assert.doesNotMatch(indexTs + pageTsx, /managed-service-account-control-shim/) +}) + +// --------------------------------------------------------------------------- +// Account subscription actions use accountControl +// --------------------------------------------------------------------------- + +test('readAccounts/toggle/startOAuth/poll/cancel are wired to service.accountControl', () => { + assert.match(pageTsx, /accountControl\?\.readAccounts\(\)/) + assert.match(pageTsx, /accountControl\.toggleAccount\(/) + assert.match(pageTsx, /accountControl\.startOAuth\(/) + assert.match(pageTsx, /accountControl\.pollOAuth\(/) + assert.match(pageTsx, /accountControl\.cancelOAuth\(/) +}) + +test('mutations carry CAS revision fencing and explicit-click gesture', () => { + assert.match(pageTsx, /expectedRevision:\s*accounts\.revision/) + assert.match(pageTsx, /expectedSequence:\s*snapshot\.sequence/) + assert.match(pageTsx, /kind:\s*'explicit-click'/) + const gestureCount = (pageTsx.match(/userGesture:\s*gesture\(\)/g) || []).length + assert.ok(gestureCount >= 3, 'service login, toggle, and OAuth actions all need explicit click gestures') +}) + +test('OAuth opens authorizationUrl via Host-pattern window.open (user-activated, noopener) and polls by opaque sessionId', () => { + // Host's own MarketplacePage uses window.open(url, '_blank', 'noopener,noreferrer') for external links; + // mirroring that pattern here because there is no public openExternal capability exposed to plugins yet. + assert.match(pageTsx, /window\.open\(result\.authorizationUrl,\s*'_blank',\s*'noopener,noreferrer'\)/) + assert.match(pageTsx, /pollOAuthSession\(service,\s*result\.sessionId,\s*provider\)/) + assert.doesNotMatch(pageTsx, /authorizationUrl[^;]*token/i) +}) + +// --------------------------------------------------------------------------- +// Renderer-safe data shape +// --------------------------------------------------------------------------- + +test('UI groups cover accounts, upstreams, and runtime status', () => { + assert.match(pageTsx, /upstream\.group\.account-subscriptions/) + assert.match(pageTsx, /upstream\.group\.cordisx-upstreams/) + assert.match(pageTsx, /upstream\.group\.runtime-status/) +}) + +test('subscription UI resolves the registered upstream translation namespace', () => { + assert.doesNotMatch(pageTsx, /(?:props\.)?t\(['"](?:group|status|field|action)\./) + assert.match(pageTsx, /props\.t\(['"]upstream\.action\.refresh['"]\)/) + assert.match(indexTs, /['"]upstream\.action\.refresh['"]:/) +}) + +test('upstream catalog links account counts to providers without exposing secrets/ports', () => { + assert.match(messagesTs, /status.model-count/) + assert.match(pageTsx, /field\.account/) + // Catalog projection only surfaces redacted origin + secretConfigured flag; baseUrl/port/headers/keys stay host-side. + assert.match(pageTsx, /provider\.endpoint\.origin/) + assert.match(pageTsx, /provider\.endpoint\.secretConfigured/) + assert.doesNotMatch(pageTsx, /provider\.endpoint\.(baseUrl|url|port|headers|apiKey|authorization)/i) + assert.doesNotMatch(pageTsx, /\blocalhost\b|127\.0\.0\.1|\b0\.0\.0\.0\b/) +}) + +test('page avoids duplicated Host chrome and outer padding/scroll', () => { + assert.match(css, /\.cus\s*\{[^}]*display:\s*block/) + // Body-only layout: no wrapper that would duplicate the Host's page padding/scroll. + assert.doesNotMatch(css, /\.cus\s*\{[^}]*padding:\s*16px/) + assert.doesNotMatch(css, /\.cus\s*\{[^}]*overflow:\s*auto/) + // Only one h1-equivalent outer title per section headings are h2/h3 inside body. + assert.doesNotMatch(pageTsx, /\s]/) +}) + +test('page is reachable from Manager settings and targets the Host-owned manager.content outlet', () => { + assert.match(indexTs, /name:\s*['"]manager\.settings\.navigation-items['"]/) + assert.match(indexTs, /CORDISX_SURFACE_CONTRIBUTION_SCHEMA_V11/) + assert.match(indexTs, /schemaVersion:\s*11/) + assert.match(indexTs, /group:\s*['"]after-settings['"]/) + assert.match(indexTs, /navigationGroup:\s*\{\s*id:\s*['"]external-accounts['"]\s*\}/s) + assert.match(indexTs, /outlet:\s*['"]manager\.content['"]/) + assert.match(indexTs, /path:\s*['"]\/manager\/extensions\/cli-proxy-api\/subscriptions['"]/) + assert.match(indexTs, /ctx\.managerContent\.register\(/) + assert.match(indexTs, /chrome:\s*['"]standard['"]/) + assert.doesNotMatch(indexTs, /name:\s*['"]sidebar\.navigation\.items['"],\s*id:\s*['"]upstream-subscriptions['"]/) +}) + +test('toolbar sits in body and refresh targets acquired service', () => { + assert.match(pageTsx, //) + assert.match(pageTsx, /onClick=\{\(\) => void refresh\(service\)\}/) +}) + +test('empty account state explicitly reports the missing add/import capability', () => { + assert.match(pageTsx, /upstream\.status\.no-accounts-description/) + assert.match(indexTs, /cannot add or import accounts/) + assert.match(indexTs, /不能新增或导入账号/) + assert.doesNotMatch(pageTsx, /addAccount|importAccount|createAccount/) +}) + +test('own plugin configuration request reports opened on Host acceptance', async () => { + const { requestOwnPluginConfiguration } = await loadPageModule() + const calls = [] + const result = await requestOwnPluginConfiguration({ + openOwnPluginConfiguration: async () => { + calls.push('open') + return 'opened' + }, + }) + assert.deepEqual(calls, ['open']) + assert.deepEqual(result, { status: 'opened' }) +}) + +test('own plugin configuration request downgrades when service is missing or unavailable', async () => { + const { requestOwnPluginConfiguration } = await loadPageModule() + assert.deepEqual(await requestOwnPluginConfiguration(undefined), { status: 'unavailable' }) + assert.deepEqual( + await requestOwnPluginConfiguration({ openOwnPluginConfiguration: async () => 'unavailable' }), + { status: 'unavailable' }, + ) +}) + +test('own plugin configuration request captures rejected calls for Host error reporting', async () => { + const { requestOwnPluginConfiguration } = await loadPageModule() + assert.deepEqual( + await requestOwnPluginConfiguration({ + openOwnPluginConfiguration: async () => { + throw new Error('manager receiver rejected') + }, + }), + { status: 'failed', message: 'manager receiver rejected' }, + ) + assert.match(pageTsx, /outcome\.status === 'failed'/) + assert.match(pageTsx, /outcome\.status === 'failed'\) showError\(\)/) + assert.doesNotMatch(pageTsx, /role=['"]alert['"]|cus-notice|setError\(/) +}) + +test('runtime identifiers and diagnostics are secondary technical details', () => { + assert.match(pageTsx, / { + assert.match(pageTsx, /Disclosure, EmptyState, FieldList, Heading, Icon, Stack, StatusBadge, Text/) + assert.match(pageTsx, / { + assert.match(pageTsx, /function stateLabel/) + assert.match(pageTsx, /stateLabel\(props\.t, snapshot\.readiness\)/) + assert.match(pageTsx, /stateLabel\(props\.t, snapshot\.health\.level\)/) + assert.match(pageTsx, /stateLabel\(props\.t, snapshot\.auth\.state\)/) +}) + +test('account snapshot shape supports real auth-files style provider list', () => { + // Renderers can iterate providers and display redacted fields only. + assert.match(pageTsx, /accountsByProvider/) + assert.match(pageTsx, /account\.email \?\? account\.account \?\? account\.label/) +}) + +test('OAuth start/toggle request envelope matches Protocol v1', () => { + assert.match(pageTsx, /\$schema:[\s\S]*managed-service-cli-proxy-oauth-start/) + assert.match(pageTsx, /contract:\s*'cordisx\.managed-service-cli-proxy-oauth-start\/v1'/) + assert.match(pageTsx, /\$schema:[\s\S]*managed-service-cli-proxy-account-toggle/) + assert.match(pageTsx, /contract:\s*'cordisx\.managed-service-cli-proxy-account-toggle\/v1'/) +}) + +test('catalog provider projection remains renderer-safe', () => { + assert.match(pageTsx, /provider\.endpoint\.origin/) + assert.match(pageTsx, /provider\.endpoint\.secretConfigured/) + assert.doesNotMatch(pageTsx, /binding\.bindingId|binding\.scope\.profileId|serviceRef\.current\s*\?\.\s*binding/) +}) + +// --------------------------------------------------------------------------- +// Type-aware smoke: TypeScript parses the page file via tsc if a tsconfig is +// wired up; otherwise we at least statically assert the factory signature by +// executing a tiny module-shape check against the source. This is a focused +// structural check that does not rely on regex-only assertions of props.shape. +// --------------------------------------------------------------------------- + +test('factory is a plain function returning a React component, not a class or tag function', () => { + assert.match(pageTsx, /return function UpstreamSubscriptionManagerPage\(props: PageProps\)/) + assert.doesNotMatch(pageTsx, /class\s+UpstreamSubscriptionManagerPage/) +}) + +test('external account page keeps the stable contribution identity and deep link', () => { + assert.match(indexTs, /id:\s*['"]providers\.upstream-subscriptions['"]/) + assert.match(indexTs, /path:\s*['"]\/manager\/extensions\/cli-proxy-api\/subscriptions['"]/) + assert.match(indexTs, /route:\s*\{\s*id:\s*['"]providers\.upstream-subscriptions['"]\s*\}/s) + assert.match(indexTs, /id:\s*['"]upstream-subscriptions['"]/) +}) + +test('external account page uses Host icons and status components', () => { + assert.match(indexTs, /icon:\s*['"]host:key['"]/) + assert.match(pageTsx, /import \{[^}]*\bIcon\b[^}]*\} from ['"]cordisx\/ui['"]/) + assert.match(pageTsx, / { + assert.match(pageTsx, /await refresh\(target, false\)/) + assert.match(pageTsx, /window\.setInterval/) + assert.match(pageTsx, /refresh\(serviceRef\.current, false\)/) + assert.match(pageTsx, /15_000/) + assert.match(pageTsx, /window\.clearInterval\(refreshTimer\)/) + assert.match(pageTsx, /subscription\?\.unsubscribe\(\)/) +}) + +test('logout is explicit, sequence-fenced, refreshes, and clears transient account work', () => { + assert.match( + pageTsx, + /service\.logout\(\{[\s\S]*?binding:\s*service\.binding[\s\S]*?action:\s*['"]logout['"][\s\S]*?expectedSequence:\s*snapshot\.sequence[\s\S]*?userGesture:\s*gesture\(\)/s, + ) + assert.match(pageTsx, /snapshot\.capabilities\.logout/) + assert.match(pageTsx, /snapshot\.auth\.state !== ['"]logged-out['"]/) + assert.match( + pageTsx, + /clearOauthPoll\(\)[\s\S]*?setOauthStateValue\(null\)[\s\S]*?setPendingToggle\(null\)[\s\S]*?await refresh\(service\)/s, + ) +}) + +test('operation failures use Host notifications without exposing raw exceptions', () => { + assert.match(indexTs, /['"]notifications['"]/) + assert.match( + pageTsx, + /notifications\.show\(\{[\s\S]*?kind:\s*['"]external-account\.operation-failed['"][\s\S]*?type:\s*['"]error['"][\s\S]*?upstream\.state\.operation-error/s, + ) + assert.doesNotMatch(pageTsx, /err instanceof Error|result\.error\.message|acquired\.message/) + assert.doesNotMatch(pageTsx, /role=['"]alert['"]|cus-notice|setError\(/) +}) From de1816fd15add2f44cc3db5d1216543a41e333f4 Mon Sep 17 00:00:00 2001 From: YiJie <51358815+NWYLZW@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:04:51 +0800 Subject: [PATCH 2/6] chore(deps): update Host review pin --- README.md | 2 +- README.zh-Hans.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6ede980..aa3547b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ filesystem path, raw transport, or Fleet handle. The plugin currently pins the experimental Protocol review head `a1127780513b76f0c3b1acee70cd638b5348c6a5` and Host review head -`6afdc0353a23f7e88d7e67fff23590552457078a`. These dependencies are public but +`41d62722bc84a5cbe9320ed116faf1a80b428e57`. These dependencies are public but unmerged and unpublished. The package remains `private` to prevent npm publication; its manifest declares explicit local source distribution, and no packaged installer is available yet. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index e1f49a2..5cb0834 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -15,7 +15,7 @@ Host 负责 endpoint 与凭据解析、进程启动、不透明 workspace handle 插件当前固定实验性 Protocol review head `a1127780513b76f0c3b1acee70cd638b5348c6a5` 和 Host review head -`6afdc0353a23f7e88d7e67fff23590552457078a`。这些公开依赖尚未合并或发布。包继续保持 +`41d62722bc84a5cbe9320ed116faf1a80b428e57`。这些公开依赖尚未合并或发布。包继续保持 `private` 以阻止 npm 发布;manifest 声明显式本地源码分发,目前仍未提供安装包。 ## 开发 diff --git a/package-lock.json b/package-lock.json index 271b079..9caf498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "6.1.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "cordisx": "github:cordisx/cordisx#6afdc0353a23f7e88d7e67fff23590552457078a", + "cordisx": "github:cordisx/cordisx#41d62722bc84a5cbe9320ed116faf1a80b428e57", "dprint": "0.57.1", "esbuild": "0.28.2", "eslint": "9.39.4", @@ -2708,7 +2708,7 @@ "node_modules/cordisx": { "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#6afdc0353a23f7e88d7e67fff23590552457078a", + "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#41d62722bc84a5cbe9320ed116faf1a80b428e57", "integrity": "sha512-xMLYyYDpH1nN22T7jLllh83s+hbvFpfv3h2aR8R2p2DG9+bKCnA1RFfw0W6c/h5QztGWQuIRrx0bg+gadcIbYg==", "bundleDependencies": [ "@cordisx/schemastery-ui" diff --git a/package.json b/package.json index cc0af15..2a32a8b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@projectwallace/stylelint-plugin": "0.7.0", "@typescript-eslint/parser": "8.69.0", "@vitejs/plugin-react": "6.1.0", - "cordisx": "github:cordisx/cordisx#6afdc0353a23f7e88d7e67fff23590552457078a", + "cordisx": "github:cordisx/cordisx#41d62722bc84a5cbe9320ed116faf1a80b428e57", "dprint": "0.57.1", "eslint": "9.39.4", "stylelint": "17.15.0", From a6d2503df7aea0cfaaf33a0895a2e43cb7f37765 Mon Sep 17 00:00:00 2001 From: YiJie <51358815+NWYLZW@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:31:49 +0800 Subject: [PATCH 3/6] chore(deps): update final Host review pin --- README.md | 2 +- README.zh-Hans.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa3547b..9cac37a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ filesystem path, raw transport, or Fleet handle. The plugin currently pins the experimental Protocol review head `a1127780513b76f0c3b1acee70cd638b5348c6a5` and Host review head -`41d62722bc84a5cbe9320ed116faf1a80b428e57`. These dependencies are public but +`204d7a59e800c27258fb41435390a72cee1c5e9e`. These dependencies are public but unmerged and unpublished. The package remains `private` to prevent npm publication; its manifest declares explicit local source distribution, and no packaged installer is available yet. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 5cb0834..2c42e7c 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -15,7 +15,7 @@ Host 负责 endpoint 与凭据解析、进程启动、不透明 workspace handle 插件当前固定实验性 Protocol review head `a1127780513b76f0c3b1acee70cd638b5348c6a5` 和 Host review head -`41d62722bc84a5cbe9320ed116faf1a80b428e57`。这些公开依赖尚未合并或发布。包继续保持 +`204d7a59e800c27258fb41435390a72cee1c5e9e`。这些公开依赖尚未合并或发布。包继续保持 `private` 以阻止 npm 发布;manifest 声明显式本地源码分发,目前仍未提供安装包。 ## 开发 diff --git a/package-lock.json b/package-lock.json index 9caf498..60740d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "6.1.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "cordisx": "github:cordisx/cordisx#41d62722bc84a5cbe9320ed116faf1a80b428e57", + "cordisx": "github:cordisx/cordisx#204d7a59e800c27258fb41435390a72cee1c5e9e", "dprint": "0.57.1", "esbuild": "0.28.2", "eslint": "9.39.4", @@ -2708,7 +2708,7 @@ "node_modules/cordisx": { "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#41d62722bc84a5cbe9320ed116faf1a80b428e57", + "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#204d7a59e800c27258fb41435390a72cee1c5e9e", "integrity": "sha512-xMLYyYDpH1nN22T7jLllh83s+hbvFpfv3h2aR8R2p2DG9+bKCnA1RFfw0W6c/h5QztGWQuIRrx0bg+gadcIbYg==", "bundleDependencies": [ "@cordisx/schemastery-ui" diff --git a/package.json b/package.json index 2a32a8b..18cdeff 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@projectwallace/stylelint-plugin": "0.7.0", "@typescript-eslint/parser": "8.69.0", "@vitejs/plugin-react": "6.1.0", - "cordisx": "github:cordisx/cordisx#41d62722bc84a5cbe9320ed116faf1a80b428e57", + "cordisx": "github:cordisx/cordisx#204d7a59e800c27258fb41435390a72cee1c5e9e", "dprint": "0.57.1", "eslint": "9.39.4", "stylelint": "17.15.0", From 4ef0259df561cc721827452e73425c64792df5a9 Mon Sep 17 00:00:00 2001 From: YiJie <51358815+NWYLZW@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:51:13 +0800 Subject: [PATCH 4/6] chore(deps): pin installable Host review head --- package-lock.json | 53 +++++++++++++++++++++++++++++++---------------- package.json | 2 +- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60740d5..b010119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "6.1.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "cordisx": "github:cordisx/cordisx#204d7a59e800c27258fb41435390a72cee1c5e9e", + "cordisx": "github:cordisx/cordisx#071700425f44060693ecc9102de2227269a7536e", "dprint": "0.57.1", "esbuild": "0.28.2", "eslint": "9.39.4", @@ -129,16 +129,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/@cordisx/channel": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/cordisx/plugin-channel.git#4cee12e3a92eeed557bc9de8cc4792710918327a", - "integrity": "sha512-uErxyAHsoKSQKASW8DZM/V6cQt6rPZ8jjE4vzQ8H1rFGIhuB8mE4Qy8PAnw7Y1sFafMsB+ENVqJDrLD5CwiGTA==", - "dev": true, - "license": "AGPL-3.0-or-later", - "engines": { - "node": ">=22.19" - } - }, "node_modules/@cordisx/eslint-config": { "version": "0.1.0", "resolved": "git+ssh://git@github.com/cordisx/cordisxmono.git#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", @@ -2708,8 +2698,8 @@ "node_modules/cordisx": { "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#204d7a59e800c27258fb41435390a72cee1c5e9e", - "integrity": "sha512-xMLYyYDpH1nN22T7jLllh83s+hbvFpfv3h2aR8R2p2DG9+bKCnA1RFfw0W6c/h5QztGWQuIRrx0bg+gadcIbYg==", + "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#071700425f44060693ecc9102de2227269a7536e", + "integrity": "sha512-bH+kywY3SyKW/+YgXG6/Hq2uJbUa/YncstRkdZrIbUi54cAFjGBVRuFyhCIq00NzHuzU8qDMklwdWaLk+ZhqtA==", "bundleDependencies": [ "@cordisx/schemastery-ui" ], @@ -2719,7 +2709,6 @@ "packages/*" ], "dependencies": { - "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", @@ -2748,11 +2737,13 @@ "reicon": "1.2.1", "remark-gfm": "4.0.1", "shiki": "4.4.3", + "smol-toml": "1.8.0", "style-to-js": "1.1.21", "swr": "2.5.1", "tar": "7.5.22", "tdesign-react": "1.18.2", - "ws": "^8.18.3" + "ws": "^8.18.3", + "yaml": "2.8.1" }, "bin": { "cordisx": "packages/cli/dist/src/cli.js" @@ -2779,7 +2770,6 @@ "extraneous": true, "license": "AGPL-3.0-or-later", "dependencies": { - "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", @@ -2809,19 +2799,20 @@ "reicon": "1.2.1", "remark-gfm": "4.0.1", "shiki": "4.4.3", + "smol-toml": "1.8.0", "style-to-js": "1.1.21", "swr": "2.5.1", "tar": "7.5.22", "tdesign-react": "1.18.2", "vite": "8.2.2", - "ws": "^8.18.3" + "ws": "^8.18.3", + "yaml": "2.8.1" }, "bin": { "cordisx": "dist/src/cli.js" }, "devDependencies": { "@cordisx/channel-runtime": "file:../channel-runtime", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", "@types/node": "^22.18.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", @@ -6922,6 +6913,19 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sortablejs": { "version": "1.15.7", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", @@ -7851,6 +7855,19 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 18cdeff..2786a51 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@projectwallace/stylelint-plugin": "0.7.0", "@typescript-eslint/parser": "8.69.0", "@vitejs/plugin-react": "6.1.0", - "cordisx": "github:cordisx/cordisx#204d7a59e800c27258fb41435390a72cee1c5e9e", + "cordisx": "github:cordisx/cordisx#071700425f44060693ecc9102de2227269a7536e", "dprint": "0.57.1", "eslint": "9.39.4", "stylelint": "17.15.0", From 443938134d70aae1fcdbc50e352b0a6cdae49acc Mon Sep 17 00:00:00 2001 From: YiJie Date: Tue, 15 Sep 2026 19:57:11 +0800 Subject: [PATCH 5/6] chore(deps): pin canonical platform merges --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index b010119..280a929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "devDependencies": { "@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a0e765d6ae3e2baba0b97dda60b7f62439a570aa", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "3.18.1", "@projectwallace/stylelint-plugin": "0.7.0", @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "6.1.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "cordisx": "github:cordisx/cordisx#071700425f44060693ecc9102de2227269a7536e", + "cordisx": "github:cordisx/cordisx#d86c48de51b75836db35e2c01af88a80e9751172", "dprint": "0.57.1", "esbuild": "0.28.2", "eslint": "9.39.4", @@ -143,8 +143,8 @@ }, "node_modules/@cordisx/protocol": { "version": "0.1.0-alpha.0", - "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#a1127780513b76f0c3b1acee70cd638b5348c6a5", - "integrity": "sha512-m+/bdnP/tx0RbzISwUOfOcRVbjuarHTyNdHuX+IsrAETRmTwBTAhJI8S5Irx/2H+YZUrsU23JcUxMZCfcjFwxQ==", + "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#a0e765d6ae3e2baba0b97dda60b7f62439a570aa", + "integrity": "sha512-HhNppMPoE+0jaoeU2J5dAPDHQvaLvNuRQtwdrsvh3eZXXJaDlEjgfXzNWokAJghHTNaCIDFhQmt/Ih2xIWLK6Q==", "dev": true, "license": "AGPL-3.0-or-later", "engines": { @@ -2698,8 +2698,8 @@ "node_modules/cordisx": { "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#071700425f44060693ecc9102de2227269a7536e", - "integrity": "sha512-bH+kywY3SyKW/+YgXG6/Hq2uJbUa/YncstRkdZrIbUi54cAFjGBVRuFyhCIq00NzHuzU8qDMklwdWaLk+ZhqtA==", + "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#d86c48de51b75836db35e2c01af88a80e9751172", + "integrity": "sha512-AXu2YdUTnpUcUCo8Lc7y9XZXbeKpMeoDiP7KDNk9aASAlDLH2pmsq5XKK8emo7er9yDNV0HeLRtAVYez5EWBtA==", "bundleDependencies": [ "@cordisx/schemastery-ui" ], @@ -2709,7 +2709,7 @@ "packages/*" ], "dependencies": { - "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a0e765d6ae3e2baba0b97dda60b7f62439a570aa", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "^3.18.1", @@ -2770,7 +2770,7 @@ "extraneous": true, "license": "AGPL-3.0-or-later", "dependencies": { - "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a0e765d6ae3e2baba0b97dda60b7f62439a570aa", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "^3.18.1", diff --git a/package.json b/package.json index 2786a51..7e717ba 100644 --- a/package.json +++ b/package.json @@ -51,13 +51,13 @@ }, "devDependencies": { "@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#a1127780513b76f0c3b1acee70cd638b5348c6a5", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#a0e765d6ae3e2baba0b97dda60b7f62439a570aa", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "3.18.1", "@projectwallace/stylelint-plugin": "0.7.0", "@typescript-eslint/parser": "8.69.0", "@vitejs/plugin-react": "6.1.0", - "cordisx": "github:cordisx/cordisx#071700425f44060693ecc9102de2227269a7536e", + "cordisx": "github:cordisx/cordisx#d86c48de51b75836db35e2c01af88a80e9751172", "dprint": "0.57.1", "eslint": "9.39.4", "stylelint": "17.15.0", From 64fe6eb778eb3ad98951b43adfc18e0e6b18a024 Mon Sep 17 00:00:00 2001 From: YiJie Date: Tue, 15 Sep 2026 20:01:36 +0800 Subject: [PATCH 6/6] test: resolve installed Protocol schemas --- test/manifest.mjs | 2 +- test/package-artifact.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/manifest.mjs b/test/manifest.mjs index 41245f8..c1b94d2 100644 --- a/test/manifest.mjs +++ b/test/manifest.mjs @@ -8,7 +8,7 @@ import Ajv2020 from 'ajv/dist/2020.js' import addFormats from 'ajv-formats' const protocolSchemas = process.env.CORDISX_PROTOCOL_ROOT === undefined - ? new URL('../../cordisx-protocol/schemas/', import.meta.url) + ? new URL('../node_modules/@cordisx/protocol/schemas/', import.meta.url) : new URL('schemas/', pathToFileURL(`${resolve(process.env.CORDISX_PROTOCOL_ROOT)}/`)) async function protocolValidator(schemaFile) { diff --git a/test/package-artifact.mjs b/test/package-artifact.mjs index 8650f9f..25a1fab 100644 --- a/test/package-artifact.mjs +++ b/test/package-artifact.mjs @@ -11,7 +11,7 @@ import addFormats from 'ajv-formats' const execFileAsync = promisify(execFile) const schemas = process.env.CORDISX_PROTOCOL_ROOT === undefined - ? new URL('../../cordisx-protocol/schemas/', import.meta.url) + ? new URL('../node_modules/@cordisx/protocol/schemas/', import.meta.url) : new URL('schemas/', pathToFileURL(`${resolve(process.env.CORDISX_PROTOCOL_ROOT)}/`)) const gatewayRuntimeResources = [ {