From f96ee99bbc8bd13863f7a5109ac8755a70bb73d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:28:55 -0700 Subject: [PATCH 1/9] Plugin SDK: harden provider auth seams --- extensions/openrouter/index.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/xai/index.ts | 2 +- extensions/zai/index.ts | 2 +- package.json | 4 ++ scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/agent-runtime.ts | 50 ++++++++++++++++++++++++- src/plugin-sdk/provider-auth-api-key.ts | 21 +++++++++++ 8 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 src/plugin-sdk/provider-auth-api-key.ts diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index bcb75ecb49d..6b9ffbd2a1a 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -4,7 +4,7 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { applyXaiModelCompat, DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models"; import { getOpenRouterModelCapabilities, diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index cdf984bb99e..2cef47dc3c3 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 6fa925637b8..0f0784c315f 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models"; import { createToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 79ae3a9d8aa..ee4aa0b30bc 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -15,7 +15,7 @@ import { type SecretInput, upsertAuthProfile, validateApiKeyInput, -} from "openclaw/plugin-sdk/provider-auth"; +} from "openclaw/plugin-sdk/provider-auth-api-key"; import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { createZaiToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; import { fetchZaiUsage, resolveLegacyPiAgentAccessToken } from "openclaw/plugin-sdk/provider-usage"; diff --git a/package.json b/package.json index a181861c2ae..e3dfda5cd75 100644 --- a/package.json +++ b/package.json @@ -414,6 +414,10 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-auth-api-key": { + "types": "./dist/plugin-sdk/provider-auth-api-key.d.ts", + "default": "./dist/plugin-sdk/provider-auth-api-key.js" + }, "./plugin-sdk/provider-auth-login": { "types": "./dist/plugin-sdk/provider-auth-login.d.ts", "default": "./dist/plugin-sdk/provider-auth-login.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 7378f3b4d9d..ac54dabe731 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -93,6 +93,7 @@ "json-store", "keyed-async-queue", "provider-auth", + "provider-auth-api-key", "provider-auth-login", "provider-catalog", "provider-models", diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index c5313f681cc..a7191fd5a01 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -1,7 +1,6 @@ // Public agent/model/runtime helpers for plugins that integrate with core agent flows. export * from "../agents/agent-scope.js"; -export * from "../agents/auth-profiles.js"; export * from "../agents/current-time.js"; export * from "../agents/date-time.js"; export * from "../agents/defaults.js"; @@ -25,3 +24,52 @@ export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; + +export { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + dedupeProfileIds, + listProfilesForProvider, + markAuthProfileGood, + setAuthProfileOrder, + upsertAuthProfile, + upsertAuthProfileWithLock, + repairOAuthProfileIdMismatch, + suggestOAuthProfileIdForLegacyDefault, + clearRuntimeAuthProfileStoreSnapshots, + ensureAuthProfileStore, + loadAuthProfileStoreForSecretsRuntime, + loadAuthProfileStoreForRuntime, + replaceRuntimeAuthProfileStoreSnapshots, + loadAuthProfileStore, + saveAuthProfileStore, + calculateAuthProfileCooldownMs, + clearAuthProfileCooldown, + clearExpiredCooldowns, + getSoonestCooldownExpiry, + isProfileInCooldown, + markAuthProfileCooldown, + markAuthProfileFailure, + markAuthProfileUsed, + resolveProfilesUnavailableReason, + resolveProfileUnusableUntilForDisplay, + resolveApiKeyForProfile, + resolveAuthProfileDisplayLabel, + formatAuthDoctorHint, + resolveAuthProfileEligibility, + resolveAuthProfileOrder, + resolveAuthStorePathForDisplay, +} from "../agents/auth-profiles.js"; +export type { + ApiKeyCredential, + AuthCredentialReasonCode, + AuthProfileCredential, + AuthProfileEligibilityReasonCode, + AuthProfileFailureReason, + AuthProfileIdRepairResult, + AuthProfileStore, + OAuthCredential, + ProfileUsageStats, + TokenCredential, + TokenExpiryState, +} from "../agents/auth-profiles.js"; diff --git a/src/plugin-sdk/provider-auth-api-key.ts b/src/plugin-sdk/provider-auth-api-key.ts new file mode 100644 index 00000000000..b083d8e27cb --- /dev/null +++ b/src/plugin-sdk/provider-auth-api-key.ts @@ -0,0 +1,21 @@ +// Public API-key onboarding helpers for provider plugins. + +export type { OpenClawConfig } from "../config/config.js"; +export type { SecretInput } from "../config/types.secrets.js"; + +export { upsertAuthProfile } from "../agents/auth-profiles.js"; +export { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; +export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; +export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; +export { + normalizeOptionalSecretInput, + normalizeSecretInput, +} from "../utils/normalize-secret-input.js"; From 238c036b0d49e0c452e9bfb79acaee58eeeb118f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:43:43 -0700 Subject: [PATCH 2/9] Tlon: pin api-beta to current known-good commit --- extensions/tlon/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index f909834f1c6..2fce246d283 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87", + "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c", "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1439fa6b2a6..d01869b8fd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,8 +530,8 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 - version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c + version: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c '@tloncorp/tlon-skill': specifier: 0.2.2 version: 0.2.2 @@ -3426,8 +3426,8 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': - resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': + resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c} version: 0.0.2 '@tloncorp/tlon-skill-darwin-arm64@0.2.2': @@ -10849,7 +10849,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 From b9e08a6839d36bc9c38c9d0c8650c4a33f962d5c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:45:00 -0700 Subject: [PATCH 3/9] Config: align model compat thinking format types --- src/config/types.models.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/types.models.ts b/src/config/types.models.ts index bc79f24943f..e1d60bcf695 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -22,13 +22,17 @@ type SupportedOpenAICompatFields = Pick< | "supportsUsageInStreaming" | "supportsStrictMode" | "maxTokensField" - | "thinkingFormat" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresThinkingAsText" >; +type SupportedThinkingFormat = + | NonNullable + | "qwen-chat-template"; + export type ModelCompatConfig = SupportedOpenAICompatFields & { + thinkingFormat?: SupportedThinkingFormat; supportsTools?: boolean; toolSchemaProfile?: "xai"; nativeWebSearchTool?: boolean; From f2655e1e92f2109bfe2e53744381bb65986a0ce5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 15:37:24 +0530 Subject: [PATCH 4/9] test(telegram): fix incomplete sticker-cache mocks in tests --- extensions/telegram/src/bot-message-dispatch.test.ts | 4 ++++ .../src/bot/delivery.resolve-media-retry.test.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index ea1c098e7b6..177e045f9e8 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -41,6 +41,10 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => { vi.mock("./sticker-cache.js", () => ({ cacheSticker: vi.fn(), + getCachedSticker: () => null, + getCacheStats: () => ({ count: 0 }), + searchStickers: () => [], + getAllCachedStickers: () => [], describeStickerImage: vi.fn(), })); diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 54dcf963997..b1cd7eb4d8a 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -28,9 +28,13 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { vi.mock("../sticker-cache.js", () => ({ cacheSticker: () => {}, getCachedSticker: () => null, + getCacheStats: () => ({ count: 0 }), + searchStickers: () => [], + getAllCachedStickers: () => [], + describeStickerImage: async () => null, })); -let resolveMedia: typeof import("./delivery.js").resolveMedia; +import { resolveMedia } from "./delivery.js"; const MAX_MEDIA_BYTES = 10_000_000; const BOT_TOKEN = "tok123"; @@ -165,9 +169,7 @@ async function flushRetryTimers() { } describe("resolveMedia getFile retry", () => { - beforeEach(async () => { - vi.resetModules(); - ({ resolveMedia } = await import("./delivery.js")); + beforeEach(() => { vi.useFakeTimers(); fetchRemoteMedia.mockReset(); saveMediaBuffer.mockReset(); From 0e9b899aee38614287a92ee1e2a0f790002504a7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 15:54:02 +0530 Subject: [PATCH 5/9] test: enable vmForks for targeted channel test runs Channel tests were always using process forks, missing the shared transform cache that vmForks provides. This caused ~138s import overhead per file. Now uses vmForks when available, matching the pattern already used by unit-fast and extensions suites. --- scripts/test-parallel.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index dd933b4e4ae..11bd12c185c 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -487,7 +487,7 @@ const createTargetedEntry = (owner, isolated, filters) => { "run", "--config", "vitest.channels.config.ts", - ...(forceForks ? ["--pool=forks"] : []), + ...(forceForks ? ["--pool=forks"] : useVmForks ? ["--pool=vmForks"] : []), ...filters, ], }; From 06832112ee7ae6f06cf83db81703d4908f08563b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:51:22 -0500 Subject: [PATCH 6/9] ci enforce boundary guardrails --- .github/workflows/ci.yml | 124 +++------------------------------------ package.json | 2 +- 2 files changed, 9 insertions(+), 117 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c2ffe0e87b..96ab35a297e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,8 +309,6 @@ jobs: needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -323,41 +321,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run plugin extension boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:plugins:no-extension-imports >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:plugins:no-extension-imports', remove src/plugins/** -> extensions/** imports where possible, and if the remaining inventory is intentional for now update test/fixtures/plugin-extension-import-boundary-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Plugin extension import boundary violations are temporarily allowed until ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Plugin extension import boundary grace period ended at ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run plugin extension boundary guard + run: pnpm run lint:plugins:no-extension-imports web-search-provider-boundary: name: "web-search-provider-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -370,41 +341,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run web search provider boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:web-search-provider-boundaries >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:web-search-provider-boundaries', move provider-specific web-search logic out of core, and if the remaining inventory is intentional for now update test/fixtures/web-search-provider-boundary-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Web search provider boundary violations are temporarily allowed until ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run web search provider boundary guard + run: pnpm run lint:web-search-provider-boundaries extension-src-outside-plugin-sdk-boundary: name: "extension-src-outside-plugin-sdk-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -417,41 +361,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run extension src boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:extensions:no-src-outside-plugin-sdk >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-src-outside-plugin-sdk', move extension imports off core src paths and onto src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-src-outside-plugin-sdk-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Extension src boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Extension src boundary grace period ended at ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run extension src boundary guard + run: pnpm run lint:extensions:no-src-outside-plugin-sdk extension-plugin-sdk-internal-boundary: name: "extension-plugin-sdk-internal-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -464,33 +381,8 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run extension plugin-sdk-internal guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:extensions:no-plugin-sdk-internal >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-plugin-sdk-internal', remove extension imports of src/plugin-sdk-internal/** in favor of src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-plugin-sdk-internal-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Extension plugin-sdk-internal boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Extension plugin-sdk-internal boundary grace period ended at ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run extension plugin-sdk-internal guard + run: pnpm run lint:extensions:no-plugin-sdk-internal build-smoke: name: "build-smoke" diff --git a/package.json b/package.json index e3dfda5cd75..5087d9bdf72 100644 --- a/package.json +++ b/package.json @@ -511,7 +511,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", From f58e0f5592fc0b58767dc941a4c2171238e9ef0b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:04:50 -0500 Subject: [PATCH 7/9] test simplify zero-state boundary guards --- .../check-extension-plugin-sdk-boundary.mjs | 16 +- .../check-web-search-provider-boundaries.mjs | 9 +- test/extension-plugin-sdk-boundary.test.ts | 59 +-- ...tension-plugin-sdk-internal-inventory.json | 1 - ...sion-src-outside-plugin-sdk-inventory.json | 418 ------------------ ...eb-search-provider-boundary-inventory.json | 1 - test/web-search-provider-boundary.test.ts | 28 +- 7 files changed, 37 insertions(+), 495 deletions(-) delete mode 100644 test/fixtures/extension-plugin-sdk-internal-inventory.json delete mode 100644 test/fixtures/extension-src-outside-plugin-sdk-inventory.json delete mode 100644 test/fixtures/web-search-provider-boundary-inventory.json diff --git a/scripts/check-extension-plugin-sdk-boundary.mjs b/scripts/check-extension-plugin-sdk-boundary.mjs index 90933218501..43046d8ab5f 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mjs +++ b/scripts/check-extension-plugin-sdk-boundary.mjs @@ -43,6 +43,7 @@ function isCodeFile(fileName) { function isTestLikeFile(relativePath) { return ( /(^|\/)(__tests__|fixtures)\//.test(relativePath) || + /(^|\/)[^/]*test-support\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath) || /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath) ); } @@ -190,7 +191,20 @@ export async function collectExtensionPluginSdkBoundaryInventory(mode) { } export async function readExpectedInventory(mode) { - return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8")); + try { + return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8")); + } catch (error) { + if ( + (mode === "plugin-sdk-internal" || mode === "src-outside-plugin-sdk") && + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } } export function diffInventory(expected, actual) { diff --git a/scripts/check-web-search-provider-boundaries.mjs b/scripts/check-web-search-provider-boundaries.mjs index ae680bc4124..2ba31b465c0 100644 --- a/scripts/check-web-search-provider-boundaries.mjs +++ b/scripts/check-web-search-provider-boundaries.mjs @@ -214,7 +214,14 @@ export async function collectWebSearchProviderBoundaryInventory() { } export async function readExpectedInventory() { - return JSON.parse(await fs.readFile(baselinePath, "utf8")); + try { + return JSON.parse(await fs.readFile(baselinePath, "utf8")); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } } export function diffInventory(expected, actual) { diff --git a/test/extension-plugin-sdk-boundary.test.ts b/test/extension-plugin-sdk-boundary.test.ts index 90372348a95..ea421d2708f 100644 --- a/test/extension-plugin-sdk-boundary.test.ts +++ b/test/extension-plugin-sdk-boundary.test.ts @@ -1,20 +1,18 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - collectExtensionPluginSdkBoundaryInventory, - diffInventory, -} from "../scripts/check-extension-plugin-sdk-boundary.mjs"; +import { collectExtensionPluginSdkBoundaryInventory } from "../scripts/check-extension-plugin-sdk-boundary.mjs"; const repoRoot = process.cwd(); const scriptPath = path.join(repoRoot, "scripts", "check-extension-plugin-sdk-boundary.mjs"); -function readBaseline(fileName: string) { - return JSON.parse(readFileSync(path.join(repoRoot, "test", "fixtures", fileName), "utf8")); -} - describe("extension src outside plugin-sdk boundary inventory", () => { + it("is currently empty", async () => { + const inventory = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); + + expect(inventory).toEqual([]); + }); + it("produces stable sorted output", async () => { const first = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); const second = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); @@ -33,31 +31,7 @@ describe("extension src outside plugin-sdk boundary inventory", () => { ).toEqual(first); }); - it("captures known current production violations", async () => { - const inventory = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); - - expect(inventory).toContainEqual( - expect.objectContaining({ - file: "extensions/brave/src/brave-web-search-provider.ts", - resolvedPath: "src/agents/tools/common.js", - }), - ); - expect(inventory).toContainEqual( - expect.objectContaining({ - file: "extensions/discord/src/runtime-api.ts", - resolvedPath: "src/config/types.secrets.js", - }), - ); - }); - - it("matches the checked-in baseline", async () => { - const expected = readBaseline("extension-src-outside-plugin-sdk-inventory.json"); - const actual = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - }); - - it("script json output matches the baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync( process.execPath, [scriptPath, "--mode=src-outside-plugin-sdk", "--json"], @@ -67,9 +41,7 @@ describe("extension src outside plugin-sdk boundary inventory", () => { }, ); - expect(JSON.parse(stdout)).toEqual( - readBaseline("extension-src-outside-plugin-sdk-inventory.json"), - ); + expect(JSON.parse(stdout)).toEqual([]); }); }); @@ -80,14 +52,7 @@ describe("extension plugin-sdk-internal boundary inventory", () => { expect(inventory).toEqual([]); }); - it("matches the checked-in empty baseline", async () => { - const expected = readBaseline("extension-plugin-sdk-internal-inventory.json"); - const actual = await collectExtensionPluginSdkBoundaryInventory("plugin-sdk-internal"); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - }); - - it("script json output matches the empty baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync( process.execPath, [scriptPath, "--mode=plugin-sdk-internal", "--json"], @@ -97,8 +62,6 @@ describe("extension plugin-sdk-internal boundary inventory", () => { }, ); - expect(JSON.parse(stdout)).toEqual( - readBaseline("extension-plugin-sdk-internal-inventory.json"), - ); + expect(JSON.parse(stdout)).toEqual([]); }); }); diff --git a/test/fixtures/extension-plugin-sdk-internal-inventory.json b/test/fixtures/extension-plugin-sdk-internal-inventory.json deleted file mode 100644 index fe51488c706..00000000000 --- a/test/fixtures/extension-plugin-sdk-internal-inventory.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json b/test/fixtures/extension-src-outside-plugin-sdk-inventory.json deleted file mode 100644 index 3c5aff2a370..00000000000 --- a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json +++ /dev/null @@ -1,418 +0,0 @@ -[ - { - "file": "extensions/discord/src/directory-config.ts", - "line": 7, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.discord.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.discord.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/discord/src/directory-config.ts", - "line": 8, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 10, - "kind": "export", - "specifier": "../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 23, - "kind": "export", - "specifier": "../../src/channels/mention-gating.js", - "resolvedPath": "src/channels/mention-gating.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 30, - "kind": "export", - "specifier": "../../src/channels/plugins/config-schema.js", - "resolvedPath": "src/channels/plugins/config-schema.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 34, - "kind": "export", - "specifier": "../../src/channels/plugins/config-helpers.js", - "resolvedPath": "src/channels/plugins/config-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 38, - "kind": "export", - "specifier": "../../src/channels/plugins/directory-config-helpers.js", - "resolvedPath": "src/channels/plugins/directory-config-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 39, - "kind": "export", - "specifier": "../../src/channels/plugins/helpers.js", - "resolvedPath": "src/channels/plugins/helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 40, - "kind": "export", - "specifier": "../../src/channels/plugins/media-limits.js", - "resolvedPath": "src/channels/plugins/media-limits.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 46, - "kind": "export", - "specifier": "../../src/channels/plugins/setup-wizard-helpers.js", - "resolvedPath": "src/channels/plugins/setup-wizard-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 47, - "kind": "export", - "specifier": "../../src/channels/plugins/pairing-message.js", - "resolvedPath": "src/channels/plugins/pairing-message.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 52, - "kind": "export", - "specifier": "../../src/channels/plugins/setup-helpers.js", - "resolvedPath": "src/channels/plugins/setup-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 53, - "kind": "export", - "specifier": "../../src/channels/plugins/account-helpers.js", - "resolvedPath": "src/channels/plugins/account-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 59, - "kind": "export", - "specifier": "../../src/channels/plugins/types.js", - "resolvedPath": "src/channels/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 60, - "kind": "export", - "specifier": "../../src/channels/plugins/types.plugin.js", - "resolvedPath": "src/channels/plugins/types.plugin.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 61, - "kind": "export", - "specifier": "../../src/channels/registry.js", - "resolvedPath": "src/channels/registry.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 62, - "kind": "export", - "specifier": "../../src/channels/reply-prefix.js", - "resolvedPath": "src/channels/reply-prefix.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 63, - "kind": "export", - "specifier": "../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 64, - "kind": "export", - "specifier": "../../src/config/dangerous-name-matching.js", - "resolvedPath": "src/config/dangerous-name-matching.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 70, - "kind": "export", - "specifier": "../../src/config/runtime-group-policy.js", - "resolvedPath": "src/config/runtime-group-policy.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 75, - "kind": "export", - "specifier": "../../src/config/types.js", - "resolvedPath": "src/config/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 76, - "kind": "export", - "specifier": "../../src/config/types.secrets.js", - "resolvedPath": "src/config/types.secrets.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 77, - "kind": "export", - "specifier": "../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 78, - "kind": "export", - "specifier": "../../src/infra/net/fetch-guard.js", - "resolvedPath": "src/infra/net/fetch-guard.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 79, - "kind": "export", - "specifier": "../../src/infra/outbound/target-errors.js", - "resolvedPath": "src/infra/outbound/target-errors.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 80, - "kind": "export", - "specifier": "../../src/plugins/config-schema.js", - "resolvedPath": "src/plugins/config-schema.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 81, - "kind": "export", - "specifier": "../../src/plugins/runtime/types.js", - "resolvedPath": "src/plugins/runtime/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 82, - "kind": "export", - "specifier": "../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 83, - "kind": "export", - "specifier": "../../src/routing/session-key.js", - "resolvedPath": "src/routing/session-key.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 84, - "kind": "export", - "specifier": "../../src/security/dm-policy-shared.js", - "resolvedPath": "src/security/dm-policy-shared.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 85, - "kind": "export", - "specifier": "../../src/terminal/links.js", - "resolvedPath": "src/terminal/links.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 86, - "kind": "export", - "specifier": "../../src/wizard/prompts.js", - "resolvedPath": "src/wizard/prompts.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 89, - "kind": "export", - "specifier": "../../src/pairing/pairing-challenge.js", - "resolvedPath": "src/pairing/pairing-challenge.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 1, - "kind": "export", - "specifier": "../../src/config/types.imessage.js", - "resolvedPath": "src/config/types.imessage.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 2, - "kind": "export", - "specifier": "../../src/channels/plugins/types.plugin.js", - "resolvedPath": "src/channels/plugins/types.plugin.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 15, - "kind": "export", - "specifier": "../../src/channels/plugins/media-limits.js", - "resolvedPath": "src/channels/plugins/media-limits.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 19, - "kind": "export", - "specifier": "../../src/channels/plugins/normalize/imessage.js", - "resolvedPath": "src/channels/plugins/normalize/imessage.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 20, - "kind": "export", - "specifier": "../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 8, - "kind": "import", - "specifier": "../../../src/channels/plugins/normalize/slack.js", - "resolvedPath": "src/channels/plugins/normalize/slack.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 9, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 10, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.slack.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.slack.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 1, - "kind": "export", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 2, - "kind": "export", - "specifier": "../../../src/config/types.slack.js", - "resolvedPath": "src/config/types.slack.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 3, - "kind": "export", - "specifier": "../../../src/channels/plugins/types.js", - "resolvedPath": "src/channels/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 19, - "kind": "export", - "specifier": "../../../src/channels/plugins/normalize/slack.js", - "resolvedPath": "src/channels/plugins/normalize/slack.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 23, - "kind": "export", - "specifier": "../../../src/channels/account-snapshot-fields.js", - "resolvedPath": "src/channels/account-snapshot-fields.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 24, - "kind": "export", - "specifier": "../../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 32, - "kind": "export", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 33, - "kind": "export", - "specifier": "../../../src/agents/date-time.js", - "resolvedPath": "src/agents/date-time.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/telegram/src/directory-config.ts", - "line": 9, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/telegram/src/directory-config.ts", - "line": 10, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.telegram.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.telegram.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/whatsapp/src/directory-config.ts", - "line": 6, - "kind": "import", - "specifier": "../../../src/whatsapp/normalize.js", - "resolvedPath": "src/whatsapp/normalize.js", - "reason": "imports core src path outside plugin-sdk from an extension" - } -] diff --git a/test/fixtures/web-search-provider-boundary-inventory.json b/test/fixtures/web-search-provider-boundary-inventory.json deleted file mode 100644 index fe51488c706..00000000000 --- a/test/fixtures/web-search-provider-boundary-inventory.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/test/web-search-provider-boundary.test.ts b/test/web-search-provider-boundary.test.ts index b75c137ca98..f211a262ca3 100644 --- a/test/web-search-provider-boundary.test.ts +++ b/test/web-search-provider-boundary.test.ts @@ -1,24 +1,10 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - collectWebSearchProviderBoundaryInventory, - diffInventory, -} from "../scripts/check-web-search-provider-boundaries.mjs"; +import { collectWebSearchProviderBoundaryInventory } from "../scripts/check-web-search-provider-boundaries.mjs"; const repoRoot = process.cwd(); const scriptPath = path.join(repoRoot, "scripts", "check-web-search-provider-boundaries.mjs"); -const baselinePath = path.join( - repoRoot, - "test", - "fixtures", - "web-search-provider-boundary-inventory.json", -); - -function readBaseline() { - return JSON.parse(readFileSync(baselinePath, "utf8")); -} describe("web search provider boundary inventory", () => { it("has no remaining production inventory in core", async () => { @@ -49,20 +35,12 @@ describe("web search provider boundary inventory", () => { ).toEqual(first); }); - it("matches the checked-in baseline", async () => { - const expected = readBaseline(); - const actual = await collectWebSearchProviderBoundaryInventory(); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - expect(actual).toEqual([]); - }); - - it("script json output matches the baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync(process.execPath, [scriptPath, "--json"], { cwd: repoRoot, encoding: "utf8", }); - expect(JSON.parse(stdout)).toEqual(readBaseline()); + expect(JSON.parse(stdout)).toEqual([]); }); }); From 089a43f5e88b4ba4f383567c14934a4fce748a5f Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Wed, 18 Mar 2026 13:11:01 +0100 Subject: [PATCH 8/9] fix(security): block build-tool and glibc env injection vectors in host exec sandbox (#49702) Add GLIBC_TUNABLES, MAVEN_OPTS, SBT_OPTS, GRADLE_OPTS, ANT_OPTS, DOTNET_ADDITIONAL_DEPS to blockedKeys and GRADLE_USER_HOME to blockedOverrideKeys in the host exec security policy. Closes #22681 --- CHANGELOG.md | 1 + .../HostEnvSecurityPolicy.generated.swift | 9 ++++++++- src/infra/host-env-security-policy.json | 9 ++++++++- src/infra/host-env-security.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471970d48d6..aa76166bf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ Docs: https://docs.openclaw.ai - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. - Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. +- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) ## 2026.3.13 diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index ecdbdd0d77c..40db384b226 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -28,11 +28,18 @@ enum HostEnvSecurityPolicy { "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "PYTHONBREAKPOINT", - "DOTNET_STARTUP_HOOKS" + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "GLIBC_TUNABLES", + "MAVEN_OPTS", + "SBT_OPTS", + "GRADLE_OPTS", + "ANT_OPTS" ] static let blockedOverrideKeys: Set = [ "HOME", + "GRADLE_USER_HOME", "ZDOTDIR", "GIT_SSH_COMMAND", "GIT_SSH", diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index bf99f458e58..785b8e37049 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -22,10 +22,17 @@ "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "PYTHONBREAKPOINT", - "DOTNET_STARTUP_HOOKS" + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "GLIBC_TUNABLES", + "MAVEN_OPTS", + "SBT_OPTS", + "GRADLE_OPTS", + "ANT_OPTS" ], "blockedOverrideKeys": [ "HOME", + "GRADLE_USER_HOME", "ZDOTDIR", "GIT_SSH_COMMAND", "GIT_SSH", diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index fe194eabc28..cd3edb3e06b 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -58,8 +58,21 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("pythonbreakpoint")).toBe(true); expect(isDangerousHostEnvVarName("DOTNET_STARTUP_HOOKS")).toBe(true); expect(isDangerousHostEnvVarName("dotnet_startup_hooks")).toBe(true); + expect(isDangerousHostEnvVarName("DOTNET_ADDITIONAL_DEPS")).toBe(true); + expect(isDangerousHostEnvVarName("dotnet_additional_deps")).toBe(true); + expect(isDangerousHostEnvVarName("GLIBC_TUNABLES")).toBe(true); + expect(isDangerousHostEnvVarName("glibc_tunables")).toBe(true); + expect(isDangerousHostEnvVarName("MAVEN_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("maven_opts")).toBe(true); + expect(isDangerousHostEnvVarName("SBT_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("sbt_opts")).toBe(true); + expect(isDangerousHostEnvVarName("GRADLE_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("gradle_opts")).toBe(true); + expect(isDangerousHostEnvVarName("ANT_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("ant_opts")).toBe(true); expect(isDangerousHostEnvVarName("PATH")).toBe(false); expect(isDangerousHostEnvVarName("FOO")).toBe(false); + expect(isDangerousHostEnvVarName("GRADLE_USER_HOME")).toBe(false); }); }); @@ -197,6 +210,8 @@ describe("isDangerousHostEnvOverrideVarName", () => { expect(isDangerousHostEnvOverrideVarName("editor")).toBe(true); expect(isDangerousHostEnvOverrideVarName("NPM_CONFIG_USERCONFIG")).toBe(true); expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); From d41c9ad4cb71352b219de2adab0dd59e1caa0ffd Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:44:23 +0100 Subject: [PATCH 9/9] Release: add plugin npm publish workflow (#47678) * Release: add plugin npm publish workflow * Release: make plugin publish scope explicit --- .github/workflows/plugin-npm-release.yml | 214 ++++++++++++ extensions/bluebubbles/package.json | 3 + extensions/diagnostics-otel/package.json | 5 +- extensions/discord/package.json | 5 +- extensions/feishu/package.json | 3 + extensions/lobster/package.json | 5 +- extensions/matrix/package.json | 3 + extensions/msteams/package.json | 3 + extensions/nextcloud-talk/package.json | 3 + extensions/nostr/package.json | 3 + extensions/voice-call/package.json | 5 +- extensions/zalo/package.json | 3 + extensions/zalouser/package.json | 3 + package.json | 2 + scripts/lib/plugin-npm-release.ts | 394 +++++++++++++++++++++++ scripts/plugin-npm-publish.sh | 45 +++ scripts/plugin-npm-release-check.ts | 47 +++ scripts/plugin-npm-release-plan.ts | 18 ++ scripts/release-check.ts | 58 ---- test/plugin-npm-release.test.ts | 217 +++++++++++++ 20 files changed, 977 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/plugin-npm-release.yml create mode 100644 scripts/lib/plugin-npm-release.ts create mode 100644 scripts/plugin-npm-publish.sh create mode 100644 scripts/plugin-npm-release-check.ts create mode 100644 scripts/plugin-npm-release-plan.ts create mode 100644 test/plugin-npm-release.test.ts diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml new file mode 100644 index 00000000000..3507a0b68a1 --- /dev/null +++ b/.github/workflows/plugin-npm-release.yml @@ -0,0 +1,214 @@ +name: Plugin NPM Release + +on: + push: + branches: + - main + paths: + - ".github/workflows/plugin-npm-release.yml" + - "extensions/**" + - "package.json" + - "scripts/lib/plugin-npm-release.ts" + - "scripts/plugin-npm-publish.sh" + - "scripts/plugin-npm-release-check.ts" + - "scripts/plugin-npm-release-plan.ts" + workflow_dispatch: + inputs: + publish_scope: + description: Publish the selected plugins or all publishable plugins from the ref + required: true + default: selected + type: choice + options: + - selected + - all-publishable + ref: + description: Commit SHA on main to publish from (copy from the preview run) + required: true + type: string + plugins: + description: Comma-separated plugin package names to publish when publish_scope=selected + required: false + type: string + +concurrency: + group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.23.0" + +jobs: + preview_plugins_npm: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + ref_sha: ${{ steps.ref.outputs.sha }} + has_candidates: ${{ steps.plan.outputs.has_candidates }} + candidate_count: ${{ steps.plan.outputs.candidate_count }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Resolve checked-out ref + id: ref + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate ref is on main + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor HEAD origin/main + + - name: Validate publishable plugin metadata + env: + PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} + RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }} + HEAD_REF: ${{ steps.ref.outputs.sha }} + run: | + set -euo pipefail + if [[ -n "${PUBLISH_SCOPE}" ]]; then + release_args=(--selection-mode "${PUBLISH_SCOPE}") + if [[ -n "${RELEASE_PLUGINS}" ]]; then + release_args+=(--plugins "${RELEASE_PLUGINS}") + fi + pnpm release:plugins:npm:check -- "${release_args[@]}" + elif [[ -n "${BASE_REF}" ]]; then + pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" + else + pnpm release:plugins:npm:check + fi + + - name: Resolve plugin release plan + id: plan + env: + PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} + RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }} + HEAD_REF: ${{ steps.ref.outputs.sha }} + run: | + set -euo pipefail + mkdir -p .local + if [[ -n "${PUBLISH_SCOPE}" ]]; then + plan_args=(--selection-mode "${PUBLISH_SCOPE}") + if [[ -n "${RELEASE_PLUGINS}" ]]; then + plan_args+=(--plugins "${RELEASE_PLUGINS}") + fi + node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json + elif [[ -n "${BASE_REF}" ]]; then + node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json + else + node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json + fi + + cat .local/plugin-npm-release-plan.json + + candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)" + has_candidates="false" + if [[ "${candidate_count}" != "0" ]]; then + has_candidates="true" + fi + matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)" + + { + echo "candidate_count=${candidate_count}" + echo "has_candidates=${has_candidates}" + echo "matrix=${matrix_json}" + } >> "$GITHUB_OUTPUT" + + echo "Plugin release candidates:" + jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json + + echo "Already published / skipped:" + jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json + + preview_plugin_pack: + needs: preview_plugins_npm + if: needs.preview_plugins_npm.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + install-deps: "false" + + - name: Preview publish command + run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}" + + - name: Preview npm pack contents + working-directory: ${{ matrix.plugin.packageDir }} + run: npm pack --dry-run --json --ignore-scripts + + publish_plugins_npm: + needs: [preview_plugins_npm, preview_plugin_pack] + if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + environment: npm-release + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + install-deps: "false" + + - name: Ensure version is not already published + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + run: | + set -euo pipefail + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + - name: Publish + run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 2426958d346..d89701af44b 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -32,6 +32,9 @@ "npmSpec": "@openclaw/bluebubbles", "localPath": "extensions/bluebubbles", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index b51ead550ef..2e31d211360 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -19,6 +19,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 43e00315f28..82770355b9e 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -7,6 +7,9 @@ "extensions": [ "./index.ts" ], - "setupEntry": "./setup-entry.ts" + "setupEntry": "./setup-entry.ts", + "release": { + "publishToNpm": true + } } } diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index d5dfe64f369..1182828f60d 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -31,6 +31,9 @@ "npmSpec": "@openclaw/feishu", "localPath": "extensions/feishu", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 915e5d5c3de..9280c21b51e 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -9,6 +9,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 8ea72d940fd..ea7c5ec5141 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -31,6 +31,9 @@ "localPath": "extensions/matrix", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "@matrix-org/matrix-sdk-crypto-nodejs", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index eb02c9cee13..6365de0b725 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -29,6 +29,9 @@ "localPath": "extensions/msteams", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "@microsoft/agents-hosting" diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index d594a67b96f..83010363da2 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -29,6 +29,9 @@ "npmSpec": "@openclaw/nextcloud-talk", "localPath": "extensions/nextcloud-talk", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 991bd54f3d4..24b50cf825d 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -27,6 +27,9 @@ "localPath": "extensions/nostr", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "nostr-tools" diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 3c65532f9c9..eac88a77d10 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -12,6 +12,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index cca065cb387..1dd30038cea 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -29,6 +29,9 @@ "npmSpec": "@openclaw/zalo", "localPath": "extensions/zalo", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 322053904fd..610744e7a8d 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -31,6 +31,9 @@ "localPath": "extensions/zalouser", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "zca-js" diff --git a/package.json b/package.json index 5087d9bdf72..c739c024c27 100644 --- a/package.json +++ b/package.json @@ -593,6 +593,8 @@ "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", + "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", + "release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts new file mode 100644 index 00000000000..34f98e86f2f --- /dev/null +++ b/scripts/lib/plugin-npm-release.ts @@ -0,0 +1,394 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseReleaseVersion } from "../openclaw-npm-release-check.ts"; + +export type PluginPackageJson = { + name?: string; + version?: string; + private?: boolean; + openclaw?: { + extensions?: string[]; + install?: { + npmSpec?: string; + }; + release?: { + publishToNpm?: boolean; + }; + }; +}; + +export type PublishablePluginPackage = { + extensionId: string; + packageDir: string; + packageName: string; + version: string; + channel: "stable" | "beta"; + publishTag: "latest" | "beta"; + installNpmSpec?: string; +}; + +export type PluginReleasePlanItem = PublishablePluginPackage & { + alreadyPublished: boolean; +}; + +export type PluginReleasePlan = { + all: PluginReleasePlanItem[]; + candidates: PluginReleasePlanItem[]; + skippedPublished: PluginReleasePlanItem[]; +}; + +export type PluginReleaseSelectionMode = "selected" | "all-publishable"; + +export type GitRangeSelection = { + baseRef: string; + headRef: string; +}; + +export type ParsedPluginReleaseArgs = { + selection: string[]; + selectionMode?: PluginReleaseSelectionMode; + pluginsFlagProvided: boolean; + baseRef?: string; + headRef?: string; +}; + +type PublishablePluginPackageCandidate = { + extensionId: string; + packageDir: string; + packageJson: PluginPackageJson; +}; + +function readPluginPackageJson(path: string): PluginPackageJson { + return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson; +} + +export function parsePluginReleaseSelection(value: string | undefined): string[] { + if (!value?.trim()) { + return []; + } + + return [ + ...new Set( + value + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ].toSorted(); +} + +export function parsePluginReleaseSelectionMode( + value: string | undefined, +): PluginReleaseSelectionMode { + if (value === "selected" || value === "all-publishable") { + return value; + } + + throw new Error( + `Unknown selection mode: ${value ?? ""}. Expected "selected" or "all-publishable".`, + ); +} + +export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs { + let selection: string[] = []; + let selectionMode: PluginReleaseSelectionMode | undefined; + let pluginsFlagProvided = false; + let baseRef: string | undefined; + let headRef: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "--plugins") { + selection = parsePluginReleaseSelection(argv[index + 1]); + pluginsFlagProvided = true; + index += 1; + continue; + } + if (arg === "--selection-mode") { + selectionMode = parsePluginReleaseSelectionMode(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--base-ref") { + baseRef = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--head-ref") { + headRef = argv[index + 1]; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (pluginsFlagProvided && selection.length === 0) { + throw new Error("`--plugins` must include at least one package name."); + } + if (selectionMode === "selected" && !pluginsFlagProvided) { + throw new Error("`--selection-mode selected` requires `--plugins`."); + } + if (selectionMode === "all-publishable" && pluginsFlagProvided) { + throw new Error("`--selection-mode all-publishable` must not be combined with `--plugins`."); + } + if (selection.length > 0 && (baseRef || headRef)) { + throw new Error("Use either --plugins or --base-ref/--head-ref, not both."); + } + if (selectionMode && (baseRef || headRef)) { + throw new Error("Use either --selection-mode or --base-ref/--head-ref, not both."); + } + if ((baseRef && !headRef) || (!baseRef && headRef)) { + throw new Error("Both --base-ref and --head-ref are required together."); + } + + return { selection, selectionMode, pluginsFlagProvided, baseRef, headRef }; +} + +export function collectPublishablePluginPackageErrors( + candidate: PublishablePluginPackageCandidate, +): string[] { + const { packageJson } = candidate; + const errors: string[] = []; + const packageName = packageJson.name?.trim() ?? ""; + const packageVersion = packageJson.version?.trim() ?? ""; + const extensions = packageJson.openclaw?.extensions ?? []; + + if (!packageName.startsWith("@openclaw/")) { + errors.push( + `package name must start with "@openclaw/"; found "${packageName || ""}".`, + ); + } + if (packageJson.private === true) { + errors.push("package.json private must not be true."); + } + if (!packageVersion) { + errors.push("package.json version must be non-empty."); + } else if (parseReleaseVersion(packageVersion) === null) { + errors.push( + `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion}".`, + ); + } + if (!Array.isArray(extensions) || extensions.length === 0) { + errors.push("openclaw.extensions must contain at least one entry."); + } + if (extensions.some((entry) => typeof entry !== "string" || !entry.trim())) { + errors.push("openclaw.extensions must contain only non-empty strings."); + } + + return errors; +} + +export function collectPublishablePluginPackages( + rootDir = resolve("."), +): PublishablePluginPackage[] { + const extensionsDir = join(rootDir, "extensions"); + const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + const publishable: PublishablePluginPackage[] = []; + const validationErrors: string[] = []; + + for (const dir of dirs) { + const packageDir = join("extensions", dir.name); + const absolutePackageDir = join(extensionsDir, dir.name); + const packageJsonPath = join(absolutePackageDir, "package.json"); + let packageJson: PluginPackageJson; + try { + packageJson = readPluginPackageJson(packageJsonPath); + } catch { + continue; + } + + if (packageJson.openclaw?.release?.publishToNpm !== true) { + continue; + } + + const candidate = { + extensionId: dir.name, + packageDir, + packageJson, + } satisfies PublishablePluginPackageCandidate; + const errors = collectPublishablePluginPackageErrors(candidate); + if (errors.length > 0) { + validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`)); + continue; + } + + const version = packageJson.version!.trim(); + const parsedVersion = parseReleaseVersion(version); + if (parsedVersion === null) { + validationErrors.push( + `${dir.name}: package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${version}".`, + ); + continue; + } + + publishable.push({ + extensionId: dir.name, + packageDir, + packageName: packageJson.name!.trim(), + version, + channel: parsedVersion.channel, + publishTag: parsedVersion.channel === "beta" ? "beta" : "latest", + installNpmSpec: packageJson.openclaw?.install?.npmSpec?.trim() || undefined, + }); + } + + if (validationErrors.length > 0) { + throw new Error( + `Publishable plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`, + ); + } + + return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName)); +} + +export function resolveSelectedPublishablePluginPackages(params: { + plugins: PublishablePluginPackage[]; + selection: string[]; +}): PublishablePluginPackage[] { + if (params.selection.length === 0) { + return params.plugins; + } + + const byName = new Map(params.plugins.map((plugin) => [plugin.packageName, plugin])); + const selected: PublishablePluginPackage[] = []; + const missing: string[] = []; + + for (const packageName of params.selection) { + const plugin = byName.get(packageName); + if (!plugin) { + missing.push(packageName); + continue; + } + selected.push(plugin); + } + + if (missing.length > 0) { + throw new Error(`Unknown or non-publishable plugin package selection: ${missing.join(", ")}.`); + } + + return selected; +} + +export function collectChangedExtensionIdsFromPaths(paths: readonly string[]): string[] { + const extensionIds = new Set(); + + for (const path of paths) { + const normalized = path.trim().replaceAll("\\", "/"); + const match = /^extensions\/([^/]+)\//.exec(normalized); + if (match?.[1]) { + extensionIds.add(match[1]); + } + } + + return [...extensionIds].toSorted(); +} + +function isNullGitRef(ref: string | undefined): boolean { + return !ref || /^0+$/.test(ref); +} + +export function collectChangedExtensionIdsFromGitRange(params: { + rootDir?: string; + gitRange: GitRangeSelection; +}): string[] { + const rootDir = params.rootDir ?? resolve("."); + const { baseRef, headRef } = params.gitRange; + + if (isNullGitRef(baseRef) || isNullGitRef(headRef)) { + return []; + } + + const changedPaths = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", baseRef, headRef, "--", "extensions"], + { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + return collectChangedExtensionIdsFromPaths(changedPaths); +} + +export function resolveChangedPublishablePluginPackages(params: { + plugins: PublishablePluginPackage[]; + changedExtensionIds: readonly string[]; +}): PublishablePluginPackage[] { + if (params.changedExtensionIds.length === 0) { + return []; + } + + const changed = new Set(params.changedExtensionIds); + return params.plugins.filter((plugin) => changed.has(plugin.extensionId)); +} + +export function isPluginVersionPublished(packageName: string, version: string): boolean { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-view-")); + const userconfigPath = join(tempDir, "npmrc"); + writeFileSync(userconfigPath, ""); + + try { + execFileSync( + "npm", + ["view", `${packageName}@${version}`, "version", "--userconfig", userconfigPath], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return true; + } catch { + return false; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +export function collectPluginReleasePlan(params?: { + rootDir?: string; + selection?: string[]; + selectionMode?: PluginReleaseSelectionMode; + gitRange?: GitRangeSelection; +}): PluginReleasePlan { + const allPublishable = collectPublishablePluginPackages(params?.rootDir); + const selectedPublishable = + params?.selectionMode === "all-publishable" + ? allPublishable + : params?.selection && params.selection.length > 0 + ? resolveSelectedPublishablePluginPackages({ + plugins: allPublishable, + selection: params.selection, + }) + : params?.gitRange + ? resolveChangedPublishablePluginPackages({ + plugins: allPublishable, + changedExtensionIds: collectChangedExtensionIdsFromGitRange({ + rootDir: params.rootDir, + gitRange: params.gitRange, + }), + }) + : allPublishable; + + const all = selectedPublishable.map((plugin) => ({ + ...plugin, + alreadyPublished: isPluginVersionPublished(plugin.packageName, plugin.version), + })); + + return { + all, + candidates: all.filter((plugin) => !plugin.alreadyPublished), + skippedPublished: all.filter((plugin) => plugin.alreadyPublished), + }; +} diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh new file mode 100644 index 00000000000..2ff1af3f037 --- /dev/null +++ b/scripts/plugin-npm-publish.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-}" +package_dir="${2:-}" + +if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then + echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--publish] " >&2 + exit 2 +fi + +if [[ -z "${package_dir}" ]]; then + echo "missing package dir" >&2 + exit 2 +fi + +package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")" +package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")" +publish_cmd=(npm publish --access public --provenance) +release_channel="stable" + +if [[ "${package_version}" == *-beta.* ]]; then + publish_cmd=(npm publish --access public --tag beta --provenance) + release_channel="beta" +fi + +echo "Resolved package dir: ${package_dir}" +echo "Resolved package name: ${package_name}" +echo "Resolved package version: ${package_version}" +echo "Resolved release channel: ${release_channel}" +echo "Publish auth: GitHub OIDC trusted publishing" + +printf 'Publish command:' +printf ' %q' "${publish_cmd[@]}" +printf '\n' + +if [[ "${mode}" == "--dry-run" ]]; then + exit 0 +fi + +( + cd "${package_dir}" + "${publish_cmd[@]}" +) diff --git a/scripts/plugin-npm-release-check.ts b/scripts/plugin-npm-release-check.ts new file mode 100644 index 00000000000..f1af5b75509 --- /dev/null +++ b/scripts/plugin-npm-release-check.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env -S node --import tsx + +import { pathToFileURL } from "node:url"; +import { + collectChangedExtensionIdsFromGitRange, + collectPublishablePluginPackages, + parsePluginReleaseArgs, + resolveChangedPublishablePluginPackages, + resolveSelectedPublishablePluginPackages, +} from "./lib/plugin-npm-release.ts"; + +export function runPluginNpmReleaseCheck(argv: string[]) { + const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv); + const publishable = collectPublishablePluginPackages(); + const selected = + selectionMode === "all-publishable" + ? publishable + : selection.length > 0 + ? resolveSelectedPublishablePluginPackages({ + plugins: publishable, + selection, + }) + : baseRef && headRef + ? resolveChangedPublishablePluginPackages({ + plugins: publishable, + changedExtensionIds: collectChangedExtensionIdsFromGitRange({ + gitRange: { baseRef, headRef }, + }), + }) + : publishable; + + console.log("plugin-npm-release-check: publishable plugin metadata looks OK."); + if (baseRef && headRef && selected.length === 0) { + console.log( + ` - no publishable plugin package changes detected between ${baseRef} and ${headRef}`, + ); + } + for (const plugin of selected) { + console.log( + ` - ${plugin.packageName}@${plugin.version} (${plugin.channel}, ${plugin.extensionId})`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runPluginNpmReleaseCheck(process.argv.slice(2)); +} diff --git a/scripts/plugin-npm-release-plan.ts b/scripts/plugin-npm-release-plan.ts new file mode 100644 index 00000000000..e18f1dc131e --- /dev/null +++ b/scripts/plugin-npm-release-plan.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env -S node --import tsx + +import { pathToFileURL } from "node:url"; +import { collectPluginReleasePlan, parsePluginReleaseArgs } from "./lib/plugin-npm-release.ts"; + +export function collectPluginNpmReleasePlan(argv: string[]) { + const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv); + return collectPluginReleasePlan({ + selection, + selectionMode, + gitRange: baseRef && headRef ? { baseRef, headRef } : undefined, + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const plan = collectPluginNpmReleasePlan(process.argv.slice(2)); + console.log(JSON.stringify(plan, null, 2)); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index fba6d197357..8f971fef119 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -34,15 +34,6 @@ const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; -function normalizePluginSyncVersion(version: string): string { - const normalized = version.trim().replace(/^v/, ""); - const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; - if (base) { - return base; - } - return normalized.replace(/[-+].*$/, ""); -} - export function collectBundledExtensionRootDependencyGapErrors(params: { rootPackage: PackageJson; extensions: BundledExtension[]; @@ -190,54 +181,6 @@ export function collectPackUnpackedSizeErrors(results: Iterable): st return errors; } -function checkPluginVersions() { - const rootPackagePath = resolve("package.json"); - const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; - const targetVersion = rootPackage.version; - const targetBaseVersion = targetVersion ? normalizePluginSyncVersion(targetVersion) : null; - - if (!targetVersion || !targetBaseVersion) { - console.error("release-check: root package.json missing version."); - process.exit(1); - } - - const extensionsDir = resolve("extensions"); - const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); - - const mismatches: string[] = []; - - for (const entry of entries) { - const packagePath = join(extensionsDir, entry.name, "package.json"); - let pkg: PackageJson; - try { - pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; - } catch { - continue; - } - - if (!pkg.name || !pkg.version) { - continue; - } - - if (normalizePluginSyncVersion(pkg.version) !== targetBaseVersion) { - mismatches.push(`${pkg.name} (${pkg.version})`); - } - } - - if (mismatches.length > 0) { - console.error( - `release-check: plugin versions must match release base ${targetBaseVersion} (root ${targetVersion}):`, - ); - for (const item of mismatches) { - console.error(` - ${item}`); - } - console.error("release-check: run `pnpm plugins:sync` to align plugin versions."); - process.exit(1); - } -} - function extractTag(item: string, tag: string): string | null { const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`<${escapedTag}>([^<]+)`); @@ -393,7 +336,6 @@ async function checkPluginSdkExports() { } async function main() { - checkPluginVersions(); checkAppcastSparkleVersions(); await checkPluginSdkExports(); checkBundledExtensionRootDependencyMirrors(); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts new file mode 100644 index 00000000000..383d97b9ab9 --- /dev/null +++ b/test/plugin-npm-release.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + collectChangedExtensionIdsFromPaths, + collectPublishablePluginPackageErrors, + parsePluginReleaseArgs, + parsePluginReleaseSelection, + parsePluginReleaseSelectionMode, + resolveChangedPublishablePluginPackages, + resolveSelectedPublishablePluginPackages, + type PublishablePluginPackage, +} from "../scripts/lib/plugin-npm-release.ts"; + +describe("parsePluginReleaseSelection", () => { + it("returns an empty list for blank input", () => { + expect(parsePluginReleaseSelection("")).toEqual([]); + expect(parsePluginReleaseSelection(" ")).toEqual([]); + expect(parsePluginReleaseSelection(undefined)).toEqual([]); + }); + + it("dedupes and sorts comma or whitespace separated package names", () => { + expect( + parsePluginReleaseSelection(" @openclaw/zalo, @openclaw/feishu @openclaw/zalo "), + ).toEqual(["@openclaw/feishu", "@openclaw/zalo"]); + }); +}); + +describe("parsePluginReleaseSelectionMode", () => { + it("accepts the supported explicit selection modes", () => { + expect(parsePluginReleaseSelectionMode("selected")).toBe("selected"); + expect(parsePluginReleaseSelectionMode("all-publishable")).toBe("all-publishable"); + }); + + it("rejects unsupported selection modes", () => { + expect(() => parsePluginReleaseSelectionMode("all")).toThrowError( + 'Unknown selection mode: all. Expected "selected" or "all-publishable".', + ); + }); +}); + +describe("parsePluginReleaseArgs", () => { + it("rejects blank explicit plugin selections", () => { + expect(() => parsePluginReleaseArgs(["--plugins", " "])).toThrowError( + "`--plugins` must include at least one package name.", + ); + }); + + it("requires plugin names for selected explicit publish mode", () => { + expect(() => parsePluginReleaseArgs(["--selection-mode", "selected"])).toThrowError( + "`--selection-mode selected` requires `--plugins`.", + ); + }); + + it("rejects plugin names when all-publishable mode is selected", () => { + expect(() => + parsePluginReleaseArgs([ + "--selection-mode", + "all-publishable", + "--plugins", + "@openclaw/zalo", + ]), + ).toThrowError("`--selection-mode all-publishable` must not be combined with `--plugins`."); + }); + + it("parses explicit all-publishable mode", () => { + expect(parsePluginReleaseArgs(["--selection-mode", "all-publishable"])).toMatchObject({ + selectionMode: "all-publishable", + selection: [], + pluginsFlagProvided: false, + }); + }); +}); + +describe("collectPublishablePluginPackageErrors", () => { + it("accepts a valid publishable plugin package candidate", () => { + expect( + collectPublishablePluginPackageErrors({ + extensionId: "zalo", + packageDir: "extensions/zalo", + packageJson: { + name: "@openclaw/zalo", + version: "2026.3.15", + openclaw: { + extensions: ["./index.ts"], + release: { + publishToNpm: true, + }, + }, + }, + }), + ).toEqual([]); + }); + + it("flags invalid publishable plugin metadata", () => { + expect( + collectPublishablePluginPackageErrors({ + extensionId: "broken", + packageDir: "extensions/broken", + packageJson: { + name: "broken", + version: "latest", + private: true, + openclaw: { + extensions: [""], + release: { + publishToNpm: true, + }, + }, + }, + }), + ).toEqual([ + 'package name must start with "@openclaw/"; found "broken".', + "package.json private must not be true.", + 'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".', + "openclaw.extensions must contain only non-empty strings.", + ]); + }); +}); + +describe("resolveSelectedPublishablePluginPackages", () => { + const publishablePlugins: PublishablePluginPackage[] = [ + { + extensionId: "feishu", + packageDir: "extensions/feishu", + packageName: "@openclaw/feishu", + version: "2026.3.15", + channel: "stable", + publishTag: "latest", + }, + { + extensionId: "zalo", + packageDir: "extensions/zalo", + packageName: "@openclaw/zalo", + version: "2026.3.15-beta.1", + channel: "beta", + publishTag: "beta", + }, + ]; + + it("returns all publishable plugins when no selection is provided", () => { + expect( + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: [], + }), + ).toEqual(publishablePlugins); + }); + + it("filters by selected publishable package names", () => { + expect( + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: ["@openclaw/zalo"], + }), + ).toEqual([publishablePlugins[1]]); + }); + + it("throws when the selection contains an unknown package name", () => { + expect(() => + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: ["@openclaw/missing"], + }), + ).toThrowError("Unknown or non-publishable plugin package selection: @openclaw/missing."); + }); +}); + +describe("collectChangedExtensionIdsFromPaths", () => { + it("extracts unique extension ids from changed extension paths", () => { + expect( + collectChangedExtensionIdsFromPaths([ + "extensions/zalo/index.ts", + "extensions/zalo/package.json", + "extensions/feishu/src/client.ts", + "docs/reference/RELEASING.md", + ]), + ).toEqual(["feishu", "zalo"]); + }); +}); + +describe("resolveChangedPublishablePluginPackages", () => { + const publishablePlugins: PublishablePluginPackage[] = [ + { + extensionId: "feishu", + packageDir: "extensions/feishu", + packageName: "@openclaw/feishu", + version: "2026.3.15", + channel: "stable", + publishTag: "latest", + }, + { + extensionId: "zalo", + packageDir: "extensions/zalo", + packageName: "@openclaw/zalo", + version: "2026.3.15-beta.1", + channel: "beta", + publishTag: "beta", + }, + ]; + + it("returns only changed publishable plugins", () => { + expect( + resolveChangedPublishablePluginPackages({ + plugins: publishablePlugins, + changedExtensionIds: ["zalo"], + }), + ).toEqual([publishablePlugins[1]]); + }); + + it("returns an empty list when no publishable plugins changed", () => { + expect( + resolveChangedPublishablePluginPackages({ + plugins: publishablePlugins, + changedExtensionIds: [], + }), + ).toEqual([]); + }); +});