From 99d662473cb4c719b8d17a60adb2493f935d250e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 10:12:15 -0700 Subject: [PATCH] fix(channels): fail-fast headless channel setup with plugin-declared env contracts (#122530) * fix(channels): validate headless channel setup * docs(channels): document headless provisioning * fix(channels): repair setup metadata typing * chore(channels): regenerate official channel catalog for env metadata * fix(slack): keep mode-conditional env contract plugin-owned Static --use-env declaration keeps only the unconditional SLACK_BOT_TOKEN; socket-vs-HTTP conditional requirements (app token, signing secret) stay in Slack's own setup validation so HTTP mode no longer demands an irrelevant SLACK_APP_TOKEN. * chore(sdk): regenerate api baselines and catalog after rebase * fix(slack): align manifest env declaration with runtime contract * chore(sdk): regenerate api baselines after rebase * chore(sdk): regenerate api baselines after rebase * chore(sdk): regenerate api baselines after rebase --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-runtime.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../channel-secret-basic-runtime.json | 2 +- .../channel-secret-runtime.json | 2 +- .../channel-setup.json | 2 +- .../config-mutation.json | 2 +- .../config-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../model-session-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-auth.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../secret-input-runtime.json | 2 +- .../secret-ref-runtime.json | 2 +- .../setup-runtime.json | 2 +- .../plugin-sdk-api-baseline/setup.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/cli/channels.md | 21 +++ docs/install/docker.md | 31 ++++ docs/plugins/sdk-setup.md | 2 + extensions/buzz/package.json | 3 +- extensions/buzz/src/setup-core.test.ts | 15 -- extensions/buzz/src/setup-core.ts | 10 +- extensions/clickclack/package.json | 3 +- extensions/clickclack/src/setup-core.ts | 1 + extensions/discord/package.json | 3 +- extensions/discord/src/setup-adapter.ts | 1 + extensions/googlechat/package.json | 4 +- extensions/googlechat/src/setup-core.ts | 2 + extensions/irc/package.json | 3 +- extensions/irc/src/setup-core.ts | 1 + extensions/line/package.json | 3 +- extensions/line/src/setup-core.ts | 1 + extensions/mattermost/package.json | 3 +- extensions/mattermost/src/setup-core.ts | 1 + extensions/nextcloud-talk/package.json | 3 +- extensions/nextcloud-talk/src/setup-core.ts | 1 + extensions/nostr/package.json | 3 +- extensions/nostr/src/channel.setup.ts | 3 +- extensions/nostr/src/channel.test.ts | 17 ++- extensions/nostr/src/private-key.ts | 15 ++ extensions/nostr/src/setup-adapter.ts | 2 + extensions/nostr/src/types.ts | 7 +- extensions/slack/package.json | 3 +- ...nnel-actions-setup-status.contract.test.ts | 58 +++++++- extensions/slack/src/setup-core.ts | 22 ++- extensions/synology-chat/package.json | 3 +- extensions/synology-chat/src/setup-surface.ts | 1 + extensions/telegram/package.json | 3 +- extensions/telegram/src/setup-core.ts | 1 + extensions/zalo/package.json | 3 +- extensions/zalo/src/setup-core.ts | 1 + .../official-external-channel-catalog.json | 65 +++++++-- .../plugins/account-config-mutation.test.ts | 25 ++++ .../plugins/account-config-mutation.ts | 32 ++++- src/channels/plugins/setup-contract.test.ts | 13 ++ src/channels/plugins/setup-contract.ts | 10 +- src/commands/channels.add.test.ts | 133 +++++++++++++++++- src/commands/channels/add.ts | 8 ++ .../manifest-registry-installed.test.ts | 4 + src/plugins/manifest-registry-installed.ts | 13 ++ 72 files changed, 527 insertions(+), 86 deletions(-) create mode 100644 extensions/nostr/src/private-key.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 5d3037193d13..79b43401faf0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"81edf86f9ac989d2c85a531a271396c8ca553bd40f80a3e93903b3cf34385146","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"f0a3282bbb9dd2ff22df32d5f86a27a488e6f7f6b007548d2026712b4e59a6c5","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 230896ca2b18..6422474cc573 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"1bb99b68596361bc2ef9f1c8011f1d87fbbdc78d0bde58d9e89625a5e2bf2de7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"60140db5b14eb3811a0398983df4729a0c4c2036f49a85622c1ed4a62b2e232a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 7a34507db9b5..f6d820607f46 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"0411ae87d138ede40528ca7416fb1a9716f7f73b2b788e7c322048a6854b40d9","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"a5fd8c9fbf19c5a9eb6dca252cd551616571ddf344cf8e2963135e4b418cf063","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 2754698ca1c0..65f9f377cf7c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"6c638b1fcf5a1cadce5a5363ae4cd8f29fe513feb90331272349ef5bf70bc63a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"e78d0b185d0a718fc12254b09e8432265614c8cdddae804a383fd0da3093968a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 9d2d3ab6b776..ddceba0e8d03 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"fa4140b50658ecfab11c323c24ba6f0fe0a5cb1608c8fa3e8eefa3e4e4192e77","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"f1804d094895fffe929f32fbda11ddbef26be518eb310efff3b337cc069feb53","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index d301e92a8907..6713a0ee44f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"5c1b1998403a8527055fff05caa86f51ccd981c08ebd8ba8c7f9da19a8e16e33","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"9e96e620155dbc97b8cc74b1eeaf8987fc535d23e9f3c86450a9a75cc202964d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 60e4a3165445..f778eec0d129 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"751d37b918948737d4324f6323e1f8f6151f991d55c3794e1908f79225745fc7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"febc6854cb89d5c26debeec50008526d0a8d97738f727011c3942509bb538fcc","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 7c34100884d7..53d1642409ae 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"87d04d89c9a3b50fc63ea3da73e04bf4f6e5ce5738cfe1be11d4023aa680ec78","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"b3806b628ae37ac8242ab0f0a8387cd209c78ff9233486269e279677afda4c1b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index 3c6aab0b2ffa..685315a3a6e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"c31c0725f25f9910d084befaee059ce147c62aaf378c6e85a510c2147ca9a42f","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"7e762365f58f15f23cc90ff932c3bfab59c91a2ba975bdc68f55c92c47ba14bd","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json index c1252726f1e2..0e2edbb6305f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json @@ -1 +1 @@ -{"contentHash":"9be41f5b34b2d2c74794053df4f4bacfa071250456affed4e4b298ecad608ff7","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} +{"contentHash":"c705cf7c1ba06b0d473d6cd83e4701e8ec464b226446d5465ebffb0f656dc4d0","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json index fd9a37335cee..4919d067f119 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json @@ -1 +1 @@ -{"contentHash":"02568442a1df10e1626b79a61f16271fcd6982a773afb3036c552f162d76c13d","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} +{"contentHash":"7a5e4516352775c3c3ed72faf36240357c6437dda7f9dd223088f5e5766cc3e2","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index 2d12d9ceb0d5..9c4f6fc5b510 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"99e8ca8cbf3a57960650ea6f69422dbd88c8cc80074c8166350e57a5124a5ccb","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"f2849884f368eadff174176a2808c1c74d041968049b460d6f8b408682c5862b","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index c30f1f412c92..3874d0f43170 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"c21f020ef8f64d952590f6e532639ca678a97368b02df665e7819397869226e6","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"2c21d121fd9d14d7f45368b5f02dff257b6750215d63d4be9ace28b5c0d3c0f8","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 45d89448009f..9bb152e18153 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"2c95ec54b192b730e9a9c606f77e66a2d0dd9eb09dd66a3c6ad8fea9fca31585","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"4ba3f7cf748c5ba26f22d626499c4bce05ef8e17f69bf864a87c1df3ab2dc5d4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 1e7953515e20..7b6cd8e8a977 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"d2051c433956de7d579b91d4e21e39d48334e63a632baf941bde0cef85ca8ac0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"518f095d39488d9c6fcfff4cf68043bca7308e4e807020d718f6a05f139d1b6b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index d72a18256642..2f0f84ec93cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"a9e35fd79c8ba80ca1de7760b0b83a243a383bffbda1730818e5dec2a049ddce","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"8b35ab0994d669d6bb95dd64bcd1d2c721fb5716d8c997d048775ab7d9cd0153","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index abac3ff08599..66692d9749a9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"3ba67320fb902e12fdf2399c4d8e74403e78e74add8dfed8213154f4ab68a858","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"3447ab3b4f9d00dd164e97954dbccb7cd53d2298aa5204c2017f390f15588437","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index 10d0b36f28dc..608f8931e9d9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"8103ae760481a799b4b1a476cdd2d6ec18010a562a9d126af7cd0ca12cd6b089","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"9c8d6c57eae34ab72ec465dbf610f75d5d1bf737d7f04eadab63c806ea8a51fa","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index ff8ab001e754..820d9db55bb7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"ca3136e446cf1fc294279e24078e3a4c6342a048b00f8012d519903c887ef3a7","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"6dea61641b09e01cdf88055074dceebfb16545da28b6dfefed6b1d85e66dbfeb","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index cb6c5737e5e5..23c5c3504bbf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"5f0b7be43fcb5e2240053e7ada316b35f28cbcdd0619b724463627961927dcb9","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"c9e9acdc7f66b6020b738d7a26657a09edce6f21c5ab5acd65e0dac8a7c3bb6f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 1235ad440fab..5630ca54d16e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"a82f45bdc59736c09a6124e788bae6a54b6d28c19dafde34458445f81ada3b74","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"c63113a4720b3d4795a3deb00ce3652cc9481522af311b9a269e4fe0d84703bb","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 7076b149adb2..de87e1b12933 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"5c9f0b8207f962f9c32228970abe15c23de32c08a00e3d42cbdf5fecbd4c1f1e","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"a45abfd8f04f808a9b7bd2703b903be221b0bdb55fb3782f7e0e9d4ee71871a0","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index aaaffdd08235..41d61dc8b9fa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"63f33404c7d000c437c4f5c5f4aa061e6bb17207fbff5b43edeba79988809b04","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"3686c85e955c6ae8a13b1a42772eb720ea7ac55fa4e12e02b179ec16160fbdd1","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index ab29a8c05d94..a6fcf0c61273 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"282d5444becc41e8230f56d6122f3a9fb6f5f33a1adfc254d772fd14d11c3dc3","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"0d0d184165355b27731442674cc6b7b641c30cf90889a833effb3e8733f5a7d4","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json index 63cd7397abb2..f3a720adf3bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json @@ -1 +1 @@ -{"contentHash":"93827c27397a0d4b2872c801e4175ce371f4ec4b7aa5f85331bd46f5323174c9","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} +{"contentHash":"19bbe24a8d2e70f8c9a34686275d86df4ac4829dbe37c83df530fe5685d3ae9b","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup.json b/docs/.generated/plugin-sdk-api-baseline/setup.json index c3ffeace4b65..6527a8e31027 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup.json @@ -1 +1 @@ -{"contentHash":"713d698d49f9dcfea998d3d520458dc61c1a6840cb4d1266fff353a9086af30d","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} +{"contentHash":"fe9cc43264a17feb6d108ddeb13a6b0ebc731575fca56460c10291fe116102fb","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 9444a64e5a85..f51e190f6ab5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"018adbdc87ee4a490fdea45b3ee249a55dca337d655cad8929e63986f4e633bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"dffc96ff312adfa28713a43b47d8ffc70e44c65be64c65e129d775a8fd77728b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 8910c15ff1e5..6de1ff4fea80 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1dac77955687176848405413e59d76ea0511e30722eaa32bf3a3cdd44c5a6395","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"4a6a6b7e93f7169d0aed68b74121ff4676d120d4a88e1b06ced1f2434e030677","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 3c801056e0df..47b4eff19f2f 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -79,6 +79,25 @@ openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" openclaw channels remove --channel telegram --delete ``` +For a headless host, complete non-interactive onboarding first, then add each channel with explicit credential flags or its environment-backed setup option: + +```bash +export OPENAI_API_KEY="" +export TELEGRAM_BOT_TOKEN="" + +openclaw onboard --non-interactive --accept-risk --skip-health \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --skip-channels \ + --no-install-daemon +openclaw channels add --channel telegram --use-env +``` + +`--use-env` validates the environment variables declared by the selected channel plugin before writing config. For Telegram, the command requires `TELEGRAM_BOT_TOKEN`; other plugins name their missing variables in the error. The Gateway service must receive the same environment variables as the bootstrap shell. If the Gateway is already running with config reload enabled, it watches the config write and restarts the affected channel automatically. + +See [CLI automation](/start/wizard-cli-automation) for additional non-interactive provider and Gateway options. Container deployments should also follow the [Docker headless bootstrap](/install/docker#headless-bootstrap) environment guidance. + `openclaw channels add telegram --help` or `openclaw channels add --channel telegram --help` shows only Telegram's setup flags. `openclaw channels add --help` shows only the shared command envelope. @@ -113,6 +132,8 @@ openclaw channels add telegram openclaw channels add --channel telegram ``` +Guided setup requires an interactive terminal. In a non-TTY shell, OpenClaw exits immediately instead of waiting for input; use `openclaw channels add --channel --use-env` or pass the selected plugin's credential flags. + The wizard can prompt for: - account ids per selected channel diff --git a/docs/install/docker.md b/docs/install/docker.md index 1331411c30b8..56019b087937 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -102,6 +102,37 @@ Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting +### Headless bootstrap + +For an unattended container host, put provider, Gateway, and channel credentials in the Compose `.env` file so both the one-shot bootstrap container and the long-running Gateway receive the same values: + +```bash +OPENAI_API_KEY= +OPENCLAW_GATEWAY_TOKEN= +TELEGRAM_BOT_TOKEN= +``` + +Run onboarding and channel provisioning without a pseudo-TTY, then start the Gateway: + +```bash +docker compose run -T --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js onboard --non-interactive --accept-risk --skip-health \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --skip-channels \ + --no-install-daemon +docker compose run -T --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js channels add --channel telegram --use-env +docker compose up -d openclaw-gateway +``` + +The channel command fails before changing config if a plugin-declared environment variable is missing. Keep `TELEGRAM_BOT_TOKEN` in `.env` after bootstrap: `--use-env` leaves credential lookup to the environment without copying the token into `openclaw.json`, and the running Gateway needs the same variable. When channel config changes after startup, the Gateway's config watcher hot-reloads the affected channel automatically. + +See [`openclaw channels`](/cli/channels) for credential-flag alternatives and other channel plugins. + ### Manual flow ```bash diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index d609a71ad1cc..72630cbaef6d 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -162,6 +162,8 @@ export const setupContract = defineChannelSetupContract({ Supported field kinds are `string`, `boolean`, `integer`, `string-list`, and `choice`. Use `sensitive: true` for credentials. Each field key must equal the camelCased attribute name of its long CLI flag, including any negated form, such as `apiToken` for `--api-token`. Boolean fields may add `cli.negatedFlags` when both positive and `--no-*` forms are needed. `channel`, `account`, and the account display `name` remain the shared control envelope. +For a boolean `useEnv` field, set `envVars` to the static environment variable names required by the plugin runtime. Non-interactive channel setup then rejects `--use-env` before writing config when any declared variable is empty. Set `envVarMode: "any"` when one variable from the list is sufficient, such as an inline credential or file-path alternative. Omitting `envVars` preserves the plugin's existing validation behavior. + The released `setup`/`ChannelSetupInput` adapter stays available for existing external plugins. New plugins should expose `setupContract`; OpenClaw always prefers it when both are present. | Field | Type | What it means | diff --git a/extensions/buzz/package.json b/extensions/buzz/package.json index 34c1cf7b85c1..f4b3aeefb867 100644 --- a/extensions/buzz/package.json +++ b/extensions/buzz/package.json @@ -70,7 +70,8 @@ "cli": { "flags": "--use-env", "description": "Use BUZZ_PRIVATE_KEY with the supplied relay URL" - } + }, + "envVars": ["BUZZ_PRIVATE_KEY"] } ] } diff --git a/extensions/buzz/src/setup-core.test.ts b/extensions/buzz/src/setup-core.test.ts index 57c4f767f096..709c890fba68 100644 --- a/extensions/buzz/src/setup-core.test.ts +++ b/extensions/buzz/src/setup-core.test.ts @@ -31,21 +31,6 @@ describe("buzzSetupContract", () => { }); }); - it("rejects --use-env when BUZZ_PRIVATE_KEY is unset", () => { - vi.stubEnv("BUZZ_PRIVATE_KEY", ""); - if (!buzzSetupContract.validateInput) { - throw new Error("Expected buzzSetupContract.validateInput to be defined"); - } - - expect( - buzzSetupContract.validateInput({ - cfg: {} as OpenClawConfig, - accountId: "default", - input: { relayUrl: "wss://buzz.example.com", useEnv: true }, - }), - ).toBe("BUZZ_PRIVATE_KEY is not set."); - }); - it("clears an identity-bound auth tag when changing the private key", () => { const cfg = { channels: { diff --git a/extensions/buzz/src/setup-core.ts b/extensions/buzz/src/setup-core.ts index 3398142bdcb0..76ed439b32d3 100644 --- a/extensions/buzz/src/setup-core.ts +++ b/extensions/buzz/src/setup-core.ts @@ -58,17 +58,18 @@ const buzzSetupAdapter: ChannelSetupAdapter = { return "Buzz requires --relay-url with a ws:// or wss:// URL."; } if (input.useEnv) { - return process.env.BUZZ_PRIVATE_KEY?.trim() ? null : "BUZZ_PRIVATE_KEY is not set."; + return null; } - if (!input.privateKey?.trim()) { + const privateKey = input.privateKey?.trim(); + if (!privateKey) { return "Buzz requires --private-key or --use-env."; } try { - decodeBuzzPrivateKey(input.privateKey); - return null; + decodeBuzzPrivateKey(privateKey); } catch (error) { return error instanceof Error ? error.message : "Invalid Buzz private key."; } + return null; }, applyAccountConfig: ({ cfg, input }) => { const currentPrivateKey = resolveComparableCurrentKey(cfg); @@ -110,6 +111,7 @@ export const buzzSetupContract = defineChannelSetupContract({ flags: "--use-env", description: "Use BUZZ_PRIVATE_KEY with the supplied relay URL", }, + envVars: ["BUZZ_PRIVATE_KEY"], }, }, adapter: buzzSetupAdapter, diff --git a/extensions/clickclack/package.json b/extensions/clickclack/package.json index 2de49d027a48..84f5021d846b 100644 --- a/extensions/clickclack/package.json +++ b/extensions/clickclack/package.json @@ -127,7 +127,8 @@ "cli": { "flags": "--use-env", "description": "Use CLICKCLACK_BOT_TOKEN" - } + }, + "envVars": ["CLICKCLACK_BOT_TOKEN"] } ] } diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 2a598f716e6d..34cf77ca7371 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -382,6 +382,7 @@ export const clickClackSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use CLICKCLACK_BOT_TOKEN" }, + envVars: ["CLICKCLACK_BOT_TOKEN"], }, }, legacyAdapter: clickClackSetupAdapter, diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 2b16b5d61c04..fe7d807e1c4a 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -74,7 +74,8 @@ "cli": { "flags": "--use-env", "description": "Use DISCORD_BOT_TOKEN" - } + }, + "envVars": ["DISCORD_BOT_TOKEN"] } ] }, diff --git a/extensions/discord/src/setup-adapter.ts b/extensions/discord/src/setup-adapter.ts index 6139119fb3fd..37b52b414461 100644 --- a/extensions/discord/src/setup-adapter.ts +++ b/extensions/discord/src/setup-adapter.ts @@ -25,6 +25,7 @@ export const discordSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use DISCORD_BOT_TOKEN" }, + envVars: ["DISCORD_BOT_TOKEN"], }, }, legacyAdapter: discordSetupAdapter, diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 69851e0bb51a..0eab3dad4826 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -122,7 +122,9 @@ "cli": { "flags": "--use-env", "description": "Use Google Chat environment credentials" - } + }, + "envVars": ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"], + "envVarMode": "any" } ] } diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index 376585516a87..0cd8ba85c177 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -82,6 +82,8 @@ export const googlechatSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use Google Chat environment credentials" }, + envVars: ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"], + envVarMode: "any", }, }, legacyAdapter: googlechatSetupAdapter, diff --git a/extensions/irc/package.json b/extensions/irc/package.json index f83a4f69ab2f..8876159ab159 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -114,7 +114,8 @@ "cli": { "flags": "--use-env", "description": "Use IRC environment configuration" - } + }, + "envVars": ["IRC_HOST", "IRC_NICK"] } ] } diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index d2d3794ab081..a1e18515237f 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -176,6 +176,7 @@ export const ircSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use IRC environment configuration" }, + envVars: ["IRC_HOST", "IRC_NICK"], }, }, legacyAdapter: ircSetupAdapter, diff --git a/extensions/line/package.json b/extensions/line/package.json index dbd60aac9640..ec9a619ca6ca 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -100,7 +100,8 @@ "cli": { "flags": "--use-env", "description": "Use LINE environment credentials" - } + }, + "envVars": ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"] } ] } diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 36c7c5d73d4e..2341c7e7e2a6 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -136,6 +136,7 @@ export const lineSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use LINE environment credentials" }, + envVars: ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], }, }, legacyAdapter: lineSetupAdapter, diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e2b9ed821430..c63adb36e855 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -78,7 +78,8 @@ "cli": { "flags": "--use-env", "description": "Use Mattermost environment credentials" - } + }, + "envVars": ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"] } ] } diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 40a47dfdaabf..cdf805734230 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -134,6 +134,7 @@ export const mattermostSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use Mattermost environment credentials" }, + envVars: ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"], }, }, legacyAdapter: mattermostSetupAdapter, diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 9e921ed975dd..121cc8e06241 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -117,7 +117,8 @@ "cli": { "flags": "--use-env", "description": "Use Nextcloud Talk environment credentials" - } + }, + "envVars": ["NEXTCLOUD_TALK_BOT_SECRET"] } ] } diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 42cd39b61c97..eebe4632be67 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -245,6 +245,7 @@ export const nextcloudTalkSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use Nextcloud Talk environment credentials" }, + envVars: ["NEXTCLOUD_TALK_BOT_SECRET"], }, }, legacyAdapter: nextcloudTalkSetupAdapter, diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 483551aa1785..0b0a5778f765 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -69,7 +69,8 @@ "cli": { "flags": "--use-env", "description": "Use NOSTR_PRIVATE_KEY" - } + }, + "envVars": ["NOSTR_PRIVATE_KEY"] } ] } diff --git a/extensions/nostr/src/channel.setup.ts b/extensions/nostr/src/channel.setup.ts index 16c9c8ec0eec..9f519c50a58c 100644 --- a/extensions/nostr/src/channel.setup.ts +++ b/extensions/nostr/src/channel.setup.ts @@ -8,6 +8,7 @@ import { import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js"; import { NostrConfigSchema } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; +import { resolveNostrPrivateKey } from "./private-key.js"; import { createNostrSetupAdapter, createNostrSetupContract, @@ -38,7 +39,7 @@ function resolveSetupNostrAccount(params: { }): ResolvedNostrAccount { const nostrCfg = getNostrConfig(params.cfg); const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg); - const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : ""; + const privateKey = resolveNostrPrivateKey(nostrCfg?.privateKey); const configured = Boolean(privateKey); return { accountId, diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts index e79bcde2cc64..d24d8ef1b222 100644 --- a/extensions/nostr/src/channel.test.ts +++ b/extensions/nostr/src/channel.test.ts @@ -5,7 +5,7 @@ import { runSetupWizardConfigure, } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; import { nostrPlugin } from "./channel.js"; import { normalizePubkey } from "./nostr-key-utils.js"; @@ -19,6 +19,10 @@ import { } from "./test-fixtures.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("nostr target classification", () => { it("accepts only valid direct-message public keys", () => { expect(nostrPlugin.messaging?.inferTargetChatType?.({ to: TEST_HEX_PUBLIC_KEY })).toBe( @@ -433,6 +437,7 @@ describe("nostr unresolved SecretRef privateKey", () => { it.each(unresolvedSecretRefPrivateKeyCases)( "$name does not treat unresolved SecretRef privateKey as configured", ({ assert }) => { + vi.stubEnv("NOSTR_PRIVATE_KEY", TEST_HEX_PRIVATE_KEY); assert(createUnresolvedNostrPrivateKeyCfg()); }, ); @@ -509,6 +514,16 @@ describe("nostr account helpers", () => { expect(account.relays).toContain("wss://nos.lol"); }); + it("resolves the default account private key from NOSTR_PRIVATE_KEY", () => { + vi.stubEnv("NOSTR_PRIVATE_KEY", TEST_HEX_PRIVATE_KEY); + + const account = resolveNostrAccount({ cfg: { channels: { nostr: { enabled: true } } } }); + + expect(account.configured).toBe(true); + expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY); + expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/); + }); + it("handles disabled channel", () => { const cfg = createConfiguredNostrCfg({ enabled: false }); const account = resolveNostrAccount({ cfg }); diff --git a/extensions/nostr/src/private-key.ts b/extensions/nostr/src/private-key.ts new file mode 100644 index 000000000000..b10c6afddcfd --- /dev/null +++ b/extensions/nostr/src/private-key.ts @@ -0,0 +1,15 @@ +import { + hasConfiguredSecretInput, + normalizeSecretInputString, + type SecretInput, +} from "openclaw/plugin-sdk/secret-input"; + +export const NOSTR_PRIVATE_KEY_ENV_VAR = "NOSTR_PRIVATE_KEY"; + +export function resolveNostrPrivateKey(value: SecretInput | undefined): string { + const configured = normalizeSecretInputString(value); + if (configured || hasConfiguredSecretInput(value)) { + return configured ?? ""; + } + return process.env[NOSTR_PRIVATE_KEY_ENV_VAR]?.trim() ?? ""; +} diff --git a/extensions/nostr/src/setup-adapter.ts b/extensions/nostr/src/setup-adapter.ts index 177e7c227504..d033f44a5fae 100644 --- a/extensions/nostr/src/setup-adapter.ts +++ b/extensions/nostr/src/setup-adapter.ts @@ -13,6 +13,7 @@ import { } from "openclaw/plugin-sdk/setup"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_RELAYS } from "./default-relays.js"; +import { NOSTR_PRIVATE_KEY_ENV_VAR } from "./private-key.js"; const channel = "nostr" as const; @@ -106,6 +107,7 @@ export function createNostrSetupContract(adapter: ChannelSetupAdapter { const account = cfg.channels?.nostr as NostrAccountConfig | undefined; - return normalizeSecretInputString(account?.privateKey) + return resolveNostrPrivateKey(account?.privateKey) ? (normalizeOptionalAccountId(account?.defaultAccount) ?? DEFAULT_ACCOUNT_ID) : undefined; }, @@ -63,7 +64,7 @@ export function resolveNostrAccount(opts: { | undefined; const baseEnabled = nostrCfg?.enabled !== false; - const privateKey = normalizeSecretInputString(nostrCfg?.privateKey) ?? ""; + const privateKey = resolveNostrPrivateKey(nostrCfg?.privateKey); const configured = Boolean(privateKey); let publicKey = ""; diff --git a/extensions/slack/package.json b/extensions/slack/package.json index d665030bfd07..e2f0cd43cd8e 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -139,7 +139,8 @@ "cli": { "flags": "--use-env", "description": "Use Slack environment credentials" - } + }, + "envVars": ["SLACK_BOT_TOKEN"] } ] } diff --git a/extensions/slack/src/channel-actions-setup-status.contract.test.ts b/extensions/slack/src/channel-actions-setup-status.contract.test.ts index 8bac12390de0..1a5298ba835a 100644 --- a/extensions/slack/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/slack/src/channel-actions-setup-status.contract.test.ts @@ -5,7 +5,7 @@ import { installChannelStatusContractSuite, } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect } from "vitest"; +import { afterEach, describe, expect, vi } from "vitest"; import { slackPlugin } from "../api.js"; import { slackSetupPlugin } from "../setup-plugin-api.js"; @@ -25,6 +25,10 @@ const slackDefaultActions = [ "emoji-list", ] as const; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("slack actions contract", () => { installChannelActionsContractSuite({ plugin: slackPlugin, @@ -87,6 +91,58 @@ describe("slack setup contract", () => { expectedAccountId: "ops", expectedValidation: "Slack env tokens can only be used for the default account.", }, + { + name: "HTTP env setup accepts a configured signing secret without an app token", + cfg: { + channels: { + slack: { + mode: "http", + signingSecret: "test-signing-secret", + }, + }, + } as OpenClawConfig, + input: { + useEnv: true, + }, + beforeTest: () => { + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-test"); + vi.stubEnv("SLACK_APP_TOKEN", ""); + }, + assertPatchedConfig: (cfg) => { + expect(cfg.channels?.slack).toMatchObject({ + enabled: true, + mode: "http", + signingSecret: "test-signing-secret", + }); + expect(cfg.channels?.slack?.appToken).toBeUndefined(); + }, + }, + { + name: "Socket Mode env setup rejects a missing app token", + cfg: {} as OpenClawConfig, + input: { + useEnv: true, + }, + beforeTest: () => { + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-test"); + vi.stubEnv("SLACK_APP_TOKEN", ""); + }, + expectedValidation: "Slack Socket Mode requires SLACK_APP_TOKEN when using --use-env.", + }, + { + name: "Socket Mode env setup accepts bot and app tokens", + cfg: {} as OpenClawConfig, + input: { + useEnv: true, + }, + beforeTest: () => { + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-test"); + vi.stubEnv("SLACK_APP_TOKEN", "xapp-test"); + }, + assertPatchedConfig: (cfg) => { + expect(cfg.channels?.slack).toMatchObject({ enabled: true }); + }, + }, { name: "user identity stores the user and Socket Mode transport tokens", cfg: {} as OpenClawConfig, diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index a6ee7ab90fc5..4c0aca4fd45c 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -187,9 +187,24 @@ const slackSetupAdapterBase = createPatchedAccountSetupAdapter({ return 'Slack user identity setup supports mode "socket" or "http", not "relay".'; } if (setupInput.useEnv) { - return identity === "user" - ? "Slack user identity setup does not support --use-env; configure userToken and the transport credential explicitly." - : null; + if (identity === "user") { + return "Slack user identity setup does not support --use-env; configure userToken and the transport credential explicitly."; + } + if ( + mode === "socket" && + !normalizeOptionalString(setupInput.appToken) && + account.appTokenStatus === "missing" + ) { + return "Slack Socket Mode requires SLACK_APP_TOKEN when using --use-env."; + } + if ( + mode === "http" && + !normalizeOptionalString(setupInput.signingSecret) && + account.signingSecretStatus === "missing" + ) { + return "Slack HTTP mode requires a configured signing secret when using --use-env."; + } + return null; } if (hasSlackSetupCredentials({ input: setupInput, identity, mode })) { return null; @@ -263,6 +278,7 @@ export const slackSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use Slack environment credentials" }, + envVars: ["SLACK_BOT_TOKEN"], }, }, legacyAdapter: slackSetupAdapter, diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 84d554174b44..cb8c963323d0 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -68,7 +68,8 @@ "cli": { "flags": "--use-env", "description": "Use Synology Chat environment credentials" - } + }, + "envVars": ["SYNOLOGY_CHAT_TOKEN"] } ] } diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index ee28adf9c519..943af0a334f5 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -218,6 +218,7 @@ export const synologyChatSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use Synology Chat environment credentials" }, + envVars: ["SYNOLOGY_CHAT_TOKEN"], }, }, legacyAdapter: synologyChatSetupAdapter, diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 41d062037fed..387c09b1c63b 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -74,7 +74,8 @@ "cli": { "flags": "--use-env", "description": "Use TELEGRAM_BOT_TOKEN" - } + }, + "envVars": ["TELEGRAM_BOT_TOKEN"] } ] }, diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 948505933cf9..f9eec0ff0bf5 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -124,6 +124,7 @@ export const telegramSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use TELEGRAM_BOT_TOKEN" }, + envVars: ["TELEGRAM_BOT_TOKEN"], }, }, legacyAdapter: telegramSetupAdapter, diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 1ab34cf7f424..46b69662a9e2 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -73,7 +73,8 @@ "cli": { "flags": "--use-env", "description": "Use ZALO_BOT_TOKEN" - } + }, + "envVars": ["ZALO_BOT_TOKEN"] } ] } diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index f145418191e7..58b80d2cf763 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -58,6 +58,7 @@ export const zaloSetupContract = defineChannelSetupContract({ useEnv: { kind: "boolean", cli: { flags: "--use-env", description: "Use ZALO_BOT_TOKEN" }, + envVars: ["ZALO_BOT_TOKEN"], }, }, legacyAdapter: zaloSetupAdapter, diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 123bc26455ac..ce17a3eec81e 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -298,7 +298,10 @@ "cli": { "flags": "--use-env", "description": "Use BUZZ_PRIVATE_KEY with the supplied relay URL" - } + }, + "envVars": [ + "BUZZ_PRIVATE_KEY" + ] } ] } @@ -624,7 +627,10 @@ "cli": { "flags": "--use-env", "description": "Use CLICKCLACK_BOT_TOKEN" - } + }, + "envVars": [ + "CLICKCLACK_BOT_TOKEN" + ] } ] } @@ -692,7 +698,10 @@ "cli": { "flags": "--use-env", "description": "Use DISCORD_BOT_TOKEN" - } + }, + "envVars": [ + "DISCORD_BOT_TOKEN" + ] } ] }, @@ -874,7 +883,12 @@ "cli": { "flags": "--use-env", "description": "Use Google Chat environment credentials" - } + }, + "envVars": [ + "GOOGLE_CHAT_SERVICE_ACCOUNT", + "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE" + ], + "envVarMode": "any" } ] } @@ -1059,7 +1073,11 @@ "cli": { "flags": "--use-env", "description": "Use IRC environment configuration" - } + }, + "envVars": [ + "IRC_HOST", + "IRC_NICK" + ] } ] } @@ -1152,7 +1170,11 @@ "cli": { "flags": "--use-env", "description": "Use LINE environment credentials" - } + }, + "envVars": [ + "LINE_CHANNEL_ACCESS_TOKEN", + "LINE_CHANNEL_SECRET" + ] } ] } @@ -1363,7 +1385,11 @@ "cli": { "flags": "--use-env", "description": "Use Mattermost environment credentials" - } + }, + "envVars": [ + "MATTERMOST_BOT_TOKEN", + "MATTERMOST_URL" + ] } ] } @@ -1518,7 +1544,10 @@ "cli": { "flags": "--use-env", "description": "Use Nextcloud Talk environment credentials" - } + }, + "envVars": [ + "NEXTCLOUD_TALK_BOT_SECRET" + ] } ] } @@ -1578,7 +1607,10 @@ "cli": { "flags": "--use-env", "description": "Use NOSTR_PRIVATE_KEY" - } + }, + "envVars": [ + "NOSTR_PRIVATE_KEY" + ] } ] } @@ -2119,7 +2151,10 @@ "cli": { "flags": "--use-env", "description": "Use Slack environment credentials" - } + }, + "envVars": [ + "SLACK_BOT_TOKEN" + ] } ] } @@ -2317,7 +2352,10 @@ "cli": { "flags": "--use-env", "description": "Use Synology Chat environment credentials" - } + }, + "envVars": [ + "SYNOLOGY_CHAT_TOKEN" + ] } ] } @@ -2656,7 +2694,10 @@ "cli": { "flags": "--use-env", "description": "Use ZALO_BOT_TOKEN" - } + }, + "envVars": [ + "ZALO_BOT_TOKEN" + ] } ] } diff --git a/src/channels/plugins/account-config-mutation.test.ts b/src/channels/plugins/account-config-mutation.test.ts index cc584270f57d..cad1f98c3ebd 100644 --- a/src/channels/plugins/account-config-mutation.test.ts +++ b/src/channels/plugins/account-config-mutation.test.ts @@ -145,6 +145,31 @@ describe("channel account config mutations", () => { expect(applyAccountConfig).not.toHaveBeenCalled(); }); + it("preserves --use-env behavior for contracts without env metadata", async () => { + const applyAccountConfig = vi.fn(({ cfg }) => cfg); + const plugin = { + ...createChannelTestPluginBase({ id: "third-party-chat" }), + setupContract: defineChannelSetupContract({ + fields: { + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use plugin environment credentials" }, + }, + }, + adapter: { applyAccountConfig }, + }), + } as ChannelPlugin; + + const prepared = await prepareChannelAccountConfiguration({ + cfg: {}, + plugin, + resolveInput: () => ({ useEnv: true }), + runtime, + }); + + expect(prepared.ok).toBe(true); + }); + it("normalizes plugin-resolved account IDs only at the config mutation boundary", async () => { const applyAccountConfig = vi.fn(({ cfg }) => cfg); const onAccountConfigChanged = vi.fn(); diff --git a/src/channels/plugins/account-config-mutation.ts b/src/channels/plugins/account-config-mutation.ts index c967c4850a71..e6b9ac0668bd 100644 --- a/src/channels/plugins/account-config-mutation.ts +++ b/src/channels/plugins/account-config-mutation.ts @@ -1,8 +1,12 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { resolveChannelSetupExecutionAdapter } from "./setup-contract.js"; +import { + resolveChannelSetupExecutionAdapter, + type ChannelSetupFieldMetadata, +} from "./setup-contract.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "./setup-helpers.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -26,6 +30,28 @@ type PreparedChannelAccountConfiguration = { input: unknown; }; +function resolveMissingSetupEnvMessage(plugin: ChannelPlugin, input: unknown): string | undefined { + if (!plugin.setupContract || !isRecord(input) || input.useEnv !== true) { + return undefined; + } + const useEnvField = plugin.setupContract.metadata.fields.find( + (field): field is Extract => + field.kind === "boolean" && field.key === "useEnv", + ); + if (!useEnvField?.envVars?.length) { + return undefined; + } + const { envVars, envVarMode } = useEnvField; + const missing = envVars.filter((name) => !process.env[name]?.trim()); + const ready = envVarMode === "any" ? missing.length < envVars.length : !missing.length; + if (ready) { + return undefined; + } + return envVarMode === "any" + ? `Set one of these environment variables before using --use-env: ${missing.join(", ")}.` + : `Set these environment variables before using --use-env: ${missing.join(", ")}.`; +} + export async function prepareChannelAccountConfiguration(params: { cfg: OpenClawConfig; plugin: ChannelPlugin; @@ -77,6 +103,10 @@ export async function prepareChannelAccountConfiguration(params: { if (validationError) { return resultError({ kind: "invalid-input", message: validationError }); } + const missingEnvMessage = resolveMissingSetupEnvMessage(params.plugin, input); + if (missingEnvMessage) { + return resultError({ kind: "invalid-input", message: missingEnvMessage }); + } return ok({ plugin: params.plugin, diff --git a/src/channels/plugins/setup-contract.test.ts b/src/channels/plugins/setup-contract.test.ts index 784240933d4c..99adaccd0f54 100644 --- a/src/channels/plugins/setup-contract.test.ts +++ b/src/channels/plugins/setup-contract.test.ts @@ -222,6 +222,12 @@ describe("defineChannelSetupContract", () => { choices: ["socket", "http"], cli: { flags: "--mode ", description: "Connection mode" }, }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use environment credentials" }, + envVars: ["CHAT_TOKEN", "CHAT_TOKEN_FILE"], + envVarMode: "any", + }, }, adapter: { applyAccountConfig: ({ cfg }) => cfg, @@ -242,6 +248,13 @@ describe("defineChannelSetupContract", () => { choices: ["socket", "http"], cli: { flags: "--mode ", description: "Connection mode" }, }, + { + key: "useEnv", + kind: "boolean", + cli: { flags: "--use-env", description: "Use environment credentials" }, + envVars: ["CHAT_TOKEN", "CHAT_TOKEN_FILE"], + envVarMode: "any", + }, ], }); }); diff --git a/src/channels/plugins/setup-contract.ts b/src/channels/plugins/setup-contract.ts index 7ebd42eb9e12..c87bfce08b4a 100644 --- a/src/channels/plugins/setup-contract.ts +++ b/src/channels/plugins/setup-contract.ts @@ -22,6 +22,8 @@ type ChannelSetupStringField = { type ChannelSetupBooleanField = { kind: "boolean"; cli: ChannelSetupCliOption; + envVars?: readonly string[]; + envVarMode?: "all" | "any"; }; type ChannelSetupIntegerField = { @@ -48,9 +50,11 @@ type ChannelSetupField = | ChannelSetupStringListField | ChannelSetupChoiceField; -export type ChannelSetupFieldMetadata = ChannelSetupField & { - key: string; -}; +type ChannelSetupFieldMetadataFor = Field extends ChannelSetupField + ? Field & { key: string } + : never; + +export type ChannelSetupFieldMetadata = ChannelSetupFieldMetadataFor; export type ChannelSetupMetadata = { fields: readonly ChannelSetupFieldMetadata[]; diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index df6df0e1316f..65601ccfd79d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -1,6 +1,6 @@ // Channels add tests cover guided setup, plugin install paths, and channel account config writes. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; import { defineChannelSetupContract } from "../channels/plugins/setup-contract.js"; @@ -47,6 +47,10 @@ const pluginInstallRecordCommitMocks = vi.hoisted(() => ({ commitConfigWithPendingPluginInstalls: vi.fn(), })); +const terminalMocks = vi.hoisted(() => ({ + isTerminalInteractive: vi.fn(() => true), +})); + const channelWizardMocks = vi.hoisted(() => { const prompter = { intro: vi.fn(async () => undefined), @@ -99,6 +103,8 @@ vi.mock("../plugins/registry-refresh.js", () => registryRefreshMocks); vi.mock("../plugins/install-record-commit.js", () => pluginInstallRecordCommitMocks); +vi.mock("../cli/terminal-interactivity.js", () => terminalMocks); + vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: () => channelWizardMocks.prompter, })); @@ -365,6 +371,17 @@ function registerExternalChatSetupPlugin(pluginId = "@vendor/external-chat-plugi ); } +async function registerBundledSetupPlugin(channelId: string): Promise { + const actual = await vi.importActual( + "../channels/plugins/bundled.js", + ); + const plugin = actual.getBundledChannelSetupPlugin(channelId as never); + if (!plugin) { + throw new Error(`Expected bundled setup plugin: ${channelId}`); + } + setActivePluginRegistry(createTestRegistry([{ pluginId: channelId, plugin, source: "test" }])); +} + type SignalAfterAccountConfigWritten = NonNullable< NonNullable["afterAccountConfigWritten"] >; @@ -450,6 +467,7 @@ describe("channelsAddCommand", () => { runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); + terminalMocks.isTerminalInteractive.mockReset().mockReturnValue(true); catalogMocks.getChannelPluginCatalogEntry.mockClear(); catalogMocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); catalogMocks.listChannelPluginCatalogEntries.mockClear(); @@ -484,6 +502,119 @@ describe("channelsAddCommand", () => { setMinimalChannelsAddRegistryForTests(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("fails fast before guided setup when no interactive terminal is available", async () => { + terminalMocks.isTerminalInteractive.mockReturnValue(false); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand({ channel: "telegram" }, runtime, { hasFlags: false }); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("channels add --channel --use-env"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(channelWizardMocks.setupChannels).not.toHaveBeenCalled(); + }); + + it.each([ + { + channel: "telegram", + options: {}, + env: { TELEGRAM_BOT_TOKEN: "" }, + missing: ["TELEGRAM_BOT_TOKEN"], + }, + { + channel: "slack", + options: {}, + env: { SLACK_BOT_TOKEN: "xoxb-token", SLACK_APP_TOKEN: "" }, + missing: ["SLACK_APP_TOKEN"], + }, + { + channel: "buzz", + options: { relayUrl: "wss://buzz.example.com" }, + env: { BUZZ_PRIVATE_KEY: "" }, + missing: ["BUZZ_PRIVATE_KEY"], + }, + ])("rejects $channel --use-env when declared env vars are missing", async (testCase) => { + for (const [name, value] of Object.entries(testCase.env)) { + vi.stubEnv(name, value); + } + await registerBundledSetupPlugin(testCase.channel); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand( + { channel: testCase.channel, useEnv: true, ...testCase.options }, + runtime, + { hasFlags: true }, + ); + + for (const missing of testCase.missing) { + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(missing)); + } + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it.each([ + { + channel: "telegram", + env: { TELEGRAM_BOT_TOKEN: "telegram-token" }, + }, + { + channel: "slack", + env: { SLACK_BOT_TOKEN: "xoxb-token", SLACK_APP_TOKEN: "xapp-token" }, + }, + ])("commits $channel --use-env config when declared env vars are present", async (testCase) => { + for (const [name, value] of Object.entries(testCase.env)) { + vi.stubEnv(name, value); + } + await registerBundledSetupPlugin(testCase.channel); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand({ channel: testCase.channel, useEnv: true }, runtime, { + hasFlags: true, + }); + + expect(writtenChannel(testCase.channel)).toEqual({ enabled: true }); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("commits Slack HTTP --use-env config without SLACK_APP_TOKEN", async () => { + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token"); + vi.stubEnv("SLACK_APP_TOKEN", ""); + await registerBundledSetupPlugin("slack"); + const config: OpenClawConfig = { + channels: { + slack: { + mode: "http", + signingSecret: "test-signing-secret", + }, + }, + }; + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + sourceConfig: config, + config, + }); + + await channelsAddCommand({ channel: "slack", useEnv: true }, runtime, { + hasFlags: true, + }); + + expect(writtenChannel("slack")).toMatchObject({ + enabled: true, + mode: "http", + signingSecret: "test-signing-secret", + }); + expect(writtenChannel("slack").appToken).toBeUndefined(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("keeps guided channel setup lazy until the user selects a channel", async () => { const config: OpenClawConfig = { channels: {} }; configMocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 10a94be8f040..52e0598bc192 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -17,6 +17,7 @@ import { formatUnknownChannelMessage, formatUnsupportedChannelActionMessage, } from "../../cli/error-format.js"; +import { isTerminalInteractive } from "../../cli/terminal-interactivity.js"; import type { OpenClawConfig } from "../../config/config.js"; import { commitConfigWithPendingPluginInstalls } from "../../plugins/install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js"; @@ -154,6 +155,13 @@ async function channelsAddCommandImpl( const useWizard = shouldUseWizard(params); if (useWizard) { + if (!isTerminalInteractive()) { + runtime.error( + "Interactive channel setup requires a TTY. Use `openclaw channels add --channel --use-env` or pass the channel's credential flags for non-interactive setup.", + ); + runtime.exit(1); + return; + } const { resolveInitialWizardChannel, runChannelsAddWizardFlow } = await import("./add-wizard.js"); const initialChannel = await resolveInitialWizardChannel(opts.channel ?? "", cfg); diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index 4d63746f2595..a72063d4b936 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -681,6 +681,8 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { { key: "useEnv", kind: "boolean", + envVars: ["INSTALLED_TOKEN", "INSTALLED_TOKEN_FILE"], + envVarMode: "any", cli: { flags: "--use-env", negatedFlags: "--no-use-env", @@ -740,6 +742,8 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { { key: "useEnv", kind: "boolean", + envVars: ["INSTALLED_TOKEN", "INSTALLED_TOKEN_FILE"], + envVarMode: "any", cli: { flags: "--use-env", negatedFlags: "--no-use-env", diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index bdad5cd8d8f5..7cd5a823d816 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -353,6 +353,19 @@ function normalizePackageChannelSetup(setup: unknown): PluginPackageChannel["set }); continue; } + if (kind === "boolean") { + const envVars = normalizeOptionalTrimmedStringList(value.envVars); + const envVarMode = + value.envVarMode === "any" || value.envVarMode === "all" ? value.envVarMode : undefined; + fields.push({ + key, + kind, + ...(envVars?.length ? { envVars } : {}), + ...(envVars?.length && envVarMode ? { envVarMode } : {}), + cli, + }); + continue; + } fields.push({ key, kind, cli }); } return { fields };