diff --git a/docs/installation.md b/docs/installation.md index 681a2283..59e0af29 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -379,6 +379,100 @@ helm upgrade mosaic "$MOSAIC_CHART" \ --timeout 12m ``` +### Feishu / Lark + +Feishu and Lark messaging is optional and uses OpenClaw's native `feishu` channel plugin, pinned to the chart's OpenClaw release. The gateway opens an outbound persistent connection to the Feishu Open Platform, so no ingress or public endpoint is required. The `openclaw` pod needs HTTPS egress to `open.feishu.cn` and to the persistent connection endpoint that it returns, currently `msg-frontier.feishu.cn` (`open.larksuite.com` and `msg-frontier.larksuite.com` for Lark). Before the gateway starts, an init container installs `@openclaw/feishu` at `openclaw.feishu.pluginVersion` from `registry.npmjs.org` and stops the pod unless the integrity that npm verified matches `openclaw.feishu.pluginIntegrity`, so the pod also needs egress to `registry.npmjs.org`. + +In the [Feishu Open Platform](https://open.feishu.cn/app) or Lark Developer console, create a custom app and: + +1. Enable the **Bot** capability. +2. Grant the messaging scopes `im:message`, `im:message.p2p_msg:readonly`, `im:message.group_at_msg:readonly`, `im:message:send_as_bot`, and `im:resource`, plus `cardkit:card:write` for streaming replies and `contact:contact.base:readonly` so the agent sees sender names instead of `open_id` values. Name lookups also require the senders to be inside the app's contact data range, configured under the app's data permissions. If the contact scope cannot be granted, set `openclaw.feishu.resolveSenderNames: false`; otherwise every message makes a failed name lookup. The chart disables the plugin's document, wiki, drive, Bitable, chat, and permission tools, so their scopes are not needed. +3. Under event subscriptions, choose the persistent connection mode and add `im.message.receive_v1`. The console verifies the connection, so save this step after the gateway is running with the app credentials. +4. Publish a version and set the app's availability to the intended users. + +Create a Secret in the release namespace from the app's App ID and App Secret. Keep the secret values outside Helm values and source control. + +```bash +read -rp 'Feishu App ID (cli_...): ' FEISHU_APP_ID +read -rsp 'Feishu App Secret: ' FEISHU_APP_SECRET; echo + +kubectl -n mosaic create secret generic mosaic-feishu \ + --from-literal=FEISHU_APP_ID="$FEISHU_APP_ID" \ + --from-literal=FEISHU_APP_SECRET="$FEISHU_APP_SECRET" \ + --dry-run=client -o yaml | kubectl apply -f - +unset FEISHU_APP_SECRET +``` + +Add these values to the release configuration and apply them through the normal Helm upgrade workflow: + +```yaml +openclaw: + feishu: + enabled: true + existingSecret: mosaic-feishu + # Use lark for Lark tenants. + domain: feishu + allowedUserIds: + - "ou_xxxxxxxxxxxxxxxx" + # Optional group chats where allowed users can @mention the bot. + groupChatIds: + - "oc_xxxxxxxxxxxxxxxx" +``` + +`allowedUserIds` lists Feishu `open_id` values (`ou_...`). An `open_id` is specific to the app. To find one, deploy with a placeholder ID, have the user send the bot a direct message, and read the rejected sender from the gateway log: + +```bash +kubectl -n mosaic logs deploy/openclaw -c gateway | grep 'blocked unauthorized sender' +``` + +`groupChatIds` lists group `chat_id` values (`oc_...`), shown in the group's settings. A group message reaches the agent only when its chat is listed, its sender is in `allowedUserIds`, and it @mentions the bot. Group messages are ignored while `groupChatIds` is empty. + +Each Feishu user has a separate direct-message session, and each group chat shares one session among its allowed members. Feishu conversations always run in View, whether or not the Edit module is enabled; use the web UI for Edit or Auto. Slack can remain enabled alongside Feishu. + +To rotate the App Secret, update the Secret and run `kubectl -n mosaic rollout restart deployment/openclaw`. Updating only the Secret does not restart the pod. When changing `openclaw.feishu.pluginVersion`, set `openclaw.feishu.pluginIntegrity` to the new package's integrity: + +```bash +read -rp 'Feishu plugin version: ' VERSION +curl -fsSL "https://registry.npmjs.org/@openclaw/feishu/-/feishu-${VERSION}.tgz" \ + | python3 -c 'import base64, hashlib, sys; print("sha512-" + base64.b64encode(hashlib.sha512(sys.stdin.buffer.read()).digest()).decode())' +``` + +The Feishu connection does not affect the gateway's readiness, so a Ready `openclaw` pod does not mean that Feishu is connected. Check the plugin's log lines instead: + +```bash +kubectl -n mosaic logs deploy/openclaw -c gateway | grep '\[feishu\]' +``` + +| Log message | Meaning | +|---|---| +| `app_id or app_secret is invalid` | The Secret holds a wrong App ID or App Secret. | +| `bot open_id unknown` | The bot identity lookup failed, usually because of wrong credentials or a missing Bot capability. Group mentions are ignored until a later retry succeeds. | +| `blocked unauthorized sender ou_...` | The direct-message sender is not in `allowedUserIds`. | +| `not in groupAllowFrom` | The group chat is not in `groupChatIds`. | +| `not in group ... sender allowlist` | The group message sender is not in `allowedUserIds`. | +| `did not mention bot` | A group message without an @mention, ignored by design. | +| `permission error resolving sender name` or `ignoring stale permission scope error` | The app lacks `contact:contact.base:readonly`. | +| `failed to resolve sender name` | The name lookup failed for another reason, most often because the sender is outside the app's contact data range. | + +To deliver scheduled reports to Feishu, enable the OpenClaw scheduler, which is disabled by default: + +```yaml +openclaw: + cron: + enabled: true +``` + +Then create an isolated job that announces its result to a user (`user:ou_...`) or a group chat (`chat:oc_...`): + +```bash +kubectl -n mosaic exec deploy/openclaw -c gateway -- node /app/dist/index.js cron create "0 9 * * *" \ + --tz Asia/Shanghai --name "Daily cluster check" --session isolated \ + --message "Run a read-only cluster check. Summarize node status, GPU capacity, allocatable and requested counts, failing pods, and recent warning events." \ + --announce --channel feishu --to "user:ou_xxxxxxxxxxxxxxxx" +``` + +Creating, editing, and manually running jobs requires the `operator.admin` scope. After each pod start, the gateway CLI is paired with read access only, so its first job command opens a scope upgrade request. Approve that request from the OpenClaw Control UI, which listens on the `openclaw` Service's gateway port and accepts the token from the `openclaw-secrets` Secret. Alternatively, manage jobs directly in the Control UI. Jobs persist on the OpenClaw volume across pod restarts, and scheduled runs stay in View. + ### Access Modes Enable guarded write operations with the Edit module: @@ -394,7 +488,7 @@ modules: Every interactive conversation starts in **View**. Users can switch that conversation to **Edit** for approval-gated changes or **Auto** for validated changes without approval. Auto requires confirmation through a warning dialog. Other conversations remain in View, and automated alert and cluster-health sessions are always read-only. When the module is disabled, the access control is not shown. -Native Slack users remain read-only unless their verified Slack user ID is listed under `openclaw.slack.editUserIds`. Edit users approve their own mutations with Slack buttons. The broader `openclaw.slack.allowedUserIds` list controls who may use AI Factory Operations Agent through DMs, group DMs, and channel mentions. +Native Slack users remain read-only unless their verified Slack user ID is listed under `openclaw.slack.editUserIds`. Edit users approve their own mutations with Slack buttons. The broader `openclaw.slack.allowedUserIds` list controls who may use AI Factory Operations Agent through DMs, group DMs, and channel mentions. Feishu users are always read-only. Privileges are attached to separate tools and credentials. Read-only infrastructure tools and sandbox-local execution run automatically in View. OpenShell enforces local execution boundaries; the diagnostic command allowlist applies to remote SSH. Mutating Kubernetes, Base Command Manager, and SSH operations are blocked in View, approval-gated in Edit when `hitl: true`, and automatic in Auto. Kubernetes RBAC, request validation, configured SSH hosts, and audit logging apply in every mode. diff --git a/docs/security_model.md b/docs/security_model.md index 3dffa9cc..512f8f2e 100644 --- a/docs/security_model.md +++ b/docs/security_model.md @@ -18,6 +18,8 @@ Automated alert and cluster-health sessions are always View-only. They cannot be For native Slack, `openclaw.slack.allowedUserIds` remains the messaging allowlist for DMs, group DMs, and channel mentions. `openclaw.slack.editUserIds` is narrower: those verified users receive Edit and approve their own mutations through Slack buttons; every other allowed Slack user remains in View. +For native Feishu, `openclaw.feishu.allowedUserIds` is the messaging allowlist for DMs and group mentions, and `openclaw.feishu.groupChatIds` limits which group chats are admitted. Feishu has no identity-verified approval path, so every Feishu run is View even if the conversation's stored mode was changed elsewhere. The chart also disables the Feishu plugin's document, wiki, drive, Bitable, chat, and permission tools. + Setting `modules.edit.hitl=false` also removes approvals from Edit mode. Keep the default `true` unless every user permitted to select Edit is trusted to make changes directly. ## Kubernetes Boundaries diff --git a/helm/mosaic-stack/files/agent-documentation.tar.gz b/helm/mosaic-stack/files/agent-documentation.tar.gz index 37c5d506..f4860bc7 100644 Binary files a/helm/mosaic-stack/files/agent-documentation.tar.gz and b/helm/mosaic-stack/files/agent-documentation.tar.gz differ diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/index.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/index.ts index 4156ea3d..c4a3193e 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/index.ts +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/index.ts @@ -58,6 +58,22 @@ export default definePluginEntry({ }); api.on("agent_end", (_event, context) => clearRunAccess(context.runId)); } + if (pluginConfig.feishuEnabled === true) { + // Feishu has no identity-verified approval path, so its runs stay in View + // even if the conversation's stored access state was changed elsewhere. + api.on("before_prompt_build", (_event, context) => { + if (context.channel !== "feishu" && context.messageProvider !== "feishu") return; + setRunAccess(context.runId, { + mode: "view", + provider: "feishu", + ...(context.senderId ? { senderId: context.senderId } : {}), + }); + return { + prependContext: "[Runtime Context]\nai_factory_operations_agent_access_mode=view\nThis access mode is authoritative for the current Feishu user.", + }; + }); + api.on("agent_end", (_event, context) => clearRunAccess(context.runId)); + } api.session.state.registerSessionExtension(sessionAccessExtension); api.registerTrustedToolPolicy({ id: "session-access", diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/openclaw.plugin.json b/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/openclaw.plugin.json index c6334fe9..0d8f1842 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/openclaw.plugin.json +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/automation-guard/openclaw.plugin.json @@ -20,6 +20,9 @@ "type": "array", "items": { "type": "string" } }, + "feishuEnabled": { + "type": "boolean" + }, "hitl": { "type": "boolean" }, diff --git a/helm/mosaic-stack/files/openclaw-seed/nemoclaw.json b/helm/mosaic-stack/files/openclaw-seed/nemoclaw.json index 094a90a0..1facbc0b 100644 --- a/helm/mosaic-stack/files/openclaw-seed/nemoclaw.json +++ b/helm/mosaic-stack/files/openclaw-seed/nemoclaw.json @@ -97,10 +97,12 @@ } ] }, - {{- if .Values.openclaw.slack.enabled }} + {{- if or .Values.openclaw.slack.enabled .Values.openclaw.feishu.enabled }} "session": { - "dmScope": {{ .Values.openclaw.slack.session.dmScope | quote }} + "dmScope": {{ ternary .Values.openclaw.slack.session.dmScope "per-channel-peer" .Values.openclaw.slack.enabled | quote }} }, + {{- end }} + {{- if .Values.openclaw.slack.enabled }} "diagnostics": { "enabled": true, "stuckSessionWarnMs": {{ .Values.openclaw.slack.diagnostics.stuckSessionWarnMs }}, @@ -141,8 +143,36 @@ "servers": {{ $managedMcpServers | toJson }} }, {{- end }} - {{- if .Values.openclaw.slack.enabled }} + {{- if or .Values.openclaw.slack.enabled .Values.openclaw.feishu.enabled }} "channels": { + {{- if .Values.openclaw.feishu.enabled }} + "feishu": { + "enabled": true, + "domain": {{ .Values.openclaw.feishu.domain | quote }}, + "connectionMode": "websocket", + "appId": "${FEISHU_APP_ID}", + "appSecret": "${FEISHU_APP_SECRET}", + "configWrites": false, + "dmPolicy": "allowlist", + "allowFrom": {{ .Values.openclaw.feishu.allowedUserIds | toJson }}, + "groupPolicy": {{ ternary "allowlist" "disabled" (gt (len .Values.openclaw.feishu.groupChatIds) 0) | quote }}, + "groupAllowFrom": {{ .Values.openclaw.feishu.groupChatIds | toJson }}, + "groupSenderAllowFrom": {{ .Values.openclaw.feishu.allowedUserIds | toJson }}, + "requireMention": true, + "streaming": {{ .Values.openclaw.feishu.streaming }}, + "resolveSenderNames": {{ .Values.openclaw.feishu.resolveSenderNames }}, + "tools": { + "doc": false, + "chat": false, + "wiki": false, + "drive": false, + "perm": false, + "scopes": false, + "bitable": false + } + }{{- if .Values.openclaw.slack.enabled }},{{- end }} + {{- end }} + {{- if .Values.openclaw.slack.enabled }} "slack": { "enabled": true, "mode": "socket", @@ -177,6 +207,7 @@ "unfurlLinks": false, "unfurlMedia": false } + {{- end }} }, {{- end }} "models": { @@ -213,7 +244,7 @@ } }, "cron": { - "enabled": false + "enabled": {{ .Values.openclaw.cron.enabled }} }, "plugins": { "allow": [ @@ -227,7 +258,8 @@ "observability", "remote-ssh", "slurm", - "llm"{{- if .Values.openclaw.slack.enabled }}, + "llm"{{- if .Values.openclaw.feishu.enabled }}, + "feishu"{{- end }}{{- if .Values.openclaw.slack.enabled }}, "slack"{{- end }}{{- if .Values.modules.webSearch.enabled }}, {{ .Values.modules.webSearch.provider | quote }}{{- end }} ], @@ -258,12 +290,16 @@ {{- end }} }, "slackEnabled": {{ .Values.openclaw.slack.enabled }}, - "slackEditUserIds": {{ .Values.openclaw.slack.editUserIds | toJson }} + "slackEditUserIds": {{ .Values.openclaw.slack.editUserIds | toJson }}, + "feishuEnabled": {{ .Values.openclaw.feishu.enabled }} } }, "llm": { "enabled": true - }{{- if .Values.openclaw.slack.enabled }}, + }{{- if .Values.openclaw.feishu.enabled }}, + "feishu": { + "enabled": true + }{{- end }}{{- if .Values.openclaw.slack.enabled }}, "slack": { "enabled": true }{{- end }}{{- if .Values.modules.webSearch.enabled }}, diff --git a/helm/mosaic-stack/templates/openclaw.yaml b/helm/mosaic-stack/templates/openclaw.yaml index e0bb7746..f11eab91 100644 --- a/helm/mosaic-stack/templates/openclaw.yaml +++ b/helm/mosaic-stack/templates/openclaw.yaml @@ -306,6 +306,35 @@ spec: - name: remote-ssh-credentials mountPath: /credentials {{- end }} + {{- if .Values.openclaw.feishu.enabled }} + - name: init-feishu-plugin + image: {{ include "mosaic-stack.image" .Values.openclaw.image | quote }} + imagePullPolicy: {{ .Values.openclaw.image.pullPolicy }} + command: + - sh + - -c + - | + set -eu + node /app/dist/index.js plugins install {{ printf "@openclaw/feishu@%v" .Values.openclaw.feishu.pluginVersion | quote }} --force + node /app/dist/index.js plugins inspect feishu --json > /tmp/feishu-plugin.json + node -e ' + const install = JSON.parse(require("fs").readFileSync("/tmp/feishu-plugin.json", "utf8")).install; + if (install?.integrity !== process.argv[1]) { + console.error(`@openclaw/feishu integrity ${install?.integrity} does not match ${process.argv[1]}`); + process.exit(1); + } + console.log(`@openclaw/feishu integrity verified: ${install.integrity}`); + ' {{ .Values.openclaw.feishu.pluginIntegrity | quote }} + env: + - name: HOME + value: /home/node + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + volumeMounts: + - name: openclaw-home + mountPath: /home/node/.openclaw + {{- end }} {{- if .Values.openclaw.slack.enabled }} - name: init-slack-plugin image: {{ include "mosaic-stack.image" .Values.openclaw.image | quote }} @@ -578,6 +607,18 @@ spec: name: {{ .Values.openclaw.slack.existingSecret | quote }} key: {{ .Values.openclaw.slack.appTokenKey | quote }} {{- end }} + {{- if .Values.openclaw.feishu.enabled }} + - name: FEISHU_APP_ID + valueFrom: + secretKeyRef: + name: {{ .Values.openclaw.feishu.existingSecret | quote }} + key: {{ .Values.openclaw.feishu.appIdKey | quote }} + - name: FEISHU_APP_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.openclaw.feishu.existingSecret | quote }} + key: {{ .Values.openclaw.feishu.appSecretKey | quote }} + {{- end }} {{- if and .Values.modules.webSearch.enabled (eq .Values.modules.webSearch.provider "brave") }} - name: WEB_SEARCH_KEY valueFrom: diff --git a/helm/mosaic-stack/templates/validation.yaml b/helm/mosaic-stack/templates/validation.yaml index df312868..c97a7be4 100644 --- a/helm/mosaic-stack/templates/validation.yaml +++ b/helm/mosaic-stack/templates/validation.yaml @@ -24,6 +24,26 @@ {{- fail (printf "Slack edit user %q must also appear in openclaw.slack.allowedUserIds" .) -}} {{- end -}} {{- end -}} +{{- if .Values.openclaw.feishu.enabled -}} +{{- if not (and .Values.modules.execution.enabled .Values.openclaw.enabled) -}} +{{- fail "openclaw.feishu.enabled requires modules.execution.enabled and openclaw.enabled" -}} +{{- end -}} +{{- if not .Values.openclaw.feishu.existingSecret -}} +{{- fail "openclaw.feishu.existingSecret is required when Feishu is enabled" -}} +{{- end -}} +{{- if empty .Values.openclaw.feishu.allowedUserIds -}} +{{- fail "openclaw.feishu.allowedUserIds must contain at least one Feishu open_id or user_id" -}} +{{- end -}} +{{- if has "*" .Values.openclaw.feishu.allowedUserIds -}} +{{- fail "openclaw.feishu.allowedUserIds must list individual users, not \"*\"" -}} +{{- end -}} +{{- if not (has .Values.openclaw.feishu.domain (list "feishu" "lark")) -}} +{{- fail "openclaw.feishu.domain must be one of: feishu, lark" -}} +{{- end -}} +{{- if and .Values.openclaw.slack.enabled (eq .Values.openclaw.slack.session.dmScope "main") -}} +{{- fail "Feishu requires separate DM sessions per sender; set openclaw.slack.session.dmScope to per-channel-peer" -}} +{{- end -}} +{{- end -}} {{- if .Values.modules.webSearch.enabled -}} {{- if not (has .Values.modules.webSearch.provider (list "duckduckgo" "brave")) -}} {{- fail "modules.webSearch.provider must be one of: duckduckgo, brave" -}} diff --git a/helm/mosaic-stack/tests/automation-context.test.mjs b/helm/mosaic-stack/tests/automation-context.test.mjs index 5af38931..066d04c0 100644 --- a/helm/mosaic-stack/tests/automation-context.test.mjs +++ b/helm/mosaic-stack/tests/automation-context.test.mjs @@ -108,6 +108,28 @@ test("fails closed when a Slack Edit run has no identity approval broker", async clearRunAccess("run-no-broker"); }); +test("keeps Feishu runs in View even when the conversation stores Auto", () => { + const feishu = { + runId: "run-feishu", + sessionKey: "agent:default:feishu:direct:ou_1234567890", + getSessionExtension: () => ({ mode: "auto" }), + }; + setRunAccess(feishu.runId, { mode: "view", provider: "feishu", senderId: "ou_1234567890" }); + assert.equal(runAccessMode(feishu), "view"); + assert.equal(contextAllowsMutation(true, feishu), false); + assert.equal(contextSkipsApproval(feishu), false); + clearRunAccess(feishu.runId); + assert.equal(runAccessMode(feishu), "auto"); + + const guard = readFileSync( + new URL("../files/openclaw-seed/extensions/automation-guard/index.ts", import.meta.url), + "utf8", + ); + assert.match(guard, /pluginConfig\.feishuEnabled === true/); + assert.match(guard, /context\.channel !== "feishu" && context\.messageProvider !== "feishu"/); + assert.match(guard, /mode: "view",\s*provider: "feishu"/); +}); + test("projects and registers access state in every approval-owning plugin", () => { assert.deepEqual(sessionAccessExtension.project({ state: { mode: "auto" } }), { mode: "auto" }); for (const plugin of ["automation-guard", "kubernetes", "bcm", "remote-ssh"]) { diff --git a/helm/mosaic-stack/tests/chart-render.test.mjs b/helm/mosaic-stack/tests/chart-render.test.mjs index 26363ba4..5ec94038 100644 --- a/helm/mosaic-stack/tests/chart-render.test.mjs +++ b/helm/mosaic-stack/tests/chart-render.test.mjs @@ -600,6 +600,131 @@ test("rejects incomplete native Slack configuration", () => { } }); +const feishuArgs = [ + "--set", + "openclaw.feishu.enabled=true", + "--set", + "openclaw.feishu.existingSecret=mosaic-feishu", + "--set", + "openclaw.feishu.allowedUserIds[0]=ou_1234567890", +]; + +test("does not render Feishu runtime behavior when disabled", () => { + const output = render(); + assert.doesNotMatch(output, /init-feishu-plugin|FEISHU_APP_(?:ID|SECRET)|@openclaw\/feishu/); + assert.match(output, /"feishuEnabled": false/); + const config = openclawConfig(); + assert.equal(config.channels, undefined); + assert.equal(config.plugins.allow.includes("feishu"), false); +}); + +test("renders native Feishu WebSocket messaging for approved users", () => { + const output = render(...feishuArgs); + assert.match(output, /name: init-feishu-plugin/); + // An npm spec install keeps OpenClaw's trusted official plugin status; a path install would not. + assert.match(output, /node \/app\/dist\/index\.js plugins install "@openclaw\/feishu@2026\.6\.10" --force/); + assert.match(output, /plugins inspect feishu --json > \/tmp\/feishu-plugin\.json/); + assert.match(output, /' "sha512-9kTlp3[A-Za-z0-9+/]+={0,2}"/); + assert.match(output, /name: FEISHU_APP_ID\s+valueFrom:\s+secretKeyRef:\s+name: "mosaic-feishu"\s+key: "FEISHU_APP_ID"/); + assert.match(output, /name: FEISHU_APP_SECRET\s+valueFrom:\s+secretKeyRef:\s+name: "mosaic-feishu"\s+key: "FEISHU_APP_SECRET"/); + assert.doesNotMatch(output, /init-slack-plugin|SLACK_(?:BOT|APP)_TOKEN/); + const config = openclawConfig(...feishuArgs); + assert.deepEqual(config.session, { dmScope: "per-channel-peer" }); + assert.deepEqual(Object.keys(config.channels), ["feishu"]); + assert.deepEqual(config.channels.feishu, { + enabled: true, + domain: "feishu", + connectionMode: "websocket", + appId: "${FEISHU_APP_ID}", + appSecret: "${FEISHU_APP_SECRET}", + configWrites: false, + dmPolicy: "allowlist", + allowFrom: ["ou_1234567890"], + groupPolicy: "disabled", + groupAllowFrom: [], + groupSenderAllowFrom: ["ou_1234567890"], + requireMention: true, + streaming: true, + resolveSenderNames: true, + tools: { doc: false, chat: false, wiki: false, drive: false, perm: false, scopes: false, bitable: false }, + }); + // OpenClaw 2026.6.10 accepts per-channel queue modes only for built-in channels. + assert.equal(config.messages, undefined); + assert.ok(config.plugins.allow.includes("feishu")); + assert.deepEqual(config.plugins.entries.feishu, { enabled: true }); + assert.equal(config.plugins.entries["automation-guard"].config.feishuEnabled, true); + const guardSchema = JSON.parse( + readFileSync(new URL("../files/openclaw-seed/extensions/automation-guard/openclaw.plugin.json", import.meta.url)), + ).configSchema; + assert.equal(guardSchema.additionalProperties, false); + for (const key of Object.keys(config.plugins.entries["automation-guard"].config)) { + assert.ok(Object.hasOwn(guardSchema.properties, key), `automation-guard schema is missing ${key}`); + } +}); + +test("restricts Feishu group mentions to approved chats and users alongside Slack", () => { + const config = openclawConfig( + ...feishuArgs, + "--set", + "openclaw.feishu.groupChatIds[0]=oc_1234567890", + "--set", + "openclaw.feishu.domain=lark", + "--set", + "openclaw.slack.enabled=true", + "--set", + "openclaw.slack.existingSecret=mosaic-slack", + "--set", + "openclaw.slack.allowedUserIds[0]=U12345678", + ); + assert.deepEqual(Object.keys(config.channels), ["feishu", "slack"]); + assert.equal(config.channels.feishu.domain, "lark"); + assert.equal(config.channels.feishu.groupPolicy, "allowlist"); + assert.deepEqual(config.channels.feishu.groupAllowFrom, ["oc_1234567890"]); + assert.deepEqual(config.channels.feishu.groupSenderAllowFrom, ["ou_1234567890"]); + assert.equal(config.channels.feishu.requireMention, true); + assert.deepEqual(config.session, { dmScope: "per-channel-peer" }); + assert.deepEqual(config.messages, { queue: { byChannel: { slack: "followup" } } }); +}); + +test("can disable Feishu sender name lookup", () => { + const config = openclawConfig(...feishuArgs, "--set", "openclaw.feishu.resolveSenderNames=false"); + assert.equal(config.channels.feishu.resolveSenderNames, false); +}); + +test("Feishu scheduled delivery requires explicit cron enablement", () => { + assert.equal(openclawConfig().cron.enabled, false); + assert.equal(openclawConfig(...feishuArgs).cron.enabled, false); + assert.equal(openclawConfig(...feishuArgs, "--set", "openclaw.cron.enabled=true").cron.enabled, true); +}); + +test("rejects incomplete or shared Feishu configuration", () => { + for (const args of [ + ["--set", "openclaw.feishu.enabled=true"], + ["--set", "openclaw.feishu.enabled=true", "--set", "openclaw.feishu.existingSecret=mosaic-feishu"], + [...feishuArgs, "--set", "openclaw.feishu.allowedUserIds[1]=*"], + [...feishuArgs, "--set", "openclaw.feishu.domain=larksuite"], + [...feishuArgs, "--set", "modules.execution.enabled=false"], + [ + ...feishuArgs, + "--set", + "openclaw.slack.enabled=true", + "--set", + "openclaw.slack.existingSecret=mosaic-slack", + "--set", + "openclaw.slack.allowedUserIds[0]=U12345678", + "--set", + "openclaw.slack.session.dmScope=main", + ], + ]) { + const result = spawnSync( + "helm", + ["template", "mosaic", chart, "--namespace", "mosaic-test", ...args], + { encoding: "utf8" }, + ); + assert.notEqual(result.status, 0, args.join(" ")); + } +}); + const disableWebSearch = ["--set", "modules.webSearch.enabled=false"]; const braveArgs = [ "--set", diff --git a/helm/mosaic-stack/values.yaml b/helm/mosaic-stack/values.yaml index 7c905e07..1b7a2998 100644 --- a/helm/mosaic-stack/values.yaml +++ b/helm/mosaic-stack/values.yaml @@ -263,6 +263,8 @@ openclaw: # Skill instructions included in every session, without a separate file read. bootstrapSkills: [] gatewayProtocol: 4 + cron: + enabled: false slack: enabled: false maxConcurrent: 8 @@ -287,6 +289,27 @@ openclaw: mode: partial nativeTransport: true pluginVersion: 2026.6.6 + feishu: + enabled: false + pluginVersion: 2026.6.10 + # npm integrity of the @openclaw/feishu tarball for pluginVersion. The plugin is + # installed by npm spec, which OpenClaw trusts as the official package, and the + # pod does not start unless npm's recorded integrity matches this value. + pluginIntegrity: sha512-9kTlp3KynPbiQ4OBst4TW+zFE4rTOlRxwfslTWJ4/jhueHAHoVtPEwEheIEijdT9tO9fESOHMUxPMsAaOgiagw== + # API domain: feishu (Feishu) or lark (Lark international). + domain: feishu + existingSecret: "" + appIdKey: FEISHU_APP_ID + appSecretKey: FEISHU_APP_SECRET + # Feishu open_id (ou_...) or user_id values permitted to message the bot. + allowedUserIds: [] + # Group chat IDs (oc_...) where allowed users can @mention the bot. + # Group messages are disabled while this list is empty. + groupChatIds: [] + streaming: true + # Show sender display names to the agent. Requires the contact:contact.base:readonly + # scope, with senders inside the app's contact data range. + resolveSenderNames: true image: repository: ghcr.io/openclaw/openclaw tag: 2026.6.10