mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
bea0e398fa
commit
99d662473c
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"1bb99b68596361bc2ef9f1c8011f1d87fbbdc78d0bde58d9e89625a5e2bf2de7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"60140db5b14eb3811a0398983df4729a0c4c2036f49a85622c1ed4a62b2e232a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"0411ae87d138ede40528ca7416fb1a9716f7f73b2b788e7c322048a6854b40d9","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
{"contentHash":"a5fd8c9fbf19c5a9eb6dca252cd551616571ddf344cf8e2963135e4b418cf063","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6c638b1fcf5a1cadce5a5363ae4cd8f29fe513feb90331272349ef5bf70bc63a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"e78d0b185d0a718fc12254b09e8432265614c8cdddae804a383fd0da3093968a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"5c1b1998403a8527055fff05caa86f51ccd981c08ebd8ba8c7f9da19a8e16e33","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"9e96e620155dbc97b8cc74b1eeaf8987fc535d23e9f3c86450a9a75cc202964d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"751d37b918948737d4324f6323e1f8f6151f991d55c3794e1908f79225745fc7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"febc6854cb89d5c26debeec50008526d0a8d97738f727011c3942509bb538fcc","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"02568442a1df10e1626b79a61f16271fcd6982a773afb3036c552f162d76c13d","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"}
|
||||
{"contentHash":"7a5e4516352775c3c3ed72faf36240357c6437dda7f9dd223088f5e5766cc3e2","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"99e8ca8cbf3a57960650ea6f69422dbd88c8cc80074c8166350e57a5124a5ccb","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
{"contentHash":"f2849884f368eadff174176a2808c1c74d041968049b460d6f8b408682c5862b","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"c21f020ef8f64d952590f6e532639ca678a97368b02df665e7819397869226e6","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
{"contentHash":"2c21d121fd9d14d7f45368b5f02dff257b6750215d63d4be9ace28b5c0d3c0f8","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2c95ec54b192b730e9a9c606f77e66a2d0dd9eb09dd66a3c6ad8fea9fca31585","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"4ba3f7cf748c5ba26f22d626499c4bce05ef8e17f69bf864a87c1df3ab2dc5d4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d2051c433956de7d579b91d4e21e39d48334e63a632baf941bde0cef85ca8ac0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"518f095d39488d9c6fcfff4cf68043bca7308e4e807020d718f6a05f139d1b6b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"3ba67320fb902e12fdf2399c4d8e74403e78e74add8dfed8213154f4ab68a858","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"3447ab3b4f9d00dd164e97954dbccb7cd53d2298aa5204c2017f390f15588437","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"ca3136e446cf1fc294279e24078e3a4c6342a048b00f8012d519903c887ef3a7","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"6dea61641b09e01cdf88055074dceebfb16545da28b6dfefed6b1d85e66dbfeb","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"5f0b7be43fcb5e2240053e7ada316b35f28cbcdd0619b724463627961927dcb9","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"c9e9acdc7f66b6020b738d7a26657a09edce6f21c5ab5acd65e0dac8a7c3bb6f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"a82f45bdc59736c09a6124e788bae6a54b6d28c19dafde34458445f81ada3b74","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
{"contentHash":"c63113a4720b3d4795a3deb00ce3652cc9481522af311b9a269e4fe0d84703bb","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"93827c27397a0d4b2872c801e4175ce371f4ec4b7aa5f85331bd46f5323174c9","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"}
|
||||
{"contentHash":"19bbe24a8d2e70f8c9a34686275d86df4ac4829dbe37c83df530fe5685d3ae9b","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"713d698d49f9dcfea998d3d520458dc61c1a6840cb4d1266fff353a9086af30d","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"}
|
||||
{"contentHash":"fe9cc43264a17feb6d108ddeb13a6b0ebc731575fca56460c10291fe116102fb","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"018adbdc87ee4a490fdea45b3ee249a55dca337d655cad8929e63986f4e633bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"dffc96ff312adfa28713a43b47d8ffc70e44c65be64c65e129d775a8fd77728b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"1dac77955687176848405413e59d76ea0511e30722eaa32bf3a3cdd44c5a6395","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"4a6a6b7e93f7169d0aed68b74121ff4676d120d4a88e1b06ced1f2434e030677","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -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="<provider-key>"
|
||||
export TELEGRAM_BOT_TOKEN="<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.
|
||||
|
||||
<Tip>
|
||||
`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.
|
||||
</Tip>
|
||||
@@ -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 <id> --use-env` or pass the selected plugin's credential flags.
|
||||
|
||||
The wizard can prompt for:
|
||||
|
||||
- account ids per selected channel
|
||||
|
||||
@@ -102,6 +102,37 @@ Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### 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=<provider-key>
|
||||
OPENCLAW_GATEWAY_TOKEN=<gateway-token>
|
||||
TELEGRAM_BOT_TOKEN=<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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use BUZZ_PRIVATE_KEY with the supplied relay URL"
|
||||
}
|
||||
},
|
||||
"envVars": ["BUZZ_PRIVATE_KEY"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -58,17 +58,18 @@ const buzzSetupAdapter: ChannelSetupAdapter<BuzzSetupInput> = {
|
||||
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,
|
||||
|
||||
@@ -127,7 +127,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use CLICKCLACK_BOT_TOKEN"
|
||||
}
|
||||
},
|
||||
"envVars": ["CLICKCLACK_BOT_TOKEN"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use DISCORD_BOT_TOKEN"
|
||||
}
|
||||
},
|
||||
"envVars": ["DISCORD_BOT_TOKEN"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use IRC environment configuration"
|
||||
}
|
||||
},
|
||||
"envVars": ["IRC_HOST", "IRC_NICK"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use LINE environment credentials"
|
||||
}
|
||||
},
|
||||
"envVars": ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use Mattermost environment credentials"
|
||||
}
|
||||
},
|
||||
"envVars": ["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use Nextcloud Talk environment credentials"
|
||||
}
|
||||
},
|
||||
"envVars": ["NEXTCLOUD_TALK_BOT_SECRET"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use NOSTR_PRIVATE_KEY"
|
||||
}
|
||||
},
|
||||
"envVars": ["NOSTR_PRIVATE_KEY"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
@@ -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<NostrSetup
|
||||
useEnv: {
|
||||
kind: "boolean",
|
||||
cli: { flags: "--use-env", description: "Use NOSTR_PRIVATE_KEY" },
|
||||
envVars: [NOSTR_PRIVATE_KEY_ENV_VAR],
|
||||
},
|
||||
},
|
||||
adapter,
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { getPublicKeyFromPrivate } from "./nostr-key-utils.js";
|
||||
import { resolveNostrPrivateKey } from "./private-key.js";
|
||||
|
||||
interface NostrAccountConfig {
|
||||
enabled?: boolean;
|
||||
@@ -42,7 +43,7 @@ const {
|
||||
fallbackAccountIdWhenEmpty: false,
|
||||
resolveImplicitAccountId: (cfg) => {
|
||||
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 = "";
|
||||
|
||||
@@ -139,7 +139,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use Slack environment credentials"
|
||||
}
|
||||
},
|
||||
"envVars": ["SLACK_BOT_TOKEN"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use Synology Chat environment credentials"
|
||||
}
|
||||
},
|
||||
"envVars": ["SYNOLOGY_CHAT_TOKEN"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use TELEGRAM_BOT_TOKEN"
|
||||
}
|
||||
},
|
||||
"envVars": ["TELEGRAM_BOT_TOKEN"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"cli": {
|
||||
"flags": "--use-env",
|
||||
"description": "Use ZALO_BOT_TOKEN"
|
||||
}
|
||||
},
|
||||
"envVars": ["ZALO_BOT_TOKEN"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ChannelSetupFieldMetadata, { kind: "boolean" }> =>
|
||||
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,
|
||||
|
||||
@@ -222,6 +222,12 @@ describe("defineChannelSetupContract", () => {
|
||||
choices: ["socket", "http"],
|
||||
cli: { flags: "--mode <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 <mode>", description: "Connection mode" },
|
||||
},
|
||||
{
|
||||
key: "useEnv",
|
||||
kind: "boolean",
|
||||
cli: { flags: "--use-env", description: "Use environment credentials" },
|
||||
envVars: ["CHAT_TOKEN", "CHAT_TOKEN_FILE"],
|
||||
envVarMode: "any",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 extends ChannelSetupField
|
||||
? Field & { key: string }
|
||||
: never;
|
||||
|
||||
export type ChannelSetupFieldMetadata = ChannelSetupFieldMetadataFor<ChannelSetupField>;
|
||||
|
||||
export type ChannelSetupMetadata = {
|
||||
fields: readonly ChannelSetupFieldMetadata[];
|
||||
|
||||
@@ -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<void> {
|
||||
const actual = await vi.importActual<typeof import("../channels/plugins/bundled.js")>(
|
||||
"../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<ChannelPlugin["setup"]>["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 <id> --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({
|
||||
|
||||
@@ -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 <id> --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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user